diff --git a/src/index.ts b/src/index.ts index 1dda5a86de..6b946ba40b 100644 --- a/src/index.ts +++ b/src/index.ts @@ -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'; diff --git a/src/logger.ts b/src/logger.ts index 241010e9d2..ba17f62ccf 100644 --- a/src/logger.ts +++ b/src/logger.ts @@ -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(); +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) diff --git a/src/options.ts b/src/options.ts index f562672220..c9ca539d1c 100644 --- a/src/options.ts +++ b/src/options.ts @@ -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, @@ -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. diff --git a/src/room/DiagnosticsBuffer.test.ts b/src/room/DiagnosticsBuffer.test.ts new file mode 100644 index 0000000000..79808b2c42 --- /dev/null +++ b/src/room/DiagnosticsBuffer.test.ts @@ -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); + }); +}); diff --git a/src/room/DiagnosticsBuffer.ts b/src/room/DiagnosticsBuffer.ts new file mode 100644 index 0000000000..3ba0e0119c --- /dev/null +++ b/src/room/DiagnosticsBuffer.ts @@ -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; + } +} diff --git a/src/room/Room.test.ts b/src/room/Room.test.ts index ba7e391b6f..ea4ade5524 100644 --- a/src/room/Room.test.ts +++ b/src/room/Room.test.ts @@ -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'; @@ -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); + }); +}); diff --git a/src/room/Room.ts b/src/room/Room.ts index af4e27f0d5..102145789e 100644 --- a/src/room/Room.ts +++ b/src/room/Room.ts @@ -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, @@ -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'; @@ -199,6 +200,10 @@ class Room extends (EventEmitter as new () => TypedEmitter) private log = log; + private diagnosticsBuffer?: DiagnosticsBuffer; + + private diagnosticSinkDispose?: () => void; + private bufferedEvents: Array = []; private isResuming: boolean = false; @@ -234,6 +239,15 @@ class Room extends (EventEmitter as new () => TypedEmitter) 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 = { @@ -472,6 +486,25 @@ class Room extends (EventEmitter as new () => TypedEmitter) }; } + /** + * 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 **/ @@ -1797,6 +1830,11 @@ class Room extends (EventEmitter as new () => TypedEmitter) } 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; } } diff --git a/src/room/defaults.ts b/src/room/defaults.ts index 68e95ffb42..239368236d 100644 --- a/src/room/defaults.ts +++ b/src/room/defaults.ts @@ -43,6 +43,7 @@ export const roomOptionDefaults: InternalRoomOptions = { disconnectOnPageLeave: true, webAudioMix: false, singlePeerConnection: true, + diagnostics: {}, } as const; export const roomConnectOptionDefaults: InternalRoomConnectOptions = {