Compare commits
9
Commits
a116926867
..
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a9d370b52d | ||
|
|
367211ee47 | ||
|
|
9fa1964dec | ||
|
|
6f1cf41043 | ||
|
|
f048312fc2 | ||
|
|
e8c09d7b64 | ||
|
|
e5d32e6b4c | ||
|
|
29dbeb6981 | ||
|
|
7f8825cf3a |
@@ -4,6 +4,23 @@
|
|||||||
|
|
||||||
## 当前状态
|
## 当前状态
|
||||||
|
|
||||||
|
### 2026-09-01 主叫实时分析专项整改(已完成)
|
||||||
|
|
||||||
|
- 用户已授权执行专项核对文档中的全部整改,包括代码、数据库迁移、B机发布和受控真实呼叫。
|
||||||
|
- 已完成:ACK起算的应答证据、显式CLOSE生命周期、生产/采集计数核对、持久缺口与死信、独立Redis、保留维护、查询快照/稳定分页、供应商筛选、告警范围和降级暂停、页面证据字段。
|
||||||
|
- 本地验证:ESLint、TypeScript构建、Prisma schema、Web生产构建通过;Vitest 29个文件/118项通过;B机OpenSIPS候选配置检查通过。
|
||||||
|
- B机已发布提交`9fa1964dec98cbe3c53c901ba764373308054d5e`,发布目录`/opt/lisglosips/releases/caller-analytics-20260901T024406Z`。OpenSIPS、API、CDR Worker、分析Worker、独立Redis及维护timer均为active/enabled,API ready为ok。
|
||||||
|
- 真实验收:6通受控SIP得到T=6/C=4/A=2/N=2,两个成功场景完成SDP协商并经过RTPEngine,300ms短通话计A;真实API、MySQL状态/分钟桶、重放幂等、RBAC与页面桌面/移动端均通过。Redis重启恢复、毒事件死信、覆盖降级/恢复、维护与轮转检查通过。证据见`docs/CALLER_REALTIME_ANALYTICS_DELIVERY_20260901.md`及`tests/reports/caller-analytics-real-20260901.json`。
|
||||||
|
- 收口:受控测试入口/出口均已禁用,临时UI会话已清零。没有修改计费、余额扣减、路由选择或旧固定6秒CDR逻辑;工作区独立的代码质量审查文件未纳入本次提交。
|
||||||
|
- 限制:未取得约定峰值CPS、号码基数和保留期,故不宣称容量上限或统计P95。代码与交付文档已推送到`origin/main`;B机生效代码为`9fa1964`,其后的`367211e`仅增加交付证据与状态文档,无需再次重启服务。
|
||||||
|
|
||||||
|
### 2026-08-31 主叫实时分析设计核对与整改方案(仅审查文档)
|
||||||
|
|
||||||
|
- 产物:[主叫实时分析设计核对与整改方案](docs/CALLER_REALTIME_ANALYTICS_REVIEW_AND_REMEDIATION_20260831.md),初始基线 `116e316eca5b564f33ec3558dbd00201da173ed8`;已补核对并行提交 `d786165` 及当时可见的存储/测试候选改动,已出现修复与仍待验收项分别标注。
|
||||||
|
- 结论:保留V1.1业务口径;列出13组整改项(10组P1、3组P2),重点为时长证据、未知状态、乱序校正、完整性/留存、告警与真实验收。整改尚未实施,不代表线上已修复。
|
||||||
|
- 本轮验证:现有专项Vitest 2文件/16项通过;纯计算复现确认缺过程END误判失败、最终零时长校正受事件到达顺序影响等边界。本审查任务没有连接线上服务、执行迁移/部署、真实呼叫或Git提交推送;没有改动原设计和其他任务文件,其他任务的并行变化均保留。
|
||||||
|
- 后续按方案W0~W4推进;下方“测试发布验证中”为既有实施记录,本轮未重新核实其现场状态。
|
||||||
|
|
||||||
### 2026-08-31 主叫号码实时分析实施(测试发布验证中)
|
### 2026-08-31 主叫号码实时分析实施(测试发布验证中)
|
||||||
|
|
||||||
- 新增独立分析进程、MySQL事件/状态/分钟汇总/权限范围/告警表、真实API和主叫分析页面,计费Worker不变。
|
- 新增独立分析进程、MySQL事件/状态/分钟汇总/权限范围/告警表、真实API和主叫分析页面,计费Worker不变。
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ V2 的目标是把当前纯前端原型转化为可部署、可联调、可上
|
|||||||
|
|
||||||
### 1.1 2026-08-31 新增设计:主叫号码实时分析
|
### 1.1 2026-08-31 新增设计:主叫号码实时分析
|
||||||
|
|
||||||
详见 [主叫号码接通率与应答率实时分析设计方案](docs/CALLER_REALTIME_ANALYTICS_DESIGN.md)。该功能处于设计完成、实现待开始阶段。新功能“接通数”按180 Ringing/183 Session Progress或后续阶段统计;“应答数”按实际通话时长大于0统计;实时总体应答率=应答数/总呼叫数,实时已接通应答率=应答数/接通数。直接成功、缺少100、未接通/待接通/未知数据及历史回算边界以专项文档V1.1为准。既有首页历史指标与计费规则不在本次文档任务中变更。
|
详见 [主叫号码接通率与应答率实时分析设计方案](docs/CALLER_REALTIME_ANALYTICS_DESIGN.md)。V1.2整改代码、加法迁移及B机测试发布已于2026-09-01完成,真实SIP/API/MySQL/RBAC/UI及可靠性结果见[整改交付报告](docs/CALLER_REALTIME_ANALYTICS_DELIVERY_20260901.md)。新功能“接通数”按180 Ringing/183 Session Progress或后续阶段统计;“应答数”按ACK后可信正通话时长统计;实时总体应答率=应答数/总呼叫数,实时已接通应答率=应答数/接通数。既有首页历史指标、计费规则及旧固定6秒CDR逻辑未变更。当前发布不构成峰值容量或P95承诺。
|
||||||
|
|
||||||
## 2. V2 范围
|
## 2. V2 范围
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { BadRequestException, ForbiddenException, Inject, Injectable } from '@nestjs/common';
|
import { BadRequestException, ForbiddenException, Inject, Injectable } from '@nestjs/common';
|
||||||
import { ANALYTICS_VERSION, COUNT_KEYS, Prisma, analyticsRates, emptyCounts, type AnalyticsCounts } from '@lisglosips/database';
|
import { ANALYTICS_REVISION, ANALYTICS_VERSION, COUNT_KEYS, Prisma, analyticsHash, analyticsRates, emptyCounts, type AnalyticsCounts } from '@lisglosips/database';
|
||||||
import { PrismaService } from '../database/prisma.service.js';
|
import { PrismaService } from '../database/prisma.service.js';
|
||||||
import type { CurrentUser } from '../security/security.metadata.js';
|
import type { CurrentUser } from '../security/security.metadata.js';
|
||||||
|
|
||||||
@@ -68,46 +68,56 @@ export class CallerAnalyticsService {
|
|||||||
}
|
}
|
||||||
async overview(raw: Query, user: CurrentUser) {
|
async overview(raw: Query, user: CurrentUser) {
|
||||||
const q = parseAnalyticsQuery(raw); const scope = await this.scope(user, q.customerId); const source = this.source(q, scope);
|
const q = parseAnalyticsQuery(raw); const scope = await this.scope(user, q.customerId); const source = this.source(q, scope);
|
||||||
const [numbers, trends, health, alerts] = await this.db.$transaction([
|
const [numbers, trends, health, alerts, gaps] = await this.db.$transaction([
|
||||||
this.db.$queryRaw<Row[]>(Prisma.sql`SELECT customer_id AS customerId, caller, ${countSql} FROM (${source}) a GROUP BY customer_id, caller HAVING SUM(CAST(JSON_UNQUOTE(JSON_EXTRACT(counts,'$.totalCalls')) AS SIGNED))>0 LIMIT 10001`),
|
this.db.$queryRaw<Row[]>(Prisma.sql`SELECT customer_id AS customerId, caller, ${countSql} FROM (${source}) a GROUP BY customer_id, caller HAVING SUM(CAST(JSON_UNQUOTE(JSON_EXTRACT(counts,'$.totalCalls')) AS SIGNED))>0 LIMIT 10001`),
|
||||||
this.db.$queryRaw<Row[]>(Prisma.sql`SELECT FLOOR(UNIX_TIMESTAMP(started_at)/60)*60000 AS time, ${countSql} FROM (${source}) a GROUP BY time ORDER BY time`),
|
this.db.$queryRaw<Row[]>(Prisma.sql`SELECT FLOOR(UNIX_TIMESTAMP(started_at)/60)*60000 AS time, ${countSql} FROM (${source}) a GROUP BY time ORDER BY time`),
|
||||||
this.db.$queryRaw<Array<{ payload: Record<string, unknown>; updated_at: Date }>>`SELECT payload, updated_at FROM caller_analysis_health WHERE id='worker'`,
|
this.db.$queryRaw<Array<{ payload: Record<string, unknown>; updated_at: Date }>>`SELECT payload, updated_at FROM caller_analysis_health WHERE id='worker'`,
|
||||||
this.db.$queryRaw<Array<{ customer_id: string; caller: string; payload: unknown; status: string }>>(Prisma.sql`SELECT customer_id, caller, payload, status FROM caller_analysis_alerts WHERE view=${q.view} ${scope ? Prisma.sql`AND customer_id IN (${Prisma.join(scope)})` : Prisma.empty} AND status='ACTIVE'`)
|
this.db.$queryRaw<Array<{ customer_id: string; caller: string; payload: unknown; status: string }>>(Prisma.sql`SELECT customer_id, caller, payload, status FROM caller_analysis_alerts WHERE view=${q.view} ${scope ? Prisma.sql`AND customer_id IN (${Prisma.join(scope)})` : Prisma.empty} AND status='ACTIVE'`)
|
||||||
|
,this.db.$queryRaw<Array<{ id:string; kind:string; started_at:Date; ended_at:Date|null }>>`SELECT id,kind,started_at,ended_at FROM caller_analysis_gaps WHERE status='OPEN' AND started_at<${q.to} AND (ended_at IS NULL OR ended_at>=${q.from}) ORDER BY started_at LIMIT 100`
|
||||||
]);
|
]);
|
||||||
if (numbers.length > 10000) throw new BadRequestException('号码超过10000个,请缩短时间或筛选客户');
|
if (numbers.length > 10000) throw new BadRequestException('号码超过10000个,请缩短时间或筛选客户');
|
||||||
const annotated = numbers.map(r => ({ ...normalize(r), alert: alerts.find(a => a.customer_id === r.customerId && a.caller === r.caller)?.payload ?? null }));
|
const h = health[0]; const captureSince=h?.payload.captureSince?new Date(String(h.payload.captureSince)):null;
|
||||||
|
const coverageStatus=!h||Date.now()-+h.updated_at>10000||h.payload.degraded||h.payload.coverageStatus!=='CONTINUOUS'||gaps.length?'DEGRADED':captureSince&&q.from<captureSince?'PARTIAL':'CONTINUOUS';
|
||||||
|
const withCoverage=(r:Row)=>{const value=normalize(r);if(coverageStatus!=='CONTINUOUS'){value.connectionRate=null;value.overallAnswerRate=null;value.connectedAnswerRate=null;}return value;};
|
||||||
|
const alertScopeMatches = coverageStatus==='CONTINUOUS' && !q.customerGatewayId && !q.vendorId && !q.vendorGatewayId && !q.city && !q.carrier && +q.to-Date.now()>-90000 && +q.to-Date.now()<5000 && +q.to-+q.from===900000;
|
||||||
|
const annotated = numbers.map(r => { const matching=alerts.filter(a=>a.customer_id===r.customerId&&a.caller===r.caller); return { ...withCoverage(r), alert: alertScopeMatches&&matching.length ? { reasons:matching.map(a=>(a.payload as {reason?:string}).reason).filter(Boolean), scope:'最近15分钟号码全量范围' } : null }; });
|
||||||
const fits = (v: number | null, min: number, max: number) => v === null ? min === 0 && max === 100 : v >= min && v <= max;
|
const fits = (v: number | null, min: number, max: number) => v === null ? min === 0 && max === 100 : v >= min && v <= max;
|
||||||
const filtered = annotated.filter(r => r.totalCalls >= q.minSamples && (!q.onlyAlerts || r.alert) && fits(r.connectionRate, q.connectionMin, q.connectionMax) && fits(r.overallAnswerRate, q.overallMin, q.overallMax) && fits(r.connectedAnswerRate, q.connectedMin, q.connectedMax));
|
const filtered = annotated.filter(r => r.totalCalls >= q.minSamples && (!q.onlyAlerts || r.alert) && fits(r.connectionRate, q.connectionMin, q.connectionMax) && fits(r.overallAnswerRate, q.overallMin, q.overallMax) && fits(r.connectedAnswerRate, q.connectedMin, q.connectedMax));
|
||||||
filtered.sort((a, b) => {
|
filtered.sort((a, b) => {
|
||||||
const av = a[q.sort as keyof typeof a]; const bv = b[q.sort as keyof typeof b];
|
const av = a[q.sort as keyof typeof a]; const bv = b[q.sort as keyof typeof b];
|
||||||
if (av === null) return bv === null ? 0 : 1; if (bv === null) return -1;
|
if (av === null) return bv === null ? 0 : 1; if (bv === null) return -1;
|
||||||
const cmp = typeof av === 'string' && typeof bv === 'string' ? av.localeCompare(bv) : Number(av) - Number(bv);
|
const cmp = typeof av === 'string' && typeof bv === 'string' ? av.localeCompare(bv) : Number(av) - Number(bv);
|
||||||
return (q.order === 'asc' ? cmp : -cmp) || String(a.caller).localeCompare(String(b.caller));
|
return (q.order === 'asc' ? cmp : -cmp) || String(a.caller).localeCompare(String(b.caller)) || String(a.customerId).localeCompare(String(b.customerId));
|
||||||
});
|
});
|
||||||
const sum = annotated.reduce((a, r) => { for (const k of COUNT_KEYS) a[k] += r[k]; return a; }, emptyCounts());
|
const sum = annotated.reduce((a, r) => { for (const k of COUNT_KEYS) a[k] += r[k]; return a; }, emptyCounts());
|
||||||
const h = health[0]; const quality = !h || Date.now() - +h.updated_at > 10000 || h.payload.degraded ? 'DEGRADED' : 'LIVE';
|
const quality=coverageStatus==='CONTINUOUS'?'LIVE':'DEGRADED'; const asOf=new Date(); const snapshotId=analyticsHash([ANALYTICS_REVISION,q.view,q.from.toISOString(),q.to.toISOString(),asOf.toISOString()]);
|
||||||
return { metricVersion: ANALYTICS_VERSION, view: q.view, from: q.from, to: q.to, asOf: new Date(), qualityStatus: quality,
|
const summaryRates=analyticsRates(sum); if(coverageStatus!=='CONTINUOUS'){summaryRates.connectionRate=null;summaryRates.overallAnswerRate=null;summaryRates.connectedAnswerRate=null;}
|
||||||
|
return { metricVersion: ANALYTICS_VERSION, analysisRevision:ANALYTICS_REVISION, snapshotId, view: q.view, from: q.from, to: q.to, asOf, qualityStatus: quality, coverageStatus,
|
||||||
dataThrough: h?.payload.dataThrough ?? null, lagMs: h?.payload.lagMs ?? null, durationPrecision: 'milliseconds',
|
dataThrough: h?.payload.dataThrough ?? null, lagMs: h?.payload.lagMs ?? null, durationPrecision: 'milliseconds',
|
||||||
health: h?.payload ?? null, summary: analyticsRates(sum), summaryScope: '时间及业务筛选;样本/比例/异常筛选仅影响号码列表',
|
health: h?.payload ?? null, gaps, summary: summaryRates, summaryScope: '时间及业务筛选;样本/比例/异常筛选仅影响号码列表',
|
||||||
numbers: filtered.slice(q.skip, q.skip + q.take), total: filtered.length, skip: q.skip, take: q.take,
|
numbers: filtered.slice(q.skip, q.skip + q.take), total: filtered.length, skip: q.skip, take: q.take,
|
||||||
trends: trends.map(r => ({ ...normalize(r), time: Number(r.time) })), historyNotice: '仅统计新采集链路的数据;旧固定6秒话单不参与应答统计或校准。' };
|
trends: trends.map(r => ({ ...withCoverage(r), time: Number(r.time) })), baseFilters:{view:q.view,from:q.from,to:q.to,customerId:q.customerId,customerGatewayId:q.customerGatewayId,vendorId:q.vendorId,vendorGatewayId:q.vendorGatewayId,caller:q.caller,city:q.city,carrier:q.carrier}, numberFilters:{minSamples:q.minSamples,onlyAlerts:q.onlyAlerts,connectionMin:q.connectionMin,connectionMax:q.connectionMax,overallMin:q.overallMin,overallMax:q.overallMax,connectedMin:q.connectedMin,connectedMax:q.connectedMax},
|
||||||
|
metricAvailability:{connectionRate:coverageStatus==='CONTINUOUS'&&!sum.unknownCalls,answerRates:coverageStatus==='CONTINUOUS'&&!sum.durationUnknownCalls}, historyNotice: coverageStatus==='CONTINUOUS'?'当前窗口在连续采集覆盖内;旧固定6秒话单不参与校准。':'当前窗口无连续完整覆盖,计数仅为已观测值,完整比例不可用。' };
|
||||||
}
|
}
|
||||||
async detail(raw: Query, user: CurrentUser) {
|
async detail(raw: Query, user: CurrentUser) {
|
||||||
const q = parseAnalyticsQuery(raw); if (!q.caller || !q.customerId) throw new BadRequestException('请选择客户和主叫号码');
|
const q = parseAnalyticsQuery(raw); if (!q.caller || !q.customerId) throw new BadRequestException('请选择客户和主叫号码');
|
||||||
const scope = await this.scope(user, q.customerId); const where = this.where(q, scope);
|
const scope = await this.scope(user, q.customerId); const where = this.where(q, scope);
|
||||||
const rows = await this.db.$queryRaw<Array<Record<string, unknown>>>(Prisma.sql`SELECT id, call_key AS callKey, started_at AS startedAt, caller, callee, vendor_gateway_id AS vendorGatewayId, counts, payload FROM caller_analysis_states WHERE ${where} AND started_at>=${q.from} AND started_at<${q.to} ORDER BY started_at DESC, id DESC LIMIT ${q.take} OFFSET ${q.skip}`);
|
|
||||||
const source = this.source(q, scope);
|
const source = this.source(q, scope);
|
||||||
const breakdown = await this.db.$queryRaw<Row[]>(Prisma.sql`SELECT vendor_gateway_id AS vendorGatewayId, city, carrier, ${countSql} FROM (${source}) a GROUP BY vendor_gateway_id, city, carrier HAVING SUM(CAST(JSON_UNQUOTE(JSON_EXTRACT(counts,'$.totalCalls')) AS SIGNED))>0 LIMIT 500`);
|
const [rows,breakdownRows]=await this.db.$transaction([
|
||||||
return { rows, breakdown: breakdown.map(normalize), skip: q.skip, take: q.take };
|
this.db.$queryRaw<Array<Record<string, unknown>>>(Prisma.sql`SELECT id, call_key AS callKey, started_at AS startedAt, caller, callee, vendor_gateway_id AS vendorGatewayId, counts, payload FROM caller_analysis_states WHERE ${where} AND started_at>=${q.from} AND started_at<${q.to} ORDER BY started_at DESC, id DESC LIMIT ${q.take} OFFSET ${q.skip}`),
|
||||||
|
this.db.$queryRaw<Row[]>(Prisma.sql`SELECT vendor_gateway_id AS vendorGatewayId, city, carrier, ${countSql} FROM (${source}) a GROUP BY vendor_gateway_id, city, carrier HAVING SUM(CAST(JSON_UNQUOTE(JSON_EXTRACT(counts,'$.totalCalls')) AS SIGNED))>0 ORDER BY vendor_gateway_id,city,carrier LIMIT 501`)
|
||||||
|
]);
|
||||||
|
return { rows, breakdown:breakdownRows.slice(0,500).map(normalize), breakdownTruncated:breakdownRows.length>500, skip:q.skip, take:q.take };
|
||||||
}
|
}
|
||||||
async options(user: CurrentUser) {
|
async options(user: CurrentUser) {
|
||||||
const scope = await this.scope(user, '');
|
const scope = await this.scope(user, '');
|
||||||
const [customers, customerGateways, vendorGateways] = await Promise.all([
|
const [customers, customerGateways, vendors, vendorGateways] = await Promise.all([
|
||||||
this.db.customer.findMany({ where: { ...(scope ? { id: { in: scope } } : {}), deletedAt: null }, select: { id: true, name: true }, take: 2000 }),
|
this.db.customer.findMany({ where: { ...(scope ? { id: { in: scope } } : {}), deletedAt: null }, select: { id: true, name: true }, take: 2000 }),
|
||||||
this.db.customerGateway.findMany({ where: { ...(scope ? { customerId: { in: scope } } : {}), deletedAt: null }, select: { id: true, name: true, customerId: true }, take: 2000 }),
|
this.db.customerGateway.findMany({ where: { ...(scope ? { customerId: { in: scope } } : {}), deletedAt: null }, select: { id: true, name: true, customerId: true }, take: 2000 }),
|
||||||
|
scope ? this.db.$queryRaw<Array<{id:string;name:string}>>(Prisma.sql`SELECT DISTINCT vendor_id AS id,vendor_id AS name FROM caller_analysis_states WHERE customer_id IN (${Prisma.join(scope)}) AND vendor_id<>'' LIMIT 2000`) : this.db.vendor.findMany({where:{deletedAt:null},select:{id:true,name:true},take:2000}),
|
||||||
// Limited users only receive gateway IDs they actually have traffic on, not the global vendor configuration.
|
// Limited users only receive gateway IDs they actually have traffic on, not the global vendor configuration.
|
||||||
scope ? this.db.$queryRaw<Array<{ id: string; name: string }>>(Prisma.sql`SELECT DISTINCT vendor_gateway_id AS id, vendor_gateway_id AS name FROM caller_analysis_states WHERE customer_id IN (${Prisma.join(scope)}) AND vendor_gateway_id<>'' LIMIT 2000`) : this.db.vendorGateway.findMany({ where: { deletedAt: null }, select: { id: true, name: true }, take: 2000 })
|
scope ? this.db.$queryRaw<Array<{ id: string; name: string }>>(Prisma.sql`SELECT DISTINCT vendor_gateway_id AS id, vendor_gateway_id AS name FROM caller_analysis_states WHERE customer_id IN (${Prisma.join(scope)}) AND vendor_gateway_id<>'' LIMIT 2000`) : this.db.vendorGateway.findMany({ where: { deletedAt: null }, select: { id: true, name: true }, take: 2000 })
|
||||||
]);
|
]);
|
||||||
return { customers, customerGateways, vendorGateways };
|
return { customers, customerGateways, vendors, vendorGateways };
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -11,11 +11,11 @@ const rateColumns = [
|
|||||||
{ key: 'connectedAnswerRate', label: '实时已接通应答率', render: r => percent(r.connectedAnswerRate) }
|
{ key: 'connectedAnswerRate', label: '实时已接通应答率', render: r => percent(r.connectedAnswerRate) }
|
||||||
];
|
];
|
||||||
const countColumns = [{ key: 'totalCalls', label: '总呼叫数' }, { key: 'connectedCalls', label: '接通数' }, { key: 'notConnectedCalls', label: '未接通数' }, { key: 'answeredCalls', label: '应答数' }];
|
const countColumns = [{ key: 'totalCalls', label: '总呼叫数' }, { key: 'connectedCalls', label: '接通数' }, { key: 'notConnectedCalls', label: '未接通数' }, { key: 'answeredCalls', label: '应答数' }];
|
||||||
const initial = { view: 'landing', minutes: '15', caller: '', customerId: '', customerGatewayId: '', vendorGatewayId: '', city: '', carrier: '', minSamples: '0', sort: 'totalCalls', order: 'desc', skip: 0, take: 25, onlyAlerts: false };
|
const initial = { view: 'landing', minutes: '15', caller: '', customerId: '', customerGatewayId: '', vendorId: '', vendorGatewayId: '', city: '', carrier: '', minSamples: '0', sort: 'totalCalls', order: 'desc', skip: 0, take: 25, onlyAlerts: false };
|
||||||
export function CallerAnalyticsPage() {
|
export function CallerAnalyticsPage() {
|
||||||
const [draft, setDraft] = useState(initial); const [filters, setFilters] = useState(initial);
|
const [draft, setDraft] = useState(initial); const [filters, setFilters] = useState(initial);
|
||||||
const [data, setData] = useState(null); const [error, setError] = useState(''); const [loading, setLoading] = useState(false);
|
const [data, setData] = useState(null); const [error, setError] = useState(''); const [loading, setLoading] = useState(false);
|
||||||
const [options, setOptions] = useState({ customers: [], customerGateways: [], vendorGateways: [] });
|
const [options, setOptions] = useState({ customers: [], customerGateways: [], vendors: [], vendorGateways: [] });
|
||||||
const [auto, setAuto] = useState(true); const [tick, setTick] = useState(0);
|
const [auto, setAuto] = useState(true); const [tick, setTick] = useState(0);
|
||||||
const [target, setTarget] = useState(null); const [detail, setDetail] = useState(null); const [detailError, setDetailError] = useState('');
|
const [target, setTarget] = useState(null); const [detail, setDetail] = useState(null); const [detailError, setDetailError] = useState('');
|
||||||
const [detailSkip, setDetailSkip] = useState(0); const sequence = useRef(0);
|
const [detailSkip, setDetailSkip] = useState(0); const sequence = useRef(0);
|
||||||
@@ -44,7 +44,7 @@ export function CallerAnalyticsPage() {
|
|||||||
.then(r => { setDetail(r); setDetailError(''); }).catch(e => { if (!c.signal.aborted) setDetailError(explainApiError(e)); });
|
.then(r => { setDetail(r); setDetailError(''); }).catch(e => { if (!c.signal.aborted) setDetailError(explainApiError(e)); });
|
||||||
return () => c.abort();
|
return () => c.abort();
|
||||||
}, [target, data, filters, detailSkip]);
|
}, [target, data, filters, detailSkip]);
|
||||||
const change = (key, value) => setDraft(d => ({ ...d, [key]: value, ...(key === 'view' && value === 'original' ? { vendorGatewayId: '' } : {}), ...(key === 'customerId' ? {customerGatewayId:''} : {}) }));
|
const change = (key, value) => setDraft(d => ({ ...d, [key]: value, ...(key === 'view' && value === 'original' ? { vendorId:'', vendorGatewayId: '' } : {}), ...(key === 'customerId' ? {customerGatewayId:''} : {}) }));
|
||||||
const names = Object.fromEntries(options.customers.map(c => [c.id, c.name]));
|
const names = Object.fromEntries(options.customers.map(c => [c.id, c.name]));
|
||||||
const gatewayNames = Object.fromEntries(options.vendorGateways.map(c => [c.id, c.name]));
|
const gatewayNames = Object.fromEntries(options.vendorGateways.map(c => [c.id, c.name]));
|
||||||
const select = (label, key, choices) => <Field label={label}><Select value={draft[key]} onChange={e => change(key, e.target.value)}><option value="">全部</option>{choices.map(c => <option key={c.id} value={c.id}>{c.name}</option>)}</Select></Field>;
|
const select = (label, key, choices) => <Field label={label}><Select value={draft[key]} onChange={e => change(key, e.target.value)}><option value="">全部</option>{choices.map(c => <option key={c.id} value={c.id}>{c.name}</option>)}</Select></Field>;
|
||||||
@@ -63,7 +63,7 @@ export function CallerAnalyticsPage() {
|
|||||||
<Field label="主叫号码"><Input value={draft.caller} onChange={e => change('caller', e.target.value)} placeholder="精确匹配号码" /></Field>
|
<Field label="主叫号码"><Input value={draft.caller} onChange={e => change('caller', e.target.value)} placeholder="精确匹配号码" /></Field>
|
||||||
{select('客户', 'customerId', options.customers)}
|
{select('客户', 'customerId', options.customers)}
|
||||||
{select('客户网关', 'customerGatewayId', options.customerGateways.filter(g => !draft.customerId || g.customerId === draft.customerId))}
|
{select('客户网关', 'customerGatewayId', options.customerGateways.filter(g => !draft.customerId || g.customerId === draft.customerId))}
|
||||||
{draft.view === 'landing' ? select('落地网关', 'vendorGatewayId', options.vendorGateways) : null}
|
{draft.view === 'landing' ? <>{select('供应商', 'vendorId', options.vendors)}{select('落地网关', 'vendorGatewayId', options.vendorGateways)}</> : null}
|
||||||
{select('被叫运营商', 'carrier', ['MOBILE','UNICOM','TELECOM','BROADCAST','MVNO','UNKNOWN'].map((id,i) => ({id,name:['移动','联通','电信','广电','虚拟运营商','未知'][i]})))}
|
{select('被叫运营商', 'carrier', ['MOBILE','UNICOM','TELECOM','BROADCAST','MVNO','UNKNOWN'].map((id,i) => ({id,name:['移动','联通','电信','广电','虚拟运营商','未知'][i]})))}
|
||||||
<Field label="被叫城市代码"><Input value={draft.city} onChange={e => change('city', e.target.value)} /></Field>
|
<Field label="被叫城市代码"><Input value={draft.city} onChange={e => change('city', e.target.value)} /></Field>
|
||||||
<Field label="最小呼叫数"><Input type="number" min="0" value={draft.minSamples} onChange={e => change('minSamples', e.target.value)} /></Field>
|
<Field label="最小呼叫数"><Input type="number" min="0" value={draft.minSamples} onChange={e => change('minSamples', e.target.value)} /></Field>
|
||||||
@@ -75,8 +75,8 @@ export function CallerAnalyticsPage() {
|
|||||||
<Button type="submit">查询</Button>
|
<Button type="submit">查询</Button>
|
||||||
</form></Panel>
|
</form></Panel>
|
||||||
<section className="metric-grid cra-metrics">{[...countColumns, ...rateColumns].map(c => <div key={c.key} className="metric-card"><span>{c.label}</span><strong>{metrics ? c.render ? c.render(metrics) : metrics[c.key] : '—'}</strong></div>)}</section>
|
<section className="metric-grid cra-metrics">{[...countColumns, ...rateColumns].map(c => <div key={c.key} className="metric-card"><span>{c.label}</span><strong>{metrics ? c.render ? c.render(metrics) : metrics[c.key] : '—'}</strong></div>)}</section>
|
||||||
<p className="muted-text" role="status">{data ? `${formatDateTime(data.from)} — ${formatDateTime(data.to)} · ${data.qualityStatus === 'LIVE' ? '采集运行中' : '数据滞后或不完整'} · 待接通 ${metrics.pendingCalls} · 已结束未接通 ${metrics.failedCalls} · 未知 ${metrics.unknownCalls} · 更新于 ${formatDateTime(data.asOf)}` : '正在读取真实统计数据…'}</p>
|
<p className="muted-text" role="status">{data ? `${formatDateTime(data.from)} — ${formatDateTime(data.to)} · ${data.coverageStatus === 'CONTINUOUS' ? '连续采集覆盖' : '数据覆盖不完整'} · 待接通 ${metrics.pendingCalls} · 已结束未接通 ${metrics.failedCalls} · 接通未知 ${metrics.unknownCalls} · 时长未知 ${metrics.durationUnknownCalls} · 暂定应答 ${metrics.provisionalAnsweredCalls} · 更新于 ${formatDateTime(data.asOf)}` : '正在读取真实统计数据…'}</p>
|
||||||
{data ? <Alert title="口径与数据范围" tone={data.qualityStatus === 'LIVE' ? 'info' : 'warning'}>{data.historyNotice} {data.summaryScope}。183 不代表实际振铃;未接通数含待接通,进行中指标为暂定值。采集运行状态不代表窗口数据完整性已核验,端到端延迟尚未测定。</Alert> : null}
|
{data ? <Alert title="口径与数据范围" tone={data.qualityStatus === 'LIVE' ? 'info' : 'warning'}>{data.historyNotice} {data.summaryScope}。183 不代表实际振铃;未接通数含待接通。口径 {data.metricVersion},算法 {data.analysisRevision},采集截至 {data.dataThrough ? formatDateTime(data.dataThrough) : '未知'},延迟 {data.lagMs == null ? '未知' : `${data.lagMs}ms`}。</Alert> : null}
|
||||||
<Panel title="分钟趋势" aside={<span className="muted-text">灰:呼叫 / 蓝:接通 / 绿:应答</span>}>
|
<Panel title="分钟趋势" aside={<span className="muted-text">灰:呼叫 / 蓝:接通 / 绿:应答</span>}>
|
||||||
<div className="cra-chart" aria-label="分钟呼叫接通应答趋势">{timeline.map((r,i) => <div key={r.time ?? i} className="cra-chart-column" title={`${formatDateTime(Number(r.time))} 呼叫${r.totalCalls} 接通${r.connectedCalls} 应答${r.answeredCalls}`}><div className="cra-bars"><span style={{height:`${r.totalCalls/max*100}%`}} /><span style={{height:`${r.connectedCalls/max*100}%`}} /><span style={{height:`${r.answeredCalls/max*100}%`}} /></div><small>{new Date(Number(r.time)).toLocaleTimeString('zh-CN',{hour:'2-digit',minute:'2-digit'})}</small></div>)}</div>
|
<div className="cra-chart" aria-label="分钟呼叫接通应答趋势">{timeline.map((r,i) => <div key={r.time ?? i} className="cra-chart-column" title={`${formatDateTime(Number(r.time))} 呼叫${r.totalCalls} 接通${r.connectedCalls} 应答${r.answeredCalls}`}><div className="cra-bars"><span style={{height:`${r.totalCalls/max*100}%`}} /><span style={{height:`${r.connectedCalls/max*100}%`}} /><span style={{height:`${r.answeredCalls/max*100}%`}} /></div><small>{new Date(Number(r.time)).toLocaleTimeString('zh-CN',{hour:'2-digit',minute:'2-digit'})}</small></div>)}</div>
|
||||||
{!timeline.length ? <p className="muted-text">当前窗口暂无已采集呼叫</p> : <details><summary>查看趋势数值与三个率</summary><SimpleTable rows={timeline.map((r,i) => ({ ...r, id:i, minute:formatDateTime(Number(r.time)) }))} columns={[{key:'minute',label:'分钟'},...countColumns,...rateColumns]} /></details>}
|
{!timeline.length ? <p className="muted-text">当前窗口暂无已采集呼叫</p> : <details><summary>查看趋势数值与三个率</summary><SimpleTable rows={timeline.map((r,i) => ({ ...r, id:i, minute:formatDateTime(Number(r.time)) }))} columns={[{key:'minute',label:'分钟'},...countColumns,...rateColumns]} /></details>}
|
||||||
@@ -84,7 +84,7 @@ export function CallerAnalyticsPage() {
|
|||||||
<Panel title="主叫号码列表" aside={<Badge>{data?.total ?? 0} 个号码</Badge>} className="wide-panel">
|
<Panel title="主叫号码列表" aside={<Badge>{data?.total ?? 0} 个号码</Badge>} className="wide-panel">
|
||||||
<SimpleTable rows={(data?.numbers ?? []).map(r => ({ ...r, id:`${r.customerId}:${r.caller}`, customerName:names[r.customerId] || r.customerId }))} loading={!data && loading} onRowClick={openDetail} columns={[
|
<SimpleTable rows={(data?.numbers ?? []).map(r => ({ ...r, id:`${r.customerId}:${r.caller}`, customerName:names[r.customerId] || r.customerId }))} loading={!data && loading} onRowClick={openDetail} columns={[
|
||||||
{key:'caller',label:'主叫号码',render:r => <Button size="sm" variant="ghost" onClick={() => openDetail(r)}>{r.caller}</Button>}, {key:'customerName',label:'客户'}, ...countColumns,...rateColumns,
|
{key:'caller',label:'主叫号码',render:r => <Button size="sm" variant="ghost" onClick={() => openDetail(r)}>{r.caller}</Button>}, {key:'customerName',label:'客户'}, ...countColumns,...rateColumns,
|
||||||
{key:'pendingCalls',label:'待接通'}, {key:'unknownCalls',label:'未知'}, {key:'alert',label:'状态',render:r => <Badge tone={r.alert ? 'danger' : 'neutral'}>{r.alert ? r.alert.reasons?.join('、') || '告警' : r.unknownCalls ? '证据不足' : '观察'}</Badge>}
|
{key:'pendingCalls',label:'待接通'}, {key:'unknownCalls',label:'接通未知'}, {key:'durationUnknownCalls',label:'时长未知'}, {key:'alert',label:'状态',render:r => <Badge tone={r.alert ? 'danger' : 'neutral'} title={r.alert?.scope}>{r.alert ? r.alert.reasons?.join('、') || '告警' : r.unknownCalls || r.durationUnknownCalls ? '证据不足' : '观察'}</Badge>}
|
||||||
]} />
|
]} />
|
||||||
<Pagination total={data?.total ?? 0} take={filters.take} skip={filters.skip} count={data?.numbers.length ?? 0} loading={loading} onPageChange={skip => setFilters(f => ({...f,skip}))} onPageSizeChange={take => setFilters(f => ({...f,take,skip:0}))} />
|
<Pagination total={data?.total ?? 0} take={filters.take} skip={filters.skip} count={data?.numbers.length ?? 0} loading={loading} onPageChange={skip => setFilters(f => ({...f,skip}))} onPageSizeChange={take => setFilters(f => ({...f,take,skip:0}))} />
|
||||||
</Panel>
|
</Panel>
|
||||||
@@ -95,9 +95,9 @@ export function CallerAnalyticsPage() {
|
|||||||
<Panel title="关联呼叫与信令证据"><SimpleTable rows={(detail?.rows ?? []).map(r => {
|
<Panel title="关联呼叫与信令证据"><SimpleTable rows={(detail?.rows ?? []).map(r => {
|
||||||
const p=r.payload; const legs=p.legs ? Object.values(p.legs) : [p];
|
const p=r.payload; const legs=p.legs ? Object.values(p.legs) : [p];
|
||||||
return {...r, callId:p.start?.callId || p.event?.callId, original:p.start?.caller || p.event?.caller,
|
return {...r, callId:p.start?.callId || p.event?.callId, original:p.start?.caller || p.event?.caller,
|
||||||
phase:legs.map(l => l.first180At ? '180 Ringing' : l.first183At ? '183 Session Progress' : l.acceptedAt ? '直接成功' : '未达接通').join(' / '),
|
phase:legs.map(l => l.first180At ? '180 Ringing' : l.first183At ? '183 Session Progress' : l.acceptedAt ? '直接成功' : l.talkMs > 0 ? '可信正时长补计' : '未达接通').join(' / '),
|
||||||
final:legs.map(l => l.finalCode || '进行中').join(' / '), duration:`${(r.counts.talkMs/1000).toFixed(3)} 秒${r.counts.activeCalls ? '(暂定)' : ''}`, time:formatDateTime(r.startedAt)};
|
final:legs.map(l => l.finalCode ? `初始 INVITE ${l.finalCode}` : '初始 INVITE 进行中').join(' / '), termination:legs.map(l => l.terminationReason || '未结束').join(' / '), duration:r.counts.durationUnknownCalls ? '证据不足' : `${(r.counts.talkMs/1000).toFixed(3)} 秒${r.counts.provisionalAnsweredCalls ? '(暂定)' : ''}`, time:formatDateTime(r.startedAt)};
|
||||||
})} columns={[{key:'callId',label:'Call-ID'},{key:'original',label:'原始主叫'},{key:'callee',label:'被叫'},{key:'phase',label:'接通证据'},{key:'final',label:'最终结果'},{key:'duration',label:'通话时长'},{key:'time',label:'发起时间'}]} /></Panel>
|
})} columns={[{key:'callId',label:'Call-ID'},{key:'original',label:'原始主叫'},{key:'callee',label:'被叫'},{key:'phase',label:'接通证据'},{key:'final',label:'初始 INVITE 结果'},{key:'termination',label:'结束原因'},{key:'duration',label:'通话时长'},{key:'time',label:'发起时间'}]} /></Panel>
|
||||||
<div className="table-actions"><Button variant="outline" disabled={!detailSkip} onClick={() => setDetailSkip(v => Math.max(0,v-25))}>上一页</Button><Button variant="outline" disabled={(detail?.rows.length ?? 0)<25} onClick={() => setDetailSkip(v => v+25)}>下一页</Button></div>
|
<div className="table-actions"><Button variant="outline" disabled={!detailSkip} onClick={() => setDetailSkip(v => Math.max(0,v-25))}>上一页</Button><Button variant="outline" disabled={(detail?.rows.length ?? 0)<25} onClick={() => setDetailSkip(v => v+25)}>下一页</Button></div>
|
||||||
<p className="muted-text">按 Call-ID 对应原始话单或信令;旧固定时长话单仅作追溯,不参与本统计校准。</p>
|
<p className="muted-text">按 Call-ID 对应原始话单或信令;旧固定时长话单仅作追溯,不参与本统计校准。</p>
|
||||||
</Drawer> : null}
|
</Drawer> : null}
|
||||||
|
|||||||
@@ -1,36 +1,20 @@
|
|||||||
import { PrismaClient, Prisma, analyticsHash, analyticsRates, COUNT_KEYS, type AnalyticsCounts } from '@lisglosips/database';
|
import { COUNT_KEYS, Prisma, PrismaClient, analyticsHash, analyticsRates, type AnalyticsCounts } from '@lisglosips/database';
|
||||||
|
type Row={customer_id:string;caller:string;view:string}&AnalyticsCounts;
|
||||||
export async function evaluateCallerAlerts(db: PrismaClient) {
|
type Prior={status:string;payload:{streak?:number;recovered?:number;since?:string;evaluationKey?:string}};
|
||||||
const end = new Date(Math.floor(Date.now() / 60000) * 60000); const start = new Date(+end - 15 * 60000);
|
const sums=()=>Prisma.join(COUNT_KEYS.map(k=>Prisma.sql`SUM(COALESCE(CAST(JSON_UNQUOTE(JSON_EXTRACT(counts,${`$.${k}`})) AS SIGNED),0)) AS ${Prisma.raw(k)}`));
|
||||||
const aggregates = Prisma.join(COUNT_KEYS.map(k => Prisma.sql`SUM(CAST(JSON_UNQUOTE(JSON_EXTRACT(counts,${`$.${k}`})) AS SIGNED)) AS ${Prisma.raw(k)}`));
|
const numeric=(row:Partial<Row>):AnalyticsCounts=>Object.fromEntries(COUNT_KEYS.map(k=>[k,Number(row[k]??0)]))as unknown as AnalyticsCounts;
|
||||||
const rows = await db.$queryRaw<Array<AnalyticsCounts & { customer_id: string; caller: string; view: string }>>(Prisma.sql`SELECT customer_id, caller, view, ${aggregates} FROM caller_analysis_minute_buckets WHERE started_at>=${start} AND started_at<${end} GROUP BY customer_id, caller, view HAVING SUM(CAST(JSON_UNQUOTE(JSON_EXTRACT(counts,'$.totalCalls')) AS SIGNED))>=50`);
|
export async function suspendCallerAlerts(db:PrismaClient,reason:string){await db.$executeRaw`UPDATE caller_analysis_alerts SET status='SUSPENDED',payload=JSON_SET(payload,'$.suspendedReason',${reason},'$.suspendedAt',${new Date().toISOString()},'$.streak',0,'$.recovered',0),updated_at=NOW(3) WHERE status IN ('ACTIVE','OBSERVING')`;}
|
||||||
for (const row of rows) {
|
export async function evaluateCallerAlerts(db:PrismaClient){
|
||||||
const counts = Object.fromEntries(COUNT_KEYS.map(k => [k, Number(row[k])])) as unknown as AnalyticsCounts;
|
const end=new Date(Math.floor(Date.now()/60000)*60000),start=new Date(+end-15*60000),evaluationKey=end.toISOString(),aggregates=sums();
|
||||||
const rates = analyticsRates(counts);
|
const rows=await db.$queryRaw<Row[]>(Prisma.sql`SELECT customer_id,caller,view,${aggregates} FROM caller_analysis_minute_buckets WHERE started_at>=${start} AND started_at<${end} GROUP BY customer_id,caller,view HAVING SUM(COALESCE(CAST(JSON_UNQUOTE(JSON_EXTRACT(counts,'$.totalCalls')) AS SIGNED),0))>=50`);
|
||||||
if (rates.unknownCalls || rates.activeCalls / rates.totalCalls > 0.2) continue;
|
for(const row of rows){const counts=numeric(row),rates=analyticsRates(counts);if(rates.unknownCalls||rates.durationUnknownCalls||rates.activeCalls/rates.totalCalls>0.2)continue;const reasons=new Map<string,string>();
|
||||||
const reasons = [
|
if(rates.connectionRate!==null&&rates.connectionRate<Number(process.env.ANALYTICS_LOW_CONNECTION_RATE??20))reasons.set('LOW_CONNECTION','低接通率');
|
||||||
rates.connectionRate !== null && rates.connectionRate < Number(process.env.ANALYTICS_LOW_CONNECTION_RATE ?? 20) ? '低接通率' : '',
|
if(rates.overallAnswerRate!==null&&rates.overallAnswerRate<Number(process.env.ANALYTICS_LOW_OVERALL_RATE??10))reasons.set('LOW_OVERALL','低总体应答率');
|
||||||
rates.overallAnswerRate !== null && rates.overallAnswerRate < Number(process.env.ANALYTICS_LOW_OVERALL_RATE ?? 10) ? '低总体应答率' : '',
|
if(rates.connectedCalls>=30&&rates.connectedAnswerRate!==null&&rates.connectedAnswerRate<Number(process.env.ANALYTICS_LOW_CONNECTED_RATE??20))reasons.set('LOW_CONNECTED','低已接通应答率');
|
||||||
rates.connectedCalls >= 30 && rates.connectedAnswerRate !== null && rates.connectedAnswerRate < Number(process.env.ANALYTICS_LOW_CONNECTED_RATE ?? 20) ? '低已接通应答率' : ''
|
const previous=await db.$queryRaw<Row[]>(Prisma.sql`SELECT ${aggregates} FROM caller_analysis_minute_buckets WHERE started_at>=${new Date(+start-15*60000)} AND started_at<${start} AND customer_id=${row.customer_id} AND caller=${row.caller} AND view=${row.view}`),p=analyticsRates(numeric(previous[0]??{}));
|
||||||
].filter(Boolean);
|
if(p.totalCalls>=50&&!p.unknownCalls&&!p.durationUnknownCalls&&p.activeCalls/p.totalCalls<=0.2)for(const[key,label]of[['connectionRate','接通率骤降'],['overallAnswerRate','总体应答率骤降'],['connectedAnswerRate','已接通应答率骤降']]as const){if(key==='connectedAnswerRate'&&(p.connectedCalls<30||rates.connectedCalls<30))continue;if(p[key]!==null&&rates[key]!==null&&p[key]!-rates[key]!>Number(process.env.ANALYTICS_RATE_DROP_POINTS??15))reasons.set(`DROP_${key}`,label);}
|
||||||
const previous = await db.$queryRaw<Array<AnalyticsCounts>>(Prisma.sql`SELECT ${aggregates} FROM caller_analysis_minute_buckets WHERE started_at>=${new Date(+start - 15 * 60000)} AND started_at<${start} AND customer_id=${row.customer_id} AND caller=${row.caller} AND view=${row.view}`);
|
const tail=await db.$queryRaw<Array<{counts:AnalyticsCounts}>>`SELECT counts FROM caller_analysis_states WHERE customer_id=${row.customer_id} AND caller=${row.caller} AND view=${row.view} AND started_at>=${start} AND started_at<${end} ORDER BY started_at DESC,id DESC LIMIT 20`;if(tail.length===20&&tail.every(t=>t.counts.failedCalls===1))reasons.set('CONSECUTIVE_FAILURE','连续20次已结束未接通');
|
||||||
const p = analyticsRates(Object.fromEntries(COUNT_KEYS.map(k => [k, Number(previous[0]?.[k] ?? 0)])) as unknown as AnalyticsCounts);
|
for(const ruleId of['LOW_CONNECTION','LOW_OVERALL','LOW_CONNECTED','DROP_connectionRate','DROP_overallAnswerRate','DROP_connectedAnswerRate','CONSECUTIVE_FAILURE']){const id=analyticsHash([row.customer_id,row.caller,row.view,ruleId,'v2']),existing=await db.$queryRaw<Prior[]>`SELECT status,payload FROM caller_analysis_alerts WHERE id=${id}`,old=existing[0];if(old?.payload.evaluationKey===evaluationKey)continue;const hit=reasons.has(ruleId),streak=hit?(old?.payload.streak??0)+1:0,recovered=hit?0:(old?.payload.recovered??0)+1,status=streak>=3?'ACTIVE':old?.status==='ACTIVE'&&recovered<3?'ACTIVE':'OBSERVING',payload=JSON.stringify({ruleId,reason:reasons.get(ruleId)??null,rates,streak,recovered,evaluationKey,windowStart:start,windowEnd:end,scope:{customerId:row.customer_id,caller:row.caller,view:row.view,minutes:15},since:old?.payload.since??new Date().toISOString(),cooldownSeconds:60});await db.$executeRaw`INSERT INTO caller_analysis_alerts(id,customer_id,view,caller,status,payload,updated_at) VALUES (${id},${row.customer_id},${row.view},${row.caller},${status},${payload},NOW(3)) ON DUPLICATE KEY UPDATE status=VALUES(status),payload=VALUES(payload),updated_at=VALUES(updated_at)`;}
|
||||||
if (p.totalCalls >= 50 && !p.unknownCalls && p.activeCalls / p.totalCalls <= 0.2) {
|
}
|
||||||
for (const [key, label] of [['connectionRate','接通率骤降'],['overallAnswerRate','总体应答率骤降'],['connectedAnswerRate','已接通应答率骤降']] as const) {
|
await db.$executeRaw`UPDATE caller_analysis_alerts SET status='STALE' WHERE updated_at<${new Date(Date.now()-120000)} AND status IN ('ACTIVE','OBSERVING','SUSPENDED')`;
|
||||||
if (key === 'connectedAnswerRate' && (p.connectedCalls < 30 || rates.connectedCalls < 30)) continue;
|
|
||||||
if (p[key] !== null && rates[key] !== null && p[key]! - rates[key]! > Number(process.env.ANALYTICS_RATE_DROP_POINTS ?? 15)) reasons.push(label);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
const tail = await db.$queryRaw<Array<{ counts: AnalyticsCounts }>>`SELECT counts FROM caller_analysis_states WHERE customer_id=${row.customer_id} AND caller=${row.caller} AND view=${row.view} AND started_at>=${start} AND started_at<${end} ORDER BY started_at DESC, id DESC LIMIT 20`;
|
|
||||||
if (tail.length === 20 && tail.every(t => t.counts.failedCalls === 1)) reasons.push('连续20次已结束未接通');
|
|
||||||
const id = analyticsHash([row.customer_id, row.caller, row.view]);
|
|
||||||
const existing = await db.$queryRaw<Array<{ status: string; payload: { streak?: number; recovered?: number; since?: string } }>>`SELECT status,payload FROM caller_analysis_alerts WHERE id=${id}`;
|
|
||||||
const streak = reasons.length ? (existing[0]?.payload.streak ?? 0) + 1 : 0;
|
|
||||||
const recovered = reasons.length ? 0 : (existing[0]?.payload.recovered ?? 0) + 1;
|
|
||||||
const status = streak >= 3 ? 'ACTIVE' : existing[0]?.status === 'ACTIVE' && recovered < 3 ? 'ACTIVE' : 'OBSERVING';
|
|
||||||
const payload = JSON.stringify({ reasons, rates, streak, recovered, windowStart: start, windowEnd: end, since: existing[0]?.payload.since ?? new Date().toISOString() });
|
|
||||||
await db.$executeRaw`INSERT INTO caller_analysis_alerts(id,customer_id,view,caller,status,payload,updated_at) VALUES (${id},${row.customer_id},${row.view},${row.caller},${status},${payload},NOW(3)) ON DUPLICATE KEY UPDATE status=VALUES(status),payload=VALUES(payload),updated_at=VALUES(updated_at)`;
|
|
||||||
}
|
|
||||||
// Old low-sample windows are no longer active evidence, not permanent alarms.
|
|
||||||
await db.$executeRaw`UPDATE caller_analysis_alerts SET status='EXPIRED' WHERE updated_at<${new Date(Date.now() - 120000)} AND status='ACTIVE'`;
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,102 +1,39 @@
|
|||||||
import { open, readFile, writeFile, rename, stat, appendFile } from 'node:fs/promises';
|
import { appendFile, open, readFile, rename, stat, writeFile } from 'node:fs/promises';
|
||||||
import { hostname } from 'node:os';
|
import { hostname } from 'node:os';
|
||||||
import { PrismaClient, Prisma, CallerAnalyticsStore, ANALYTICS_GROUP, ANALYTICS_STREAM, analyticsHash, parseAnalyticsEvent, type AnalyticsCall, type AnalyticsEvent } from '@lisglosips/database';
|
import { ANALYTICS_GROUP, ANALYTICS_STREAM, CallerAnalyticsStore, Prisma, PrismaClient, analyticsHash, parseAnalyticsEvent, validateAnalyticsEvent, type AnalyticsCall, type AnalyticsEvent } from '@lisglosips/database';
|
||||||
import { createRedisClient } from '@lisglosips/redis';
|
import { createRedisClient } from '@lisglosips/redis';
|
||||||
import { createLogger } from '@lisglosips/observability';
|
import { createLogger } from '@lisglosips/observability';
|
||||||
import { evaluateCallerAlerts } from './analytics-alerts.js';
|
import { evaluateCallerAlerts, suspendCallerAlerts } from './analytics-alerts.js';
|
||||||
|
|
||||||
const logger = createLogger('caller-analytics');
|
const logger = createLogger('caller-analytics'); const db = new PrismaClient();
|
||||||
const db = new PrismaClient(); const redis = createRedisClient(process.env.REDIS_URL ?? '');
|
const redis = createRedisClient(process.env.REDIS_URL ?? ''); const store = new CallerAnalyticsStore(db);
|
||||||
const store = new CallerAnalyticsStore(db);
|
|
||||||
const spool = process.env.ANALYTICS_SPOOL ?? '/var/log/lisglosips/caller-analytics.log';
|
const spool = process.env.ANALYTICS_SPOOL ?? '/var/log/lisglosips/caller-analytics.log';
|
||||||
const checkpoint = process.env.ANALYTICS_CHECKPOINT ?? '/var/lib/lisglosips/caller-analytics-offset.json';
|
const checkpoint = process.env.ANALYTICS_CHECKPOINT ?? '/var/lib/lisglosips/caller-analytics-offset.json';
|
||||||
let cursor = { offset: 0, ino: 0 }; let stopping = false; let lastAlerts = 0;
|
interface CollectorCursor { offset:number; ino:number; gap?:boolean; eventsSinceProducerStart?:number; lastProducerCount?:number }
|
||||||
let dataThrough: string | null = null; let gap = false;
|
let cursor:CollectorCursor={offset:0,ino:0,eventsSinceProducerStart:0}; let stopping=false; let lastAlerts=0; let lastTrim=0; let lastAlertSuspend=0;
|
||||||
const consumer = `${hostname()}-${process.pid}`;
|
let dataThrough:string|null=null; let ingestLagMs:number|null=null; let gap=false; let bootId='unknown';
|
||||||
process.on('SIGTERM', () => { stopping = true; }); process.on('SIGINT', () => { stopping = true; });
|
const consumer=`${hostname()}-${process.pid}`;
|
||||||
|
process.on('SIGTERM',()=>{stopping=true;}); process.on('SIGINT',()=>{stopping=true;});
|
||||||
|
|
||||||
function decodeLine(line: string): AnalyticsEvent | null {
|
function decodeLine(line:string):AnalyticsEvent|null {
|
||||||
const marker = line.indexOf('CRA1|'); if (marker < 0) return null;
|
const marker=line.indexOf('CRA1|'); if(marker<0)return null; const parts=line.slice(marker).trim().split('|');
|
||||||
const parts = line.slice(marker).trim().split('|');
|
if(parts.length!==20)throw new Error('Malformed producer record');
|
||||||
if (parts.length !== 20) throw new Error('Malformed producer record');
|
const [uid,callId,customerId,customerGatewayId,vendorId,vendorGatewayId,caller,landingCaller,callee,city,carrier,attemptId]=parts.slice(8).map(s=>Buffer.from(s,'base64').toString('utf8'));
|
||||||
const decoded = parts.slice(8).map(s => Buffer.from(s, 'base64').toString('utf8'));
|
const at=Number(parts[2])*1000+Math.floor(Number(parts[3])/1000); const startedAt=Number(parts[4])*1000+Math.floor(Number(parts[5])/1000); const receivedAt=Date.now();
|
||||||
const [uid, callId, customerId, customerGatewayId, vendorId, vendorGatewayId, caller, landingCaller, callee, city, carrier, attemptId] = decoded;
|
const fields={version:'2',eventId:analyticsHash(parts),callUid:analyticsHash([uid,customerId,hostname()]),callId,customerId,customerGatewayId,vendorId,vendorGatewayId,caller,landingCaller,callee,city,carrier,attemptId,
|
||||||
const at = Number(parts[2]) * 1000 + Math.floor(Number(parts[3]) / 1000);
|
kind:parts[1],at:String(at),startedAt:String(startedAt),code:parts[6],source:parts[7],talkMs:'-1',sequence:String(at*1000),nodeId:hostname(),bootId,receivedAt:String(receivedAt),method:'INVITE',evidenceVersion:'2'};
|
||||||
const startedAt = Number(parts[4]) * 1000 + Math.floor(Number(parts[5]) / 1000);
|
|
||||||
const fields = { version: '1', eventId: analyticsHash(parts), callUid: analyticsHash(uid), callId, customerId, customerGatewayId, vendorId, vendorGatewayId, caller, landingCaller, callee, city, carrier, attemptId,
|
|
||||||
kind: parts[1], at: String(at), startedAt: String(startedAt), code: parts[6], source: parts[7], talkMs: '-1', sequence: '0' };
|
|
||||||
return parseAnalyticsEvent(Object.entries(fields).flat());
|
return parseAnalyticsEvent(Object.entries(fields).flat());
|
||||||
}
|
}
|
||||||
async function collect() {
|
async function recordGap(kind:string,detail:Record<string,unknown>){const id=analyticsHash([kind,detail,Date.now()]);await db.$executeRaw`INSERT INTO caller_analysis_gaps(id,node_id,kind,status,started_at,payload,created_at,updated_at) VALUES (${id},${hostname()},${kind},'OPEN',NOW(3),${JSON.stringify(detail)},NOW(3),NOW(3))`;}
|
||||||
const info = await stat(spool);
|
async function persistCursor(){await writeFile(`${checkpoint}.tmp`,JSON.stringify({...cursor,gap}),{mode:0o600});await rename(`${checkpoint}.tmp`,checkpoint);}
|
||||||
if (cursor.ino && (cursor.ino !== info.ino || info.size < cursor.offset)) { gap = true; cursor.offset = 0; }
|
async function collect(){
|
||||||
cursor.ino = info.ino;
|
const info=await stat(spool); if(cursor.ino&&(cursor.ino!==info.ino||info.size<cursor.offset)){gap=true;await recordGap('SPOOL_ROTATED_OR_TRUNCATED',{previousIno:cursor.ino,currentIno:info.ino,previousOffset:cursor.offset,currentSize:info.size});cursor.offset=0;} cursor.ino=info.ino;
|
||||||
const file = await open(spool, 'r');
|
const file=await open(spool,'r'); try{const buffer=Buffer.alloc(Math.min(1024*1024,Math.max(0,info.size-cursor.offset)));const{bytesRead}=await file.read(buffer,0,buffer.length,cursor.offset);const last=buffer.subarray(0,bytesRead).lastIndexOf(10);if(last<0)return;
|
||||||
try {
|
for(const line of buffer.subarray(0,last+1).toString('utf8').split('\n').filter(Boolean)){try{const event=decodeLine(line);if(event){await redis.xadd(ANALYTICS_STREAM,'*','event',JSON.stringify(event));cursor.eventsSinceProducerStart=(cursor.eventsSinceProducerStart??0)+1;}}catch(error){const message=error instanceof Error?error.message:'decode error';await appendFile(`${checkpoint}.deadletter`,`${JSON.stringify({at:new Date(),line,error:message})}\n`,{mode:0o600});gap=true;await recordGap('MALFORMED_SPOOL_RECORD',{offset:cursor.offset,error:message});}}
|
||||||
const buffer = Buffer.alloc(Math.min(1024 * 1024, Math.max(0, info.size - cursor.offset)));
|
cursor.offset+=last+1;await persistCursor();}finally{await file.close();}
|
||||||
const { bytesRead } = await file.read(buffer, 0, buffer.length, cursor.offset);
|
|
||||||
const last = buffer.subarray(0, bytesRead).lastIndexOf(10); if (last < 0) return;
|
|
||||||
for (const line of buffer.subarray(0, last + 1).toString('utf8').split('\n').filter(Boolean)) {
|
|
||||||
let event: AnalyticsEvent | null;
|
|
||||||
try { event = decodeLine(line); } catch {
|
|
||||||
await appendFile(`${checkpoint}.deadletter`, `${JSON.stringify({ at:new Date(), line })}\n`, { mode:0o600 });
|
|
||||||
gap = true; continue;
|
|
||||||
}
|
|
||||||
if (event) await redis.xadd(ANALYTICS_STREAM, '*', 'event', JSON.stringify(event));
|
|
||||||
}
|
|
||||||
// Persist only after durable queue writes. Crash before checkpoint replays harmlessly.
|
|
||||||
cursor.offset += last + 1;
|
|
||||||
await writeFile(`${checkpoint}.tmp`, JSON.stringify({ ...cursor, gap }), { mode: 0o600 }); await rename(`${checkpoint}.tmp`, checkpoint);
|
|
||||||
} finally { await file.close(); }
|
|
||||||
}
|
}
|
||||||
async function entries(entries: Array<[string, string[]]>) {
|
async function consumeEntries(items:Array<[string,string[]]>){for(const[id,fields]of items){try{const i=fields.findIndex(v=>v==='event');if(i<0||!fields[i+1])throw new Error('Missing event field');const event=validateAnalyticsEvent(JSON.parse(fields[i+1]));await store.consume(event);ingestLagMs=Math.max(0,(event.receivedAt??Date.now())-event.at);if(!dataThrough||event.at>Date.parse(dataThrough))dataThrough=new Date(event.at).toISOString();}catch(error){const message=error instanceof Error?error.message.slice(0,500):'Invalid queued event';const deadId=analyticsHash([ANALYTICS_STREAM,id]);await db.$executeRaw`INSERT IGNORE INTO caller_analysis_dead_letters(id,stream_id,payload,error,status,created_at,updated_at) VALUES (${deadId},${id},${JSON.stringify(fields)},${message},'OPEN',NOW(3),NOW(3))`;gap=true;}await redis.xack(ANALYTICS_STREAM,ANALYTICS_GROUP,id);}}
|
||||||
for (const [id, fields] of entries) {
|
async function producerCount():Promise<number>{const response=await fetch(process.env.ANALYTICS_MI_URL??'http://127.0.0.1:8888/mi',{method:'POST',headers:{'content-type':'application/json'},body:JSON.stringify({jsonrpc:'2.0',id:1,method:'get_statistics',params:{statistics:['cra_event_total']}}),signal:AbortSignal.timeout(2000)});const body=await response.json()as{result?:Record<string,number>};const value=body.result?.['script:cra_event_total'];if(!response.ok||typeof value!=='number'||!Number.isSafeInteger(value))throw new Error('Producer counter unavailable');return value;}
|
||||||
const event = JSON.parse(fields[1]) as AnalyticsEvent;
|
async function observeActive(){const response=await fetch(process.env.ANALYTICS_MI_URL??'http://127.0.0.1:8888/mi',{method:'POST',headers:{'content-type':'application/json'},body:JSON.stringify({jsonrpc:'2.0',id:1,method:'dlg_list',params:[]}),signal:AbortSignal.timeout(2000)});const body=await response.json()as{result?:{Dialogs?:Array<{callid:string;state:number}>}};if(!response.ok||!body.result?.Dialogs)throw new Error('Dialog observation unavailable');const live=new Map(body.result.Dialogs.map(d=>[d.callid,Number(d.state)]));let lastId='';for(;;){const calls=await db.$queryRaw<Array<{id:string;payload:AnalyticsCall}>>`SELECT c.id,c.payload FROM caller_analysis_calls c WHERE c.id>${lastId} AND EXISTS (SELECT 1 FROM caller_analysis_states s WHERE s.call_key=c.id AND s.view='original' AND CAST(JSON_UNQUOTE(JSON_EXTRACT(s.counts,'$.activeCalls')) AS SIGNED)>0) ORDER BY c.id LIMIT 500`;if(!calls.length)break;const now=Date.now();for(const{payload}of calls)for(const leg of Object.values(payload.legs)){if(leg.endedAt!==null||leg.talkStartedAt==null||leg.talkMs>0)continue;if((live.get(leg.event.callId)??0)===4&&now>leg.talkStartedAt)await store.consume({...leg.event,kind:'DURATION',at:now,talkMs:now-leg.talkStartedAt,source:'dialog-acked-observation',eventId:analyticsHash([leg.event.callUid,leg.event.attemptId,'positive-duration'])});}lastId=calls[calls.length-1].id;}}
|
||||||
await store.consume(event);
|
async function main(){try{bootId=(await readFile('/proc/sys/kernel/random/boot_id','utf8')).trim();}catch{bootId=`${hostname()}-unknown`;}try{const saved=JSON.parse(await readFile(checkpoint,'utf8'))as CollectorCursor;cursor={...cursor,...saved,eventsSinceProducerStart:saved.eventsSinceProducerStart??0};gap=saved.gap===true;}catch{/* first start */}await redis.connect();await db.$connect();try{await redis.xgroup('CREATE',ANALYTICS_STREAM,ANALYTICS_GROUP,'0','MKSTREAM');}catch(e){if(!(e instanceof Error)||!e.message.includes('BUSYGROUP'))throw e;}let pendingCursor='0-0';while(!stopping){let degraded=gap;let reason='';try{await collect();const produced=await producerCount();if(cursor.lastProducerCount!==undefined&&produced<cursor.lastProducerCount){gap=degraded=true;reason='PRODUCER_RESTART';await recordGap(reason,{previous:cursor.lastProducerCount,current:produced});cursor.eventsSinceProducerStart=0;}cursor.lastProducerCount=produced;await persistCursor();const claimed=await redis.xautoclaim(ANALYTICS_STREAM,ANALYTICS_GROUP,consumer,5000,pendingCursor,'COUNT',100)as[string,Array<[string,string[]]>];pendingCursor=claimed[0];await consumeEntries(claimed[1]);const fresh=await redis.xreadgroup('GROUP',ANALYTICS_GROUP,consumer,'COUNT',200,'BLOCK',100,'STREAMS',ANALYTICS_STREAM,'>')as Array<[string,Array<[string,string[]]>]>|null;if(fresh)for(const[,batch]of fresh)await consumeEntries(batch);await observeActive();const groups=await redis.xinfo('GROUPS',ANALYTICS_STREAM)as Array<Array<string|number>>;const group=groups.map(g=>Object.fromEntries(Array.from({length:g.length/2},(_,i)=>[g[i*2],g[i*2+1]]))).find(g=>g.name===ANALYTICS_GROUP);if(Number(group?.pending??0)||Number(group?.lag??0)){degraded=true;reason='QUEUE_BACKLOG';}if(produced!==(cursor.eventsSinceProducerStart??0)){degraded=true;reason='PRODUCER_COLLECTOR_COUNT_MISMATCH';}if(!Number(group?.pending??0)&&Date.now()-lastTrim>60000){await redis.xtrim(ANALYTICS_STREAM,'MAXLEN','~',Number(process.env.ANALYTICS_STREAM_MAXLEN??1000000));lastTrim=Date.now();}if(Date.now()-lastAlerts>15000&&!degraded){await evaluateCallerAlerts(db);lastAlerts=Date.now();}}catch(e){degraded=true;reason=e instanceof Error?e.message.slice(0,200):'Worker error';logger.error({reason},'Analytics cycle failed');}if(degraded&&Date.now()-lastAlertSuspend>15000){try{await suspendCallerAlerts(db,reason||'ANALYTICS_DEGRADED');lastAlertSuspend=Date.now();}catch{logger.error('Analytics alert suspension failed');}}try{const spoolSize=await stat(spool).then(s=>s.size).catch(()=>null);const coverageStatus=!degraded&&!gap&&cursor.lastProducerCount===cursor.eventsSinceProducerStart?'CONTINUOUS':'DEGRADED';const payload=JSON.stringify({degraded,reason,dataThrough,lagMs:ingestLagMs,coverageStatus,captureSince:process.env.ANALYTICS_CAPTURE_SINCE??null,spoolOffset:cursor.offset,spoolUnreadBytes:spoolSize===null?null:Math.max(0,spoolSize-cursor.offset),gap,producerEventCount:cursor.lastProducerCount??null,collectedEventCount:cursor.eventsSinceProducerStart??null,bootId,updatedAt:new Date().toISOString()});await db.$executeRaw(Prisma.sql`INSERT INTO caller_analysis_health(id,payload,updated_at) VALUES ('worker',${payload},NOW(3)) ON DUPLICATE KEY UPDATE payload=VALUES(payload),updated_at=VALUES(updated_at)`);}catch{logger.error('Analytics heartbeat write failed');}await new Promise(resolve=>setTimeout(resolve,700));}redis.disconnect();await db.$disconnect();}
|
||||||
await redis.xack(ANALYTICS_STREAM, ANALYTICS_GROUP, id);
|
void main().catch(e=>{logger.error({message:e instanceof Error?e.message:'Startup failed'},'Analytics failed');process.exitCode=1;redis.disconnect();void db.$disconnect();});
|
||||||
if (!dataThrough || event.at > Date.parse(dataThrough)) dataThrough = new Date(event.at).toISOString();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
async function observeActive() {
|
|
||||||
const response = await fetch(process.env.ANALYTICS_MI_URL ?? 'http://127.0.0.1:8888/mi', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ jsonrpc: '2.0', id: 1, method: 'dlg_list', params: [] }), signal: AbortSignal.timeout(2000) });
|
|
||||||
const body = await response.json() as { result?: { Dialogs?: Array<{ callid: string; state: number }> } };
|
|
||||||
if (!response.ok || !body.result?.Dialogs) throw new Error('Dialog observation unavailable');
|
|
||||||
const calls = await db.$queryRaw<Array<{ payload: AnalyticsCall }>>`SELECT c.payload FROM caller_analysis_calls c WHERE EXISTS (SELECT 1 FROM caller_analysis_states s WHERE s.call_key=c.id AND s.view='original' AND CAST(JSON_UNQUOTE(JSON_EXTRACT(s.counts,'$.activeCalls')) AS SIGNED)>0) LIMIT 2000`;
|
|
||||||
const live = new Map(body.result.Dialogs.map(d => [d.callid, Number(d.state)]));
|
|
||||||
const now = Date.now();
|
|
||||||
for (const { payload } of calls) for (const leg of Object.values(payload.legs)) {
|
|
||||||
if (leg.endedAt !== null || leg.acceptedAt === null || leg.talkMs > 0) continue;
|
|
||||||
// Only a confirmed live dialog supplies provisional positive duration. Never a browser timer.
|
|
||||||
if ((live.get(leg.event.callId) ?? 0) >= 3 && (live.get(leg.event.callId) ?? 0) <= 4 && now > leg.acceptedAt) {
|
|
||||||
await store.consume({ ...leg.event, kind: 'DURATION', at: now, talkMs: now - leg.acceptedAt, source: 'dialog-observation', eventId: analyticsHash([leg.event.callUid, leg.event.attemptId, 'positive-duration']) });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
async function main() {
|
|
||||||
try { const saved = JSON.parse(await readFile(checkpoint, 'utf8')); cursor = saved; gap = saved.gap === true; } catch { /* First start reads the retained spool. */ }
|
|
||||||
await redis.connect(); await db.$connect();
|
|
||||||
try { await redis.xgroup('CREATE', ANALYTICS_STREAM, ANALYTICS_GROUP, '0', 'MKSTREAM'); } catch (e) { if (!(e instanceof Error) || !e.message.includes('BUSYGROUP')) throw e; }
|
|
||||||
let pendingCursor = '0-0';
|
|
||||||
while (!stopping) {
|
|
||||||
let degraded = gap; let reason = '';
|
|
||||||
try {
|
|
||||||
await collect();
|
|
||||||
const claimed = await redis.xautoclaim(ANALYTICS_STREAM, ANALYTICS_GROUP, consumer, 5000, pendingCursor, 'COUNT', 100) as [string, Array<[string, string[]]>];
|
|
||||||
pendingCursor = claimed[0]; await entries(claimed[1]);
|
|
||||||
const fresh = await redis.xreadgroup('GROUP', ANALYTICS_GROUP, consumer, 'COUNT', 200, 'BLOCK', 100, 'STREAMS', ANALYTICS_STREAM, '>') as Array<[string, Array<[string, string[]]>]> | null;
|
|
||||||
if (fresh) for (const [, batch] of fresh) await entries(batch);
|
|
||||||
await observeActive();
|
|
||||||
const groups = await redis.xinfo('GROUPS', ANALYTICS_STREAM) as Array<Array<string | number>>;
|
|
||||||
const group = groups.map(g => Object.fromEntries(Array.from({ length: g.length / 2 }, (_, i) => [g[i * 2], g[i * 2 + 1]]))).find(g => g.name === ANALYTICS_GROUP);
|
|
||||||
if (Number(group?.pending ?? 0) || Number(group?.lag ?? 0)) { degraded = true; reason = 'QUEUE_BACKLOG'; }
|
|
||||||
if (Date.now() - lastAlerts > 15000 && !degraded) { await evaluateCallerAlerts(db); lastAlerts = Date.now(); }
|
|
||||||
} catch (e) { degraded = true; reason = e instanceof Error ? e.message.slice(0, 200) : 'Worker error'; logger.error({ reason }, 'Analytics cycle failed'); }
|
|
||||||
try {
|
|
||||||
const payload = JSON.stringify({ degraded, reason, dataThrough, lagMs: null, coverageStatus: 'OBSERVATIONS_ONLY', captureSince: process.env.ANALYTICS_CAPTURE_SINCE ?? null, spoolOffset: cursor.offset, gap, updatedAt: new Date().toISOString() });
|
|
||||||
await db.$executeRaw(Prisma.sql`INSERT INTO caller_analysis_health(id,payload,updated_at) VALUES ('worker',${payload},NOW(3)) ON DUPLICATE KEY UPDATE payload=VALUES(payload),updated_at=VALUES(updated_at)`);
|
|
||||||
} catch { logger.error('Analytics heartbeat write failed'); }
|
|
||||||
await new Promise(resolve => setTimeout(resolve, 700));
|
|
||||||
}
|
|
||||||
redis.disconnect(); await db.$disconnect();
|
|
||||||
}
|
|
||||||
void main().catch(e => { logger.error({ message: e instanceof Error ? e.message : 'Startup failed' }, 'Analytics failed'); process.exitCode = 1; redis.disconnect(); void db.$disconnect(); });
|
|
||||||
|
|||||||
@@ -0,0 +1,64 @@
|
|||||||
|
# 主叫号码实时分析专项整改交付报告
|
||||||
|
|
||||||
|
日期:2026-09-01
|
||||||
|
状态:整改实现、B机发布及受控功能/可靠性验收完成;容量与真实多线路重试保留明确限制。
|
||||||
|
|
||||||
|
## 1. 交付版本与边界
|
||||||
|
|
||||||
|
- B机生效提交:`9fa1964dec98cbe3c53c901ba764373308054d5e`。
|
||||||
|
- 发布目录:`/opt/lisglosips/releases/caller-analytics-20260901T024406Z`。
|
||||||
|
- 回滚备份:`/var/backups/lisglosips-caller-analytics/20260901T024406Z`。
|
||||||
|
- 发布制品SHA-256:`b2f22a04312b1979a11eec62ed797e65ab6c4c8d25174075a8dbc0d723ea7435`。
|
||||||
|
- 新采集连续覆盖起点保留为`2026-09-01T02:23:21Z`,发布重启不伪造新的连续历史。
|
||||||
|
- 本次只改主叫分析采集、分析存储、API、页面、独立Redis和运维脚本。没有修改计费、余额、路由选择和旧固定6秒CDR逻辑,没有部署A/T,也没有呼叫公网号码。
|
||||||
|
|
||||||
|
本地标准制品脚本在Windows创建符号链接时遇到`EPERM`。本次使用等价的delta overlay制品:B机先复制上一release,再覆盖本提交受控文件;发布脚本仍执行备份、迁移、候选配置检查、服务切换和健康门槛。
|
||||||
|
|
||||||
|
## 2. 整改结果
|
||||||
|
|
||||||
|
| 整改组 | 落地结果 | 状态 |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| R01~R03 时长、未知与乱序 | TALK_START在对话路由匹配后产生,ACK后开始计时;时长分未知/暂定/最终;最终零时长具有权威优先级;状态归约支持重复和乱序 | 已完成 |
|
||||||
|
| R04~R05 完整性、留存与隔离 | 生产/采集计数、水位、日志未读字节、持久gap/DLQ、pending安全裁剪;独立Redis启用AOF、noeviction及256MB上限;维护和logrotate已安装 | 已完成 |
|
||||||
|
| R06~R07 生命周期与契约 | 增加显式CLOSE、生产节点/boot/sequence、事务阶段、证据来源与算法版本;单attempt保持当前真实路由语义 | 已完成;真实重试启用前补验 |
|
||||||
|
| R08~R10 API、告警与页面 | 查询覆盖、snapshotId、稳定分页、供应商筛选、未知/null比率、告警规则独立持续计数与降级暂停、详情信令证据均已落地 | 已完成 |
|
||||||
|
| R11 性能与容量 | 活跃呼叫分页、查询上限、资源隔离和延迟字段已落地;本轮只验证受控功能负载 | 工程整改完成;容量/P95未承诺 |
|
||||||
|
| R12 发布回滚 | 版本化采集V3、完整备份、逐步回滚、独立Redis备份、配置校验、空闲检查、健康和提交校验已落地并用于本次发布 | 已完成;未逐点实施破坏性失败注入 |
|
||||||
|
| R13 状态与证据 | V1.2设计、状态索引、测试索引、本报告及机器证据已同步 | 已完成 |
|
||||||
|
|
||||||
|
## 3. 验证结果
|
||||||
|
|
||||||
|
本地验证全部通过:ESLint、`tsc -b tsconfig.build.json`、Vite生产构建、Prisma schema校验;Vitest共29个文件、118项。OpenSIPS从V1/V2生成V3配置后在B机执行`opensips -C`通过。
|
||||||
|
|
||||||
|
B机最终审计显示以下服务均为`active/enabled`:OpenSIPS、API、CDR Worker、Caller Analytics Worker、Caller Analytics独立Redis、维护timer。`/api/v2/health/ready`返回`status=ok`,数据库和Redis检查均为ok。独立Redis实测AOF开启、持久化状态正常、`noeviction`、上限256MB;Stream pending为0。
|
||||||
|
|
||||||
|
末轮6通真实本机回环SIP全部通过原有平台路由,两个成功场景包含SDP offer/answer并通过RTPEngine:
|
||||||
|
|
||||||
|
| 场景 | 统计结果 |
|
||||||
|
| --- | --- |
|
||||||
|
| 180后486 | C=1,A=0 |
|
||||||
|
| 183后487 | C=1,A=0 |
|
||||||
|
| 直接486 | C=0,A=0 |
|
||||||
|
| 直接200,ACK后约1.5秒BYE | C=1,A=1 |
|
||||||
|
| 180→200,ACK后约300ms BYE | C=1,A=1 |
|
||||||
|
| 100后486 | C=0,A=0 |
|
||||||
|
|
||||||
|
汇总为`T=6、C=4、A=2、N=2`,接通率66.6667%,总体应答率33.3333%,已接通应答率50%,`talkMs=1801`,未知、待接通和暂定应答均为0。实测数据覆盖为`CONTINUOUS`,末次API采集延迟468ms。该单点观测不能替代统计P95。
|
||||||
|
|
||||||
|
真实API与数据库还验证了状态表/分钟桶逐字段对账、5条事件重放幂等、未认证401、非法查询400、无授权/跨客户403、客户及供应商范围选项不越权。机器证据归档在[caller-analytics-real-20260901.json](../tests/reports/caller-analytics-real-20260901.json)。
|
||||||
|
|
||||||
|
可靠性验证包括:维护任务dry-run与实际执行、logrotate规则识别、独立Redis重启后Stream长度保持、非法JSON进入持久死信并ACK、覆盖状态转为降级、处置后恢复连续。最终保留一条`RESOLVED`的计划发布重启gap及一条`RESOLVED`的毒事件deadletter,作为审计证据。
|
||||||
|
|
||||||
|
## 4. 页面验收
|
||||||
|
|
||||||
|
优先使用Codex Browser插件访问B机时,HTTP入口被客户端阻止,HTTPS尝试超时并重置浏览器内核。按浏览器测试规范改用仓库Playwright和系统Chrome完成回退验收;登录通过真实`/auth/refresh`会话,业务数据来自真实API/MySQL,没有mock、静态替代或localStorage业务数据。
|
||||||
|
|
||||||
|
已验证应用壳、主叫分析菜单、客户/供应商筛选、连续覆盖、版本/未知字段、三个比例、详情证据及300ms时长,覆盖1440×1000桌面和390×844移动视口。移动端宽表使用现有横向滚动容器,抽屉独立纵向滚动。控制台无错误。截图保存在`.codex-private/caller-analytics-desktop-20260901.png`和`.codex-private/caller-analytics-mobile-20260901.png`。
|
||||||
|
|
||||||
|
验收后已删除临时登录会话,数据库中对应会话数为0;受控客户入口和供应商出口`cra_20260831`均已恢复为`DISABLED`,测试证据保留。
|
||||||
|
|
||||||
|
## 5. 未作出的承诺
|
||||||
|
|
||||||
|
本轮没有可确认的峰值CPS、号码/维度基数和目标保留期,因此没有执行有业务依据的容量压测,也不宣称页面端到端P95≤5秒或机器容量上限。后续在真实多线路重试启用前,还需按实际attemptId和路由策略补做两线路重试验收。上述两项不会把当前单线路统计结果解释成未完成,但限制容量与未来重试能力的对外声明。
|
||||||
|
|
||||||
|
代码、交付文档和机器证据已推送到`origin/main`。B机生效代码为`9fa1964`;其后的`367211e`只增加交付报告、证据JSON和状态索引,因此没有为了纯文档提交再次重启B机服务。
|
||||||
@@ -2,8 +2,8 @@
|
|||||||
|
|
||||||
日期:2026-08-31
|
日期:2026-08-31
|
||||||
|
|
||||||
版本:V1.1(按用户修订后的业务口径)
|
版本:V1.2(整改实施版)
|
||||||
状态:设计文档已完成;代码、数据库迁移、真实联调与上线均待实施。
|
状态:整改代码与部署方案已完成;最终上线与真实验收结果见对应日期的交付报告。
|
||||||
|
|
||||||
实施更新(2026-08-31):独立分析链路、API、页面和测试脚本已编写;部署与真实验收结果以实施状态及发布报告为准,上述为方案编制时状态。
|
实施更新(2026-08-31):独立分析链路、API、页面和测试脚本已编写;部署与真实验收结果以实施状态及发布报告为准,上述为方案编制时状态。
|
||||||
|
|
||||||
@@ -172,7 +172,7 @@
|
|||||||
|
|
||||||
新增影响包括OpenSIPS事件采集开销、Redis队列容量、MySQL写放大、查询和缓存负载。统计新表/新服务独立部署并使用功能开关;回滚可关闭采集钩子和分析Worker/入口,保留证据及新表,不删除既有话单、不调整计费余额和路由。生产修改及受控外呼须按届时任务授权执行。
|
新增影响包括OpenSIPS事件采集开销、Redis队列容量、MySQL写放大、查询和缓存负载。统计新表/新服务独立部署并使用功能开关;回滚可关闭采集钩子和分析Worker/入口,保留证据及新表,不删除既有话单、不调整计费余额和路由。生产修改及受控外呼须按届时任务授权执行。
|
||||||
|
|
||||||
## 10. 验收用例(设计,尚未执行)
|
## 10. 验收用例(设计与执行状态)
|
||||||
|
|
||||||
以下单呼叫终态用例默认T=1;应答时长均指可信实际通话时长。未说明缺口的用例假设完整采集。
|
以下单呼叫终态用例默认T=1;应答时长均指可信实际通话时长。未说明缺口的用例假设完整采集。
|
||||||
|
|
||||||
@@ -203,7 +203,7 @@
|
|||||||
| CRA-023 | 只有可信最终正时长CDR,过程丢失 | 补C/A并标CDR证据,不伪造180/183,不重复T |
|
| CRA-023 | 只有可信最终正时长CDR,过程丢失 | 补C/A并标CDR证据,不伪造180/183,不重复T |
|
||||||
| CRA-024 | 采集失联导致一个呼叫阶段无法判定 | U单列,T=C+N+U,不自动判失败,率标暂定/不完整 |
|
| CRA-024 | 采集失联导致一个呼叫阶段无法判定 | U单列,T=C+N+U,不自动判失败,率标暂定/不完整 |
|
||||||
|
|
||||||
功能验收须形成真实SIP信令、API返回、数据库状态/聚合与最终话单的对应证据;mock、静态页面或localStorage不构成功能验收。单元测试用于公式/状态转换,真实联调用于端到端与故障恢复。正常约定负载下事件或可信正时长可用至页面可见的P95目标≤5秒;这是待测目标,不是当前容量承诺。需分别记录采集、消费、API和页面延迟及原有呼叫/计费回归结果。
|
功能验收须形成真实SIP信令、API返回、数据库状态/聚合与最终话单的对应证据;mock、静态页面或localStorage不构成功能验收。2026-09-01已完成公式/状态自动化回归、6通受控真实SIP、SDP/RTPEngine、真实API/MySQL/RBAC、重放幂等、故障恢复及真实页面验收,详见[整改交付报告](CALLER_REALTIME_ANALYTICS_DELIVERY_20260901.md)。正常约定负载下事件或可信正时长可用至页面可见的P95目标≤5秒仍为待测目标;当前没有约定峰值负载,不作容量承诺。真实多线路重试在路由功能启用前补验。
|
||||||
|
|
||||||
## 11. 本次交付边界
|
## 11. 本次交付边界
|
||||||
|
|
||||||
@@ -219,3 +219,44 @@
|
|||||||
- 实际当前OpenSIPS路由只选择一次落地,没有切换重试流程;采集标记单attempt,状态层支持多attempt且有测试,未来新增真实重试必须同步采集独立attemptId,不能直接复用单attempt钩子。
|
- 实际当前OpenSIPS路由只选择一次落地,没有切换重试流程;采集标记单attempt,状态层支持多attempt且有测试,未来新增真实重试必须同步采集独立attemptId,不能直接复用单attempt钩子。
|
||||||
- 历史无过程证据的话单不自动导入完整统计。页面显示新采集生效说明,旧数据不可用不等于呼叫数为0;历史补录仅接受已核验来源的RECONCILE,不接受旧固定时长CDR。
|
- 历史无过程证据的话单不自动导入完整统计。页面显示新采集生效说明,旧数据不可用不等于呼叫数为0;历史补录仅接受已核验来源的RECONCILE,不接受旧固定时长CDR。
|
||||||
- 告警通过独立进程环境变量配置三个低率阈值和前窗下降百分点;采用样本/未结束占比门槛、连续三次评估及三次恢复,站内展示,不执行停号换路。后续应按实际业务校准默认阈值。
|
- 告警通过独立进程环境变量配置三个低率阈值和前窗下降百分点;采用样本/未结束占比门槛、连续三次评估及三次恢复,站内展示,不执行停号换路。后续应按实际业务校准默认阈值。
|
||||||
|
|
||||||
|
## 13. V1.2 整改落地规范(2026-09-01)
|
||||||
|
|
||||||
|
本节覆盖V1.1实施后专项核对发现的问题,并作为运行实现的约束。原有计费、余额、路由决策和旧CDR生成语义不在本次整改范围内。
|
||||||
|
|
||||||
|
### 13.1 应答证据和生命周期
|
||||||
|
|
||||||
|
- 初始INVITE的2xx只证明已接通,不直接证明应答。只有匹配已建立对话的ACK产生`TALK_START`,之后的权威结束时间或对话存活观察才能形成正通话时长。
|
||||||
|
- 180/183后以非2xx结束时,通话时长确定为0;收到2xx但缺少ACK或最终时长证据时,单列`durationUnknownCalls`,两个应答率返回null。
|
||||||
|
- 新事件带`schemaVersion=2`、nodeId、bootId、receivedAt及证据版本。业务呼叫以显式`CLOSE`结束;单个线路尝试结束不能提前关闭原始呼叫。
|
||||||
|
- 原始视角和落地视角继续分开计数。当前路由仍只有一个真实出局尝试;未来增加重试必须为每次尝试生成独立attemptId。
|
||||||
|
|
||||||
|
### 13.2 完整性、可靠性和资源边界
|
||||||
|
|
||||||
|
- OpenSIPS的两个分析事件发射器都累加`cra_event_total`。采集器将生产计数与成功写入独立Stream的计数核对;不一致、生产进程重启、日志截断/轮转、无效记录、队列积压均进入降级状态。
|
||||||
|
- 缺口写入`caller_analysis_gaps`,毒消息写入`caller_analysis_dead_letters`。毒消息持久化后ACK,避免无限阻塞消费组;缺口未由运维核实关闭前,相关窗口比例不可用。
|
||||||
|
- 分析Stream迁移到B机独立Redis实例`127.0.0.1:6381`,使用AOF everysec、RDB、noeviction和256MB上限,不与计费热路径共享内存淘汰策略。发布前备份既有独立实例,服务和维护定时器纳入开机目标。
|
||||||
|
- 原始事件默认保留30天、呼叫状态90天、分钟桶400天;已解决缺口和死信保留90天。定时维护先统计后事务删除,OPEN记录不自动删除。
|
||||||
|
- Stream仅在消费组pending为0时近似裁剪,默认上限100万条。数据库提交成功后才ACK;重领和重放由事件唯一键与事务差值更新保证幂等。
|
||||||
|
|
||||||
|
### 13.3 API、页面和告警
|
||||||
|
|
||||||
|
- API返回`analysisRevision=caller-analytics-r2`、snapshotId、baseFilters、numberFilters、coverageStatus、dataThrough、lagMs、缺口和指标可用性。窗口不是连续完整覆盖时,三个比例统一返回null。
|
||||||
|
- 号码排序使用业务排序键、caller、customerId的固定次序;详情和拆分在同一数据库事务快照读取,拆分超过500组合时明确返回截断标志。
|
||||||
|
- 落地主叫增加供应商筛选;原始主叫禁止混用供应商及落地网关筛选。页面分别显示接通未知、时长未知和暂定应答,并把初始INVITE结果与对话结束原因分开展示。
|
||||||
|
- 站内告警按规则独立保存,使用固定分钟窗键,连续三个不同评估窗触发、连续三个窗恢复。只在最近15分钟、无维度缩窄且连续覆盖的号码全量范围展示;采集降级时告警置为SUSPENDED并停止业务触发。
|
||||||
|
|
||||||
|
### 13.4 新增验收用例
|
||||||
|
|
||||||
|
| ID | 场景 | 预期 |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| CRA-025 | 初始INVITE收到200但从未收到ACK,随后结束 | C=1;A不强判;时长未知=1;应答率为null |
|
||||||
|
| CRA-026 | ACK后500ms内结束 | TALK_START至结束为正毫秒时长,A=1 |
|
||||||
|
| CRA-027 | 一个原始呼叫第一尝试失败、第二尝试应答 | 原始T=1且关闭发生在尝试耗尽/业务结束后;落地T=2 |
|
||||||
|
| CRA-028 | OpenSIPS发射器计数高于采集计数 | coverageStatus=DEGRADED,比例与业务告警不可用 |
|
||||||
|
| CRA-029 | Stream出现不合法JSON或字段 | 写入持久死信并ACK;消费继续;完整性降级 |
|
||||||
|
| CRA-030 | Redis pending存在时达到裁剪周期 | 不裁剪;pending清零后才执行有界近似裁剪 |
|
||||||
|
| CRA-031 | 查询缩窄供应商/网关/地区后存在活动告警 | 不把全量告警贴到缩窄结果;告警范围说明一致 |
|
||||||
|
| CRA-032 | 同一比例并列且分页重复查询 | 排序和分页结果稳定,snapshotId及筛选回显可追溯 |
|
||||||
|
| CRA-033 | B机重启 | 独立Redis、分析Worker和维护timer自动恢复,API明确显示恢复期覆盖状态 |
|
||||||
|
| CRA-034 | 保留维护执行 | 仅删除超过期限的数据;OPEN缺口/死信保留;分钟桶和状态仍可查询 |
|
||||||
|
|||||||
@@ -0,0 +1,402 @@
|
|||||||
|
# 主叫号码实时分析设计核对与整改方案
|
||||||
|
|
||||||
|
日期:2026-08-31
|
||||||
|
审查版本:V1.0
|
||||||
|
被审文档:[主叫号码接通率与应答率实时分析设计方案 V1.1](CALLER_REALTIME_ANALYTICS_DESIGN.md)
|
||||||
|
初始源码基线:`116e316eca5b564f33ec3558dbd00201da173ed8`;收尾补核对提交 `d78616561ead24e54734c8dfc9b34178bfd24ab1` 及当时可见的并行未提交差异。
|
||||||
|
结论:业务指标定义基本成立,可保留;实现已有基础,但证据可信度、乱序校正、采集完整性、运维隔离和验收闭环仍需整改,暂不具备认定“完整实时分析已验收”的依据。
|
||||||
|
|
||||||
|
## 1. 核查范围与结论边界
|
||||||
|
|
||||||
|
本次核对设计内部一致性,以及设计与本地 OpenSIPS 采集脚本、分析 Worker、状态计算、MySQL 聚合、API、React 页面、迁移、发布脚本和测试脚本的对应关系。补充查阅 SIP、OpenSIPS、Redis 官方资料,并运行现有专项单元测试和不连接外部服务的内存复现。
|
||||||
|
|
||||||
|
本次没有连接 B/T 或生产服务,没有查询线上数据库,没有执行迁移、发布、服务重启、真实呼叫或计费操作;不把历史实施记录当成本轮现场验证。原文第12节记录的 B 现场事实仍属历史记录。本地 `infra/server-a/s28/opensips.cfg` 是采集脚本依赖的路由布局参照,不等于当前 B 生效配置。
|
||||||
|
|
||||||
|
核查开始时已有以下其他任务文件,本次不改动、不纳入交付:`docs/CODE_QUALITY_REVIEW_20260831.md`、`tests/reports/code-quality-20260831-dependency-audit.json`、`tests/reports/code-quality-20260831-repro.mjs`。
|
||||||
|
|
||||||
|
**并行更新说明:**核查期间另一个任务提交了 `d786165`,并继续修改存储/真实测试及新增 `scripts/rebuild-caller-analytics.mjs`。本文已补核对:gap写入游标文件、部分回滚恢复、日志匹配与趋势时间序列化已有修复代码;分钟桶JSON键修复、SQL对账与重复消费测试为收尾时可见的候选变化。下文注明相应进展,不将其全部视为未开发,也不代替另一任务宣称已部署/验收。本轮不覆盖或提交这些变化。
|
||||||
|
|
||||||
|
证据分级:
|
||||||
|
|
||||||
|
| 标记 | 含义 | 本文如何使用 |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| 已复现 | 本轮直接执行本地纯计算,得到输出 | 确认状态计算缺陷,不等于线上事故 |
|
||||||
|
| 源码确认 | 当前文件可直接确认行为或缺少必要路径 | 能确认实现差距,发生概率、线上影响仍需联调 |
|
||||||
|
| 待现场验证 | 涉及生效配置、SIP 状态、资源与部署结果 | 作为实施前置门槛,不写成已发生事实 |
|
||||||
|
|
||||||
|
整改分为13组,其中10组P1、3组P2。P1指完整功能验收或正式启用前应关闭的问题;P2指应补齐的契约、展示和容量工程项。未凭本地审查认定已有P0线上事故。
|
||||||
|
|
||||||
|
## 2. 应保留的设计,以及需要澄清的约束
|
||||||
|
|
||||||
|
以下内容不应在整改时改回旧口径:
|
||||||
|
|
||||||
|
1. 接通 C 以匹配的初始 INVITE 下游180/183、直接有效2xx或可信正时长证据判定;183不代表真实振铃,C不等于真人接听。
|
||||||
|
2. 应答 A 以可信业务通话时长严格大于0判定;200本身、answeredAt、计费取整秒数均不能替代。不能为整改方便把A重新定义成2xx数量,也不额外要求真人检测或RTP分析。
|
||||||
|
3. 原始主叫按业务呼叫去重,落地主叫按实际出局尝试计数;不同客户同号不能合并归属。
|
||||||
|
4. 保留 `N=F+P`、`T=C+F+P+U`、`0≤A≤C≤T`,以及三个率 `C/T`、`A/T`、`A/C`;分母0返回null。
|
||||||
|
5. 后续进展回写发起时间桶;最终失败不撤销已有接通证据;历史缺少过程时不能用最终486/487倒推F。
|
||||||
|
6. 统计不改变计费、余额、路由,不自动停号换路。保留独立消费者、数据库事务去重及提交后ACK。
|
||||||
|
|
||||||
|
原文关于100/180/183及200与请求方法相关的说明与 [RFC 3261 §21.1–21.2](https://www.rfc-editor.org/rfc/rfc3261.html#section-21.1) 一致;“180/183算业务接通”仍是本平台产品定义,不是协议规定的接听率。
|
||||||
|
|
||||||
|
需新增两个独立概念:
|
||||||
|
|
||||||
|
- **呼叫分类未知 U**:已知存在的呼叫中,不能确认是否达到C的数量;继续参与上述守恒式。
|
||||||
|
- **数据覆盖缺口 / 应答证据不完整**:可能整个呼叫都未采到,或C已知但A未知。不能硬塞进U,也不能因U=0就认为所有指标完整。前者连T的真实值都可能未知,必须单独标注窗口覆盖状态。
|
||||||
|
|
||||||
|
原文“对账后P=U=0”只能作为完整采集且可修复的验收目标;不可修复的历史缺口不能为了闭环强行归零。
|
||||||
|
|
||||||
|
## 3. 分级问题与整改要求
|
||||||
|
|
||||||
|
### R01|P1|“成功响应至结束的时间差”尚未充分证明实际通话时长
|
||||||
|
|
||||||
|
**对应原文:**第3.4、6、12节。**证据:源码确认,纯计算复现;协议边界待真实验证。**
|
||||||
|
|
||||||
|
- `packages/database/src/caller-analytics-model.ts:79–84` 在没有显式talkMs时,用结束时间减acceptedAt生成最终时长。
|
||||||
|
- `apps/worker-cdr/src/analytics-main.ts:60–71` 只按Call-ID匹配MI,状态3或4均可生成暂定正时长。
|
||||||
|
- `scripts/instrument-caller-analytics.mjs:31–38` 将挂断、超时和事务失败统一标为 `verified-dialog-final`,该字符串本身并不能证明会话时长已核验。
|
||||||
|
- `tests/api/caller-analytics-sip.py` 的INVITE/响应均为 `Content-Length: 0`,未提供SDP offer/answer。这些场景能验证SIP响应和对话流程,但不足以独立证明实际业务会话的计时口径。
|
||||||
|
|
||||||
|
OpenSIPS 3.4官方文档区分状态3(已收到最终响应,尚未收到ACK)与4(已收到ACK);对话lifetime由对话确认起计算。不能只凭字段名或状态范围声明为“实际通话时长”。参见 [OpenSIPS Dialog状态与lifetime](https://docs.opensips.org/manual/3-4/modules/dialog/#exported-pseudo-variables)。实际部署版本仍需现场核对。
|
||||||
|
|
||||||
|
**整改:**建立经过验证的时长来源适配层,分别保存 `inviteAcceptedAt`、`talkStartedAt`、`talkEndedAt`、`talkDurationMs`、`durationEvidenceId`、`durationStatus` 和证据版本。ACK、协商及对话状态用于核验来源,不改动最终“可信正时长>0”的业务条件。信令观察差值在核验完成前只能作为观察值,不能自动升级为权威最终时长。无ACK、协商失败、取消竞态、对话超时须逐类定义时长有效性;不确定时保留C、标记A证据待核实。使用一致时钟域;秒/微秒读取应为同一次时间采样,并检测时钟跳变,而非仅用Math.max掩盖负时长。
|
||||||
|
|
||||||
|
**关闭标准:**CRA-005/006/007/013/014及新增“200无ACK”“协商失败”“早期媒体”“零时长终态”真实验证;证明正常500ms计A、可信零时长不计A。必须保留会话开始/结束证据及来源解释。
|
||||||
|
|
||||||
|
### R02|P1|结束事件错误地清除未知,采集丢失可能被统计成失败
|
||||||
|
|
||||||
|
**对应原文:**第3.3、4、7节。**证据:已复现。**
|
||||||
|
|
||||||
|
`caller-analytics-model.ts:68–78` 初始可把缺过程的leg设为unknown,但遇到 `verified-dialog-final` 或 `platform-final` 的END就清零。当前END记录不携带完整的历史阶段快照,不能证明之前没有180/183。
|
||||||
|
|
||||||
|
本轮输入只有 `END(487, verified-dialog-final)`,得到 `T=1,C=0,F=1,U=0`;输入 `ATTEMPT→UNKNOWN→END` 也得到F=1。对缺少过程证据的下游呼叫,预期应保留U,除非补回过程或有可信完整终态快照。
|
||||||
|
|
||||||
|
另外,`legCounts()` 的unknown受 `!connected` 限制:一旦C成立,A时长丢失不会通过U反映;这不是修改守恒式的理由,而是需要独立的时长完整性字段。
|
||||||
|
|
||||||
|
**整改:**分离 `ended`、`connectionEvidenceComplete`、`durationEvidenceComplete`、`captureGapIds`。END只证明结束,不能默认证明全过程完整。平台本地明确拒绝可通过独立的“未出局终态证据”判F;下游失败只有在阶段覆盖完整时判F。终态快照应包含已观测180/183/2xx标志、证据ID及序列覆盖,不伪造时间。
|
||||||
|
|
||||||
|
**关闭标准:**CRA-019/020/023/024;有缺口的487保留U,补回180后只转C;已接通但时长证据缺失时,A相关比率按不完整口径展示且不触发业务告警。
|
||||||
|
|
||||||
|
### R03|P1|最终校正与乱序合并不满足到达顺序无关
|
||||||
|
|
||||||
|
**对应原文:**第3.4、4、6节。**证据:已复现。**
|
||||||
|
|
||||||
|
`caller-analytics-model.ts:86–89` 同时以“最终/暂定”和event.at决定更新。MI先取得在线快照、结束事件晚到时,可能已产生晚于真实结束时刻的暂定观察。最终零时长事件若event.at较早,会被忽略。
|
||||||
|
|
||||||
|
同一组事实的本轮输出:
|
||||||
|
|
||||||
|
| 输入到达顺序(t为统一起点) | 当前结果 | 应整改为 |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| ACCEPTED(t+1s) → DURATION(t+4s,3000ms) → RECONCILE(t+3s,0ms) | A=1,talkMs=3000 | A=0,talkMs=0 |
|
||||||
|
| ACCEPTED(t+1s) → RECONCILE(t+3s,0ms) → DURATION(t+4s,3000ms) | A=0,talkMs=0 | 与上一行相同 |
|
||||||
|
| ATTEMPT → UNKNOWN → END(487) | F=1,U=0 | 缺口尚未修复时U=1 |
|
||||||
|
| ATTEMPT → END(487) → UNKNOWN | F=0,U=1 | 与上一行相同 |
|
||||||
|
|
||||||
|
**整改:**将业务事件时间、接收时间、证据权威等级、校正版本分开。已核验最终时长优先于暂定时长;最终之间按来源修订版本选择,不能用消息到达顺序决定。未知缺口只能被对应补偿证据关闭。保留纯函数状态归约,但使同一事实集合的排列、重复、重领结果一致;更高版本纠错通过现有同事务贡献差值回写。
|
||||||
|
|
||||||
|
**关闭标准:**新增排列测试、显式零时长后迟到END/ACCEPTED测试;真实MySQL重放后events/state/buckets一致,A回退不产生负数,C按其独立有效证据保留。
|
||||||
|
|
||||||
|
**聚合层补充:**初始基线 `caller-analytics-store.ts:46` 将JSON路径 `$.totalCalls` 等作为JSON_OBJECT的键传入,而读取使用的是普通键totalCalls等。并行候选改动已把写入键修正为普通键,并新增从states重建分钟桶的维护脚本。这属于“修复代码已出现、历史聚合修复与真实验证待确认”,不能只发布新写入逻辑而留下旧桶。须核对受影响桶、states与events是否可信,备份后按第5节重建/差异核对;本轮没有执行该维护脚本。
|
||||||
|
|
||||||
|
### R04|P1|运行心跳被当作数据完整性,lagMs=0缺少测量依据
|
||||||
|
|
||||||
|
**对应原文:**第4、5、6、10节。**证据:源码确认。**
|
||||||
|
|
||||||
|
- 初始基线的gap仅在内存;并行提交 `d786165` 已将gap随游标保存并在启动恢复,原“重启必丢标志”问题已有修复代码。仍只有全局布尔值,没有受影响节点/时间范围与修复状态;保存失败、无完整新日志行、旧游标缺字段等边界仍需测试。
|
||||||
|
- `analytics-main.ts:57` 将最后处理的event.at作为dataThrough,乱序可倒退,且不代表此前无缺口。
|
||||||
|
- `analytics-main.ts:89–95` 只看消费组pending/lag,正常时直接返回 `lagMs: 0`;未计入未读日志、rsyslog队列或生产端停采。
|
||||||
|
- `caller-analytics.service.ts:88–90` 以Worker心跳新鲜且未degraded标LIVE。即使OpenSIPS日志钩子失效,只要日志可读、MI可用且队列空,仍可能显示LIVE。
|
||||||
|
|
||||||
|
**整改:**增加生产端序列/心跳、日志未读字节、Redis最旧未处理年龄、消费速率、DB提交水位及持久化gap表。按节点维护“连续处理到哪个生产序列”,再合成查询窗口覆盖状态。没有可靠延迟证据时lagMs返回null;空闲与停采必须可区分。恢复进程只改变运行状态,不能自动修复历史缺口。
|
||||||
|
|
||||||
|
**关闭标准:**停采但不停Worker、积压日志但Stream暂空、轮转后重启、迟到旧事件四类场景均不误标完整;质量提示可按时间窗口查询,缺口修复有审计记录。
|
||||||
|
|
||||||
|
### R05|P1|留存、持久化与资源隔离没有形成闭环
|
||||||
|
|
||||||
|
**对应原文:**第6、9节。**证据:源码确认;线上参数待核验。**
|
||||||
|
|
||||||
|
- `analytics-main.ts:45–49` 在XADD返回后推进本机游标,但这不等于Redis数据已经落盘。本地Redis基线 `infra/server-b/s04/redis/redis-lisglosips.conf:8–18` 为AOF everysec、1536MB、noeviction,实际生效值本轮未核实。everysec存在故障丢失最近约一秒写入的边界,见 [Redis持久化文档](https://redis.io/docs/latest/operate/oss_and_stack/management/persistence/)。
|
||||||
|
- 分析代码中未见Stream裁剪、专属日志轮转方案、事件/状态/桶/死信清理任务;rsyslog的256MB队列上限不是日志文件、Stream或MySQL表的保留期。
|
||||||
|
- `infra/server-b/caller-analytics/deploy.sh:64` 从cdr-worker环境复制REDIS_URL。独立Stream/消费组不能隔离同一Redis实例内存耗尽的影响。
|
||||||
|
- `analytics-main.ts:52–55,84` 消费时直接JSON.parse并断言类型;缺少消费端schema校验和坏事件重试上限,也未处理XAUTOCLAIM可能返回的已删除pending ID列表。
|
||||||
|
|
||||||
|
Redis XACK移除的是消费组待确认记录,不能代替Stream数据删除。参见 [Redis XACK](https://redis.io/docs/latest/commands/xack/) 和 [Streams恢复说明](https://redis.io/docs/latest/develop/use-cases/streaming/)。
|
||||||
|
|
||||||
|
**整改:**建立已持久化消费水位与日志可重放区间,Redis恢复后识别回退并从保留日志补送。轮转按inode读取完旧文件后切换,游标含文件身份、序列、校验与持久化状态。为日志、Stream、events、states、buckets、deadletter分别定义容量、保留期、清理安全线和溢出行为;清理不得早于允许迟到/重放/去重窗口。超界必须登记缺口,不能静默丢弃。消费端校验输入,坏事件隔离并告警,正常事件继续推进。
|
||||||
|
|
||||||
|
资源方面先测共享Redis和MySQL余量;若无法证明统计流在故障与峰值下不影响呼叫/计费,则使用独立Redis实例或等效强隔离。仅换Redis DB编号不构成内存隔离。日志、死信含号码与Call-ID,须同步限定文件权限、访问范围、导出审计和脱敏保留规则。
|
||||||
|
|
||||||
|
**关闭标准:**断电/进程崩溃、XADD后游标提交、日志轮转、毒事件、满盘/满队列、重领/清理竞态均有真实恢复记录;数据可恢复或缺口可见,原呼叫与计费服务不受统计积压拖垮。
|
||||||
|
|
||||||
|
### R06|P1|呼叫/尝试生命周期及真实出局边界不完整
|
||||||
|
|
||||||
|
**对应原文:**第3.1、4、6、12节。**证据:源码确认,重试间隙已纯计算复现;真实重试尚未实施。**
|
||||||
|
|
||||||
|
- 当前ATTEMPT在 `t_relay()` 前发出(`instrument-caller-analytics.mjs:21`、路由基线 `opensips.cfg:307–315`)。若本地转发失败且未实际发包,仍可能计入“实际发出的线路尝试”。
|
||||||
|
- `projectAnalytics():109–115` 把“当前所有leg结束”视为原始呼叫结束,没有业务呼叫关闭/不再重试事实。第一条失败、第二条尚未生成的间隙,原始视角提前判F。
|
||||||
|
- 路由暂为单attempt=1,这是第12节已经承认的范围限制;现有多attempt单测证明能聚合两条记录,不能证明整个重试生命周期已支持。
|
||||||
|
- `observeActive()` 对MI中消失的呼叫没有UNKNOWN或对账处理;只有START或丢失END的记录可长期停在active/P。
|
||||||
|
|
||||||
|
**整改:**补充 `CALL_CLOSED/retryExhausted`、`ATTEMPT_DISPATCHED`、本地发送失败及终态对账契约。线路分母用可核验的实际派发事实;准备路由和尝试派发分开。原始呼叫只有被权威关闭且全部尝试判定完成时才判F。MI消失只表示需核对,不直接判业务超时;找不到终态依据转数据未知,并保留差异清单。
|
||||||
|
|
||||||
|
**关闭标准:**CRA-015/017/020;本地relay失败原始T=1、落地T按是否真实发送判断;重试间隙不提前判F;丢失END后不永久伪装处理中。真实重试启用前必须完成对应采集验收,不扩大当前路由功能范围。
|
||||||
|
|
||||||
|
### R07|P2|身份、版本、号码标准化与对账字段缺少可执行契约
|
||||||
|
|
||||||
|
**对应原文:**第3.1、6、7节。**证据:源码确认。**
|
||||||
|
|
||||||
|
事件接口没有显式nodeId、bootId、事务方法/CSeq/branch、receivedAt、schemaVersion;日志解码时固定version=1、sequence=0。callUid由Call-ID、From-tag及OpenSIPS启动时间组合后哈希,并不是只用Call-ID,但仍缺少显式节点与客户隔离及事务重传/鉴权续发规则。不能因使用哈希就认定全球唯一。
|
||||||
|
|
||||||
|
状态和桶没有metricVersion/analysisRevision列,桶hash也不包含版本。号码直接使用caller/landingCaller,没有原值、规范值和规则版本的明确分离。`RECONCILE`虽在类型中存在,当前采集/Worker未见实际最终话单对账生产者;详情让用户按Call-ID查找,尚无稳定话单关联键。
|
||||||
|
|
||||||
|
**整改:**定义事件JSON Schema与日志映射,增加生产身份/事务/阶段/时间字段及规范化规则。保存原号码、用于分组的规范号码和规则版本;空维度采用文档化非空哨兵。增加事实修订与聚合版本,定义callUid/attemptId到原始话单的显式关联;来源无法核实时保留“未关联”,禁止Call-ID单字段猜配。旧固定6秒CDR继续禁止用于时长校准。
|
||||||
|
|
||||||
|
**关闭标准:**同号不同客户、跨节点相同Call-ID、重传、鉴权续发、主叫改写、升级前后重放均不串数;版本升级能并行核对和回退。
|
||||||
|
|
||||||
|
### R08|P1|历史无覆盖与空窗口未区分,详情也未保持同一快照
|
||||||
|
|
||||||
|
**对应原文:**第3.5、4、5、7节及第12节补充。**证据:源码确认。**
|
||||||
|
|
||||||
|
- `overview()` 仅返回通用historyNotice;不按captureSince和缺口检查查询窗口。新采集前的窗口可返回T=0和LIVE,不能据此区分“没有呼叫”和“没有采集覆盖”。
|
||||||
|
- overview使用一次数据库事务读取列表和趋势,这是已有正确基础;但asOf在查询后生成,详情另开查询,其rows与breakdown也不在同一快照。使用相同from/to只能保证发起窗口相同,不能冻结后续状态变更。
|
||||||
|
- `detail()` 的分布直接 `LIMIT 500`,无ORDER BY、total或truncated字段,超过500组合会静默截断。
|
||||||
|
- 列表后的客户、号码排序没有完整稳定的唯一兜底键,同号跨客户且指标相同时,分页稳定性需要补足。
|
||||||
|
|
||||||
|
**整改:**增加按查询窗口计算的coverage及各指标可用性;无覆盖返回null/NO_COVERAGE,不把未知历史展示成0。建议同一响应内生成总览与选中号码详情,或使用服务端短期snapshotId缓存聚合结果;明确“发起时间窗口”和“状态截至快照”的区别。详情查询也使用一致事务。分布改为稳定分页,或返回total/truncated并禁止将截断数据解释为完整总量。
|
||||||
|
|
||||||
|
第12节关于“卡片/趋势使用业务筛选,样本/比例/仅告警只筛号码列表”的调整合理,页面已有提示,可保留;但须把baseFilters与numberFilters写进正式API契约,避免与第3.5节混淆。
|
||||||
|
|
||||||
|
**关闭标准:**CRA-017/018/019/024;采集生效前、跨覆盖边界、非整分钟窗口、跨日、并发状态更新及>500组合均能正确解释并对账。
|
||||||
|
|
||||||
|
### R09|P1|告警规则混合持续计数,且告警范围与查询范围不一致
|
||||||
|
|
||||||
|
**对应原文:**第8、12节。**证据:源码确认。**
|
||||||
|
|
||||||
|
- `analytics-alerts.ts:26–31` 每个“客户+号码+视角”只有一个streak;“低接通率→低总体应答率→低接通率”会累计成三次,即使没有同一规则连续满足。
|
||||||
|
- 告警仅按号码全线路、最近15分钟计算;API按客户+号码附加ACTIVE告警,没有检查所选历史窗口、落地网关、地区等范围。筛选某条正常线路仍可能显示另一条线路造成的号码级告警。
|
||||||
|
- Worker在degraded时连评估与过期处理一起跳过,旧ACTIVE可持续保留;API未按更新时间或查询窗口过滤。健康恢复前不能将旧快照继续解释为当前业务异常。
|
||||||
|
- 当前实现有连续三次触发/恢复,但没有独立规则状态、明确冷却期、恢复阈值迟滞;同一窗口可每15秒重复评估,是否算独立持续证据未定义。
|
||||||
|
|
||||||
|
**整改:**按ruleId、客户、号码、视角、规则作用域和规则版本维护状态,分别记录触发计数、恢复计数、冷却、触发/恢复阈值与评估快照。采集异常时转SUSPENDED/STALE,保留历史原因但不继续当有效业务告警。若第一期只支持全线路15分钟号码级告警,应明确标注该作用域;历史或线路筛选下不冒充当前筛选结果的异常。
|
||||||
|
|
||||||
|
**关闭标准:**交替命中规则不误累计;同规则连续达到条件才触发;健康异常时暂停业务判断;查询历史/单线路时告警作用域清楚;仍不发送外部通知或自动处置号码。
|
||||||
|
|
||||||
|
### R10|P2|页面与API有确定的契约差异,详情功能不完整
|
||||||
|
|
||||||
|
**对应原文:**第5、6节。**证据:源码确认;本轮未做浏览器渲染验收。**
|
||||||
|
|
||||||
|
- 页面用countColumns生成排序项,其中包含 `notConnectedCalls`(`CallerAnalyticsPage.jsx:13,65`),API白名单没有该字段(`caller-analytics.service.ts:26`),选择该排序会被400拒绝。
|
||||||
|
- 页面未展示API已有的metricVersion、lagMs、dataThrough、captureSince;仅展示通用质量状态和asOf。
|
||||||
|
- 供应商筛选在API有vendorId,页面没有;详情缺少选中号码的独立分钟趋势、180/183/直接成功统计分布、未接通原因聚合、接通未应答最终原因聚合和稳定CDR详情链接。
|
||||||
|
- 详情用单一 `finalCode` 显示“最终结果”,采集挂断钩子填200、超时填408,可能把对话结束原因与初始INVITE最终响应混为一谈。
|
||||||
|
- 页面详情遇到“仅可信正时长RECONCILE补C、无180/183/2xx时间”时仍显示“未达接通”,与C/A计数不符。
|
||||||
|
- 自动刷新为请求完成后再等4000ms,与原文“每5秒刷新”不同;失败保留旧数据、筛选变化取消旧请求的逻辑可保留。
|
||||||
|
|
||||||
|
**整改:**共享字段/排序契约,支持N排序或去掉无效选项;补齐版本、覆盖时间、延迟未知说明。详情分离 `initialInviteFinalCode`、`terminationReason`、`localFailureCode`,显示真实connectedEvidence。补齐承诺功能;若分期延后,必须列出未交付项,不能写“页面已完成”概括替代。今日边界建议由服务端计算,统一Asia/Shanghai展示;自定义输入标明时区。保留宽表、业务数字直显、筛选和详情刷新语义。
|
||||||
|
|
||||||
|
**关闭标准:**逐一操作所有排序、筛选、分页、详情与失败重试;采集缺口和CDR补计显示一致;网络面板、真实API/DB与截图留证,静态页面不算验收。
|
||||||
|
|
||||||
|
### R11|P2|实时SLO、查询成本和容量限制需要实测拆解
|
||||||
|
|
||||||
|
**对应原文:**第6、9、10、12节。**证据:源码确认,容量待实测。**
|
||||||
|
|
||||||
|
`analytics-main.ts` 将日志采集、串行消费、全量MI查询、活跃呼叫观察、告警循环放在同一循环;`observeActive()` 固定LIMIT 2000且无遍历游标,超过范围可能长期漏观察尾部呼叫。时长大于0后不再更新,进行中的talkMs只是首次正时长观察值,不是持续实时增长时长。
|
||||||
|
|
||||||
|
API overview每次读取最多10001号码后在应用内过滤排序,summary/numbers/trends均复用完整overview;10000上限限制结果数量,不等于限制数据库扫描成本。7天分钟趋势可达到10080点,需验证前端渲染成本。告警每号码还执行多次查询。状态更新会对已有多个投影反复撤销/添加桶贡献,吞吐不能仅按事件数量估计。
|
||||||
|
|
||||||
|
**整改:**先记录峰值CPS、事件/呼叫数、号码与维度基数、平均通话时长、迟到分布、保留期和资源余量,再确定索引与任务频率。将采集、消费、MI观察和告警按职责解耦或设独立预算;活跃观察游标遍历并暴露遗漏量。API用实际SQL执行计划核验,再决定数据库分页/汇总、短缓存和趋势降采样,缓存键必须含授权范围、筛选及快照/版本。
|
||||||
|
|
||||||
|
容量估算统一使用:`事件写入率≈CPS×每呼叫事件数`;留存空间使用实测平均事件字节、索引及复制/备份开销。消费恢复能力必须大于持续新增速率,不能只测空队列瞬时吞吐。
|
||||||
|
|
||||||
|
**关闭标准:**保留原目标“约定负载下证据可用至页面可见P95≤5秒”,分解采集、消费、查询、轮询等待与渲染延迟;本轮不给机器容量承诺。若轮询无法满足整体目标,调整轮询或推送,不能把SLO起点偷换为API返回时刻。记录原呼叫建立、计费延迟与资源回归。
|
||||||
|
|
||||||
|
### R12|P1|发布脚本的完整回滚与再次发布行为需要补齐
|
||||||
|
|
||||||
|
**对应原文:**第9、12节。**证据:源码确认,未执行部署。**
|
||||||
|
|
||||||
|
`infra/server-b/caller-analytics/deploy.sh` 已有备份、候选配置校验、空闲通话检查、健康检查和加法迁移保留,这些应保留。但仍存在:
|
||||||
|
|
||||||
|
1. 已含CRA1标记时直接沿用旧采集配置(41–46行附近),后续采集器修复不会自动进入生效配置;须有明确instrumentationVersion和差异审核流程。
|
||||||
|
2. 初始rollback未恢复rsyslog配置/env及旧分析进程;并行提交 `d786165` 已补恢复配置、重启rsyslog及按原active状态拉起分析服务。仍需恢复原enable/disable状态、验证每个失败点,不能仅凭代码补齐就认定已演练通过。
|
||||||
|
3. 部分配置修改发生在changed=1之前,之后验证失败可能不进入完整回滚;只能证明主指针回退,不能证明所有步骤原子恢复。
|
||||||
|
4. 开始时一次“当前无呼叫”检查与实际重启之间存在新呼叫进入窗口;这不是持续排空保证。
|
||||||
|
5. 脚本末尾只检查服务active/API ready/commit,未验证分析权限、心跳、采集覆盖、端到端数据和持久化。不能把这些检查当完整功能验收。
|
||||||
|
|
||||||
|
**整改:**使用可重复执行的版本化采集配置生成;逐步登记变更与回滚动作,包括原配置存在性、服务enable/active状态、rsyslog重载、入口开关和DB版本。发布前后分别检查,并采用维护窗口/受控排空防止中途新呼叫。回滚默认保留分析事实与新表;恢复应用/配置/消费者组合,不回滚或删除计费账务。增加分析端到端冒烟门槛并验证服务自动启动关系。
|
||||||
|
|
||||||
|
**关闭标准:**在测试环境分别注入迁移后、rsyslog校验后、OpenSIPS安装后、API切换后、分析服务启动后的失败;每个步骤均能恢复原服务组合,保留证据且不影响既有账务。
|
||||||
|
|
||||||
|
### R13|P1|“已实施”与“已验收”记录没有闭合
|
||||||
|
|
||||||
|
**对应原文:**文首、第2、10、11、12节;主设计与实施状态索引。**证据:源码/本地报告目录确认。**
|
||||||
|
|
||||||
|
原文用实施更新和第12节解释前文历史状态,但文首仍写全部待实施,第11节仍写仅文档交付;主设计V2第1.1节仍写实现待开始。`IMPLEMENTATION_STATUS.md` 顶部写测试发布验证中,测试计划仍注明CRA-001至024全部待执行。本轮在 `tests/reports` 未检索到专项真实验收报告;这只能说明本地未归档,不能断言远端从未执行。
|
||||||
|
|
||||||
|
初始 `caller-analytics-real.mjs` 主要断言六个本机SIP场景的聚合值、详情、401和非法参数400,“real DB states”主要来自API详情。收尾时并行候选代码已增加独立states SQL求和与API汇总核对、事件重复消费保持不变的断言;本轮只检查代码,未执行这些新增步骤,也未看到其真实报告。普通view客户越权、采集丢失、计费不重复、P95、真实最终CDR校正、故障恢复与发布回滚仍无完整证据;SIP脚本未带SDP。因此不可把“六场景通过”写成24项全通过或媒体/计费全链路验收。
|
||||||
|
|
||||||
|
**整改:**原文修订为设计文档V1.2,保留V1.1业务口径,增加明确“设计/本地实现/测试部署/真实验收/正式启用”状态表;原第11节移到历史记录。建立需求—实现—CRA用例—报告路径—提交/环境对应台账;每项标记通过、失败、未执行、范围外,不以测试文件存在代替执行。若真实证据在远端,核验后脱敏归档。
|
||||||
|
|
||||||
|
**关闭标准:**24项原CRA逐条有对应结果;新增边界有证据;发布报告包含commit、配置hash、迁移版本、环境、执行时间、回滚点及未通过项。没有证据的项继续标未验收。
|
||||||
|
|
||||||
|
## 4. 推荐目标契约与实施顺序
|
||||||
|
|
||||||
|
### 4.1 最小充分整改范围
|
||||||
|
|
||||||
|
保留现有“OpenSIPS观察 → 本机留存 → 独立Stream → 分析Worker → MySQL事实/状态/分钟桶 → API/页面”架构。先修事实与状态,不以重写整个平台、引入新消息中间件或修改计费作为前提。
|
||||||
|
|
||||||
|
最小需要触及:采集脚本与版本、分析Worker/模型/存储、加法数据库迁移、API/页面契约、专项测试、部署/回滚脚本与文档。是否增加独立Redis实例由R05容量及故障隔离验证决定,作为需提前报告的范围扩展。
|
||||||
|
|
||||||
|
固定6秒旧CDR属于独立计费/话单问题。本轮只规定禁止其作为统计权威时长;不自动重算历史费用、补扣费或变更账务。后续若整改该问题,须另列范围、影响评估与验收。
|
||||||
|
|
||||||
|
### 4.2 建议新增/明确的数据字段
|
||||||
|
|
||||||
|
下表为整改目标,不表示当前已存在。物理表拆分由实施时评审,优先加法兼容。
|
||||||
|
|
||||||
|
| 对象 | 最小补充内容 | 作用 |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| 事件事实 | schemaVersion、nodeId、bootId、producerSequence、occurredAt、receivedAt、初始事务身份、dispatch结果、source/evidenceId | 稳定关联、去重、追踪顺序与缺口 |
|
||||||
|
| 业务呼叫 | callUid、客户快照、businessStartedAt、closedAt、retryExhausted、callCloseEvidence | 原始视角的分母与结束条件 |
|
||||||
|
| 线路尝试 | attemptId、attemptStartedAt、dispatchedAt、first180At/183At、initialInviteFinalCode、terminationReason | 区分准备、发送、进展、最终响应与对话结束 |
|
||||||
|
| 时长证据 | talkStartedAt、talkEndedAt、talkDurationMs、durationStatus、sourceRevision、correctedAt | 暂定/最终/无效/未知及乱序校正 |
|
||||||
|
| 完整性 | 节点、起止序列/时间、gapId、影响范围、恢复/未恢复状态、处理水位 | 不以Worker活跃冒充数据完整 |
|
||||||
|
| 状态/分钟桶 | metricVersion、analysisRevision、numberNormalizationVersion、coverage信息 | 版本隔离、可回算与可回退 |
|
||||||
|
| 关联/告警 | 稳定CDR关联键;ruleId、scopeHash、ruleVersion、snapshotId、冷却/恢复状态 | 可追溯且不会混用范围 |
|
||||||
|
|
||||||
|
保留 `metricVersion=caller-analytics-v1.1` 表示业务公式未改变;增加analysisRevision表示算法/证据修复批次。若未来真正改变指标定义,才升级metricVersion。文档版本与业务口径版本不能混为一谈。
|
||||||
|
|
||||||
|
API建议在现有字段之外明确:`snapshotId`、`coverageStatus`、`captureFrom`、`gapIntervals`、`metricAvailability`、`provisionalAnsweredCalls`、`durationUnknownCalls`、`baseFilters`、`numberFilters`及分页完整性。`asOf`标数据库状态快照时间,`dataThrough`标连续覆盖水位,`lagMs`为测量值或null。
|
||||||
|
|
||||||
|
### 4.3 工作包与依赖
|
||||||
|
|
||||||
|
| 工作包 | 优先级/涉及项 | 产物 | 前置依赖与完成门槛 |
|
||||||
|
| --- | --- | --- | --- |
|
||||||
|
| W0 基线冻结 | P1,R13 | V1.2文档、需求/用例矩阵、实际部署只读核对表 | 确定生效配置、版本、授权测试范围,不实施业务变更 |
|
||||||
|
| W1 证据与状态 | P1,R01/R02/R03/R06;带R07字段 | 采集契约、确定性归约、真实会话时长验证 | 无法核验时长来源时,A不能宣称完整;单元边界先通过 |
|
||||||
|
| W2 完整性与可靠性 | P1,R04/R05 | gap、水位、补送/死信/留存、资源隔离 | 必须完成真实Redis/MySQL恢复与计费隔离验证 |
|
||||||
|
| W3 API/页面/告警 | P1/P2,R08/R09/R10/R11 | 快照、覆盖、告警状态机、契约对齐、性能报告 | 依赖W1/W2可信事实;页面不能用模拟数据验收 |
|
||||||
|
| W4 迁移/发布/验收 | P1,R12/R13 | 灰度发布、故障回滚记录、CRA证据和关闭台账 | P1全部关闭,P2未完成项须明确限制,不笼统全量验收 |
|
||||||
|
|
||||||
|
W0/W1是首批最小开工范围;W2前不得扩大长期采集留存或据此承诺“计费隔离”。数据库索引、批量策略、轮询/推送选择在实测后确定。本轮未获得当前规模、可用测试时段及现场依赖,不给出缺乏依据的人日或上线日期承诺。
|
||||||
|
|
||||||
|
## 5. 数据修复、迁移与回滚方案
|
||||||
|
|
||||||
|
1. **只读盘点与备份:**核对当前release、OpenSIPS/Redis/MySQL版本及生效参数、分析表行数/体积、Stream长度/PEL、日志及游标、采集生效时间、账务基线;备份配置与分析证据。已有发布状态先记录再变更。
|
||||||
|
2. **兼容加法迁移:**增加证据/覆盖/版本列或新表,不覆盖原始events,不删除既有raw/rated CDR;保留旧读路径。迁移前核对现有数据、唯一键及索引成本。
|
||||||
|
3. **影子重放:**用新的analysisRevision写入独立状态/桶命名空间。按客户、号码、视角、发起分钟对比旧新T/C/A/F/P/U,输出每项差异对应的事件证据与修复原因。
|
||||||
|
4. **不能恢复的历史数据:**旧事件缺少事务/时长/覆盖证据时,不因新代码部署而变成可信数据;登记未知/无覆盖,明确新证据生效边界。旧固定6秒不参与补算A。
|
||||||
|
5. **灰度读取:**先内部测试客户及受控SIP入口,再扩大授权客户范围;先观察数据质量,业务告警默认抑制,满足完整性门槛后启用。测试呼叫限定已授权环境和目标,不发外部通知。
|
||||||
|
6. **验收切换:**完成原CRA及新增用例、数据重放一致性、既有呼叫/计费回归、延迟和资源验证,再切换读取版本,归档数据覆盖起点和发布记录。
|
||||||
|
7. **回滚:**关闭业务告警和新读入口,切回上一个匹配的应用/配置/数据读版本,恢复原服务组合。存在采集性能风险时再关闭采集钩子,登记停采缺口;保留事实和新表以便修复,不全库恢复覆盖期间产生的新话单与账务。
|
||||||
|
|
||||||
|
回算、清理和去重必须共用保留期契约:允许重放的时间范围不能超过去重身份的保留范围;若超出,只允许写入新的重建版本,不能重灌在线聚合造成重复累计。
|
||||||
|
|
||||||
|
## 6. 验收矩阵与证据要求
|
||||||
|
|
||||||
|
原CRA-001至CRA-024继续有效,本次不修改其业务预期。已有单测通过仅说明部分纯计算行为正确。
|
||||||
|
|
||||||
|
| 验收组 | 关联原用例 / 新增建议编号 | 必须提供的证据 |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| 接通/应答基础语义 | CRA-001~009、011~014、018、021~023 | 真实SIP报文与事务匹配、会话时长来源、API快照、状态及桶SQL |
|
||||||
|
| 去重与多视角 | CRA-010、015~017 | 同一业务呼叫多尝试、重传/鉴权续发、同号不同客户与重领;DB及账务不重复 |
|
||||||
|
| 历史/故障/未知 | CRA-019/020/024 | 缺过程终态、缺整个呼叫、失联/轮转/重启、补送溢出及覆盖提示 |
|
||||||
|
| 时长来源边界 | CRA-025(建议新增) | 200无ACK、协商失败、早期媒体与真实500ms/0ms,区分可信/暂定/未知 |
|
||||||
|
| 最终校正乱序 | CRA-026(建议新增) | 本文R03两种顺序、重复、迟到END/ACCEPTED,终态和聚合完全一致 |
|
||||||
|
| 采集完整性 | CRA-027(建议新增) | Worker存活而生产端停采、日志积压、gap跨重启、水位乱序不误报LIVE |
|
||||||
|
| 留存与毒事件 | CRA-028(建议新增) | XADD后故障、游标恢复、轮转旧文件、坏schema、PEL被裁剪;不静默丢失 |
|
||||||
|
| 原始呼叫关闭 | CRA-029(建议新增) | 未真实出局、重试间隙、丢END后MI消失,不误算落地T和原始F |
|
||||||
|
| 快照与覆盖 | CRA-030(建议新增) | 生效前窗口、跨覆盖、非整分钟、跨日、>500组合及同步详情 |
|
||||||
|
| 告警状态机 | CRA-031(建议新增) | 交替规则、冷却、迟滞、健康暂停、历史/线路筛选作用域 |
|
||||||
|
| 权限与页面 | CRA-032(建议新增) | view但无scope、单客户scope、view_all、401/403;overview/detail/options均校验;全部排序及详情证据显示 |
|
||||||
|
| 容量与隔离 | CRA-033(建议新增) | 按约定CPS/基数/保留期记录端到端P95和原有呼叫/计费指标;活跃观察超2000不漏 |
|
||||||
|
| 发布与回滚 | CRA-034(建议新增) | 发布每个失败点、二次采集脚本升级、服务自动启动、配置与应用版本恢复 |
|
||||||
|
|
||||||
|
真实验收每个用例至少记录:runId、时间/环境、提交与配置hash、脱敏callUid/attemptId/Call-ID、预期计数、SIP与会话证据、API请求/返回、DB事件/状态/分钟桶查询、CDR关联或不能关联的原因、结果与报告路径。涉及计费隔离的用例,还必须独立核对原始话单、计费话单、余额变更和消费去重,而非只看分析API。
|
||||||
|
|
||||||
|
建议统一归档到 `tests/reports/caller-analytics/<runId>/`;真实数据按最小必要原则脱敏。没有覆盖的用例必须显式未执行,不用“正常”或“已支持”代替结果。
|
||||||
|
|
||||||
|
## 7. 原设计文档的逐节修订清单
|
||||||
|
|
||||||
|
此处给出待实施修订,不在本次直接重写原文,避免把建议误记为已落实。
|
||||||
|
|
||||||
|
| 原位置 | 建议修订 |
|
||||||
|
| --- | --- |
|
||||||
|
| 文首、第11/12节 | 当前状态集中成表;第11节移为历史交付记录,第12节事实附环境/时间/证据路径;设计版本V1.2、业务口径仍V1.1 |
|
||||||
|
| 第2节 | 改为“已有实现与未关闭差距”,准确列出分析表/Worker/API/页面,不再统称待新增 |
|
||||||
|
| 第3.1节 | 明确派发边界、业务呼叫关闭、单attempt现状与未来重试前置门槛 |
|
||||||
|
| 第3.3/3.5节 | 区分U、覆盖缺口、A证据不完整,明确null/暂定规则与baseFilters/numberFilters |
|
||||||
|
| 第3.4节 | 写明经过验证的时长来源、权威等级和校正版本,去掉以标签或差值自动认定“实际”的歧义 |
|
||||||
|
| 第4节 | 增加确定性归约、业务关闭、水位和时钟域;明确快照不等于窗口结束时间 |
|
||||||
|
| 第5节 | 增加页面已交付/待补列表;补供应商、详情、证据、延迟与覆盖展示 |
|
||||||
|
| 第6节 | 把建议事件名与实际START/ATTEMPT等做映射;补字段Schema、版本唯一键、补送/死信/保留期与对账生产者 |
|
||||||
|
| 第7节 | 规定新覆盖生效时间、不可修复历史规则、算法修复批次及影子回算方法 |
|
||||||
|
| 第8节 | 逐规则持续计数、范围、评估周期、冷却/迟滞、健康暂停及恢复条件 |
|
||||||
|
| 第9节 | 引用逐步发布/回滚清单和资源隔离门槛,不以服务active等同功能正常 |
|
||||||
|
| 第10节 | 保留24项原用例,增补CRA-025~034并链接真实报告;P95仍为待测目标 |
|
||||||
|
| 相关索引 | 同步主设计V2第1.1节、IMPLEMENTATION_STATUS和TEST_PLAN_AND_CASES,历史状态不覆盖当前状态 |
|
||||||
|
|
||||||
|
## 8. 本轮验证记录与复现方法
|
||||||
|
|
||||||
|
### 8.1 已执行的专项测试
|
||||||
|
|
||||||
|
执行本地现有依赖,不安装/升级依赖:
|
||||||
|
|
||||||
|
```text
|
||||||
|
node node_modules/vitest/vitest.mjs run packages/database/src/caller-analytics.spec.ts apps/api/src/modules/caller-analytics/caller-analytics.service.spec.ts
|
||||||
|
```
|
||||||
|
|
||||||
|
本轮2026-08-31 17:32(Asia/Shanghai)结果:2个文件通过,16项测试通过;其中模型13项、查询参数3项。未在本轮重跑全量114项、构建或现场测试,因此不能引用旧全量结果作本轮验收。
|
||||||
|
|
||||||
|
### 8.2 纯计算复现
|
||||||
|
|
||||||
|
本轮用本地TypeScript转译源码后以内存模块加载 `foldAnalytics/projectAnalytics`,不实例化PrismaClient、不连接Redis、不读环境密钥、不调用SIP。以下等价复现可在项目支持TypeScript导入的Node/tsx环境执行;事件均为构造输入,用于证明算法边界,不能代替真实业务验收。
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
import { foldAnalytics, projectAnalytics } from './packages/database/src/caller-analytics-model.ts';
|
||||||
|
const t = Date.UTC(2026, 7, 31);
|
||||||
|
const event = (kind, extra = {}) => ({
|
||||||
|
eventId: kind, callUid: 'review-u1', callId: 'review-c1', attemptId: '1',
|
||||||
|
kind, at: t + 1000, startedAt: t,
|
||||||
|
customerId: 'review-c', customerGatewayId: 'cg', vendorId: 'v', vendorGatewayId: 'vg',
|
||||||
|
caller: '123', landingCaller: '456', callee: '789', city: 'x', carrier: 'UNKNOWN',
|
||||||
|
code: 0, talkMs: null, source: 'opensips', sequence: 0, ...extra
|
||||||
|
});
|
||||||
|
const run = events => projectAnalytics(events.reduce((c, e) => foldAnalytics(c, e), null))[0];
|
||||||
|
const attempt = event('ATTEMPT');
|
||||||
|
const accepted = event('ACCEPTED', { code: 200 });
|
||||||
|
const provisional = event('DURATION', { at: t + 4000, talkMs: 3000 });
|
||||||
|
const finalZero = event('RECONCILE', { at: t + 3000, talkMs: 0, source: 'verified-dialog-final' });
|
||||||
|
const end = event('END', { code: 487, source: 'verified-dialog-final' });
|
||||||
|
console.log(run([end])); // 当前F=1,U=0
|
||||||
|
console.log(run([attempt, event('UNKNOWN'), end])); // 当前F=1,U=0
|
||||||
|
console.log(run([attempt, end, event('UNKNOWN')])); // 当前F=0,U=1
|
||||||
|
console.log(run([attempt, accepted, provisional, finalZero])); // 当前A=1,talkMs=3000
|
||||||
|
console.log(run([attempt, accepted, finalZero, provisional])); // 当前A=0,talkMs=0
|
||||||
|
```
|
||||||
|
|
||||||
|
另两组本轮纯计算观察:第一尝试失败且尚无业务关闭事实时原始F=1;只有ACCEPTED及32秒后的对话END、无独立会话确认事实时A=1、talkMs=32000。前者证明多attempt生命周期仍不足;后者证明当前模型依赖信令时间差,不能单独证明实际通话成立。
|
||||||
|
|
||||||
|
### 8.3 本轮没有完成的验证
|
||||||
|
|
||||||
|
未读取线上活动配置、未执行真实MySQL/Redis查询与事务重放、未运行SIP脚本、未验收UI渲染、未做容量/故障/回滚演练。本文件是审查与整改方案,所有整改工作包尚待实施,不应据此标记缺陷已修复或功能已上线。
|
||||||
|
|
||||||
|
## 9. 主要依据索引
|
||||||
|
|
||||||
|
行号对应本次源码基线,后续修改后以函数/路由名及提交为准。
|
||||||
|
|
||||||
|
| 本地文件 | 核查用途 |
|
||||||
|
| --- | --- |
|
||||||
|
| [原设计](CALLER_REALTIME_ANALYTICS_DESIGN.md) | 业务语义、实现补充、24项CRA与交付边界 |
|
||||||
|
| [状态模型](../packages/database/src/caller-analytics-model.ts) | 60–129行,事实合并、时长校正、投影、比率 |
|
||||||
|
| [存储事务](../packages/database/src/caller-analytics-store.ts) | consume/bucket/state,事务去重和贡献差值 |
|
||||||
|
| [分析Worker](../apps/worker-cdr/src/analytics-main.ts) | 18–98行,日志/Stream/MI/健康与恢复 |
|
||||||
|
| [告警](../apps/worker-cdr/src/analytics-alerts.ts) | 3–35行,窗口、规则状态及过期 |
|
||||||
|
| [采集脚本](../scripts/instrument-caller-analytics.mjs) | 11–38行,身份、挂钩、阶段和结束事件 |
|
||||||
|
| [API](../apps/api/src/modules/caller-analytics/caller-analytics.service.ts) | parseAnalyticsQuery/overview/detail/options,筛选、权限、快照、分布及覆盖 |
|
||||||
|
| [页面](../apps/web/src/pages/CallerAnalyticsPage.jsx) | 排序、刷新、展示与详情契约 |
|
||||||
|
| [迁移](../prisma/migrations/20260831090000_caller_analytics/migration.sql) | 表、索引、版本字段与权限 |
|
||||||
|
| [发布脚本](../infra/server-b/caller-analytics/deploy.sh) | 备份、迁移、配置切换、回滚与发布门槛 |
|
||||||
|
| [Redis基线](../infra/server-b/s04/redis/redis-lisglosips.conf) | 本地持久化/内存配置参照,不代表现场 |
|
||||||
|
| [真实测试入口](../tests/api/caller-analytics-real.mjs) / [SIP脚本](../tests/api/caller-analytics-sip.py) | 六场景覆盖范围及缺失证据 |
|
||||||
|
| [实施状态](../IMPLEMENTATION_STATUS.md) / [主设计V2](../SOFTSWITCH_PLATFORM_DESIGN_V2.md) / [测试计划](TEST_PLAN_AND_CASES.md) | 状态一致性与验收索引 |
|
||||||
|
|
||||||
|
外部依据使用本轮查阅的RFC及OpenSIPS/Redis官方文档;不据此猜测现场软件版本或配置。所有方案中的生产变更、受控呼叫、数据回算和发布,都应在后续实施任务的实际授权范围内执行。
|
||||||
@@ -1977,7 +1977,7 @@ corepack pnpm@10.33.0 exec vitest run apps/worker-recording/src/transfer.spec.ts
|
|||||||
|
|
||||||
## 8A. 主叫号码实时分析验收设计索引(2026-08-31)
|
## 8A. 主叫号码实时分析验收设计索引(2026-08-31)
|
||||||
|
|
||||||
[主叫号码实时分析V1.1](CALLER_REALTIME_ANALYTICS_DESIGN.md)第10节定义CRA-001至CRA-024,覆盖180/183业务接通、正时长应答、总体应答率A/T、已接通应答率A/C、直接200/缺少100/零时长/亚秒时长、待接通与未知、线路重试、重复乱序、历史缺口、权限隔离和故障恢复。该组用例目前全部待执行,不计入既有通过数量;最终验收必须有真实SIP、API、数据库及CDR对账证据。
|
[主叫号码实时分析V1.2](CALLER_REALTIME_ANALYTICS_DESIGN.md)第10节及第13节定义CRA-001至CRA-034。2026-09-01已完成状态/公式/乱序/告警/查询契约的自动化回归(全量Vitest 29文件/118项),并以6通受控真实SIP完成180、183、直接失败、直接200、ACK后1.5秒/300ms通话、SDP/RTPEngine、真实API、MySQL状态/分钟桶、重放幂等和RBAC验收;Redis重启、毒事件死信、覆盖降级/恢复、维护任务及真实页面也已验证。结果见[整改交付报告](CALLER_REALTIME_ANALYTICS_DELIVERY_20260901.md)和[机器证据JSON](../tests/reports/caller-analytics-real-20260901.json)。未按约定峰值负载执行容量与页面端到端P95统计,因此CRA容量项保持“未执行/不作容量承诺”;真实多线路重试启用前仍须补做对应实际路由验收。
|
||||||
|
|
||||||
## 9. 缺陷分级
|
## 9. 缺陷分级
|
||||||
|
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ required_services=(
|
|||||||
tailscaled
|
tailscaled
|
||||||
mysql
|
mysql
|
||||||
redis-server
|
redis-server
|
||||||
|
lisglosips-caller-analytics-redis
|
||||||
postgresql
|
postgresql
|
||||||
nginx
|
nginx
|
||||||
opensips
|
opensips
|
||||||
@@ -57,8 +58,8 @@ echo "== services =="
|
|||||||
systemctl is-active "${required_services[@]}"
|
systemctl is-active "${required_services[@]}"
|
||||||
|
|
||||||
echo "== timers =="
|
echo "== timers =="
|
||||||
systemctl is-active lisglosips-backup.timer lisglosips-recording-finalize.timer
|
systemctl is-active lisglosips-backup.timer lisglosips-recording-finalize.timer lisglosips-caller-analytics-maintenance.timer
|
||||||
systemctl is-enabled lisglosips.target lisglosips-backup.timer lisglosips-recording-finalize.timer \
|
systemctl is-enabled lisglosips.target lisglosips-backup.timer lisglosips-recording-finalize.timer lisglosips-caller-analytics-maintenance.timer \
|
||||||
lisglosips@api lisglosips@cdr-worker lisglosips@recording-worker lisglosips@config-publisher
|
lisglosips@api lisglosips@cdr-worker lisglosips@recording-worker lisglosips@config-publisher
|
||||||
|
|
||||||
echo "== api ready =="
|
echo "== api ready =="
|
||||||
|
|||||||
@@ -15,25 +15,51 @@ test ! -e "$new"
|
|||||||
curl -fsS -H 'Content-Type: application/json' --data '{"jsonrpc":"2.0","id":1,"method":"dlg_list","params":[]}' http://127.0.0.1:8888/mi | python3 -c 'import sys,json; assert json.load(sys.stdin)["result"]["Dialogs"]==[], "Active calls: retry after calls end"'
|
curl -fsS -H 'Content-Type: application/json' --data '{"jsonrpc":"2.0","id":1,"method":"dlg_list","params":[]}' http://127.0.0.1:8888/mi | python3 -c 'import sys,json; assert json.load(sys.stdin)["result"]["Dialogs"]==[], "Active calls: retry after calls end"'
|
||||||
systemctl start lisglosips-backup.service
|
systemctl start lisglosips-backup.service
|
||||||
install -d -m 0700 "$backup"
|
install -d -m 0700 "$backup"
|
||||||
|
if systemctl --quiet is-active lisglosips-caller-analytics-redis.service && test -f /etc/lisglosips/caller-analytics-redis.secret; then
|
||||||
|
export REDISCLI_AUTH
|
||||||
|
REDISCLI_AUTH=$(cat /etc/lisglosips/caller-analytics-redis.secret)
|
||||||
|
redis-cli -h 127.0.0.1 -p 6381 --no-auth-warning SAVE >/dev/null
|
||||||
|
unset REDISCLI_AUTH
|
||||||
|
cp -a /var/lib/redis-caller-analytics "$backup/redis-caller-analytics"
|
||||||
|
fi
|
||||||
cp -a /etc/opensips/opensips.cfg "$backup/opensips.cfg"
|
cp -a /etc/opensips/opensips.cfg "$backup/opensips.cfg"
|
||||||
|
checkpoint_file=/var/lib/lisglosips/caller-analytics-offset.json
|
||||||
|
if test -f "$checkpoint_file"; then cp -a "$checkpoint_file" "$backup/caller-analytics-offset.json"; fi
|
||||||
printf '%s\n' "$old" > "$backup/previous-release"
|
printf '%s\n' "$old" > "$backup/previous-release"
|
||||||
for file in /etc/lisglosips/caller-analytics.env /etc/rsyslog.d/35-caller-analytics.conf; do
|
managed_files=(/etc/lisglosips/caller-analytics.env /etc/rsyslog.d/35-caller-analytics.conf /etc/redis/caller-analytics.conf /etc/systemd/system/lisglosips-caller-analytics-redis.service /etc/logrotate.d/lisglosips-caller-analytics /etc/systemd/system/lisglosips-caller-analytics-maintenance.service /etc/systemd/system/lisglosips-caller-analytics-maintenance.timer /etc/systemd/system/lisglosips.target)
|
||||||
|
for file in "${managed_files[@]}"; do
|
||||||
if test -f "$file"; then cp -a "$file" "$backup/$(basename "$file")"; fi
|
if test -f "$file"; then cp -a "$file" "$backup/$(basename "$file")"; fi
|
||||||
done
|
done
|
||||||
changed=0
|
changed=0
|
||||||
was_running=$(systemctl is-active lisglosips@caller-analytics || true)
|
was_running=$(systemctl is-active lisglosips@caller-analytics || true)
|
||||||
|
was_enabled=$(systemctl is-enabled lisglosips@caller-analytics 2>/dev/null || true)
|
||||||
|
redis_was_running=$(systemctl is-active lisglosips-caller-analytics-redis 2>/dev/null || true)
|
||||||
|
redis_was_enabled=$(systemctl is-enabled lisglosips-caller-analytics-redis 2>/dev/null || true)
|
||||||
|
maintenance_was_running=$(systemctl is-active lisglosips-caller-analytics-maintenance.timer 2>/dev/null || true)
|
||||||
|
maintenance_was_enabled=$(systemctl is-enabled lisglosips-caller-analytics-maintenance.timer 2>/dev/null || true)
|
||||||
|
secret_existed=0
|
||||||
|
test -f /etc/lisglosips/caller-analytics-redis.secret && secret_existed=1
|
||||||
rollback() {
|
rollback() {
|
||||||
rc=$?
|
rc=$?
|
||||||
if test "$changed" = 1; then
|
if test "$changed" = 1; then
|
||||||
systemctl stop lisglosips@caller-analytics || true
|
systemctl stop lisglosips@caller-analytics || true
|
||||||
|
systemctl stop lisglosips-caller-analytics-maintenance.timer lisglosips-caller-analytics-redis || true
|
||||||
cp -a "$backup/opensips.cfg" /etc/opensips/opensips.cfg
|
cp -a "$backup/opensips.cfg" /etc/opensips/opensips.cfg
|
||||||
for file in /etc/lisglosips/caller-analytics.env /etc/rsyslog.d/35-caller-analytics.conf; do
|
if test -f "$backup/caller-analytics-offset.json"; then cp -a "$backup/caller-analytics-offset.json" "$checkpoint_file"; fi
|
||||||
|
for file in "${managed_files[@]}"; do
|
||||||
if test -f "$backup/$(basename "$file")"; then cp -a "$backup/$(basename "$file")" "$file"; else rm -f "$file"; fi
|
if test -f "$backup/$(basename "$file")"; then cp -a "$backup/$(basename "$file")" "$file"; else rm -f "$file"; fi
|
||||||
done
|
done
|
||||||
ln -sfn "$old" /opt/lisglosips/current
|
ln -sfn "$old" /opt/lisglosips/current
|
||||||
systemctl restart rsyslog || true
|
systemctl restart rsyslog || true
|
||||||
|
systemctl daemon-reload || true
|
||||||
systemctl restart opensips lisglosips@api || true
|
systemctl restart opensips lisglosips@api || true
|
||||||
if test "$was_running" = active; then systemctl start lisglosips@caller-analytics || true; fi
|
if test "$was_running" = active; then systemctl start lisglosips@caller-analytics || true; fi
|
||||||
|
if test "$was_enabled" != enabled; then systemctl disable lisglosips@caller-analytics || true; fi
|
||||||
|
if test "$redis_was_running" = active; then systemctl start lisglosips-caller-analytics-redis || true; fi
|
||||||
|
if test "$redis_was_enabled" != enabled; then systemctl disable lisglosips-caller-analytics-redis || true; fi
|
||||||
|
if test "$maintenance_was_running" = active; then systemctl start lisglosips-caller-analytics-maintenance.timer || true; fi
|
||||||
|
if test "$maintenance_was_enabled" != enabled; then systemctl disable lisglosips-caller-analytics-maintenance.timer || true; fi
|
||||||
|
if test "$secret_existed" = 0; then rm -f /etc/lisglosips/caller-analytics-redis.secret; fi
|
||||||
fi
|
fi
|
||||||
echo "FAILED rc=$rc backup=$backup (additive analytics tables retained)"
|
echo "FAILED rc=$rc backup=$backup (additive analytics tables retained)"
|
||||||
exit "$rc"
|
exit "$rc"
|
||||||
@@ -44,8 +70,7 @@ tar -xzf "$archive" -C "$new"
|
|||||||
printf '%s\n' "$commit" > "$new/.deployed-commit"
|
printf '%s\n' "$commit" > "$new/.deployed-commit"
|
||||||
printf '%s\n' "$old" > "$new/.delta-base-release"
|
printf '%s\n' "$old" > "$new/.delta-base-release"
|
||||||
source_cfg="$backup/opensips.cfg"
|
source_cfg="$backup/opensips.cfg"
|
||||||
if grep -q 'CRA1|' "$source_cfg"; then
|
if grep -q 'CRA_INSTRUMENTATION_VERSION=3' "$source_cfg"; then
|
||||||
# A code-only redeploy keeps previously reviewed instrumentation unchanged.
|
|
||||||
cp "$source_cfg" "$new/opensips-candidate.cfg"
|
cp "$source_cfg" "$new/opensips-candidate.cfg"
|
||||||
else
|
else
|
||||||
/usr/bin/node "$new/scripts/instrument-caller-analytics.mjs" "$source_cfg" "$new/opensips-candidate.cfg"
|
/usr/bin/node "$new/scripts/instrument-caller-analytics.mjs" "$source_cfg" "$new/opensips-candidate.cfg"
|
||||||
@@ -68,27 +93,65 @@ chown syslog:lisglosips /var/log/lisglosips/caller-analytics.log
|
|||||||
chmod 0640 /var/log/lisglosips/caller-analytics.log
|
chmod 0640 /var/log/lisglosips/caller-analytics.log
|
||||||
install -m 0644 "$new/infra/server-b/caller-analytics/rsyslog.conf" /etc/rsyslog.d/35-caller-analytics.conf
|
install -m 0644 "$new/infra/server-b/caller-analytics/rsyslog.conf" /etc/rsyslog.d/35-caller-analytics.conf
|
||||||
rsyslogd -N1
|
rsyslogd -N1
|
||||||
if ! test -f /etc/lisglosips/caller-analytics.env; then
|
changed=1
|
||||||
grep -E '^(DATABASE_URL|REDIS_URL)=' /etc/lisglosips/cdr-worker.env > /etc/lisglosips/caller-analytics.env
|
install -d -o redis -g redis -m 0750 /var/lib/redis-caller-analytics
|
||||||
printf '%s\n' 'LISGLOSIPS_ENTRYPOINT=apps/worker-cdr/dist/analytics-main.js' 'LISGLOSIPS_SERVICE_NAME=caller-analytics' "ANALYTICS_CAPTURE_SINCE=$(date -u +%Y-%m-%dT%H:%M:%SZ)" >> /etc/lisglosips/caller-analytics.env
|
if ! test -f /etc/lisglosips/caller-analytics-redis.secret; then openssl rand -hex 32 > /etc/lisglosips/caller-analytics-redis.secret; chown root:lisglosips /etc/lisglosips/caller-analytics-redis.secret; chmod 0640 /etc/lisglosips/caller-analytics-redis.secret; fi
|
||||||
chown root:lisglosips /etc/lisglosips/caller-analytics.env
|
analytics_redis_password=$(cat /etc/lisglosips/caller-analytics-redis.secret)
|
||||||
chmod 0640 /etc/lisglosips/caller-analytics.env
|
sed "s/@@ANALYTICS_REDIS_PASSWORD@@/$analytics_redis_password/" "$new/infra/server-b/caller-analytics/redis.conf" > /etc/redis/caller-analytics.conf
|
||||||
|
chown root:redis /etc/redis/caller-analytics.conf; chmod 0640 /etc/redis/caller-analytics.conf
|
||||||
|
install -m 0644 "$new/infra/server-b/caller-analytics/redis.service" /etc/systemd/system/lisglosips-caller-analytics-redis.service
|
||||||
|
install -m 0644 "$new/infra/server-b/caller-analytics/logrotate.conf" /etc/logrotate.d/lisglosips-caller-analytics
|
||||||
|
install -m 0644 "$new/infra/server-b/caller-analytics/maintenance.service" /etc/systemd/system/lisglosips-caller-analytics-maintenance.service
|
||||||
|
install -m 0644 "$new/infra/server-b/caller-analytics/maintenance.timer" /etc/systemd/system/lisglosips-caller-analytics-maintenance.timer
|
||||||
|
install -m 0644 "$new/infra/server-b/s58/systemd/lisglosips.target" /etc/systemd/system/lisglosips.target
|
||||||
|
systemctl daemon-reload
|
||||||
|
systemctl enable --now lisglosips-caller-analytics-redis.service
|
||||||
|
encoded_redis_password=$(/usr/bin/node -e 'console.log(encodeURIComponent(process.argv[1]))' "$analytics_redis_password")
|
||||||
|
if grep -q 'CRA_INSTRUMENTATION_VERSION=3' "$source_cfg" && grep -q '^ANALYTICS_CAPTURE_SINCE=' /etc/lisglosips/caller-analytics.env; then
|
||||||
|
analytics_capture_since=$(sed -n 's/^ANALYTICS_CAPTURE_SINCE=//p' /etc/lisglosips/caller-analytics.env | head -n 1)
|
||||||
|
else
|
||||||
|
analytics_capture_since=$(date -u +%Y-%m-%dT%H:%M:%SZ)
|
||||||
fi
|
fi
|
||||||
|
[[ "$analytics_capture_since" =~ ^20[0-9]{2}-[0-9]{2}-[0-9]{2}T[0-9]{2}:[0-9]{2}:[0-9]{2}Z$ ]]
|
||||||
|
grep '^DATABASE_URL=' /etc/lisglosips/cdr-worker.env > /etc/lisglosips/caller-analytics.env.new
|
||||||
|
printf '%s\n' "REDIS_URL=redis://:$encoded_redis_password@127.0.0.1:6381/0" 'LISGLOSIPS_ENTRYPOINT=apps/worker-cdr/dist/analytics-main.js' 'LISGLOSIPS_SERVICE_NAME=caller-analytics' 'ANALYTICS_STREAM_MAXLEN=1000000' "ANALYTICS_CAPTURE_SINCE=$analytics_capture_since" >> /etc/lisglosips/caller-analytics.env.new
|
||||||
|
install -o root -g lisglosips -m 0640 /etc/lisglosips/caller-analytics.env.new /etc/lisglosips/caller-analytics.env
|
||||||
|
rm -f /etc/lisglosips/caller-analytics.env.new
|
||||||
|
unset analytics_redis_password encoded_redis_password analytics_capture_since
|
||||||
chown -R root:lisglosips "$new/apps/api/dist" "$new/apps/worker-cdr/dist" "$new/packages/database/dist" "$new/packages/auth/dist" "$new/public" "$new/scripts" "$new/infra/server-b/caller-analytics"
|
chown -R root:lisglosips "$new/apps/api/dist" "$new/apps/worker-cdr/dist" "$new/packages/database/dist" "$new/packages/auth/dist" "$new/public" "$new/scripts" "$new/infra/server-b/caller-analytics"
|
||||||
chmod -R u=rwX,g=rX,o= "$new/apps/api/dist" "$new/apps/worker-cdr/dist" "$new/packages/database/dist" "$new/packages/auth/dist" "$new/public" "$new/scripts" "$new/infra/server-b/caller-analytics"
|
chmod -R u=rwX,g=rX,o= "$new/apps/api/dist" "$new/apps/worker-cdr/dist" "$new/packages/database/dist" "$new/packages/auth/dist" "$new/public" "$new/scripts" "$new/infra/server-b/caller-analytics"
|
||||||
changed=1
|
|
||||||
systemctl restart rsyslog
|
systemctl restart rsyslog
|
||||||
install -o root -g root -m 0644 "$new/opensips-candidate.cfg" /etc/opensips/opensips.cfg
|
install -o root -g root -m 0644 "$new/opensips-candidate.cfg" /etc/opensips/opensips.cfg
|
||||||
ln -sfn "$new" /opt/lisglosips/current
|
ln -sfn "$new" /opt/lisglosips/current
|
||||||
|
systemctl stop lisglosips@caller-analytics
|
||||||
|
# This is a coordinated producer restart after the spool is quiescent. Reset
|
||||||
|
# only the per-process reconciliation baseline; preserve any pre-existing gap.
|
||||||
|
if test -f "$checkpoint_file"; then
|
||||||
|
python3 - "$checkpoint_file" <<'PY'
|
||||||
|
import json, os, sys
|
||||||
|
p=sys.argv[1]; st=os.stat(p)
|
||||||
|
with open(p,encoding='utf-8') as f: data=json.load(f)
|
||||||
|
spool='/var/log/lisglosips/caller-analytics.log'
|
||||||
|
assert int(data.get('offset',-1)) == os.path.getsize(spool), 'analytics spool is not quiescent'
|
||||||
|
produced=data.get('lastProducerCount'); collected=data.get('eventsSinceProducerStart')
|
||||||
|
assert produced is None or produced == collected, 'producer/collector counters differ before planned restart'
|
||||||
|
data.pop('lastProducerCount',None); data['eventsSinceProducerStart']=0
|
||||||
|
t=p+'.deploy-tmp'
|
||||||
|
with open(t,'w',encoding='utf-8') as f: json.dump(data,f,separators=(',',':'))
|
||||||
|
os.chown(t,st.st_uid,st.st_gid); os.chmod(t,st.st_mode); os.replace(t,p)
|
||||||
|
PY
|
||||||
|
fi
|
||||||
systemctl restart opensips lisglosips@api
|
systemctl restart opensips lisglosips@api
|
||||||
systemctl enable --now lisglosips@caller-analytics
|
systemctl enable --now lisglosips@caller-analytics
|
||||||
systemctl restart lisglosips@caller-analytics
|
systemctl restart lisglosips@caller-analytics
|
||||||
|
systemctl enable --now lisglosips-caller-analytics-maintenance.timer
|
||||||
for i in $(seq 1 15); do
|
for i in $(seq 1 15); do
|
||||||
if curl -fsS http://127.0.0.1:3000/api/v2/health/ready >/dev/null; then break; fi
|
if curl -fsS http://127.0.0.1:3000/api/v2/health/ready >/dev/null; then break; fi
|
||||||
sleep 1
|
sleep 1
|
||||||
done
|
done
|
||||||
curl -fsS http://127.0.0.1:3000/api/v2/health/ready
|
curl -fsS http://127.0.0.1:3000/api/v2/health/ready
|
||||||
systemctl is-active opensips lisglosips@api lisglosips@cdr-worker lisglosips@caller-analytics
|
systemctl is-active opensips lisglosips@api lisglosips@cdr-worker lisglosips@caller-analytics lisglosips-caller-analytics-redis
|
||||||
|
systemctl is-enabled lisglosips-caller-analytics-maintenance.timer
|
||||||
test "$(cat /opt/lisglosips/current/.deployed-commit)" = "$commit"
|
test "$(cat /opt/lisglosips/current/.deployed-commit)" = "$commit"
|
||||||
changed=0
|
changed=0
|
||||||
trap - ERR
|
trap - ERR
|
||||||
|
|||||||
@@ -0,0 +1,11 @@
|
|||||||
|
/var/log/lisglosips/caller-analytics.log {
|
||||||
|
daily
|
||||||
|
rotate 14
|
||||||
|
size 50M
|
||||||
|
compress
|
||||||
|
delaycompress
|
||||||
|
copytruncate
|
||||||
|
missingok
|
||||||
|
notifempty
|
||||||
|
su syslog lisglosips
|
||||||
|
}
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
[Unit]
|
||||||
|
Description=LisgloSIPS caller analytics retention maintenance
|
||||||
|
After=mysql.service lisglosips-caller-analytics-redis.service
|
||||||
|
|
||||||
|
[Service]
|
||||||
|
Type=oneshot
|
||||||
|
User=lisglosips
|
||||||
|
Group=lisglosips
|
||||||
|
WorkingDirectory=/opt/lisglosips/current
|
||||||
|
EnvironmentFile=/etc/lisglosips/caller-analytics.env
|
||||||
|
ExecStart=/usr/bin/node scripts/maintain-caller-analytics.mjs --apply
|
||||||
|
NoNewPrivileges=true
|
||||||
|
PrivateTmp=true
|
||||||
|
ProtectSystem=strict
|
||||||
|
ProtectHome=true
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
[Unit]
|
||||||
|
Description=Daily caller analytics retention maintenance
|
||||||
|
|
||||||
|
[Timer]
|
||||||
|
OnCalendar=*-*-* 03:35:00
|
||||||
|
Persistent=true
|
||||||
|
RandomizedDelaySec=600
|
||||||
|
|
||||||
|
[Install]
|
||||||
|
WantedBy=timers.target
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
bind 127.0.0.1
|
||||||
|
protected-mode yes
|
||||||
|
port 6381
|
||||||
|
dir /var/lib/redis-caller-analytics
|
||||||
|
dbfilename dump.rdb
|
||||||
|
appendonly yes
|
||||||
|
appendfilename "appendonly.aof"
|
||||||
|
appenddirname "appendonlydir"
|
||||||
|
appendfsync everysec
|
||||||
|
save 900 1
|
||||||
|
save 300 10
|
||||||
|
save 60 10000
|
||||||
|
stop-writes-on-bgsave-error yes
|
||||||
|
maxmemory 256mb
|
||||||
|
maxmemory-policy noeviction
|
||||||
|
tcp-keepalive 60
|
||||||
|
requirepass @@ANALYTICS_REDIS_PASSWORD@@
|
||||||
|
pidfile /run/redis-caller-analytics/redis.pid
|
||||||
|
logfile /var/log/redis/redis-caller-analytics.log
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
[Unit]
|
||||||
|
Description=LisgloSIPS isolated caller analytics Redis
|
||||||
|
After=network.target
|
||||||
|
|
||||||
|
[Service]
|
||||||
|
Type=notify
|
||||||
|
User=redis
|
||||||
|
Group=redis
|
||||||
|
RuntimeDirectory=redis-caller-analytics
|
||||||
|
RuntimeDirectoryMode=0750
|
||||||
|
ExecStart=/usr/bin/redis-server /etc/redis/caller-analytics.conf --supervised systemd
|
||||||
|
ExecStop=/bin/kill -s TERM $MAINPID
|
||||||
|
Restart=on-failure
|
||||||
|
RestartSec=2s
|
||||||
|
NoNewPrivileges=true
|
||||||
|
PrivateTmp=true
|
||||||
|
ProtectSystem=strict
|
||||||
|
ReadWritePaths=/var/lib/redis-caller-analytics /var/log/redis /run/redis-caller-analytics
|
||||||
|
|
||||||
|
[Install]
|
||||||
|
WantedBy=multi-user.target
|
||||||
@@ -1,14 +1,14 @@
|
|||||||
[Unit]
|
[Unit]
|
||||||
Description=LisgloSIPS single-server stack on Server B
|
Description=LisgloSIPS single-server stack on Server B
|
||||||
Wants=network-online.target tailscaled.service
|
Wants=network-online.target tailscaled.service
|
||||||
Wants=mysql.service redis-server.service postgresql.service
|
Wants=mysql.service redis-server.service lisglosips-caller-analytics-redis.service postgresql.service
|
||||||
Wants=nginx.service opensips.service rtpengine-daemon.service rtpengine-recording-daemon.service
|
Wants=nginx.service opensips.service rtpengine-daemon.service rtpengine-recording-daemon.service
|
||||||
Wants=lisglosips-redis-auth-proxy.service lisglosips-redis-hotpath-load.service
|
Wants=lisglosips-redis-auth-proxy.service lisglosips-redis-hotpath-load.service
|
||||||
Wants=lisglosips@api.service lisglosips@cdr-worker.service lisglosips@recording-worker.service lisglosips@config-publisher.service
|
Wants=lisglosips@api.service lisglosips@cdr-worker.service lisglosips@recording-worker.service lisglosips@config-publisher.service
|
||||||
Wants=heplify-server.service homer-app.service
|
Wants=heplify-server.service homer-app.service
|
||||||
Wants=lisglosips-prometheus.service grafana-server.service
|
Wants=lisglosips-prometheus.service grafana-server.service
|
||||||
Wants=lisglosips-node-exporter.service lisglosips-mysqld-exporter.service lisglosips-postgres-exporter.service lisglosips-redis-exporter.service
|
Wants=lisglosips-node-exporter.service lisglosips-mysqld-exporter.service lisglosips-postgres-exporter.service lisglosips-redis-exporter.service
|
||||||
Wants=lisglosips-backup.timer lisglosips-recording-finalize.timer
|
Wants=lisglosips-backup.timer lisglosips-recording-finalize.timer lisglosips-caller-analytics-maintenance.timer
|
||||||
After=network-online.target tailscaled.service
|
After=network-online.target tailscaled.service
|
||||||
After=mysql.service redis-server.service postgresql.service
|
After=mysql.service redis-server.service postgresql.service
|
||||||
|
|
||||||
|
|||||||
@@ -1,29 +1,37 @@
|
|||||||
import { createHash } from 'node:crypto';
|
import { createHash } from 'node:crypto';
|
||||||
|
|
||||||
export const ANALYTICS_VERSION = 'caller-analytics-v1.1';
|
export const ANALYTICS_VERSION = 'caller-analytics-v1.1';
|
||||||
|
export const ANALYTICS_REVISION = 'caller-analytics-r2';
|
||||||
export const ANALYTICS_STREAM = 'stream:caller_analytics';
|
export const ANALYTICS_STREAM = 'stream:caller_analytics';
|
||||||
export const ANALYTICS_GROUP = 'caller-analytics-workers';
|
export const ANALYTICS_GROUP = 'caller-analytics-workers';
|
||||||
export type AnalyticsKind = 'START' | 'ATTEMPT' | 'PROGRESS' | 'ACCEPTED' | 'DURATION' | 'END' | 'RECONCILE' | 'UNKNOWN';
|
export type AnalyticsKind = 'START' | 'ATTEMPT' | 'PROGRESS' | 'ACCEPTED' | 'TALK_START' | 'DURATION' | 'END' | 'CLOSE' | 'RECONCILE' | 'UNKNOWN';
|
||||||
export interface AnalyticsEvent {
|
export interface AnalyticsEvent {
|
||||||
eventId: string; callUid: string; callId: string; attemptId: string;
|
eventId: string; callUid: string; callId: string; attemptId: string;
|
||||||
kind: AnalyticsKind; at: number; startedAt: number;
|
kind: AnalyticsKind; at: number; startedAt: number;
|
||||||
customerId: string; customerGatewayId: string; vendorId: string; vendorGatewayId: string;
|
customerId: string; customerGatewayId: string; vendorId: string; vendorGatewayId: string;
|
||||||
caller: string; landingCaller: string; callee: string; city: string; carrier: string;
|
caller: string; landingCaller: string; callee: string; city: string; carrier: string;
|
||||||
code: number; talkMs: number | null; source: string; sequence: number;
|
code: number; talkMs: number | null; source: string; sequence: number;
|
||||||
|
schemaVersion?: number; nodeId?: string; bootId?: string; receivedAt?: number;
|
||||||
|
method?: string; cseq?: string; branch?: string; evidenceVersion?: number;
|
||||||
}
|
}
|
||||||
export interface AnalyticsLeg {
|
export interface AnalyticsLeg {
|
||||||
event: AnalyticsEvent; first180At: number | null; first183At: number | null;
|
event: AnalyticsEvent; first180At: number | null; first183At: number | null;
|
||||||
acceptedAt: number | null; endedAt: number | null; finalCode: number;
|
acceptedAt: number | null; endedAt: number | null; finalCode: number;
|
||||||
talkMs: number; durationAt: number; durationFinal: boolean; unknown: boolean;
|
talkMs: number; durationAt: number; durationFinal: boolean; unknown: boolean;
|
||||||
coverageGap?: boolean; durationAuthority?: number;
|
coverageGap?: boolean; durationAuthority?: number; talkStartedAt?: number | null;
|
||||||
|
durationComplete?: boolean; terminationReason?: string;
|
||||||
|
}
|
||||||
|
export interface AnalyticsCall {
|
||||||
|
start: AnalyticsEvent; legs: Record<string, AnalyticsLeg>;
|
||||||
|
closedAt?: number | null; closeEvidence?: string; closeExpected?: boolean;
|
||||||
}
|
}
|
||||||
export interface AnalyticsCall { start: AnalyticsEvent; legs: Record<string, AnalyticsLeg>; }
|
|
||||||
export interface AnalyticsCounts {
|
export interface AnalyticsCounts {
|
||||||
totalCalls: number; connectedCalls: number; answeredCalls: number;
|
totalCalls: number; connectedCalls: number; answeredCalls: number;
|
||||||
failedCalls: number; pendingCalls: number; unknownCalls: number; activeCalls: number; talkMs: number;
|
failedCalls: number; pendingCalls: number; unknownCalls: number; activeCalls: number; talkMs: number;
|
||||||
|
durationUnknownCalls: number; provisionalAnsweredCalls: number;
|
||||||
}
|
}
|
||||||
export const COUNT_KEYS = ['totalCalls', 'connectedCalls', 'answeredCalls', 'failedCalls', 'pendingCalls', 'unknownCalls', 'activeCalls', 'talkMs'] as const;
|
export const COUNT_KEYS = ['totalCalls', 'connectedCalls', 'answeredCalls', 'failedCalls', 'pendingCalls', 'unknownCalls', 'activeCalls', 'talkMs', 'durationUnknownCalls', 'provisionalAnsweredCalls'] as const;
|
||||||
export const emptyCounts = (): AnalyticsCounts => ({ totalCalls: 0, connectedCalls: 0, answeredCalls: 0, failedCalls: 0, pendingCalls: 0, unknownCalls: 0, activeCalls: 0, talkMs: 0 });
|
export const emptyCounts = (): AnalyticsCounts => ({ totalCalls: 0, connectedCalls: 0, answeredCalls: 0, failedCalls: 0, pendingCalls: 0, unknownCalls: 0, activeCalls: 0, talkMs: 0, durationUnknownCalls: 0, provisionalAnsweredCalls: 0 });
|
||||||
export function analyticsHash(value: unknown): string { return createHash('sha256').update(JSON.stringify(value)).digest('hex'); }
|
export function analyticsHash(value: unknown): string { return createHash('sha256').update(JSON.stringify(value)).digest('hex'); }
|
||||||
|
|
||||||
export function parseAnalyticsEvent(fields: string[]): AnalyticsEvent {
|
export function parseAnalyticsEvent(fields: string[]): AnalyticsEvent {
|
||||||
@@ -40,7 +48,7 @@ export function parseAnalyticsEvent(fields: string[]): AnalyticsEvent {
|
|||||||
return value;
|
return value;
|
||||||
};
|
};
|
||||||
const kind = text('kind') as AnalyticsKind;
|
const kind = text('kind') as AnalyticsKind;
|
||||||
if (f.version !== '1' || !['START', 'ATTEMPT', 'PROGRESS', 'ACCEPTED', 'DURATION', 'END', 'RECONCILE', 'UNKNOWN'].includes(kind)) throw new Error('Invalid analytics schema');
|
if (!['1', '2'].includes(f.version) || !['START', 'ATTEMPT', 'PROGRESS', 'ACCEPTED', 'TALK_START', 'DURATION', 'END', 'CLOSE', 'RECONCILE', 'UNKNOWN'].includes(kind)) throw new Error('Invalid analytics schema');
|
||||||
const at = num('at'); const startedAt = num('startedAt');
|
const at = num('at'); const startedAt = num('startedAt');
|
||||||
if (at < 1_700_000_000_000 || at > Date.now() + 60_000 || startedAt > at || startedAt < 1_700_000_000_000) throw new Error('Invalid event time');
|
if (at < 1_700_000_000_000 || at > Date.now() + 60_000 || startedAt > at || startedAt < 1_700_000_000_000) throw new Error('Invalid event time');
|
||||||
const event: AnalyticsEvent = {
|
const event: AnalyticsEvent = {
|
||||||
@@ -49,7 +57,9 @@ export function parseAnalyticsEvent(fields: string[]): AnalyticsEvent {
|
|||||||
vendorId: text('vendorId', 32), vendorGatewayId: text('vendorGatewayId', 32),
|
vendorId: text('vendorId', 32), vendorGatewayId: text('vendorGatewayId', 32),
|
||||||
caller: text('caller', 64), landingCaller: text('landingCaller', 64), callee: text('callee', 64),
|
caller: text('caller', 64), landingCaller: text('landingCaller', 64), callee: text('callee', 64),
|
||||||
city: text('city', 12), carrier: text('carrier', 20), code: num('code'),
|
city: text('city', 12), carrier: text('carrier', 20), code: num('code'),
|
||||||
talkMs: f.talkMs === undefined || f.talkMs === '-1' ? null : num('talkMs'), source: text('source', 64), sequence: num('sequence')
|
talkMs: f.talkMs === undefined || f.talkMs === '-1' ? null : num('talkMs'), source: text('source', 64), sequence: num('sequence'),
|
||||||
|
schemaVersion: Number(f.version), nodeId: text('nodeId', 64), bootId: text('bootId', 80), receivedAt: num('receivedAt', at),
|
||||||
|
method: text('method', 24), cseq: text('cseq', 64), branch: text('branch', 128), evidenceVersion: num('evidenceVersion', 1)
|
||||||
};
|
};
|
||||||
if (!event.eventId || !event.callUid || !event.callId || !event.customerId || !event.caller) throw new Error('Missing call identity');
|
if (!event.eventId || !event.callUid || !event.callId || !event.customerId || !event.caller) throw new Error('Missing call identity');
|
||||||
if (event.code > 699 || (kind === 'PROGRESS' && ![100, 180, 181, 182, 183].includes(event.code))) throw new Error('Invalid response code');
|
if (event.code > 699 || (kind === 'PROGRESS' && ![100, 180, 181, 182, 183].includes(event.code))) throw new Error('Invalid response code');
|
||||||
@@ -57,32 +67,59 @@ export function parseAnalyticsEvent(fields: string[]): AnalyticsEvent {
|
|||||||
return event;
|
return event;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function validateAnalyticsEvent(input: unknown): AnalyticsEvent {
|
||||||
|
if (!input || typeof input !== 'object' || Array.isArray(input)) throw new Error('Invalid analytics event');
|
||||||
|
const raw = input as Record<string, unknown>;
|
||||||
|
const mapped: Record<string, string> = {};
|
||||||
|
for (const [key, value] of Object.entries(raw)) {
|
||||||
|
if (value !== null && value !== undefined) mapped[key] = String(value);
|
||||||
|
}
|
||||||
|
mapped.version = String(raw.schemaVersion ?? raw.version ?? 1);
|
||||||
|
if (raw.talkMs === null || raw.talkMs === undefined) mapped.talkMs = '-1';
|
||||||
|
return parseAnalyticsEvent(Object.entries(mapped).flat());
|
||||||
|
}
|
||||||
|
|
||||||
const earliest = (a: number | null, b: number): number => a === null ? b : Math.min(a, b);
|
const earliest = (a: number | null, b: number): number => a === null ? b : Math.min(a, b);
|
||||||
export function foldAnalytics(call: AnalyticsCall | null, event: AnalyticsEvent): AnalyticsCall {
|
export function foldAnalytics(call: AnalyticsCall | null, event: AnalyticsEvent): AnalyticsCall {
|
||||||
const result: AnalyticsCall = call ? structuredClone(call) : { start: event, legs: {} };
|
const result: AnalyticsCall = call ? structuredClone(call) : { start: event, legs: {} };
|
||||||
if (result.start.customerId !== event.customerId || result.start.caller !== event.caller) throw new Error('Conflicting call identity');
|
if (result.start.customerId !== event.customerId || result.start.caller !== event.caller) throw new Error('Conflicting call identity');
|
||||||
if (event.startedAt < result.start.startedAt || (event.kind === 'START' && event.startedAt === result.start.startedAt)) result.start = event;
|
if (event.startedAt < result.start.startedAt || (event.kind === 'START' && event.startedAt === result.start.startedAt)) result.start = event;
|
||||||
|
if ((event.schemaVersion ?? 1) >= 2) result.closeExpected = true;
|
||||||
if (event.kind === 'START') return result;
|
if (event.kind === 'START') return result;
|
||||||
|
if (event.kind === 'CLOSE') {
|
||||||
|
if (result.closedAt === null || result.closedAt === undefined || event.at >= result.closedAt) {
|
||||||
|
result.closedAt = event.at; result.closeEvidence = event.source;
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
const key = event.attemptId || 'platform';
|
const key = event.attemptId || 'platform';
|
||||||
const leg: AnalyticsLeg = result.legs[key] ?? {
|
const leg: AnalyticsLeg = result.legs[key] ?? {
|
||||||
event, first180At: null, first183At: null, acceptedAt: null, endedAt: null,
|
event, first180At: null, first183At: null, acceptedAt: null, endedAt: null,
|
||||||
finalCode: 0, talkMs: 0, durationAt: 0, durationFinal: false, unknown: event.kind !== 'ATTEMPT'
|
finalCode: 0, talkMs: 0, durationAt: 0, durationFinal: false, unknown: event.kind !== 'ATTEMPT',
|
||||||
|
talkStartedAt: null, durationComplete: false
|
||||||
};
|
};
|
||||||
if (event.kind === 'ATTEMPT') { leg.event = event; if (!leg.coverageGap) leg.unknown = false; }
|
if (event.kind === 'ATTEMPT') { leg.event = event; if (!leg.coverageGap) leg.unknown = false; }
|
||||||
if (event.kind === 'PROGRESS' && event.code === 180) leg.first180At = earliest(leg.first180At, event.at);
|
if (event.kind === 'PROGRESS' && event.code === 180) leg.first180At = earliest(leg.first180At, event.at);
|
||||||
if (event.kind === 'PROGRESS' && event.code === 183) leg.first183At = earliest(leg.first183At, event.at);
|
if (event.kind === 'PROGRESS' && event.code === 183) leg.first183At = earliest(leg.first183At, event.at);
|
||||||
if (event.kind === 'ACCEPTED' && event.code >= 200 && event.code < 300) leg.acceptedAt = earliest(leg.acceptedAt, event.at);
|
if (event.kind === 'ACCEPTED' && event.code >= 200 && event.code < 300) leg.acceptedAt = earliest(leg.acceptedAt, event.at);
|
||||||
|
if (event.kind === 'TALK_START') leg.talkStartedAt = earliest(leg.talkStartedAt ?? null, event.at);
|
||||||
if (event.kind === 'UNKNOWN') { leg.unknown = true; leg.coverageGap = true; }
|
if (event.kind === 'UNKNOWN') { leg.unknown = true; leg.coverageGap = true; }
|
||||||
if (event.kind === 'END' || event.kind === 'RECONCILE') {
|
if (event.kind === 'END' || event.kind === 'RECONCILE') {
|
||||||
if (leg.endedAt === null || event.at >= leg.endedAt) { leg.endedAt = event.at; leg.finalCode = event.code; }
|
if (leg.endedAt === null || event.at >= leg.endedAt) { leg.endedAt = event.at; leg.finalCode = event.code; }
|
||||||
// END carries the producer's accumulated phase flags through separate replayable events.
|
// END carries the producer's accumulated phase flags through separate replayable events.
|
||||||
if (event.source === 'platform-final' && !event.attemptId && !leg.coverageGap) leg.unknown = false;
|
if (event.source === 'platform-final' && !event.attemptId && !leg.coverageGap) leg.unknown = false;
|
||||||
if (event.source === 'verified-dialog-final' && leg.acceptedAt !== null && event.talkMs === null && !leg.durationFinal) {
|
leg.terminationReason = event.source;
|
||||||
leg.talkMs = Math.max(0, event.at - leg.acceptedAt); leg.durationAt = event.at; leg.durationFinal = true; leg.durationAuthority = 1;
|
// A terminal non-2xx response proves that no answered dialog existed, so
|
||||||
|
// the final talk duration is exactly zero even if provisional 180/183 was seen.
|
||||||
|
if ((event.code < 200 || event.code >= 300) && leg.acceptedAt === null && leg.talkStartedAt === null) {
|
||||||
|
leg.talkMs = 0; leg.durationAt = event.at; leg.durationFinal = true; leg.durationAuthority = 1; leg.durationComplete = true;
|
||||||
|
}
|
||||||
|
if (event.source === 'verified-dialog-final' && leg.talkStartedAt != null && event.talkMs === null && !leg.durationFinal) {
|
||||||
|
leg.talkMs = Math.max(0, event.at - leg.talkStartedAt); leg.durationAt = event.at; leg.durationFinal = true; leg.durationAuthority = 1; leg.durationComplete = true;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (event.kind === 'ACCEPTED' && leg.endedAt !== null && !leg.durationFinal) {
|
if (event.kind === 'TALK_START' && leg.endedAt !== null && !leg.durationFinal) {
|
||||||
leg.talkMs = Math.max(0, leg.endedAt - event.at); leg.durationAt = leg.endedAt; leg.durationFinal = true; leg.durationAuthority = 1;
|
leg.talkMs = Math.max(0, leg.endedAt - event.at); leg.durationAt = leg.endedAt; leg.durationFinal = true; leg.durationAuthority = 1; leg.durationComplete = true;
|
||||||
}
|
}
|
||||||
if (event.talkMs !== null && ['DURATION', 'END', 'RECONCILE'].includes(event.kind)) {
|
if (event.talkMs !== null && ['DURATION', 'END', 'RECONCILE'].includes(event.kind)) {
|
||||||
const final = event.kind !== 'DURATION';
|
const final = event.kind !== 'DURATION';
|
||||||
@@ -90,6 +127,7 @@ export function foldAnalytics(call: AnalyticsCall | null, event: AnalyticsEvent)
|
|||||||
const priorAuthority = leg.durationAuthority ?? (leg.durationFinal ? 1 : 0);
|
const priorAuthority = leg.durationAuthority ?? (leg.durationFinal ? 1 : 0);
|
||||||
if (authority > priorAuthority || (authority === priorAuthority && event.at >= leg.durationAt)) {
|
if (authority > priorAuthority || (authority === priorAuthority && event.at >= leg.durationAt)) {
|
||||||
leg.talkMs = event.talkMs; leg.durationAt = event.at; leg.durationFinal = final; leg.durationAuthority = authority;
|
leg.talkMs = event.talkMs; leg.durationAt = event.at; leg.durationFinal = final; leg.durationAuthority = authority;
|
||||||
|
if (final) leg.durationComplete = true;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
result.legs[key] = leg;
|
result.legs[key] = leg;
|
||||||
@@ -104,19 +142,25 @@ export interface AnalyticsProjection extends AnalyticsCounts {
|
|||||||
function legCounts(leg: AnalyticsLeg): AnalyticsCounts {
|
function legCounts(leg: AnalyticsLeg): AnalyticsCounts {
|
||||||
const connected = leg.first180At !== null || leg.first183At !== null || leg.acceptedAt !== null || leg.talkMs > 0;
|
const connected = leg.first180At !== null || leg.first183At !== null || leg.acceptedAt !== null || leg.talkMs > 0;
|
||||||
const unknown = !connected && leg.unknown;
|
const unknown = !connected && leg.unknown;
|
||||||
return { totalCalls: 1, connectedCalls: +connected, answeredCalls: +(leg.talkMs > 0), failedCalls: +(!connected && !unknown && leg.endedAt !== null), pendingCalls: +(!connected && !unknown && leg.endedAt === null), unknownCalls: +unknown, activeCalls: +(leg.endedAt === null), talkMs: leg.talkMs };
|
const answered = leg.talkMs > 0;
|
||||||
|
return { totalCalls: 1, connectedCalls: +connected, answeredCalls: +answered, failedCalls: +(!connected && !unknown && leg.endedAt !== null), pendingCalls: +(!connected && !unknown && leg.endedAt === null), unknownCalls: +unknown, activeCalls: +(leg.endedAt === null), talkMs: leg.talkMs,
|
||||||
|
durationUnknownCalls: +(connected && leg.endedAt !== null && !leg.durationComplete), provisionalAnsweredCalls: +(answered && !leg.durationFinal) };
|
||||||
}
|
}
|
||||||
export function projectAnalytics(call: AnalyticsCall): AnalyticsProjection[] {
|
export function projectAnalytics(call: AnalyticsCall): AnalyticsProjection[] {
|
||||||
const e = call.start; const key = analyticsHash(e.callUid);
|
const e = call.start; const key = analyticsHash(e.callUid);
|
||||||
const legs = Object.values(call.legs); const counts = legs.map(legCounts);
|
const legs = Object.values(call.legs); const counts = legs.map(legCounts);
|
||||||
const connected = counts.some(c => c.connectedCalls > 0); const active = !legs.length || counts.some(c => c.activeCalls > 0);
|
const connected = counts.some(c => c.connectedCalls > 0);
|
||||||
|
const legacyClosed = resultIsLegacy(call) && legs.length > 0 && counts.every(c => c.activeCalls === 0);
|
||||||
|
const active = resultIsLegacy(call) ? (!legs.length || counts.some(c => c.activeCalls > 0)) : call.closedAt == null;
|
||||||
const unknown = !connected && counts.some(c => c.unknownCalls > 0);
|
const unknown = !connected && counts.some(c => c.unknownCalls > 0);
|
||||||
const base = { customerId: e.customerId, customerGatewayId: e.customerGatewayId, callee: e.callee, city: e.city, carrier: e.carrier, callKey: key };
|
const base = { customerId: e.customerId, customerGatewayId: e.customerGatewayId, callee: e.callee, city: e.city, carrier: e.carrier, callKey: key };
|
||||||
return [{ ...base, id: analyticsHash([key, 'original']), view: 'original', startedAt: e.startedAt, caller: e.caller,
|
return [{ ...base, id: analyticsHash([key, 'original']), view: 'original', startedAt: e.startedAt, caller: e.caller,
|
||||||
vendorId: '', vendorGatewayId: '', payload: call, totalCalls: 1, connectedCalls: +connected,
|
vendorId: '', vendorGatewayId: '', payload: call, totalCalls: 1, connectedCalls: +connected,
|
||||||
answeredCalls: +(counts.some(c => c.answeredCalls > 0)), failedCalls: +(!connected && !unknown && !active),
|
answeredCalls: +(counts.some(c => c.answeredCalls > 0)), failedCalls: +(!connected && !unknown && (legacyClosed || !active)),
|
||||||
pendingCalls: +(!connected && !unknown && active), unknownCalls: +unknown, activeCalls: +active,
|
pendingCalls: +(!connected && !unknown && active), unknownCalls: +unknown, activeCalls: +active,
|
||||||
talkMs: counts.reduce((sum, c) => sum + c.talkMs, 0)
|
talkMs: counts.reduce((sum, c) => sum + c.talkMs, 0),
|
||||||
|
durationUnknownCalls: +(connected && !counts.some(c => c.answeredCalls > 0) && counts.some(c => c.durationUnknownCalls > 0)),
|
||||||
|
provisionalAnsweredCalls: +(counts.some(c => c.provisionalAnsweredCalls > 0))
|
||||||
}, ...legs.filter(l => l.event.attemptId && l.event.vendorGatewayId).map(leg => ({
|
}, ...legs.filter(l => l.event.attemptId && l.event.vendorGatewayId).map(leg => ({
|
||||||
...base, ...legCounts(leg), id: analyticsHash([key, leg.event.attemptId]), view: 'landing' as const,
|
...base, ...legCounts(leg), id: analyticsHash([key, leg.event.attemptId]), view: 'landing' as const,
|
||||||
startedAt: leg.event.startedAt, caller: leg.event.landingCaller || leg.event.caller,
|
startedAt: leg.event.startedAt, caller: leg.event.landingCaller || leg.event.caller,
|
||||||
@@ -128,6 +172,8 @@ export function analyticsRates(counts: AnalyticsCounts) {
|
|||||||
const ratio = (n: number, d: number) => d ? Number((n / d * 100).toFixed(4)) : null;
|
const ratio = (n: number, d: number) => d ? Number((n / d * 100).toFixed(4)) : null;
|
||||||
return { ...counts, notConnectedCalls: counts.failedCalls + counts.pendingCalls,
|
return { ...counts, notConnectedCalls: counts.failedCalls + counts.pendingCalls,
|
||||||
connectionRate: counts.unknownCalls ? null : ratio(counts.connectedCalls, counts.totalCalls),
|
connectionRate: counts.unknownCalls ? null : ratio(counts.connectedCalls, counts.totalCalls),
|
||||||
overallAnswerRate: ratio(counts.answeredCalls, counts.totalCalls),
|
overallAnswerRate: counts.durationUnknownCalls ? null : ratio(counts.answeredCalls, counts.totalCalls),
|
||||||
connectedAnswerRate: counts.unknownCalls ? null : ratio(counts.answeredCalls, counts.connectedCalls) };
|
connectedAnswerRate: counts.unknownCalls || counts.durationUnknownCalls ? null : ratio(counts.answeredCalls, counts.connectedCalls) };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function resultIsLegacy(call: AnalyticsCall): boolean { return call.closeExpected !== true; }
|
||||||
|
|||||||
@@ -43,7 +43,7 @@ export class CallerAnalyticsStore {
|
|||||||
const minute = new Date(Math.floor(row.startedAt / 60000) * 60000);
|
const minute = new Date(Math.floor(row.startedAt / 60000) * 60000);
|
||||||
const id = analyticsHash([minute.toISOString(), row.view, row.customerId, row.customerGatewayId, row.vendorId, row.vendorGatewayId, row.caller, row.city, row.carrier]);
|
const id = analyticsHash([minute.toISOString(), row.view, row.customerId, row.customerGatewayId, row.vendorId, row.vendorGatewayId, row.caller, row.city, row.carrier]);
|
||||||
const delta = JSON.stringify(Object.fromEntries(COUNT_KEYS.map(k => [k, sign * row[k]])));
|
const delta = JSON.stringify(Object.fromEntries(COUNT_KEYS.map(k => [k, sign * row[k]])));
|
||||||
const additions = Prisma.join(COUNT_KEYS.flatMap(k => [Prisma.sql`${k}`, Prisma.sql`CAST(JSON_UNQUOTE(JSON_EXTRACT(counts, ${`$.${k}`})) AS SIGNED) + ${sign * row[k]}`]));
|
const additions = Prisma.join(COUNT_KEYS.flatMap(k => [Prisma.sql`${k}`, Prisma.sql`COALESCE(CAST(JSON_UNQUOTE(JSON_EXTRACT(counts, ${`$.${k}`})) AS SIGNED),0) + ${sign * row[k]}`]));
|
||||||
await tx.$executeRaw(Prisma.sql`INSERT INTO caller_analysis_minute_buckets
|
await tx.$executeRaw(Prisma.sql`INSERT INTO caller_analysis_minute_buckets
|
||||||
(id, started_at, view, customer_id, customer_gateway_id, vendor_id, vendor_gateway_id, caller, city, carrier, counts)
|
(id, started_at, view, customer_id, customer_gateway_id, vendor_id, vendor_gateway_id, caller, city, carrier, counts)
|
||||||
VALUES (${id}, ${minute}, ${row.view}, ${row.customerId}, ${row.customerGatewayId}, ${row.vendorId}, ${row.vendorGatewayId}, ${row.caller}, ${row.city}, ${row.carrier}, ${delta})
|
VALUES (${id}, ${minute}, ${row.view}, ${row.customerId}, ${row.customerGatewayId}, ${row.vendorId}, ${row.vendorGatewayId}, ${row.caller}, ${row.city}, ${row.carrier}, ${delta})
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ function run(events: AnalyticsEvent[]) { return projectAnalytics(events.reduce<A
|
|||||||
describe('caller analytics business state', () => {
|
describe('caller analytics business state', () => {
|
||||||
it.each([180,183])('counts %i as connected even if final call fails', code => {
|
it.each([180,183])('counts %i as connected even if final call fails', code => {
|
||||||
const [r] = run([event('ATTEMPT'), event('PROGRESS', { code }), event('END', { code: 487, source: 'verified-dialog-final' })]);
|
const [r] = run([event('ATTEMPT'), event('PROGRESS', { code }), event('END', { code: 487, source: 'verified-dialog-final' })]);
|
||||||
expect(analyticsRates(r)).toMatchObject({ totalCalls: 1, connectedCalls: 1, answeredCalls: 0, failedCalls: 0, notConnectedCalls: 0, connectionRate: 100, overallAnswerRate: 0, connectedAnswerRate: 0 });
|
expect(analyticsRates(r)).toMatchObject({ totalCalls: 1, connectedCalls: 1, answeredCalls: 0, failedCalls: 0, notConnectedCalls: 0, connectionRate: 100, overallAnswerRate: 0, connectedAnswerRate: 0, durationUnknownCalls:0 });
|
||||||
});
|
});
|
||||||
it.each([100,181,182])('does not treat %i as connected', code => {
|
it.each([100,181,182])('does not treat %i as connected', code => {
|
||||||
const [r] = run([event('ATTEMPT'), event('PROGRESS', { code }), event('END', { code: 486, source: 'verified-dialog-final' })]);
|
const [r] = run([event('ATTEMPT'), event('PROGRESS', { code }), event('END', { code: 486, source: 'verified-dialog-final' })]);
|
||||||
@@ -15,8 +15,8 @@ describe('caller analytics business state', () => {
|
|||||||
it('counts positive sub-second duration, not just 200', () => {
|
it('counts positive sub-second duration, not just 200', () => {
|
||||||
const [zero] = run([event('ATTEMPT'), event('ACCEPTED', { code: 200 })]);
|
const [zero] = run([event('ATTEMPT'), event('ACCEPTED', { code: 200 })]);
|
||||||
expect(zero.answeredCalls).toBe(0);
|
expect(zero.answeredCalls).toBe(0);
|
||||||
const [positive] = run([event('ATTEMPT'), event('ACCEPTED', { code: 200 }), event('END', { at: start + 1500, source: 'verified-dialog-final' })]);
|
const [positive] = run([event('ATTEMPT'), event('ACCEPTED', { code: 200 }), event('TALK_START', { at:start+1100, source:'dialog-acked' }), event('END', { at: start + 1500, source: 'verified-dialog-final' })]);
|
||||||
expect(positive).toMatchObject({ connectedCalls: 1, answeredCalls: 1, talkMs: 500 });
|
expect(positive).toMatchObject({ connectedCalls: 1, answeredCalls: 1, talkMs: 400 });
|
||||||
});
|
});
|
||||||
it('preserves zero actual duration', () => {
|
it('preserves zero actual duration', () => {
|
||||||
const [r] = run([event('ATTEMPT'), event('ACCEPTED', { code: 200 }), event('END', { talkMs: 0, source: 'verified-dialog-final' })]);
|
const [r] = run([event('ATTEMPT'), event('ACCEPTED', { code: 200 }), event('END', { talkMs: 0, source: 'verified-dialog-final' })]);
|
||||||
@@ -44,7 +44,7 @@ describe('caller analytics business state', () => {
|
|||||||
expect(() => run([event('ATTEMPT'), event('PROGRESS', { customerId: 'other', code: 180 })])).toThrow('Conflicting');
|
expect(() => run([event('ATTEMPT'), event('PROGRESS', { customerId: 'other', code: 180 })])).toThrow('Conflicting');
|
||||||
});
|
});
|
||||||
it('calculates the three different business rates', () => {
|
it('calculates the three different business rates', () => {
|
||||||
expect(analyticsRates({ totalCalls:100, connectedCalls:60, answeredCalls:30, failedCalls:25, pendingCalls:15, unknownCalls:0, activeCalls:15, talkMs:0 })).toMatchObject({ notConnectedCalls:40, connectionRate:60, overallAnswerRate:30, connectedAnswerRate:50 });
|
expect(analyticsRates({ totalCalls:100, connectedCalls:60, answeredCalls:30, failedCalls:25, pendingCalls:15, unknownCalls:0, activeCalls:15, talkMs:0, durationUnknownCalls:0, provisionalAnsweredCalls:0 })).toMatchObject({ notConnectedCalls:40, connectionRate:60, overallAnswerRate:30, connectedAnswerRate:50 });
|
||||||
});
|
});
|
||||||
it('does not erase missing progress evidence with an end event', () => {
|
it('does not erase missing progress evidence with an end event', () => {
|
||||||
expect(run([event('END',{code:487,source:'verified-dialog-final'})])[0]).toMatchObject({unknownCalls:1,failedCalls:0});
|
expect(run([event('END',{code:487,source:'verified-dialog-final'})])[0]).toMatchObject({unknownCalls:1,failedCalls:0});
|
||||||
@@ -59,4 +59,13 @@ describe('caller analytics business state', () => {
|
|||||||
expect(run([event('ATTEMPT'),event('ACCEPTED',{code:200}),...sequence,event('END',{at:start+5000,source:'verified-dialog-final'})])[0]).toMatchObject({connectedCalls:1,answeredCalls:0,talkMs:0});
|
expect(run([event('ATTEMPT'),event('ACCEPTED',{code:200}),...sequence,event('END',{at:start+5000,source:'verified-dialog-final'})])[0]).toMatchObject({connectedCalls:1,answeredCalls:0,talkMs:0});
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
it('does not infer answer duration from 2xx without ACK evidence',()=>{
|
||||||
|
const[r]=run([event('ATTEMPT'),event('ACCEPTED',{code:200}),event('END',{at:start+5000,source:'verified-dialog-final'})]);
|
||||||
|
expect(analyticsRates(r)).toMatchObject({connectedCalls:1,answeredCalls:0,talkMs:0,durationUnknownCalls:1,overallAnswerRate:null,connectedAnswerRate:null});
|
||||||
|
});
|
||||||
|
it('keeps a version 2 original call pending until explicit close',()=>{
|
||||||
|
const base={schemaVersion:2};
|
||||||
|
expect(run([event('START',base),event('ATTEMPT',base),event('END',{...base,code:486,source:'verified-dialog-final'})])[0]).toMatchObject({activeCalls:1,pendingCalls:1,failedCalls:0});
|
||||||
|
expect(run([event('START',base),event('ATTEMPT',base),event('END',{...base,code:486,source:'verified-dialog-final'}),event('CLOSE',{...base,source:'attempts-exhausted'})])[0]).toMatchObject({activeCalls:0,pendingCalls:0,failedCalls:1});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -0,0 +1,11 @@
|
|||||||
|
CREATE TABLE caller_analysis_gaps (
|
||||||
|
id CHAR(64) NOT NULL PRIMARY KEY, node_id VARCHAR(64) NOT NULL, kind VARCHAR(64) NOT NULL,
|
||||||
|
status VARCHAR(20) NOT NULL, started_at DATETIME(3) NOT NULL, ended_at DATETIME(3) NULL,
|
||||||
|
payload JSON NOT NULL, created_at DATETIME(3) NOT NULL, updated_at DATETIME(3) NOT NULL,
|
||||||
|
INDEX caller_gaps_status (status, started_at)
|
||||||
|
);
|
||||||
|
CREATE TABLE caller_analysis_dead_letters (
|
||||||
|
id CHAR(64) NOT NULL PRIMARY KEY, stream_id VARCHAR(64) NOT NULL, payload JSON NOT NULL,
|
||||||
|
error VARCHAR(500) NOT NULL, status VARCHAR(20) NOT NULL, created_at DATETIME(3) NOT NULL,
|
||||||
|
updated_at DATETIME(3) NOT NULL, INDEX caller_dead_status (status, created_at)
|
||||||
|
);
|
||||||
@@ -1045,6 +1045,32 @@ model CallerAnalysisAlert {
|
|||||||
@@map("caller_analysis_alerts")
|
@@map("caller_analysis_alerts")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
model CallerAnalysisGap {
|
||||||
|
id String @id @db.Char(64)
|
||||||
|
nodeId String @map("node_id") @db.VarChar(64)
|
||||||
|
kind String @db.VarChar(64)
|
||||||
|
status String @db.VarChar(20)
|
||||||
|
startedAt DateTime @map("started_at") @db.DateTime(3)
|
||||||
|
endedAt DateTime? @map("ended_at") @db.DateTime(3)
|
||||||
|
payload Json
|
||||||
|
createdAt DateTime @map("created_at") @db.DateTime(3)
|
||||||
|
updatedAt DateTime @map("updated_at") @db.DateTime(3)
|
||||||
|
@@index([status, startedAt], map: "caller_gaps_status")
|
||||||
|
@@map("caller_analysis_gaps")
|
||||||
|
}
|
||||||
|
|
||||||
|
model CallerAnalysisDeadLetter {
|
||||||
|
id String @id @db.Char(64)
|
||||||
|
streamId String @map("stream_id") @db.VarChar(64)
|
||||||
|
payload Json
|
||||||
|
error String @db.VarChar(500)
|
||||||
|
status String @db.VarChar(20)
|
||||||
|
createdAt DateTime @map("created_at") @db.DateTime(3)
|
||||||
|
updatedAt DateTime @map("updated_at") @db.DateTime(3)
|
||||||
|
@@index([status, createdAt], map: "caller_dead_status")
|
||||||
|
@@map("caller_analysis_dead_letters")
|
||||||
|
}
|
||||||
|
|
||||||
model IdempotencyKey {
|
model IdempotencyKey {
|
||||||
id String @id @db.VarChar(40)
|
id String @id @db.VarChar(40)
|
||||||
key String @unique @db.VarChar(128)
|
key String @unique @db.VarChar(128)
|
||||||
|
|||||||
@@ -76,6 +76,8 @@ const copyEntries = [
|
|||||||
['infra/server-b/s58', 'infra/server-b/s58', true],
|
['infra/server-b/s58', 'infra/server-b/s58', true],
|
||||||
['infra/server-b/caller-analytics', 'infra/server-b/caller-analytics', true],
|
['infra/server-b/caller-analytics', 'infra/server-b/caller-analytics', true],
|
||||||
['scripts/instrument-caller-analytics.mjs', 'scripts/instrument-caller-analytics.mjs', true],
|
['scripts/instrument-caller-analytics.mjs', 'scripts/instrument-caller-analytics.mjs', true],
|
||||||
|
['scripts/maintain-caller-analytics.mjs', 'scripts/maintain-caller-analytics.mjs', true],
|
||||||
|
['scripts/rebuild-caller-analytics.mjs', 'scripts/rebuild-caller-analytics.mjs', true],
|
||||||
['infra/ops/startup/lisglosips-start-b.sh', 'infra/ops/startup/lisglosips-start-b.sh', true],
|
['infra/ops/startup/lisglosips-start-b.sh', 'infra/ops/startup/lisglosips-start-b.sh', true],
|
||||||
['infra/server-a/s28/lisglosips_hotpath.lua', 'infra/server-a/s28/lisglosips_hotpath.lua', true],
|
['infra/server-a/s28/lisglosips_hotpath.lua', 'infra/server-a/s28/lisglosips_hotpath.lua', true],
|
||||||
['scripts/phase2-gateway-migration.mjs', 'scripts/phase2-gateway-migration.mjs', true],
|
['scripts/phase2-gateway-migration.mjs', 'scripts/phase2-gateway-migration.mjs', true],
|
||||||
|
|||||||
@@ -3,11 +3,42 @@ import { readFileSync, writeFileSync } from 'node:fs';
|
|||||||
const [source, output] = process.argv.slice(2);
|
const [source, output] = process.argv.slice(2);
|
||||||
if (!source || !output) throw new Error('Usage: node instrument-caller-analytics.mjs source.cfg output.cfg');
|
if (!source || !output) throw new Error('Usage: node instrument-caller-analytics.mjs source.cfg output.cfg');
|
||||||
let cfg = readFileSync(source, 'utf8').replaceAll('\r\n', '\n').replace(/^\uFEFF/, '');
|
let cfg = readFileSync(source, 'utf8').replaceAll('\r\n', '\n').replace(/^\uFEFF/, '');
|
||||||
if (cfg.includes('CRA1|')) throw new Error('Already instrumented; use the recorded pre-analytics backup');
|
if (cfg.includes('CRA_INSTRUMENTATION_VERSION=3')) throw new Error('Already instrumented at version 3');
|
||||||
function insert(marker, extra, after = false) {
|
function insert(marker, extra, after = false) {
|
||||||
if (cfg.split(marker).length !== 2) throw new Error(`Unsupported routing layout: ${marker}`);
|
if (cfg.split(marker).length !== 2) throw new Error(`Unsupported routing layout: ${marker}`);
|
||||||
cfg = cfg.replace(marker, after ? marker + extra : extra + marker);
|
cfg = cfg.replace(marker, after ? marker + extra : extra + marker);
|
||||||
}
|
}
|
||||||
|
const versionMarker = '# CRA_INSTRUMENTATION_VERSION=3\n';
|
||||||
|
const registerStats = () => insert('modparam("statistics", "variable", "s28_cdr_xadd_error_total/no_reset")', '\nmodparam("statistics", "variable", "cra_event_total/no_reset")', true);
|
||||||
|
const ackHook = ` if (is_method("ACK")) {\n $var(cra_kind)="TALK_START"; $var(cra_code)=200; $var(cra_source)="dialog-acked"; route(CRA_EMIT_DLG);\n }\n`;
|
||||||
|
const testPeerAckHook = ` if (loose_route()) {\n $var(cra_kind)="TALK_START"; $var(cra_code)=200; $var(cra_source)="dialog-acked"; route(CRA_EMIT_DLG);\n }\n`;
|
||||||
|
const placeAckHooks = () => {
|
||||||
|
insert(' if (loose_route()) {\n', ackHook, true);
|
||||||
|
insert(' if (is_method("ACK") && $si == "100.93.185.30") {\n', testPeerAckHook, true);
|
||||||
|
};
|
||||||
|
if (cfg.includes('CRA_INSTRUMENTATION_VERSION=2')) {
|
||||||
|
if (!cfg.includes(ackHook)) throw new Error('Version 2 ACK hook not found');
|
||||||
|
cfg = cfg.replace('# CRA_INSTRUMENTATION_VERSION=2\n', '').replace(ackHook, '');
|
||||||
|
placeAckHooks();
|
||||||
|
writeFileSync(output, versionMarker + cfg, 'utf8');
|
||||||
|
console.log('Analytics observation hooks upgraded to version 3 ACK matching; OpenSIPS config validation is required before activation.');
|
||||||
|
process.exit(0);
|
||||||
|
}
|
||||||
|
if (cfg.includes('CRA1|')) {
|
||||||
|
registerStats();
|
||||||
|
placeAckHooks();
|
||||||
|
cfg = cfg
|
||||||
|
.replace('$var(cra_kind)="END"; $var(cra_code)=$var(s28_reply_code); $var(cra_source)="platform-final"; route(CRA_EMIT_AVP);', '$var(cra_kind)="END"; $var(cra_code)=$var(s28_reply_code); $var(cra_source)="platform-final"; route(CRA_EMIT_AVP);\n $var(cra_kind)="CLOSE"; $var(cra_source)="platform-closed"; route(CRA_EMIT_AVP);')
|
||||||
|
.replace('$var(cra_kind)="END"; $var(cra_code)=200; $var(cra_source)="verified-dialog-final"; route(CRA_EMIT_DLG);', '$var(cra_kind)="END"; $var(cra_code)=200; $var(cra_source)="verified-dialog-final"; route(CRA_EMIT_DLG);\n $var(cra_kind)="CLOSE"; $var(cra_source)="dialog-closed"; route(CRA_EMIT_DLG);')
|
||||||
|
.replace('$var(cra_kind)="END"; $var(cra_code)=408; $var(cra_source)="verified-dialog-final"; route(CRA_EMIT_DLG);', '$var(cra_kind)="END"; $var(cra_code)=408; $var(cra_source)="verified-dialog-final"; route(CRA_EMIT_DLG);\n $var(cra_kind)="CLOSE"; $var(cra_source)="dialog-timeout"; route(CRA_EMIT_DLG);')
|
||||||
|
.replace('$var(cra_kind)="END"; $var(cra_code)=$T_reply_code; $var(cra_source)="verified-dialog-final"; route(CRA_EMIT_DLG);', '$var(cra_kind)="END"; $var(cra_code)=$T_reply_code; $var(cra_source)="verified-dialog-final"; route(CRA_EMIT_DLG);\n $var(cra_kind)="CLOSE"; $var(cra_source)="attempts-exhausted"; route(CRA_EMIT_DLG);')
|
||||||
|
.replaceAll('xlog("L_NOTICE", "CRA1|', 'update_stat("cra_event_total", 1);\n xlog("L_NOTICE", "CRA1|');
|
||||||
|
writeFileSync(output, versionMarker + cfg, 'utf8');
|
||||||
|
console.log('Analytics observation hooks upgraded to version 3; OpenSIPS config validation is required before activation.');
|
||||||
|
process.exit(0);
|
||||||
|
}
|
||||||
|
registerStats();
|
||||||
|
placeAckHooks();
|
||||||
const metadata = {
|
const metadata = {
|
||||||
uid: '$ci + "~" + $ft + "~" + $TS', callid: '$ci', customer: '$var(s28_customer_id)',
|
uid: '$ci + "~" + $ft + "~" + $TS', callid: '$ci', customer: '$var(s28_customer_id)',
|
||||||
gateway: '$var(s28_gateway_id)', vendor: '$var(s28_vendor_id)', vendorGateway: '$var(s28_vendor_gateway_id)',
|
gateway: '$var(s28_gateway_id)', vendor: '$var(s28_vendor_id)', vendorGateway: '$var(s28_vendor_gateway_id)',
|
||||||
@@ -20,23 +51,26 @@ const bind = Object.keys(metadata).map(k => ` $dlg_val(cra_${k}) = $avp(cra_$
|
|||||||
insert(' $dlg_val(s28_customer_id) = $var(s28_customer_id);', `${bind}\n dlg_on_hangup("CRA_HANGUP");\n dlg_on_timeout("CRA_TIMEOUT");\n`);
|
insert(' $dlg_val(s28_customer_id) = $var(s28_customer_id);', `${bind}\n dlg_on_hangup("CRA_HANGUP");\n dlg_on_timeout("CRA_TIMEOUT");\n`);
|
||||||
insert(' t_on_reply("S28_REPLY");', ' $dlg_val(cra_attempt)="1"; $avp(cra_attempt)="1";\n $var(cra_kind)="ATTEMPT"; $var(cra_code)=0; $var(cra_source)="opensips"; route(CRA_EMIT_DLG);\n t_on_failure("CRA_FAILURE");\n');
|
insert(' t_on_reply("S28_REPLY");', ' $dlg_val(cra_attempt)="1"; $avp(cra_attempt)="1";\n $var(cra_kind)="ATTEMPT"; $var(cra_code)=0; $var(cra_source)="opensips"; route(CRA_EMIT_DLG);\n t_on_failure("CRA_FAILURE");\n');
|
||||||
insert('onreply_route[S28_REPLY] {', '\n if ($rs==180 || $rs==183 || $rs==100 || $rs==181 || $rs==182) {\n $var(cra_kind)="PROGRESS"; $var(cra_code)=$rs; $var(cra_source)="opensips"; route(CRA_EMIT_DLG);\n }\n if ($rs>=200 && $rs<300) {\n $var(cra_kind)="ACCEPTED"; $var(cra_code)=$rs; $var(cra_source)="opensips"; route(CRA_EMIT_DLG);\n }\n', true);
|
insert('onreply_route[S28_REPLY] {', '\n if ($rs==180 || $rs==183 || $rs==100 || $rs==181 || $rs==182) {\n $var(cra_kind)="PROGRESS"; $var(cra_code)=$rs; $var(cra_source)="opensips"; route(CRA_EMIT_DLG);\n }\n if ($rs>=200 && $rs<300) {\n $var(cra_kind)="ACCEPTED"; $var(cra_code)=$rs; $var(cra_source)="opensips"; route(CRA_EMIT_DLG);\n }\n', true);
|
||||||
insert('route[S28_CDR_FAILURE] {', '\n $var(cra_kind)="END"; $var(cra_code)=$var(s28_reply_code); $var(cra_source)="platform-final"; route(CRA_EMIT_AVP);\n', true);
|
insert('route[S28_CDR_FAILURE] {', '\n $var(cra_kind)="END"; $var(cra_code)=$var(s28_reply_code); $var(cra_source)="platform-final"; route(CRA_EMIT_AVP);\n $var(cra_kind)="CLOSE"; $var(cra_source)="platform-closed"; route(CRA_EMIT_AVP);\n', true);
|
||||||
const fields = ['uid', 'callid', 'customer', 'gateway', 'vendor', 'vendorGateway', 'caller', 'landing', 'callee', 'city', 'carrier', 'attempt'];
|
const fields = ['uid', 'callid', 'customer', 'gateway', 'vendor', 'vendorGateway', 'caller', 'landing', 'callee', 'city', 'carrier', 'attempt'];
|
||||||
function emitter(type, prefix) {
|
function emitter(type, prefix) {
|
||||||
const ref = k => `$${prefix}(cra_${k})`;
|
const ref = k => `$${prefix}(cra_${k})`;
|
||||||
const encoded = fields.map(k => `$(${prefix}(cra_${k}){s.b64encode})`).join('|');
|
const encoded = fields.map(k => `$(${prefix}(cra_${k}){s.b64encode})`).join('|');
|
||||||
return `\nroute[CRA_EMIT_${type}] {\n if (${ref('customer')}==NULL || ${ref('customer')}=="none") return;\n xlog("L_NOTICE", "CRA1|$var(cra_kind)|$Ts|$Tsm|${ref('startsec')}|${ref('startmicro')}|$var(cra_code)|$var(cra_source)|${encoded}\\n");\n}\n`;
|
return `\nroute[CRA_EMIT_${type}] {\n if (${ref('customer')}==NULL || ${ref('customer')}=="none") return;\n update_stat("cra_event_total", 1);\n xlog("L_NOTICE", "CRA1|$var(cra_kind)|$Ts|$Tsm|${ref('startsec')}|${ref('startmicro')}|$var(cra_code)|$var(cra_source)|${encoded}\\n");\n}\n`;
|
||||||
}
|
}
|
||||||
cfg += emitter('AVP', 'avp') + emitter('DLG', 'dlg_val') + `
|
cfg += emitter('AVP', 'avp') + emitter('DLG', 'dlg_val') + `
|
||||||
route[CRA_HANGUP] {
|
route[CRA_HANGUP] {
|
||||||
$var(cra_kind)="END"; $var(cra_code)=200; $var(cra_source)="verified-dialog-final"; route(CRA_EMIT_DLG);
|
$var(cra_kind)="END"; $var(cra_code)=200; $var(cra_source)="verified-dialog-final"; route(CRA_EMIT_DLG);
|
||||||
|
$var(cra_kind)="CLOSE"; $var(cra_source)="dialog-closed"; route(CRA_EMIT_DLG);
|
||||||
}
|
}
|
||||||
route[CRA_TIMEOUT] {
|
route[CRA_TIMEOUT] {
|
||||||
$var(cra_kind)="END"; $var(cra_code)=408; $var(cra_source)="verified-dialog-final"; route(CRA_EMIT_DLG);
|
$var(cra_kind)="END"; $var(cra_code)=408; $var(cra_source)="verified-dialog-final"; route(CRA_EMIT_DLG);
|
||||||
|
$var(cra_kind)="CLOSE"; $var(cra_source)="dialog-timeout"; route(CRA_EMIT_DLG);
|
||||||
}
|
}
|
||||||
failure_route[CRA_FAILURE] {
|
failure_route[CRA_FAILURE] {
|
||||||
$var(cra_kind)="END"; $var(cra_code)=$T_reply_code; $var(cra_source)="verified-dialog-final"; route(CRA_EMIT_DLG);
|
$var(cra_kind)="END"; $var(cra_code)=$T_reply_code; $var(cra_source)="verified-dialog-final"; route(CRA_EMIT_DLG);
|
||||||
|
$var(cra_kind)="CLOSE"; $var(cra_source)="attempts-exhausted"; route(CRA_EMIT_DLG);
|
||||||
}
|
}
|
||||||
`;
|
`;
|
||||||
writeFileSync(output, cfg, 'utf8');
|
writeFileSync(output, versionMarker + cfg, 'utf8');
|
||||||
console.log('Analytics observation hooks generated; OpenSIPS config validation is required before activation.');
|
console.log('Analytics observation hooks generated at version 3; OpenSIPS config validation is required before activation.');
|
||||||
|
|||||||
@@ -0,0 +1,14 @@
|
|||||||
|
// Retention is intentionally shorter for raw evidence than aggregate trends.
|
||||||
|
import { PrismaClient } from '@prisma/client';
|
||||||
|
import assert from 'node:assert/strict';
|
||||||
|
const apply=process.argv.includes('--apply'); const db=new PrismaClient();
|
||||||
|
const days=(name,fallback)=>{const value=Number(process.env[name]??fallback);assert(Number.isInteger(value)&&value>=7&&value<=730,`Invalid ${name}`);return value;};
|
||||||
|
const eventBefore=new Date(Date.now()-days('ANALYTICS_EVENT_RETENTION_DAYS',30)*86400000);
|
||||||
|
const stateBefore=new Date(Date.now()-days('ANALYTICS_STATE_RETENTION_DAYS',90)*86400000);
|
||||||
|
const bucketBefore=new Date(Date.now()-days('ANALYTICS_BUCKET_RETENTION_DAYS',400)*86400000);
|
||||||
|
try{
|
||||||
|
const count=async rows=>Number(rows[0]?.n??0);
|
||||||
|
const counts={events:await count(await db.$queryRaw`SELECT COUNT(*) AS n FROM caller_analysis_events WHERE created_at<${eventBefore}`),states:await count(await db.$queryRaw`SELECT COUNT(*) AS n FROM caller_analysis_states WHERE started_at<${stateBefore}`),buckets:await count(await db.$queryRaw`SELECT COUNT(*) AS n FROM caller_analysis_minute_buckets WHERE started_at<${bucketBefore}`),deadLetters:await count(await db.$queryRaw`SELECT COUNT(*) AS n FROM caller_analysis_dead_letters WHERE status<>'OPEN' AND created_at<${stateBefore}`),gaps:await count(await db.$queryRaw`SELECT COUNT(*) AS n FROM caller_analysis_gaps WHERE status<>'OPEN' AND started_at<${stateBefore}`)};
|
||||||
|
if(apply)await db.$transaction(async tx=>{await tx.$executeRaw`DELETE FROM caller_analysis_events WHERE created_at<${eventBefore}`;await tx.$executeRaw`DELETE FROM caller_analysis_minute_buckets WHERE started_at<${bucketBefore}`;await tx.$executeRaw`DELETE FROM caller_analysis_states WHERE started_at<${stateBefore}`;await tx.$executeRaw`DELETE c FROM caller_analysis_calls c LEFT JOIN caller_analysis_states s ON s.call_key=c.id WHERE s.id IS NULL AND c.updated_at<${stateBefore}`;await tx.$executeRaw`DELETE FROM caller_analysis_dead_letters WHERE status<>'OPEN' AND created_at<${stateBefore}`;await tx.$executeRaw`DELETE FROM caller_analysis_gaps WHERE status<>'OPEN' AND started_at<${stateBefore}`;},{timeout:60000});
|
||||||
|
console.log(JSON.stringify({apply,cutoffs:{eventBefore,stateBefore,bucketBefore},counts}));
|
||||||
|
}finally{await db.$disconnect();}
|
||||||
@@ -45,6 +45,9 @@ try {
|
|||||||
}
|
}
|
||||||
assert.equal(result.status,200,JSON.stringify(result.body));
|
assert.equal(result.status,200,JSON.stringify(result.body));
|
||||||
assert.deepEqual(Object.fromEntries(['totalCalls','connectedCalls','answeredCalls','failedCalls'].map(k=>[k,result.body.summary[k]])),{totalCalls:6,connectedCalls:4,answeredCalls:2,failedCalls:2});
|
assert.deepEqual(Object.fromEntries(['totalCalls','connectedCalls','answeredCalls','failedCalls'].map(k=>[k,result.body.summary[k]])),{totalCalls:6,connectedCalls:4,answeredCalls:2,failedCalls:2});
|
||||||
|
assert.equal(result.body.analysisRevision,'caller-analytics-r2');
|
||||||
|
assert.equal(result.body.coverageStatus,'CONTINUOUS',JSON.stringify(result.body.health));
|
||||||
|
assert.equal(result.body.summary.durationUnknownCalls,0);
|
||||||
assert.equal((await get('/caller-analytics/overview',false)).status,401);
|
assert.equal((await get('/caller-analytics/overview',false)).status,401);
|
||||||
assert.equal((await get('/caller-analytics/overview?view=invalid')).status,400);
|
assert.equal((await get('/caller-analytics/overview?view=invalid')).status,400);
|
||||||
assert.equal(result.body.summary.connectedAnswerRate,50);
|
assert.equal(result.body.summary.connectedAnswerRate,50);
|
||||||
@@ -56,7 +59,7 @@ try {
|
|||||||
const auditPath = `/caller-analytics/overview?customerId=${fixture}&view=landing&from=${encodeURIComponent(auditFrom)}`;
|
const auditPath = `/caller-analytics/overview?customerId=${fixture}&view=landing&from=${encodeURIComponent(auditFrom)}`;
|
||||||
const beforeReplay = await get(auditPath); assert.equal(beforeReplay.status,200);
|
const beforeReplay = await get(auditPath); assert.equal(beforeReplay.status,200);
|
||||||
const stateRows = await db.$queryRaw`SELECT counts FROM caller_analysis_states WHERE customer_id=${fixture} AND view='landing' AND started_at>=${new Date(auditFrom)}`;
|
const stateRows = await db.$queryRaw`SELECT counts FROM caller_analysis_states WHERE customer_id=${fixture} AND view='landing' AND started_at>=${new Date(auditFrom)}`;
|
||||||
for (const key of COUNT_KEYS) assert.equal(beforeReplay.body.summary[key],stateRows.reduce((sum,row)=>sum+row.counts[key],0),`Minute bucket reconciliation: ${key}`);
|
for (const key of COUNT_KEYS) assert.equal(beforeReplay.body.summary[key],stateRows.reduce((sum,row)=>sum+Number(row.counts[key]??0),0),`Minute bucket reconciliation: ${key}`);
|
||||||
const events = await db.$queryRaw`SELECT payload FROM caller_analysis_events WHERE call_key=${detail.body.rows[0].callKey}`;
|
const events = await db.$queryRaw`SELECT payload FROM caller_analysis_events WHERE call_key=${detail.body.rows[0].callKey}`;
|
||||||
const store = new CallerAnalyticsStore(db);
|
const store = new CallerAnalyticsStore(db);
|
||||||
for (const event of events) assert.equal(await store.consume(event.payload),'duplicate');
|
for (const event of events) assert.equal(await store.consume(event.payload),'duplicate');
|
||||||
@@ -73,11 +76,13 @@ try {
|
|||||||
assert.equal((await get('/caller-analytics/overview?customerId=another_customer',true,scopedToken)).status,403);
|
assert.equal((await get('/caller-analytics/overview?customerId=another_customer',true,scopedToken)).status,403);
|
||||||
const options=await get('/caller-analytics/options',true,scopedToken);
|
const options=await get('/caller-analytics/options',true,scopedToken);
|
||||||
assert.equal(options.status,200); assert.deepEqual(options.body.customers.map(c=>c.id),[fixture]);
|
assert.equal(options.status,200); assert.deepEqual(options.body.customers.map(c=>c.id),[fixture]);
|
||||||
|
assert.deepEqual(options.body.vendors.map(v=>v.id),[fixture]);
|
||||||
} finally {
|
} finally {
|
||||||
await db.$executeRaw`DELETE FROM caller_analysis_access WHERE user_id=${testUserId}`;
|
await db.$executeRaw`DELETE FROM caller_analysis_access WHERE user_id=${testUserId}`;
|
||||||
await db.user.deleteMany({where:{id:testUserId}}); await db.role.deleteMany({where:{id:testUserId}});
|
await db.user.deleteMany({where:{id:testUserId}}); await db.role.deleteMany({where:{id:testUserId}});
|
||||||
}
|
}
|
||||||
const evidence={date:new Date().toISOString(),from,api:result,detail:detail.body,sip:JSON.parse(sip.stdout),bucketAudit:beforeReplay.body.summary,replayed:events.length,tests:['real SIP six scenarios','real API counts and three rates','real DB states and minute buckets','database replay idempotency','customer scope and 403 isolation','scoped filter options','unauthenticated 401','invalid query 400','positive subsecond duration']};
|
const sipEvents=JSON.parse(sip.stdout); assert.equal(sipEvents.filter(e=>e.direction==='UAC'&&e.code===200&&e.sdp).length,2,'Two successful calls must negotiate SDP through RTPEngine');
|
||||||
|
const evidence={date:new Date().toISOString(),from,api:result,detail:detail.body,sip:sipEvents,bucketAudit:beforeReplay.body.summary,replayed:events.length,tests:['real SIP six scenarios','two SDP offer/answer negotiations through RTPEngine','ACK-based positive duration including subsecond call','real API counts, completeness and three rates','real DB states and minute buckets','database replay idempotency','customer scope and 403 isolation','scoped customer/vendor filter options','unauthenticated 401','invalid query 400']};
|
||||||
await writeFile(process.env.CRA_REPORT || '/tmp/caller-analytics-real-evidence.json',JSON.stringify(evidence,null,2));
|
await writeFile(process.env.CRA_REPORT || '/tmp/caller-analytics-real-evidence.json',JSON.stringify(evidence,null,2));
|
||||||
console.log(JSON.stringify({passed:true,summary:result.body.summary,shortMs:short.counts.talkMs,quality:result.body.qualityStatus}));
|
console.log(JSON.stringify({passed:true,summary:result.body.summary,shortMs:short.counts.talkMs,quality:result.body.qualityStatus}));
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -14,7 +14,10 @@ def header(message, name):
|
|||||||
match = re.search(r'^' + re.escape(name) + r':\s*(.+)$', message, re.I | re.M)
|
match = re.search(r'^' + re.escape(name) + r':\s*(.+)$', message, re.I | re.M)
|
||||||
return match.group(1).strip() if match else ''
|
return match.group(1).strip() if match else ''
|
||||||
|
|
||||||
def reply(message, code, reason):
|
def sdp(port):
|
||||||
|
return '\r\n'.join(['v=0', 'o=cra 1 1 IN IP4 127.0.0.1', 's=caller-analytics-test', 'c=IN IP4 127.0.0.1', 't=0 0', f'm=audio {port} RTP/AVP 0', 'a=rtpmap:0 PCMU/8000', 'a=sendrecv', ''])
|
||||||
|
|
||||||
|
def reply(message, code, reason, body=''):
|
||||||
to = header(message, 'To')
|
to = header(message, 'To')
|
||||||
if ';tag=' not in to:
|
if ';tag=' not in to:
|
||||||
to += ';tag=cra-uas'
|
to += ';tag=cra-uas'
|
||||||
@@ -23,7 +26,11 @@ def reply(message, code, reason):
|
|||||||
lines = [f'SIP/2.0 {code} {reason}'] + [f'Via: {v.strip()}' for v in vias]
|
lines = [f'SIP/2.0 {code} {reason}'] + [f'Via: {v.strip()}' for v in vias]
|
||||||
lines += [f'From: {header(message,"From")}', f'To: {to}', f'Call-ID: {header(message,"Call-ID")}', f'CSeq: {header(message,"CSeq")}']
|
lines += [f'From: {header(message,"From")}', f'To: {to}', f'Call-ID: {header(message,"Call-ID")}', f'CSeq: {header(message,"CSeq")}']
|
||||||
lines += [f'Record-Route: {r.strip()}' for r in routes]
|
lines += [f'Record-Route: {r.strip()}' for r in routes]
|
||||||
lines += ['Contact: <sip:uas@127.0.0.1:50630>', 'Content-Length: 0', '', '']
|
lines += ['Contact: <sip:uas@127.0.0.1:50630>']
|
||||||
|
if body:
|
||||||
|
lines += ['Content-Type: application/sdp', f'Content-Length: {len(body.encode())}', '', body]
|
||||||
|
else:
|
||||||
|
lines += ['Content-Length: 0', '', '']
|
||||||
return '\r\n'.join(lines).encode()
|
return '\r\n'.join(lines).encode()
|
||||||
|
|
||||||
def uas():
|
def uas():
|
||||||
@@ -37,8 +44,9 @@ def uas():
|
|||||||
number = re.search(r'INVITE sip:([^@]+)',msg).group(1)
|
number = re.search(r'INVITE sip:([^@]+)',msg).group(1)
|
||||||
cases = {'99101':[(180,'Ringing'),(486,'Busy Here')], '99102':[(183,'Session Progress'),(487,'Request Terminated')], '99103':[(486,'Busy Here')], '99104':[(200,'OK')], '99105':[(180,'Ringing'),(200,'OK')], '99106':[(100,'Trying'),(486,'Busy Here')]}
|
cases = {'99101':[(180,'Ringing'),(486,'Busy Here')], '99102':[(183,'Session Progress'),(487,'Request Terminated')], '99103':[(486,'Busy Here')], '99104':[(200,'OK')], '99105':[(180,'Ringing'),(200,'OK')], '99106':[(100,'Trying'),(486,'Busy Here')]}
|
||||||
for code, reason in cases[number]:
|
for code, reason in cases[number]:
|
||||||
sock.sendto(reply(msg,code,reason),addr)
|
answer = sdp(40000) if code == 200 else ''
|
||||||
events.append({'callId':header(msg,'Call-ID'),'direction':'UAS','code':code,'time':time.time()})
|
sock.sendto(reply(msg,code,reason,answer),addr)
|
||||||
|
events.append({'callId':header(msg,'Call-ID'),'direction':'UAS','code':code,'sdp':bool(answer),'time':time.time()})
|
||||||
time.sleep(.15 if number!='99106' else 1.5)
|
time.sleep(.15 if number!='99106' else 1.5)
|
||||||
elif method == 'BYE': sock.sendto(reply(msg,200,'OK'),addr)
|
elif method == 'BYE': sock.sendto(reply(msg,200,'OK'),addr)
|
||||||
|
|
||||||
@@ -50,15 +58,20 @@ try:
|
|||||||
callid=f'cra-{number}-{uuid.uuid4().hex}@loopback'; tag=uuid.uuid4().hex[:8]
|
callid=f'cra-{number}-{uuid.uuid4().hex}@loopback'; tag=uuid.uuid4().hex[:8]
|
||||||
branch='z9hG4bK'+uuid.uuid4().hex
|
branch='z9hG4bK'+uuid.uuid4().hex
|
||||||
base=[f'Via: SIP/2.0/UDP 127.0.0.1:{port};branch={branch};rport', f'From: <sip:99100001@127.0.0.1>;tag={tag}', f'To: <sip:{number}@127.0.0.1>', f'Call-ID: {callid}', f'Contact: <sip:99100001@127.0.0.1:{port}>', 'Max-Forwards: 70']
|
base=[f'Via: SIP/2.0/UDP 127.0.0.1:{port};branch={branch};rport', f'From: <sip:99100001@127.0.0.1>;tag={tag}', f'To: <sip:{number}@127.0.0.1>', f'Call-ID: {callid}', f'Contact: <sip:99100001@127.0.0.1:{port}>', 'Max-Forwards: 70']
|
||||||
invite='\r\n'.join([f'INVITE sip:{number}@127.0.0.1:15060 SIP/2.0',*base,'CSeq: 1 INVITE','Content-Length: 0','',''])
|
offer = sdp(40002) if number in ('99104','99105') else ''
|
||||||
|
invite_lines=[f'INVITE sip:{number}@127.0.0.1:15060 SIP/2.0',*base,'CSeq: 1 INVITE']
|
||||||
|
if offer: invite_lines += ['Content-Type: application/sdp',f'Content-Length: {len(offer.encode())}','',''+offer]
|
||||||
|
else: invite_lines += ['Content-Length: 0','','']
|
||||||
|
invite='\r\n'.join(invite_lines)
|
||||||
sock.sendto(invite.encode(),('127.0.0.1',15060))
|
sock.sendto(invite.encode(),('127.0.0.1',15060))
|
||||||
while True:
|
while True:
|
||||||
msg=sock.recv(65535).decode(); code=int(msg.split()[1]); events.append({'callId':callid,'direction':'UAC','code':code,'time':time.time()})
|
msg=sock.recv(65535).decode(); code=int(msg.split()[1]); has_sdp='application/sdp' in msg.lower(); events.append({'callId':callid,'direction':'UAC','code':code,'sdp':has_sdp,'time':time.time()})
|
||||||
if code<200: continue
|
if code<200: continue
|
||||||
if code>=300:
|
if code>=300:
|
||||||
ack=invite.replace('INVITE sip:','ACK sip:',1).replace('CSeq: 1 INVITE','CSeq: 1 ACK').replace(base[2],f'To: {header(msg,"To")}')
|
ack=invite.replace('INVITE sip:','ACK sip:',1).replace('CSeq: 1 INVITE','CSeq: 1 ACK').replace(base[2],f'To: {header(msg,"To")}')
|
||||||
sock.sendto(ack.encode(),('127.0.0.1',15060)); break
|
sock.sendto(ack.encode(),('127.0.0.1',15060)); break
|
||||||
routes = list(reversed(re.findall(r'^Record-Route:\s*(.+)$',msg,re.I|re.M)))
|
routes = list(reversed(re.findall(r'^Record-Route:\s*(.+)$',msg,re.I|re.M)))
|
||||||
|
assert not offer or has_sdp, 'Successful SDP offer must receive an SDP answer'
|
||||||
for method,cseq in [('ACK',1),('BYE',2)]:
|
for method,cseq in [('ACK',1),('BYE',2)]:
|
||||||
if method=='BYE': time.sleep(.3 if number=='99105' else 1.5)
|
if method=='BYE': time.sleep(.3 if number=='99105' else 1.5)
|
||||||
seq=[f'{method} sip:uas@127.0.0.1:50630 SIP/2.0',f'Via: SIP/2.0/UDP 127.0.0.1:{port};branch=z9hG4bK{uuid.uuid4().hex};rport',base[1],f'To: {header(msg,"To")}',base[3],*['Route: '+r.strip() for r in routes],f'CSeq: {cseq} {method}','Max-Forwards: 70','Content-Length: 0','','']
|
seq=[f'{method} sip:uas@127.0.0.1:50630 SIP/2.0',f'Via: SIP/2.0/UDP 127.0.0.1:{port};branch=z9hG4bK{uuid.uuid4().hex};rport',base[1],f'To: {header(msg,"To")}',base[3],*['Route: '+r.strip() for r in routes],f'CSeq: {cseq} {method}','Max-Forwards: 70','Content-Length: 0','','']
|
||||||
|
|||||||
@@ -0,0 +1,752 @@
|
|||||||
|
{
|
||||||
|
"date": "2026-09-01T02:44:55.304Z",
|
||||||
|
"from": "2026-09-01T02:44:48.013Z",
|
||||||
|
"api": {
|
||||||
|
"status": 200,
|
||||||
|
"body": {
|
||||||
|
"metricVersion": "caller-analytics-v1.1",
|
||||||
|
"analysisRevision": "caller-analytics-r2",
|
||||||
|
"snapshotId": "11b2aac60d66a436654584f578321207b4f3c0195942b10630ebd7bba8ed39f6",
|
||||||
|
"view": "landing",
|
||||||
|
"from": "2026-09-01T02:44:48.013Z",
|
||||||
|
"to": "2026-09-01T02:44:55.140Z",
|
||||||
|
"asOf": "2026-09-01T02:44:55.150Z",
|
||||||
|
"qualityStatus": "LIVE",
|
||||||
|
"coverageStatus": "CONTINUOUS",
|
||||||
|
"dataThrough": "2026-09-01T02:44:53.599Z",
|
||||||
|
"lagMs": 468,
|
||||||
|
"durationPrecision": "milliseconds",
|
||||||
|
"health": {
|
||||||
|
"gap": false,
|
||||||
|
"lagMs": 468,
|
||||||
|
"bootId": "66823a5b-4361-4c39-8bf8-f8db9736347f",
|
||||||
|
"reason": "",
|
||||||
|
"degraded": false,
|
||||||
|
"updatedAt": "2026-09-01T02:44:54.959Z",
|
||||||
|
"dataThrough": "2026-09-01T02:44:53.599Z",
|
||||||
|
"spoolOffset": 109742,
|
||||||
|
"captureSince": "2026-09-01T02:23:21Z",
|
||||||
|
"coverageStatus": "CONTINUOUS",
|
||||||
|
"spoolUnreadBytes": 0,
|
||||||
|
"producerEventCount": 32,
|
||||||
|
"collectedEventCount": 32
|
||||||
|
},
|
||||||
|
"gaps": [],
|
||||||
|
"summary": {
|
||||||
|
"totalCalls": 6,
|
||||||
|
"connectedCalls": 4,
|
||||||
|
"answeredCalls": 2,
|
||||||
|
"failedCalls": 2,
|
||||||
|
"pendingCalls": 0,
|
||||||
|
"unknownCalls": 0,
|
||||||
|
"activeCalls": 0,
|
||||||
|
"talkMs": 1801,
|
||||||
|
"durationUnknownCalls": 0,
|
||||||
|
"provisionalAnsweredCalls": 0,
|
||||||
|
"notConnectedCalls": 2,
|
||||||
|
"connectionRate": 66.6667,
|
||||||
|
"overallAnswerRate": 33.3333,
|
||||||
|
"connectedAnswerRate": 50
|
||||||
|
},
|
||||||
|
"summaryScope": "时间及业务筛选;样本/比例/异常筛选仅影响号码列表",
|
||||||
|
"numbers": [
|
||||||
|
{
|
||||||
|
"customerId": "cra_20260831",
|
||||||
|
"caller": "99100001",
|
||||||
|
"totalCalls": 6,
|
||||||
|
"connectedCalls": 4,
|
||||||
|
"answeredCalls": 2,
|
||||||
|
"failedCalls": 2,
|
||||||
|
"pendingCalls": 0,
|
||||||
|
"unknownCalls": 0,
|
||||||
|
"activeCalls": 0,
|
||||||
|
"talkMs": 1801,
|
||||||
|
"durationUnknownCalls": 0,
|
||||||
|
"provisionalAnsweredCalls": 0,
|
||||||
|
"notConnectedCalls": 2,
|
||||||
|
"connectionRate": 66.6667,
|
||||||
|
"overallAnswerRate": 33.3333,
|
||||||
|
"connectedAnswerRate": 50,
|
||||||
|
"alert": null
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"total": 1,
|
||||||
|
"skip": 0,
|
||||||
|
"take": 25,
|
||||||
|
"trends": [
|
||||||
|
{
|
||||||
|
"time": 1788230640000,
|
||||||
|
"totalCalls": 6,
|
||||||
|
"connectedCalls": 4,
|
||||||
|
"answeredCalls": 2,
|
||||||
|
"failedCalls": 2,
|
||||||
|
"pendingCalls": 0,
|
||||||
|
"unknownCalls": 0,
|
||||||
|
"activeCalls": 0,
|
||||||
|
"talkMs": 1801,
|
||||||
|
"durationUnknownCalls": 0,
|
||||||
|
"provisionalAnsweredCalls": 0,
|
||||||
|
"caller": "",
|
||||||
|
"customerId": "",
|
||||||
|
"notConnectedCalls": 2,
|
||||||
|
"connectionRate": 66.6667,
|
||||||
|
"overallAnswerRate": 33.3333,
|
||||||
|
"connectedAnswerRate": 50
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"baseFilters": {
|
||||||
|
"view": "landing",
|
||||||
|
"from": "2026-09-01T02:44:48.013Z",
|
||||||
|
"to": "2026-09-01T02:44:55.140Z",
|
||||||
|
"customerId": "cra_20260831",
|
||||||
|
"customerGatewayId": "",
|
||||||
|
"vendorId": "",
|
||||||
|
"vendorGatewayId": "",
|
||||||
|
"caller": "",
|
||||||
|
"city": "",
|
||||||
|
"carrier": ""
|
||||||
|
},
|
||||||
|
"numberFilters": {
|
||||||
|
"minSamples": 0,
|
||||||
|
"onlyAlerts": false,
|
||||||
|
"connectionMin": 0,
|
||||||
|
"connectionMax": 100,
|
||||||
|
"overallMin": 0,
|
||||||
|
"overallMax": 100,
|
||||||
|
"connectedMin": 0,
|
||||||
|
"connectedMax": 100
|
||||||
|
},
|
||||||
|
"metricAvailability": {
|
||||||
|
"connectionRate": true,
|
||||||
|
"answerRates": true
|
||||||
|
},
|
||||||
|
"historyNotice": "当前窗口在连续采集覆盖内;旧固定6秒话单不参与校准。"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"detail": {
|
||||||
|
"rows": [
|
||||||
|
{
|
||||||
|
"id": "bd2f99d0f2f094dafa116dc400cfb9d7d8371005cc529737e327dfeb1d9b8345",
|
||||||
|
"callKey": "be549d085a7dee62f13ae303c379803081d7bc8e5436e80fed9a933707c22d0d",
|
||||||
|
"startedAt": "2026-09-01T02:44:52.099Z",
|
||||||
|
"caller": "99100001",
|
||||||
|
"callee": "99106",
|
||||||
|
"vendorGatewayId": "cra_20260831",
|
||||||
|
"counts": {
|
||||||
|
"talkMs": 0,
|
||||||
|
"totalCalls": 1,
|
||||||
|
"activeCalls": 0,
|
||||||
|
"failedCalls": 1,
|
||||||
|
"pendingCalls": 0,
|
||||||
|
"unknownCalls": 0,
|
||||||
|
"answeredCalls": 0,
|
||||||
|
"connectedCalls": 0,
|
||||||
|
"durationUnknownCalls": 0,
|
||||||
|
"provisionalAnsweredCalls": 0
|
||||||
|
},
|
||||||
|
"payload": {
|
||||||
|
"event": {
|
||||||
|
"at": 1788230692099,
|
||||||
|
"city": "UNKNOWN",
|
||||||
|
"code": 0,
|
||||||
|
"cseq": "",
|
||||||
|
"kind": "ATTEMPT",
|
||||||
|
"bootId": "66823a5b-4361-4c39-8bf8-f8db9736347f",
|
||||||
|
"branch": "",
|
||||||
|
"callId": "cra-99106-d9a7fdb7122b429b8eb2925d6ee8df7d@loopback",
|
||||||
|
"callee": "99106",
|
||||||
|
"caller": "99100001",
|
||||||
|
"method": "INVITE",
|
||||||
|
"nodeId": "YanZi",
|
||||||
|
"source": "opensips",
|
||||||
|
"talkMs": null,
|
||||||
|
"callUid": "e8c57831b2aac971c4ca3583cb67cad8582eed13b58e7ea39f5f0be2bc86c6ed",
|
||||||
|
"carrier": "UNKNOWN",
|
||||||
|
"eventId": "231e79d6950e2d7307bfe838c6baf2ca4d3ecc9c83bd929e6b3045bd2139021c",
|
||||||
|
"sequence": 1788230692099000,
|
||||||
|
"vendorId": "cra_20260831",
|
||||||
|
"attemptId": "1",
|
||||||
|
"startedAt": 1788230692099,
|
||||||
|
"customerId": "cra_20260831",
|
||||||
|
"receivedAt": 1788230692439,
|
||||||
|
"landingCaller": "99100001",
|
||||||
|
"schemaVersion": 2,
|
||||||
|
"evidenceVersion": 2,
|
||||||
|
"vendorGatewayId": "cra_20260831",
|
||||||
|
"customerGatewayId": "cra_20260831"
|
||||||
|
},
|
||||||
|
"talkMs": 0,
|
||||||
|
"endedAt": 1788230693599,
|
||||||
|
"unknown": false,
|
||||||
|
"finalCode": 486,
|
||||||
|
"acceptedAt": null,
|
||||||
|
"durationAt": 1788230693599,
|
||||||
|
"first180At": null,
|
||||||
|
"first183At": null,
|
||||||
|
"durationFinal": true,
|
||||||
|
"talkStartedAt": null,
|
||||||
|
"durationComplete": true,
|
||||||
|
"durationAuthority": 1,
|
||||||
|
"terminationReason": "verified-dialog-final"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "1ced517eabcf44a9c87bbb01859b0478486e3de0c57c9aacbc88373b26caa332",
|
||||||
|
"callKey": "87a0252e111310be93d0ac6200ca31519408e7eda0f6d0019e58129dbb394c8e",
|
||||||
|
"startedAt": "2026-09-01T02:44:51.345Z",
|
||||||
|
"caller": "99100001",
|
||||||
|
"callee": "99105",
|
||||||
|
"vendorGatewayId": "cra_20260831",
|
||||||
|
"counts": {
|
||||||
|
"talkMs": 300,
|
||||||
|
"totalCalls": 1,
|
||||||
|
"activeCalls": 0,
|
||||||
|
"failedCalls": 0,
|
||||||
|
"pendingCalls": 0,
|
||||||
|
"unknownCalls": 0,
|
||||||
|
"answeredCalls": 1,
|
||||||
|
"connectedCalls": 1,
|
||||||
|
"durationUnknownCalls": 0,
|
||||||
|
"provisionalAnsweredCalls": 0
|
||||||
|
},
|
||||||
|
"payload": {
|
||||||
|
"event": {
|
||||||
|
"at": 1788230691346,
|
||||||
|
"city": "UNKNOWN",
|
||||||
|
"code": 0,
|
||||||
|
"cseq": "",
|
||||||
|
"kind": "ATTEMPT",
|
||||||
|
"bootId": "66823a5b-4361-4c39-8bf8-f8db9736347f",
|
||||||
|
"branch": "",
|
||||||
|
"callId": "cra-99105-f2cc27a5f1c44e3097a1ba39497600a1@loopback",
|
||||||
|
"callee": "99105",
|
||||||
|
"caller": "99100001",
|
||||||
|
"method": "INVITE",
|
||||||
|
"nodeId": "YanZi",
|
||||||
|
"source": "opensips",
|
||||||
|
"talkMs": null,
|
||||||
|
"callUid": "158db5741881d9555396f88f8d71be8114b3de9b3fbb01b1977afc3216c191f3",
|
||||||
|
"carrier": "UNKNOWN",
|
||||||
|
"eventId": "a2573b627de297d67e9800ad9ce123999e4a56885c9fe4b60f8e046df0072622",
|
||||||
|
"sequence": 1788230691346000,
|
||||||
|
"vendorId": "cra_20260831",
|
||||||
|
"attemptId": "1",
|
||||||
|
"startedAt": 1788230691345,
|
||||||
|
"customerId": "cra_20260831",
|
||||||
|
"receivedAt": 1788230691655,
|
||||||
|
"landingCaller": "99100001",
|
||||||
|
"schemaVersion": 2,
|
||||||
|
"evidenceVersion": 2,
|
||||||
|
"vendorGatewayId": "cra_20260831",
|
||||||
|
"customerGatewayId": "cra_20260831"
|
||||||
|
},
|
||||||
|
"talkMs": 300,
|
||||||
|
"endedAt": 1788230691797,
|
||||||
|
"unknown": false,
|
||||||
|
"finalCode": 200,
|
||||||
|
"acceptedAt": 1788230691496,
|
||||||
|
"durationAt": 1788230691797,
|
||||||
|
"first180At": 1788230691346,
|
||||||
|
"first183At": null,
|
||||||
|
"durationFinal": true,
|
||||||
|
"talkStartedAt": 1788230691497,
|
||||||
|
"durationComplete": true,
|
||||||
|
"durationAuthority": 1,
|
||||||
|
"terminationReason": "verified-dialog-final"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "4c4589bdcd2ef482d577a76f47e7e3cd504bd05aee95b6228a86939e89530940",
|
||||||
|
"callKey": "52584c11a53a7388dd68ad49afc1e5dfc407b851aa12a813229eb81265975f71",
|
||||||
|
"startedAt": "2026-09-01T02:44:49.540Z",
|
||||||
|
"caller": "99100001",
|
||||||
|
"callee": "99104",
|
||||||
|
"vendorGatewayId": "cra_20260831",
|
||||||
|
"counts": {
|
||||||
|
"talkMs": 1501,
|
||||||
|
"totalCalls": 1,
|
||||||
|
"activeCalls": 0,
|
||||||
|
"failedCalls": 0,
|
||||||
|
"pendingCalls": 0,
|
||||||
|
"unknownCalls": 0,
|
||||||
|
"answeredCalls": 1,
|
||||||
|
"connectedCalls": 1,
|
||||||
|
"durationUnknownCalls": 0,
|
||||||
|
"provisionalAnsweredCalls": 0
|
||||||
|
},
|
||||||
|
"payload": {
|
||||||
|
"event": {
|
||||||
|
"at": 1788230689541,
|
||||||
|
"city": "UNKNOWN",
|
||||||
|
"code": 0,
|
||||||
|
"cseq": "",
|
||||||
|
"kind": "ATTEMPT",
|
||||||
|
"bootId": "66823a5b-4361-4c39-8bf8-f8db9736347f",
|
||||||
|
"branch": "",
|
||||||
|
"callId": "cra-99104-95c6b3c023b94f4f9fdd7781c37587f8@loopback",
|
||||||
|
"callee": "99104",
|
||||||
|
"caller": "99100001",
|
||||||
|
"method": "INVITE",
|
||||||
|
"nodeId": "YanZi",
|
||||||
|
"source": "opensips",
|
||||||
|
"talkMs": null,
|
||||||
|
"callUid": "5c68fe746adb05173b06bbc7f1fc3fb60b15a2c155500be4d5c860a34fcd408d",
|
||||||
|
"carrier": "UNKNOWN",
|
||||||
|
"eventId": "1b15c22588b24db034cff9e4a7bfa135d326ba3b38c8f592952fb72aaf13c041",
|
||||||
|
"sequence": 1788230689541000,
|
||||||
|
"vendorId": "cra_20260831",
|
||||||
|
"attemptId": "1",
|
||||||
|
"startedAt": 1788230689540,
|
||||||
|
"customerId": "cra_20260831",
|
||||||
|
"receivedAt": 1788230689889,
|
||||||
|
"landingCaller": "99100001",
|
||||||
|
"schemaVersion": 2,
|
||||||
|
"evidenceVersion": 2,
|
||||||
|
"vendorGatewayId": "cra_20260831",
|
||||||
|
"customerGatewayId": "cra_20260831"
|
||||||
|
},
|
||||||
|
"talkMs": 1501,
|
||||||
|
"endedAt": 1788230691043,
|
||||||
|
"unknown": false,
|
||||||
|
"finalCode": 200,
|
||||||
|
"acceptedAt": 1788230689541,
|
||||||
|
"durationAt": 1788230691043,
|
||||||
|
"first180At": null,
|
||||||
|
"first183At": null,
|
||||||
|
"durationFinal": true,
|
||||||
|
"talkStartedAt": 1788230689542,
|
||||||
|
"durationComplete": true,
|
||||||
|
"durationAuthority": 1,
|
||||||
|
"terminationReason": "verified-dialog-final"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "cdcbfca07604ebff4bbe4fe6734873f0eea61d1f798825b0ff2e698e33d0d6e6",
|
||||||
|
"callKey": "7a108c8adcfc4af723c08103a2be7bba190f9d42a6b8edc272cc2542a5fe6330",
|
||||||
|
"startedAt": "2026-09-01T02:44:49.239Z",
|
||||||
|
"caller": "99100001",
|
||||||
|
"callee": "99103",
|
||||||
|
"vendorGatewayId": "cra_20260831",
|
||||||
|
"counts": {
|
||||||
|
"talkMs": 0,
|
||||||
|
"totalCalls": 1,
|
||||||
|
"activeCalls": 0,
|
||||||
|
"failedCalls": 1,
|
||||||
|
"pendingCalls": 0,
|
||||||
|
"unknownCalls": 0,
|
||||||
|
"answeredCalls": 0,
|
||||||
|
"connectedCalls": 0,
|
||||||
|
"durationUnknownCalls": 0,
|
||||||
|
"provisionalAnsweredCalls": 0
|
||||||
|
},
|
||||||
|
"payload": {
|
||||||
|
"event": {
|
||||||
|
"at": 1788230689239,
|
||||||
|
"city": "UNKNOWN",
|
||||||
|
"code": 0,
|
||||||
|
"cseq": "",
|
||||||
|
"kind": "ATTEMPT",
|
||||||
|
"bootId": "66823a5b-4361-4c39-8bf8-f8db9736347f",
|
||||||
|
"branch": "",
|
||||||
|
"callId": "cra-99103-53e5a373297a43919927eebd528cd362@loopback",
|
||||||
|
"callee": "99103",
|
||||||
|
"caller": "99100001",
|
||||||
|
"method": "INVITE",
|
||||||
|
"nodeId": "YanZi",
|
||||||
|
"source": "opensips",
|
||||||
|
"talkMs": null,
|
||||||
|
"callUid": "cd37b5bfc20e65e02019970b922add6fab0e8980d6620fb4edadbde91428dc47",
|
||||||
|
"carrier": "UNKNOWN",
|
||||||
|
"eventId": "60c7fa2f2798a67e603bb6d4a39daa3bdc402ca3d3a924611e1067692372dba3",
|
||||||
|
"sequence": 1788230689239000,
|
||||||
|
"vendorId": "cra_20260831",
|
||||||
|
"attemptId": "1",
|
||||||
|
"startedAt": 1788230689239,
|
||||||
|
"customerId": "cra_20260831",
|
||||||
|
"receivedAt": 1788230689889,
|
||||||
|
"landingCaller": "99100001",
|
||||||
|
"schemaVersion": 2,
|
||||||
|
"evidenceVersion": 2,
|
||||||
|
"vendorGatewayId": "cra_20260831",
|
||||||
|
"customerGatewayId": "cra_20260831"
|
||||||
|
},
|
||||||
|
"talkMs": 0,
|
||||||
|
"endedAt": 1788230689239,
|
||||||
|
"unknown": false,
|
||||||
|
"finalCode": 486,
|
||||||
|
"acceptedAt": null,
|
||||||
|
"durationAt": 1788230689239,
|
||||||
|
"first180At": null,
|
||||||
|
"first183At": null,
|
||||||
|
"durationFinal": true,
|
||||||
|
"talkStartedAt": null,
|
||||||
|
"durationComplete": true,
|
||||||
|
"durationAuthority": 1,
|
||||||
|
"terminationReason": "verified-dialog-final"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "662cc9075f141345c45a274d2412d4ebffdf043dd75fa1b6e1a6ac9d85ce9460",
|
||||||
|
"callKey": "6c05dd76e643c25294847a1163d623262108b4176e37e33b51fc3685355f19f9",
|
||||||
|
"startedAt": "2026-09-01T02:44:48.787Z",
|
||||||
|
"caller": "99100001",
|
||||||
|
"callee": "99102",
|
||||||
|
"vendorGatewayId": "cra_20260831",
|
||||||
|
"counts": {
|
||||||
|
"talkMs": 0,
|
||||||
|
"totalCalls": 1,
|
||||||
|
"activeCalls": 0,
|
||||||
|
"failedCalls": 0,
|
||||||
|
"pendingCalls": 0,
|
||||||
|
"unknownCalls": 0,
|
||||||
|
"answeredCalls": 0,
|
||||||
|
"connectedCalls": 1,
|
||||||
|
"durationUnknownCalls": 0,
|
||||||
|
"provisionalAnsweredCalls": 0
|
||||||
|
},
|
||||||
|
"payload": {
|
||||||
|
"event": {
|
||||||
|
"at": 1788230688787,
|
||||||
|
"city": "UNKNOWN",
|
||||||
|
"code": 0,
|
||||||
|
"cseq": "",
|
||||||
|
"kind": "ATTEMPT",
|
||||||
|
"bootId": "66823a5b-4361-4c39-8bf8-f8db9736347f",
|
||||||
|
"branch": "",
|
||||||
|
"callId": "cra-99102-5b8fad37fb9141d6ba5e5453e8372d4a@loopback",
|
||||||
|
"callee": "99102",
|
||||||
|
"caller": "99100001",
|
||||||
|
"method": "INVITE",
|
||||||
|
"nodeId": "YanZi",
|
||||||
|
"source": "opensips",
|
||||||
|
"talkMs": null,
|
||||||
|
"callUid": "7cda6788fb05dd8dcf36a3c2ffbb8fe124c9746a2c32bca294954ef72c91c748",
|
||||||
|
"carrier": "UNKNOWN",
|
||||||
|
"eventId": "17d7dc0a0bb67acb249cb5a1f82615e513853127cd327818224c492fa0dc16a3",
|
||||||
|
"sequence": 1788230688787000,
|
||||||
|
"vendorId": "cra_20260831",
|
||||||
|
"attemptId": "1",
|
||||||
|
"startedAt": 1788230688787,
|
||||||
|
"customerId": "cra_20260831",
|
||||||
|
"receivedAt": 1788230689112,
|
||||||
|
"landingCaller": "99100001",
|
||||||
|
"schemaVersion": 2,
|
||||||
|
"evidenceVersion": 2,
|
||||||
|
"vendorGatewayId": "cra_20260831",
|
||||||
|
"customerGatewayId": "cra_20260831"
|
||||||
|
},
|
||||||
|
"talkMs": 0,
|
||||||
|
"endedAt": 1788230688938,
|
||||||
|
"unknown": false,
|
||||||
|
"finalCode": 487,
|
||||||
|
"acceptedAt": null,
|
||||||
|
"durationAt": 1788230688938,
|
||||||
|
"first180At": null,
|
||||||
|
"first183At": 1788230688787,
|
||||||
|
"durationFinal": true,
|
||||||
|
"talkStartedAt": null,
|
||||||
|
"durationComplete": true,
|
||||||
|
"durationAuthority": 1,
|
||||||
|
"terminationReason": "verified-dialog-final"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "f5a3e11aa1b23e91559582bf2ea0dbc32032c0b5bb55a04d28f1ab02bbb5a64b",
|
||||||
|
"callKey": "fe25de2674a793555634a0b1732790425667f16cc81f89a3e28aaa46e35c3acc",
|
||||||
|
"startedAt": "2026-09-01T02:44:48.335Z",
|
||||||
|
"caller": "99100001",
|
||||||
|
"callee": "99101",
|
||||||
|
"vendorGatewayId": "cra_20260831",
|
||||||
|
"counts": {
|
||||||
|
"talkMs": 0,
|
||||||
|
"totalCalls": 1,
|
||||||
|
"activeCalls": 0,
|
||||||
|
"failedCalls": 0,
|
||||||
|
"pendingCalls": 0,
|
||||||
|
"unknownCalls": 0,
|
||||||
|
"answeredCalls": 0,
|
||||||
|
"connectedCalls": 1,
|
||||||
|
"durationUnknownCalls": 0,
|
||||||
|
"provisionalAnsweredCalls": 0
|
||||||
|
},
|
||||||
|
"payload": {
|
||||||
|
"event": {
|
||||||
|
"at": 1788230688335,
|
||||||
|
"city": "UNKNOWN",
|
||||||
|
"code": 0,
|
||||||
|
"cseq": "",
|
||||||
|
"kind": "ATTEMPT",
|
||||||
|
"bootId": "66823a5b-4361-4c39-8bf8-f8db9736347f",
|
||||||
|
"branch": "",
|
||||||
|
"callId": "cra-99101-7c962c8b0e6f4dc6aa922e850c96cbbe@loopback",
|
||||||
|
"callee": "99101",
|
||||||
|
"caller": "99100001",
|
||||||
|
"method": "INVITE",
|
||||||
|
"nodeId": "YanZi",
|
||||||
|
"source": "opensips",
|
||||||
|
"talkMs": null,
|
||||||
|
"callUid": "69136797dbe483cbd977dcf5576464b07eda7fe03ca8d7810144b9de44b99423",
|
||||||
|
"carrier": "UNKNOWN",
|
||||||
|
"eventId": "d4b0b273a78d6aad97e695882c34a44c59fc8a9c1130b98c5865b4b00f8cadb1",
|
||||||
|
"sequence": 1788230688335000,
|
||||||
|
"vendorId": "cra_20260831",
|
||||||
|
"attemptId": "1",
|
||||||
|
"startedAt": 1788230688335,
|
||||||
|
"customerId": "cra_20260831",
|
||||||
|
"receivedAt": 1788230688370,
|
||||||
|
"landingCaller": "99100001",
|
||||||
|
"schemaVersion": 2,
|
||||||
|
"evidenceVersion": 2,
|
||||||
|
"vendorGatewayId": "cra_20260831",
|
||||||
|
"customerGatewayId": "cra_20260831"
|
||||||
|
},
|
||||||
|
"talkMs": 0,
|
||||||
|
"endedAt": 1788230688486,
|
||||||
|
"unknown": false,
|
||||||
|
"finalCode": 486,
|
||||||
|
"acceptedAt": null,
|
||||||
|
"durationAt": 1788230688486,
|
||||||
|
"first180At": 1788230688336,
|
||||||
|
"first183At": null,
|
||||||
|
"durationFinal": true,
|
||||||
|
"talkStartedAt": null,
|
||||||
|
"durationComplete": true,
|
||||||
|
"durationAuthority": 1,
|
||||||
|
"terminationReason": "verified-dialog-final"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"breakdown": [
|
||||||
|
{
|
||||||
|
"vendorGatewayId": "cra_20260831",
|
||||||
|
"city": "UNKNOWN",
|
||||||
|
"carrier": "UNKNOWN",
|
||||||
|
"totalCalls": 6,
|
||||||
|
"connectedCalls": 4,
|
||||||
|
"answeredCalls": 2,
|
||||||
|
"failedCalls": 2,
|
||||||
|
"pendingCalls": 0,
|
||||||
|
"unknownCalls": 0,
|
||||||
|
"activeCalls": 0,
|
||||||
|
"talkMs": 1801,
|
||||||
|
"durationUnknownCalls": 0,
|
||||||
|
"provisionalAnsweredCalls": 0,
|
||||||
|
"caller": "",
|
||||||
|
"customerId": "",
|
||||||
|
"notConnectedCalls": 2,
|
||||||
|
"connectionRate": 66.6667,
|
||||||
|
"overallAnswerRate": 33.3333,
|
||||||
|
"connectedAnswerRate": 50
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"breakdownTruncated": false,
|
||||||
|
"skip": 0,
|
||||||
|
"take": 25
|
||||||
|
},
|
||||||
|
"sip": [
|
||||||
|
{
|
||||||
|
"callId": "cra-99101-7c962c8b0e6f4dc6aa922e850c96cbbe@loopback",
|
||||||
|
"direction": "UAC",
|
||||||
|
"code": 100,
|
||||||
|
"sdp": false,
|
||||||
|
"time": 1788230688.3355267
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"callId": "cra-99101-7c962c8b0e6f4dc6aa922e850c96cbbe@loopback",
|
||||||
|
"direction": "UAS",
|
||||||
|
"code": 180,
|
||||||
|
"sdp": false,
|
||||||
|
"time": 1788230688.3360817
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"callId": "cra-99101-7c962c8b0e6f4dc6aa922e850c96cbbe@loopback",
|
||||||
|
"direction": "UAC",
|
||||||
|
"code": 180,
|
||||||
|
"sdp": false,
|
||||||
|
"time": 1788230688.3362823
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"callId": "cra-99101-7c962c8b0e6f4dc6aa922e850c96cbbe@loopback",
|
||||||
|
"direction": "UAS",
|
||||||
|
"code": 486,
|
||||||
|
"sdp": false,
|
||||||
|
"time": 1788230688.4863043
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"callId": "cra-99101-7c962c8b0e6f4dc6aa922e850c96cbbe@loopback",
|
||||||
|
"direction": "UAC",
|
||||||
|
"code": 486,
|
||||||
|
"sdp": false,
|
||||||
|
"time": 1788230688.4864848
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"callId": "cra-99102-5b8fad37fb9141d6ba5e5453e8372d4a@loopback",
|
||||||
|
"direction": "UAC",
|
||||||
|
"code": 100,
|
||||||
|
"sdp": false,
|
||||||
|
"time": 1788230688.7877743
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"callId": "cra-99102-5b8fad37fb9141d6ba5e5453e8372d4a@loopback",
|
||||||
|
"direction": "UAS",
|
||||||
|
"code": 183,
|
||||||
|
"sdp": false,
|
||||||
|
"time": 1788230688.7879457
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"callId": "cra-99102-5b8fad37fb9141d6ba5e5453e8372d4a@loopback",
|
||||||
|
"direction": "UAC",
|
||||||
|
"code": 183,
|
||||||
|
"sdp": false,
|
||||||
|
"time": 1788230688.7880127
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"callId": "cra-99102-5b8fad37fb9141d6ba5e5453e8372d4a@loopback",
|
||||||
|
"direction": "UAS",
|
||||||
|
"code": 487,
|
||||||
|
"sdp": false,
|
||||||
|
"time": 1788230688.9381335
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"callId": "cra-99102-5b8fad37fb9141d6ba5e5453e8372d4a@loopback",
|
||||||
|
"direction": "UAC",
|
||||||
|
"code": 487,
|
||||||
|
"sdp": false,
|
||||||
|
"time": 1788230688.9384828
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"callId": "cra-99103-53e5a373297a43919927eebd528cd362@loopback",
|
||||||
|
"direction": "UAC",
|
||||||
|
"code": 100,
|
||||||
|
"sdp": false,
|
||||||
|
"time": 1788230689.239587
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"callId": "cra-99103-53e5a373297a43919927eebd528cd362@loopback",
|
||||||
|
"direction": "UAS",
|
||||||
|
"code": 486,
|
||||||
|
"sdp": false,
|
||||||
|
"time": 1788230689.2397466
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"callId": "cra-99103-53e5a373297a43919927eebd528cd362@loopback",
|
||||||
|
"direction": "UAC",
|
||||||
|
"code": 486,
|
||||||
|
"sdp": false,
|
||||||
|
"time": 1788230689.2398334
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"callId": "cra-99104-95c6b3c023b94f4f9fdd7781c37587f8@loopback",
|
||||||
|
"direction": "UAC",
|
||||||
|
"code": 100,
|
||||||
|
"sdp": false,
|
||||||
|
"time": 1788230689.541705
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"callId": "cra-99104-95c6b3c023b94f4f9fdd7781c37587f8@loopback",
|
||||||
|
"direction": "UAS",
|
||||||
|
"code": 200,
|
||||||
|
"sdp": true,
|
||||||
|
"time": 1788230689.5418484
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"callId": "cra-99104-95c6b3c023b94f4f9fdd7781c37587f8@loopback",
|
||||||
|
"direction": "UAC",
|
||||||
|
"code": 200,
|
||||||
|
"sdp": true,
|
||||||
|
"time": 1788230689.54246
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"callId": "cra-99105-f2cc27a5f1c44e3097a1ba39497600a1@loopback",
|
||||||
|
"direction": "UAC",
|
||||||
|
"code": 100,
|
||||||
|
"sdp": false,
|
||||||
|
"time": 1788230691.3461745
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"callId": "cra-99105-f2cc27a5f1c44e3097a1ba39497600a1@loopback",
|
||||||
|
"direction": "UAS",
|
||||||
|
"code": 180,
|
||||||
|
"sdp": false,
|
||||||
|
"time": 1788230691.3463302
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"callId": "cra-99105-f2cc27a5f1c44e3097a1ba39497600a1@loopback",
|
||||||
|
"direction": "UAC",
|
||||||
|
"code": 180,
|
||||||
|
"sdp": false,
|
||||||
|
"time": 1788230691.3463998
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"callId": "cra-99105-f2cc27a5f1c44e3097a1ba39497600a1@loopback",
|
||||||
|
"direction": "UAS",
|
||||||
|
"code": 200,
|
||||||
|
"sdp": true,
|
||||||
|
"time": 1788230691.4965453
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"callId": "cra-99105-f2cc27a5f1c44e3097a1ba39497600a1@loopback",
|
||||||
|
"direction": "UAC",
|
||||||
|
"code": 200,
|
||||||
|
"sdp": true,
|
||||||
|
"time": 1788230691.4974086
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"callId": "cra-99106-d9a7fdb7122b429b8eb2925d6ee8df7d@loopback",
|
||||||
|
"direction": "UAC",
|
||||||
|
"code": 100,
|
||||||
|
"sdp": false,
|
||||||
|
"time": 1788230692.099361
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"callId": "cra-99106-d9a7fdb7122b429b8eb2925d6ee8df7d@loopback",
|
||||||
|
"direction": "UAS",
|
||||||
|
"code": 100,
|
||||||
|
"sdp": false,
|
||||||
|
"time": 1788230692.0994966
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"callId": "cra-99106-d9a7fdb7122b429b8eb2925d6ee8df7d@loopback",
|
||||||
|
"direction": "UAS",
|
||||||
|
"code": 486,
|
||||||
|
"sdp": false,
|
||||||
|
"time": 1788230693.5997336
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"callId": "cra-99106-d9a7fdb7122b429b8eb2925d6ee8df7d@loopback",
|
||||||
|
"direction": "UAC",
|
||||||
|
"code": 486,
|
||||||
|
"sdp": false,
|
||||||
|
"time": 1788230693.5999396
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"bucketAudit": {
|
||||||
|
"totalCalls": 54,
|
||||||
|
"connectedCalls": 36,
|
||||||
|
"answeredCalls": 16,
|
||||||
|
"failedCalls": 18,
|
||||||
|
"pendingCalls": 0,
|
||||||
|
"unknownCalls": 0,
|
||||||
|
"activeCalls": 0,
|
||||||
|
"talkMs": 14405,
|
||||||
|
"durationUnknownCalls": 2,
|
||||||
|
"provisionalAnsweredCalls": 0,
|
||||||
|
"notConnectedCalls": 18,
|
||||||
|
"connectionRate": null,
|
||||||
|
"overallAnswerRate": null,
|
||||||
|
"connectedAnswerRate": null
|
||||||
|
},
|
||||||
|
"replayed": 5,
|
||||||
|
"tests": [
|
||||||
|
"real SIP six scenarios",
|
||||||
|
"two SDP offer/answer negotiations through RTPEngine",
|
||||||
|
"ACK-based positive duration including subsecond call",
|
||||||
|
"real API counts, completeness and three rates",
|
||||||
|
"real DB states and minute buckets",
|
||||||
|
"database replay idempotency",
|
||||||
|
"customer scope and 403 isolation",
|
||||||
|
"scoped customer/vendor filter options",
|
||||||
|
"unauthenticated 401",
|
||||||
|
"invalid query 400"
|
||||||
|
]
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user