diff --git a/README.md b/README.md index c97b7f1..aad46db 100644 --- a/README.md +++ b/README.md @@ -96,6 +96,12 @@ bandwidthRtc.sendDtmf("313,3211*#"); bandwidthRtc.sendDtmf("5", undefined, 200, 100); // 200ms tone, 100ms gap ``` +### getCallStats + +- Description: returns a `CallStatsSnapshot` parsed from native WebRTC stats (v1 client). Pass the previous snapshot to get `inboundBitrate`/`outboundBitrate` (bits/s). +- Fields: `packetsReceived`, `packetsLost`, `bytesReceived`, `jitter`, `audioLevel`, `packetsSent`, `bytesSent`, `roundTripTime`, `codec`, `inboundBitrate`, `outboundBitrate`, `timestamp` (seconds) +- RTCP data from the remote receiver: `remoteFractionLost`, `remoteJitter`, `rtcpRoundTripTime` + ## Event Listeners ### onStreamAvailable diff --git a/src/index.ts b/src/index.ts index 042b9cd..13444b2 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,3 +1,4 @@ export { default } from "./bandwidthRtc"; export { BandwidthRtc as BandwidthRtcV1 } from "./v1/bandwidthRtc"; export * from "./types"; +export type { CallStatsSnapshot } from "./v1/types"; diff --git a/src/v1/bandwidthRtc.test.ts b/src/v1/bandwidthRtc.test.ts index b1c6ab4..b4a049d 100644 --- a/src/v1/bandwidthRtc.test.ts +++ b/src/v1/bandwidthRtc.test.ts @@ -1,6 +1,7 @@ import { BandwidthRtc } from "./bandwidthRtc"; import { setupMocks, setupNavigatorMocks } from "../mocks"; import { BandwidthRtcError } from "../types"; +import logger from "../logging"; // Mock Signaling class jest.mock("./signaling", () => { @@ -1158,3 +1159,116 @@ describe("bandwidthRtcV1 onconnectionstatechange wiring", () => { expect(offerPublishSdp).not.toHaveBeenCalled(); }); }); + +describe("bandwidthRtcV1 getCallStats", () => { + const report = (entries: any[]) => new Map(entries.map((e) => [e.id, e])); + const subReport = report([ + { id: "in", type: "inbound-rtp", kind: "audio", packetsReceived: 10, packetsLost: 1, bytesReceived: 2000, jitter: 0.01, audioLevel: 0.5, codecId: "c" }, + { id: "c", type: "codec", mimeType: "audio/opus" }, + { id: "cp", type: "candidate-pair", state: "succeeded", currentRoundTripTime: 0.05 }, + ]); + const pubReport = report([ + { id: "out", type: "outbound-rtp", kind: "audio", packetsSent: 20, bytesSent: 4000 }, + { id: "r", type: "remote-inbound-rtp", kind: "audio", fractionLost: 0.1, jitter: 0.02, roundTripTime: 0.08 }, + ]); + const setup = (sub: any, pub: any) => { + const brtc = new BandwidthRtc(); + (brtc as any).subscribingPeerConnection = sub && { getStats: sub }; + (brtc as any).publishingPeerConnection = pub && { getStats: pub }; + return brtc; + }; + + test("parses both reports including RTCP fields", async () => { + const snap = await setup(jest.fn().mockResolvedValue(subReport), jest.fn().mockResolvedValue(pubReport)).getCallStats(); + expect(snap).toMatchObject({ + packetsReceived: 10, + packetsLost: 1, + bytesReceived: 2000, + jitter: 0.01, + audioLevel: 0.5, + roundTripTime: 0.05, + codec: "opus", + packetsSent: 20, + bytesSent: 4000, + remoteFractionLost: 0.1, + remoteJitter: 0.02, + rtcpRoundTripTime: 0.08, + inboundBitrate: 0, + outboundBitrate: 0, + }); + expect(snap.timestamp).toBeGreaterThan(0); + }); + + test("computes bitrate from previous snapshot and clamps negative deltas", async () => { + const brtc = setup(jest.fn().mockResolvedValue(subReport), jest.fn().mockResolvedValue(pubReport)); + const now = Date.now() / 1000; + const prev: any = { bytesReceived: 1000, bytesSent: 9000, timestamp: now - 2 }; + const snap = await brtc.getCallStats(prev); + expect(snap.inboundBitrate).toBeCloseTo(4000, -2); + expect(snap.outboundBitrate).toBe(0); + }); + + test("returns defaults when peer connections are undefined", async () => { + const snap = await setup(undefined, undefined).getCallStats(); + expect(snap.codec).toBe("unknown"); + expect(snap.packetsReceived).toBe(0); + expect(snap.timestamp).toBeGreaterThan(0); + }); + + test("keeps defaults for a side whose getStats rejects", async () => { + const snap = await setup(jest.fn().mockRejectedValue(new Error("boom")), jest.fn().mockResolvedValue(pubReport)).getCallStats(); + expect(snap.packetsReceived).toBe(0); + expect(snap.bytesSent).toBe(4000); + }); +}); + +describe("bandwidthRtcV1 call stats trace", () => { + const TRACE_INTERVAL_MS = 5 * 60 * 1000; + const pc = () => ({ getStats: jest.fn().mockResolvedValue(new Map()), close: jest.fn() }); + + beforeEach(() => { + jest.useFakeTimers(); + jest.spyOn(logger, "debug").mockImplementation(() => {}); + }); + + afterEach(() => { + jest.useRealTimers(); + jest.restoreAllMocks(); + }); + + const traceCalls = () => (logger.debug as jest.Mock).mock.calls.filter(([msg]) => msg === "Call stats"); + + test("logs a snapshot every 5 minutes after connect", async () => { + const brtc = new BandwidthRtc(); + (brtc as any).publishingPeerConnection = pc(); + await brtc.connect({ endpointToken: "t" }); + + await jest.advanceTimersByTimeAsync(TRACE_INTERVAL_MS - 1); + expect(traceCalls()).toHaveLength(0); + await jest.advanceTimersByTimeAsync(1); + expect(traceCalls()).toHaveLength(1); + expect(traceCalls()[0][1]).toMatchObject({ codec: "unknown", rtcpRoundTripTime: 0 }); + await jest.advanceTimersByTimeAsync(TRACE_INTERVAL_MS); + expect(traceCalls()).toHaveLength(2); + brtc.disconnect(); + }); + + test("stops tracing after disconnect", async () => { + const brtc = new BandwidthRtc(); + (brtc as any).publishingPeerConnection = pc(); + await brtc.connect({ endpointToken: "t" }); + brtc.disconnect(); + + await jest.advanceTimersByTimeAsync(TRACE_INTERVAL_MS * 2); + expect(traceCalls()).toHaveLength(0); + }); + + test("skips the trace while no peer connection exists", async () => { + const brtc = new BandwidthRtc(); + await brtc.connect({ endpointToken: "t" }); + + await jest.advanceTimersByTimeAsync(TRACE_INTERVAL_MS); + expect(traceCalls()).toHaveLength(0); + brtc.disconnect(); + }); +}); diff --git a/src/v1/bandwidthRtc.ts b/src/v1/bandwidthRtc.ts index ef31b69..22b64ba 100644 --- a/src/v1/bandwidthRtc.ts +++ b/src/v1/bandwidthRtc.ts @@ -20,6 +20,7 @@ import { import { DiagnosticsBatcher } from "./diagnostics"; import Signaling from "./signaling"; import { + CallStatsSnapshot, CodecPreferences, ReadyMetadata, PublishedStream, @@ -66,12 +67,19 @@ const PUBLISH_ICE_CONNECT_POLL_INTERVAL_MS = 100; // The gateway owns ICE restart and rejects client offers until the peer is connected again. const RETRY_ICE_ON_FAILED = false; +/** How often the call stats snapshot is written to the debug log while connected. */ +const CALL_STATS_TRACE_INTERVAL_MS = 5 * 60 * 1000; + export class BandwidthRtc { private options?: RtcOptions; private diagnosticsBatcher: DiagnosticsBatcher; private signaling: Signaling; + private callStatsTraceTimer?: ReturnType; + // Previous traced snapshot; lets each trace carry bitrates computed over the trace interval + private lastTracedCallStats?: CallStatsSnapshot; + // One peer connection for all published (outgoing) streams, one for all subscribed (incoming) streams private publishingPeerConnection?: RTCPeerConnection; private subscribingPeerConnection?: RTCPeerConnection; @@ -150,6 +158,7 @@ export class BandwidthRtc { await this.signaling.connect(authParams, options); logger.info("Successfully connected"); + this.startCallStatsTrace(); } /** @@ -431,11 +440,109 @@ export class BandwidthRtc { .forEach(([, stream]) => stream.mediaStream.getVideoTracks().forEach((track) => (track.enabled = enabled))); } + /** + * Get a call quality snapshot from the native WebRTC stats, including RTCP data from the remote receiver + * @param previousSnapshot optional earlier snapshot, used to compute inbound/outbound bitrate + */ + async getCallStats(previousSnapshot?: CallStatsSnapshot): Promise { + const snapshot: CallStatsSnapshot = { + packetsReceived: 0, + packetsLost: 0, + bytesReceived: 0, + jitter: 0, + audioLevel: 0, + packetsSent: 0, + bytesSent: 0, + roundTripTime: 0, + codec: "unknown", + inboundBitrate: 0, + outboundBitrate: 0, + timestamp: 0, + remoteFractionLost: 0, + remoteJitter: 0, + rtcpRoundTripTime: 0, + }; + // Read the peer connections now; they are replaced on reconnect + const subPc = this.subscribingPeerConnection; + const pubPc = this.publishingPeerConnection; + const [subReport, pubReport] = await Promise.all([ + subPc?.getStats().catch((err) => logger.warn("getCallStats: subscribing getStats failed", err)), + pubPc?.getStats().catch((err) => logger.warn("getCallStats: publishing getStats failed", err)), + ]); + + let codecId: string | undefined; + subReport?.forEach((stat: any) => { + if (stat.type === "inbound-rtp" && stat.kind === "audio") { + snapshot.packetsReceived = stat.packetsReceived ?? 0; + snapshot.packetsLost = stat.packetsLost ?? 0; + snapshot.bytesReceived = stat.bytesReceived ?? 0; + snapshot.jitter = stat.jitter ?? 0; + snapshot.audioLevel = stat.audioLevel ?? 0; + codecId = stat.codecId; + } else if (stat.type === "candidate-pair" && stat.state === "succeeded") { + snapshot.roundTripTime = stat.currentRoundTripTime ?? 0; + } + }); + const mimeType = codecId ? subReport?.get(codecId)?.mimeType : undefined; + if (mimeType) { + snapshot.codec = mimeType.replace(/^audio\//, ""); + } + + pubReport?.forEach((stat: any) => { + if (stat.kind !== "audio") { + return; + } + if (stat.type === "outbound-rtp") { + snapshot.packetsSent = stat.packetsSent ?? 0; + snapshot.bytesSent = stat.bytesSent ?? 0; + } else if (stat.type === "remote-inbound-rtp") { + snapshot.remoteFractionLost = stat.fractionLost ?? 0; + snapshot.remoteJitter = stat.jitter ?? 0; + snapshot.rtcpRoundTripTime = stat.roundTripTime ?? 0; + } + }); + + snapshot.timestamp = Date.now() / 1000; + const timeDelta = previousSnapshot ? snapshot.timestamp - previousSnapshot.timestamp : 0; + if (previousSnapshot && timeDelta > 0) { + snapshot.inboundBitrate = (Math.max(0, snapshot.bytesReceived - previousSnapshot.bytesReceived) * 8) / timeDelta; + snapshot.outboundBitrate = (Math.max(0, snapshot.bytesSent - previousSnapshot.bytesSent) * 8) / timeDelta; + } + return snapshot; + } + + private startCallStatsTrace() { + this.stopCallStatsTrace(); + this.callStatsTraceTimer = setInterval(() => this.traceCallStats(), CALL_STATS_TRACE_INTERVAL_MS); + } + + private stopCallStatsTrace() { + if (this.callStatsTraceTimer) { + clearInterval(this.callStatsTraceTimer); + this.callStatsTraceTimer = undefined; + } + this.lastTracedCallStats = undefined; + } + + private async traceCallStats() { + // Between a teardown and the next reconnect there is nothing to measure + if (!this.publishingPeerConnection && !this.subscribingPeerConnection) { + return; + } + try { + this.lastTracedCallStats = await this.getCallStats(this.lastTracedCallStats); + logger.debug("Call stats", this.lastTracedCallStats); + } catch (err) { + logger.warn("Call stats trace failed", err); + } + } + /** * Disconnect from the Bandwidth WebRTC platform, and tear down all published streams */ disconnect() { logger.info("Disconnecting"); + this.stopCallStatsTrace(); this.cleanupPublishedStreams(); this.publishingPeerConnection?.close(); this.subscribingPeerConnection?.close(); diff --git a/src/v1/types.ts b/src/v1/types.ts index e85ef6a..f26b950 100644 --- a/src/v1/types.ts +++ b/src/v1/types.ts @@ -105,3 +105,26 @@ export interface ReadyMetadata { territory: string; // TODO enum region: string; // TODO enum } + +/** Call quality snapshot parsed from native WebRTC stats. Times are in seconds, bitrates in bits/s. */ +export interface CallStatsSnapshot { + packetsReceived: number; + packetsLost: number; + bytesReceived: number; + jitter: number; + audioLevel: number; + packetsSent: number; + bytesSent: number; + roundTripTime: number; + codec: string; + inboundBitrate: number; + outboundBitrate: number; + /** Unix time in seconds */ + timestamp: number; + /** RTCP remote-inbound-rtp fractionLost */ + remoteFractionLost: number; + /** RTCP remote-inbound-rtp jitter */ + remoteJitter: number; + /** RTCP remote-inbound-rtp roundTripTime */ + rtcpRoundTripTime: number; +}