nimeet Docs
中文

Analytics

Realtime metrics, usage, quality, call inspection, data insights and alert thresholds.

Version v1.1.0 · Updated 2026-10-26 · revision 1

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).

MetricRaw thresholdNotes
Video freezeA single freeze > 600ms (native) / > 500ms (web)Selected by the product parameter
Audio freezeA single freeze > 200msIndependent of platform type
Network delayEnd-to-end > 400msOnly the delay time above the threshold is counted
Login successJoining within 5 seconds counts as a 5s successlogin5sMs = 5000

Formulas (from the metrics interface definition):

MetricFormula
videoFreezeRateΣ videoFreezeMs (only > threshold) / Σ videoDurationMs
audioFreezeRateΣ audioFreezeMs (only > threshold) / Σ audioDurationMs
networkDelayRateΣ networkDelayMs (> 400ms) / Σ networkDelayDurationMs
loginSuccessRateSuccessful joins / join attempts
login5sSuccessRateSuccessful 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:

MetricAlarm thresholdgoodwarnbad
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 rateTarget 95%≥ 97.5%95%–97.5%< 95%
5s login success rateTarget 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/fieldValue
WindowThe most recent 30 minutes
Bucket size60 seconds
Chart refresh20 seconds (chartRefreshMs)
Heat-map refresh60 seconds (heatmapRefreshMs)
Four real-time metricsaverageLoginTimeMs, 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:

FieldDescription
bucketBucket start (totals uses the string total)
videoFreezeRate / audioFreezeRate / networkDelayRateThe rate values, to 4 decimal places
loginSuccessRate / login5sSuccessRateSuccess rates
averageLoginTimeMsAverage login time
users / channelsDeduplicated 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:

FieldDescription
channelName + uidRequired; samples missing them count as rejected
bucketStartOptional; the server floors it to the minute
videoFreezeMs / videoDurationMsRaw freeze duration / total video duration (milliseconds)
audioFreezeMs / audioDurationMsSame, for audio
networkDelayMs / networkDelayDurationMsDelay duration above the threshold / total measured duration
joinAttempt / joinSuccessJoin attempt and its outcome
joinWithin5sOptional; defaults to joinSuccess && joinDelayMs ≤ 5000
joinDelayMsJoin duration (milliseconds)
dimensionsregion / networkType / osType / sdkVersion / deviceType / channelSize
javascript
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):

TypeUnit priceFree quota
audio0.9910,000 minutes per type
video3.9910,000 minutes per type
recording7.990
streaming2.990
transcoding4.990

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

ParameterAllowed values
metricvideoFreezeRate (default), audioFreezeRate, networkDelayRate, loginSuccessRate, login5sSuccessRate
dimensionregion (default), networkType, osType, sdkVersion, deviceType, channelSize
granularityminute (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:

javascript
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):

javascript
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:

bash
#!/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, rtt and jitter are not yet part of the QualitySample interface definition; adding them starts with extending the domain interface definition (a breaking-change process).
  • usage.totals.peakUsers is currently always 0 (peak-user tracking is still to be wired up), while peakChannels is already computed with per-bucket deduplication.

Generated by the documentation build pipeline · 2026-10-31 01:24:02 UTC · Documentation center v1.1.0.

Content follows the public API definitions.

ni-meet is a self-hosted RTC PaaS with a LiveKit-based media plane (Apache-2.0) and an Agora-compatible API.