Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -103,6 +103,7 @@ bandwidthRtc.sendDtmf("5", undefined, 200, 100); // 200ms tone, 100ms gap
- 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
- Diagnostics logs: buffered logs are sent to the gateway every 5 minutes, and also when the page is hidden or unloaded (best effort, since the browser may close the connection first)

## Event Listeners

Expand Down
11 changes: 11 additions & 0 deletions src/v1/diagnostics.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -76,6 +76,17 @@ describe("DiagnosticsBatcher", () => {
expect(result).toEqual({ logs: [] });
});

test("should report whether logs are buffered", () => {
const diagnostics = new DiagnosticsBatcher();
expect(diagnostics.hasLogs()).toBe(false);

(diagnostics as any).handleLogEvent("info", Date.now(), "test");
expect(diagnostics.hasLogs()).toBe(true);

diagnostics.flushDiagnostics();
expect(diagnostics.hasLogs()).toBe(false);
});

test("should flush diagnostics and clear interval on shutdown", () => {
const clearIntervalSpy = jest.spyOn(global, "clearInterval");
const diagnostics = new DiagnosticsBatcher();
Expand Down
4 changes: 4 additions & 0 deletions src/v1/diagnostics.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,10 @@ export class DiagnosticsBatcher extends EventEmitter {
return { logs: this.logEvents };
}

hasLogs(): boolean {
return this.logEvents.length > 0;
}

flushDiagnostics() {
this.emit("diagnostics", this.getDiagnostics());
this.logEvents = new Array();
Expand Down
118 changes: 118 additions & 0 deletions src/v1/signaling.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -397,6 +397,124 @@ describe("Signaling disconnect", () => {
});
});

describe("Signaling page-exit diagnostics flush", () => {
let batcher: DiagnosticsBatcher;
let signaling: Signaling;
let ws: any;
let visibility: string;

function setVisibility(state: string) {
visibility = state;
document.dispatchEvent(new Event("visibilitychange"));
}

// Pushes a log line into the batcher so a flush has something to send.
function bufferLog(message: string) {
(batcher as any).handleLogEvent("info", Date.now(), message);
}

function deviceDiagnosticsCalls() {
return ws.notify.mock.calls.filter((call: any) => call[0] === "deviceDiagnostics");
}

beforeEach(async () => {
visibility = "visible";
Object.defineProperty(document, "visibilityState", { configurable: true, get: () => visibility });
batcher = new DiagnosticsBatcher();
signaling = new Signaling(batcher);
await signaling.connect({ endpointToken: "test-token" });
ws = (signaling as any).ws;
await ws.on.mock.calls.find((call: any) => call[0] === "open")[1]();
});

afterEach(() => {
signaling.disconnect();
batcher.shutdown();
delete (document as any).visibilityState;
});

test("should flush buffered logs on pagehide", () => {
bufferLog("before-pagehide");

window.dispatchEvent(new Event("pagehide"));

const calls = deviceDiagnosticsCalls();
expect(calls).toHaveLength(1);
expect(calls[0][1].logs.map((log: any) => log.message)).toContainEqual(["before-pagehide"]);
});

test("should flush buffered logs when the page becomes hidden", () => {
bufferLog("before-hidden");

setVisibility("hidden");

const calls = deviceDiagnosticsCalls();
expect(calls).toHaveLength(1);
expect(calls[0][1].logs.map((log: any) => log.message)).toContainEqual(["before-hidden"]);
});

test("should not flush when the page becomes visible", () => {
bufferLog("before-visible");

setVisibility("visible");

expect(deviceDiagnosticsCalls()).toHaveLength(0);
});

test("should not register duplicate listeners on repeated opens", async () => {
const openCallback = ws.on.mock.calls.find((call: any) => call[0] === "open")[1];
await openCallback();
await openCallback();

window.dispatchEvent(new Event("pagehide"));

expect(deviceDiagnosticsCalls()).toHaveLength(1);
});

test("should stop flushing after disconnect", () => {
signaling.disconnect();
// The handler checks hasLogs() first, so a spy on it proves the listener is gone
const hasLogsSpy = jest.spyOn(batcher, "hasLogs");
const flushSpy = jest.spyOn(batcher, "flushDiagnostics");
bufferLog("after-disconnect");
ws.notify.mockClear();

window.dispatchEvent(new Event("pagehide"));
setVisibility("hidden");

expect(hasLogsSpy).not.toHaveBeenCalled();
expect(flushSpy).not.toHaveBeenCalled();
expect(ws.notify).not.toHaveBeenCalled();
});

test("should not send diagnostics when the buffer is empty", () => {
// The batcher captures every SDK log line, so drain it first
batcher.flushDiagnostics();
ws.notify.mockClear();

window.dispatchEvent(new Event("pagehide"));
setVisibility("hidden");

expect(deviceDiagnosticsCalls()).toHaveLength(0);
});

test("should keep buffered logs when the socket is not open", () => {
bufferLog("while-closed");
ws.ready = false;

window.dispatchEvent(new Event("pagehide"));

expect(deviceDiagnosticsCalls()).toHaveLength(0);

ws.ready = true;
window.dispatchEvent(new Event("pagehide"));

const calls = deviceDiagnosticsCalls();
expect(calls).toHaveLength(1);
expect(calls[0][1].logs.map((log: any) => log.message)).toContainEqual(["while-closed"]);
});
});

describe("Signaling test all the smaller functions", () => {
let signaling: Signaling;
beforeEach(async () => {
Expand Down
42 changes: 42 additions & 0 deletions src/v1/signaling.ts
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,7 @@ class Signaling extends EventEmitter {
private readyMetadata: ReadyMetadata | null = null;
private diagnosticsBatcher?: DiagnosticsBatcher;
private rtcOptions?: RtcOptions;
private pageExitFlush?: (event: Event) => void;

constructor(diagnosticsBatcher?: DiagnosticsBatcher) {
super();
Expand Down Expand Up @@ -118,6 +119,7 @@ class Signaling extends EventEmitter {
logger.debug("Websocket open");
const isReconnect = hasConnectedOnce;
hasConnectedOnce = true;
this.addPageExitFlush();
if (!isReconnect && globalThis.addEventListener) {
globalThis.addEventListener("beforeunload", (event) => {
this.disconnect();
Expand Down Expand Up @@ -208,8 +210,48 @@ class Signaling extends EventEmitter {
}) as Promise<SetMediaPreferencesWebRtcResponse>;
}

// Best effort: the browser may close the socket before the bytes leave the page.
private addPageExitFlush() {
if (this.pageExitFlush || !this.diagnosticsBatcher) {
return;
}
this.pageExitFlush = (event: Event) => {
if (event.type === "visibilitychange" && document.visibilityState !== "hidden") {
return;
}
if (!this.diagnosticsBatcher?.hasLogs()) {
return;
}
// A flush clears the buffer, so keep the logs for a later flush while the socket is down
if (!this.socketOpen) {
return;
}
this.diagnosticsBatcher?.flushDiagnostics();
};
if (typeof window !== "undefined" && typeof window.addEventListener === "function") {
window.addEventListener("pagehide", this.pageExitFlush);
}
if (typeof document !== "undefined" && typeof document.addEventListener === "function") {
document.addEventListener("visibilitychange", this.pageExitFlush);
}
}

private removePageExitFlush() {
if (!this.pageExitFlush) {
return;
}
if (typeof window !== "undefined" && typeof window.removeEventListener === "function") {
window.removeEventListener("pagehide", this.pageExitFlush);
}
if (typeof document !== "undefined" && typeof document.removeEventListener === "function") {
document.removeEventListener("visibilitychange", this.pageExitFlush);
}
this.pageExitFlush = undefined;
}

private _disconnect(notifyLeave: boolean) {
logger.debug("Disconnecting websocket");
this.removePageExitFlush();
const hadSocket = this.ws !== null;
if (this.ws) {
if (notifyLeave && this.socketOpen) {
Expand Down
Loading