Skip to content
Draft
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
7 changes: 7 additions & 0 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,13 @@ export * from './connectionHelper/ConnectionCheck';
export * from './connectionHelper/checks/Checker';
export * from './e2ee';
export type { BaseE2EEManager } from './e2ee/E2eeManager';
export type {
BaseDiagnosticEntry,
DiagnosticEntry,
DiagnosticsBufferOptions,
LogDiagnosticEntry,
} from './room/DiagnosticsBuffer';
export { DEFAULT_DIAGNOSTICS_BUFFER_SIZE } from './room/DiagnosticsBuffer';
export * from './options';
export * from './room/errors';
export * from './room/events';
Expand Down
33 changes: 33 additions & 0 deletions src/logger.ts
Original file line number Diff line number Diff line change
Expand Up @@ -141,6 +141,39 @@ export function setLogLevel(level: LogLevel | LogLevelString, loggerName?: Logge

export type LogExtension = (level: LogLevel, msg: string, context?: object) => void;

const diagnosticSinks = new Set<LogExtension>();
let diagnosticSinkDispatcherInstalled = false;

function ensureDiagnosticSinkDispatcher() {
if (diagnosticSinkDispatcherInstalled) return;
diagnosticSinkDispatcherInstalled = true;
setLogExtension((level, msg, context) => {
for (const sink of diagnosticSinks) {
try {
sink(level, msg, context);
} catch {
// swallow sink errors so a misbehaving consumer can't break logging
}
}
});
}

/**
* Register a callback that receives every internal log entry across all
* livekit loggers. Returns an unsubscribe function. Unlike
* {@link setLogExtension}, sinks can be added and removed without
* permanently layering additional methodFactory wrappers on each call.
*
* @internal
*/
export function addDiagnosticSink(sink: LogExtension): () => void {
diagnosticSinks.add(sink);
ensureDiagnosticSinkDispatcher();
return () => {
diagnosticSinks.delete(sink);
};
}

/**
* use this to hook into the logging function to allow sending internal livekit logs to third party services
* if set, the browser logs will lose their stacktrace information (see https://github.com/pimterry/loglevel#writing-plugins)
Expand Down
11 changes: 11 additions & 0 deletions src/options.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import type { E2EEOptions } from './e2ee/types';
import type { DiagnosticsBufferOptions } from './room/DiagnosticsBuffer';
import type { ReconnectPolicy } from './room/ReconnectPolicy';
import type {
AudioCaptureOptions,
Expand Down Expand Up @@ -100,6 +101,16 @@ export interface InternalRoomOptions {

loggerName?: string;

/**
* Retain a bounded ring buffer of recent internal events (logs today,
* additional signals like WebRTC stats in the future) so consumers can
* snapshot them for bug reports via `room.getRecentDiagnostics()`.
*
* Pass `false` to disable, or an options object to customise. Defaults
* to an enabled buffer at the default capacity.
*/
diagnostics?: DiagnosticsBufferOptions | false;

/**
* will attempt to connect via single peer connection mode.
* falls back to dual peer connection mode if not available.
Expand Down
88 changes: 88 additions & 0 deletions src/room/DiagnosticsBuffer.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,88 @@
import { describe, expect, it } from 'vitest';
import { LogLevel } from '../logger';
import {
DEFAULT_DIAGNOSTICS_BUFFER_SIZE,
DiagnosticsBuffer,
type LogDiagnosticEntry,
} from './DiagnosticsBuffer';

const logEntry = (message: string): LogDiagnosticEntry => ({
type: 'log',
timestamp: 0,
level: LogLevel.info,
message,
});

describe('DiagnosticsBuffer', () => {
it('uses the default capacity when no size is provided', () => {
const buffer = new DiagnosticsBuffer();
expect(buffer.size).toBe(DEFAULT_DIAGNOSTICS_BUFFER_SIZE);
});

it('honours a custom capacity', () => {
const buffer = new DiagnosticsBuffer({ size: 4 });
expect(buffer.size).toBe(4);
});

it('falls back to default when size is invalid', () => {
expect(new DiagnosticsBuffer({ size: 0 }).size).toBe(DEFAULT_DIAGNOSTICS_BUFFER_SIZE);
expect(new DiagnosticsBuffer({ size: -5 }).size).toBe(DEFAULT_DIAGNOSTICS_BUFFER_SIZE);
});

it('returns entries in chronological order while under capacity', () => {
const buffer = new DiagnosticsBuffer({ size: 4 });
buffer.push(logEntry('a'));
buffer.push(logEntry('b'));
buffer.push(logEntry('c'));
expect(buffer.snapshot().map((e) => (e as LogDiagnosticEntry).message)).toEqual([
'a',
'b',
'c',
]);
expect(buffer.count).toBe(3);
});

it('overwrites the oldest entry once capacity is reached', () => {
const buffer = new DiagnosticsBuffer({ size: 3 });
['a', 'b', 'c', 'd', 'e'].forEach((m) => buffer.push(logEntry(m)));
expect(buffer.snapshot().map((e) => (e as LogDiagnosticEntry).message)).toEqual([
'c',
'd',
'e',
]);
expect(buffer.count).toBe(3);
});

it('snapshot returns a detached copy', () => {
const buffer = new DiagnosticsBuffer({ size: 3 });
buffer.push(logEntry('a'));
const snap = buffer.snapshot();
buffer.push(logEntry('b'));
expect(snap.map((e) => (e as LogDiagnosticEntry).message)).toEqual(['a']);
});

it('clear empties the buffer', () => {
const buffer = new DiagnosticsBuffer({ size: 3 });
buffer.push(logEntry('a'));
buffer.push(logEntry('b'));
buffer.clear();
expect(buffer.count).toBe(0);
expect(buffer.snapshot()).toEqual([]);
// subsequent pushes work from a clean state
buffer.push(logEntry('c'));
expect(buffer.snapshot().map((e) => (e as LogDiagnosticEntry).message)).toEqual(['c']);
});

it('accepts custom entry types beyond log entries', () => {
interface StatsEntry {
type: 'stats';
timestamp: number;
bytesSent: number;
}
const buffer = new DiagnosticsBuffer({ size: 2 });
// buffer is intentionally agnostic to kind — future entry variants can be
// pushed once the DiagnosticEntry union is extended.
buffer.push({ type: 'stats', timestamp: 1, bytesSent: 42 } as unknown as LogDiagnosticEntry);
expect((buffer.snapshot()[0] as unknown as StatsEntry).bytesSent).toBe(42);
});
});
103 changes: 103 additions & 0 deletions src/room/DiagnosticsBuffer.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,103 @@
import { LogLevel } from '../logger';

/**
* Default number of entries retained by {@link DiagnosticsBuffer}.
* Sized to cover a typical connect → publish → reconnect → disconnect cycle
* at debug verbosity without holding onto an unbounded amount of memory.
*/
export const DEFAULT_DIAGNOSTICS_BUFFER_SIZE = 500;

export interface DiagnosticsBufferOptions {
/**
* Maximum number of entries retained in the ring. When full, the oldest
* entry is overwritten. Defaults to {@link DEFAULT_DIAGNOSTICS_BUFFER_SIZE}.
*/
size?: number;
}

/**
* Fields common to every entry kind retained by the diagnostics buffer.
* Additional entry kinds can extend this interface to remain compatible
* with {@link DiagnosticsBuffer.push}.
*/
export interface BaseDiagnosticEntry {
/** Discriminator identifying the entry kind. */
type: string;
/** Wall-clock time the entry was recorded, in milliseconds since epoch. */
timestamp: number;
}

export interface LogDiagnosticEntry extends BaseDiagnosticEntry {
type: 'log';
level: LogLevel;
message: string;
context?: object;
}

/**
* Union of all entry kinds retained by the diagnostics buffer. Add new
* variants here (e.g. `WebRTCStatsEntry`) as additional signals are
* captured — the buffer itself is agnostic to which variants exist.
*/
export type DiagnosticEntry = LogDiagnosticEntry;

/**
* Fixed-capacity ring buffer of recent diagnostic entries. Once capacity is
* reached, each new entry overwrites the oldest one. Intended to give SDK
* consumers a bounded, readable window of recent activity (logs today, WebRTC
* stats and other signals in the future) that can be attached to bug reports
* without having to keep console history around.
*/
export class DiagnosticsBuffer {
private readonly capacity: number;

private readonly buffer: (DiagnosticEntry | undefined)[];

private writeIndex = 0;

private length = 0;

constructor(options: DiagnosticsBufferOptions = {}) {
const requested = options.size ?? DEFAULT_DIAGNOSTICS_BUFFER_SIZE;
this.capacity = requested > 0 ? Math.floor(requested) : DEFAULT_DIAGNOSTICS_BUFFER_SIZE;
this.buffer = new Array(this.capacity);
}

/** Maximum number of entries this buffer will retain. */
get size() {
return this.capacity;
}

/** Current number of entries held (≤ {@link size}). */
get count() {
return this.length;
}

push(entry: DiagnosticEntry) {
this.buffer[this.writeIndex] = entry;
this.writeIndex = (this.writeIndex + 1) % this.capacity;
if (this.length < this.capacity) {
this.length += 1;
}
}

/**
* Returns a copy of the retained entries in chronological order (oldest
* first). The returned array is detached from the buffer — subsequent
* pushes do not mutate it.
*/
snapshot(): DiagnosticEntry[] {
const result: DiagnosticEntry[] = new Array(this.length);
const start = this.length < this.capacity ? 0 : this.writeIndex;
for (let i = 0; i < this.length; i += 1) {
result[i] = this.buffer[(start + i) % this.capacity]!;
}
return result;
}

clear() {
this.buffer.fill(undefined);
this.writeIndex = 0;
this.length = 0;
}
}
38 changes: 38 additions & 0 deletions src/room/Room.test.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,6 @@
import { describe, expect, it } from 'vitest';
import { LogLevel, LoggerNames, setLogLevel } from '../logger';
import type { LogDiagnosticEntry } from './DiagnosticsBuffer';
import Room from './Room';
import { RoomEvent } from './events';

Expand Down Expand Up @@ -28,3 +30,39 @@ describe('Active device switch', () => {
expect(kind).toBe('audioinput');
});
});

