diff --git a/packages/sdk/src/realtime/media-channel.ts b/packages/sdk/src/realtime/media-channel.ts index 725943d..8823d42 100644 --- a/packages/sdk/src/realtime/media-channel.ts +++ b/packages/sdk/src/realtime/media-channel.ts @@ -12,6 +12,7 @@ import mitt, { type Emitter } from "mitt"; import { createConsoleLogger, type Logger } from "../utils/logger"; import { REALTIME_CONFIG } from "./config-realtime"; +import { attachRoomInstrumentation } from "./observability/network-instrumentation"; import type { RealtimeObservability } from "./observability/realtime-observability"; export type VideoCodec = "h264" | "vp8" | "vp9" | "av1"; @@ -57,6 +58,7 @@ export class MediaChannel { private remoteStream: MediaStream | null = null; private events: Emitter = mitt(); private readonly logger: Logger; + private detachInstrumentation: (() => void) | null = null; constructor(private readonly config: MediaChannelConfig) { this.logger = config.logger ?? createConsoleLogger("warn"); @@ -105,6 +107,9 @@ export class MediaChannel { await room.connect(opts.url, opts.token); this.config.observability?.endPhase("webrtc-handshake", { success: true }); this.config.observability?.setLiveKitRoom(room); + if (this.config.observability) { + this.detachInstrumentation = attachRoomInstrumentation(room, this.config.observability); + } } async publishLocalTracks(): Promise { @@ -118,6 +123,10 @@ export class MediaChannel { const room = this.room; this.room = null; this.remoteStream = null; + if (this.detachInstrumentation) { + this.detachInstrumentation(); + this.detachInstrumentation = null; + } this.config.observability?.setLiveKitRoom(null); if (room) { room.disconnect().catch(() => {}); diff --git a/packages/sdk/src/realtime/observability/network-instrumentation.ts b/packages/sdk/src/realtime/observability/network-instrumentation.ts new file mode 100644 index 0000000..7ec90a1 --- /dev/null +++ b/packages/sdk/src/realtime/observability/network-instrumentation.ts @@ -0,0 +1,435 @@ +/** + * WebRTC / LiveKit / browser-network instrumentation that emits raw debug + * data over the SDK observability sink (which the realtime WS forwards to + * bouncer, where Datadog ingests it under the session's log context). + * + * Captures the kind of data that's actually useful when diagnosing an ICE + * failure — every candidate gathered (host/srflx/relay/prflx, address, + * port, priority, foundation), every state transition on both transports + * (publisher/subscriber: ice/peer/gathering/signaling), candidate errors, + * the selected pair on success, signaling traffic in/out, and the + * browser's view of its own network state. + * + * The browser-side `Room` doesn't publicly expose the underlying + * `RTCPeerConnection` objects, so we reach through `room.engine.pcManager` + * via a typed-cast access path. `addEventListener` is used everywhere so + * we never displace LiveKit's own handlers. + */ + +import { DisconnectReason, Room, RoomEvent, Track } from "livekit-client"; +import type { RealtimeObservability } from "./realtime-observability"; + +const DISCONNECT_REASON_NAMES: Record = { + [DisconnectReason.UNKNOWN_REASON]: "unknown", + [DisconnectReason.CLIENT_INITIATED]: "client_initiated", + [DisconnectReason.DUPLICATE_IDENTITY]: "duplicate_identity", + [DisconnectReason.SERVER_SHUTDOWN]: "server_shutdown", + [DisconnectReason.PARTICIPANT_REMOVED]: "participant_removed", + [DisconnectReason.ROOM_DELETED]: "room_deleted", + [DisconnectReason.STATE_MISMATCH]: "state_mismatch", + [DisconnectReason.JOIN_FAILURE]: "join_failure", + [DisconnectReason.MIGRATION]: "migration", + [DisconnectReason.SIGNAL_CLOSE]: "signal_close", + [DisconnectReason.ROOM_CLOSED]: "room_closed", + [DisconnectReason.USER_UNAVAILABLE]: "user_unavailable", + [DisconnectReason.USER_REJECTED]: "user_rejected", + [DisconnectReason.SIP_TRUNK_FAILURE]: "sip_trunk_failure", + [DisconnectReason.CONNECTION_TIMEOUT]: "connection_timeout", + [DisconnectReason.MEDIA_FAILURE]: "media_failure", +}; + +function disconnectReasonString(reason: number | undefined): string | undefined { + if (reason === undefined) return undefined; + return DISCONNECT_REASON_NAMES[reason] ?? `unknown(${reason})`; +} + +type Side = "publisher" | "subscriber"; + +// Loose typings for the parts of LiveKit's engine we touch. Everything is +// optional / `unknown` because these are private APIs that have moved +// across LiveKit versions; we degrade gracefully if a field is missing. +type EngineLike = { + pcManager?: PcManagerLike; +}; +type PcManagerLike = { + publisher?: PcTransportLike; + subscriber?: PcTransportLike; + getConnectedAddress?: (target?: unknown) => Promise; +}; +type PcTransportLike = { + pc?: RTCPeerConnection; + // some livekit builds expose it as `_pc` + _pc?: RTCPeerConnection; +}; +type RoomWithEngine = Room & { engine?: EngineLike }; + +type ConnectionInfo = { + effectiveType?: string; + downlinkMbps?: number; + rttMs?: number; + saveData?: boolean; + type?: string; + online?: boolean; +}; + +// Structural shape of an RTCIceCandidateStats entry. The DOM type isn't +// in every TS lib build, so we redeclare what we read. +type IceCandidateStat = { + id: string; + type: "local-candidate" | "remote-candidate"; + candidateType?: string; + protocol?: string; + port?: number; + priority?: number; + address?: string; + networkType?: string; + relayProtocol?: string; + url?: string; +}; + +function summarizeCandidate(candidate: RTCIceCandidate | null): Record { + if (!candidate) return { eof: true }; + // Keep only the fields useful for ICE debugging. Dropped vs. raw RTCIceCandidate: + // - candidate (raw SDP string — redundant with type/address/port/protocol) + // - sdpMid / sdpMLineIndex (mux indexing, not network-debug) + // - usernameFragment (ICE ufrag — auth, not network-debug) + const c = candidate as RTCIceCandidate & { + address?: string; + relatedAddress?: string; + relatedPort?: number; + tcpType?: string; + networkType?: string; + url?: string; + }; + return { + type: c.type, + protocol: c.protocol, + address: c.address ?? null, + port: c.port, + priority: c.priority, + foundation: c.foundation, + component: c.component, + tcpType: c.tcpType ?? null, + relatedAddress: c.relatedAddress ?? null, + relatedPort: c.relatedPort ?? null, + networkType: c.networkType ?? null, + url: c.url ?? null, + }; +} + +function summarizeCandidateError(ev: RTCPeerConnectionIceErrorEvent): Record { + return { + address: ev.address ?? null, + port: ev.port ?? null, + url: ev.url ?? null, + errorCode: ev.errorCode, + errorText: ev.errorText, + hostCandidate: (ev as unknown as { hostCandidate?: string }).hostCandidate ?? null, + }; +} + +function snapshotConnection(): ConnectionInfo { + const info: ConnectionInfo = {}; + if (typeof navigator !== "undefined") { + info.online = navigator.onLine; + const conn = (navigator as Navigator & { connection?: Record }).connection; + if (conn) { + info.effectiveType = conn.effectiveType as string | undefined; + info.downlinkMbps = conn.downlink as number | undefined; + info.rttMs = conn.rtt as number | undefined; + info.saveData = conn.saveData as boolean | undefined; + info.type = conn.type as string | undefined; + } + } + return info; +} + +// Structural shape of an RTCIceCandidatePairStats entry — same reason +// as IceCandidateStat above (some TS lib builds don't expose this). +type IceCandidatePairStat = { + type: "candidate-pair"; + state: string; + nominated?: boolean; + localCandidateId?: string; + remoteCandidateId?: string; + currentRoundTripTime?: number; + availableOutgoingBitrate?: number; +}; + +async function snapshotSelectedPair(pc: RTCPeerConnection): Promise | null> { + try { + const report = await pc.getStats(); + let pair: IceCandidatePairStat | null = null; + const candidates = new Map(); + report.forEach((stat) => { + const s = stat as unknown as { type: string; state?: string; nominated?: boolean; id: string }; + if (s.type === "candidate-pair" && s.state === "succeeded" && s.nominated) { + pair = stat as unknown as IceCandidatePairStat; + } + if (s.type === "local-candidate" || s.type === "remote-candidate") { + candidates.set(s.id, stat as unknown as IceCandidateStat); + } + }); + if (!pair) return null; + const localId = (pair as IceCandidatePairStat).localCandidateId; + const remoteId = (pair as IceCandidatePairStat).remoteCandidateId; + const local = localId ? candidates.get(localId) : undefined; + const remote = remoteId ? candidates.get(remoteId) : undefined; + const rtt = (pair as IceCandidatePairStat).currentRoundTripTime; + const aob = (pair as IceCandidatePairStat).availableOutgoingBitrate; + return { + currentRoundTripTimeMs: rtt != null ? rtt * 1000 : null, + availableOutgoingBitrate: aob ?? null, + local: local + ? { + type: local.candidateType, + protocol: local.protocol, + address: local.address, + port: local.port, + networkType: local.networkType, + } + : null, + remote: remote + ? { + type: remote.candidateType, + protocol: remote.protocol, + address: remote.address, + port: remote.port, + } + : null, + }; + } catch { + return null; + } +} + +function getPc(transport: PcTransportLike | undefined): RTCPeerConnection | undefined { + if (!transport) return undefined; + return transport.pc ?? transport._pc; +} + +/** + * Walk `pc.getStats()` once and emit one synthetic `ice-candidate-past` + * event per local-candidate (and `remote-candidate-past` per remote one) + * already known to the PC. Covers the very common case where ICE + * gathering finished before our `icecandidate` listener was attached. + */ +async function snapshotPastCandidates( + pc: RTCPeerConnection, + side: Side, + emit: (name: string, data: Record) => void, +): Promise { + try { + const report = await pc.getStats(); + report.forEach((stat) => { + const s = stat as unknown as IceCandidateStat; + if (s.type === "local-candidate" || s.type === "remote-candidate") { + const c = s; + emit("ice-candidate-past", { + side, + source: c.type, // local-candidate | remote-candidate + candidateType: c.candidateType, + protocol: c.protocol, + address: c.address ?? null, + port: c.port, + priority: c.priority, + networkType: c.networkType ?? null, + relayProtocol: c.relayProtocol ?? null, + url: c.url ?? null, + }); + } + }); + } catch { + // ignore + } +} + +/** + * Attach low-level instrumentation to a connected LiveKit `Room`. Safe to + * call once per room. Returns a cleanup function that detaches all listeners. + */ +export function attachRoomInstrumentation(room: Room, observability: RealtimeObservability): () => void { + const emit = (name: string, data: Record = {}): void => { + observability.emitInstrumentationEvent(name, data); + }; + + // Initial browser network snapshot — gives a baseline to compare against. + emit("network-state", snapshotConnection()); + + // Browser network events relevant to ICE failure debugging. Page + // visibility transitions are intentionally not forwarded — they're + // noise for connection diagnostics. + const onOnline = () => emit("browser-online", { ...snapshotConnection() }); + const onOffline = () => emit("browser-offline", { ...snapshotConnection() }); + const conn = + typeof navigator !== "undefined" ? (navigator as Navigator & { connection?: EventTarget }).connection : undefined; + const onConnChange = () => emit("network-change", snapshotConnection()); + if (typeof window !== "undefined") { + window.addEventListener("online", onOnline); + window.addEventListener("offline", onOffline); + } + conn?.addEventListener?.("change", onConnChange); + + // LiveKit Room lifecycle events that matter for connection debugging. + // Steady-state events (connection-quality, track-subscribed/muted/unmuted, + // local-track-published, participant-connected, page-visibility) are + // intentionally not forwarded — they're noise once a session is up and + // running, and the goal of this stream is to debug WHY connections fail + // or take too long, not narrate a healthy session. + const onConnected = () => emit("room-connected", { name: room.name, sid: room.localParticipant?.sid }); + const onDisconnected = (reason?: DisconnectReason) => + emit("room-disconnected", { reason, reasonName: disconnectReasonString(reason) }); + const onReconnecting = () => emit("room-reconnecting"); + const onSignalReconnecting = () => emit("room-signal-reconnecting"); + const onReconnected = () => emit("room-reconnected"); + const onMediaDevicesError = (e: Error) => emit("media-devices-error", { name: e.name, message: e.message }); + + room.on(RoomEvent.Connected, onConnected); + room.on(RoomEvent.Disconnected, onDisconnected); + room.on(RoomEvent.Reconnecting, onReconnecting); + room.on(RoomEvent.SignalReconnecting, onSignalReconnecting); + room.on(RoomEvent.Reconnected, onReconnected); + room.on(RoomEvent.MediaDevicesError, onMediaDevicesError); + // RoomEvent.ConnectionStateChanged is intentionally not hooked — its + // states duplicate room-connected / -reconnecting / -disconnected. + + // Attach to the underlying RTCPeerConnections for ICE-level visibility. + // Done lazily on next tick — LiveKit creates the PC transports during + // `room.connect()`, and the engine may not be wired up yet at the + // moment we register Room events. Polling for a short window covers + // both early and late attach scenarios. + const pcCleanups: Array<() => void> = []; + const attachedSides = new Set(); + const attachPcEventsIfReady = (): boolean => { + const engine = (room as RoomWithEngine).engine; + const mgr = engine?.pcManager; + if (!mgr) return false; + for (const side of ["publisher", "subscriber"] as Side[]) { + if (attachedSides.has(side)) continue; + const pc = getPc(side === "publisher" ? mgr.publisher : mgr.subscriber); + if (!pc) continue; + attachedSides.add(side); + pcCleanups.push(attachPeerConnectionInstrumentation(pc, side, emit, mgr)); + } + return attachedSides.size === 2; + }; + // Try immediately, then poll briefly for the late case. + if (!attachPcEventsIfReady()) { + let attempts = 0; + const poll = setInterval(() => { + attempts++; + if (attachPcEventsIfReady() || attempts > 50) { + clearInterval(poll); + } + }, 100); + pcCleanups.push(() => clearInterval(poll)); + } + + return () => { + try { + room.off(RoomEvent.Connected, onConnected); + room.off(RoomEvent.Disconnected, onDisconnected); + room.off(RoomEvent.Reconnecting, onReconnecting); + room.off(RoomEvent.SignalReconnecting, onSignalReconnecting); + room.off(RoomEvent.Reconnected, onReconnected); + room.off(RoomEvent.MediaDevicesError, onMediaDevicesError); + } catch { + // ignore detach errors during teardown + } + if (typeof window !== "undefined") { + window.removeEventListener("online", onOnline); + window.removeEventListener("offline", onOffline); + } + conn?.removeEventListener?.("change", onConnChange); + for (const fn of pcCleanups) { + try { + fn(); + } catch { + // ignore + } + } + }; +} + +function summarizeIceServers(pc: RTCPeerConnection): Array> { + try { + const cfg = pc.getConfiguration(); + return (cfg.iceServers ?? []).map((s) => ({ + urls: Array.isArray(s.urls) ? s.urls : [s.urls], + hasUsername: !!s.username, + hasCredential: !!s.credential, + })); + } catch { + return []; + } +} + +function attachPeerConnectionInstrumentation( + pc: RTCPeerConnection, + side: Side, + emit: (name: string, data: Record) => void, + _pcManager?: PcManagerLike, +): () => void { + // pc-attached carries the PC's iceServers config — directly answers + // "did the SDK get STUN/TURN URLs from the JoinResponse?". Credentials + // are redacted (we only log whether they were present). + emit("pc-attached", { + side, + iceConnectionState: pc.iceConnectionState, + connectionState: pc.connectionState, + iceGatheringState: pc.iceGatheringState, + signalingState: pc.signalingState, + iceServers: summarizeIceServers(pc), + iceTransportPolicy: pc.getConfiguration().iceTransportPolicy ?? null, + }); + + // The PC may already have gathered all its candidates by the time we + // attach. addEventListener('icecandidate', ...) only catches FUTURE + // events, so we walk getStats() once to surface what was already + // produced. This is the data we'd care about most for an ICE-failure + // post-mortem (srflx address, candidate types, the winning pair). + void snapshotPastCandidates(pc, side, emit); + if (pc.iceConnectionState === "connected" || pc.iceConnectionState === "completed") { + void (async () => { + const pair = await snapshotSelectedPair(pc); + if (pair) emit("selected-candidate-pair", { side, ...pair, snapshot: true }); + })(); + } + + const onIceCandidate = (ev: RTCPeerConnectionIceEvent) => { + emit("ice-candidate", { side, ...summarizeCandidate(ev.candidate) }); + }; + const onIceCandidateError = (ev: Event) => { + emit("ice-candidate-error", { side, ...summarizeCandidateError(ev as RTCPeerConnectionIceErrorEvent) }); + }; + const onIceConnectionStateChange = async () => { + emit("ice-connection-state", { side, state: pc.iceConnectionState }); + // Snapshot the winning candidate pair when ICE settles. + if (pc.iceConnectionState === "connected" || pc.iceConnectionState === "completed") { + const pair = await snapshotSelectedPair(pc); + if (pair) emit("selected-candidate-pair", { side, ...pair }); + } + }; + const onConnectionStateChange = () => emit("pc-connection-state", { side, state: pc.connectionState }); + const onIceGatheringStateChange = () => emit("ice-gathering-state", { side, state: pc.iceGatheringState }); + // signalingstatechange + negotiationneeded + track + datachannel are + // intentionally not hooked — they fire on every SDP renegotiation cycle + // (each prompt / set_image triggers one) and bury the ICE-level signal. + + pc.addEventListener("icecandidate", onIceCandidate); + pc.addEventListener("icecandidateerror", onIceCandidateError); + pc.addEventListener("iceconnectionstatechange", onIceConnectionStateChange); + pc.addEventListener("connectionstatechange", onConnectionStateChange); + pc.addEventListener("icegatheringstatechange", onIceGatheringStateChange); + + return () => { + pc.removeEventListener("icecandidate", onIceCandidate); + pc.removeEventListener("icecandidateerror", onIceCandidateError); + pc.removeEventListener("iceconnectionstatechange", onIceConnectionStateChange); + pc.removeEventListener("connectionstatechange", onConnectionStateChange); + pc.removeEventListener("icegatheringstatechange", onIceGatheringStateChange); + }; +} + +// Re-export for convenience so consumers know what's available. +export { Track }; diff --git a/packages/sdk/src/realtime/observability/realtime-observability.ts b/packages/sdk/src/realtime/observability/realtime-observability.ts index 7eefe60..2d10d2c 100644 --- a/packages/sdk/src/realtime/observability/realtime-observability.ts +++ b/packages/sdk/src/realtime/observability/realtime-observability.ts @@ -51,13 +51,41 @@ export class RealtimeObservability { private videoStalled = false; private stallStartMs = 0; private connectionBreakdown: ConnectionBreakdownBuffer | null = null; + /** + * Sink for forwarding diagnostics and stats over the existing realtime + * WebSocket (bouncer logs them to Datadog under the session's context). + * Set by `StreamSession` after the signaling channel is created; + * cleared on tearDown. + */ + private observabilityForwarder: ((payload: unknown) => void) | null = null; constructor(private readonly options: RealtimeObservabilityOptions) {} + /** Wire/unwire the WebSocket-side observability sink. Idempotent. */ + setObservabilityForwarder(fn: ((payload: unknown) => void) | null): void { + this.observabilityForwarder = fn; + } + + /** + * Emit a raw instrumentation event over the observability sink. Used by + * `network-instrumentation.ts` to forward per-ICE-candidate / signaling / + * peer-connection-state events that don't fit the strict `DiagnosticEvents` + * type union. Best-effort; silently dropped if the sink isn't attached. + */ + emitInstrumentationEvent(name: string, data: unknown): void { + this.observabilityForwarder?.({ + kind: "instrumentation", + name, + data, + timestamp: Date.now(), + }); + } + diagnostic(name: K, data: DiagnosticEvents[K], timestamp: number = Date.now()): void { this.options.logger.debug(name, data as Record); this.options.onDiagnostic?.({ name, data } as DiagnosticEvent); this.addTelemetryDiagnostic(name, data, timestamp); + this.observabilityForwarder?.({ kind: "diagnostic", name, data, timestamp }); } beginConnectionBreakdown(attempt: number, initialImageSizeKb: number | null): void { @@ -204,6 +232,11 @@ export class RealtimeObservability { this.options.onStats?.(stats); this.telemetryReporter.addStats(stats); this.detectVideoStall(stats); + // Stats intentionally not forwarded over the WS. They fire every 1s + // and would dominate Datadog's signal-to-noise ratio while not + // helping diagnose connection-establishment failures (which is what + // the WS-forwarded observability stream is for). They still flow to + // the local `onStats` callback and the platform telemetry POST. } private detectVideoStall(stats: WebRTCStats): void { diff --git a/packages/sdk/src/realtime/signaling-channel.ts b/packages/sdk/src/realtime/signaling-channel.ts index 6b873e4..cffcc05 100644 --- a/packages/sdk/src/realtime/signaling-channel.ts +++ b/packages/sdk/src/realtime/signaling-channel.ts @@ -165,6 +165,21 @@ export class SignalingChannel { if (!ack.success) throw new Error(ack.error ?? "Failed to send prompt"); } + /** + * Fire-and-forget client-side observability event. Goes out over the + * existing realtime WebSocket as `{type: "observability", data}` and is + * logged by bouncer under the current session's log context. Never + * throws; quietly drops if the socket isn't open. + */ + sendObservability(data: unknown): void { + if (this.ws?.readyState !== WebSocket.OPEN) return; + try { + this.writeMessage({ type: "observability", data }); + } catch { + // Best-effort; never disrupt the session for a telemetry hiccup. + } + } + async setImage(payload: SetImagePayload, opts: ImageSetOptions = {}): Promise { const message: OutgoingRealtimeMessage = payload.kind === "ref" @@ -344,6 +359,24 @@ export class SignalingChannel { } private handleMessage(msg: IncomingRealtimeMessage): void { + // Only trace connection-establishment-relevant signaling messages. + // Routine generation_tick / prompt_ack / set_image_ack chatter is + // suppressed to keep Datadog's signal-to-noise ratio usable. + if (msg.type === "livekit_room_info") { + // Capture which LiveKit SFU node the bouncer steered this session + // to — useful for "which node was bad?" debugging. Token is not + // logged (auth material). + this.config.observability?.emitInstrumentationEvent("signaling-received", { + type: msg.type, + livekitUrl: msg.livekit_url, + roomName: msg.room_name, + sessionId: msg.session_id, + }); + } else if ((msg as { type?: string }).type === "session_id") { + // `session_id` isn't in the typed IncomingRealtimeMessage union but is + // sent by the bouncer; trace it for the connection timeline. + this.config.observability?.emitInstrumentationEvent("signaling-received", { type: "session_id" }); + } for (const ack of [...this.pendingAcks]) { if (ack.matches(msg)) { ack.onMatch(msg); diff --git a/packages/sdk/src/realtime/stream-session.ts b/packages/sdk/src/realtime/stream-session.ts index 014baee..82b43c0 100644 --- a/packages/sdk/src/realtime/stream-session.ts +++ b/packages/sdk/src/realtime/stream-session.ts @@ -318,11 +318,18 @@ export class StreamSession { logger: this.logger, videoCodec: this.config.videoCodec, }); + // Forward client-side diagnostics + WebRTC stats back over the + // realtime WS so bouncer can log them to Datadog under the + // session's existing log context. + this.config.observability?.setObservabilityForwarder((payload) => { + this.signaling.sendObservability(payload); + }); this.wireSignalingEvents(); this.wireMediaEvents(); } private tearDown(): void { + this.config.observability?.setObservabilityForwarder(null); this.signaling.close(); this.media.disconnect(); this.initialStateGate.reset(); diff --git a/packages/sdk/src/realtime/types.ts b/packages/sdk/src/realtime/types.ts index 961800a..2e329d0 100644 --- a/packages/sdk/src/realtime/types.ts +++ b/packages/sdk/src/realtime/types.ts @@ -129,7 +129,15 @@ export type IncomingRealtimeMessage = | LiveKitRoomInfoMessage | QueuePositionMessage; +// Client-side WebRTC / ICE / networking observability events. Free-form +// payload; logged by bouncer under the session's existing log context and +// not forwarded upstream. +export type ObservabilityMessage = { + type: "observability"; + data: unknown; +}; + // Outgoing message types (to server) -export type OutgoingRealtimeMessage = LiveKitJoinMessage | PromptMessage | SetImageMessage; +export type OutgoingRealtimeMessage = LiveKitJoinMessage | PromptMessage | SetImageMessage | ObservabilityMessage; export type OutgoingMessage = PromptMessage | SetImageMessage; diff --git a/packages/sdk/tests/realtime.unit.test.ts b/packages/sdk/tests/realtime.unit.test.ts index 270d1f4..b951fb8 100644 --- a/packages/sdk/tests/realtime.unit.test.ts +++ b/packages/sdk/tests/realtime.unit.test.ts @@ -23,6 +23,27 @@ const liveKitMock = vi.hoisted(() => { SignalReconnecting: "signalReconnecting", Disconnected: "disconnected", } as const; + // Mirrors livekit-client's DisconnectReason enum well enough for our + // network-instrumentation lookup table. Real numeric values from the + // protocol; safe to extend without breaking tests. + const DisconnectReason = { + UNKNOWN_REASON: 0, + CLIENT_INITIATED: 1, + DUPLICATE_IDENTITY: 2, + SERVER_SHUTDOWN: 3, + PARTICIPANT_REMOVED: 4, + ROOM_DELETED: 5, + STATE_MISMATCH: 6, + JOIN_FAILURE: 7, + MIGRATION: 8, + SIGNAL_CLOSE: 9, + ROOM_CLOSED: 10, + USER_UNAVAILABLE: 11, + USER_REJECTED: 12, + SIP_TRUNK_FAILURE: 13, + CONNECTION_TIMEOUT: 14, + MEDIA_FAILURE: 15, + } as const; class MockRoom { handlers = new Map void>>(); @@ -49,7 +70,7 @@ const liveKitMock = vi.hoisted(() => { } } - return { roomInstances, RoomEvent, Track, TrackEvent, ConnectionState, MockRoom }; + return { roomInstances, RoomEvent, Track, TrackEvent, ConnectionState, DisconnectReason, MockRoom }; }); vi.mock("livekit-client", () => ({ @@ -58,6 +79,7 @@ vi.mock("livekit-client", () => ({ Track: liveKitMock.Track, TrackEvent: liveKitMock.TrackEvent, ConnectionState: liveKitMock.ConnectionState, + DisconnectReason: liveKitMock.DisconnectReason, })); class FakeMediaStream {