水晶球(数据分析)
四个实时指标的精确定义、告警阈值、用量与质量总览、通话调查、数据洞察维度与粒度。
水晶球(Analytics)回答四个问题:现在怎么样(实时监控)、用了多少(用量)、质量如何(质量总览与诊断)、哪一通通话出了问题(通话调查 / 数据洞察)。所有指标定义固化在 接口定义,算法实现在 控制面组件,API、控制台与测试共用同一份计算。
指标口径(必须精确一致)
阈值定义:只有单次卡顿/延迟超过阈值才计入统计(接口定义口径)。
| 指标 | 原始阈值 | 说明 |
|---|---|---|
| 视频卡顿 | 单次 > 600ms(native)/ > 500ms(web) | 由 product 参数选择阈值 |
| 音频卡顿 | 单次 > 200ms | 与端类型无关 |
| 网络延迟 | 端到端 > 400ms | 只统计超阈值部分对应的延迟时长 |
| 登录成功 | 5 秒内完成入会视为 5s 成功 | login5sMs = 5000 |
计算公式(metrics.json#formulas):
| 指标 | 公式 |
|---|---|
videoFreezeRate | Σ videoFreezeMs (仅 > 阈值) / Σ videoDurationMs |
audioFreezeRate | Σ audioFreezeMs (仅 > 阈值) / Σ audioDurationMs |
networkDelayRate | Σ networkDelayMs (> 400ms) / Σ networkDelayDurationMs |
loginSuccessRate | 成功入会次数 / 入会尝试次数 |
login5sSuccessRate | 5s 内成功次数 / 入会尝试次数 |
averageLoginTimeMs | Σ joinDelayMs / 成功入会样本数 |
补充字段:packetLossRate(0–1)来自 SDK 传输统计,经 QualityReporter.recordPacketLoss() 上报, 出现在质量总览 totals.packetLossRate 与通话调查每个用户的 packetLossRate 上。它属于声网的呼叫 详情类指标,不改变实时面板的 4 个热力图指标。
告警阈值(metrics.json#alarmThresholds)与诊断分档(#diagnosis):
| 指标 | 告警阈值 | good | warn | bad |
|---|---|---|---|---|
| 视频卡顿率 | ≥ 5% | < 2.5% | 2.5%–5% | ≥ 5% |
| 音频卡顿率 | ≥ 3% | < 1.5% | 1.5%–3% | ≥ 3% |
| 网络延迟率 | ≥ 10% | < 5% | 5%–10% | ≥ 10% |
| 登录成功率 | 目标 95% | ≥ 97.5% | 95%–97.5% | < 95% |
| 5s 登录成功率 | 目标 95% | ≥ 97.5% | 95%–97.5% | < 95% |
分档规则:卡顿/延迟类 good < 50% 阈值、warn 50%~100%、bad ≥ 阈值;成功率类反过来(越低越差)。diagnosis.alerting 只要任一项 bad 即为 true,reasons[] 给出人话原因。
diagnosis.minUsersForCallMetric = 20 是声网“每分钟在线用户 ≤ 20 时不计入部分统计”的口径说明;当前实现未强制该门限,接入时若需要严格的“小样本不计”请在展示层过滤。
实时监控:GET /v1/analytics/realtime
| 参数/字段 | 值 |
|---|---|
| 窗口 | 最近 30 分钟 |
| 桶粒度 | 60 秒 |
| 图表刷新 | 20 秒(chartRefreshMs) |
| 热力图刷新 | 60 秒(heatmapRefreshMs) |
| 四个实时指标 | averageLoginTimeMs、audioFreezeRate、videoFreezeRate、networkDelayRate |
响应:{ windowMinutes: 30, buckets: [30 个桶], latest, alarm }。每个桶含 bucketStart、四个指标、inCallUsers、inCallChannels、joinAttempts、joinSuccesses、joinWithin5s;latest 是最新桶,alarm 是对最新桶的 diagnose() 结果。
质量总览:GET /v1/analytics/quality
参数:from、to(ISO-8601,默认最近 24 小时)、granularity(minute/hour/day,默认 hour)、product(native 默认 / web)。
响应:{ from, to, product, granularity, buckets: [...], totals },buckets 与 totals 元素字段:
| 字段 | 说明 |
|---|---|
bucket | 桶起点(totals 为字符串 total) |
videoFreezeRate / audioFreezeRate / networkDelayRate | 四个率值,保留 4 位小数 |
loginSuccessRate / login5sSuccessRate | 成功率 |
averageLoginTimeMs | 平均登录耗时 |
users / channels | 去重用户数与频道数 |
diagnosis | { video, audio, network, login, login5s, alerting, reasons } |
上报 SDK 遥测:POST /v1/analytics/quality
服务端到服务端(项目 Basic 凭证),客户端不得持有 Customer Secret。请求体 { "samples": [QualitySample] },单次最多 5000 条,返回 202 { accepted, rejected }。
每条样本是一个用户一分钟的聚合:
| 字段 | 说明 |
|---|---|
channelName + uid | 必填;缺失的样本计入 rejected |
bucketStart | 可省略;服务端向下取整到分钟 |
videoFreezeMs / videoDurationMs | 原始卡顿时长 / 视频总时长(毫秒) |
audioFreezeMs / audioDurationMs | 同上(音频) |
networkDelayMs / networkDelayDurationMs | 超阈值的延迟时长 / 统计总时长 |
joinAttempt / joinSuccess | 入会尝试与结果 |
joinWithin5s | 可省略;默认由 joinSuccess && joinDelayMs ≤ 5000 推导 |
joinDelayMs | 入会耗时(毫秒) |
dimensions | region / networkType / osType / sdkVersion / deviceType / channelSize |
import { uniqueChannel } from './_lib.mjs';
export const meta = {
id: '09-quality-ingest',
title: '上报 SDK 遥测并读取质量总览',
safe: true,
needsApi: true,
};
/**
* POST /v1/analytics/quality (服务端凭证;客户端不得持有 Customer Secret)
* GET /v1/analytics/quality (质量总览)
* GET /v1/analytics/data-insight(按维度拆分,用于隔离本次上报的数据)
*
* 统一口径(所有分析接口共用同一条计数规则,与声网定义一致):
* - 低于阈值的卡顿/延迟样本**完全不计入**:视频 native 600ms / web 500ms、音频 200ms、网络延迟 400ms;
* - realtime、质量总览、通话详情、data-insight 都走同一个 aggregateRates 实现;
* - 因此 300ms 的视频卡顿样本不计,1200ms 的样本计入。
*
* 本示例用 sdkVersion 维度把本次上报单独拆出来,避免演示 seed 数据干扰断言。
*/
export async function run(ctx) {
const api = ctx.api;
const channelName = uniqueChannel('docs-quality');
const bucketStart = new Date(Math.floor(Date.now() / 60000) * 60000).toISOString();
const sdkVersion = 'docs-1.0.0';
const samples = [
{
channelName,
uid: 91001,
bucketStart,
videoFreezeMs: 1200,
videoDurationMs: 60000,
audioFreezeMs: 300,
audioDurationMs: 60000,
networkDelayMs: 900,
networkDelayDurationMs: 60000,
joinAttempt: true,
joinSuccess: true,
joinDelayMs: 320,
dimensions: { region: 'cn-shanghai', networkType: 'wifi', osType: 'web', sdkVersion, deviceType: 'desktop', channelSize: '<=20' },
},
{
channelName,
uid: 91002,
bucketStart,
videoFreezeMs: 300,
videoDurationMs: 60000,
audioFreezeMs: 0,
audioDurationMs: 60000,
networkDelayMs: 100,
networkDelayDurationMs: 60000,
joinAttempt: true,
joinSuccess: true,
joinDelayMs: 480,
joinWithin5s: true,
dimensions: { region: 'us-west', networkType: '4g', osType: 'web', sdkVersion, deviceType: 'mobile', channelSize: '<=20' },
},
];
const ingest = await api('POST', '/v1/analytics/quality', { body: { samples } });
ctx.assert(ingest.status === 202, '上报应返回 202');
ctx.assert(ingest.json.accepted === 2, `期望 accepted=2,实际 ${JSON.stringify(ingest.json)}`);
const to = new Date(Date.now() + 60_000);
const from = new Date(Date.now() - 3600_000);
const insight = await api('GET', '/v1/analytics/data-insight', {
query: {
from: from.toISOString(),
to: to.toISOString(),
granularity: 'hour',
metric: 'videoFreezeRate',
dimension: 'sdkVersion',
},
});
const own = (insight.json.breakdown ?? []).find((entry) => entry.key === sdkVersion);
ctx.assert(own, `data-insight 应包含 sdkVersion=${sdkVersion} 的分组`);
// 阈值过滤后:1200 计入、300 不计入 → 1200 / (60000 + 60000) = 0.01
ctx.assert(Math.abs(own.value - 0.01) < 1e-6, `data-insight 阈值过滤后应为 0.01,实际 ${own.value}`);
ctx.assert(own.users === 2, `该维度应统计到 2 个用户,实际 ${own.users}`);
const overview = await api('GET', '/v1/analytics/quality', {
query: { from: from.toISOString(), to: to.toISOString(), granularity: 'hour', product: 'web' },
});
const totals = overview.json.totals;
ctx.assert(typeof totals.videoFreezeRate === 'number', '质量总览应返回 videoFreezeRate');
ctx.assert(totals.users > 0, '质量总览应统计到用户');
ctx.assert(typeof totals.diagnosis?.alerting === 'boolean', '应返回诊断块');
const realtime = await api('GET', '/v1/analytics/realtime');
ctx.assert(realtime.json.buckets.length === 30, '实时面板应有 30 个桶');
return {
summary:
`上报 2 条样本(channel=${channelName}, sdkVersion=${sdkVersion});` +
`data-insight videoFreezeRate=${own.value}(1200ms 计入、300ms 不计入);` +
`质量总览 videoFreezeRate=${totals.videoFreezeRate}、users=${totals.users}、alerting=${totals.diagnosis.alerting}`,
details: { ingest: ingest.json, breakdownEntry: own, totals },
};
}用量总览:GET /v1/analytics/usage
参数同上(granularity 默认 hour,另支持 type 过滤:audio/video/recording/streaming/transcoding)。
响应 { projectId, granularity, from, to, buckets, totals, forecast }:
buckets[]:{ bucket, type, minutes, channels, users }totals:{ minutes, peakChannels, peakUsers, amount, currency }forecast:{ dailyMinutes, projectedMonthlyMinutes, projectedAmount }(按最近 7 天线性外推 ×30)
计费口径(USD / 千分钟,单价可按部署配置调整):
| 类型 | 单价 | 免费额度 |
|---|---|---|
audio | 0.99 | 每类型 10,000 分钟 |
video | 3.99 | 每类型 10,000 分钟 |
recording | 7.99 | 0 |
streaming | 2.99 | 0 |
transcoding | 4.99 | 0 |
amount = Σ (max(0, minutes_type − freeMinutes) / 1000 × unitPricePerThousandMinutes)。用量事件按 (projectId, idempotencyKey) 幂等,Webhook 与对账通道重复观测不会重复计费。
通话调查:GET /v1/analytics/calls 与 /v1/analytics/calls/{channelName}
- 列表:
{ from, to, total, calls: [{ channelName, startedAt, endedAt, peakUsers, userCount, videoFreezeRate, audioFreezeRate }] } - 详情(Call Inspector):
{ projectId, channelName, startedAt, endedAt, peakUsers, users: [...], events: [...] }
users[]:uid、userId、role、platform、joinedAt、leftAt、durationSeconds、三项 rate、loginTimeMs、packetLossRate。 events[]:{ ts, kind: 'join' | 'leave' | 'kick', uid, detail },其中 kind='kick' 对应离开原因 3。
没有该频道的参与者记录时返回 404 channel_not_found(提示“在所选窗口内没有通话记录”)。packetLossRate 目前固定为 0(媒体面丢包需由 SDK 上报,尚未纳入质量样本)。
数据洞察:GET /v1/analytics/data-insight
| 参数 | 可选值 |
|---|---|
metric | videoFreezeRate(默认)、audioFreezeRate、networkDelayRate、loginSuccessRate、login5sSuccessRate |
dimension | region(默认)、networkType、osType、sdkVersion、deviceType、channelSize |
granularity | minute(60s)/ hour(3600s,默认)/ day(86400s) |
响应 { metric, dimension, series: [{ bucket, value, users }], breakdown: [{ key, value, users }] },breakdown 按 value 降序,便于直接画“哪个地域最差”的排行。
端到端示例
实时面板:
export const meta = {
id: '08-analytics-realtime',
title: '水晶球:实时监控(近 30 分钟)',
safe: true,
needsApi: true,
};
/**
* GET /v1/analytics/realtime
* 返回 30 个 1 分钟桶 + 最新桶 + 告警诊断。四个实时指标:
* averageLoginTimeMs / audioFreezeRate / videoFreezeRate / networkDelayRate。
*/
export async function run(ctx) {
const api = ctx.api;
const response = await api('GET', '/v1/analytics/realtime');
const body = response.json;
ctx.assert(body.windowMinutes === 30, '窗口应为 30 分钟');
ctx.assert(Array.isArray(body.buckets) && body.buckets.length === 30, '应有 30 个 1 分钟桶');
const latest = body.latest;
const alarm = body.alarm;
return {
summary: latest
? `最新桶 ${latest.bucketStart}:在线用户 ${latest.inCallUsers}、频道 ${latest.inCallChannels}、` +
`视频卡顿率 ${latest.videoFreezeRate}、音频卡顿率 ${latest.audioFreezeRate}、` +
`网络延迟率 ${latest.networkDelayRate}、平均登录 ${latest.averageLoginTimeMs}ms;告警=${alarm?.alerting}`
: '30 个桶均无遥测样本(先调用 09 号示例上报质量数据)',
details: { latest, alarm, bucketCount: body.buckets.length },
};
}用量、通话调查与数据洞察(同一份脚本里串起来):
export const meta = {
id: '10-analytics-usage-calls',
title: '水晶球:用量总览与通话调查',
safe: true,
needsApi: true,
};
/**
* GET /v1/analytics/usage → { buckets, totals: { minutes, peakChannels, amount, currency }, forecast }
* GET /v1/analytics/calls → 通话列表(频道维度会话)
* GET /v1/analytics/calls/{channelName} → 通话详情(每用户指标 + 时间线)
*/
export async function run(ctx) {
const api = ctx.api;
const to = new Date();
const from = new Date(to.getTime() - 24 * 3600 * 1000);
const usage = await api('GET', '/v1/analytics/usage', {
query: { from: from.toISOString(), to: to.toISOString(), granularity: 'hour' },
});
ctx.assert(Array.isArray(usage.json.buckets), 'usage 应返回 buckets');
ctx.assert(typeof usage.json.totals?.minutes === 'number', 'totals.minutes 应为数字');
ctx.assert(usage.json.forecast && typeof usage.json.forecast.projectedMonthlyMinutes === 'number', '应返回 7 日外推');
const calls = await api('GET', '/v1/analytics/calls', {
query: { from: from.toISOString(), to: to.toISOString(), limit: 20 },
});
const sessions = calls.json.calls ?? [];
ctx.assert(Array.isArray(sessions), 'calls 应为数组');
let inspector = null;
if (sessions.length > 0) {
const channelName = sessions[0].channelName;
const detail = await api('GET', `/v1/analytics/calls/${encodeURIComponent(channelName)}`, {
query: { from: from.toISOString(), to: to.toISOString() },
});
inspector = detail.json;
ctx.assert(Array.isArray(inspector.users), ' inspector 应返回 users');
ctx.assert(Array.isArray(inspector.events), 'inspector 应返回 events');
}
return {
summary:
`用量 ${usage.json.totals.minutes} 分钟 / ${usage.json.buckets.length} 个桶,预估月用量 ` +
`${usage.json.forecast.projectedMonthlyMinutes} 分钟;通话列表 ${sessions.length} 条` +
`${inspector ? `,${inspector.channelName} 详情含 ${inspector.users.length} 个用户、${inspector.events.length} 条事件` : ''}`,
details: {
totals: usage.json.totals,
forecast: usage.json.forecast,
firstCalls: sessions.slice(0, 3),
},
};
}curl 版本:
#!/usr/bin/env bash
# 水晶球:上报 SDK 遥测(服务端凭证)→ 读取质量总览与实时面板
set -euo pipefail
: "${NIMEET_API_BASE:=http://127.0.0.1:8787}"
CRED_FILE=$([ -f 控制面 class="tok-flag">-credentials.json ] && echo 控制面 class="tok-flag">-credentials.json || echo data/demo-credentials.json)
CRED=$(node -e "const c=JSON.parse(require('fs').readFileSync(process.argv[1],'utf8'));process.stdout.write(Buffer.from(c.customerId+':'+c.customerSecret).toString('base64'))" "$CRED_FILE")
BUCKET=$(node -e "console.log(new Date(Math.floor(Date.now()/60000)*60000).toISOString())")
# 1) 上报:一行 = 一个用户一分钟的聚合值(原始卡顿/延迟时长,阈值应用见文档)
curl -s -X POST "$NIMEET_API_BASE/v1/analytics/quality" \
-H "Authorization: Basic $CRED" -H 'Content-Type: application/json' \
-d "{\"samples\":[{\"channelName\":\"weekly-sync\",\"uid\":10001,\"bucketStart\":\"$BUCKET\",\"videoFreezeMs\":1200,\"videoDurationMs\":60000,\"audioFreezeMs\":0,\"audioDurationMs\":60000,\"networkDelayMs\":900,\"networkDelayDurationMs\":60000,\"joinAttempt\":true,\"joinSuccess\":true,\"joinDelayMs\":320,\"dimensions\":{\"region\":\"cn-shanghai\",\"networkType\":\"wifi\",\"osType\":\"web\",\"sdkVersion\":\"1.0.0\"}}]}"
# 2) 质量总览(product=web 时视频卡顿阈值为 500ms)
curl -s -G "$NIMEET_API_BASE/v1/analytics/quality" \
-H "Authorization: Basic $CRED" \
--data-urlencode 'granularity=hour' \
--data-urlencode 'product=web'
# 3) 实时面板(近 30 分钟、1 分钟桶)
curl -s "$NIMEET_API_BASE/v1/analytics/realtime" -H "Authorization: Basic $CRED"
# 4) 用量总览(默认最近 24 小时)
curl -s -G "$NIMEET_API_BASE/v1/analytics/usage" \
-H "Authorization: Basic $CRED" \
--data-urlencode 'granularity=hour'
# 5) 通话调查:列表 + 单频道详情
curl -s -G "$NIMEET_API_BASE/v1/analytics/calls" -H "Authorization: Basic $CRED" --data-urlencode 'limit=20'
curl -s "$NIMEET_API_BASE/v1/analytics/calls/weekly-sync" -H "Authorization: Basic $CRED"
# 6) 数据洞察:指标 × 维度 × 粒度
curl -s -G "$NIMEET_API_BASE/v1/analytics/data-insight" \
-H "Authorization: Basic $CRED" \
--data-urlencode 'granularity=hour' \
--data-urlencode 'metric=videoFreezeRate' \
--data-urlencode 'dimension=region'已知边界
- 质量数据的保留与聚合都在内存存储中,服务重启即清空;生产化需要接 Postgres/TSDB(
Store端口已预留)。 minUsersForCallMetric(20 人门限)已在接口定义中定义,但当前不在计算路径上强制执行。packetLossRate、rtt、jitter等来自 SDK 的细粒度网络指标尚未进入QualitySample接口定义;如需请先扩展接口定义(属破坏性变更流程)。usage.totals.peakUsers目前恒为0(峰值用户统计待接入),peakChannels已按桶去重计算。