describe('Room diagnostics', () => {
it('captures internal log entries into the ring buffer', () => {
setLogLevel(LogLevel.debug, LoggerNames.Room);
const room = new Room({ diagnostics: { size: 16 } });
// trigger an internal log at a known level
(room as unknown as { log: { info: (m: string) => void } }).log.info(
'diagnostics roundtrip probe',
);
const entries = room.getRecentDiagnostics();
const probe = entries.find(
(e): e is LogDiagnosticEntry =>
e.type === 'log' && (e as LogDiagnosticEntry).message.includes('diagnostics roundtrip probe'),
);
expect(probe).toBeDefined();
expect(probe?.level).toBe(LogLevel.info);
setLogLevel(LogLevel.info);
});

it('returns an empty array when diagnostics are disabled', () => {
const room = new Room({ diagnostics: false });
expect(room.getRecentDiagnostics()).toEqual([]);
});

it('accepts custom entries via recordDiagnostic', () => {
const room = new Room({ diagnostics: { size: 4 } });
room.recordDiagnostic({
type: 'log',
timestamp: 1234,
level: LogLevel.warn,
message: 'manual entry',
});
const entries = room.getRecentDiagnostics();
expect(entries.some((e) => (e as LogDiagnosticEntry).message === 'manual entry')).toBe(true);
});
});
40 changes: 39 additions & 1 deletion src/room/Room.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,7 @@ import type TypedEmitter from 'typed-emitter';
import { ensureTrailingSlash } from '../api/utils';
import { EncryptionEvent } from '../e2ee';
import { type BaseE2EEManager, E2EEManager } from '../e2ee/E2eeManager';
import log, { LoggerNames, getLogger } from '../logger';
import log, { LoggerNames, addDiagnosticSink, getLogger } from '../logger';
import type {
InternalRoomConnectOptions,
InternalRoomOptions,
Expand All @@ -47,6 +47,7 @@ import TypedPromise from '../utils/TypedPromise';
import { getBrowser } from '../utils/browserParser';
import { BackOffStrategy } from './BackOffStrategy';
import DeviceManager from './DeviceManager';
import { type DiagnosticEntry, DiagnosticsBuffer } from './DiagnosticsBuffer';
import RTCEngine, { DataChannelKind, type RegionStrategy } from './RTCEngine';
import { DEFAULT_MAX_AGE_MS, RegionUrlProvider } from './RegionUrlProvider';
import IncomingDataStreamManager from './data-stream/incoming/IncomingDataStreamManager';
Expand Down Expand Up @@ -199,6 +200,10 @@ class Room extends (EventEmitter as new () => TypedEmitter<RoomEventCallbacks>)

private log = log;

private diagnosticsBuffer?: DiagnosticsBuffer;

private diagnosticSinkDispose?: () => void;

private bufferedEvents: Array<any> = [];

private isResuming: boolean = false;
Expand Down Expand Up @@ -234,6 +239,15 @@ class Room extends (EventEmitter as new () => TypedEmitter<RoomEventCallbacks>)
this.options = { ...roomOptionDefaults, ...options };

this.log = getLogger(this.options.loggerName ?? LoggerNames.Room, () => this.logContext);
if (this.options.diagnostics !== false) {
const bufferOptions =
typeof this.options.diagnostics === 'object' ? this.options.diagnostics : undefined;
this.diagnosticsBuffer = new DiagnosticsBuffer(bufferOptions);
const buffer = this.diagnosticsBuffer;
this.diagnosticSinkDispose = addDiagnosticSink((level, message, context) => {
buffer.push({ type: 'log', timestamp: Date.now(), level, message, context });
});
}
this.transcriptionReceivedTimes = new Map();

this.options.audioCaptureDefaults = {
Expand Down Expand Up @@ -472,6 +486,25 @@ class Room extends (EventEmitter as new () => TypedEmitter<RoomEventCallbacks>)
};
}

