feat(web): observe selected pair responses (#181)

Add local, generation-guarded selected-pair response evidence without granting it routing or recovery authority.
This commit is contained in:
AAA authored and GitHub committed 2026-08-22 08:26:41 +08:00
1 parent 9980376a80
commit f81577c4c5
10 files changed
+285 -2

No files matched your search

+25
View File
@@ -53,6 +53,21 @@ function candidateEndpoint(
return `${host}:${port}`;
}
function candidatePairResponses(metrics: ConnectionMetrics): string {
if (metrics.candidatePairResponsesReceived === null) {
return "未知";
}
if (
metrics.intervalCandidatePairResponsesReceived === null ||
metrics.candidatePairSampleWindowMs === null
) {
return `${metrics.candidatePairResponsesReceived} 累计 · 区间未知`;
}
return `${metrics.candidatePairResponsesReceived} 累计 · +${
metrics.intervalCandidatePairResponsesReceived
} / ${(metrics.candidatePairSampleWindowMs / 1_000).toFixed(1)} s`;
}
function Metric({ label, value, title }: { label: string; value: string; title?: string }) {
return (
<div className="metric" title={title}>
@@ -164,6 +179,16 @@ export function StatsGrid({
title={remoteCandidateEndpoint}
/>
)}
<Metric
label="候选对 ID"
value={metrics.selectedCandidatePairId ?? "未知"}
title={metrics.selectedCandidatePairId ?? undefined}
/>
<Metric
label="STUN 响应"
value={candidatePairResponses(metrics)}
title="当前候选对的累计响应,以及同一候选对相邻有效采样窗的增量;零增量不代表断链"
/>
<Metric label="视频 Codec" value={metrics.codec ?? "未知"} />
{metrics.codecProfile && (
<Metric label="视频 Codec profile token" value={metrics.codecProfile} />
+2
View File
@@ -4,6 +4,8 @@ export const DIAGNOSTIC_SCHEMA_VERSION = 1;
const DIAGNOSTIC_METRIC_KEYS = [
"sampleWindowMs", "captureWidth", "captureHeight",
"candidatePairResponsesReceived", "intervalCandidatePairResponsesReceived",
"candidatePairSampleWindowMs",
"captureFramesPerSecond", "mediaSourceFramesPerSecond", "path",
"iceProtocol", "localRelayProtocol", "localCandidateType",
"remoteCandidateType", "rttMs", "bitrateKbps", "availableOutgoingKbps",
+6
View File
@@ -15,6 +15,9 @@ export interface ConnectionMetrics {
rtpRid: string | null;
trackIdentifier: string | null;
selectedCandidatePairId: string | null;
candidatePairResponsesReceived: number | null;
intervalCandidatePairResponsesReceived: number | null;
candidatePairSampleWindowMs: number | null;
captureWidth: number | null;
captureHeight: number | null;
captureFramesPerSecond: number | null;
@@ -93,6 +96,9 @@ export const EMPTY_METRICS: ConnectionMetrics = {
rtpRid: null,
trackIdentifier: null,
selectedCandidatePairId: null,
candidatePairResponsesReceived: null,
intervalCandidatePairResponsesReceived: null,
candidatePairSampleWindowMs: null,
captureWidth: null,
captureHeight: null,
captureFramesPerSecond: null,
+49
View File
@@ -10,7 +10,12 @@ type StatsRecord = Record<string, unknown> & {
timestamp: number;
};
const MAX_CANDIDATE_PAIR_SAMPLE_WINDOW_MS = 5_000;
export interface StatsAccumulator {
candidatePairId: string | null;
candidatePairResponsesReceived: number | null;
candidatePairTimestamp: number | null;
mediaId: string | null;
ssrc: number | null;
trackIdentifier: string | null;
@@ -76,6 +81,9 @@ export function captureMetrics(
export function createStatsAccumulator(): StatsAccumulator {
return {
candidatePairId: null,
candidatePairResponsesReceived: null,
candidatePairTimestamp: null,
mediaId: null,
ssrc: null,
trackIdentifier: null,
@@ -354,6 +362,16 @@ function positiveIntegerValue(
return value !== null && Number.isInteger(value) && value > 0 ? value : null;
}
function nonNegativeIntegerValue(
record: StatsRecord | null,
key: string,
): number | null {
const value = numberValue(record, key);
return value !== null && Number.isSafeInteger(value) && value >= 0
? value
: null;
}
function candidateAddressValue(record: StatsRecord | null): string | null {
const value = stringValue(record, "address");
return value !== null &&
@@ -433,6 +451,34 @@ export function collectConnectionMetricsFromReport(
? "direct"
: "unknown";
const candidatePairId = pair?.id ?? null;
const candidatePairResponsesReceived = nonNegativeIntegerValue(
pair,
"responsesReceived",
);
const candidatePairTimestamp = numberValue(pair, "timestamp");
const candidatePairSampleWindowMs =
candidatePairId !== null &&
candidatePairId === previous.candidatePairId &&
candidatePairResponsesReceived !== null &&
previous.candidatePairResponsesReceived !== null &&
candidatePairResponsesReceived >= previous.candidatePairResponsesReceived &&
candidatePairTimestamp !== null &&
previous.candidatePairTimestamp !== null &&
candidatePairTimestamp > previous.candidatePairTimestamp &&
candidatePairTimestamp - previous.candidatePairTimestamp <=
MAX_CANDIDATE_PAIR_SAMPLE_WINDOW_MS
? candidatePairTimestamp - previous.candidatePairTimestamp
: null;
const intervalCandidatePairResponsesReceived = intervalDelta(
candidatePairResponsesReceived,
previous.candidatePairResponsesReceived,
candidatePairSampleWindowMs !== null,
);
previous.candidatePairId = candidatePairId;
previous.candidatePairResponsesReceived = candidatePairResponsesReceived;
previous.candidatePairTimestamp = candidatePairTimestamp;
const mediaId = media?.id ?? null;
const ssrc = numberValue(media, "ssrc");
const trackIdentifier = media
@@ -763,6 +809,9 @@ export function collectConnectionMetricsFromReport(
rtpRid: stringValue(media, "rid"),
trackIdentifier,
selectedCandidatePairId: pair?.id ?? null,
candidatePairResponsesReceived,
intervalCandidatePairResponsesReceived,
candidatePairSampleWindowMs,
path,
iceProtocol,
localRelayProtocol,