跳到主要内容

可观测性与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_idroom-001
user_idu-12345
session_ids-abc...(PeerConnection 实例 id)
client.platformweb / ios / android / electron
client.uaChrome 138 / Safari 18
client.networkwifi / 4g / 5g / wired
ice.local_candidate_typehost / srflx / relay
ice.remote_candidate_typehost / srflx / relay
codec.videoVP8 / VP9 / H264 / AV1
simulcast.layersq,h,f
egress.regionap-northeast-1
connect_duration_ms1230

3. 关键指标定义(每条都要立刻能算)

指标定义来源红线
joinSuccessRate加入房间 5s 内成功比例信令 + 客户端> 99.5%
firstFrameLatencyMs从 setRemoteDescription 到第一帧解码完成客户端P95 < 2s
iceConnectMsgather → connected 耗时客户端P95 < 1.5s
rtt_msRTCP RR 报告的 RTT客户端 + SFUP95 < 200
packetLossRatiopacketsLost / packetsReceivedgetStatsP95 < 2%
jitterMsRTP jitter,时钟 90kHz 转毫秒getStatsP95 < 40
freezeCount视频卡顿次数(每分钟)getStats freezeCount< 2
freezeRatio卡顿时间占比见 §5< 1%
audioLevel是否长时间静音getStats audioLevel静音 > 3s 警告
mos_estimateE-model 估算 MOS见 §6> 3.8
bitrate_outbound实际发送码率getStats与目标差 < 30%
qualityLimitationReasoncpu / bandwidth / other / nonegetStatsbandwidth > 10% 时段需告警
disconnectReasonnormal / 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:用户感知的卡顿

freezeRatiopacketLoss 更接近用户体感。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 取 10
  • Bpl:丢包鲁棒系数,Opus 取 25、G.711 取 4.3
  • Ppl:丢包率(百分比,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 推荐方案:

  1. 客户端 join 时生成 traceId(W3C TraceContext 格式)
  2. 信令请求把 traceparent 头带给服务端
  3. SFU 在 RTP 旁路一条 OTLP span,attribute 里塞 room_id / user_id / session_id
  4. 客户端 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 不串个案排查 30minOTLP 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. 权威资料