fix: complete caller analytics reliability remediation
This commit is contained in:
@@ -1,5 +1,5 @@
|
||||
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 type { CurrentUser } from '../security/security.metadata.js';
|
||||
|
||||
@@ -68,46 +68,56 @@ export class CallerAnalyticsService {
|
||||
}
|
||||
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 [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 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<{ 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个,请缩短时间或筛选客户');
|
||||
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 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) => {
|
||||
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;
|
||||
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 h = health[0]; const quality = !h || Date.now() - +h.updated_at > 10000 || h.payload.degraded ? 'DEGRADED' : 'LIVE';
|
||||
return { metricVersion: ANALYTICS_VERSION, view: q.view, from: q.from, to: q.to, asOf: new Date(), qualityStatus: quality,
|
||||
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()]);
|
||||
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',
|
||||
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,
|
||||
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) {
|
||||
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 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 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`);
|
||||
return { rows, breakdown: breakdown.map(normalize), skip: q.skip, take: q.take };
|
||||
const [rows,breakdownRows]=await this.db.$transaction([
|
||||
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) {
|
||||
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.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.
|
||||
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) }
|
||||
];
|
||||
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() {
|
||||
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 [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 [target, setTarget] = useState(null); const [detail, setDetail] = useState(null); const [detailError, setDetailError] = useState('');
|
||||
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)); });
|
||||
return () => c.abort();
|
||||
}, [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 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>;
|
||||
@@ -63,7 +63,7 @@ export function CallerAnalyticsPage() {
|
||||
<Field label="主叫号码"><Input value={draft.caller} onChange={e => change('caller', e.target.value)} placeholder="精确匹配号码" /></Field>
|
||||
{select('客户', 'customerId', options.customers)}
|
||||
{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]})))}
|
||||
<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>
|
||||
@@ -75,8 +75,8 @@ export function CallerAnalyticsPage() {
|
||||
<Button type="submit">查询</Button>
|
||||
</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>
|
||||
<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>
|
||||
{data ? <Alert title="口径与数据范围" tone={data.qualityStatus === 'LIVE' ? 'info' : 'warning'}>{data.historyNotice} {data.summaryScope}。183 不代表实际振铃;未接通数含待接通,进行中指标为暂定值。采集运行状态不代表窗口数据完整性已核验,端到端延迟尚未测定。</Alert> : null}
|
||||
<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 不代表实际振铃;未接通数含待接通。口径 {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>}>
|
||||
<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>}
|
||||
@@ -84,7 +84,7 @@ export function CallerAnalyticsPage() {
|
||||
<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={[
|
||||
{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}))} />
|
||||
</Panel>
|
||||
@@ -95,9 +95,9 @@ export function CallerAnalyticsPage() {
|
||||
<Panel title="关联呼叫与信令证据"><SimpleTable rows={(detail?.rows ?? []).map(r => {
|
||||
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,
|
||||
phase:legs.map(l => l.first180At ? '180 Ringing' : l.first183At ? '183 Session Progress' : l.acceptedAt ? '直接成功' : '未达接通').join(' / '),
|
||||
final:legs.map(l => l.finalCode || '进行中').join(' / '), duration:`${(r.counts.talkMs/1000).toFixed(3)} 秒${r.counts.activeCalls ? '(暂定)' : ''}`, 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>
|
||||
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 ? `初始 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:'初始 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>
|
||||
<p className="muted-text">按 Call-ID 对应原始话单或信令;旧固定时长话单仅作追溯,不参与本统计校准。</p>
|
||||
</Drawer> : null}
|
||||
|
||||
@@ -1,36 +1,20 @@
|
||||
import { PrismaClient, Prisma, analyticsHash, analyticsRates, COUNT_KEYS, type AnalyticsCounts } from '@lisglosips/database';
|
||||
|
||||
export async function evaluateCallerAlerts(db: PrismaClient) {
|
||||
const end = new Date(Math.floor(Date.now() / 60000) * 60000); const start = new Date(+end - 15 * 60000);
|
||||
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 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`);
|
||||
for (const row of rows) {
|
||||
const counts = Object.fromEntries(COUNT_KEYS.map(k => [k, Number(row[k])])) as unknown as AnalyticsCounts;
|
||||
const rates = analyticsRates(counts);
|
||||
if (rates.unknownCalls || rates.activeCalls / rates.totalCalls > 0.2) continue;
|
||||
const reasons = [
|
||||
rates.connectionRate !== null && rates.connectionRate < Number(process.env.ANALYTICS_LOW_CONNECTION_RATE ?? 20) ? '低接通率' : '',
|
||||
rates.overallAnswerRate !== null && rates.overallAnswerRate < Number(process.env.ANALYTICS_LOW_OVERALL_RATE ?? 10) ? '低总体应答率' : '',
|
||||
rates.connectedCalls >= 30 && rates.connectedAnswerRate !== null && rates.connectedAnswerRate < Number(process.env.ANALYTICS_LOW_CONNECTED_RATE ?? 20) ? '低已接通应答率' : ''
|
||||
].filter(Boolean);
|
||||
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 p = analyticsRates(Object.fromEntries(COUNT_KEYS.map(k => [k, Number(previous[0]?.[k] ?? 0)])) as unknown as AnalyticsCounts);
|
||||
if (p.totalCalls >= 50 && !p.unknownCalls && 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.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'`;
|
||||
import { COUNT_KEYS, Prisma, PrismaClient, analyticsHash, analyticsRates, type AnalyticsCounts } from '@lisglosips/database';
|
||||
type Row={customer_id:string;caller:string;view:string}&AnalyticsCounts;
|
||||
type Prior={status:string;payload:{streak?:number;recovered?:number;since?:string;evaluationKey?:string}};
|
||||
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 numeric=(row:Partial<Row>):AnalyticsCounts=>Object.fromEntries(COUNT_KEYS.map(k=>[k,Number(row[k]??0)]))as unknown as AnalyticsCounts;
|
||||
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')`;}
|
||||
export async function evaluateCallerAlerts(db:PrismaClient){
|
||||
const end=new Date(Math.floor(Date.now()/60000)*60000),start=new Date(+end-15*60000),evaluationKey=end.toISOString(),aggregates=sums();
|
||||
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`);
|
||||
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>();
|
||||
if(rates.connectionRate!==null&&rates.connectionRate<Number(process.env.ANALYTICS_LOW_CONNECTION_RATE??20))reasons.set('LOW_CONNECTION','低接通率');
|
||||
if(rates.overallAnswerRate!==null&&rates.overallAnswerRate<Number(process.env.ANALYTICS_LOW_OVERALL_RATE??10))reasons.set('LOW_OVERALL','低总体应答率');
|
||||
if(rates.connectedCalls>=30&&rates.connectedAnswerRate!==null&&rates.connectedAnswerRate<Number(process.env.ANALYTICS_LOW_CONNECTED_RATE??20))reasons.set('LOW_CONNECTED','低已接通应答率');
|
||||
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]??{}));
|
||||
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 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次已结束未接通');
|
||||
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)`;}
|
||||
}
|
||||
await db.$executeRaw`UPDATE caller_analysis_alerts SET status='STALE' WHERE updated_at<${new Date(Date.now()-120000)} AND status IN ('ACTIVE','OBSERVING','SUSPENDED')`;
|
||||
}
|
||||
|
||||
@@ -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 { 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 { createLogger } from '@lisglosips/observability';
|
||||
import { evaluateCallerAlerts } from './analytics-alerts.js';
|
||||
import { evaluateCallerAlerts, suspendCallerAlerts } from './analytics-alerts.js';
|
||||
|
||||
const logger = createLogger('caller-analytics');
|
||||
const db = new PrismaClient(); const redis = createRedisClient(process.env.REDIS_URL ?? '');
|
||||
const store = new CallerAnalyticsStore(db);
|
||||
const logger = createLogger('caller-analytics'); const db = new PrismaClient();
|
||||
const redis = createRedisClient(process.env.REDIS_URL ?? ''); const store = new CallerAnalyticsStore(db);
|
||||
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';
|
||||
let cursor = { offset: 0, ino: 0 }; let stopping = false; let lastAlerts = 0;
|
||||
let dataThrough: string | null = null; let gap = false;
|
||||
const consumer = `${hostname()}-${process.pid}`;
|
||||
process.on('SIGTERM', () => { stopping = true; }); process.on('SIGINT', () => { stopping = true; });
|
||||
interface CollectorCursor { offset:number; ino:number; gap?:boolean; eventsSinceProducerStart?:number; lastProducerCount?:number }
|
||||
let cursor:CollectorCursor={offset:0,ino:0,eventsSinceProducerStart:0}; let stopping=false; let lastAlerts=0; let lastTrim=0; let lastAlertSuspend=0;
|
||||
let dataThrough:string|null=null; let ingestLagMs:number|null=null; let gap=false; let bootId='unknown';
|
||||
const consumer=`${hostname()}-${process.pid}`;
|
||||
process.on('SIGTERM',()=>{stopping=true;}); process.on('SIGINT',()=>{stopping=true;});
|
||||
|
||||
function decodeLine(line: string): AnalyticsEvent | null {
|
||||
const marker = line.indexOf('CRA1|'); if (marker < 0) return null;
|
||||
const parts = line.slice(marker).trim().split('|');
|
||||
if (parts.length !== 20) throw new Error('Malformed producer record');
|
||||
const decoded = parts.slice(8).map(s => Buffer.from(s, 'base64').toString('utf8'));
|
||||
const [uid, callId, customerId, customerGatewayId, vendorId, vendorGatewayId, caller, landingCaller, callee, city, carrier, attemptId] = decoded;
|
||||
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 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' };
|
||||
function decodeLine(line:string):AnalyticsEvent|null {
|
||||
const marker=line.indexOf('CRA1|'); if(marker<0)return null; const parts=line.slice(marker).trim().split('|');
|
||||
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 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 fields={version:'2',eventId:analyticsHash(parts),callUid:analyticsHash([uid,customerId,hostname()]),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:String(at*1000),nodeId:hostname(),bootId,receivedAt:String(receivedAt),method:'INVITE',evidenceVersion:'2'};
|
||||
return parseAnalyticsEvent(Object.entries(fields).flat());
|
||||
}
|
||||
async function collect() {
|
||||
const info = await stat(spool);
|
||||
if (cursor.ino && (cursor.ino !== info.ino || info.size < cursor.offset)) { gap = true; cursor.offset = 0; }
|
||||
cursor.ino = info.ino;
|
||||
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;
|
||||
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 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))`;}
|
||||
async function persistCursor(){await writeFile(`${checkpoint}.tmp`,JSON.stringify({...cursor,gap}),{mode:0o600});await rename(`${checkpoint}.tmp`,checkpoint);}
|
||||
async function collect(){
|
||||
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'); 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;
|
||||
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});}}
|
||||
cursor.offset+=last+1;await persistCursor();}finally{await file.close();}
|
||||
}
|
||||
async function entries(entries: Array<[string, string[]]>) {
|
||||
for (const [id, fields] of entries) {
|
||||
const event = JSON.parse(fields[1]) as AnalyticsEvent;
|
||||
await store.consume(event);
|
||||
await redis.xack(ANALYTICS_STREAM, ANALYTICS_GROUP, id);
|
||||
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(); });
|
||||
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);}}
|
||||
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;}
|
||||
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;}}
|
||||
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=saved;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();}
|
||||
void main().catch(e=>{logger.error({message:e instanceof Error?e.message:'Startup failed'},'Analytics failed');process.exitCode=1;redis.disconnect();void db.$disconnect();});
|
||||
|
||||
Reference in New Issue
Block a user