Analytics
Realtime metrics, usage, quality, call inspection, data insights and alert thresholds.
Analytics answers four questions: how are we doing right now (real-time monitoring), how much did we use (usage), what is quality like (quality overview and diagnosis) and which call went wrong (call inspection / data insight). Every metric definition is frozen in the metrics interface definition and implemented once, so the API, the console and the tests all share the same computation.
Metric definitions (must match exactly)
Threshold rule: only a single freeze/delay that exceeds the threshold is counted (interface definition definition).
| Metric | Raw threshold | Notes |
|---|---|---|
| Video freeze | A single freeze > 600ms (native) / > 500ms (web) | Selected by the product parameter |
| Audio freeze | A single freeze > 200ms | Independent of platform type |
| Network delay | End-to-end > 400ms | Only the delay time above the threshold is counted |
| Login success | Joining within 5 seconds counts as a 5s success | login5sMs = 5000 |
Formulas (from the metrics interface definition):
| Metric | Formula |
|---|---|
videoFreezeRate | Σ videoFreezeMs (only > threshold) / Σ videoDurationMs |
audioFreezeRate | Σ audioFreezeMs (only > threshold) / Σ audioDurationMs |
networkDelayRate | Σ networkDelayMs (> 400ms) / Σ networkDelayDurationMs |
loginSuccessRate | Successful joins / join attempts |
login5sSuccessRate | Successful joins within 5s / join attempts |
averageLoginTimeMs | Σ joinDelayMs / successful join samples |
Additional field: packetLossRate (0–1) comes from SDK transport statistics, is reported through QualityReporter.recordPacketLoss(), and appears in the quality overview's totals.packetLossRate and in each user's packetLossRate in call inspection. It belongs to Agora's call-detail class of metrics and does not change the four heat-map metrics of the real-time panel.
Alarm thresholds (from the metrics interface definition) and diagnosis bands:
| Metric | Alarm threshold | good | warn | bad |
|---|---|---|---|---|
| Video freeze rate | ≥ 5% | < 2.5% | 2.5%–5% | ≥ 5% |
| Audio freeze rate | ≥ 3% | < 1.5% | 1.5%–3% | ≥ 3% |
| Network delay rate | ≥ 10% | < 5% | 5%–10% | ≥ 10% |
| Login success rate | Target 95% | ≥ 97.5% | 95%–97.5% | < 95% |
| 5s login success rate | Target 95% | ≥ 97.5% | 95%–97.5% | < 95% |
Band rules: for freeze/delay metrics good < 50% of threshold, warn 50%–100%, bad ≥ threshold; success-rate metrics are reversed (lower is worse). diagnosis.alerting is true as soon as any item is bad, and reasons[] gives a human-readable explanation.
diagnosis.minUsersForCallMetric = 20 mirrors Agora's definition that "part of the statistics are not counted when there are ≤ 20 online users per minute"; the current implementation does not enforce that floor, so if you need a strict "small samples are excluded" rule, filter in your presentation layer.
Real-time monitoring: GET /v1/analytics/realtime
| Parameter/field | Value |
|---|---|
| Window | The most recent 30 minutes |
| Bucket size | 60 seconds |
| Chart refresh | 20 seconds (chartRefreshMs) |
| Heat-map refresh | 60 seconds (heatmapRefreshMs) |
| Four real-time metrics | averageLoginTimeMs, audioFreezeRate, videoFreezeRate, networkDelayRate |
Response: { windowMinutes: 30, buckets: [30 buckets], latest, alarm }. Each bucket contains bucketStart, the four metrics, inCallUsers, inCallChannels, joinAttempts, joinSuccesses, joinWithin5s; latest is the newest bucket and alarm is the result of diagnose() on that newest bucket.
Quality overview: GET /v1/analytics/quality
Parameters: from, to (ISO-8601, defaulting to the last 24 hours), granularity (minute/hour/day, default hour), product (native by default / web).
Response: { from, to, product, granularity, buckets: [...], totals }; the fields of the buckets and totals elements:
| Field | Description |
|---|---|
bucket | Bucket start (totals uses the string total) |
videoFreezeRate / audioFreezeRate / networkDelayRate | The rate values, to 4 decimal places |
loginSuccessRate / login5sSuccessRate | Success rates |
averageLoginTimeMs | Average login time |
users / channels | Deduplicated user and channel counts |
diagnosis | { video, audio, network, login, login5s, alerting, reasons } |
Reporting SDK telemetry: POST /v1/analytics/quality
Server to server (project Basic credential); clients must never hold the Customer Secret. The request body is { "samples": [QualitySample] }, at most 5000 samples per call, and the response is 202 { accepted, rejected }.
Each sample is one user's one-minute aggregate:
| Field | Description |
|---|---|
channelName + uid | Required; samples missing them count as rejected |
bucketStart | Optional; the server floors it to the minute |
videoFreezeMs / videoDurationMs | Raw freeze duration / total video duration (milliseconds) |
audioFreezeMs / audioDurationMs | Same, for audio |
networkDelayMs / networkDelayDurationMs | Delay duration above the threshold / total measured duration |
joinAttempt / joinSuccess | Join attempt and its outcome |
joinWithin5s | Optional; defaults to joinSuccess && joinDelayMs ≤ 5000 |
joinDelayMs | Join duration (milliseconds) |
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 },
};
}Usage overview: GET /v1/analytics/usage
The parameters are the same as above (granularity defaults to hour; a type filter is also supported: audio/video/recording/streaming/transcoding).
Response { projectId, granularity, from, to, buckets, totals, forecast }:
buckets[]:{ bucket, type, minutes, channels, users }totals:{ minutes, peakChannels, peakUsers, amount, currency }forecast:{ dailyMinutes, projectedMonthlyMinutes, projectedAmount }(linear extrapolation from the last 7 days ×30)
Billing (DEFAULT_PRICES, USD per thousand minutes):
| Type | Unit price | Free quota |
|---|---|---|
audio | 0.99 | 10,000 minutes per type |
video | 3.99 | 10,000 minutes per type |
recording | 7.99 | 0 |
streaming | 2.99 | 0 |
transcoding | 4.99 | 0 |
amount = Σ (max(0, minutes_type − freeMinutes) / 1000 × unitPricePerThousandMinutes). Usage events are idempotent on (projectId, idempotencyKey), so duplicate observations over the webhook and reconciliation channels are never billed twice.
Call inspection: GET /v1/analytics/calls and /v1/analytics/calls/{channelName}
- List:
{ from, to, total, calls: [{ channelName, startedAt, endedAt, peakUsers, userCount, videoFreezeRate, audioFreezeRate }] } - Detail (Call Inspector):
{ projectId, channelName, startedAt, endedAt, peakUsers, users: [...], events: [...] }
users[]: uid, userId, role, platform, joinedAt, leftAt, durationSeconds, the three rates, loginTimeMs, packetLossRate. events[]: { ts, kind: 'join' | 'leave' | 'kick', uid, detail }, where kind='kick' corresponds to leave reason 3.
When there are no participant records for that channel the response is 404 channel_not_found ("no call records in the selected window"). packetLossRate is currently always 0 (media-plane packet loss has to be reported by the SDK and is not yet part of the quality samples).
Data insight: GET /v1/analytics/data-insight
| Parameter | Allowed values |
|---|---|
metric | videoFreezeRate (default), audioFreezeRate, networkDelayRate, loginSuccessRate, login5sSuccessRate |
dimension | region (default), networkType, osType, sdkVersion, deviceType, channelSize |
granularity | minute (60s) / hour (3600s, default) / day (86400s) |
Response { metric, dimension, series: [{ bucket, value, users }], breakdown: [{ key, value, users }] }; breakdown is ordered by value descending, so it can be plotted directly as a "which region is worst" ranking.
End-to-end examples
Real-time panel:
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 },
};
}Usage, call inspection and data insight (chained in one script):
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 version:
#!/usr/bin/env bash
# 水晶球:上报 SDK 遥测(服务端凭证)→ 读取质量总览与实时面板
set -euo pipefail
: "${NIMEET_API_BASE:=http://127.0.0.1:8787}"
CRED_FILE=$([ -f the control plane class="tok-flag">-credentials.json ] && echo the control plane 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'Known boundaries
- Quality data is retained and aggregated in memory storage, so a restart clears it; production use requires Postgres/TSDB behind the reserved storage port.
minUsersForCallMetric(the 20-user floor) is defined in the interface definition but is not currently enforced in the computation path.- Fine-grained network metrics from the SDK such as
packetLossRate,rttandjitterare not yet part of theQualitySampleinterface definition; adding them starts with extending the domain interface definition (a breaking-change process). usage.totals.peakUsersis currently always0(peak-user tracking is still to be wired up), whilepeakChannelsis already computed with per-bucket deduplication.