getStats全字段速查
RTCPeerConnection.getStats() 是 WebRTC 唯一的官方观测面。线上排障、A/B 实验、SLA 上报全靠它。但 W3C 标准里有 20+ 种 stats type、200+ 字段,没人能背全。本文做两件事:
- 全字段表:按 type 分组,每个字段标注含义和"排障用什么"。
- 场景对照:卡顿、模糊、回声、断连,分别看哪几个字段。
字段来源 W3C
webrtc-stats(Editor's Draft 2026-06)。Chrome 实现与标准基本对齐,少数goog*字段已废弃,本文不列。
1. getStats 心智模型
调用方式:
const report = await pc.getStats();
for (const stat of report.values()) {
console.log(stat.type, stat.id, stat);
}
采样频率:1~2 秒一次足够。100ms 高频会显著拉 CPU,且很多字段就是累计型,差分计算没意义。
2. 全字段表
2.1 inbound-rtp:本端接收
| 字段 | 类型/单位 | 含义 | 排障用途 |
|---|---|---|---|
kind | "audio"/"video" | 媒体类型 | 区分音视频 |
ssrc | uint | 接收的 SSRC | 关联具体流 |
mid | string | 对应 m-line | 找对应 transceiver |
rid | string | Simulcast 层 | 知道收的是哪一档 |
codecId | string | 引用 codec stat | 反查编码器 |
packetsReceived | uint | 累计收到 RTP 包数 | 流是否在动 |
packetsLost | int | 累计丢包数(可负,表示重复包) | 丢包率分子 |
packetsDiscarded | uint | 抖动缓冲扔掉的包 | 抖动是否太大 |
packetsRepaired | uint | 通过 FEC 恢复的包 | FEC 是否有用 |
bytesReceived | uint | 累计字节 | 下行码率 = Δbytes/Δt |
headerBytesReceived | uint | RTP/扩展头字节 | overhead 分析 |
jitter | s | 抖动估计(RFC 3550) | < 30ms 健康 |
jitterBufferDelay | s | 抖动缓冲累计延迟 | 平均缓冲 = jitterBufferDelay / jitterBufferEmittedCount |
jitterBufferEmittedCount | uint | 抖动缓冲送出的样本/帧数 | 分母 |
jitterBufferTargetDelay | s | 目标缓冲深度 | 自适应抖动 |
jitterBufferMinimumDelay | s | 最小缓冲深度 | 应用层设置 |
nackCount | uint | 接收端发出的 NACK 数 | 丢包重传压力 |
pliCount | uint | 接收端发出的 PLI 数(视频) | 关键帧请求频率 |
firCount | uint | 接收端发出的 FIR 数 | 同上,FIR 罕用 |
framesReceived | uint | 收到的帧数 | 视频 |
framesDecoded | uint | 解码成功帧数 | 解码器健康 |
framesDropped | uint | 渲染前丢的帧 | 渲染压力 |
framesPerSecond | double | 当前解码帧率 | < 15 体感差 |
keyFramesDecoded | uint | 解码的关键帧数 | 关键帧间隔 |
frameWidth / frameHeight | uint | 最近帧的分辨率 | 实际接收的清晰度 |
qpSum | uint | QP 累加和(视频) | 平均 QP = qpSum/framesDecoded |
decodeTimeSeconds | s | 累计解码耗时 | 解码瓶颈 |
totalDecodeTime | s | 同上 | 兼容字段 |
totalProcessingDelay | s | 接收到送出累计延迟 | 端到端延迟一部分 |
totalAssemblyTime | s | 包组装成帧的耗时 | 高表示丢包重传多 |
framesAssembledFromMultiplePackets | uint | 多包组成的帧数 | 关键帧/大帧 |
freezeCount | uint | 卡顿次数(视频) | 体验直接指标 |
totalFreezesDuration | s | 累计卡顿时长 | 体验 |
pauseCount | uint | 主动暂停次数 | 用户操作 |
totalPausesDuration | s | 累计暂停时长 | 与卡顿区分 |
concealedSamples | uint | PLC 补帧样本数(音频) | 音频丢包恢复量 |
silentConcealedSamples | uint | 用静音补的样本数 | PLC 失败兜底 |
concealmentEvents | uint | 补帧事件数 | 离散度 |
insertedSamplesForDeceleration | uint | 抖动缓冲加样本 | 应对网络变快 |
removedSamplesForAcceleration | uint | 抖动缓冲减样本 | 应对网络变慢 |
audioLevel | 0~1 | 当前音量 | 麦克风是否在采 |
totalAudioEnergy | float | 累计能量 | RMS = √(energy/duration) |
totalSamplesDuration | s | 总样本时长 | 配合 audioEnergy |
fecPacketsReceived | uint | 收到 FEC 包数 | FEC 是否启用 |
fecPacketsDiscarded | uint | FEC 包但没用上的数 | 越多说明丢包少 |
lastPacketReceivedTimestamp | DOMHighResTimeStamp | 最后收包时间 | 流是否停了 |
estimatedPlayoutTimestamp | NTP | 预计播放时间戳 | 与发送端时序对齐 |
2.2 outbound-rtp:本端发送
| 字段 | 类型/单位 | 含义 | 排障用途 |
|---|---|---|---|
kind | "audio"/"video" | ||
ssrc | uint | 发出的 SSRC | |
mid | string | ||
rid | string | Simulcast 层名 | 哪一档 |
codecId | string | 编码器 stat 引用 | |
mediaSourceId | string | 引用 media-source | 同源关联 |
packetsSent | uint | 累计发包数 | 上行流是否在动 |
bytesSent | uint | 累计发送字节 | 上行码率 |
headerBytesSent | uint | RTP 头字节 | overhead |
retransmittedPacketsSent | uint | RTX 重传包数 | NACK 应答数 |
retransmittedBytesSent | uint | RTX 字节 | 重传占带宽 |
nackCount | uint | 收到的 NACK 数 | 重传压力 |
pliCount | uint | 收到的 PLI 数 | 关键帧请求压力 |
firCount | uint | 收到的 FIR 数 | |
framesSent | uint | 发出的帧数 | |
framesEncoded | uint | 编码完成的帧数 | 编码器吞吐 |
keyFramesEncoded | uint | 编码的关键帧数 | IDR 频率 |
framesPerSecond | double | 编码帧率 | 编码侧限制 |
frameWidth / frameHeight | uint | 当前分辨率 | |
qpSum | uint | QP 累加 | 平均 QP = qpSum/framesEncoded |
targetBitrate | uint bps | 编码器目标码率 | GCC 给的指令 |
totalEncodeTime | s | 累计编码耗时 | 编码 CPU 瓶颈 |
totalPacketSendDelay | s | 入 Pacer 到出网累计延迟 | Pacer 压力 |
encoderImplementation | string | "OpenH264" / "ExternalEncoder" 等 | 硬编/软编 |
powerEfficientEncoder | bool | 是否使用低功耗硬编 | 移动端电池 |
qualityLimitationReason | "none"/"cpu"/"bandwidth"/"other" | 受限原因 | 关键诊断字段 |
qualityLimitationDurations | dict<reason, s> | 各原因累计时长 | 拍 SLA |
qualityLimitationResolutionChanges | uint | 因受限切分辨率次数 | 稳定性 |
scalabilityMode | string | "L1T3"/"L3T3_KEY" | SVC 是否生效 |
fecPacketsSent | uint | FEC 包数 | |
active | bool | 该 encoding 是否激活 | Simulcast 层切 |
rtxSsrc | uint | 对应 RTX 流 SSRC |
2.3 remote-inbound-rtp:对端报告的接收情况(你的发送视角)
| 字段 | 含义 | 用途 |
|---|---|---|
packetsReceived | 对端收到的包数 | |
packetsLost | 对端丢包数 | 端到端丢包率 |
fractionLost | 0~1 | 对端瞬时丢包率(RTCP RR 报告) |
jitter | s | 对端测得的抖动 |
roundTripTime | s | 该流的 RTT 估计 |
totalRoundTripTime | s | 累计 RTT |
roundTripTimeMeasurements | uint | RTT 样本数 |
reportsReceived | uint | 收到的 RR 数 |
2.4 remote-outbound-rtp:对端报告的发送情况
| 字段 | 含义 |
|---|---|
packetsSent | 对端发出包数 |
bytesSent | 对端发出字节 |
remoteTimestamp | 远端时间戳 |
reportsSent | 发出的 SR 数 |
totalRoundTripTime | RTT 累计 |
2.5 media-source:采集源(仅发送侧)
| 字段 | 含义 | 用途 |
|---|---|---|
kind | audio/video | |
trackIdentifier | string | track id |
width / height | 原始采集分辨率 | 采集是否变化 |
framesPerSecond | 原始帧率 | 摄像头能力 |
frames | 累计采集帧数 | |
audioLevel | 0~1 | 麦克风电平 |
totalAudioEnergy | float | |
echoReturnLoss | dB | AEC 效果 |
echoReturnLossEnhancement | dB | AEC 残留压制 |
droppedSamplesDuration | s | 采集丢样时长 |
droppedSamplesEvents | uint | 采集丢样事件数 |
2.6 media-playout:渲染侧(接收侧)
| 字段 | 含义 |
|---|---|
kind | "audio" |
synthesizedSamplesDuration | PLC 合成时长 |
synthesizedSamplesEvents | PLC 事件数 |
totalSamplesDuration | 总播放时长 |
totalPlayoutDelay | 累计播放延迟 |
totalSamplesCount | 总样本数 |
2.7 candidate-pair:当前选用的 ICE 路径
| 字段 | 含义 | 用途 |
|---|---|---|
state | "succeeded" 等 | 是否在用 |
nominated | bool | 是否被提名 |
localCandidateId / remoteCandidateId | string | 关联候选 |
priority | uint | ICE 优先级 |
bytesSent / bytesReceived | uint | 路径字节数 |
packetsSent / packetsReceived | uint | 路径包数 |
currentRoundTripTime | s | 当前 RTT |
availableOutgoingBitrate | bps | GCC 估的可用上行 |
availableIncomingBitrate | bps | 下行估计(不一定准) |
requestsSent / requestsReceived | uint | STUN binding 请求 |
consentRequestsSent | uint | ICE consent 请求 |
lastPacketSentTimestamp | ts | 最后发包时间 |
totalRoundTripTime | s | RTT 累计 |
2.8 local-candidate / remote-candidate
| 字段 | 含义 |
|---|---|
candidateType | "host"/"srflx"/"prflx"/"relay" |
protocol | "udp"/"tcp" |
address | IP(隐私模式可能 mDNS) |
port | uint |
priority | uint |
url | 触发该候选的 STUN/TURN URL |
relayProtocol | TURN 中继协议 |
networkType | "wifi"/"cellular"/"ethernet"(部分浏览器) |
2.9 transport
| 字段 | 含义 |
|---|---|
bytesSent / bytesReceived | uint |
packetsSent / packetsReceived | uint |
dtlsState | "new"/"connecting"/"connected"/"closed"/"failed" |
iceState | RTCIceTransportState |
selectedCandidatePairId | string |
localCertificateId / remoteCertificateId | string |
tlsVersion | hex |
dtlsCipher | string |
srtpCipher | string |
iceRole | "controlling"/"controlled" |
iceLocalUsernameFragment | string |
2.10 codec
| 字段 | 含义 |
|---|---|
payloadType | RTP PT |
mimeType | "video/VP8" 等 |
clockRate | 90000/48000 |
channels | 音频 |
sdpFmtpLine | fmtp 内容 |
2.11 certificate
| 字段 | 含义 |
|---|---|
fingerprint | SHA-256 等 |
fingerprintAlgorithm | string |
base64Certificate | string |
issuerCertificateId | string |
2.12 data-channel
| 字段 | 含义 |
|---|---|
label | string |
protocol | string |
state | "open" 等 |
messagesSent / messagesReceived | uint |
bytesSent / bytesReceived | uint |
dataChannelIdentifier | uint |
2.13 peer-connection
| 字段 | 含义 |
|---|---|
dataChannelsOpened | uint |
dataChannelsClosed | uint |
3. 排障对照表
按"用户反馈的现象 → 看哪些字段"。
3.1 视频卡顿
| 看 | 字段 | 解读 |
|---|---|---|
| 收 | inbound-rtp.freezeCount ↑ | 直接卡顿次数 |
| 收 | inbound-rtp.totalFreezesDuration | 累计冻结秒数 |
| 收 | inbound-rtp.framesPerSecond < 15 | 帧率掉 |
| 收 | inbound-rtp.packetsLost 增长快 | 接收侧丢包 |
| 收 | inbound-rtp.jitterBufferDelay/Emitted 飙升 | 抖动太大 |
| 发 | outbound-rtp.qualityLimitationReason=bandwidth | 上行受限 |
| 发 | outbound-rtp.framesPerSecond 与设定值差距大 | 编码器跟不上 |
| 发 | outbound-rtp.qualityLimitationReason=cpu | CPU 瓶颈 |
| 链路 | candidate-pair.currentRoundTripTime > 0.3s | 远 |
3.2 画面模糊
| 字段 | 解读 |
|---|---|
outbound-rtp.frameWidth/Height 比 media-source.width/height 小 | 编码侧降分辨率 |
outbound-rtp.qualityLimitationReason=bandwidth | 带宽逼降 |
outbound-rtp.qpSum / framesEncoded 高(> 35) | QP 高 → 模糊 |
inbound-rtp.frameWidth/Height | 接收侧实际分辨率 |
Simulcast: outbound-rtp[rid=f].active=false | 高层关了 |
3.3 回声
| 字段 | 解读 |
|---|---|
media-source.echoReturnLoss < 10dB | AEC 几乎没用 |
media-source.echoReturnLossEnhancement 低 | AEC NLP 不够 |
inbound-rtp.audioLevel 周期性变化 | 远端可能听到自己 |
media-source.totalAudioEnergy 高 + 自己说话停止 | 设备拾到了播放声 |
3.4 声音断续
| 字段 | 解读 |
|---|---|
inbound-rtp.concealedSamples 增长快 | PLC 频繁 |
inbound-rtp.concealmentEvents ↑ | 离散事件 |
inbound-rtp.jitter > 30ms | 抖动 |
media-playout.synthesizedSamplesDuration ↑ | 渲染侧合成多 |
inbound-rtp.packetsLost ↑ | 丢包 |
3.5 RTT 高 / 互动延迟大
| 字段 | 解读 |
|---|---|
candidate-pair.currentRoundTripTime | 当前 RTT |
candidate-pair.localCandidateId → 看 candidateType | 是否走了 relay |
remote-inbound-rtp.roundTripTime | 媒体流 RTT |
inbound-rtp.jitterBufferDelay/Emitted | 抖动缓冲贡献了多少延迟 |
3.6 频繁断流
| 字段 | 解读 |
|---|---|
transport.dtlsState | DTLS 状态 |
candidate-pair.state | ICE 路径状态 |
candidate-pair.requestsSent 是否在涨 | ICE keepalive |
candidate-pair.consentRequestsSent | consent freshness |
4. freezeCount 是怎么算的
W3C 定义:视频帧间隔超过 max(3 × avgFrameInterval, avgFrameInterval + 150ms) 计一次 freeze。
function detectFreeze(framesReceivedTimes) {
const intervals = [];
for (let i = 1; i < framesReceivedTimes.length; i++) {
intervals.push(framesReceivedTimes[i] - framesReceivedTimes[i-1]);
}
const avg = intervals.reduce((a,b) => a+b, 0) / intervals.length;
const threshold = Math.max(3 * avg, avg + 0.15);
let count = 0;
let totalDuration = 0;
for (const iv of intervals) {
if (iv > threshold) {
count++;
totalDuration += iv;
}
}
return { freezeCount: count, totalFreezesDuration: totalDuration };
}
实务上直接读 inbound-rtp.freezeCount 和 totalFreezesDuration,浏览器算好的更准。
告警阈值:单分钟 freezeCount > 1 即标记为体验差。
5. 推荐上报模型
5.1 采集
class StatsReporter {
constructor(pc, opts = {}) {
this.pc = pc;
this.intervalMs = opts.intervalMs ?? 2000;
this.prev = new Map();
this.timer = null;
this.onSample = opts.onSample ?? (() => {});
}
start() {
this.timer = setInterval(() => this.collect(), this.intervalMs);
}
stop() {
clearInterval(this.timer);
}
async collect() {
const report = await this.pc.getStats();
const sample = { ts: Date.now() };
for (const r of report.values()) {
switch (r.type) {
case 'inbound-rtp':
this.fillInbound(sample, r);
break;
case 'outbound-rtp':
this.fillOutbound(sample, r);
break;
case 'remote-inbound-rtp':
this.fillRemoteInbound(sample, r);
break;
case 'candidate-pair':
if (r.nominated) this.fillCandidatePair(sample, r);
break;
case 'media-source':
this.fillMediaSource(sample, r);
break;
}
}
this.onSample(this.diff(sample));
}
fillInbound(s, r) {
const k = `${r.kind}_in`;
s[`${k}_packetsLost`] = r.packetsLost;
s[`${k}_packetsReceived`] = r.packetsReceived;
s[`${k}_bytesReceived`] = r.bytesReceived;
s[`${k}_jitter`] = r.jitter;
s[`${k}_freezeCount`] = r.freezeCount;
s[`${k}_freezeDur`] = r.totalFreezesDuration;
s[`${k}_framesDecoded`] = r.framesDecoded;
s[`${k}_fps`] = r.framesPerSecond;
s[`${k}_width`] = r.frameWidth;
s[`${k}_height`] = r.frameHeight;
s[`${k}_concealed`] = r.concealedSamples;
}
fillOutbound(s, r) {
const k = `${r.kind}_out_${r.rid ?? '0'}`;
s[`${k}_bytesSent`] = r.bytesSent;
s[`${k}_packetsSent`] = r.packetsSent;
s[`${k}_retxBytes`] = r.retransmittedBytesSent;
s[`${k}_framesEncoded`] = r.framesEncoded;
s[`${k}_fps`] = r.framesPerSecond;
s[`${k}_qpSum`] = r.qpSum;
s[`${k}_targetBitrate`] = r.targetBitrate;
s[`${k}_limit`] = r.qualityLimitationReason;
s[`${k}_active`] = r.active;
s[`${k}_width`] = r.frameWidth;
s[`${k}_height`] = r.frameHeight;
}
fillRemoteInbound(s, r) {
const k = `${r.kind}_remoteIn`;
s[`${k}_fractionLost`] = r.fractionLost;
s[`${k}_rtt`] = r.roundTripTime;
}
fillCandidatePair(s, r) {
s.pair_rtt = r.currentRoundTripTime;
s.pair_availOut = r.availableOutgoingBitrate;
s.pair_availIn = r.availableIncomingBitrate;
s.pair_bytesSent = r.bytesSent;
s.pair_bytesReceived = r.bytesReceived;
}
fillMediaSource(s, r) {
if (r.kind === 'audio') {
s.mic_audioLevel = r.audioLevel;
s.mic_echoReturnLoss = r.echoReturnLoss;
} else {
s.cam_width = r.width;
s.cam_height = r.height;
s.cam_fps = r.framesPerSecond;
}
}
diff(curr) {
// 把累计型字段转成"过去 intervalMs 内的增量"
const out = { ts: curr.ts, intervalMs: this.intervalMs };
const accumulative = [
'video_in_packetsLost', 'video_in_packetsReceived', 'video_in_bytesReceived',
'video_in_freezeCount',
'audio_in_packetsLost', 'audio_in_packetsReceived', 'audio_in_concealed',
'pair_bytesSent', 'pair_bytesReceived',
];
for (const key of accumulative) {
const prev = this.prev.get(key) ?? curr[key];
out[key + '_delta'] = curr[key] - prev;
this.prev.set(key, curr[key]);
}
// 其它字段直接拷
for (const k of Object.keys(curr)) {
if (!accumulative.includes(k)) out[k] = curr[k];
}
return out;
}
}
const reporter = new StatsReporter(pc, {
intervalMs: 5000,
onSample: (s) => {
navigator.sendBeacon('/api/webrtc-stats', JSON.stringify(s));
},
});
reporter.start();
5.2 上报维度建议
| 维度 | 用途 |
|---|---|
| user_id / room_id / publisher_id | 关联用户/房间 |
| device / OS / browser version | 设备分群 |
| network_type (wifi/4g/5g) | 网络分群 |
| sfu_node | 服务端归因 |
| client_version | 版本回滚 |
5.3 关键告警指标
| 指标 | 计算 | 阈值 |
|---|---|---|
| 视频丢包率 | ΔpacketsLost / (ΔpacketsLost + Δpacketsreceived) | > 5% 红色 |
| 卡顿率 | Δfreezeduration / intervalMs | > 5% 红色 |
| RTT | pair_rtt | > 0.3s 黄 / > 0.5s 红 |
| 平均 QP | qpSum / framesEncoded(按差分) | > 35 黄 |
| 上行受限时长比 | qualityLimitationDurations.bandwidth / total | > 30% 黄 |
6. 完整诊断函数
async function diagnose(pc) {
const report = await pc.getStats();
const issues = [];
let inboundVideo, outboundVideo, pair, remoteInbound;
for (const r of report.values()) {
if (r.type === 'inbound-rtp' && r.kind === 'video') inboundVideo = r;
if (r.type === 'outbound-rtp' && r.kind === 'video') outboundVideo = r;
if (r.type === 'remote-inbound-rtp' && r.kind === 'video') remoteInbound = r;
if (r.type === 'candidate-pair' && r.nominated) pair = r;
}
if (pair?.currentRoundTripTime > 0.3) {
issues.push(`RTT 高: ${(pair.currentRoundTripTime*1000).toFixed(0)}ms`);
}
if (inboundVideo?.framesPerSecond < 15) {
issues.push(`接收帧率低: ${inboundVideo.framesPerSecond}`);
}
if (inboundVideo?.freezeCount > 0) {
issues.push(`卡顿次数: ${inboundVideo.freezeCount}`);
}
if (outboundVideo?.qualityLimitationReason && outboundVideo.qualityLimitationReason !== 'none') {
issues.push(`编码受限: ${outboundVideo.qualityLimitationReason}`);
}
if (remoteInbound?.fractionLost > 0.05) {
issues.push(`远端丢包率: ${(remoteInbound.fractionLost*100).toFixed(1)}%`);
}
if (pair?.availableOutgoingBitrate < (outboundVideo?.targetBitrate ?? 0) * 1.1) {
issues.push(`上行带宽紧张: ${pair.availableOutgoingBitrate}`);
}
return issues;
}
setInterval(async () => {
const issues = await diagnose(pc);
if (issues.length) console.warn('[WebRTC]', issues.join('; '));
}, 5000);
7. chrome://webrtc-internals 速通
| 区块 | 看什么 |
|---|---|
| Create Dump | 拷整页 JSON,用于离线分析 |
| RTCPeerConnection | 选具体连接 |
| Stats Graphs | 自动绘制时间序列,重点看 bytesReceived、framesPerSecond、packetsLost、availableOutgoingBitrate |
| GetUserMedia Requests | 媒体约束历史 |
| ICE candidate grid | 看选用哪一对 candidate |
Firefox 对应 about:webrtc,字段命名略不同但语义一致。
8. 反模式
| 反模式 | 后果 | 正确做法 |
|---|---|---|
| 100ms 频率 getStats | 主线程压力大,触发自身卡顿 | 1~5s 一次 |
| 上报全部字段 | 流量爆,存储成本高 | 只取告警相关 20 个 |
用 packetsLost / packetsReceived 算丢包率 | 公式错(应是 lost / (lost + received)) | 注意分母 |
用 goog-* 字段 | 已废弃,未来移除 | 用标准字段 |
| 不做差分直接上报累计值 | 后端拉差分压力大 | 客户端差分 |
拿 availableIncomingBitrate 当下行带宽真值 | 该字段在多数浏览器上不准 | 用接收侧 bytesReceived 差分 |
把 connectionState 替代 iceState | 含义不同 | 关联看,不要互换 |
忽略 qualityLimitationReason | 不知道为何降清晰度 | 必上报 |
| 不区分 Simulcast 各 rid 的 outbound-rtp | 数据混在一起 | 按 rid 分维度 |
| 没考虑跨连接重启 stats 重置 | 差分变成负数 | 连接 id 变化时重置 prev |
9. 权威资料
- W3C
webrtc-stats:https://www.w3.org/TR/webrtc-stats/ - W3C
webrtc(PeerConnection + getStats):https://www.w3.org/TR/webrtc/ - RFC 3550 RTP(jitter / fractionLost 定义):https://www.rfc-editor.org/rfc/rfc3550
- RFC 8888 Congestion Control Feedback:https://www.rfc-editor.org/rfc/rfc8888
- Chrome WebRTC stats implementation:https://webrtc.googlesource.com/src/+/refs/heads/main/stats/
- Mozilla
about:webrtc字段映射:https://wiki.mozilla.org/Media/WebRTC/about_webrtc - 核对日期:2026-06-22