nimeet 文档中心
English

水晶球(数据分析)

四个实时指标的精确定义、告警阈值、用量与质量总览、通话调查、数据洞察维度与粒度。

版本 v1.1.0 · 更新于 2026-10-26 · 修订 1

水晶球(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成功入会次数 / 入会尝试次数
login5sSuccessRate5s 内成功次数 / 入会尝试次数
averageLoginTimeMsΣ joinDelayMs / 成功入会样本数

补充字段:packetLossRate(0–1)来自 SDK 传输统计,经 QualityReporter.recordPacketLoss() 上报, 出现在质量总览 totals.packetLossRate 与通话调查每个用户的 packetLossRate 上。它属于声网的呼叫 详情类指标,不改变实时面板的 4 个热力图指标。

告警阈值(metrics.json#alarmThresholds)与诊断分档(#diagnosis):

指标告警阈值goodwarnbad
视频卡顿率≥ 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入会耗时(毫秒)
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 },
  };
}

用量总览: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 / 千分钟,单价可按部署配置调整):

类型单价免费额度
audio0.99每类型 10,000 分钟
video3.99每类型 10,000 分钟
recording7.990
streaming2.990
transcoding4.990

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

参数可选值
metricvideoFreezeRate(默认)、audioFreezeRate、networkDelayRate、loginSuccessRate、login5sSuccessRate
dimensionregion(默认)、networkType、osType、sdkVersion、deviceType、channelSize
granularityminute(60s)/ hour(3600s,默认)/ day(86400s)

响应 { metric, dimension, series: [{ bucket, value, users }], breakdown: [{ key, value, users }] },breakdown 按 value 降序,便于直接画“哪个地域最差”的排行。

端到端示例

实时面板:

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 },
  };
}

用量、通话调查与数据洞察(同一份脚本里串起来):

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 版本:

bash
#!/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 已按桶去重计算。

本页由文档构建流水线生成 · 2026-10-31 01:24:02 UTC · 文档中心 v1.1.0.

内容依据公开 API 定义.

ni-meet 是自建 RTC PaaS,媒体面基于 LiveKit(Apache-2.0),并与声网 API 兼容。