可观测性与QoE
实时音视频的"体验好"不是感觉,是可以测量、回归、报警的工程指标。本文给出一套可落地的可观测体系:trace 模型、关键指标定义、freezeRatio 与 MOS 估算、客户端 → 服务端上报模型、PostgreSQL/ClickHouse schema、Grafana + PromQL 面板示例。
体系原则:没有"无法解释"的卡顿。每一次用户主诉都能在 5 分钟内定位到具体 trace、具体一段 RTP 时间窗、具体的链路环节。
1. 三层观测的关系
三层各自的强项:
- Metrics:聚合后做大盘、报警;不适合单用户回溯
- Traces:单次会话全链路重现,适合定位个案
- Logs:辅助上下文(设备、UA、错误堆栈)
- 长期表:周/月级别 QoE 趋势、AB 实验、产品决策
2. Trace 模型
每一次"加入房间"是一个 root span,下面挂子 span:
关键属性(推荐打在 span 上):
| 属性 | 例子 |
|---|---|
room_id | room-001 |
user_id | u-12345 |
session_id | s-abc...(PeerConnection 实例 id) |
client.platform | web / ios / android / electron |
client.ua | Chrome 138 / Safari 18 |
client.network | wifi / 4g / 5g / wired |
ice.local_candidate_type | host / srflx / relay |
ice.remote_candidate_type | host / srflx / relay |
codec.video | VP8 / VP9 / H264 / AV1 |
simulcast.layers | q,h,f |
egress.region | ap-northeast-1 |
connect_duration_ms | 1230 |
3. 关键指标定义(每条都要立刻能算)
| 指标 | 定义 | 来源 | 红线 |
|---|---|---|---|
joinSuccessRate | 加入房间 5s 内成功比例 | 信令 + 客户端 | > 99.5% |
firstFrameLatencyMs | 从 setRemoteDescription 到第一帧解码完成 | 客户端 | P95 < 2s |
iceConnectMs | gather → connected 耗时 | 客户端 | P95 < 1.5s |
rtt_ms | RTCP RR 报告的 RTT | 客户端 + SFU | P95 < 200 |
packetLossRatio | packetsLost / packetsReceived | getStats | P95 < 2% |
jitterMs | RTP jitter,时钟 90kHz 转毫秒 | getStats | P95 < 40 |
freezeCount | 视频卡顿次数(每分钟) | getStats freezeCount | < 2 |
freezeRatio | 卡顿时间占比 | 见 §5 | < 1% |
audioLevel | 是否长时间静音 | getStats audioLevel | 静音 > 3s 警告 |
mos_estimate | E-model 估算 MOS | 见 §6 | > 3.8 |
bitrate_outbound | 实际发送码率 | getStats | 与目标差 < 30% |
qualityLimitationReason | cpu / bandwidth / other / none | getStats | bandwidth > 10% 时段需告警 |
disconnectReason | normal / network / kicked / crash | 客户端 | 异常断开 < 1% |
4. 客户端 getStats 上报
4.1 上报频率与体量
- 细粒度:1s 一次,仅留在客户端环形 buffer 用于 trace
- 聚合粒度:5s / 10s 上报一次,按平台和指标聚合
- 会话结束:再发一份 summary,包含 P50/P95/sum/count
4.2 上报样例 schema(JSON)
{
"schema": "rtc.stats.v2",
"ts": 1750589420000,
"trace_id": "01H...",
"session_id": "pc-abc-123",
"room_id": "room-001",
"user_id": "u-12345",
"client": {
"platform": "web",
"ua": "Chrome/138.0.0.0 macOS",
"sdk_version": "1.4.2",
"network": "wifi",
"egress_region": "ap-northeast-1"
},
"window": { "start": 1750589415000, "end": 1750589420000, "seconds": 5 },
"audio_outbound": {
"ssrc": 1234567890,
"codec": "opus",
"packets_sent": 250,
"bytes_sent": 32500,
"retransmitted_packets": 2,
"nack_count": 1,
"target_bitrate": 64000,
"actual_bitrate": 52000
},
"video_outbound": [
{
"ssrc": 9876543210,
"rid": "f",
"codec": "VP9",
"frames_sent": 145,
"frames_encoded": 145,
"key_frames_encoded": 2,
"frames_dropped": 0,
"fir_count": 0,
"pli_count": 0,
"nack_count": 1,
"qp_sum": 4350,
"quality_limitation_reason": "none",
"quality_limitation_resolutions_changes": 0,
"actual_bitrate": 1480000,
"target_bitrate": 1500000
}
],
"video_inbound": [
{
"ssrc": 5544332211,
"codec": "VP9",
"frames_received": 142,
"frames_decoded": 140,
"frames_dropped": 2,
"freeze_count": 0,
"total_freezes_duration_ms": 0,
"jitter_buffer_delay_ms": 65,
"jitter_buffer_emitted_count": 140,
"packets_lost": 3,
"packets_received": 1450,
"rtt_ms": 78,
"jitter_ms": 22
}
],
"candidate_pair": {
"local": "srflx",
"remote": "host",
"selected": true,
"current_rtt_ms": 78,
"available_outgoing_bitrate": 5_000_000
}
}
4.3 客户端采集骨架(TypeScript)
type StatsReport = {
schema: 'rtc.stats.v2';
ts: number;
traceId: string;
sessionId: string;
windowSec: number;
audioOutbound?: AudioOutboundMetric;
videoOutbound?: VideoOutboundMetric[];
videoInbound?: VideoInboundMetric[];
candidatePair?: CandidatePairMetric;
};
class StatsCollector {
private last?: RTCStatsReport;
private buf: StatsReport[] = [];
constructor(
private pc: RTCPeerConnection,
private send: (batch: StatsReport[]) => Promise<void>,
private windowSec = 5,
) {}
start(): void {
setInterval(() => this.tick(), 1000);
setInterval(() => this.flush(), this.windowSec * 1000);
}
private async tick(): Promise<void> {
const stats = await this.pc.getStats();
const report = this.diff(this.last, stats);
this.last = stats;
this.buf.push(report);
}
private diff(prev: RTCStatsReport | undefined, curr: RTCStatsReport): StatsReport {
// 与上一次 stats 比较,得到本秒的增量(packets_sent diff、bytes_sent diff 等)
// 省略具体计算……
return {} as StatsReport;
}
private async flush(): Promise<void> {
if (this.buf.length === 0) return;
const batch = this.buf.splice(0, this.buf.length);
// 失败重试 + sendBeacon 兜底
try {
await this.send(batch);
} catch {
navigator.sendBeacon?.('/stats', new Blob([JSON.stringify(batch)], { type: 'application/json' }));
}
}
}
要点:
- getStats 拿到的多数字段是累计值,必须自己做 diff
- 避免高频上报:5s 批量上报、用
sendBeacon在 unload 时兜底 - 采样:长稳服务可对 90% 用户降到 10s 粒度,对 10% 灰度组保持 1s
- 不要把整个 RTCStatsReport 上传:只提取业务用得到的字段
5. freezeRatio:用户感知的卡顿
freezeRatio 比 packetLoss 更接近用户体感。Chrome 早就在 inbound-rtp 暴露了 freezeCount / totalFreezesDuration。Spec:单帧渲染间隔超过 max(3 × avgFrameDuration, 150ms + decodeDuration) 计一次 freeze。
freezeRatio = totalFreezesDuration / sessionDuration
5.1 客户端计算
function calcFreezeRatio(prev: RTCInboundRtpStreamStats, curr: RTCInboundRtpStreamStats, windowMs: number): number {
const dt = (curr.totalFreezesDuration ?? 0) - (prev.totalFreezesDuration ?? 0);
return Math.max(0, Math.min(1, dt * 1000 / windowMs));
}
5.2 服务端兜底
服务端拿不到 freezeCount,但能用 RTP 间隔近似:
// 每个接收端每 100ms 检查一次 RTP arrival 时间
if now.Sub(lastRtpArrival) > maxFrameInterval*3 && now.Sub(lastRtpArrival) > 150*time.Millisecond {
metricFreezeDuration.WithLabelValues(roomID, userID).Add(now.Sub(lastRtpArrival).Seconds())
}
5.3 报警阈值
| freezeRatio | 含义 | 行动 |
|---|---|---|
| < 0.5% | 良好 | 无 |
| 0.5%–2% | 偶卡 | 关注趋势 |
| 2%–5% | 明显卡 | 触发指标告警 |
| > 5% | 严重 | 立即排查、可能切层 / 切区域 |
6. MOS 估算(E-model 简化)
MOS(Mean Opinion Score)从 1(极差)到 5(极好)。WebRTC 没法直接拿用户主观分,但可以用 ITU-T G.107 E-model 的简化版按 RTT、丢包、jitter 估算 audio MOS。
注意:E-model 本是为电信语音质量设计,搬到 Opus + 公网链路上只能作为相对趋势指标,不能当成绝对分数。
6.1 简化公式
Ie_eff = Ie + (95 - Ie) * Ppl / (Ppl + Bpl)
R = 93.2 - Ie_eff - Id
MOS = 1 + 0.035*R + R*(R-60)*(100-R)*7e-6
字段:
Ie:基础编码损伤,Opus 取 5、G.711 取 0、G.729 取 10Bpl:丢包鲁棒系数,Opus 取 25、G.711 取 4.3Ppl:丢包率(百分比,0–100)Id:延迟损伤,简化Id = 0.024*d + 0.11*(d-177.3)*H(d-177.3),d 为单程延迟 ms
6.2 TypeScript 实现
function estimateAudioMos(opts: {
rttMs: number;
packetLossPct: number; // 0–100
jitterMs: number;
codec: 'opus' | 'g711' | 'g729';
}): number {
const { rttMs, packetLossPct, jitterMs, codec } = opts;
const Ie = codec === 'opus' ? 5 : codec === 'g711' ? 0 : 10;
const Bpl = codec === 'opus' ? 25 : codec === 'g711' ? 4.3 : 19;
const oneWayDelay = rttMs / 2 + jitterMs;
const Id =
0.024 * oneWayDelay + (oneWayDelay > 177.3 ? 0.11 * (oneWayDelay - 177.3) : 0);
const IeEff = Ie + (95 - Ie) * (packetLossPct / (packetLossPct + Bpl || 1));
const R = 93.2 - IeEff - Id;
if (R < 0) return 1;
if (R > 100) return 4.5;
const mos = 1 + 0.035 * R + R * (R - 60) * (100 - R) * 7e-6;
return Math.max(1, Math.min(4.5, Math.round(mos * 10) / 10));
}
6.3 视频 MOS 怎么办
视频没有像 E-model 那样被广泛接受的公式。工程上常用的代理指标:
videoQoE = w1 * (1 - freezeRatio) + w2 * normalize(resolution) + w3 * (1 - packetLossPct/100) + w4 * (1 - clamp(rtt, 100, 500))
例如:
function estimateVideoQoE(opts: {
freezeRatio: number;
resolution: 'q' | 'h' | 'f' | 'hd' | 'fhd';
packetLossPct: number;
rttMs: number;
}): number {
const resMap = { q: 0.4, h: 0.6, f: 0.8, hd: 0.9, fhd: 1.0 };
const rttScore = Math.max(0, Math.min(1, 1 - (opts.rttMs - 100) / 400));
const lossScore = Math.max(0, 1 - opts.packetLossPct / 10);
const freezeScore = Math.max(0, 1 - opts.freezeRatio * 20);
const q =
0.4 * freezeScore +
0.2 * resMap[opts.resolution] +
0.2 * lossScore +
0.2 * rttScore;
// 映射到 1–5
return Math.round((1 + q * 4) * 10) / 10;
}
权重建议由产品 + 数据团队基于 AB 实验回归用户主观打分校准,不要直接抄默认值。
7. 服务端指标(Prometheus)
7.1 SFU 必备指标
# 房间相关
sfu_room_count{region}
sfu_participant_count{region, room_size_bucket}
sfu_join_total{result="success|fail", reason}
sfu_join_duration_seconds{region}
# 媒体相关
sfu_rtp_packets_received_total{kind="audio|video", codec}
sfu_rtp_packets_lost_total{kind, codec}
sfu_rtp_bytes_received_total{kind}
sfu_rtcp_rtt_seconds{direction="up|down"}
sfu_simulcast_layer_count{rid="q|h|f"}
sfu_keyframe_request_total{reason="join|switch_layer|nack"}
# 拥塞控制
sfu_twcc_estimated_bps{transport}
sfu_twcc_loss_ratio{transport}
# 异常
sfu_ice_failure_total{reason}
sfu_dtls_failure_total{reason}
sfu_consumer_paused_total{reason}
7.2 上报端指标
client_stats_received_total{platform}
client_stats_drop_total{reason="schema_invalid|rate_limit"}
client_freeze_ratio_bucket{platform, le}
client_mos_estimate_bucket{platform, le}
client_join_duration_seconds_bucket{platform, le}
8. Grafana PromQL 面板示例
8.1 加入房间成功率(房间级)
sum(rate(sfu_join_total{result="success"}[5m]))
/
sum(rate(sfu_join_total[5m]))
8.2 加入耗时 P50/P95/P99
histogram_quantile(0.50, sum by (le, region) (rate(sfu_join_duration_seconds_bucket[5m])))
histogram_quantile(0.95, sum by (le, region) (rate(sfu_join_duration_seconds_bucket[5m])))
histogram_quantile(0.99, sum by (le, region) (rate(sfu_join_duration_seconds_bucket[5m])))
8.3 房间维度的丢包率 Top N
topk(20,
sum by (room) (rate(sfu_rtp_packets_lost_total{kind="video"}[5m]))
/
sum by (room) (rate(sfu_rtp_packets_received_total{kind="video"}[5m]))
)
8.4 客户端 freezeRatio 分布
histogram_quantile(0.50, sum by (le, platform) (rate(client_freeze_ratio_bucket[5m])))
histogram_quantile(0.95, sum by (le, platform) (rate(client_freeze_ratio_bucket[5m])))
8.5 平均 MOS(按客户端类型)
sum by (platform) (rate(client_mos_estimate_sum[5m]))
/
sum by (platform) (rate(client_mos_estimate_count[5m]))
8.6 TWCC 估计带宽 vs 实际发送
avg(sfu_twcc_estimated_bps) by (region)
sum(rate(sfu_rtp_bytes_received_total[1m])) by (region) * 8
把两条曲线叠在一起可以看到拥塞控制是否合理:估计带宽明显高于实际,说明发送端有上限;估计带宽低于实际且 loss 高,说明拥塞控制偏激进。
8.7 ICE 失败率
sum(rate(sfu_ice_failure_total[5m]))
/
sum(rate(sfu_join_total[5m]))
8.8 报警示例(Prometheus rules)
groups:
- name: webrtc-qoe
rules:
- alert: HighJoinFailRate
expr: |
sum(rate(sfu_join_total{result="fail"}[5m]))
/
sum(rate(sfu_join_total[5m])) > 0.01
for: 5m
labels: { severity: warning }
annotations:
summary: "加入房间失败率高于 1%"
- alert: BadFreezeRatio
expr: |
histogram_quantile(0.95, sum by (le, platform)
(rate(client_freeze_ratio_bucket[5m]))) > 0.05
for: 10m
labels: { severity: critical }
annotations:
summary: "P95 freezeRatio > 5%"
- alert: PoorMOS
expr: |
(sum(rate(client_mos_estimate_sum[5m]))
/ sum(rate(client_mos_estimate_count[5m]))) < 3.5
for: 10m
labels: { severity: critical }
9. 长期分析:ClickHouse schema
Prometheus 适合 5 分钟–7 天的曲线,长期 QoE 分析、AB 实验需要 ClickHouse 或类似列存:
CREATE TABLE rtc_session_stats (
event_date Date,
ts DateTime64(3),
trace_id String,
session_id String,
room_id String,
user_id String,
platform LowCardinality(String),
region LowCardinality(String),
network LowCardinality(String),
codec_video LowCardinality(String),
codec_audio LowCardinality(String),
rtt_ms UInt32,
jitter_ms UInt32,
packet_loss_pct Float32,
freeze_ratio Float32,
mos_estimate Float32,
video_bitrate UInt32,
audio_bitrate UInt32,
resolution LowCardinality(String),
quality_limitation_reason LowCardinality(String)
)
ENGINE = MergeTree
PARTITION BY toYYYYMM(event_date)
ORDER BY (event_date, region, platform, ts);
-- 用户主诉定位
SELECT trace_id, session_id, platform, region, rtt_ms, jitter_ms,
packet_loss_pct, freeze_ratio, mos_estimate
FROM rtc_session_stats
WHERE user_id = 'u-12345'
AND ts BETWEEN now() - INTERVAL 1 DAY AND now()
ORDER BY ts;
-- 区域 P95 卡顿
SELECT region,
quantile(0.95)(freeze_ratio) AS p95_freeze,
quantile(0.95)(rtt_ms) AS p95_rtt,
avg(mos_estimate) AS avg_mos
FROM rtc_session_stats
WHERE event_date >= today() - 7
GROUP BY region
ORDER BY p95_freeze DESC;
-- AB 实验:新拥塞控制 vs 旧
SELECT exp_group,
avg(mos_estimate) AS avg_mos,
quantile(0.95)(freeze_ratio) AS p95_freeze
FROM rtc_session_stats
WHERE event_date >= today() - 14
AND exp_name = 'cc_v2'
GROUP BY exp_group;
10. trace 串联:客户端 → 服务端
OpenTelemetry 推荐方案:
- 客户端 join 时生成
traceId(W3C TraceContext 格式) - 信令请求把
traceparent头带给服务端 - SFU 在 RTP 旁路一条 OTLP span,attribute 里塞
room_id/user_id/session_id - 客户端 getStats 上报也带
traceId,让 Grafana Tempo 能把客户端心跳和服务端 RTP 数据合到一条 trace
// 客户端:生成并广播 traceparent
const traceId = crypto.randomUUID().replace(/-/g, '');
const spanId = crypto.randomUUID().replace(/-/g, '').slice(0, 16);
const traceparent = `00-${traceId}-${spanId}-01`;
await fetch('/api/join', {
method: 'POST',
headers: { traceparent, 'Content-Type': 'application/json' },
body: JSON.stringify({ room: 'room-001' }),
});
11. 端侧自检:在错误发生时拿到现场
仅靠周期 stats 还不够。错误发生时应触发一次性深度采集:
pc.oniceconnectionstatechange = () => {
if (pc.iceConnectionState === 'failed' || pc.iceConnectionState === 'disconnected') {
captureSnapshot(pc, 'ice_failed');
}
};
async function captureSnapshot(pc: RTCPeerConnection, reason: string) {
const stats = await pc.getStats();
const all: Record<string, RTCStats> = {};
stats.forEach((s) => { all[s.id] = s; });
navigator.sendBeacon('/snapshot', new Blob([JSON.stringify({
reason,
ts: Date.now(),
iceConnectionState: pc.iceConnectionState,
iceGatheringState: pc.iceGatheringState,
signalingState: pc.signalingState,
stats: all,
})], { type: 'application/json' }));
}
事件钩子:
iceconnectionstatechange→ 失败/断开connectionstatechange→ 失败track→ 收到新轨negotiationneeded→ 重新协商onerror→ DataChannel 异常
12. 一份"开播 5 分钟"的快速诊断流程
13. 反模式
| 反模式 | 后果 | 替代 |
|---|---|---|
| 1s 高频上报完整 stats | 网络拥堵、被自家监控放大 | 5s 聚合 + 端内 buffer |
| 只看丢包不看 freezeRatio | 用户体感与数据脱节 | freezeRatio 设为核心红线 |
| 把 MOS 当绝对值 | 决策走偏 | 用作相对趋势 + AB 校准 |
| 客户端 / 服务端 trace 不串 | 个案排查 30min | OTLP traceId 全链路 |
| 报警只看 region 总量 | 单房间炸不报 | 加房间 / 用户 topk |
| 用 Prometheus 长期存 | 一年数据查不动 | 长期写 ClickHouse |
| stats 上报不限频 | DDoS 自己 | 端限频 + 服务端令牌桶 |
| 把所有 RTCStatsReport 字段塞 ES | 索引爆炸 | 只留业务必需字段 |
| 仅看 P50 | 长尾被忽略 | 必须看 P95 / P99 |
| 没有"错误现场"采样 | 复现成本极高 | 触发式 snapshot |
没记 qualityLimitationReason | 不知道为什么码率被压 | 必须记录 |
| Grafana 面板按租户共享 | 数据互染 | 多租户标签隔离 |
14. 落地清单
- 客户端有 stats collector,1s 采集 + 5s 聚合 + sendBeacon 兜底
- 上报 schema 定版本号(
rtc.stats.v2),向后兼容 - 服务端有 SFU / TURN / 信令三组 Prometheus 指标
- OpenTelemetry 跨端串 traceId
- 客户端关键事件触发 snapshot(ice failed / connection failed)
- Grafana 大盘至少 4 张:加入成功 / 媒体质量 / 拥塞控制 / 异常事件
- 报警最低标准:join 失败率、freezeRatio P95、MOS 平均
- 长期 QoE 落 ClickHouse,至少留 90 天
- AB 实验组与 stats 字段打通
- 用户主诉到 trace 的链路在内部工具 5 分钟可达
15. 权威资料
- W3C WebRTC Statistics API: https://www.w3.org/TR/webrtc-stats/
- W3C WebRTC 1.0: https://www.w3.org/TR/webrtc/
- ITU-T G.107 E-model: https://www.itu.int/rec/T-REC-G.107
- ITU-T P.800 MOS: https://www.itu.int/rec/T-REC-P.800
- IETF RFC 8888(CCFB): https://www.rfc-editor.org/rfc/rfc8888
- IETF transport-wide congestion control: https://datatracker.ietf.org/doc/draft-holmer-rmcat-transport-wide-cc-extensions/
- OpenTelemetry W3C TraceContext: https://www.w3.org/TR/trace-context/
- Prometheus best practices: https://prometheus.io/docs/practices/naming/
- Grafana Tempo: https://grafana.com/oss/tempo/
- LiveKit observability: https://docs.livekit.io/home/cloud/analytics-api/
- Google WebRTC stats 来源: https://chromium.googlesource.com/external/webrtc/+/refs/heads/main/api/stats/
- 核对日期:2026-06-22