/**
* Returns a chronological snapshot of recent internal events retained by
* the diagnostics ring buffer. Currently contains log entries; additional
* kinds (e.g. WebRTC stats) may be added over time. Returns an empty
* array when diagnostics have been disabled via `options.diagnostics: false`.
*/
getRecentDiagnostics(): DiagnosticEntry[] {
return this.diagnosticsBuffer?.snapshot() ?? [];
}

/**
* Append a custom entry to the diagnostics ring buffer. No-op when
* diagnostics are disabled. Useful for surfacing integration-specific
* state alongside SDK-internal events in the same bug-report snapshot.
*/
recordDiagnostic(entry: DiagnosticEntry) {
this.diagnosticsBuffer?.push(entry);
}

/**
* if the current room has a participant with `recorder: true` in its JWT grant
**/
Expand Down Expand Up @@ -1797,6 +1830,11 @@ class Room extends (EventEmitter as new () => TypedEmitter<RoomEventCallbacks>)
} finally {
this.setAndEmitConnectionState(ConnectionState.Disconnected);
this.emit(RoomEvent.Disconnected, reason);
// drop the log→buffer hook so repeated Room constructions don't leak
// sinks; the buffer itself stays on `this` so getRecentDiagnostics()
// still works for post-mortem inspection.
this.diagnosticSinkDispose?.();
this.diagnosticSinkDispose = undefined;
}
}

Expand Down
Loading