From 8ce3006f0b3aaa16f6d4fb11db0a0c148332e357 Mon Sep 17 00:00:00 2001 From: smoghe-bw Date: Wed, 14 Oct 2026 16:37:53 -0400 Subject: [PATCH] VAPI-4167 feat: per-peer sent/received counters in call stats Add publishPeer and subscribePeer to CallStatsSnapshot, each holding that peer connection's own audio RTP packets/bytes sent and received, so stats can be attributed to the peer they came from. Existing flat fields are unchanged. Co-Authored-By: Claude Opus 5.5 --- README.md | 1 + src/index.ts | 2 +- src/v1/bandwidthRtc.test.ts | 68 +++++++++++++++++++++++++++++++++++++ src/v1/bandwidthRtc.ts | 12 +++++++ src/v1/types.ts | 12 +++++++ 5 files changed, 94 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index 31e3741..8457c0f 100644 --- a/README.md +++ b/README.md @@ -100,6 +100,7 @@ bandwidthRtc.sendDtmf("5", undefined, 200, 100); // 200ms tone, 100ms gap - Description: returns a `CallStatsSnapshot` parsed from native WebRTC stats. Pass the previous snapshot to get `inboundBitrate`/`outboundBitrate` (bits/s). - Fields: `packetsReceived`, `packetsLost`, `bytesReceived`, `jitter`, `audioLevel`, `packetsSent`, `bytesSent`, `roundTripTime`, `codec`, `inboundBitrate`, `outboundBitrate`, `timestamp` (seconds) +- Per peer connection: `publishPeer` and `subscribePeer`, each with audio RTP `packetsSent`, `bytesSent`, `packetsReceived`, `bytesReceived`. The publish peer normally only sends and the subscribe peer normally only receives. - RTCP data from the remote receiver: `remoteFractionLost`, `remoteJitter`, `rtcpRoundTripTime`. These are 0 until the first receiver report arrives. - With several audio streams, packet and byte counts are summed; jitter, loss, round trip time and audio level show the highest stream - Debug log: while connected, a snapshot is logged every 5 minutes, and a final one when the call ends (disconnect, fatal error, or the signaling socket closing for good). While in a call (a subscribe stream is available), a snapshot is also logged every 10 seconds under "In-call stats", with a final one when the call ends. The two traces run independently and may log at the same time diff --git a/src/index.ts b/src/index.ts index 13444b2..d860f2e 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,4 +1,4 @@ export { default } from "./bandwidthRtc"; export { BandwidthRtc as BandwidthRtcV1 } from "./v1/bandwidthRtc"; export * from "./types"; -export type { CallStatsSnapshot } from "./v1/types"; +export type { CallStatsSnapshot, PeerCallStats } from "./v1/types"; diff --git a/src/v1/bandwidthRtc.test.ts b/src/v1/bandwidthRtc.test.ts index 49a893a..12c641f 100644 --- a/src/v1/bandwidthRtc.test.ts +++ b/src/v1/bandwidthRtc.test.ts @@ -1267,6 +1267,74 @@ describe("bandwidthRtcV1 getCallStats", () => { expect(snap.packetsReceived).toBe(0); expect(snap.bytesSent).toBe(4000); }); + + describe("per-peer counters", () => { + const zeros = { packetsSent: 0, bytesSent: 0, packetsReceived: 0, bytesReceived: 0 }; + + test("each peer takes its counters only from its own report", async () => { + const sub = report([ + { id: "in", type: "inbound-rtp", kind: "audio", packetsReceived: 10, bytesReceived: 2000 }, + { id: "out", type: "outbound-rtp", kind: "audio", packetsSent: 3, bytesSent: 300 }, + ]); + const pub = report([ + { id: "out", type: "outbound-rtp", kind: "audio", packetsSent: 20, bytesSent: 4000 }, + { id: "in", type: "inbound-rtp", kind: "audio", packetsReceived: 7, bytesReceived: 700 }, + ]); + const snap = await setup(jest.fn().mockResolvedValue(sub), jest.fn().mockResolvedValue(pub)).getCallStats(); + expect(snap.publishPeer).toEqual({ packetsSent: 20, bytesSent: 4000, packetsReceived: 7, bytesReceived: 700 }); + expect(snap.subscribePeer).toEqual({ packetsSent: 3, bytesSent: 300, packetsReceived: 10, bytesReceived: 2000 }); + }); + + test("flat fields ignore the unexpected direction of each peer", async () => { + const sub = report([ + { id: "in", type: "inbound-rtp", kind: "audio", packetsReceived: 10, bytesReceived: 2000 }, + { id: "out", type: "outbound-rtp", kind: "audio", packetsSent: 3, bytesSent: 300 }, + ]); + const pub = report([ + { id: "out", type: "outbound-rtp", kind: "audio", packetsSent: 20, bytesSent: 4000 }, + { id: "in", type: "inbound-rtp", kind: "audio", packetsReceived: 7, bytesReceived: 700 }, + ]); + const snap = await setup(jest.fn().mockResolvedValue(sub), jest.fn().mockResolvedValue(pub)).getCallStats(); + expect(snap).toMatchObject({ packetsReceived: 10, bytesReceived: 2000, packetsSent: 20, bytesSent: 4000 }); + }); + + test("sums counters across audio streams of a peer", async () => { + const pub = report([ + { id: "out1", type: "outbound-rtp", kind: "audio", packetsSent: 20, bytesSent: 4000 }, + { id: "out2", type: "outbound-rtp", kind: "audio", packetsSent: 7, bytesSent: 900 }, + ]); + const sub = report([ + { id: "in1", type: "inbound-rtp", kind: "audio", packetsReceived: 10, bytesReceived: 2000 }, + { id: "in2", type: "inbound-rtp", kind: "audio", packetsReceived: 5, bytesReceived: 1000 }, + ]); + const snap = await setup(jest.fn().mockResolvedValue(sub), jest.fn().mockResolvedValue(pub)).getCallStats(); + expect(snap.publishPeer).toEqual({ ...zeros, packetsSent: 27, bytesSent: 4900 }); + expect(snap.subscribePeer).toEqual({ ...zeros, packetsReceived: 15, bytesReceived: 3000 }); + }); + + test("ignores non-audio stats", async () => { + const sub = report([ + { id: "vin", type: "inbound-rtp", kind: "video", packetsReceived: 10, bytesReceived: 2000 }, + { id: "vout", type: "outbound-rtp", kind: "video", packetsSent: 3, bytesSent: 300 }, + ]); + const pub = report([ + { id: "vout", type: "outbound-rtp", kind: "video", packetsSent: 20, bytesSent: 4000 }, + { id: "vin", type: "inbound-rtp", kind: "video", packetsReceived: 7, bytesReceived: 700 }, + ]); + const snap = await setup(jest.fn().mockResolvedValue(sub), jest.fn().mockResolvedValue(pub)).getCallStats(); + expect(snap.publishPeer).toEqual(zeros); + expect(snap.subscribePeer).toEqual(zeros); + }); + + test("a missing peer connection gives zeros for that peer", async () => { + const snap = await setup(jest.fn().mockResolvedValue(subReport), undefined).getCallStats(); + expect(snap.publishPeer).toEqual(zeros); + expect(snap.subscribePeer).toEqual({ ...zeros, packetsReceived: 10, bytesReceived: 2000 }); + const none = await setup(undefined, undefined).getCallStats(); + expect(none.publishPeer).toEqual(zeros); + expect(none.subscribePeer).toEqual(zeros); + }); + }); }); describe("bandwidthRtcV1 call stats trace", () => { diff --git a/src/v1/bandwidthRtc.ts b/src/v1/bandwidthRtc.ts index dbf4e0a..95b8323 100644 --- a/src/v1/bandwidthRtc.ts +++ b/src/v1/bandwidthRtc.ts @@ -487,6 +487,8 @@ export class BandwidthRtc { remoteFractionLost: 0, remoteJitter: 0, rtcpRoundTripTime: 0, + publishPeer: { packetsSent: 0, bytesSent: 0, packetsReceived: 0, bytesReceived: 0 }, + subscribePeer: { packetsSent: 0, bytesSent: 0, packetsReceived: 0, bytesReceived: 0 }, }; // Read the peer connections now; they are replaced on reconnect const subPc = this.subscribingPeerConnection; @@ -511,9 +513,14 @@ export class BandwidthRtc { snapshot.packetsReceived += stat.packetsReceived ?? 0; snapshot.packetsLost += stat.packetsLost ?? 0; snapshot.bytesReceived += stat.bytesReceived ?? 0; + snapshot.subscribePeer.packetsReceived += stat.packetsReceived ?? 0; + snapshot.subscribePeer.bytesReceived += stat.bytesReceived ?? 0; snapshot.jitter = Math.max(snapshot.jitter, stat.jitter ?? 0); snapshot.audioLevel = Math.max(snapshot.audioLevel, stat.audioLevel ?? 0); codecId = codecId ?? stat.codecId; + } else if (stat.type === "outbound-rtp" && stat.kind === "audio") { + snapshot.subscribePeer.packetsSent += stat.packetsSent ?? 0; + snapshot.subscribePeer.bytesSent += stat.bytesSent ?? 0; } else if (stat.type === "transport") { selectedPairId = stat.selectedCandidatePairId; } else if (stat.type === "candidate-pair" && (stat.selected || (stat.nominated && stat.state === "succeeded"))) { @@ -535,6 +542,11 @@ export class BandwidthRtc { if (stat.type === "outbound-rtp") { snapshot.packetsSent += stat.packetsSent ?? 0; snapshot.bytesSent += stat.bytesSent ?? 0; + snapshot.publishPeer.packetsSent += stat.packetsSent ?? 0; + snapshot.publishPeer.bytesSent += stat.bytesSent ?? 0; + } else if (stat.type === "inbound-rtp") { + snapshot.publishPeer.packetsReceived += stat.packetsReceived ?? 0; + snapshot.publishPeer.bytesReceived += stat.bytesReceived ?? 0; } else if (stat.type === "remote-inbound-rtp") { snapshot.remoteFractionLost = Math.max(snapshot.remoteFractionLost, stat.fractionLost ?? 0); snapshot.remoteJitter = Math.max(snapshot.remoteJitter, stat.jitter ?? 0); diff --git a/src/v1/types.ts b/src/v1/types.ts index e965b28..f46b018 100644 --- a/src/v1/types.ts +++ b/src/v1/types.ts @@ -106,6 +106,14 @@ export interface ReadyMetadata { region: string; // TODO enum } +/** Audio RTP counters of one peer connection, summed over its audio streams */ +export interface PeerCallStats { + packetsSent: number; + bytesSent: number; + packetsReceived: number; + bytesReceived: number; +} + /** * Call quality snapshot parsed from native WebRTC stats. Times are in seconds, bitrates in bits/s. * A field the browser has not reported yet is 0, so 0 does not always mean a perfect value. @@ -130,4 +138,8 @@ export interface CallStatsSnapshot { remoteJitter: number; /** RTCP remote-inbound-rtp roundTripTime */ rtcpRoundTripTime: number; + /** Counters of the peer connection that sends local media. Normally only the sent fields are non-zero. */ + publishPeer: PeerCallStats; + /** Counters of the peer connection that receives remote media. Normally only the received fields are non-zero. */ + subscribePeer: PeerCallStats; }