Skip to content

Commit 6520a75

Browse files
feat: add missing data in stream detector
1 parent be1df2e commit 6520a75

File tree

3 files changed

+156
-0
lines changed

3 files changed

+156
-0
lines changed

src/WebRTCIssueDetector.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@ import {
2727
} from './detectors';
2828
import { CompositeRTCStatsParser, RTCStatsParser } from './parser';
2929
import createLogger from './utils/logger';
30+
import { MissingStreamDataDetector } from './detectors/MissingStreamDataDetector';
3031

3132
class WebRTCIssueDetector {
3233
readonly eventEmitter: WebRTCIssueEmitter;
@@ -67,6 +68,7 @@ class WebRTCIssueDetector {
6768
new AvailableOutgoingBitrateIssueDetector(),
6869
new UnknownVideoDecoderImplementationDetector(),
6970
new FrozenVideoTrackDetector(),
71+
new MissingStreamDataDetector(),
7072
];
7173

7274
this.networkScoresCalculator = params.networkScoresCalculator ?? new DefaultNetworkScoresCalculator();
Lines changed: 152 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,152 @@
1+
import {
2+
IssueDetectorResult,
3+
IssueReason,
4+
IssueType,
5+
ParsedInboundAudioStreamStats,
6+
ParsedInboundVideoStreamStats,
7+
WebRTCStatsParsed,
8+
} from '../types';
9+
import BaseIssueDetector from './BaseIssueDetector';
10+
11+
interface MissingStreamDetectorParams {
12+
timeoutMs?: number;
13+
}
14+
15+
export class MissingStreamDataDetector extends BaseIssueDetector {
16+
readonly #lastMarkedAt = new Map<string, number>();
17+
readonly #timeoutMs: number;
18+
19+
constructor(params: MissingStreamDetectorParams = {}) {
20+
super();
21+
this.#timeoutMs = params.timeoutMs ?? 10_000;
22+
}
23+
24+
performDetection(data: WebRTCStatsParsed): IssueDetectorResult {
25+
const { connection: { id: connectionId } } = data;
26+
const issues = this.processData(data);
27+
this.setLastProcessedStats(connectionId, data);
28+
return issues;
29+
}
30+
31+
private processData(data: WebRTCStatsParsed): IssueDetectorResult {
32+
const { connection: { id: connectionId } } = data;
33+
const previousStats = this.getLastProcessedStats(connectionId);
34+
const issues: IssueDetectorResult = [];
35+
36+
if (!previousStats) {
37+
return issues;
38+
}
39+
40+
const { video: { inbound: newVideoInbound } } = data;
41+
const { video: { inbound: prevVideoInbound } } = previousStats;
42+
const { audio: { inbound: newAudioInbound } } = data;
43+
const { audio: { inbound: prevAudioInbound } } = previousStats;
44+
45+
const mapVideoStatsByTrackId = (items: ParsedInboundVideoStreamStats[]) => new Map<string, ParsedInboundVideoStreamStats>(
46+
items.map((item) => [item.track.trackIdentifier, item] as const),
47+
);
48+
const mapAudioStatsByTrackId = (items: ParsedInboundAudioStreamStats[]) => new Map<string, ParsedInboundAudioStreamStats>(
49+
items.map((item) => [item.track.trackIdentifier, item] as const),
50+
);
51+
52+
const newVideoInboundByTrackId = mapVideoStatsByTrackId(newVideoInbound);
53+
const prevVideoInboundByTrackId = mapVideoStatsByTrackId(prevVideoInbound);
54+
const newAudioInboundByTrackId = mapAudioStatsByTrackId(newAudioInbound);
55+
const prevAudioInboundByTrackId = mapAudioStatsByTrackId(prevAudioInbound);
56+
const unvisitedTrackIds = new Set(this.#lastMarkedAt.keys());
57+
58+
Array.from(newVideoInboundByTrackId.entries()).forEach(([trackId, newInboundItem]) => {
59+
unvisitedTrackIds.delete(trackId);
60+
61+
const prevInboundItem = prevVideoInboundByTrackId.get(trackId);
62+
if (!prevInboundItem) {
63+
return;
64+
}
65+
66+
const deltaFramesReceived = newInboundItem.framesReceived - prevInboundItem.framesReceived;
67+
68+
if (deltaFramesReceived === 0 && !newInboundItem.track.detached && !newInboundItem.track.ended) {
69+
const hasIssue = this.markIssue(trackId);
70+
71+
if (!hasIssue) {
72+
return;
73+
}
74+
75+
const statsSample = {
76+
framesReceived: newInboundItem.framesReceived,
77+
framesDropped: newInboundItem.framesDropped,
78+
trackDetached: newInboundItem.track.detached,
79+
trackEnded: newInboundItem.track.ended,
80+
};
81+
82+
issues.push({
83+
type: IssueType.Stream,
84+
reason: IssueReason.MissingVideoStreamData,
85+
statsSample,
86+
});
87+
} else {
88+
this.removeMarkIssue(trackId);
89+
}
90+
});
91+
92+
Array.from(newAudioInboundByTrackId.entries()).forEach(([trackId, newInboundItem]) => {
93+
unvisitedTrackIds.delete(trackId);
94+
95+
const prevInboundItem = prevAudioInboundByTrackId.get(trackId);
96+
if (!prevInboundItem) {
97+
return;
98+
}
99+
100+
const deltaFramesReceived = newInboundItem.bytesReceived - prevInboundItem.bytesReceived;
101+
102+
if (deltaFramesReceived === 0 && !newInboundItem.track.detached && !newInboundItem.track.ended) {
103+
const hasIssue = this.markIssue(trackId);
104+
105+
if (!hasIssue) {
106+
return;
107+
}
108+
109+
const statsSample = {
110+
bytesReceived: newInboundItem.bytesReceived,
111+
packetsDiscarded: newInboundItem.packetsDiscarded,
112+
trackDetached: newInboundItem.track.detached,
113+
trackEnded: newInboundItem.track.ended,
114+
};
115+
116+
issues.push({
117+
type: IssueType.Stream,
118+
reason: IssueReason.MissingAudioStreamData,
119+
statsSample,
120+
});
121+
} else {
122+
this.removeMarkIssue(trackId);
123+
}
124+
});
125+
126+
unvisitedTrackIds.forEach((trackId) => {
127+
const lastMarkedAt = this.#lastMarkedAt.get(trackId);
128+
if (lastMarkedAt && Date.now() - lastMarkedAt > this.#timeoutMs) {
129+
this.removeMarkIssue(trackId);
130+
}
131+
});
132+
133+
return issues;
134+
}
135+
136+
private markIssue(trackId: string): boolean {
137+
const now = Date.now();
138+
const lastMarkedAt = this.#lastMarkedAt.get(trackId);
139+
140+
if (!lastMarkedAt || now - lastMarkedAt > this.#timeoutMs) {
141+
this.#lastMarkedAt.set(trackId, now);
142+
return true;
143+
}
144+
145+
return false;
146+
}
147+
148+
private removeMarkIssue(trackId: string): void {
149+
this.#lastMarkedAt.delete(trackId);
150+
}
151+
}
152+

src/types.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -83,6 +83,8 @@ export enum IssueReason {
8383
LowInboundMOS = 'low-inbound-mean-opinion-score',
8484
LowOutboundMOS = 'low-outbound-mean-opinion-score',
8585
FrozenVideoTrack = 'frozen-video-track',
86+
MissingVideoStreamData = 'missing-video-stream-data',
87+
MissingAudioStreamData = 'missing-audio-stream-data',
8688
}
8789

8890
export type IssuePayload = {

0 commit comments

Comments
 (0)