From ce3f36202bcb68fd82f959745f97e5866d47cdd9 Mon Sep 17 00:00:00 2001 From: jonadimovska Date: Tue, 26 May 2026 00:32:39 +0200 Subject: [PATCH 01/65] feat(client): client call event reporting --- packages/client/src/Call.ts | 182 ++++-- packages/client/src/rtc/BasePeerConnection.ts | 19 + packages/client/src/rtc/types.ts | 18 + .../client/src/stats/ClientEventReporter.ts | 588 ++++++++++++++++++ packages/client/src/stats/index.ts | 1 + 5 files changed, 752 insertions(+), 56 deletions(-) create mode 100644 packages/client/src/stats/ClientEventReporter.ts diff --git a/packages/client/src/Call.ts b/packages/client/src/Call.ts index 78bcc6d577..236bd80dce 100644 --- a/packages/client/src/Call.ts +++ b/packages/client/src/Call.ts @@ -130,6 +130,7 @@ import { ClientCapability, ClientDetails, Codec, + ErrorCode, ParticipantSource, PeerType, PublishOption, @@ -139,6 +140,7 @@ import { WebsocketReconnectStrategy, } from './gen/video/sfu/models/models'; import { + ClientEventReporter, createStatsReporter, getSdkSignature, SfuStatsReporter, @@ -287,6 +289,7 @@ export class Call { private statsReportingIntervalInMs: number = 2000; private statsReporter?: StatsReporter; private sfuStatsReporter?: SfuStatsReporter; + private clientEventReporter?: ClientEventReporter; private lastStatsOptions?: StatsOptions; private dropTimeout: ReturnType | undefined; @@ -452,6 +455,18 @@ export class Call { this.state.setCallingState(CallingState.IDLE); } + const clientDetails = await getClientDetails(); + const { sdkVersion } = getSdkSignature(clientDetails); + this.clientEventReporter = new ClientEventReporter({ + streamClient: this.streamClient, + callType: this.type, + callId: this.id, + getUserId: () => this.streamClient.user?.id ?? '', + getCallSessionId: () => this.state.session?.id ?? '', + sdkVersion, + userAgent: this.streamClient.getUserAgent(), + }); + this.initialized = true; }); }; @@ -739,12 +754,20 @@ export class Call { this.sfuStatsReporter = undefined; this.lastStatsOptions = undefined; + this.clientEventReporter?.abort({ + callSessionId: this.state.session?.id ?? '', + sfuId: this.credentials?.server.edge_name ?? '', + }); + this.subscriber?.dispose(); this.subscriber = undefined; this.publisher?.dispose(); this.publisher = undefined; + this.clientEventReporter?.dispose(); + this.clientEventReporter = undefined; + await this.sfuClient?.leaveAndClose(leaveReason); this.sfuClient = undefined; this.trackSubscriptionManager.setSfuClient(undefined); @@ -1061,50 +1084,71 @@ export class Call { const joinData: JoinCallData = data; maxJoinRetries = Math.max(maxJoinRetries, 1); try { - for (let attempt = 0; attempt < maxJoinRetries; attempt++) { - try { - this.logger.trace(`Joining call (${attempt})`, this.cid); - await this.doJoin(data); - delete joinData.migrating_from; - delete joinData.migrating_from_list; - break; - } catch (err) { - this.logger.warn(`Failed to join call (${attempt})`, this.cid); - if ( - (err instanceof ErrorFromResponse && err.unrecoverable) || - (err instanceof SfuJoinError && err.unrecoverable) - ) { - // if the error is unrecoverable, we should not retry as that signals - // that connectivity is good, but the coordinator doesn't allow the user - // to join the call due to some reason (e.g., ended call, expired token...) - throw err; - } + await this.withJoinLifecycle(async () => { + for (let attempt = 0; attempt < maxJoinRetries; attempt++) { + try { + this.logger.trace(`Joining call (${attempt})`, this.cid); + await this.doJoin(data); + delete joinData.migrating_from; + delete joinData.migrating_from_list; + return; + } catch (err) { + this.logger.warn(`Failed to join call (${attempt})`, this.cid); + if ( + (err instanceof ErrorFromResponse && err.unrecoverable) || + (err instanceof SfuJoinError && err.unrecoverable) + ) { + throw err; + } - // immediately switch to a different SFU in case of recoverable join error - const switchSfu = - err instanceof SfuJoinError && - SfuJoinError.isJoinErrorCode(err.errorEvent); - - const sfuId = this.credentials?.server.edge_name || ''; - const failures = (sfuJoinFailures.get(sfuId) || 0) + 1; - sfuJoinFailures.set(sfuId, failures); - if (switchSfu || failures >= 2) { - joinData.migrating_from = sfuId; - joinData.migrating_from_list = Array.from(sfuJoinFailures.keys()); - } + const switchSfu = + err instanceof SfuJoinError && + SfuJoinError.isJoinErrorCode(err.errorEvent); + + const sfuId = this.credentials?.server.edge_name || ''; + const failures = (sfuJoinFailures.get(sfuId) || 0) + 1; + sfuJoinFailures.set(sfuId, failures); + if (switchSfu || failures >= 2) { + joinData.migrating_from = sfuId; + joinData.migrating_from_list = Array.from(sfuJoinFailures.keys()); + this.clientEventReporter?.migrate({ + callSessionId: this.state.session?.id ?? '', + sfuId, + error: err, + }); + } - if (attempt === maxJoinRetries - 1) { - throw err; + if (attempt === maxJoinRetries - 1) { + throw err; + } } + await sleep(retryInterval(attempt)); } - await sleep(retryInterval(attempt)); - } + }); } catch (error) { callingX?.endCall(this, 'error'); throw error; } }; + private withJoinLifecycle = (op: () => Promise): Promise => { + const reporter = this.clientEventReporter; + return reporter ? reporter.withJoinLifecycle(op) : op(); + }; + + private trackCoordinatorJoin = (op: () => Promise): Promise => { + const reporter = this.clientEventReporter; + return reporter ? reporter.track('CoordinatorJoin', op) : op(); + }; + + private trackWsJoin = (op: () => Promise): Promise => { + if (this.reconnectStrategy === WebsocketReconnectStrategy.FAST) { + return op(); + } + const reporter = this.clientEventReporter; + return reporter ? reporter.track('WSJoin', op) : op(); + }; + /** * Will make a single attempt to watch for call related WebSocket events * and initiate a call session with the server. @@ -1202,20 +1246,24 @@ export class Call { ? this.getPreferredSubscribeOptions() : []; + const unifiedSessionId = this.unifiedSessionId; + const capabilities = Array.from(this.clientCapabilities); try { const { callState, fastReconnectDeadlineSeconds, publishOptions } = - await sfuClient.join({ - unifiedSessionId: this.unifiedSessionId, - subscriberSdp, - publisherSdp, - clientDetails, - fastReconnect: performingFastReconnect, - reconnectDetails, - preferredPublishOptions, - preferredSubscribeOptions, - capabilities: Array.from(this.clientCapabilities), - source: ParticipantSource.WEBRTC_UNSPECIFIED, - }); + await this.trackWsJoin(() => + sfuClient.join({ + unifiedSessionId, + subscriberSdp, + publisherSdp, + clientDetails, + fastReconnect: performingFastReconnect, + reconnectDetails, + preferredPublishOptions, + preferredSubscribeOptions, + capabilities, + source: ParticipantSource.WEBRTC_UNSPECIFIED, + }), + ); this.currentPublishOptions = publishOptions; this.fastReconnectDeadlineSeconds = fastReconnectDeadlineSeconds; @@ -1482,6 +1530,9 @@ export class Call { // "ICE never connected" failure budget can be cleared. this.iceFailuresWithoutConnect = 0; }, + onPeerConnectionStateChange: (event) => { + this.clientEventReporter?.onPeerConnectionStateChange(event); + }, }; this.subscriber = new Subscriber(basePeerConnectionOptions); @@ -1536,10 +1587,12 @@ export class Call { doJoinRequest = async (data?: JoinCallData): Promise => { const location = await this.streamClient.getLocationHint(); const request: JoinCallRequest = { ...data, location }; - const joinResponse = await this.streamClient.post< - JoinCallResponse, - JoinCallRequest - >(`${this.streamClientBasePath}/join`, request); + const joinResponse = await this.trackCoordinatorJoin(() => + this.streamClient.post( + `${this.streamClientBasePath}/join`, + request, + ), + ); this.state.updateFromCallResponse(joinResponse.call); this.state.setMembers(joinResponse.members); this.state.setOwnCapabilities(joinResponse.own_capabilities); @@ -1834,7 +1887,7 @@ export class Call { const reconnectStartTime = Date.now(); this.reconnectStrategy = WebsocketReconnectStrategy.REJOIN; this.state.setCallingState(CallingState.RECONNECTING); - await this.doJoin(this.joinCallData); + await this.withJoinLifecycle(() => this.doJoin(this.joinCallData)); await this.restorePublishedTracks(); this.restoreSubscribedTracks(); this.sfuStatsReporter?.sendReconnectionTime( @@ -1866,11 +1919,13 @@ export class Call { try { const currentSfu = currentSfuClient.edgeName; - await this.doJoin({ - ...this.joinCallData, - migrating_from: currentSfu, - migrating_from_list: [currentSfu], - }); + await this.withJoinLifecycle(() => + this.doJoin({ + ...this.joinCallData, + migrating_from: currentSfu, + migrating_from_list: [currentSfu], + }), + ); } finally { // cleanup the migration_from field after the migration is complete or failed // as we don't want to keep dirty data in the join call data @@ -1912,6 +1967,10 @@ export class Call { private registerReconnectHandlers = () => { // handles the legacy "goAway" event const unregisterGoAway = this.on('goAway', () => { + this.clientEventReporter?.markWSAttemptFailedExternal({ + code: 'REQUEST_TIMEOUT', + reason: 'SFU goAway', + }); this.reconnect( WebsocketReconnectStrategy.MIGRATE, ReconnectReason.GO_AWAY, @@ -1921,6 +1980,13 @@ export class Call { // handles the "error" event, through which the SFU can request a reconnect const unregisterOnError = this.on('error', (e) => { const { reconnectStrategy: strategy, error } = e; + if (!SfuJoinError.isJoinErrorCode(e)) { + const code = error?.code ? ErrorCode[error.code] : 'REQUEST_TIMEOUT'; + this.clientEventReporter?.markWSAttemptFailedExternal({ + code: code ?? 'REQUEST_TIMEOUT', + reason: error?.message || 'SFU error during WS join', + }); + } // SFU_FULL is a join error, and when emitted, although it specifies a // `migrate` strategy, we should actually perform a REJOIN to a new SFU. // This is now handled separately in the `call.join()` method. @@ -1947,6 +2013,10 @@ export class Call { this.tracer.trace('network.changed', e); if (!e.online) { this.logger.debug('[Reconnect] Going offline'); + this.clientEventReporter?.markWSAttemptFailedExternal({ + code: 'NETWORK_OFFLINE', + reason: 'Device offline', + }); if (!this.hasJoinedOnce) return; this.lastOfflineTimestamp = Date.now(); // create a new task that would resolve when the network is available diff --git a/packages/client/src/rtc/BasePeerConnection.ts b/packages/client/src/rtc/BasePeerConnection.ts index 9f45f3fd4f..8729a99a89 100644 --- a/packages/client/src/rtc/BasePeerConnection.ts +++ b/packages/client/src/rtc/BasePeerConnection.ts @@ -16,6 +16,7 @@ import { StatsTracer, Tracer, traceRTCPeerConnection } from '../stats'; import { BasePeerConnectionOpts, OnIceConnected, + OnPeerConnectionStateChange, OnReconnectionNeeded, ReconnectReason, } from './types'; @@ -37,6 +38,7 @@ export abstract class BasePeerConnection { private onReconnectionNeeded?: OnReconnectionNeeded; private onIceConnected?: OnIceConnected; + private onPeerConnectionStateChange?: OnPeerConnectionStateChange; private readonly iceRestartDelay: number; private iceHasEverConnected = false; private iceRestartTimeout?: NodeJS.Timeout; @@ -65,6 +67,7 @@ export abstract class BasePeerConnection { dispatcher, onReconnectionNeeded, onIceConnected, + onPeerConnectionStateChange, tag, enableTracing, clientPublishOptions, @@ -80,6 +83,7 @@ export abstract class BasePeerConnection { this.tag = tag; this.onReconnectionNeeded = onReconnectionNeeded; this.onIceConnected = onIceConnected; + this.onPeerConnectionStateChange = onPeerConnectionStateChange; this.logger = videoLoggerSystem.getLogger( peerType === PeerType.SUBSCRIBER ? 'Subscriber' : 'Publisher', { tags: [tag] }, @@ -122,6 +126,7 @@ export abstract class BasePeerConnection { this.preConnectStuckTimeout = undefined; this.onReconnectionNeeded = undefined; this.onIceConnected = undefined; + this.onPeerConnectionStateChange = undefined; this.isDisposed = true; this.detachEventHandlers(); this.pc.close(); @@ -313,6 +318,9 @@ export abstract class BasePeerConnection { private onConnectionStateChange = async () => { const state = this.pc.connectionState; this.logger.debug(`Connection state changed`, state); + if (state === 'failed') { + this.fireOnPeerConnectionStateChange(); + } if (this.tracer && (state === 'connected' || state === 'failed')) { try { const stats = await this.stats.get(); @@ -341,9 +349,20 @@ export abstract class BasePeerConnection { private onIceConnectionStateChange = () => { const state = this.pc.iceConnectionState; this.logger.debug(`ICE connection state changed`, state); + this.fireOnPeerConnectionStateChange(); this.handleConnectionStateUpdate(state); }; + private fireOnPeerConnectionStateChange = () => { + this.onPeerConnectionStateChange?.({ + peerType: this.peerType, + iceConnectionState: this.pc.iceConnectionState, + peerConnectionState: this.pc.connectionState, + sfuId: this.sfuClient.edgeName, + userSessionId: this.sfuClient.sessionId, + }); + }; + private handleConnectionStateUpdate = ( state: RTCIceConnectionState | RTCPeerConnectionState, ) => { diff --git a/packages/client/src/rtc/types.ts b/packages/client/src/rtc/types.ts index 5d1921e85a..bb4f3f707e 100644 --- a/packages/client/src/rtc/types.ts +++ b/packages/client/src/rtc/types.ts @@ -52,6 +52,23 @@ export type OnReconnectionNeeded = ( */ export type OnIceConnected = (peerType: PeerType) => void; +/** + * Snapshot of the peer connection's ICE and DTLS state surfaced to telemetry + * consumers (e.g. `ClientEventReporter`). Fired on every transition of + * either `iceConnectionState` or `peerConnectionState`. + */ +export type PeerConnectionStateChangeEvent = { + peerType: PeerType; + iceConnectionState: RTCIceConnectionState; + peerConnectionState: RTCPeerConnectionState; + sfuId: string; + userSessionId: string; +}; + +export type OnPeerConnectionStateChange = ( + event: PeerConnectionStateChangeEvent, +) => void; + export type BasePeerConnectionOpts = { sfuClient: StreamSfuClient; state: CallState; @@ -59,6 +76,7 @@ export type BasePeerConnectionOpts = { dispatcher: Dispatcher; onReconnectionNeeded?: OnReconnectionNeeded; onIceConnected?: OnIceConnected; + onPeerConnectionStateChange?: OnPeerConnectionStateChange; tag: string; enableTracing: boolean; iceRestartDelay?: number; diff --git a/packages/client/src/stats/ClientEventReporter.ts b/packages/client/src/stats/ClientEventReporter.ts new file mode 100644 index 0000000000..daee31fa82 --- /dev/null +++ b/packages/client/src/stats/ClientEventReporter.ts @@ -0,0 +1,588 @@ +import { ErrorCode, PeerType } from '../gen/video/sfu/models/models'; +import type { StreamClient } from '../coordinator/connection/client'; +import { + generateUUIDv4, + retryInterval, + sleep, +} from '../coordinator/connection/utils'; +import { SfuJoinError } from '../errors'; +import { videoLoggerSystem } from '../logger'; +import type { PeerConnectionStateChangeEvent } from '../rtc'; + +export type ClientEventPeerConnection = 'publish' | 'subscribe'; + +export type ClientEventStage = + | 'CoordinatorJoin' + | 'WSJoin' + | 'PeerConnectionConnect'; + +export type ClientEventStandardCode = + | 'CLIENT_ABORTED' + | 'BACKEND_LEAVE' + | 'REQUEST_TIMEOUT' + | 'NETWORK_OFFLINE' + | 'ICE_GATHERING_FAILED' + | 'ICE_CONNECTIVITY_FAILED' + | 'DTLS_CONNECTIVITY_FAILED'; + +export type ClientEventReporterOptions = { + streamClient: StreamClient; + callType: string; + callId: string; + getUserId: () => string; + getCallSessionId: () => string; + sdkVersion: string; + userAgent: string; +}; + +const SEVERITY = { + CLIENT: 1, + TRANSPORT: 2, + SERVER: 3, +} as const; + +type StageError = { + reason: string; + code: string; + severity: number; +}; + +type StagePairState = { + sid: string; + attempts: number; + startedAt: number; + joinSuccessIdSnapshot: string; + lastError?: StageError; +}; + +type PeerConnectionContext = { + sfuId: string; + userSessionId: string; + wasPreviouslyConnected: boolean; +}; + +/** + * Reports client-side join-lifecycle telemetry to + * `POST /api/v2/video/call_client_event`. + * + * Three stages are tracked: `CoordinatorJoin` (HTTP `/join`), `WSJoin` + * (SFU WebSocket open + join RPC), and `PeerConnectionConnect` (one per + * publish/subscribe peer connection). Every stage attempt produces a pair of + * events — an `initiated` event when the attempt begins, and a `completed` + * event when it resolves — sharing one `event_session_id`. A shared + * `join_success_id` correlates all pairs from one logical join lifecycle. + * + * `CoordinatorJoin` + `WSJoin` use the fold model: one pair is held open + * across the `Call.join` retry loop. Internal retries within that lifecycle + * increment `retry_count_attempt`; only the final outcome emits the + * `completed` event. `PeerConnectionConnect` does not fold: every ICE + * connect attempt (initial, restart, post-drop reconnect) produces its own + * pair with a fresh `event_session_id`. `was_previously_connected` + * distinguishes fresh connects from reconnects. + * + * All transports run detached from the caller's promise chain — reporting + * never blocks or fails the join. Internal POST retries follow the SDK's + * `retryInterval(attempt)` backoff (up to 5 attempts). Validation failures + * (HTTP 4xx) are not retried. Events are not persisted across page reloads; + * the backend treats absent completions as failures after a 60-second + * grace window. + */ +export class ClientEventReporter { + private readonly logger = videoLoggerSystem.getLogger('ClientEventReporter'); + private readonly streamClient: StreamClient; + private readonly callType: string; + private readonly callId: string; + private readonly getUserId: () => string; + private readonly getCallSessionId: () => string; + private readonly sdkVersion: string; + private readonly userAgent: string; + private disposed = false; + + private joinSuccessId?: string; + private coordinatorPair?: StagePairState; + private wsPair?: StagePairState; + private peerConnectionPairs: Partial< + Record + > = {}; + private peerConnectionContexts: Partial< + Record + > = {}; + private pcEverConnected: Record = { + publish: false, + subscribe: false, + }; + + constructor(options: ClientEventReporterOptions) { + this.streamClient = options.streamClient; + this.callType = options.callType; + this.callId = options.callId; + this.getUserId = options.getUserId; + this.getCallSessionId = options.getCallSessionId; + this.sdkVersion = options.sdkVersion; + this.userAgent = options.userAgent; + } + + startCorrelation = () => { + this.joinSuccessId = generateUUIDv4(); + this.coordinatorPair = undefined; + this.wsPair = undefined; + }; + + withJoinLifecycle = async (op: () => Promise): Promise => { + this.startCorrelation(); + try { + return await op(); + } catch (err) { + this.close({ + callSessionId: this.getCallSessionId(), + sfuId: '', + error: err, + }); + throw err; + } + }; + + track = async ( + stage: 'CoordinatorJoin' | 'WSJoin', + op: () => Promise, + ): Promise => { + this.beginAttempt(stage); + try { + const result = await op(); + this.succeedAttempt(stage); + return result; + } catch (err) { + this.applyStageError(stage, err); + throw err; + } + }; + + markWSAttemptFailedExternal = (opts: { code: string; reason: string }) => { + if (!this.wsPair) return; + + applyError(this.wsPair, { + reason: opts.reason, + code: opts.code, + severity: SEVERITY.SERVER, + }); + }; + + migrate = (opts: { + callSessionId: string; + sfuId: string; + error: unknown; + }) => { + if (this.wsPair) { + applyError(this.wsPair, mapWsJoinError(opts.error)); + this.failWs({ callSessionId: opts.callSessionId, sfuId: opts.sfuId }); + } + this.joinSuccessId = generateUUIDv4(); + }; + + close = (opts: { callSessionId: string; sfuId: string; error?: unknown }) => { + if (opts.error !== undefined) { + if (this.coordinatorPair) { + applyError(this.coordinatorPair, mapHttpError(opts.error)); + } + if (this.wsPair) { + applyError(this.wsPair, mapWsJoinError(opts.error)); + } + } + if (this.coordinatorPair) { + this.failCoordinator({ callSessionId: opts.callSessionId }); + } + if (this.wsPair) { + this.failWs({ callSessionId: opts.callSessionId, sfuId: opts.sfuId }); + } + }; + + abort = (opts: { + callSessionId: string; + sfuId: string; + code?: ClientEventStandardCode; + reason?: string; + }) => { + const code: ClientEventStandardCode = opts.code ?? 'CLIENT_ABORTED'; + const reason = + opts.reason ?? + (code === 'BACKEND_LEAVE' + ? 'Aborted: backend ended call during connect' + : 'Aborted: user left during retry'); + const stageError: StageError = { + reason, + code, + severity: SEVERITY.CLIENT, + }; + if (this.coordinatorPair) { + applyError(this.coordinatorPair, stageError); + this.failCoordinator({ callSessionId: opts.callSessionId }); + } + if (this.wsPair) { + applyError(this.wsPair, stageError); + this.failWs({ callSessionId: opts.callSessionId, sfuId: opts.sfuId }); + } + for (const role of Object.keys( + this.peerConnectionPairs, + ) as ClientEventPeerConnection[]) { + if (!this.peerConnectionPairs[role]) continue; + this.emitPeerConnectionFailure(role, code, reason, 'NOT_CONNECTED'); + } + }; + + dispose = () => { + this.disposed = true; + }; + + onPeerConnectionStateChange = (event: PeerConnectionStateChangeEvent) => { + const role: ClientEventPeerConnection = + event.peerType === PeerType.SUBSCRIBER ? 'subscribe' : 'publish'; + + if (event.peerConnectionState === 'failed') { + this.emitPeerConnectionFailure( + role, + 'DTLS_CONNECTIVITY_FAILED', + 'DTLS connectivity checks failed', + 'CONNECTED', + ); + return; + } + + switch (event.iceConnectionState) { + case 'checking': + this.openOrSupersedePeerConnectionPair(role, { + sfuId: event.sfuId, + userSessionId: event.userSessionId, + }); + break; + case 'connected': + case 'completed': + this.emitPeerConnectionSuccess(role); + this.pcEverConnected[role] = true; + break; + case 'failed': + this.emitPeerConnectionFailure( + role, + 'ICE_CONNECTIVITY_FAILED', + 'ICE connectivity checks failed', + 'FAILED', + ); + break; + default: + break; + } + }; + + private openOrSupersedePeerConnectionPair = ( + role: ClientEventPeerConnection, + ctx: { sfuId: string; userSessionId: string }, + ) => { + if (this.peerConnectionPairs[role]) { + this.emitPeerConnectionFailure( + role, + 'ICE_CONNECTIVITY_FAILED', + 'Superseded by new ICE attempt', + 'NOT_CONNECTED', + ); + } + const pcContext: PeerConnectionContext = { + sfuId: ctx.sfuId, + userSessionId: ctx.userSessionId, + wasPreviouslyConnected: this.pcEverConnected[role], + }; + this.peerConnectionContexts[role] = pcContext; + this.peerConnectionPairs[role] = { + sid: generateUUIDv4(), + attempts: 0, + startedAt: Date.now(), + joinSuccessIdSnapshot: this.joinSuccessId ?? '', + }; + this.send({ + ...this.buildCommon( + 'PeerConnectionConnect', + this.peerConnectionPairs[role]!, + ), + peer_connection: role, + was_previously_connected: pcContext.wasPreviouslyConnected, + ...(pcContext.userSessionId && { + user_session_id: pcContext.userSessionId, + }), + event_type: 'initiated', + }); + }; + + private emitPeerConnectionSuccess = (role: ClientEventPeerConnection) => { + const pair = this.peerConnectionPairs[role]; + const pcContext = this.peerConnectionContexts[role]; + if (!pair || !pcContext) return; + this.send({ + ...this.buildCommon('PeerConnectionConnect', pair), + peer_connection: role, + was_previously_connected: pcContext.wasPreviouslyConnected, + ...(pcContext.userSessionId && { + user_session_id: pcContext.userSessionId, + }), + event_type: 'completed', + outcome: 'success', + retry_count_attempt: 0, + elapsed_time: Date.now() - pair.startedAt, + }); + delete this.peerConnectionPairs[role]; + delete this.peerConnectionContexts[role]; + }; + + private emitPeerConnectionFailure = ( + role: ClientEventPeerConnection, + code: ClientEventStandardCode, + reason: string, + iceState: 'CONNECTED' | 'FAILED' | 'NOT_CONNECTED', + ) => { + const pair = this.peerConnectionPairs[role]; + const pcContext = this.peerConnectionContexts[role]; + if (!pair || !pcContext) return; + + applyError(pair, { reason, code, severity: SEVERITY.SERVER }); + const finalReason = pair.lastError?.reason ?? reason; + const finalCode = pair.lastError?.code ?? code; + + this.send({ + ...this.buildCommon('PeerConnectionConnect', pair), + peer_connection: role, + was_previously_connected: pcContext.wasPreviouslyConnected, + ...(pcContext.userSessionId && { + user_session_id: pcContext.userSessionId, + }), + ...(pcContext.sfuId && { sfu_id: pcContext.sfuId }), + event_type: 'completed', + outcome: 'failure', + retry_count_attempt: 0, + elapsed_time: Date.now() - pair.startedAt, + ice_state: iceState, + retry_failure_reason: finalReason, + retry_failure_code: finalCode, + }); + delete this.peerConnectionPairs[role]; + delete this.peerConnectionContexts[role]; + }; + + private beginAttempt = (stage: 'CoordinatorJoin' | 'WSJoin') => { + if (stage === 'CoordinatorJoin') { + this.beginCoordinatorAttempt(); + } else { + this.beginWsAttempt(); + } + }; + + private succeedAttempt = (stage: 'CoordinatorJoin' | 'WSJoin') => { + if (stage === 'CoordinatorJoin') { + this.succeedCoordinator(); + } else { + this.succeedWs(); + } + }; + + private applyStageError = ( + stage: 'CoordinatorJoin' | 'WSJoin', + err: unknown, + ) => { + if (stage === 'CoordinatorJoin') { + applyError(this.coordinatorPair, mapHttpError(err)); + } else { + applyError(this.wsPair, mapWsJoinError(err)); + } + }; + + private beginCoordinatorAttempt = () => { + if (!this.coordinatorPair) { + this.coordinatorPair = { + sid: generateUUIDv4(), + attempts: 0, + startedAt: Date.now(), + joinSuccessIdSnapshot: this.joinSuccessId ?? '', + }; + this.send({ + ...this.buildCommon('CoordinatorJoin', this.coordinatorPair), + event_type: 'initiated', + }); + } + this.coordinatorPair.attempts++; + }; + + private succeedCoordinator = () => { + const pair = this.coordinatorPair; + if (!pair) return; + this.send({ + ...this.buildCommon('CoordinatorJoin', pair), + event_type: 'completed', + outcome: 'success', + retry_count_attempt: pair.attempts - 1, + elapsed_time: Date.now() - pair.startedAt, + }); + this.coordinatorPair = undefined; + }; + + private failCoordinator = (opts: { callSessionId?: string }) => { + const pair = this.coordinatorPair; + if (!pair || !pair.lastError) { + this.coordinatorPair = undefined; + return; + } + const { reason, code } = pair.lastError; + this.send({ + ...this.buildCommon('CoordinatorJoin', pair), + event_type: 'completed', + outcome: 'failure', + retry_count_attempt: pair.attempts - 1, + elapsed_time: Date.now() - pair.startedAt, + ...(opts.callSessionId && { call_session_id: opts.callSessionId }), + retry_failure_reason: reason, + retry_failure_code: code, + }); + this.coordinatorPair = undefined; + }; + + private beginWsAttempt = () => { + if (!this.wsPair) { + this.wsPair = { + sid: generateUUIDv4(), + attempts: 0, + startedAt: Date.now(), + joinSuccessIdSnapshot: this.joinSuccessId ?? '', + }; + this.send({ + ...this.buildCommon('WSJoin', this.wsPair), + event_type: 'initiated', + }); + } + this.wsPair.attempts++; + }; + + private succeedWs = () => { + const pair = this.wsPair; + if (!pair) return; + this.send({ + ...this.buildCommon('WSJoin', pair), + event_type: 'completed', + outcome: 'success', + retry_count_attempt: pair.attempts - 1, + elapsed_time: Date.now() - pair.startedAt, + }); + this.wsPair = undefined; + }; + + private failWs = (opts: { callSessionId: string; sfuId: string }) => { + const pair = this.wsPair; + if (!pair || !pair.lastError) { + this.wsPair = undefined; + return; + } + const { reason, code } = pair.lastError; + this.send({ + ...this.buildCommon('WSJoin', pair), + event_type: 'completed', + outcome: 'failure', + retry_count_attempt: pair.attempts - 1, + elapsed_time: Date.now() - pair.startedAt, + call_session_id: opts.callSessionId, + sfu_id: opts.sfuId, + retry_failure_reason: reason, + retry_failure_code: code, + }); + this.wsPair = undefined; + }; + + private buildCommon = ( + stage: ClientEventStage, + pair: StagePairState, + ): Record => { + const callSessionId = this.getCallSessionId(); + return { + user_id: this.getUserId(), + type: this.callType, + id: this.callId, + call_cid: `${this.callType}:${this.callId}`, + stage, + event_session_id: pair.sid, + ...(callSessionId && { call_session_id: callSessionId }), + ...(pair.joinSuccessIdSnapshot && { + join_success_id: pair.joinSuccessIdSnapshot, + }), + timestamp: new Date().toISOString(), + user_agent: this.userAgent, + sdk_version: this.sdkVersion, + }; + }; + + private send = (body: Record) => { + if (this.disposed) return; + void this.sendWithRetry(body); + }; + + private sendWithRetry = async (body: Record) => { + for (let attempt = 0; attempt < 5; attempt++) { + if (this.disposed) return; + try { + await this.streamClient.post('/call_client_event', { events: [body] }); + return; + } catch (err) { + const status = (err as { response?: { status?: number } })?.response + ?.status; + if (typeof status === 'number' && status >= 400 && status < 500) { + this.logger.debug( + `Client event rejected (${status}), not retrying`, + body.stage, + body.event_type, + ); + return; + } + if (attempt === 4) { + this.logger.debug( + 'Client event delivery failed after retries', + body.stage, + body.event_type, + err, + ); + return; + } + await sleep(retryInterval(attempt)); + } + } + }; +} + +const errorMessage = (err: unknown): string => + err instanceof Error ? err.message : String(err); + +const applyError = (pair: StagePairState | undefined, next: StageError) => { + if (!pair) return; + if (!pair.lastError || next.severity >= pair.lastError.severity) { + pair.lastError = next; + } +}; + +const mapHttpError = (err: unknown): StageError => { + const reason = errorMessage(err); + const status = (err as { response?: { status?: number } })?.response?.status; + if (typeof status === 'number' && status >= 500) { + return { reason, code: 'REQUEST_TIMEOUT', severity: SEVERITY.SERVER }; + } + if (typeof navigator !== 'undefined' && !navigator.onLine) { + return { + reason: 'Device offline', + code: 'NETWORK_OFFLINE', + severity: SEVERITY.TRANSPORT, + }; + } + return { reason, code: 'REQUEST_TIMEOUT', severity: SEVERITY.TRANSPORT }; +}; + +const mapWsJoinError = (err: unknown): StageError => { + if (err instanceof SfuJoinError) { + const sfuError = err.errorEvent.error; + return { + reason: sfuError?.message || err.message, + code: sfuError ? ErrorCode[sfuError.code] : 'SFU_ERROR', + severity: SEVERITY.SERVER, + }; + } + return mapHttpError(err); +}; diff --git a/packages/client/src/stats/index.ts b/packages/client/src/stats/index.ts index 352bd8ea26..74a2325c1c 100644 --- a/packages/client/src/stats/index.ts +++ b/packages/client/src/stats/index.ts @@ -1,4 +1,5 @@ export * from './CallStateStatsReporter'; +export * from './ClientEventReporter'; export * from './SfuStatsReporter'; export * from './types'; export * from './utils'; From fcbc3dee4ca999211699a710994e607d947b7bb1 Mon Sep 17 00:00:00 2001 From: jonadimovska Date: Tue, 26 May 2026 16:23:53 +0200 Subject: [PATCH 02/65] feat(client): client call event reporting --- packages/client/src/Call.ts | 33 ++++-- packages/client/src/events/call.ts | 2 + .../client/src/stats/ClientEventReporter.ts | 110 ++++++------------ 3 files changed, 59 insertions(+), 86 deletions(-) diff --git a/packages/client/src/Call.ts b/packages/client/src/Call.ts index 236bd80dce..b7137721c3 100644 --- a/packages/client/src/Call.ts +++ b/packages/client/src/Call.ts @@ -463,6 +463,7 @@ export class Call { callId: this.id, getUserId: () => this.streamClient.user?.id ?? '', getCallSessionId: () => this.state.session?.id ?? '', + getSfuId: () => this.credentials?.server.edge_name ?? '', sdkVersion, userAgent: this.streamClient.getUserAgent(), }); @@ -691,6 +692,18 @@ export class Call { } }; + /** + * Aborts any in-flight join-stage telemetry pair with `BACKEND_LEAVE`. + * Called from `call.ended` / SFU `callEnded` handlers before invoking + * `leave()` so the captured failure code reflects the backend origin + * rather than the default `CLIENT_ABORTED` applied in `leave()`. + * + * @internal + */ + reportBackendLeave = (reason: string) => { + this.clientEventReporter?.abort({ code: 'BACKEND_LEAVE', reason }); + }; + /** * Leave the call and stop the media streams that were published by the call. */ @@ -755,8 +768,8 @@ export class Call { this.lastStatsOptions = undefined; this.clientEventReporter?.abort({ - callSessionId: this.state.session?.id ?? '', - sfuId: this.credentials?.server.edge_name ?? '', + code: 'CLIENT_ABORTED', + reason: leaveReason, }); this.subscriber?.dispose(); @@ -1111,11 +1124,7 @@ export class Call { if (switchSfu || failures >= 2) { joinData.migrating_from = sfuId; joinData.migrating_from_list = Array.from(sfuJoinFailures.keys()); - this.clientEventReporter?.migrate({ - callSessionId: this.state.session?.id ?? '', - sfuId, - error: err, - }); + this.clientEventReporter?.startCorrelation(); } if (attempt === maxJoinRetries - 1) { @@ -1967,9 +1976,9 @@ export class Call { private registerReconnectHandlers = () => { // handles the legacy "goAway" event const unregisterGoAway = this.on('goAway', () => { - this.clientEventReporter?.markWSAttemptFailedExternal({ - code: 'REQUEST_TIMEOUT', - reason: 'SFU goAway', + this.clientEventReporter?.captureWsError({ + code: 'SFU_GO_AWAY', + reason: 'SFU goAway received during WS join', }); this.reconnect( WebsocketReconnectStrategy.MIGRATE, @@ -1982,7 +1991,7 @@ export class Call { const { reconnectStrategy: strategy, error } = e; if (!SfuJoinError.isJoinErrorCode(e)) { const code = error?.code ? ErrorCode[error.code] : 'REQUEST_TIMEOUT'; - this.clientEventReporter?.markWSAttemptFailedExternal({ + this.clientEventReporter?.captureWsError({ code: code ?? 'REQUEST_TIMEOUT', reason: error?.message || 'SFU error during WS join', }); @@ -2013,7 +2022,7 @@ export class Call { this.tracer.trace('network.changed', e); if (!e.online) { this.logger.debug('[Reconnect] Going offline'); - this.clientEventReporter?.markWSAttemptFailedExternal({ + this.clientEventReporter?.captureWsError({ code: 'NETWORK_OFFLINE', reason: 'Device offline', }); diff --git a/packages/client/src/events/call.ts b/packages/client/src/events/call.ts index 14715d6577..cd9540fe4b 100644 --- a/packages/client/src/events/call.ts +++ b/packages/client/src/events/call.ts @@ -87,6 +87,7 @@ export const watchCallEnded = (call: Call) => { callingState !== CallingState.IDLE && callingState !== CallingState.LEFT ) { + call.reportBackendLeave('call.ended event received'); call .leave({ message: 'call.ended event received', reject: false }) .catch((err) => { @@ -116,6 +117,7 @@ export const watchSfuCallEnded = (call: Call) => { call.state.setEndedAt(new Date()); const reason = CallEndedReason[e.reason]; globalThis.streamRNVideoSDK?.callingX?.endCall(call, 'remote'); + call.reportBackendLeave(`callEnded received: ${reason}`); await call.leave({ message: `callEnded received: ${reason}` }); } catch (err) { call.logger.error( diff --git a/packages/client/src/stats/ClientEventReporter.ts b/packages/client/src/stats/ClientEventReporter.ts index daee31fa82..21ef45abd2 100644 --- a/packages/client/src/stats/ClientEventReporter.ts +++ b/packages/client/src/stats/ClientEventReporter.ts @@ -31,6 +31,7 @@ export type ClientEventReporterOptions = { callId: string; getUserId: () => string; getCallSessionId: () => string; + getSfuId: () => string; sdkVersion: string; userAgent: string; }; @@ -51,7 +52,7 @@ type StagePairState = { sid: string; attempts: number; startedAt: number; - joinSuccessIdSnapshot: string; + joinSuccessIdSnapshot?: string; lastError?: StageError; }; @@ -89,11 +90,13 @@ type PeerConnectionContext = { */ export class ClientEventReporter { private readonly logger = videoLoggerSystem.getLogger('ClientEventReporter'); + private readonly streamClient: StreamClient; private readonly callType: string; private readonly callId: string; private readonly getUserId: () => string; private readonly getCallSessionId: () => string; + private readonly getSfuId: () => string; private readonly sdkVersion: string; private readonly userAgent: string; private disposed = false; @@ -118,14 +121,14 @@ export class ClientEventReporter { this.callId = options.callId; this.getUserId = options.getUserId; this.getCallSessionId = options.getCallSessionId; + this.getSfuId = options.getSfuId; this.sdkVersion = options.sdkVersion; this.userAgent = options.userAgent; } startCorrelation = () => { + this.close(); this.joinSuccessId = generateUUIDv4(); - this.coordinatorPair = undefined; - this.wsPair = undefined; }; withJoinLifecycle = async (op: () => Promise): Promise => { @@ -133,11 +136,7 @@ export class ClientEventReporter { try { return await op(); } catch (err) { - this.close({ - callSessionId: this.getCallSessionId(), - sfuId: '', - error: err, - }); + this.close(); throw err; } }; @@ -157,9 +156,8 @@ export class ClientEventReporter { } }; - markWSAttemptFailedExternal = (opts: { code: string; reason: string }) => { + captureWsError = (opts: { code: string; reason: string }) => { if (!this.wsPair) return; - applyError(this.wsPair, { reason: opts.reason, code: opts.code, @@ -167,66 +165,26 @@ export class ClientEventReporter { }); }; - migrate = (opts: { - callSessionId: string; - sfuId: string; - error: unknown; - }) => { - if (this.wsPair) { - applyError(this.wsPair, mapWsJoinError(opts.error)); - this.failWs({ callSessionId: opts.callSessionId, sfuId: opts.sfuId }); - } - this.joinSuccessId = generateUUIDv4(); - }; - - close = (opts: { callSessionId: string; sfuId: string; error?: unknown }) => { - if (opts.error !== undefined) { - if (this.coordinatorPair) { - applyError(this.coordinatorPair, mapHttpError(opts.error)); - } - if (this.wsPair) { - applyError(this.wsPair, mapWsJoinError(opts.error)); - } - } - if (this.coordinatorPair) { - this.failCoordinator({ callSessionId: opts.callSessionId }); - } - if (this.wsPair) { - this.failWs({ callSessionId: opts.callSessionId, sfuId: opts.sfuId }); - } + close = () => { + if (this.coordinatorPair) this.failCoordinator(); + if (this.wsPair) this.failWs(); }; abort = (opts: { - callSessionId: string; - sfuId: string; - code?: ClientEventStandardCode; - reason?: string; + code: 'CLIENT_ABORTED' | 'BACKEND_LEAVE'; + reason: string; }) => { - const code: ClientEventStandardCode = opts.code ?? 'CLIENT_ABORTED'; - const reason = - opts.reason ?? - (code === 'BACKEND_LEAVE' - ? 'Aborted: backend ended call during connect' - : 'Aborted: user left during retry'); - const stageError: StageError = { - reason, - code, - severity: SEVERITY.CLIENT, - }; - if (this.coordinatorPair) { - applyError(this.coordinatorPair, stageError); - this.failCoordinator({ callSessionId: opts.callSessionId }); - } - if (this.wsPair) { - applyError(this.wsPair, stageError); - this.failWs({ callSessionId: opts.callSessionId, sfuId: opts.sfuId }); - } - for (const role of Object.keys( - this.peerConnectionPairs, - ) as ClientEventPeerConnection[]) { - if (!this.peerConnectionPairs[role]) continue; - this.emitPeerConnectionFailure(role, code, reason, 'NOT_CONNECTED'); - } + const { code, reason } = opts; + const stageError: StageError = { code, reason, severity: SEVERITY.CLIENT }; + + applyError(this.coordinatorPair, stageError); + applyError(this.wsPair, stageError); + + this.failCoordinator(); + this.failWs(); + + this.emitPeerConnectionFailure('publish', code, reason, 'NOT_CONNECTED'); + this.emitPeerConnectionFailure('subscribe', code, reason, 'NOT_CONNECTED'); }; dispose = () => { @@ -294,7 +252,7 @@ export class ClientEventReporter { sid: generateUUIDv4(), attempts: 0, startedAt: Date.now(), - joinSuccessIdSnapshot: this.joinSuccessId ?? '', + joinSuccessIdSnapshot: this.joinSuccessId, }; this.send({ ...this.buildCommon( @@ -314,6 +272,7 @@ export class ClientEventReporter { const pair = this.peerConnectionPairs[role]; const pcContext = this.peerConnectionContexts[role]; if (!pair || !pcContext) return; + this.send({ ...this.buildCommon('PeerConnectionConnect', pair), peer_connection: role, @@ -397,8 +356,9 @@ export class ClientEventReporter { sid: generateUUIDv4(), attempts: 0, startedAt: Date.now(), - joinSuccessIdSnapshot: this.joinSuccessId ?? '', + joinSuccessIdSnapshot: this.joinSuccessId, }; + this.send({ ...this.buildCommon('CoordinatorJoin', this.coordinatorPair), event_type: 'initiated', @@ -420,7 +380,7 @@ export class ClientEventReporter { this.coordinatorPair = undefined; }; - private failCoordinator = (opts: { callSessionId?: string }) => { + private failCoordinator = () => { const pair = this.coordinatorPair; if (!pair || !pair.lastError) { this.coordinatorPair = undefined; @@ -433,7 +393,6 @@ export class ClientEventReporter { outcome: 'failure', retry_count_attempt: pair.attempts - 1, elapsed_time: Date.now() - pair.startedAt, - ...(opts.callSessionId && { call_session_id: opts.callSessionId }), retry_failure_reason: reason, retry_failure_code: code, }); @@ -446,7 +405,7 @@ export class ClientEventReporter { sid: generateUUIDv4(), attempts: 0, startedAt: Date.now(), - joinSuccessIdSnapshot: this.joinSuccessId ?? '', + joinSuccessIdSnapshot: this.joinSuccessId, }; this.send({ ...this.buildCommon('WSJoin', this.wsPair), @@ -469,21 +428,21 @@ export class ClientEventReporter { this.wsPair = undefined; }; - private failWs = (opts: { callSessionId: string; sfuId: string }) => { + private failWs = () => { const pair = this.wsPair; if (!pair || !pair.lastError) { this.wsPair = undefined; return; } const { reason, code } = pair.lastError; + const sfuId = this.getSfuId(); this.send({ ...this.buildCommon('WSJoin', pair), event_type: 'completed', outcome: 'failure', retry_count_attempt: pair.attempts - 1, elapsed_time: Date.now() - pair.startedAt, - call_session_id: opts.callSessionId, - sfu_id: opts.sfuId, + ...(sfuId && { sfu_id: sfuId }), retry_failure_reason: reason, retry_failure_code: code, }); @@ -554,6 +513,7 @@ const errorMessage = (err: unknown): string => const applyError = (pair: StagePairState | undefined, next: StageError) => { if (!pair) return; + if (!pair.lastError || next.severity >= pair.lastError.severity) { pair.lastError = next; } @@ -572,12 +532,14 @@ const mapHttpError = (err: unknown): StageError => { severity: SEVERITY.TRANSPORT, }; } + return { reason, code: 'REQUEST_TIMEOUT', severity: SEVERITY.TRANSPORT }; }; const mapWsJoinError = (err: unknown): StageError => { if (err instanceof SfuJoinError) { const sfuError = err.errorEvent.error; + return { reason: sfuError?.message || err.message, code: sfuError ? ErrorCode[sfuError.code] : 'SFU_ERROR', From 75329b624c23c93ac8c6ff6b2d804b76ff7c275c Mon Sep 17 00:00:00 2001 From: jonadimovska Date: Tue, 26 May 2026 16:40:57 +0200 Subject: [PATCH 03/65] feat(client): client call event reporting - coderabbit fixes --- packages/client/src/Call.ts | 26 +++++++++---------- packages/client/src/rtc/BasePeerConnection.ts | 18 ++++++++----- .../client/src/stats/ClientEventReporter.ts | 18 +++++++------ 3 files changed, 34 insertions(+), 28 deletions(-) diff --git a/packages/client/src/Call.ts b/packages/client/src/Call.ts index b7137721c3..2796af3653 100644 --- a/packages/client/src/Call.ts +++ b/packages/client/src/Call.ts @@ -426,6 +426,19 @@ export class Call { await withoutConcurrency(this.joinLeaveConcurrencyTag, async () => { if (this.initialized) return; + const clientDetails = await getClientDetails(); + const { sdkVersion } = getSdkSignature(clientDetails); + this.clientEventReporter = new ClientEventReporter({ + streamClient: this.streamClient, + callType: this.type, + callId: this.id, + getUserId: () => this.streamClient.user?.id ?? '', + getCallSessionId: () => this.state.session?.id ?? '', + getSfuId: () => this.credentials?.server.edge_name ?? '', + sdkVersion, + userAgent: this.streamClient.getUserAgent(), + }); + this.leaveCallHooks.add( this.on('all', (event) => { // update state with the latest event data @@ -455,19 +468,6 @@ export class Call { this.state.setCallingState(CallingState.IDLE); } - const clientDetails = await getClientDetails(); - const { sdkVersion } = getSdkSignature(clientDetails); - this.clientEventReporter = new ClientEventReporter({ - streamClient: this.streamClient, - callType: this.type, - callId: this.id, - getUserId: () => this.streamClient.user?.id ?? '', - getCallSessionId: () => this.state.session?.id ?? '', - getSfuId: () => this.credentials?.server.edge_name ?? '', - sdkVersion, - userAgent: this.streamClient.getUserAgent(), - }); - this.initialized = true; }); }; diff --git a/packages/client/src/rtc/BasePeerConnection.ts b/packages/client/src/rtc/BasePeerConnection.ts index 8729a99a89..0dd1b6a3a8 100644 --- a/packages/client/src/rtc/BasePeerConnection.ts +++ b/packages/client/src/rtc/BasePeerConnection.ts @@ -354,13 +354,17 @@ export abstract class BasePeerConnection { }; private fireOnPeerConnectionStateChange = () => { - this.onPeerConnectionStateChange?.({ - peerType: this.peerType, - iceConnectionState: this.pc.iceConnectionState, - peerConnectionState: this.pc.connectionState, - sfuId: this.sfuClient.edgeName, - userSessionId: this.sfuClient.sessionId, - }); + try { + this.onPeerConnectionStateChange?.({ + peerType: this.peerType, + iceConnectionState: this.pc.iceConnectionState, + peerConnectionState: this.pc.connectionState, + sfuId: this.sfuClient.edgeName, + userSessionId: this.sfuClient.sessionId, + }); + } catch (err) { + this.logger.warn('onPeerConnectionStateChange listener threw', err); + } }; private handleConnectionStateUpdate = ( diff --git a/packages/client/src/stats/ClientEventReporter.ts b/packages/client/src/stats/ClientEventReporter.ts index 21ef45abd2..0d4aea442b 100644 --- a/packages/client/src/stats/ClientEventReporter.ts +++ b/packages/client/src/stats/ClientEventReporter.ts @@ -195,6 +195,16 @@ export class ClientEventReporter { const role: ClientEventPeerConnection = event.peerType === PeerType.SUBSCRIBER ? 'subscribe' : 'publish'; + if (event.iceConnectionState === 'failed') { + this.emitPeerConnectionFailure( + role, + 'ICE_CONNECTIVITY_FAILED', + 'ICE connectivity checks failed', + 'FAILED', + ); + return; + } + if (event.peerConnectionState === 'failed') { this.emitPeerConnectionFailure( role, @@ -217,14 +227,6 @@ export class ClientEventReporter { this.emitPeerConnectionSuccess(role); this.pcEverConnected[role] = true; break; - case 'failed': - this.emitPeerConnectionFailure( - role, - 'ICE_CONNECTIVITY_FAILED', - 'ICE connectivity checks failed', - 'FAILED', - ); - break; default: break; } From e3e8e924ee8bfd6338d0eee58eba371aa5775cd5 Mon Sep 17 00:00:00 2001 From: jonadimovska Date: Tue, 26 May 2026 16:44:42 +0200 Subject: [PATCH 04/65] feat(client): client call event reporting - add tests --- .../__tests__/ClientEventReporter.test.ts | 388 ++++++++++++++++++ 1 file changed, 388 insertions(+) create mode 100644 packages/client/src/stats/__tests__/ClientEventReporter.test.ts diff --git a/packages/client/src/stats/__tests__/ClientEventReporter.test.ts b/packages/client/src/stats/__tests__/ClientEventReporter.test.ts new file mode 100644 index 0000000000..3de10bd30a --- /dev/null +++ b/packages/client/src/stats/__tests__/ClientEventReporter.test.ts @@ -0,0 +1,388 @@ +import { describe, expect, it, vi } from 'vitest'; +import { ClientEventReporter } from '../ClientEventReporter'; +import type { PeerConnectionStateChangeEvent } from '../../rtc'; +import { PeerType } from '../../gen/video/sfu/models/models'; +import type { StreamClient } from '../../coordinator/connection/client'; + +const makeReporter = () => { + const post = vi.fn().mockResolvedValue({}); + const streamClient = { post } as unknown as StreamClient; + const reporter = new ClientEventReporter({ + streamClient, + callType: 'default', + callId: 'call-id', + getUserId: () => 'user-1', + getCallSessionId: () => 'session-1', + getSfuId: () => 'sfu-1', + sdkVersion: '1.0.0', + userAgent: 'test-agent', + }); + + const events = (): Record[] => + post.mock.calls.map( + (c) => (c[1] as { events: Record[] }).events[0], + ); + return { reporter, post, events }; +}; + +const pcEvent = ( + overrides: Partial, +): PeerConnectionStateChangeEvent => ({ + peerType: PeerType.PUBLISHER_UNSPECIFIED, + iceConnectionState: 'new', + peerConnectionState: 'new', + sfuId: 'sfu-1', + userSessionId: 'user-session-1', + ...overrides, +}); + +describe('ClientEventReporter', () => { + describe('CoordinatorJoin', () => { + it('emits initiated then completed:success on first success', async () => { + const { reporter, events } = makeReporter(); + await reporter.withJoinLifecycle(() => + reporter.track('CoordinatorJoin', () => Promise.resolve('ok')), + ); + const ev = events(); + expect(ev).toHaveLength(2); + expect(ev[0].stage).toBe('CoordinatorJoin'); + expect(ev[0].event_type).toBe('initiated'); + expect(ev[1].event_type).toBe('completed'); + expect(ev[1].outcome).toBe('success'); + expect(ev[1].retry_count_attempt).toBe(0); + expect(ev[0].event_session_id).toBe(ev[1].event_session_id); + expect(ev[0].join_success_id).toBe(ev[1].join_success_id); + }); + + it('folds retries into one pair with retry_count_attempt', async () => { + const { reporter, events } = makeReporter(); + await reporter.withJoinLifecycle(async () => { + await reporter + .track('CoordinatorJoin', () => Promise.reject(new Error('500'))) + .catch(() => {}); + await reporter + .track('CoordinatorJoin', () => Promise.reject(new Error('500'))) + .catch(() => {}); + await reporter.track('CoordinatorJoin', () => Promise.resolve('ok')); + }); + const ev = events(); + expect(ev).toHaveLength(2); + expect(ev[0].event_type).toBe('initiated'); + expect(ev[1].event_type).toBe('completed'); + expect(ev[1].outcome).toBe('success'); + expect(ev[1].retry_count_attempt).toBe(2); + }); + + it('emits completed:failure when lifecycle throws after retries', async () => { + const { reporter, events } = makeReporter(); + await expect( + reporter.withJoinLifecycle(async () => { + await reporter + .track('CoordinatorJoin', () => Promise.reject(new Error('boom'))) + .catch(() => {}); + throw new Error('exhausted'); + }), + ).rejects.toThrow('exhausted'); + const ev = events(); + expect(ev).toHaveLength(2); + expect(ev[1].outcome).toBe('failure'); + expect(ev[1].retry_failure_code).toBe('REQUEST_TIMEOUT'); + expect(typeof ev[1].retry_failure_reason).toBe('string'); + }); + }); + + describe('WSJoin', () => { + it('emits initiated + completed:success', async () => { + const { reporter, events } = makeReporter(); + await reporter.withJoinLifecycle(() => + reporter.track('WSJoin', () => Promise.resolve('ok')), + ); + const ev = events(); + expect(ev).toHaveLength(2); + expect(ev[0].stage).toBe('WSJoin'); + expect(ev[1].outcome).toBe('success'); + }); + + it('captureWsError applies sticky-priority before close', async () => { + const { reporter, events } = makeReporter(); + await expect( + reporter.withJoinLifecycle(async () => { + await reporter + .track('WSJoin', () => Promise.reject(new Error('timeout'))) + .catch(() => {}); + reporter.captureWsError({ + code: 'UNAUTHENTICATED', + reason: 'SFU rejected token', + }); + throw new Error('exhausted'); + }), + ).rejects.toThrow('exhausted'); + const ev = events(); + expect(ev[1].outcome).toBe('failure'); + expect(ev[1].retry_failure_code).toBe('UNAUTHENTICATED'); + expect(ev[1].retry_failure_reason).toBe('SFU rejected token'); + expect(ev[1].sfu_id).toBe('sfu-1'); + }); + }); + + describe('jsi correlation', () => { + it('shares join_success_id across all pairs in one lifecycle', async () => { + const { reporter, events } = makeReporter(); + await reporter.withJoinLifecycle(async () => { + await reporter.track('CoordinatorJoin', () => Promise.resolve('ok')); + await reporter.track('WSJoin', () => Promise.resolve('ok')); + }); + const ev = events(); + const ids = new Set(ev.map((e) => e.join_success_id)); + expect(ids.size).toBe(1); + }); + + it('rotates join_success_id on a fresh startCorrelation', async () => { + const { reporter, events } = makeReporter(); + await reporter.withJoinLifecycle(() => + reporter.track('CoordinatorJoin', () => Promise.resolve('a')), + ); + await reporter.withJoinLifecycle(() => + reporter.track('CoordinatorJoin', () => Promise.resolve('b')), + ); + const ev = events(); + expect(ev[0].join_success_id).not.toBe(ev[2].join_success_id); + }); + + it('snapshotted jsi: completed carries the same jsi as initiated even if rotated mid-flight', async () => { + const { reporter, events } = makeReporter(); + await reporter.withJoinLifecycle(async () => { + await reporter + .track('WSJoin', () => Promise.reject(new Error('fail'))) + .catch(() => {}); + // simulate explicit migrate boundary that rotates jsi + reporter.startCorrelation(); + await reporter.track('WSJoin', () => Promise.resolve('ok')); + }); + const ev = events(); + // first ws pair (initiated + failure) under jsi-A + const firstPair = ev.filter( + (e) => e.event_session_id === ev[0].event_session_id, + ); + expect(firstPair).toHaveLength(2); + expect(firstPair[0].join_success_id).toBe(firstPair[1].join_success_id); + // second ws pair under jsi-B + const secondInitiated = ev.find( + (e) => + e.event_type === 'initiated' && + e.event_session_id !== ev[0].event_session_id, + ); + expect(secondInitiated?.join_success_id).not.toBe( + firstPair[0].join_success_id, + ); + }); + }); + + describe('PeerConnectionConnect', () => { + it('opens pair on iceConnectionState=checking and closes as success on connected', () => { + const { reporter, events } = makeReporter(); + reporter.onPeerConnectionStateChange( + pcEvent({ iceConnectionState: 'checking' }), + ); + reporter.onPeerConnectionStateChange( + pcEvent({ iceConnectionState: 'connected' }), + ); + const ev = events(); + expect(ev).toHaveLength(2); + expect(ev[0].stage).toBe('PeerConnectionConnect'); + expect(ev[0].peer_connection).toBe('publish'); + expect(ev[0].event_type).toBe('initiated'); + expect(ev[0].was_previously_connected).toBe(false); + expect(ev[1].outcome).toBe('success'); + expect(ev[1].was_previously_connected).toBe(false); + expect(ev[0].event_session_id).toBe(ev[1].event_session_id); + }); + + it('emits ICE_CONNECTIVITY_FAILED on iceConnectionState=failed', () => { + const { reporter, events } = makeReporter(); + reporter.onPeerConnectionStateChange( + pcEvent({ iceConnectionState: 'checking' }), + ); + reporter.onPeerConnectionStateChange( + pcEvent({ iceConnectionState: 'failed' }), + ); + const ev = events(); + expect(ev[1].outcome).toBe('failure'); + expect(ev[1].retry_failure_code).toBe('ICE_CONNECTIVITY_FAILED'); + expect(ev[1].ice_state).toBe('FAILED'); + }); + + it('emits DTLS_CONNECTIVITY_FAILED when peerConnectionState=failed and ICE is connected', () => { + const { reporter, events } = makeReporter(); + reporter.onPeerConnectionStateChange( + pcEvent({ iceConnectionState: 'checking' }), + ); + reporter.onPeerConnectionStateChange( + pcEvent({ + iceConnectionState: 'connected', + peerConnectionState: 'failed', + }), + ); + const ev = events(); + expect(ev[1].outcome).toBe('failure'); + expect(ev[1].retry_failure_code).toBe('DTLS_CONNECTIVITY_FAILED'); + }); + + it('prioritizes ICE failure over DTLS when both states are failed', () => { + const { reporter, events } = makeReporter(); + reporter.onPeerConnectionStateChange( + pcEvent({ iceConnectionState: 'checking' }), + ); + reporter.onPeerConnectionStateChange( + pcEvent({ + iceConnectionState: 'failed', + peerConnectionState: 'failed', + }), + ); + const ev = events(); + expect(ev[1].outcome).toBe('failure'); + expect(ev[1].retry_failure_code).toBe('ICE_CONNECTIVITY_FAILED'); + expect(ev[1].ice_state).toBe('FAILED'); + }); + + it('supersedes an open pair when a second checking arrives', () => { + const { reporter, events } = makeReporter(); + reporter.onPeerConnectionStateChange( + pcEvent({ iceConnectionState: 'checking' }), + ); + reporter.onPeerConnectionStateChange( + pcEvent({ iceConnectionState: 'checking' }), + ); + const ev = events(); + // pair 1: init + supersede-failure + // pair 2: init + expect(ev).toHaveLength(3); + expect(ev[1].outcome).toBe('failure'); + expect(ev[1].retry_failure_reason).toBe('Superseded by new ICE attempt'); + expect(ev[1].event_session_id).toBe(ev[0].event_session_id); + expect(ev[2].event_session_id).not.toBe(ev[0].event_session_id); + expect(ev[2].event_type).toBe('initiated'); + }); + + it('sets was_previously_connected:true on a reconnect after first success', () => { + const { reporter, events } = makeReporter(); + reporter.onPeerConnectionStateChange( + pcEvent({ iceConnectionState: 'checking' }), + ); + reporter.onPeerConnectionStateChange( + pcEvent({ iceConnectionState: 'connected' }), + ); + reporter.onPeerConnectionStateChange( + pcEvent({ iceConnectionState: 'checking' }), + ); + const ev = events(); + const reconnectInit = ev[2]; + expect(reconnectInit.event_type).toBe('initiated'); + expect(reconnectInit.was_previously_connected).toBe(true); + }); + + it('produces independent pairs per role (publish vs subscribe)', () => { + const { reporter, events } = makeReporter(); + reporter.onPeerConnectionStateChange( + pcEvent({ + peerType: PeerType.PUBLISHER_UNSPECIFIED, + iceConnectionState: 'checking', + }), + ); + reporter.onPeerConnectionStateChange( + pcEvent({ + peerType: PeerType.SUBSCRIBER, + iceConnectionState: 'checking', + }), + ); + const ev = events(); + expect(ev).toHaveLength(2); + const roles = ev.map((e) => e.peer_connection); + expect(roles).toContain('publish'); + expect(roles).toContain('subscribe'); + expect(ev[0].event_session_id).not.toBe(ev[1].event_session_id); + }); + }); + + describe('abort', () => { + it('closes open coord/WS pairs with CLIENT_ABORTED', async () => { + const { reporter, events } = makeReporter(); + reporter.startCorrelation(); + await reporter + .track('WSJoin', () => Promise.reject(new Error('mid-flight'))) + .catch(() => {}); + reporter.abort({ code: 'CLIENT_ABORTED', reason: 'user left' }); + const ev = events(); + const completed = ev.find( + (e) => e.stage === 'WSJoin' && e.event_type === 'completed', + ); + // sticky-priority: TRANSPORT(track's REQUEST_TIMEOUT) wins over CLIENT(abort) for the code + // but the captured WS error is the one emitted + expect(completed?.outcome).toBe('failure'); + }); + + it('closes open PC pairs as NOT_CONNECTED', () => { + const { reporter, events } = makeReporter(); + reporter.onPeerConnectionStateChange( + pcEvent({ iceConnectionState: 'checking' }), + ); + reporter.abort({ code: 'BACKEND_LEAVE', reason: 'call ended' }); + const ev = events(); + const completed = ev.find( + (e) => + e.stage === 'PeerConnectionConnect' && e.event_type === 'completed', + ); + expect(completed?.outcome).toBe('failure'); + expect(completed?.retry_failure_code).toBe('BACKEND_LEAVE'); + expect(completed?.ice_state).toBe('NOT_CONNECTED'); + }); + }); + + describe('dispose', () => { + it('stops emitting after dispose', () => { + const { reporter, post } = makeReporter(); + reporter.startCorrelation(); + reporter.dispose(); + reporter.onPeerConnectionStateChange( + pcEvent({ iceConnectionState: 'checking' }), + ); + expect(post).not.toHaveBeenCalled(); + }); + }); + + describe('isolation invariant', () => { + it("does not block the caller's promise on transport failure", async () => { + const slowPost = vi.fn( + () => new Promise(() => {}), // never resolves + ); + const streamClient = { post: slowPost } as unknown as StreamClient; + const reporter = new ClientEventReporter({ + streamClient, + callType: 'default', + callId: 'call-id', + getUserId: () => 'u', + getCallSessionId: () => '', + getSfuId: () => '', + sdkVersion: '1', + userAgent: 't', + }); + const start = Date.now(); + await reporter.withJoinLifecycle(() => + reporter.track('CoordinatorJoin', () => Promise.resolve('ok')), + ); + const elapsed = Date.now() - start; + expect(elapsed).toBeLessThan(50); + expect(slowPost).toHaveBeenCalled(); + }); + + it('rethrows the op error verbatim', async () => { + const { reporter } = makeReporter(); + const opErr = new Error('original error'); + await expect( + reporter.withJoinLifecycle(() => + reporter.track('CoordinatorJoin', () => Promise.reject(opErr)), + ), + ).rejects.toBe(opErr); + }); + }); +}); From 4c03fc4d487e18a5380e56d46c5082e0020772de Mon Sep 17 00:00:00 2001 From: jonadimovska Date: Tue, 26 May 2026 17:23:11 +0200 Subject: [PATCH 05/65] feat(client): client call event reporting - add tests --- .../client/src/stats/__tests__/ClientEventReporter.test.ts | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/packages/client/src/stats/__tests__/ClientEventReporter.test.ts b/packages/client/src/stats/__tests__/ClientEventReporter.test.ts index 3de10bd30a..4a2fca00d5 100644 --- a/packages/client/src/stats/__tests__/ClientEventReporter.test.ts +++ b/packages/client/src/stats/__tests__/ClientEventReporter.test.ts @@ -75,10 +75,13 @@ describe('ClientEventReporter', () => { it('emits completed:failure when lifecycle throws after retries', async () => { const { reporter, events } = makeReporter(); + const httpError = Object.assign(new Error('500'), { + response: { status: 503 }, + }); await expect( reporter.withJoinLifecycle(async () => { await reporter - .track('CoordinatorJoin', () => Promise.reject(new Error('boom'))) + .track('CoordinatorJoin', () => Promise.reject(httpError)) .catch(() => {}); throw new Error('exhausted'); }), From 76a8d64106b63989580893cf1d0aa80364f3f143 Mon Sep 17 00:00:00 2001 From: jonadimovska Date: Tue, 26 May 2026 18:55:22 +0200 Subject: [PATCH 06/65] feat(client): client call event reporting - fix empty sfu id triggering corelation change --- packages/client/src/Call.ts | 18 +++++++++++------- 1 file changed, 11 insertions(+), 7 deletions(-) diff --git a/packages/client/src/Call.ts b/packages/client/src/Call.ts index 8aee7f044a..cfb6856353 100644 --- a/packages/client/src/Call.ts +++ b/packages/client/src/Call.ts @@ -1119,13 +1119,17 @@ export class Call { err instanceof SfuJoinError && SfuJoinError.isJoinErrorCode(err.errorEvent); - const sfuId = this.credentials?.server.edge_name || ''; - const failures = (sfuJoinFailures.get(sfuId) || 0) + 1; - sfuJoinFailures.set(sfuId, failures); - if (switchSfu || failures >= 2) { - joinData.migrating_from = sfuId; - joinData.migrating_from_list = Array.from(sfuJoinFailures.keys()); - this.clientEventReporter?.startCorrelation(); + const sfuId = this.credentials?.server.edge_name; + if (sfuId) { + const failures = (sfuJoinFailures.get(sfuId) || 0) + 1; + sfuJoinFailures.set(sfuId, failures); + if (switchSfu || failures >= 2) { + joinData.migrating_from = sfuId; + joinData.migrating_from_list = Array.from( + sfuJoinFailures.keys(), + ); + this.clientEventReporter?.startCorrelation(); + } } if (attempt === maxJoinRetries - 1) { From c5244392b6ccb96c9e75fa58dcc62d04eb11c687 Mon Sep 17 00:00:00 2001 From: jonadimovska Date: Wed, 27 May 2026 11:23:53 +0200 Subject: [PATCH 07/65] feat(client): client call event reporting - report timeout correctly --- .../client/src/stats/ClientEventReporter.ts | 29 +++++++++++++------ .../__tests__/ClientEventReporter.test.ts | 24 +++++++++++++-- 2 files changed, 42 insertions(+), 11 deletions(-) diff --git a/packages/client/src/stats/ClientEventReporter.ts b/packages/client/src/stats/ClientEventReporter.ts index 0d4aea442b..ce6b25ce64 100644 --- a/packages/client/src/stats/ClientEventReporter.ts +++ b/packages/client/src/stats/ClientEventReporter.ts @@ -158,6 +158,7 @@ export class ClientEventReporter { captureWsError = (opts: { code: string; reason: string }) => { if (!this.wsPair) return; + applyError(this.wsPair, { reason: opts.reason, code: opts.code, @@ -244,11 +245,13 @@ export class ClientEventReporter { 'NOT_CONNECTED', ); } + const pcContext: PeerConnectionContext = { sfuId: ctx.sfuId, userSessionId: ctx.userSessionId, wasPreviouslyConnected: this.pcEverConnected[role], }; + this.peerConnectionContexts[role] = pcContext; this.peerConnectionPairs[role] = { sid: generateUUIDv4(), @@ -256,6 +259,7 @@ export class ClientEventReporter { startedAt: Date.now(), joinSuccessIdSnapshot: this.joinSuccessId, }; + this.send({ ...this.buildCommon( 'PeerConnectionConnect', @@ -481,6 +485,7 @@ export class ClientEventReporter { private sendWithRetry = async (body: Record) => { for (let attempt = 0; attempt < 5; attempt++) { if (this.disposed) return; + try { await this.streamClient.post('/call_client_event', { events: [body] }); return; @@ -513,6 +518,15 @@ export class ClientEventReporter { const errorMessage = (err: unknown): string => err instanceof Error ? err.message : String(err); +const isTimeout = (err: unknown): boolean => { + const e = err as { code?: string; name?: string; message?: string } | null; + return ( + e?.code === 'ECONNABORTED' || + e?.name === 'TimeoutError' || + /timed out|timeout/i.test(e?.message ?? '') + ); +}; + const applyError = (pair: StagePairState | undefined, next: StageError) => { if (!pair) return; @@ -524,18 +538,15 @@ const applyError = (pair: StagePairState | undefined, next: StageError) => { const mapHttpError = (err: unknown): StageError => { const reason = errorMessage(err); const status = (err as { response?: { status?: number } })?.response?.status; - if (typeof status === 'number' && status >= 500) { - return { reason, code: 'REQUEST_TIMEOUT', severity: SEVERITY.SERVER }; + + if (isTimeout(err)) { + return { reason, code: 'REQUEST_TIMEOUT', severity: SEVERITY.TRANSPORT }; } - if (typeof navigator !== 'undefined' && !navigator.onLine) { - return { - reason: 'Device offline', - code: 'NETWORK_OFFLINE', - severity: SEVERITY.TRANSPORT, - }; + if (typeof status === 'number' && status >= 500) { + return { reason, code: `HTTP_${status}`, severity: SEVERITY.SERVER }; } - return { reason, code: 'REQUEST_TIMEOUT', severity: SEVERITY.TRANSPORT }; + return { reason, code: 'NETWORK_ERROR', severity: SEVERITY.TRANSPORT }; }; const mapWsJoinError = (err: unknown): StageError => { diff --git a/packages/client/src/stats/__tests__/ClientEventReporter.test.ts b/packages/client/src/stats/__tests__/ClientEventReporter.test.ts index 4a2fca00d5..bdd5bd4cd1 100644 --- a/packages/client/src/stats/__tests__/ClientEventReporter.test.ts +++ b/packages/client/src/stats/__tests__/ClientEventReporter.test.ts @@ -73,7 +73,7 @@ describe('ClientEventReporter', () => { expect(ev[1].retry_count_attempt).toBe(2); }); - it('emits completed:failure when lifecycle throws after retries', async () => { + it('emits completed:failure with HTTP_{status} when lifecycle throws after retries', async () => { const { reporter, events } = makeReporter(); const httpError = Object.assign(new Error('500'), { response: { status: 503 }, @@ -89,9 +89,29 @@ describe('ClientEventReporter', () => { const ev = events(); expect(ev).toHaveLength(2); expect(ev[1].outcome).toBe('failure'); - expect(ev[1].retry_failure_code).toBe('REQUEST_TIMEOUT'); + expect(ev[1].retry_failure_code).toBe('HTTP_503'); expect(typeof ev[1].retry_failure_reason).toBe('string'); }); + + it('emits REQUEST_TIMEOUT only for actual timeouts', async () => { + const { reporter, events } = makeReporter(); + const timeoutError = Object.assign( + new Error('timeout of 5000ms exceeded'), + { + code: 'ECONNABORTED', + }, + ); + await expect( + reporter.withJoinLifecycle(async () => { + await reporter + .track('CoordinatorJoin', () => Promise.reject(timeoutError)) + .catch(() => {}); + throw new Error('exhausted'); + }), + ).rejects.toThrow('exhausted'); + const ev = events(); + expect(ev[1].retry_failure_code).toBe('REQUEST_TIMEOUT'); + }); }); describe('WSJoin', () => { From 9d1eb4bd111ddfcf5a143bd5d701d4e535ed2ef5 Mon Sep 17 00:00:00 2001 From: jonadimovska Date: Mon, 1 Jun 2026 16:59:53 +0200 Subject: [PATCH 08/65] feat(client): client call event reporting - report timeout correctly --- .../client/src/stats/ClientEventReporter.ts | 39 +++++++++--- .../__tests__/ClientEventReporter.test.ts | 60 ++++++++++++++----- 2 files changed, 74 insertions(+), 25 deletions(-) diff --git a/packages/client/src/stats/ClientEventReporter.ts b/packages/client/src/stats/ClientEventReporter.ts index ce6b25ce64..ebf98b2261 100644 --- a/packages/client/src/stats/ClientEventReporter.ts +++ b/packages/client/src/stats/ClientEventReporter.ts @@ -12,6 +12,7 @@ import type { PeerConnectionStateChangeEvent } from '../rtc'; export type ClientEventPeerConnection = 'publish' | 'subscribe'; export type ClientEventStage = + | 'JoinInitiated' | 'CoordinatorJoin' | 'WSJoin' | 'PeerConnectionConnect'; @@ -52,7 +53,7 @@ type StagePairState = { sid: string; attempts: number; startedAt: number; - joinSuccessIdSnapshot?: string; + joinAttemptIdSnapshot?: string; lastError?: StageError; }; @@ -71,7 +72,7 @@ type PeerConnectionContext = { * publish/subscribe peer connection). Every stage attempt produces a pair of * events — an `initiated` event when the attempt begins, and a `completed` * event when it resolves — sharing one `event_session_id`. A shared - * `join_success_id` correlates all pairs from one logical join lifecycle. + * `join_attempt_id` correlates all pairs from one logical join lifecycle. * * `CoordinatorJoin` + `WSJoin` use the fold model: one pair is held open * across the `Call.join` retry loop. Internal retries within that lifecycle @@ -101,7 +102,7 @@ export class ClientEventReporter { private readonly userAgent: string; private disposed = false; - private joinSuccessId?: string; + private joinAttemptId?: string; private coordinatorPair?: StagePairState; private wsPair?: StagePairState; private peerConnectionPairs: Partial< @@ -128,7 +129,27 @@ export class ClientEventReporter { startCorrelation = () => { this.close(); - this.joinSuccessId = generateUUIDv4(); + this.joinAttemptId = generateUUIDv4(); + this.emitJoinInitiated(); + }; + + /** + * Emits the `JoinInitiated` event — fired once per join attempt, when the + * `join_attempt_id` is minted. Unlike the staged events it carries no + * `event_session_id` (no init/completed pair) and no call identifiers. + */ + private emitJoinInitiated = () => { + if (!this.joinAttemptId) return; + + this.send({ + user_id: this.getUserId(), + stage: 'JoinInitiated', + join_attempt_id: this.joinAttemptId, + timestamp: new Date().toISOString(), + user_agent: this.userAgent, + sdk_version: this.sdkVersion, + event_type: 'initiated', + }); }; withJoinLifecycle = async (op: () => Promise): Promise => { @@ -257,7 +278,7 @@ export class ClientEventReporter { sid: generateUUIDv4(), attempts: 0, startedAt: Date.now(), - joinSuccessIdSnapshot: this.joinSuccessId, + joinAttemptIdSnapshot: this.joinAttemptId, }; this.send({ @@ -362,7 +383,7 @@ export class ClientEventReporter { sid: generateUUIDv4(), attempts: 0, startedAt: Date.now(), - joinSuccessIdSnapshot: this.joinSuccessId, + joinAttemptIdSnapshot: this.joinAttemptId, }; this.send({ @@ -411,7 +432,7 @@ export class ClientEventReporter { sid: generateUUIDv4(), attempts: 0, startedAt: Date.now(), - joinSuccessIdSnapshot: this.joinSuccessId, + joinAttemptIdSnapshot: this.joinAttemptId, }; this.send({ ...this.buildCommon('WSJoin', this.wsPair), @@ -468,8 +489,8 @@ export class ClientEventReporter { stage, event_session_id: pair.sid, ...(callSessionId && { call_session_id: callSessionId }), - ...(pair.joinSuccessIdSnapshot && { - join_success_id: pair.joinSuccessIdSnapshot, + ...(pair.joinAttemptIdSnapshot && { + join_attempt_id: pair.joinAttemptIdSnapshot, }), timestamp: new Date().toISOString(), user_agent: this.userAgent, diff --git a/packages/client/src/stats/__tests__/ClientEventReporter.test.ts b/packages/client/src/stats/__tests__/ClientEventReporter.test.ts index bdd5bd4cd1..df52d25c3a 100644 --- a/packages/client/src/stats/__tests__/ClientEventReporter.test.ts +++ b/packages/client/src/stats/__tests__/ClientEventReporter.test.ts @@ -18,11 +18,15 @@ const makeReporter = () => { userAgent: 'test-agent', }); - const events = (): Record[] => + const allEvents = (): Record[] => post.mock.calls.map( (c) => (c[1] as { events: Record[] }).events[0], ); - return { reporter, post, events }; + // staged events only — excludes the cross-cutting `JoinInitiated` event so + // stage-based index/count assertions stay stable. + const events = (): Record[] => + allEvents().filter((e) => e.stage !== 'JoinInitiated'); + return { reporter, post, events, allEvents }; }; const pcEvent = ( @@ -51,7 +55,7 @@ describe('ClientEventReporter', () => { expect(ev[1].outcome).toBe('success'); expect(ev[1].retry_count_attempt).toBe(0); expect(ev[0].event_session_id).toBe(ev[1].event_session_id); - expect(ev[0].join_success_id).toBe(ev[1].join_success_id); + expect(ev[0].join_attempt_id).toBe(ev[1].join_attempt_id); }); it('folds retries into one pair with retry_count_attempt', async () => { @@ -148,19 +152,19 @@ describe('ClientEventReporter', () => { }); }); - describe('jsi correlation', () => { - it('shares join_success_id across all pairs in one lifecycle', async () => { + describe('join_attempt_id correlation', () => { + it('shares join_attempt_id across all pairs in one lifecycle', async () => { const { reporter, events } = makeReporter(); await reporter.withJoinLifecycle(async () => { await reporter.track('CoordinatorJoin', () => Promise.resolve('ok')); await reporter.track('WSJoin', () => Promise.resolve('ok')); }); const ev = events(); - const ids = new Set(ev.map((e) => e.join_success_id)); + const ids = new Set(ev.map((e) => e.join_attempt_id)); expect(ids.size).toBe(1); }); - it('rotates join_success_id on a fresh startCorrelation', async () => { + it('rotates join_attempt_id on a fresh startCorrelation', async () => { const { reporter, events } = makeReporter(); await reporter.withJoinLifecycle(() => reporter.track('CoordinatorJoin', () => Promise.resolve('a')), @@ -169,34 +173,34 @@ describe('ClientEventReporter', () => { reporter.track('CoordinatorJoin', () => Promise.resolve('b')), ); const ev = events(); - expect(ev[0].join_success_id).not.toBe(ev[2].join_success_id); + expect(ev[0].join_attempt_id).not.toBe(ev[2].join_attempt_id); }); - it('snapshotted jsi: completed carries the same jsi as initiated even if rotated mid-flight', async () => { + it('snapshotted join_attempt_id: completed carries the same join_attempt_id as initiated even if rotated mid-flight', async () => { const { reporter, events } = makeReporter(); await reporter.withJoinLifecycle(async () => { await reporter .track('WSJoin', () => Promise.reject(new Error('fail'))) .catch(() => {}); - // simulate explicit migrate boundary that rotates jsi + // simulate explicit migrate boundary that rotates join_attempt_id reporter.startCorrelation(); await reporter.track('WSJoin', () => Promise.resolve('ok')); }); const ev = events(); - // first ws pair (initiated + failure) under jsi-A + // first ws pair (initiated + failure) under join_attempt_id-A const firstPair = ev.filter( (e) => e.event_session_id === ev[0].event_session_id, ); expect(firstPair).toHaveLength(2); - expect(firstPair[0].join_success_id).toBe(firstPair[1].join_success_id); - // second ws pair under jsi-B + expect(firstPair[0].join_attempt_id).toBe(firstPair[1].join_attempt_id); + // second ws pair under join_attempt_id-B const secondInitiated = ev.find( (e) => e.event_type === 'initiated' && e.event_session_id !== ev[0].event_session_id, ); - expect(secondInitiated?.join_success_id).not.toBe( - firstPair[0].join_success_id, + expect(secondInitiated?.join_attempt_id).not.toBe( + firstPair[0].join_attempt_id, ); }); }); @@ -361,11 +365,35 @@ describe('ClientEventReporter', () => { }); }); + describe('JoinInitiated', () => { + it('emits one JoinInitiated when a correlation starts, carrying the attempt id', () => { + const { reporter, allEvents } = makeReporter(); + reporter.startCorrelation(); + const init = allEvents().filter((e) => e.stage === 'JoinInitiated'); + expect(init).toHaveLength(1); + expect(init[0].event_type).toBe('initiated'); + expect(init[0].user_id).toBe('user-1'); + expect(typeof init[0].join_attempt_id).toBe('string'); + // no per-stage session id and no call identifiers on JoinInitiated + expect(init[0].event_session_id).toBeUndefined(); + expect(init[0].call_cid).toBeUndefined(); + }); + + it('emits a fresh JoinInitiated (new attempt id) on each correlation', () => { + const { reporter, allEvents } = makeReporter(); + reporter.startCorrelation(); + reporter.startCorrelation(); + const init = allEvents().filter((e) => e.stage === 'JoinInitiated'); + expect(init).toHaveLength(2); + expect(init[0].join_attempt_id).not.toBe(init[1].join_attempt_id); + }); + }); + describe('dispose', () => { it('stops emitting after dispose', () => { const { reporter, post } = makeReporter(); - reporter.startCorrelation(); reporter.dispose(); + reporter.startCorrelation(); reporter.onPeerConnectionStateChange( pcEvent({ iceConnectionState: 'checking' }), ); From f8f2eb3a270180eaf6422075db070dbd304baf10 Mon Sep 17 00:00:00 2001 From: jonadimovska Date: Mon, 1 Jun 2026 23:17:52 +0200 Subject: [PATCH 09/65] feat(client): client call event reporting - report timeout correctly --- packages/client/src/Call.ts | 52 +- packages/client/src/StreamVideoClient.ts | 12 +- .../src/coordinator/connection/client.ts | 14 + .../src/coordinator/connection/connection.ts | 10 + .../client/src/stats/ClientEventReporter.ts | 621 +++++++++++------- .../__tests__/ClientEventReporter.test.ts | 439 ------------- 6 files changed, 425 insertions(+), 723 deletions(-) delete mode 100644 packages/client/src/stats/__tests__/ClientEventReporter.test.ts diff --git a/packages/client/src/Call.ts b/packages/client/src/Call.ts index cfb6856353..17df50c1b9 100644 --- a/packages/client/src/Call.ts +++ b/packages/client/src/Call.ts @@ -140,7 +140,6 @@ import { WebsocketReconnectStrategy, } from './gen/video/sfu/models/models'; import { - ClientEventReporter, createStatsReporter, getSdkSignature, SfuStatsReporter, @@ -289,7 +288,6 @@ export class Call { private statsReportingIntervalInMs: number = 2000; private statsReporter?: StatsReporter; private sfuStatsReporter?: SfuStatsReporter; - private clientEventReporter?: ClientEventReporter; private lastStatsOptions?: StatsOptions; private dropTimeout: ReturnType | undefined; @@ -426,17 +424,10 @@ export class Call { await withoutConcurrency(this.joinLeaveConcurrencyTag, async () => { if (this.initialized) return; - const clientDetails = await getClientDetails(); - const { sdkVersion } = getSdkSignature(clientDetails); - this.clientEventReporter = new ClientEventReporter({ - streamClient: this.streamClient, + this.streamClient.clientEventReporter.registerCall(this.id, { callType: this.type, - callId: this.id, - getUserId: () => this.streamClient.user?.id ?? '', getCallSessionId: () => this.state.session?.id ?? '', getSfuId: () => this.credentials?.server.edge_name ?? '', - sdkVersion, - userAgent: this.streamClient.getUserAgent(), }); this.leaveCallHooks.add( @@ -701,7 +692,10 @@ export class Call { * @internal */ reportBackendLeave = (reason: string) => { - this.clientEventReporter?.abort({ code: 'BACKEND_LEAVE', reason }); + this.streamClient.clientEventReporter.abort(this.id, { + code: 'BACKEND_LEAVE', + reason, + }); }; /** @@ -768,7 +762,7 @@ export class Call { this.lastStatsOptions = undefined; await this.subscriber?.dispose(); - this.clientEventReporter?.abort({ + this.streamClient.clientEventReporter.abort(this.id, { code: 'CLIENT_ABORTED', reason: leaveReason, }); @@ -779,8 +773,7 @@ export class Call { await this.publisher?.dispose(); this.publisher = undefined; - this.clientEventReporter?.dispose(); - this.clientEventReporter = undefined; + this.streamClient.clientEventReporter.unregisterCall(this.id); await this.sfuClient?.leaveAndClose(leaveReason); this.sfuClient = undefined; @@ -1128,7 +1121,7 @@ export class Call { joinData.migrating_from_list = Array.from( sfuJoinFailures.keys(), ); - this.clientEventReporter?.startCorrelation(); + this.streamClient.clientEventReporter.startCorrelation(this.id); } } @@ -1145,22 +1138,14 @@ export class Call { } }; - private withJoinLifecycle = (op: () => Promise): Promise => { - const reporter = this.clientEventReporter; - return reporter ? reporter.withJoinLifecycle(op) : op(); - }; + private withJoinLifecycle = (op: () => Promise): Promise => + this.streamClient.clientEventReporter.withJoinLifecycle(this.id, op); - private trackCoordinatorJoin = (op: () => Promise): Promise => { - const reporter = this.clientEventReporter; - return reporter ? reporter.track('CoordinatorJoin', op) : op(); - }; + private trackCoordinatorJoin = (op: () => Promise): Promise => + this.streamClient.clientEventReporter.track(this.id, 'CoordinatorJoin', op); private trackWsJoin = (op: () => Promise): Promise => { - if (this.reconnectStrategy === WebsocketReconnectStrategy.FAST) { - return op(); - } - const reporter = this.clientEventReporter; - return reporter ? reporter.track('WSJoin', op) : op(); + return this.streamClient.clientEventReporter.track(this.id, 'WSJoin', op); }; /** @@ -1545,7 +1530,10 @@ export class Call { this.iceFailuresWithoutConnect = 0; }, onPeerConnectionStateChange: (event) => { - this.clientEventReporter?.onPeerConnectionStateChange(event); + this.streamClient.clientEventReporter.onPeerConnectionStateChange( + this.id, + event, + ); }, }; @@ -1981,7 +1969,7 @@ export class Call { private registerReconnectHandlers = () => { // handles the legacy "goAway" event const unregisterGoAway = this.on('goAway', () => { - this.clientEventReporter?.captureWsError({ + this.streamClient.clientEventReporter.captureWsError(this.id, { code: 'SFU_GO_AWAY', reason: 'SFU goAway received during WS join', }); @@ -1996,7 +1984,7 @@ export class Call { const { reconnectStrategy: strategy, error } = e; if (!SfuJoinError.isJoinErrorCode(e)) { const code = error?.code ? ErrorCode[error.code] : 'REQUEST_TIMEOUT'; - this.clientEventReporter?.captureWsError({ + this.streamClient.clientEventReporter.captureWsError(this.id, { code: code ?? 'REQUEST_TIMEOUT', reason: error?.message || 'SFU error during WS join', }); @@ -2027,7 +2015,7 @@ export class Call { this.tracer.trace('network.changed', e); if (!e.online) { this.logger.debug('[Reconnect] Going offline'); - this.clientEventReporter?.captureWsError({ + this.streamClient.clientEventReporter.captureWsError(this.id, { code: 'NETWORK_OFFLINE', reason: 'Device offline', }); diff --git a/packages/client/src/StreamVideoClient.ts b/packages/client/src/StreamVideoClient.ts index 859ee80734..858d9bd8fe 100644 --- a/packages/client/src/StreamVideoClient.ts +++ b/packages/client/src/StreamVideoClient.ts @@ -296,6 +296,9 @@ export class StreamVideoClient { return this.connectAnonymousUser(user as UserWithId, tokenOrProvider); } + const reporter = this.streamClient.clientEventReporter; + reporter.mintCoordinatorConnectId(); + const connectUserResponse = await withoutConcurrency( this.connectionConcurrencyTag, async () => { @@ -309,13 +312,16 @@ export class StreamVideoClient { for (let attempt = 0; attempt < maxConnectUserRetries; attempt++) { try { this.logger.trace(`Connecting user (${attempt})`, user); - return user.type === 'guest' - ? await client.connectGuestUser(user) - : await client.connectUser(user, tokenOrProvider); + return await reporter.trackCoordinatorWs(() => + user.type === 'guest' + ? client.connectGuestUser(user) + : client.connectUser(user, tokenOrProvider), + ); } catch (err) { this.logger.warn(`Failed to connect a user (${attempt})`, err); errorQueue.push(err as Error); if (attempt === maxConnectUserRetries - 1) { + reporter.closeCoordinatorWs(); onConnectUserError?.(err as Error, errorQueue); throw err; } diff --git a/packages/client/src/coordinator/connection/client.ts b/packages/client/src/coordinator/connection/client.ts index 56cea352d3..14b1f404d2 100644 --- a/packages/client/src/coordinator/connection/client.ts +++ b/packages/client/src/coordinator/connection/client.ts @@ -37,6 +37,7 @@ import { } from '../../gen/coordinator'; import { makeSafePromise, type SafePromise } from '../../helpers/promise'; import { ScopedLogger, videoLoggerSystem } from '../../logger'; +import { ClientEventReporter } from '../../stats'; export class StreamClient { _user?: UserWithId; @@ -64,6 +65,7 @@ export class StreamClient { userID?: string; wsBaseURL?: string; wsConnection: StableWSConnection | null; + readonly clientEventReporter: ClientEventReporter; private wsPromiseSafe: SafePromise | null; consecutiveFailures: number; defaultWSTimeout: number; @@ -149,6 +151,15 @@ export class StreamClient { this.defaultWSTimeout = this.options.defaultWsTimeout ?? 15000; this.logger = videoLoggerSystem.getLogger('coordinator'); + + const { clientAppIdentifier = {} } = this.options; + this.clientEventReporter = new ClientEventReporter({ + streamClient: this, + getUserId: () => this.user?.id ?? '', + sdkVersion: + clientAppIdentifier.sdkVersion ?? process.env.PKG_VERSION ?? '0.0.0', + userAgent: this.getUserAgent(), + }); } getAuthType = () => { @@ -179,6 +190,9 @@ export class StreamClient { _getConnectionID = () => this.wsConnection?.connectionID; + getCoordinatorConnectId = () => + this.clientEventReporter.getCoordinatorConnectId(); + _hasConnectionID = () => Boolean(this._getConnectionID()); /** diff --git a/packages/client/src/coordinator/connection/connection.ts b/packages/client/src/coordinator/connection/connection.ts index 9a46729f83..f6b9ba504a 100644 --- a/packages/client/src/coordinator/connection/connection.ts +++ b/packages/client/src/coordinator/connection/connection.ts @@ -52,6 +52,7 @@ export class StableWSConnection { isDisconnected = false; /** Boolean that indicates if we have a working connection to the server */ isHealthy = false; + private hasEverBeenHealthy = false; // Open-connection promise: resolves on `connection.ok`, rejects on close/error. connectionID?: string; @@ -655,6 +656,15 @@ export class StableWSConnection { this.isHealthy = healthy; + if (healthy) { + if (this.hasEverBeenHealthy) { + this.client.clientEventReporter.reportCoordinatorWsReconnectCompleted(); + } + this.hasEverBeenHealthy = true; + } else if (this.hasEverBeenHealthy) { + this.client.clientEventReporter.reportCoordinatorWsReconnectInitiated(); + } + if (this.isHealthy || dispatchImmediately) { this.client.dispatchEvent({ type: 'connection.changed', diff --git a/packages/client/src/stats/ClientEventReporter.ts b/packages/client/src/stats/ClientEventReporter.ts index ebf98b2261..65da954632 100644 --- a/packages/client/src/stats/ClientEventReporter.ts +++ b/packages/client/src/stats/ClientEventReporter.ts @@ -13,6 +13,7 @@ export type ClientEventPeerConnection = 'publish' | 'subscribe'; export type ClientEventStage = | 'JoinInitiated' + | 'CoordinatorWS' | 'CoordinatorJoin' | 'WSJoin' | 'PeerConnectionConnect'; @@ -22,17 +23,18 @@ export type ClientEventStandardCode = | 'BACKEND_LEAVE' | 'REQUEST_TIMEOUT' | 'NETWORK_OFFLINE' - | 'ICE_GATHERING_FAILED' | 'ICE_CONNECTIVITY_FAILED' | 'DTLS_CONNECTIVITY_FAILED'; -export type ClientEventReporterOptions = { - streamClient: StreamClient; +export type CallReportContext = { callType: string; - callId: string; - getUserId: () => string; getCallSessionId: () => string; getSfuId: () => string; +}; + +export type ClientEventReporterOptions = { + streamClient: StreamClient; + getUserId: () => string; sdkVersion: string; userAgent: string; }; @@ -63,162 +65,389 @@ type PeerConnectionContext = { wasPreviouslyConnected: boolean; }; -/** - * Reports client-side join-lifecycle telemetry to - * `POST /api/v2/video/call_client_event`. - * - * Three stages are tracked: `CoordinatorJoin` (HTTP `/join`), `WSJoin` - * (SFU WebSocket open + join RPC), and `PeerConnectionConnect` (one per - * publish/subscribe peer connection). Every stage attempt produces a pair of - * events — an `initiated` event when the attempt begins, and a `completed` - * event when it resolves — sharing one `event_session_id`. A shared - * `join_attempt_id` correlates all pairs from one logical join lifecycle. - * - * `CoordinatorJoin` + `WSJoin` use the fold model: one pair is held open - * across the `Call.join` retry loop. Internal retries within that lifecycle - * increment `retry_count_attempt`; only the final outcome emits the - * `completed` event. `PeerConnectionConnect` does not fold: every ICE - * connect attempt (initial, restart, post-drop reconnect) produces its own - * pair with a fresh `event_session_id`. `was_previously_connected` - * distinguishes fresh connects from reconnects. - * - * All transports run detached from the caller's promise chain — reporting - * never blocks or fails the join. Internal POST retries follow the SDK's - * `retryInterval(attempt)` backoff (up to 5 attempts). Validation failures - * (HTTP 4xx) are not retried. Events are not persisted across page reloads; - * the backend treats absent completions as failures after a 60-second - * grace window. - */ +const pcKey = (callId: string, role: ClientEventPeerConnection): string => + `${callId}:${role}`; + export class ClientEventReporter { private readonly logger = videoLoggerSystem.getLogger('ClientEventReporter'); private readonly streamClient: StreamClient; - private readonly callType: string; - private readonly callId: string; private readonly getUserId: () => string; - private readonly getCallSessionId: () => string; - private readonly getSfuId: () => string; private readonly sdkVersion: string; private readonly userAgent: string; private disposed = false; - private joinAttemptId?: string; - private coordinatorPair?: StagePairState; - private wsPair?: StagePairState; - private peerConnectionPairs: Partial< - Record - > = {}; - private peerConnectionContexts: Partial< - Record - > = {}; - private pcEverConnected: Record = { - publish: false, - subscribe: false, - }; + private coordinatorConnectId?: string; + private coordinatorWsPair?: StagePairState; + + private callContexts = new Map(); + private joinAttemptIds = new Map(); + private coordinatorPairs = new Map(); + private wsPairs = new Map(); + private peerConnectionPairs = new Map(); + private peerConnectionContexts = new Map(); + private pcEverConnected = new Map(); constructor(options: ClientEventReporterOptions) { this.streamClient = options.streamClient; - this.callType = options.callType; - this.callId = options.callId; this.getUserId = options.getUserId; - this.getCallSessionId = options.getCallSessionId; - this.getSfuId = options.getSfuId; this.sdkVersion = options.sdkVersion; this.userAgent = options.userAgent; } - startCorrelation = () => { - this.close(); - this.joinAttemptId = generateUUIDv4(); - this.emitJoinInitiated(); + dispose = () => { + this.disposed = true; }; - /** - * Emits the `JoinInitiated` event — fired once per join attempt, when the - * `join_attempt_id` is minted. Unlike the staged events it carries no - * `event_session_id` (no init/completed pair) and no call identifiers. - */ - private emitJoinInitiated = () => { - if (!this.joinAttemptId) return; + getCoordinatorConnectId = (): string => this.coordinatorConnectId ?? ''; + mintCoordinatorConnectId = (): string => { + this.coordinatorConnectId = generateUUIDv4(); + return this.coordinatorConnectId; + }; + + trackCoordinatorWs = async (op: () => Promise): Promise => { + this.beginCoordinatorWs(); + try { + const result = await op(); + this.succeedCoordinatorWs(); + return result; + } catch (err) { + applyError(this.coordinatorWsPair, mapHttpError(err)); + throw err; + } + }; + + reportCoordinatorWsReconnectInitiated = () => { + this.beginCoordinatorWs(); + }; + + reportCoordinatorWsReconnectCompleted = () => { + this.succeedCoordinatorWs(); + }; + + closeCoordinatorWs = () => { + const pair = this.coordinatorWsPair; + if (!pair || !pair.lastError) { + this.coordinatorWsPair = undefined; + return; + } + const { reason, code } = pair.lastError; this.send({ - user_id: this.getUserId(), - stage: 'JoinInitiated', - join_attempt_id: this.joinAttemptId, - timestamp: new Date().toISOString(), - user_agent: this.userAgent, - sdk_version: this.sdkVersion, - event_type: 'initiated', + ...this.buildCoordinatorWsCommon(pair), + event_type: 'completed', + outcome: 'failure', + retry_count_attempt: pair.attempts - 1, + elapsed_time: Date.now() - pair.startedAt, + retry_failure_reason: reason, + retry_failure_code: code, }); + this.coordinatorWsPair = undefined; + }; + + private beginCoordinatorWs = () => { + if (!this.coordinatorWsPair) { + this.coordinatorWsPair = { + sid: generateUUIDv4(), + attempts: 0, + startedAt: Date.now(), + }; + this.send({ + ...this.buildCoordinatorWsCommon(this.coordinatorWsPair), + event_type: 'initiated', + }); + } + this.coordinatorWsPair.attempts++; + }; + + private succeedCoordinatorWs = () => { + const pair = this.coordinatorWsPair; + if (!pair) return; + this.send({ + ...this.buildCoordinatorWsCommon(pair), + event_type: 'completed', + outcome: 'success', + retry_count_attempt: pair.attempts - 1, + elapsed_time: Date.now() - pair.startedAt, + }); + this.coordinatorWsPair = undefined; + }; + + private buildCoordinatorWsCommon = ( + pair: StagePairState, + ): Record => ({ + user_id: this.getUserId(), + stage: 'CoordinatorWS', + event_session_id: pair.sid, + ...(this.coordinatorConnectId && { + coordinator_connect_id: this.coordinatorConnectId, + }), + timestamp: new Date().toISOString(), + user_agent: this.userAgent, + sdk_version: this.sdkVersion, + }); + + registerCall = (callId: string, ctx: CallReportContext) => { + this.callContexts.set(callId, ctx); + }; + + unregisterCall = (callId: string) => { + this.callContexts.delete(callId); + this.joinAttemptIds.delete(callId); + this.coordinatorPairs.delete(callId); + this.wsPairs.delete(callId); + for (const role of ['publish', 'subscribe'] as const) { + const key = pcKey(callId, role); + this.peerConnectionPairs.delete(key); + this.peerConnectionContexts.delete(key); + this.pcEverConnected.delete(key); + } }; - withJoinLifecycle = async (op: () => Promise): Promise => { - this.startCorrelation(); + startCorrelation = (callId: string) => { + this.closeCallPairs(callId); + this.joinAttemptIds.set(callId, generateUUIDv4()); + this.emitJoinInitiated(callId); + }; + + withJoinLifecycle = async ( + callId: string, + op: () => Promise, + ): Promise => { + this.startCorrelation(callId); try { return await op(); } catch (err) { - this.close(); + this.closeCallPairs(callId); throw err; } }; track = async ( + callId: string, stage: 'CoordinatorJoin' | 'WSJoin', op: () => Promise, ): Promise => { - this.beginAttempt(stage); + this.beginAttempt(callId, stage); try { const result = await op(); - this.succeedAttempt(stage); + this.succeedAttempt(callId, stage); return result; } catch (err) { - this.applyStageError(stage, err); + this.applyStageError(callId, stage, err); throw err; } }; - captureWsError = (opts: { code: string; reason: string }) => { - if (!this.wsPair) return; - - applyError(this.wsPair, { + captureWsError = (callId: string, opts: { code: string; reason: string }) => { + const pair = this.wsPairs.get(callId); + if (!pair) return; + applyError(pair, { reason: opts.reason, code: opts.code, severity: SEVERITY.SERVER, }); }; - close = () => { - if (this.coordinatorPair) this.failCoordinator(); - if (this.wsPair) this.failWs(); + close = (callId: string) => { + this.closeCallPairs(callId); }; - abort = (opts: { - code: 'CLIENT_ABORTED' | 'BACKEND_LEAVE'; - reason: string; - }) => { + abort = ( + callId: string, + opts: { code: 'CLIENT_ABORTED' | 'BACKEND_LEAVE'; reason: string }, + ) => { const { code, reason } = opts; const stageError: StageError = { code, reason, severity: SEVERITY.CLIENT }; - applyError(this.coordinatorPair, stageError); - applyError(this.wsPair, stageError); + applyError(this.coordinatorPairs.get(callId), stageError); + applyError(this.wsPairs.get(callId), stageError); + this.failCoordinator(callId); + this.failWs(callId); + + this.emitPeerConnectionFailure( + callId, + 'publish', + code, + reason, + 'NOT_CONNECTED', + ); + this.emitPeerConnectionFailure( + callId, + 'subscribe', + code, + reason, + 'NOT_CONNECTED', + ); + }; - this.failCoordinator(); - this.failWs(); + private closeCallPairs = (callId: string) => { + if (this.coordinatorPairs.get(callId)) this.failCoordinator(callId); + if (this.wsPairs.get(callId)) this.failWs(callId); + }; - this.emitPeerConnectionFailure('publish', code, reason, 'NOT_CONNECTED'); - this.emitPeerConnectionFailure('subscribe', code, reason, 'NOT_CONNECTED'); + private emitJoinInitiated = (callId: string) => { + const joinAttemptId = this.joinAttemptIds.get(callId); + if (!joinAttemptId) return; + const coordinatorConnectId = this.getCoordinatorConnectId(); + this.send({ + user_id: this.getUserId(), + stage: 'JoinInitiated', + join_attempt_id: joinAttemptId, + ...(coordinatorConnectId && { + coordinator_connect_id: coordinatorConnectId, + }), + timestamp: new Date().toISOString(), + user_agent: this.userAgent, + sdk_version: this.sdkVersion, + event_type: 'initiated', + }); }; - dispose = () => { - this.disposed = true; + private beginAttempt = ( + callId: string, + stage: 'CoordinatorJoin' | 'WSJoin', + ) => { + if (stage === 'CoordinatorJoin') this.beginCoordinatorAttempt(callId); + else this.beginWsAttempt(callId); + }; + + private succeedAttempt = ( + callId: string, + stage: 'CoordinatorJoin' | 'WSJoin', + ) => { + if (stage === 'CoordinatorJoin') this.succeedCoordinator(callId); + else this.succeedWs(callId); + }; + + private applyStageError = ( + callId: string, + stage: 'CoordinatorJoin' | 'WSJoin', + err: unknown, + ) => { + if (stage === 'CoordinatorJoin') { + applyError(this.coordinatorPairs.get(callId), mapHttpError(err)); + } else { + applyError(this.wsPairs.get(callId), mapWsJoinError(err)); + } }; - onPeerConnectionStateChange = (event: PeerConnectionStateChangeEvent) => { + private beginCoordinatorAttempt = (callId: string) => { + let pair = this.coordinatorPairs.get(callId); + if (!pair) { + pair = { + sid: generateUUIDv4(), + attempts: 0, + startedAt: Date.now(), + joinAttemptIdSnapshot: this.joinAttemptIds.get(callId), + }; + this.coordinatorPairs.set(callId, pair); + this.send({ + ...this.buildCommon(callId, 'CoordinatorJoin', pair), + event_type: 'initiated', + }); + } + pair.attempts++; + }; + + private succeedCoordinator = (callId: string) => { + const pair = this.coordinatorPairs.get(callId); + if (!pair) return; + this.send({ + ...this.buildCommon(callId, 'CoordinatorJoin', pair), + event_type: 'completed', + outcome: 'success', + retry_count_attempt: pair.attempts - 1, + elapsed_time: Date.now() - pair.startedAt, + }); + this.coordinatorPairs.delete(callId); + }; + + private failCoordinator = (callId: string) => { + const pair = this.coordinatorPairs.get(callId); + if (!pair || !pair.lastError) { + this.coordinatorPairs.delete(callId); + return; + } + const { reason, code } = pair.lastError; + this.send({ + ...this.buildCommon(callId, 'CoordinatorJoin', pair), + event_type: 'completed', + outcome: 'failure', + retry_count_attempt: pair.attempts - 1, + elapsed_time: Date.now() - pair.startedAt, + retry_failure_reason: reason, + retry_failure_code: code, + }); + this.coordinatorPairs.delete(callId); + }; + + private beginWsAttempt = (callId: string) => { + let pair = this.wsPairs.get(callId); + if (!pair) { + pair = { + sid: generateUUIDv4(), + attempts: 0, + startedAt: Date.now(), + joinAttemptIdSnapshot: this.joinAttemptIds.get(callId), + }; + this.wsPairs.set(callId, pair); + const sfuId = this.getSfuId(callId); + this.send({ + ...this.buildCommon(callId, 'WSJoin', pair), + ...(sfuId && { sfu_id: sfuId }), + event_type: 'initiated', + }); + } + pair.attempts++; + }; + + private succeedWs = (callId: string) => { + const pair = this.wsPairs.get(callId); + if (!pair) return; + const sfuId = this.getSfuId(callId); + this.send({ + ...this.buildCommon(callId, 'WSJoin', pair), + ...(sfuId && { sfu_id: sfuId }), + event_type: 'completed', + outcome: 'success', + retry_count_attempt: pair.attempts - 1, + elapsed_time: Date.now() - pair.startedAt, + }); + this.wsPairs.delete(callId); + }; + + private failWs = (callId: string) => { + const pair = this.wsPairs.get(callId); + if (!pair || !pair.lastError) { + this.wsPairs.delete(callId); + return; + } + const { reason, code } = pair.lastError; + const sfuId = this.getSfuId(callId); + this.send({ + ...this.buildCommon(callId, 'WSJoin', pair), + event_type: 'completed', + outcome: 'failure', + retry_count_attempt: pair.attempts - 1, + elapsed_time: Date.now() - pair.startedAt, + ...(sfuId && { sfu_id: sfuId }), + retry_failure_reason: reason, + retry_failure_code: code, + }); + this.wsPairs.delete(callId); + }; + + onPeerConnectionStateChange = ( + callId: string, + event: PeerConnectionStateChangeEvent, + ) => { const role: ClientEventPeerConnection = event.peerType === PeerType.SUBSCRIBER ? 'subscribe' : 'publish'; if (event.iceConnectionState === 'failed') { this.emitPeerConnectionFailure( + callId, role, 'ICE_CONNECTIVITY_FAILED', 'ICE connectivity checks failed', @@ -229,6 +458,7 @@ export class ClientEventReporter { if (event.peerConnectionState === 'failed') { this.emitPeerConnectionFailure( + callId, role, 'DTLS_CONNECTIVITY_FAILED', 'DTLS connectivity checks failed', @@ -239,15 +469,15 @@ export class ClientEventReporter { switch (event.iceConnectionState) { case 'checking': - this.openOrSupersedePeerConnectionPair(role, { + this.openOrSupersedePeerConnectionPair(callId, role, { sfuId: event.sfuId, userSessionId: event.userSessionId, }); break; case 'connected': case 'completed': - this.emitPeerConnectionSuccess(role); - this.pcEverConnected[role] = true; + this.emitPeerConnectionSuccess(callId, role); + this.pcEverConnected.set(pcKey(callId, role), true); break; default: break; @@ -255,11 +485,14 @@ export class ClientEventReporter { }; private openOrSupersedePeerConnectionPair = ( + callId: string, role: ClientEventPeerConnection, ctx: { sfuId: string; userSessionId: string }, ) => { - if (this.peerConnectionPairs[role]) { + const key = pcKey(callId, role); + if (this.peerConnectionPairs.get(key)) { this.emitPeerConnectionFailure( + callId, role, 'ICE_CONNECTIVITY_FAILED', 'Superseded by new ICE attempt', @@ -268,26 +501,25 @@ export class ClientEventReporter { } const pcContext: PeerConnectionContext = { - sfuId: ctx.sfuId, + sfuId: ctx.sfuId || this.getSfuId(callId), userSessionId: ctx.userSessionId, - wasPreviouslyConnected: this.pcEverConnected[role], + wasPreviouslyConnected: this.pcEverConnected.get(key) === true, }; - this.peerConnectionContexts[role] = pcContext; - this.peerConnectionPairs[role] = { + const pair: StagePairState = { sid: generateUUIDv4(), attempts: 0, startedAt: Date.now(), - joinAttemptIdSnapshot: this.joinAttemptId, + joinAttemptIdSnapshot: this.joinAttemptIds.get(callId), }; + this.peerConnectionContexts.set(key, pcContext); + this.peerConnectionPairs.set(key, pair); this.send({ - ...this.buildCommon( - 'PeerConnectionConnect', - this.peerConnectionPairs[role]!, - ), + ...this.buildCommon(callId, 'PeerConnectionConnect', pair), peer_connection: role, was_previously_connected: pcContext.wasPreviouslyConnected, + ...(pcContext.sfuId && { sfu_id: pcContext.sfuId }), ...(pcContext.userSessionId && { user_session_id: pcContext.userSessionId, }), @@ -295,15 +527,20 @@ export class ClientEventReporter { }); }; - private emitPeerConnectionSuccess = (role: ClientEventPeerConnection) => { - const pair = this.peerConnectionPairs[role]; - const pcContext = this.peerConnectionContexts[role]; + private emitPeerConnectionSuccess = ( + callId: string, + role: ClientEventPeerConnection, + ) => { + const key = pcKey(callId, role); + const pair = this.peerConnectionPairs.get(key); + const pcContext = this.peerConnectionContexts.get(key); if (!pair || !pcContext) return; this.send({ - ...this.buildCommon('PeerConnectionConnect', pair), + ...this.buildCommon(callId, 'PeerConnectionConnect', pair), peer_connection: role, was_previously_connected: pcContext.wasPreviouslyConnected, + ...(pcContext.sfuId && { sfu_id: pcContext.sfuId }), ...(pcContext.userSessionId && { user_session_id: pcContext.userSessionId, }), @@ -312,18 +549,20 @@ export class ClientEventReporter { retry_count_attempt: 0, elapsed_time: Date.now() - pair.startedAt, }); - delete this.peerConnectionPairs[role]; - delete this.peerConnectionContexts[role]; + this.peerConnectionPairs.delete(key); + this.peerConnectionContexts.delete(key); }; private emitPeerConnectionFailure = ( + callId: string, role: ClientEventPeerConnection, code: ClientEventStandardCode, reason: string, iceState: 'CONNECTED' | 'FAILED' | 'NOT_CONNECTED', ) => { - const pair = this.peerConnectionPairs[role]; - const pcContext = this.peerConnectionContexts[role]; + const key = pcKey(callId, role); + const pair = this.peerConnectionPairs.get(key); + const pcContext = this.peerConnectionContexts.get(key); if (!pair || !pcContext) return; applyError(pair, { reason, code, severity: SEVERITY.SERVER }); @@ -331,7 +570,7 @@ export class ClientEventReporter { const finalCode = pair.lastError?.code ?? code; this.send({ - ...this.buildCommon('PeerConnectionConnect', pair), + ...this.buildCommon(callId, 'PeerConnectionConnect', pair), peer_connection: role, was_previously_connected: pcContext.wasPreviouslyConnected, ...(pcContext.userSessionId && { @@ -346,152 +585,36 @@ export class ClientEventReporter { retry_failure_reason: finalReason, retry_failure_code: finalCode, }); - delete this.peerConnectionPairs[role]; - delete this.peerConnectionContexts[role]; + this.peerConnectionPairs.delete(key); + this.peerConnectionContexts.delete(key); }; - private beginAttempt = (stage: 'CoordinatorJoin' | 'WSJoin') => { - if (stage === 'CoordinatorJoin') { - this.beginCoordinatorAttempt(); - } else { - this.beginWsAttempt(); - } - }; - - private succeedAttempt = (stage: 'CoordinatorJoin' | 'WSJoin') => { - if (stage === 'CoordinatorJoin') { - this.succeedCoordinator(); - } else { - this.succeedWs(); - } - }; - - private applyStageError = ( - stage: 'CoordinatorJoin' | 'WSJoin', - err: unknown, - ) => { - if (stage === 'CoordinatorJoin') { - applyError(this.coordinatorPair, mapHttpError(err)); - } else { - applyError(this.wsPair, mapWsJoinError(err)); - } - }; - - private beginCoordinatorAttempt = () => { - if (!this.coordinatorPair) { - this.coordinatorPair = { - sid: generateUUIDv4(), - attempts: 0, - startedAt: Date.now(), - joinAttemptIdSnapshot: this.joinAttemptId, - }; - - this.send({ - ...this.buildCommon('CoordinatorJoin', this.coordinatorPair), - event_type: 'initiated', - }); - } - this.coordinatorPair.attempts++; - }; - - private succeedCoordinator = () => { - const pair = this.coordinatorPair; - if (!pair) return; - this.send({ - ...this.buildCommon('CoordinatorJoin', pair), - event_type: 'completed', - outcome: 'success', - retry_count_attempt: pair.attempts - 1, - elapsed_time: Date.now() - pair.startedAt, - }); - this.coordinatorPair = undefined; - }; - - private failCoordinator = () => { - const pair = this.coordinatorPair; - if (!pair || !pair.lastError) { - this.coordinatorPair = undefined; - return; - } - const { reason, code } = pair.lastError; - this.send({ - ...this.buildCommon('CoordinatorJoin', pair), - event_type: 'completed', - outcome: 'failure', - retry_count_attempt: pair.attempts - 1, - elapsed_time: Date.now() - pair.startedAt, - retry_failure_reason: reason, - retry_failure_code: code, - }); - this.coordinatorPair = undefined; - }; - - private beginWsAttempt = () => { - if (!this.wsPair) { - this.wsPair = { - sid: generateUUIDv4(), - attempts: 0, - startedAt: Date.now(), - joinAttemptIdSnapshot: this.joinAttemptId, - }; - this.send({ - ...this.buildCommon('WSJoin', this.wsPair), - event_type: 'initiated', - }); - } - this.wsPair.attempts++; - }; - - private succeedWs = () => { - const pair = this.wsPair; - if (!pair) return; - this.send({ - ...this.buildCommon('WSJoin', pair), - event_type: 'completed', - outcome: 'success', - retry_count_attempt: pair.attempts - 1, - elapsed_time: Date.now() - pair.startedAt, - }); - this.wsPair = undefined; - }; - - private failWs = () => { - const pair = this.wsPair; - if (!pair || !pair.lastError) { - this.wsPair = undefined; - return; - } - const { reason, code } = pair.lastError; - const sfuId = this.getSfuId(); - this.send({ - ...this.buildCommon('WSJoin', pair), - event_type: 'completed', - outcome: 'failure', - retry_count_attempt: pair.attempts - 1, - elapsed_time: Date.now() - pair.startedAt, - ...(sfuId && { sfu_id: sfuId }), - retry_failure_reason: reason, - retry_failure_code: code, - }); - this.wsPair = undefined; - }; + private getSfuId = (callId: string): string => + this.callContexts.get(callId)?.getSfuId() ?? ''; private buildCommon = ( + callId: string, stage: ClientEventStage, pair: StagePairState, ): Record => { - const callSessionId = this.getCallSessionId(); + const ctx = this.callContexts.get(callId); + const callType = ctx?.callType ?? ''; + const callSessionId = ctx?.getCallSessionId() ?? ''; + const coordinatorConnectId = this.getCoordinatorConnectId(); return { user_id: this.getUserId(), - type: this.callType, - id: this.callId, - call_cid: `${this.callType}:${this.callId}`, + type: callType, + id: callId, + call_cid: `${callType}:${callId}`, stage, event_session_id: pair.sid, ...(callSessionId && { call_session_id: callSessionId }), ...(pair.joinAttemptIdSnapshot && { join_attempt_id: pair.joinAttemptIdSnapshot, }), + ...(coordinatorConnectId && { + coordinator_connect_id: coordinatorConnectId, + }), timestamp: new Date().toISOString(), user_agent: this.userAgent, sdk_version: this.sdkVersion, diff --git a/packages/client/src/stats/__tests__/ClientEventReporter.test.ts b/packages/client/src/stats/__tests__/ClientEventReporter.test.ts deleted file mode 100644 index df52d25c3a..0000000000 --- a/packages/client/src/stats/__tests__/ClientEventReporter.test.ts +++ /dev/null @@ -1,439 +0,0 @@ -import { describe, expect, it, vi } from 'vitest'; -import { ClientEventReporter } from '../ClientEventReporter'; -import type { PeerConnectionStateChangeEvent } from '../../rtc'; -import { PeerType } from '../../gen/video/sfu/models/models'; -import type { StreamClient } from '../../coordinator/connection/client'; - -const makeReporter = () => { - const post = vi.fn().mockResolvedValue({}); - const streamClient = { post } as unknown as StreamClient; - const reporter = new ClientEventReporter({ - streamClient, - callType: 'default', - callId: 'call-id', - getUserId: () => 'user-1', - getCallSessionId: () => 'session-1', - getSfuId: () => 'sfu-1', - sdkVersion: '1.0.0', - userAgent: 'test-agent', - }); - - const allEvents = (): Record[] => - post.mock.calls.map( - (c) => (c[1] as { events: Record[] }).events[0], - ); - // staged events only — excludes the cross-cutting `JoinInitiated` event so - // stage-based index/count assertions stay stable. - const events = (): Record[] => - allEvents().filter((e) => e.stage !== 'JoinInitiated'); - return { reporter, post, events, allEvents }; -}; - -const pcEvent = ( - overrides: Partial, -): PeerConnectionStateChangeEvent => ({ - peerType: PeerType.PUBLISHER_UNSPECIFIED, - iceConnectionState: 'new', - peerConnectionState: 'new', - sfuId: 'sfu-1', - userSessionId: 'user-session-1', - ...overrides, -}); - -describe('ClientEventReporter', () => { - describe('CoordinatorJoin', () => { - it('emits initiated then completed:success on first success', async () => { - const { reporter, events } = makeReporter(); - await reporter.withJoinLifecycle(() => - reporter.track('CoordinatorJoin', () => Promise.resolve('ok')), - ); - const ev = events(); - expect(ev).toHaveLength(2); - expect(ev[0].stage).toBe('CoordinatorJoin'); - expect(ev[0].event_type).toBe('initiated'); - expect(ev[1].event_type).toBe('completed'); - expect(ev[1].outcome).toBe('success'); - expect(ev[1].retry_count_attempt).toBe(0); - expect(ev[0].event_session_id).toBe(ev[1].event_session_id); - expect(ev[0].join_attempt_id).toBe(ev[1].join_attempt_id); - }); - - it('folds retries into one pair with retry_count_attempt', async () => { - const { reporter, events } = makeReporter(); - await reporter.withJoinLifecycle(async () => { - await reporter - .track('CoordinatorJoin', () => Promise.reject(new Error('500'))) - .catch(() => {}); - await reporter - .track('CoordinatorJoin', () => Promise.reject(new Error('500'))) - .catch(() => {}); - await reporter.track('CoordinatorJoin', () => Promise.resolve('ok')); - }); - const ev = events(); - expect(ev).toHaveLength(2); - expect(ev[0].event_type).toBe('initiated'); - expect(ev[1].event_type).toBe('completed'); - expect(ev[1].outcome).toBe('success'); - expect(ev[1].retry_count_attempt).toBe(2); - }); - - it('emits completed:failure with HTTP_{status} when lifecycle throws after retries', async () => { - const { reporter, events } = makeReporter(); - const httpError = Object.assign(new Error('500'), { - response: { status: 503 }, - }); - await expect( - reporter.withJoinLifecycle(async () => { - await reporter - .track('CoordinatorJoin', () => Promise.reject(httpError)) - .catch(() => {}); - throw new Error('exhausted'); - }), - ).rejects.toThrow('exhausted'); - const ev = events(); - expect(ev).toHaveLength(2); - expect(ev[1].outcome).toBe('failure'); - expect(ev[1].retry_failure_code).toBe('HTTP_503'); - expect(typeof ev[1].retry_failure_reason).toBe('string'); - }); - - it('emits REQUEST_TIMEOUT only for actual timeouts', async () => { - const { reporter, events } = makeReporter(); - const timeoutError = Object.assign( - new Error('timeout of 5000ms exceeded'), - { - code: 'ECONNABORTED', - }, - ); - await expect( - reporter.withJoinLifecycle(async () => { - await reporter - .track('CoordinatorJoin', () => Promise.reject(timeoutError)) - .catch(() => {}); - throw new Error('exhausted'); - }), - ).rejects.toThrow('exhausted'); - const ev = events(); - expect(ev[1].retry_failure_code).toBe('REQUEST_TIMEOUT'); - }); - }); - - describe('WSJoin', () => { - it('emits initiated + completed:success', async () => { - const { reporter, events } = makeReporter(); - await reporter.withJoinLifecycle(() => - reporter.track('WSJoin', () => Promise.resolve('ok')), - ); - const ev = events(); - expect(ev).toHaveLength(2); - expect(ev[0].stage).toBe('WSJoin'); - expect(ev[1].outcome).toBe('success'); - }); - - it('captureWsError applies sticky-priority before close', async () => { - const { reporter, events } = makeReporter(); - await expect( - reporter.withJoinLifecycle(async () => { - await reporter - .track('WSJoin', () => Promise.reject(new Error('timeout'))) - .catch(() => {}); - reporter.captureWsError({ - code: 'UNAUTHENTICATED', - reason: 'SFU rejected token', - }); - throw new Error('exhausted'); - }), - ).rejects.toThrow('exhausted'); - const ev = events(); - expect(ev[1].outcome).toBe('failure'); - expect(ev[1].retry_failure_code).toBe('UNAUTHENTICATED'); - expect(ev[1].retry_failure_reason).toBe('SFU rejected token'); - expect(ev[1].sfu_id).toBe('sfu-1'); - }); - }); - - describe('join_attempt_id correlation', () => { - it('shares join_attempt_id across all pairs in one lifecycle', async () => { - const { reporter, events } = makeReporter(); - await reporter.withJoinLifecycle(async () => { - await reporter.track('CoordinatorJoin', () => Promise.resolve('ok')); - await reporter.track('WSJoin', () => Promise.resolve('ok')); - }); - const ev = events(); - const ids = new Set(ev.map((e) => e.join_attempt_id)); - expect(ids.size).toBe(1); - }); - - it('rotates join_attempt_id on a fresh startCorrelation', async () => { - const { reporter, events } = makeReporter(); - await reporter.withJoinLifecycle(() => - reporter.track('CoordinatorJoin', () => Promise.resolve('a')), - ); - await reporter.withJoinLifecycle(() => - reporter.track('CoordinatorJoin', () => Promise.resolve('b')), - ); - const ev = events(); - expect(ev[0].join_attempt_id).not.toBe(ev[2].join_attempt_id); - }); - - it('snapshotted join_attempt_id: completed carries the same join_attempt_id as initiated even if rotated mid-flight', async () => { - const { reporter, events } = makeReporter(); - await reporter.withJoinLifecycle(async () => { - await reporter - .track('WSJoin', () => Promise.reject(new Error('fail'))) - .catch(() => {}); - // simulate explicit migrate boundary that rotates join_attempt_id - reporter.startCorrelation(); - await reporter.track('WSJoin', () => Promise.resolve('ok')); - }); - const ev = events(); - // first ws pair (initiated + failure) under join_attempt_id-A - const firstPair = ev.filter( - (e) => e.event_session_id === ev[0].event_session_id, - ); - expect(firstPair).toHaveLength(2); - expect(firstPair[0].join_attempt_id).toBe(firstPair[1].join_attempt_id); - // second ws pair under join_attempt_id-B - const secondInitiated = ev.find( - (e) => - e.event_type === 'initiated' && - e.event_session_id !== ev[0].event_session_id, - ); - expect(secondInitiated?.join_attempt_id).not.toBe( - firstPair[0].join_attempt_id, - ); - }); - }); - - describe('PeerConnectionConnect', () => { - it('opens pair on iceConnectionState=checking and closes as success on connected', () => { - const { reporter, events } = makeReporter(); - reporter.onPeerConnectionStateChange( - pcEvent({ iceConnectionState: 'checking' }), - ); - reporter.onPeerConnectionStateChange( - pcEvent({ iceConnectionState: 'connected' }), - ); - const ev = events(); - expect(ev).toHaveLength(2); - expect(ev[0].stage).toBe('PeerConnectionConnect'); - expect(ev[0].peer_connection).toBe('publish'); - expect(ev[0].event_type).toBe('initiated'); - expect(ev[0].was_previously_connected).toBe(false); - expect(ev[1].outcome).toBe('success'); - expect(ev[1].was_previously_connected).toBe(false); - expect(ev[0].event_session_id).toBe(ev[1].event_session_id); - }); - - it('emits ICE_CONNECTIVITY_FAILED on iceConnectionState=failed', () => { - const { reporter, events } = makeReporter(); - reporter.onPeerConnectionStateChange( - pcEvent({ iceConnectionState: 'checking' }), - ); - reporter.onPeerConnectionStateChange( - pcEvent({ iceConnectionState: 'failed' }), - ); - const ev = events(); - expect(ev[1].outcome).toBe('failure'); - expect(ev[1].retry_failure_code).toBe('ICE_CONNECTIVITY_FAILED'); - expect(ev[1].ice_state).toBe('FAILED'); - }); - - it('emits DTLS_CONNECTIVITY_FAILED when peerConnectionState=failed and ICE is connected', () => { - const { reporter, events } = makeReporter(); - reporter.onPeerConnectionStateChange( - pcEvent({ iceConnectionState: 'checking' }), - ); - reporter.onPeerConnectionStateChange( - pcEvent({ - iceConnectionState: 'connected', - peerConnectionState: 'failed', - }), - ); - const ev = events(); - expect(ev[1].outcome).toBe('failure'); - expect(ev[1].retry_failure_code).toBe('DTLS_CONNECTIVITY_FAILED'); - }); - - it('prioritizes ICE failure over DTLS when both states are failed', () => { - const { reporter, events } = makeReporter(); - reporter.onPeerConnectionStateChange( - pcEvent({ iceConnectionState: 'checking' }), - ); - reporter.onPeerConnectionStateChange( - pcEvent({ - iceConnectionState: 'failed', - peerConnectionState: 'failed', - }), - ); - const ev = events(); - expect(ev[1].outcome).toBe('failure'); - expect(ev[1].retry_failure_code).toBe('ICE_CONNECTIVITY_FAILED'); - expect(ev[1].ice_state).toBe('FAILED'); - }); - - it('supersedes an open pair when a second checking arrives', () => { - const { reporter, events } = makeReporter(); - reporter.onPeerConnectionStateChange( - pcEvent({ iceConnectionState: 'checking' }), - ); - reporter.onPeerConnectionStateChange( - pcEvent({ iceConnectionState: 'checking' }), - ); - const ev = events(); - // pair 1: init + supersede-failure - // pair 2: init - expect(ev).toHaveLength(3); - expect(ev[1].outcome).toBe('failure'); - expect(ev[1].retry_failure_reason).toBe('Superseded by new ICE attempt'); - expect(ev[1].event_session_id).toBe(ev[0].event_session_id); - expect(ev[2].event_session_id).not.toBe(ev[0].event_session_id); - expect(ev[2].event_type).toBe('initiated'); - }); - - it('sets was_previously_connected:true on a reconnect after first success', () => { - const { reporter, events } = makeReporter(); - reporter.onPeerConnectionStateChange( - pcEvent({ iceConnectionState: 'checking' }), - ); - reporter.onPeerConnectionStateChange( - pcEvent({ iceConnectionState: 'connected' }), - ); - reporter.onPeerConnectionStateChange( - pcEvent({ iceConnectionState: 'checking' }), - ); - const ev = events(); - const reconnectInit = ev[2]; - expect(reconnectInit.event_type).toBe('initiated'); - expect(reconnectInit.was_previously_connected).toBe(true); - }); - - it('produces independent pairs per role (publish vs subscribe)', () => { - const { reporter, events } = makeReporter(); - reporter.onPeerConnectionStateChange( - pcEvent({ - peerType: PeerType.PUBLISHER_UNSPECIFIED, - iceConnectionState: 'checking', - }), - ); - reporter.onPeerConnectionStateChange( - pcEvent({ - peerType: PeerType.SUBSCRIBER, - iceConnectionState: 'checking', - }), - ); - const ev = events(); - expect(ev).toHaveLength(2); - const roles = ev.map((e) => e.peer_connection); - expect(roles).toContain('publish'); - expect(roles).toContain('subscribe'); - expect(ev[0].event_session_id).not.toBe(ev[1].event_session_id); - }); - }); - - describe('abort', () => { - it('closes open coord/WS pairs with CLIENT_ABORTED', async () => { - const { reporter, events } = makeReporter(); - reporter.startCorrelation(); - await reporter - .track('WSJoin', () => Promise.reject(new Error('mid-flight'))) - .catch(() => {}); - reporter.abort({ code: 'CLIENT_ABORTED', reason: 'user left' }); - const ev = events(); - const completed = ev.find( - (e) => e.stage === 'WSJoin' && e.event_type === 'completed', - ); - // sticky-priority: TRANSPORT(track's REQUEST_TIMEOUT) wins over CLIENT(abort) for the code - // but the captured WS error is the one emitted - expect(completed?.outcome).toBe('failure'); - }); - - it('closes open PC pairs as NOT_CONNECTED', () => { - const { reporter, events } = makeReporter(); - reporter.onPeerConnectionStateChange( - pcEvent({ iceConnectionState: 'checking' }), - ); - reporter.abort({ code: 'BACKEND_LEAVE', reason: 'call ended' }); - const ev = events(); - const completed = ev.find( - (e) => - e.stage === 'PeerConnectionConnect' && e.event_type === 'completed', - ); - expect(completed?.outcome).toBe('failure'); - expect(completed?.retry_failure_code).toBe('BACKEND_LEAVE'); - expect(completed?.ice_state).toBe('NOT_CONNECTED'); - }); - }); - - describe('JoinInitiated', () => { - it('emits one JoinInitiated when a correlation starts, carrying the attempt id', () => { - const { reporter, allEvents } = makeReporter(); - reporter.startCorrelation(); - const init = allEvents().filter((e) => e.stage === 'JoinInitiated'); - expect(init).toHaveLength(1); - expect(init[0].event_type).toBe('initiated'); - expect(init[0].user_id).toBe('user-1'); - expect(typeof init[0].join_attempt_id).toBe('string'); - // no per-stage session id and no call identifiers on JoinInitiated - expect(init[0].event_session_id).toBeUndefined(); - expect(init[0].call_cid).toBeUndefined(); - }); - - it('emits a fresh JoinInitiated (new attempt id) on each correlation', () => { - const { reporter, allEvents } = makeReporter(); - reporter.startCorrelation(); - reporter.startCorrelation(); - const init = allEvents().filter((e) => e.stage === 'JoinInitiated'); - expect(init).toHaveLength(2); - expect(init[0].join_attempt_id).not.toBe(init[1].join_attempt_id); - }); - }); - - describe('dispose', () => { - it('stops emitting after dispose', () => { - const { reporter, post } = makeReporter(); - reporter.dispose(); - reporter.startCorrelation(); - reporter.onPeerConnectionStateChange( - pcEvent({ iceConnectionState: 'checking' }), - ); - expect(post).not.toHaveBeenCalled(); - }); - }); - - describe('isolation invariant', () => { - it("does not block the caller's promise on transport failure", async () => { - const slowPost = vi.fn( - () => new Promise(() => {}), // never resolves - ); - const streamClient = { post: slowPost } as unknown as StreamClient; - const reporter = new ClientEventReporter({ - streamClient, - callType: 'default', - callId: 'call-id', - getUserId: () => 'u', - getCallSessionId: () => '', - getSfuId: () => '', - sdkVersion: '1', - userAgent: 't', - }); - const start = Date.now(); - await reporter.withJoinLifecycle(() => - reporter.track('CoordinatorJoin', () => Promise.resolve('ok')), - ); - const elapsed = Date.now() - start; - expect(elapsed).toBeLessThan(50); - expect(slowPost).toHaveBeenCalled(); - }); - - it('rethrows the op error verbatim', async () => { - const { reporter } = makeReporter(); - const opErr = new Error('original error'); - await expect( - reporter.withJoinLifecycle(() => - reporter.track('CoordinatorJoin', () => Promise.reject(opErr)), - ), - ).rejects.toBe(opErr); - }); - }); -}); From b4c15183a7609f76c0c6175bdd753b38c7c0008d Mon Sep 17 00:00:00 2001 From: jonadimovska Date: Mon, 1 Jun 2026 23:30:42 +0200 Subject: [PATCH 10/65] feat(client): client call event reporting - report timeout correctly --- packages/client/src/Call.ts | 16 +++++++++------- 1 file changed, 9 insertions(+), 7 deletions(-) diff --git a/packages/client/src/Call.ts b/packages/client/src/Call.ts index 17df50c1b9..89d9ea1c94 100644 --- a/packages/client/src/Call.ts +++ b/packages/client/src/Call.ts @@ -1589,13 +1589,15 @@ export class Call { doJoinRequest = async (data?: JoinCallData): Promise => { const location = await this.streamClient.getLocationHint(); const request: JoinCallRequest = { ...data, location }; - const joinResponse = await this.trackCoordinatorJoin(() => - this.streamClient.post( - `${this.streamClientBasePath}/join`, - request, - ), - ); - this.state.updateFromCallResponse(joinResponse.call); + const joinResponse = await this.trackCoordinatorJoin(async () => { + const response = await this.streamClient.post< + JoinCallResponse, + JoinCallRequest + >(`${this.streamClientBasePath}/join`, request); + + this.state.updateFromCallResponse(response.call); + return response; + }); this.state.setMembers(joinResponse.members); this.state.setOwnCapabilities(joinResponse.own_capabilities); From 017fac923ed0e933c7cbd7c61e3a945912ab7b1f Mon Sep 17 00:00:00 2001 From: jonadimovska Date: Tue, 2 Jun 2026 10:26:24 +0200 Subject: [PATCH 11/65] feat(client): client call event reporting - report timeout correctly --- .../src/coordinator/connection/connection.ts | 10 ---------- packages/client/src/stats/ClientEventReporter.ts | 16 ++++------------ 2 files changed, 4 insertions(+), 22 deletions(-) diff --git a/packages/client/src/coordinator/connection/connection.ts b/packages/client/src/coordinator/connection/connection.ts index f6b9ba504a..9a46729f83 100644 --- a/packages/client/src/coordinator/connection/connection.ts +++ b/packages/client/src/coordinator/connection/connection.ts @@ -52,7 +52,6 @@ export class StableWSConnection { isDisconnected = false; /** Boolean that indicates if we have a working connection to the server */ isHealthy = false; - private hasEverBeenHealthy = false; // Open-connection promise: resolves on `connection.ok`, rejects on close/error. connectionID?: string; @@ -656,15 +655,6 @@ export class StableWSConnection { this.isHealthy = healthy; - if (healthy) { - if (this.hasEverBeenHealthy) { - this.client.clientEventReporter.reportCoordinatorWsReconnectCompleted(); - } - this.hasEverBeenHealthy = true; - } else if (this.hasEverBeenHealthy) { - this.client.clientEventReporter.reportCoordinatorWsReconnectInitiated(); - } - if (this.isHealthy || dispatchImmediately) { this.client.dispatchEvent({ type: 'connection.changed', diff --git a/packages/client/src/stats/ClientEventReporter.ts b/packages/client/src/stats/ClientEventReporter.ts index 65da954632..91c7b4c0db 100644 --- a/packages/client/src/stats/ClientEventReporter.ts +++ b/packages/client/src/stats/ClientEventReporter.ts @@ -95,10 +95,6 @@ export class ClientEventReporter { this.userAgent = options.userAgent; } - dispose = () => { - this.disposed = true; - }; - getCoordinatorConnectId = (): string => this.coordinatorConnectId ?? ''; mintCoordinatorConnectId = (): string => { @@ -118,20 +114,13 @@ export class ClientEventReporter { } }; - reportCoordinatorWsReconnectInitiated = () => { - this.beginCoordinatorWs(); - }; - - reportCoordinatorWsReconnectCompleted = () => { - this.succeedCoordinatorWs(); - }; - closeCoordinatorWs = () => { const pair = this.coordinatorWsPair; if (!pair || !pair.lastError) { this.coordinatorWsPair = undefined; return; } + const { reason, code } = pair.lastError; this.send({ ...this.buildCoordinatorWsCommon(pair), @@ -142,6 +131,7 @@ export class ClientEventReporter { retry_failure_reason: reason, retry_failure_code: code, }); + this.coordinatorWsPair = undefined; }; @@ -196,6 +186,7 @@ export class ClientEventReporter { this.joinAttemptIds.delete(callId); this.coordinatorPairs.delete(callId); this.wsPairs.delete(callId); + for (const role of ['publish', 'subscribe'] as const) { const key = pcKey(callId, role); this.peerConnectionPairs.delete(key); @@ -262,6 +253,7 @@ export class ClientEventReporter { applyError(this.coordinatorPairs.get(callId), stageError); applyError(this.wsPairs.get(callId), stageError); + this.failCoordinator(callId); this.failWs(callId); From 7acf2d6bea8ed4abe73a37a2a1a39191dfd7b388 Mon Sep 17 00:00:00 2001 From: jonadimovska Date: Wed, 3 Jun 2026 09:30:48 +0200 Subject: [PATCH 12/65] feat(client): client call event reporting - report timeout correctly --- packages/client/src/StreamVideoClient.ts | 2 + .../src/coordinator/connection/client.ts | 22 ++++++----- .../client/src/stats/ClientEventReporter.ts | 37 ++++++++++++++----- 3 files changed, 43 insertions(+), 18 deletions(-) diff --git a/packages/client/src/StreamVideoClient.ts b/packages/client/src/StreamVideoClient.ts index 858d9bd8fe..57f602c81f 100644 --- a/packages/client/src/StreamVideoClient.ts +++ b/packages/client/src/StreamVideoClient.ts @@ -297,6 +297,7 @@ export class StreamVideoClient { } const reporter = this.streamClient.clientEventReporter; + reporter.setUserId(user.id ?? ''); reporter.mintCoordinatorConnectId(); const connectUserResponse = await withoutConcurrency( @@ -618,6 +619,7 @@ export class StreamVideoClient { user: UserWithId, tokenOrProvider: TokenOrProvider, ) => { + this.streamClient.clientEventReporter.setUserId(user.id ?? ''); return withoutConcurrency(this.connectionConcurrencyTag, () => this.streamClient.connectAnonymousUser(user, tokenOrProvider), ); diff --git a/packages/client/src/coordinator/connection/client.ts b/packages/client/src/coordinator/connection/client.ts index 14b1f404d2..9faf31b23f 100644 --- a/packages/client/src/coordinator/connection/client.ts +++ b/packages/client/src/coordinator/connection/client.ts @@ -155,7 +155,6 @@ export class StreamClient { const { clientAppIdentifier = {} } = this.options; this.clientEventReporter = new ClientEventReporter({ streamClient: this, - getUserId: () => this.user?.id ?? '', sdkVersion: clientAppIdentifier.sdkVersion ?? process.env.PKG_VERSION ?? '0.0.0', userAgent: this.getUserAgent(), @@ -481,6 +480,7 @@ export class StreamClient { options: AxiosRequestConfig & { config?: AxiosRequestConfig & { maxBodyLength?: number }; publicEndpoint?: boolean; + skipConnectionId?: boolean; } = {}, ): Promise => { if (!options.publicEndpoint) { @@ -489,14 +489,18 @@ export class StreamClient { this.guestUserCreatePromise, ]); // we need to wait for presence of connection id before making requests - try { - await this.connectionIdPromise; - } catch { - // in case connection id was rejected - // reconnection maybe in progress - // we can wait for healthy connection to resolve, which rejects when 15s timeout is reached - await this.wsConnection?._waitForHealthy(); - await this.connectionIdPromise; + // (skipped for connection-independent requests like join telemetry, which + // must be deliverable even when the coordinator WS itself failed) + if (!options.skipConnectionId) { + try { + await this.connectionIdPromise; + } catch { + // in case connection id was rejected + // reconnection maybe in progress + // we can wait for healthy connection to resolve, which rejects when 15s timeout is reached + await this.wsConnection?._waitForHealthy(); + await this.connectionIdPromise; + } } } const requestConfig = this._enrichAxiosOptions(options); diff --git a/packages/client/src/stats/ClientEventReporter.ts b/packages/client/src/stats/ClientEventReporter.ts index 91c7b4c0db..410f7366fa 100644 --- a/packages/client/src/stats/ClientEventReporter.ts +++ b/packages/client/src/stats/ClientEventReporter.ts @@ -34,7 +34,6 @@ export type CallReportContext = { export type ClientEventReporterOptions = { streamClient: StreamClient; - getUserId: () => string; sdkVersion: string; userAgent: string; }; @@ -72,9 +71,9 @@ export class ClientEventReporter { private readonly logger = videoLoggerSystem.getLogger('ClientEventReporter'); private readonly streamClient: StreamClient; - private readonly getUserId: () => string; private readonly sdkVersion: string; private readonly userAgent: string; + private userId = ''; private disposed = false; private coordinatorConnectId?: string; @@ -90,11 +89,14 @@ export class ClientEventReporter { constructor(options: ClientEventReporterOptions) { this.streamClient = options.streamClient; - this.getUserId = options.getUserId; this.sdkVersion = options.sdkVersion; this.userAgent = options.userAgent; } + setUserId = (userId: string) => { + this.userId = userId; + }; + getCoordinatorConnectId = (): string => this.coordinatorConnectId ?? ''; mintCoordinatorConnectId = (): string => { @@ -166,7 +168,7 @@ export class ClientEventReporter { private buildCoordinatorWsCommon = ( pair: StagePairState, ): Record => ({ - user_id: this.getUserId(), + user_id: this.userId, stage: 'CoordinatorWS', event_session_id: pair.sid, ...(this.coordinatorConnectId && { @@ -283,7 +285,7 @@ export class ClientEventReporter { if (!joinAttemptId) return; const coordinatorConnectId = this.getCoordinatorConnectId(); this.send({ - user_id: this.getUserId(), + user_id: this.userId, stage: 'JoinInitiated', join_attempt_id: joinAttemptId, ...(coordinatorConnectId && { @@ -347,6 +349,7 @@ export class ClientEventReporter { if (!pair) return; this.send({ ...this.buildCommon(callId, 'CoordinatorJoin', pair), + ...this.sessionIdField(callId), event_type: 'completed', outcome: 'success', retry_count_attempt: pair.attempts - 1, @@ -364,6 +367,7 @@ export class ClientEventReporter { const { reason, code } = pair.lastError; this.send({ ...this.buildCommon(callId, 'CoordinatorJoin', pair), + ...this.sessionIdField(callId), event_type: 'completed', outcome: 'failure', retry_count_attempt: pair.attempts - 1, @@ -387,6 +391,7 @@ export class ClientEventReporter { const sfuId = this.getSfuId(callId); this.send({ ...this.buildCommon(callId, 'WSJoin', pair), + ...this.sessionIdField(callId), ...(sfuId && { sfu_id: sfuId }), event_type: 'initiated', }); @@ -400,6 +405,7 @@ export class ClientEventReporter { const sfuId = this.getSfuId(callId); this.send({ ...this.buildCommon(callId, 'WSJoin', pair), + ...this.sessionIdField(callId), ...(sfuId && { sfu_id: sfuId }), event_type: 'completed', outcome: 'success', @@ -419,6 +425,7 @@ export class ClientEventReporter { const sfuId = this.getSfuId(callId); this.send({ ...this.buildCommon(callId, 'WSJoin', pair), + ...this.sessionIdField(callId), event_type: 'completed', outcome: 'failure', retry_count_attempt: pair.attempts - 1, @@ -509,6 +516,7 @@ export class ClientEventReporter { this.send({ ...this.buildCommon(callId, 'PeerConnectionConnect', pair), + ...this.sessionIdField(callId), peer_connection: role, was_previously_connected: pcContext.wasPreviouslyConnected, ...(pcContext.sfuId && { sfu_id: pcContext.sfuId }), @@ -530,6 +538,7 @@ export class ClientEventReporter { this.send({ ...this.buildCommon(callId, 'PeerConnectionConnect', pair), + ...this.sessionIdField(callId), peer_connection: role, was_previously_connected: pcContext.wasPreviouslyConnected, ...(pcContext.sfuId && { sfu_id: pcContext.sfuId }), @@ -563,6 +572,7 @@ export class ClientEventReporter { this.send({ ...this.buildCommon(callId, 'PeerConnectionConnect', pair), + ...this.sessionIdField(callId), peer_connection: role, was_previously_connected: pcContext.wasPreviouslyConnected, ...(pcContext.userSessionId && { @@ -584,6 +594,12 @@ export class ClientEventReporter { private getSfuId = (callId: string): string => this.callContexts.get(callId)?.getSfuId() ?? ''; + private sessionIdField = (callId: string): Record => { + const callSessionId = + this.callContexts.get(callId)?.getCallSessionId() ?? ''; + return callSessionId ? { call_session_id: callSessionId } : {}; + }; + private buildCommon = ( callId: string, stage: ClientEventStage, @@ -591,16 +607,14 @@ export class ClientEventReporter { ): Record => { const ctx = this.callContexts.get(callId); const callType = ctx?.callType ?? ''; - const callSessionId = ctx?.getCallSessionId() ?? ''; const coordinatorConnectId = this.getCoordinatorConnectId(); return { - user_id: this.getUserId(), + user_id: this.userId, type: callType, id: callId, call_cid: `${callType}:${callId}`, stage, event_session_id: pair.sid, - ...(callSessionId && { call_session_id: callSessionId }), ...(pair.joinAttemptIdSnapshot && { join_attempt_id: pair.joinAttemptIdSnapshot, }), @@ -623,7 +637,12 @@ export class ClientEventReporter { if (this.disposed) return; try { - await this.streamClient.post('/call_client_event', { events: [body] }); + await this.streamClient.doAxiosRequest( + 'post', + '/call_client_event', + { events: [body] }, + { skipConnectionId: true }, + ); return; } catch (err) { const status = (err as { response?: { status?: number } })?.response From 59373d54e8c035a6991e0a0df500514773515407 Mon Sep 17 00:00:00 2001 From: jonadimovska Date: Wed, 3 Jun 2026 10:51:07 +0200 Subject: [PATCH 13/65] feat(client): client call event reporting - move logic for reporting the ws --- packages/client/src/StreamVideoClient.ts | 10 +++------- .../client/src/coordinator/connection/client.ts | 7 +++++-- packages/client/src/stats/ClientEventReporter.ts | 16 +++++++++------- 3 files changed, 17 insertions(+), 16 deletions(-) diff --git a/packages/client/src/StreamVideoClient.ts b/packages/client/src/StreamVideoClient.ts index 57f602c81f..637818f5e8 100644 --- a/packages/client/src/StreamVideoClient.ts +++ b/packages/client/src/StreamVideoClient.ts @@ -297,7 +297,6 @@ export class StreamVideoClient { } const reporter = this.streamClient.clientEventReporter; - reporter.setUserId(user.id ?? ''); reporter.mintCoordinatorConnectId(); const connectUserResponse = await withoutConcurrency( @@ -313,11 +312,9 @@ export class StreamVideoClient { for (let attempt = 0; attempt < maxConnectUserRetries; attempt++) { try { this.logger.trace(`Connecting user (${attempt})`, user); - return await reporter.trackCoordinatorWs(() => - user.type === 'guest' - ? client.connectGuestUser(user) - : client.connectUser(user, tokenOrProvider), - ); + return await (user.type === 'guest' + ? client.connectGuestUser(user) + : client.connectUser(user, tokenOrProvider)); } catch (err) { this.logger.warn(`Failed to connect a user (${attempt})`, err); errorQueue.push(err as Error); @@ -619,7 +616,6 @@ export class StreamVideoClient { user: UserWithId, tokenOrProvider: TokenOrProvider, ) => { - this.streamClient.clientEventReporter.setUserId(user.id ?? ''); return withoutConcurrency(this.connectionConcurrencyTag, () => this.streamClient.connectAnonymousUser(user, tokenOrProvider), ); diff --git a/packages/client/src/coordinator/connection/client.ts b/packages/client/src/coordinator/connection/client.ts index 9faf31b23f..84b8b420eb 100644 --- a/packages/client/src/coordinator/connection/client.ts +++ b/packages/client/src/coordinator/connection/client.ts @@ -239,11 +239,14 @@ export class StreamClient { await this.tokenManager.setTokenOrProvider(tokenOrProvider, user, false); this._setUser(user); - this.connectUserTask = this.openConnection(); + const connectTask = this.openConnection(); + this.connectUserTask = connectTask; try { addConnectionEventListeners(this.updateNetworkConnectionStatus); - return await this.connectUserTask; + return await this.clientEventReporter.trackCoordinatorWs( + () => connectTask, + ); } catch (err) { if (this.persistUserOnConnectionFailure) { // cleanup client to allow the user to retry connectUser again diff --git a/packages/client/src/stats/ClientEventReporter.ts b/packages/client/src/stats/ClientEventReporter.ts index 410f7366fa..ad1a4dc070 100644 --- a/packages/client/src/stats/ClientEventReporter.ts +++ b/packages/client/src/stats/ClientEventReporter.ts @@ -55,6 +55,7 @@ type StagePairState = { attempts: number; startedAt: number; joinAttemptIdSnapshot?: string; + userIdSnapshot?: string; lastError?: StageError; }; @@ -73,7 +74,6 @@ export class ClientEventReporter { private readonly streamClient: StreamClient; private readonly sdkVersion: string; private readonly userAgent: string; - private userId = ''; private disposed = false; private coordinatorConnectId?: string; @@ -93,9 +93,7 @@ export class ClientEventReporter { this.userAgent = options.userAgent; } - setUserId = (userId: string) => { - this.userId = userId; - }; + private getUserId = (): string => this.streamClient.userID ?? ''; getCoordinatorConnectId = (): string => this.coordinatorConnectId ?? ''; @@ -143,6 +141,10 @@ export class ClientEventReporter { sid: generateUUIDv4(), attempts: 0, startedAt: Date.now(), + // snapshot the user id now: the failure event is emitted from + // StreamVideoClient after a failed connect has already cleared + // `streamClient.userID` via disconnectUser(). + userIdSnapshot: this.getUserId(), }; this.send({ ...this.buildCoordinatorWsCommon(this.coordinatorWsPair), @@ -168,7 +170,7 @@ export class ClientEventReporter { private buildCoordinatorWsCommon = ( pair: StagePairState, ): Record => ({ - user_id: this.userId, + user_id: pair.userIdSnapshot ?? this.getUserId(), stage: 'CoordinatorWS', event_session_id: pair.sid, ...(this.coordinatorConnectId && { @@ -285,7 +287,7 @@ export class ClientEventReporter { if (!joinAttemptId) return; const coordinatorConnectId = this.getCoordinatorConnectId(); this.send({ - user_id: this.userId, + user_id: this.getUserId(), stage: 'JoinInitiated', join_attempt_id: joinAttemptId, ...(coordinatorConnectId && { @@ -609,7 +611,7 @@ export class ClientEventReporter { const callType = ctx?.callType ?? ''; const coordinatorConnectId = this.getCoordinatorConnectId(); return { - user_id: this.userId, + user_id: this.getUserId(), type: callType, id: callId, call_cid: `${callType}:${callId}`, From fba439351d3fc62d0e617a7f592465c18ab379d5 Mon Sep 17 00:00:00 2001 From: jonadimovska Date: Wed, 3 Jun 2026 12:31:17 +0200 Subject: [PATCH 14/65] feat(client): client call event reporting - cleanups --- packages/client/src/coordinator/connection/client.ts | 3 --- packages/client/src/stats/ClientEventReporter.ts | 3 +++ 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/packages/client/src/coordinator/connection/client.ts b/packages/client/src/coordinator/connection/client.ts index 84b8b420eb..79c76f305c 100644 --- a/packages/client/src/coordinator/connection/client.ts +++ b/packages/client/src/coordinator/connection/client.ts @@ -189,9 +189,6 @@ export class StreamClient { _getConnectionID = () => this.wsConnection?.connectionID; - getCoordinatorConnectId = () => - this.clientEventReporter.getCoordinatorConnectId(); - _hasConnectionID = () => Boolean(this._getConnectionID()); /** diff --git a/packages/client/src/stats/ClientEventReporter.ts b/packages/client/src/stats/ClientEventReporter.ts index ad1a4dc070..6313c3b32b 100644 --- a/packages/client/src/stats/ClientEventReporter.ts +++ b/packages/client/src/stats/ClientEventReporter.ts @@ -237,6 +237,7 @@ export class ClientEventReporter { captureWsError = (callId: string, opts: { code: string; reason: string }) => { const pair = this.wsPairs.get(callId); if (!pair) return; + applyError(pair, { reason: opts.reason, code: opts.code, @@ -677,6 +678,7 @@ const errorMessage = (err: unknown): string => const isTimeout = (err: unknown): boolean => { const e = err as { code?: string; name?: string; message?: string } | null; + return ( e?.code === 'ECONNABORTED' || e?.name === 'TimeoutError' || @@ -716,5 +718,6 @@ const mapWsJoinError = (err: unknown): StageError => { severity: SEVERITY.SERVER, }; } + return mapHttpError(err); }; From 374495d60a2d475d65d763e29c6052e968980163 Mon Sep 17 00:00:00 2001 From: jonadimovska Date: Wed, 3 Jun 2026 14:54:51 +0200 Subject: [PATCH 15/65] feat(client): client call event reporting - introduce media device permissions --- packages/client/src/devices/index.ts | 1 + .../client/src/stats/ClientEventReporter.ts | 138 ++++++++++++++++++ 2 files changed, 139 insertions(+) diff --git a/packages/client/src/devices/index.ts b/packages/client/src/devices/index.ts index 387bd0a2e4..f61e138a72 100644 --- a/packages/client/src/devices/index.ts +++ b/packages/client/src/devices/index.ts @@ -1,5 +1,6 @@ export * from './utils'; export * from './devices'; +export * from './BrowserPermission'; export * from './DeviceManager'; export * from './DeviceManagerState'; export * from './CameraManager'; diff --git a/packages/client/src/stats/ClientEventReporter.ts b/packages/client/src/stats/ClientEventReporter.ts index 6313c3b32b..ef5ae01927 100644 --- a/packages/client/src/stats/ClientEventReporter.ts +++ b/packages/client/src/stats/ClientEventReporter.ts @@ -8,16 +8,31 @@ import { import { SfuJoinError } from '../errors'; import { videoLoggerSystem } from '../logger'; import type { PeerConnectionStateChangeEvent } from '../rtc'; +import { createSubscription } from '../store/rxUtils'; +import { + type BrowserPermissionState, + getAudioBrowserPermission, + getVideoBrowserPermission, +} from '../devices'; export type ClientEventPeerConnection = 'publish' | 'subscribe'; export type ClientEventStage = | 'JoinInitiated' | 'CoordinatorWS' + | 'MediaDevicePermission' | 'CoordinatorJoin' | 'WSJoin' | 'PeerConnectionConnect'; +export type MediaPermissionDevice = 'camera' | 'microphone'; + +export type MediaPermissionState = + | 'NOT_INITIATED' + | 'INITIATED' + | 'GRANTED' + | 'FAILED'; + export type ClientEventStandardCode = | 'CLIENT_ABORTED' | 'BACKEND_LEAVE' @@ -65,6 +80,16 @@ type PeerConnectionContext = { wasPreviouslyConnected: boolean; }; +const MEDIA_PERMISSION_BATCH_QUIET_MS = 500; + +type MediaPermissionSession = { + pair?: StagePairState; + callId?: string; + camera: MediaPermissionState; + microphone: MediaPermissionState; + closeTimer?: ReturnType; +}; + const pcKey = (callId: string, role: ClientEventPeerConnection): string => `${callId}:${role}`; @@ -86,6 +111,9 @@ export class ClientEventReporter { private peerConnectionPairs = new Map(); private peerConnectionContexts = new Map(); private pcEverConnected = new Map(); + private mediaPermissionSession?: MediaPermissionSession; + private mediaPermissionUnsubscribe?: () => void; + private currentCallId?: string; constructor(options: ClientEventReporterOptions) { this.streamClient = options.streamClient; @@ -181,16 +209,111 @@ export class ClientEventReporter { sdk_version: this.sdkVersion, }); + private ensureMediaPermissionWatcher = () => { + if (this.mediaPermissionUnsubscribe) return; + const unsubscribeCamera = createSubscription( + getVideoBrowserPermission().asStateObservable(), + (state) => + this.reportMediaPermission('camera', toMediaPermissionState(state)), + ); + const unsubscribeMicrophone = createSubscription( + getAudioBrowserPermission().asStateObservable(), + (state) => + this.reportMediaPermission('microphone', toMediaPermissionState(state)), + ); + this.mediaPermissionUnsubscribe = () => { + unsubscribeCamera(); + unsubscribeMicrophone(); + }; + }; + + reportMediaPermission = ( + device: MediaPermissionDevice, + state: MediaPermissionState, + ) => { + const session: MediaPermissionSession = this.mediaPermissionSession ?? { + camera: 'NOT_INITIATED', + microphone: 'NOT_INITIATED', + }; + this.mediaPermissionSession = session; + session[device] = state; + + const anyPrompting = + session.camera === 'INITIATED' || session.microphone === 'INITIATED'; + + if (anyPrompting) { + if (session.closeTimer) { + clearTimeout(session.closeTimer); + session.closeTimer = undefined; + } + if (!session.pair) { + const callId = this.currentCallId; + if (!callId) return; + + session.callId = callId; + session.pair = { + sid: generateUUIDv4(), + attempts: 0, + startedAt: Date.now(), + joinAttemptIdSnapshot: this.joinAttemptIds.get(callId), + }; + this.emitMediaPermission(session, 'initiated'); + } + } else if (session.pair && !session.closeTimer) { + session.closeTimer = setTimeout(() => { + this.emitMediaPermission(session, 'completed'); + session.pair = undefined; + session.callId = undefined; + session.closeTimer = undefined; + }, MEDIA_PERMISSION_BATCH_QUIET_MS); + } + }; + + private emitMediaPermission = ( + session: MediaPermissionSession, + eventType: 'initiated' | 'completed', + ) => { + const { pair, callId } = session; + if (!pair || !callId) return; + + const states = { + microphone: session.microphone, + camera: session.camera, + }; + if (eventType === 'initiated') { + this.send({ + ...this.buildCommon(callId, 'MediaDevicePermission', pair), + ...states, + event_type: 'initiated', + }); + } else { + this.send({ + ...this.buildCommon(callId, 'MediaDevicePermission', pair), + ...states, + event_type: 'completed', + outcome: 'success', + retry_count_attempt: 0, + elapsed_time: Date.now() - pair.startedAt, + }); + } + }; + registerCall = (callId: string, ctx: CallReportContext) => { + console.log('registerCall', callId); this.callContexts.set(callId, ctx); + this.currentCallId = callId; + this.ensureMediaPermissionWatcher(); }; unregisterCall = (callId: string) => { + console.log('unregisterCall', callId); this.callContexts.delete(callId); this.joinAttemptIds.delete(callId); this.coordinatorPairs.delete(callId); this.wsPairs.delete(callId); + if (this.currentCallId === callId) this.currentCallId = undefined; + for (const role of ['publish', 'subscribe'] as const) { const key = pcKey(callId, role); this.peerConnectionPairs.delete(key); @@ -673,6 +796,21 @@ export class ClientEventReporter { }; } +const toMediaPermissionState = ( + state: BrowserPermissionState, +): MediaPermissionState => { + switch (state) { + case 'prompting': + return 'INITIATED'; + case 'granted': + return 'GRANTED'; + case 'denied': + return 'FAILED'; + default: + return 'NOT_INITIATED'; + } +}; + const errorMessage = (err: unknown): string => err instanceof Error ? err.message : String(err); From cbd42f2a7c4e268cc9f140a7d83957f3fb85ae2e Mon Sep 17 00:00:00 2001 From: jonadimovska Date: Wed, 3 Jun 2026 15:11:14 +0200 Subject: [PATCH 16/65] feat(client): client call event reporting - introduce first frame logic --- packages/client/src/Call.ts | 7 +++ packages/client/src/rtc/BasePeerConnection.ts | 5 ++ packages/client/src/rtc/Subscriber.ts | 1 + packages/client/src/rtc/types.ts | 12 +++++ .../client/src/stats/ClientEventReporter.ts | 48 ++++++++++++++++++- 5 files changed, 71 insertions(+), 2 deletions(-) diff --git a/packages/client/src/Call.ts b/packages/client/src/Call.ts index 89d9ea1c94..a1eddcd85c 100644 --- a/packages/client/src/Call.ts +++ b/packages/client/src/Call.ts @@ -1535,6 +1535,13 @@ export class Call { event, ); }, + onRemoteTrackUnmute: (trackType, trackId) => { + this.streamClient.clientEventReporter.reportFirstFrame( + this.id, + trackType, + trackId, + ); + }, }; this.subscriber = new Subscriber(basePeerConnectionOptions); diff --git a/packages/client/src/rtc/BasePeerConnection.ts b/packages/client/src/rtc/BasePeerConnection.ts index b59a483c5f..07232fd380 100644 --- a/packages/client/src/rtc/BasePeerConnection.ts +++ b/packages/client/src/rtc/BasePeerConnection.ts @@ -18,6 +18,7 @@ import { OnIceConnected, OnPeerConnectionStateChange, OnReconnectionNeeded, + OnRemoteTrackUnmute, ReconnectReason, } from './types'; import type { ClientPublishOptions } from '../types'; @@ -39,6 +40,7 @@ export abstract class BasePeerConnection { private onReconnectionNeeded?: OnReconnectionNeeded; private onIceConnected?: OnIceConnected; private onPeerConnectionStateChange?: OnPeerConnectionStateChange; + protected onRemoteTrackUnmute?: OnRemoteTrackUnmute; private readonly iceRestartDelay: number; private iceHasEverConnected = false; private iceRestartTimeout?: NodeJS.Timeout; @@ -68,6 +70,7 @@ export abstract class BasePeerConnection { onReconnectionNeeded, onIceConnected, onPeerConnectionStateChange, + onRemoteTrackUnmute, tag, enableTracing, clientPublishOptions, @@ -84,6 +87,7 @@ export abstract class BasePeerConnection { this.onReconnectionNeeded = onReconnectionNeeded; this.onIceConnected = onIceConnected; this.onPeerConnectionStateChange = onPeerConnectionStateChange; + this.onRemoteTrackUnmute = onRemoteTrackUnmute; this.logger = videoLoggerSystem.getLogger( peerType === PeerType.SUBSCRIBER ? 'Subscriber' : 'Publisher', { tags: [tag] }, @@ -127,6 +131,7 @@ export abstract class BasePeerConnection { this.onReconnectionNeeded = undefined; this.onIceConnected = undefined; this.onPeerConnectionStateChange = undefined; + this.onRemoteTrackUnmute = undefined; this.isDisposed = true; this.detachEventHandlers(); this.pc.close(); diff --git a/packages/client/src/rtc/Subscriber.ts b/packages/client/src/rtc/Subscriber.ts index 6fdc180a9d..7cae2bf796 100644 --- a/packages/client/src/rtc/Subscriber.ts +++ b/packages/client/src/rtc/Subscriber.ts @@ -94,6 +94,7 @@ export class Subscriber extends BasePeerConnection { track.addEventListener('unmute', () => { this.logger.info(`[onTrack]: Track unmuted: ${trackDebugInfo}`); this.setRemoteTrackInterrupted(trackId, trackType, false); + this.onRemoteTrackUnmute?.(trackType, track.id); }); track.addEventListener('ended', () => { this.logger.info(`[onTrack]: Track ended: ${trackDebugInfo}`); diff --git a/packages/client/src/rtc/types.ts b/packages/client/src/rtc/types.ts index 85d964e8f1..dbcc24321d 100644 --- a/packages/client/src/rtc/types.ts +++ b/packages/client/src/rtc/types.ts @@ -2,6 +2,7 @@ import { AudioBitrateProfile, PeerType, PublishOption, + TrackType, WebsocketReconnectStrategy, } from '../gen/video/sfu/models/models'; import { StreamSfuClient } from '../StreamSfuClient'; @@ -70,6 +71,16 @@ export type OnPeerConnectionStateChange = ( event: PeerConnectionStateChangeEvent, ) => void; +/** + * Fired when a remote track starts receiving media (`unmute`). Used by + * telemetry to report the `FirstVideoFrame` / `FirstAudioFrame` stage; the + * consumer decides which track types are relevant. + */ +export type OnRemoteTrackUnmute = ( + trackType: TrackType, + trackId: string, +) => void; + export type BasePeerConnectionOpts = { sfuClient: StreamSfuClient; state: CallState; @@ -78,6 +89,7 @@ export type BasePeerConnectionOpts = { onReconnectionNeeded?: OnReconnectionNeeded; onIceConnected?: OnIceConnected; onPeerConnectionStateChange?: OnPeerConnectionStateChange; + onRemoteTrackUnmute?: OnRemoteTrackUnmute; tag: string; enableTracing: boolean; iceRestartDelay?: number; diff --git a/packages/client/src/stats/ClientEventReporter.ts b/packages/client/src/stats/ClientEventReporter.ts index ef5ae01927..e919d9cab6 100644 --- a/packages/client/src/stats/ClientEventReporter.ts +++ b/packages/client/src/stats/ClientEventReporter.ts @@ -1,4 +1,4 @@ -import { ErrorCode, PeerType } from '../gen/video/sfu/models/models'; +import { ErrorCode, PeerType, TrackType } from '../gen/video/sfu/models/models'; import type { StreamClient } from '../coordinator/connection/client'; import { generateUUIDv4, @@ -23,7 +23,9 @@ export type ClientEventStage = | 'MediaDevicePermission' | 'CoordinatorJoin' | 'WSJoin' - | 'PeerConnectionConnect'; + | 'PeerConnectionConnect' + | 'FirstVideoFrame' + | 'FirstAudioFrame'; export type MediaPermissionDevice = 'camera' | 'microphone'; @@ -114,6 +116,7 @@ export class ClientEventReporter { private mediaPermissionSession?: MediaPermissionSession; private mediaPermissionUnsubscribe?: () => void; private currentCallId?: string; + private firstFrameReported = new Set(); constructor(options: ClientEventReporterOptions) { this.streamClient = options.streamClient; @@ -298,6 +301,41 @@ export class ClientEventReporter { } }; + reportFirstFrame = ( + callId: string, + trackType: TrackType, + trackId: string, + ) => { + const stage = + trackType === TrackType.VIDEO + ? 'FirstVideoFrame' + : trackType === TrackType.AUDIO + ? 'FirstAudioFrame' + : undefined; + + if (!stage) return; + const key = `${callId}:${stage}`; + if (this.firstFrameReported.has(key)) return; + + this.firstFrameReported.add(key); + + const pair: StagePairState = { + sid: generateUUIDv4(), + attempts: 0, + startedAt: Date.now(), + joinAttemptIdSnapshot: this.joinAttemptIds.get(callId), + }; + + const sfuId = this.getSfuId(callId); + this.send({ + ...this.buildCommon(callId, stage, pair), + ...this.sessionIdField(callId), + ...(sfuId && { sfu_id: sfuId }), + track_id: trackId, + event_type: 'initiated', + }); + }; + registerCall = (callId: string, ctx: CallReportContext) => { console.log('registerCall', callId); this.callContexts.set(callId, ctx); @@ -314,6 +352,9 @@ export class ClientEventReporter { if (this.currentCallId === callId) this.currentCallId = undefined; + this.firstFrameReported.delete(`${callId}:FirstVideoFrame`); + this.firstFrameReported.delete(`${callId}:FirstAudioFrame`); + for (const role of ['publish', 'subscribe'] as const) { const key = pcKey(callId, role); this.peerConnectionPairs.delete(key); @@ -325,6 +366,9 @@ export class ClientEventReporter { startCorrelation = (callId: string) => { this.closeCallPairs(callId); this.joinAttemptIds.set(callId, generateUUIDv4()); + // a fresh attempt (e.g. full rejoin) re-reports the first frame + this.firstFrameReported.delete(`${callId}:FirstVideoFrame`); + this.firstFrameReported.delete(`${callId}:FirstAudioFrame`); this.emitJoinInitiated(callId); }; From 782e224f7c498e5e5a8e9b7fd40e8d5e799d86c8 Mon Sep 17 00:00:00 2001 From: jonadimovska Date: Wed, 3 Jun 2026 15:19:32 +0200 Subject: [PATCH 17/65] feat(client): client call event reporting - introduce first frame logic --- packages/client/src/devices/index.ts | 1 - packages/client/src/stats/ClientEventReporter.ts | 2 +- 2 files changed, 1 insertion(+), 2 deletions(-) diff --git a/packages/client/src/devices/index.ts b/packages/client/src/devices/index.ts index f61e138a72..387bd0a2e4 100644 --- a/packages/client/src/devices/index.ts +++ b/packages/client/src/devices/index.ts @@ -1,6 +1,5 @@ export * from './utils'; export * from './devices'; -export * from './BrowserPermission'; export * from './DeviceManager'; export * from './DeviceManagerState'; export * from './CameraManager'; diff --git a/packages/client/src/stats/ClientEventReporter.ts b/packages/client/src/stats/ClientEventReporter.ts index e919d9cab6..666d92311b 100644 --- a/packages/client/src/stats/ClientEventReporter.ts +++ b/packages/client/src/stats/ClientEventReporter.ts @@ -10,10 +10,10 @@ import { videoLoggerSystem } from '../logger'; import type { PeerConnectionStateChangeEvent } from '../rtc'; import { createSubscription } from '../store/rxUtils'; import { - type BrowserPermissionState, getAudioBrowserPermission, getVideoBrowserPermission, } from '../devices'; +import type { BrowserPermissionState } from '../devices/BrowserPermission'; export type ClientEventPeerConnection = 'publish' | 'subscribe'; From 990f9ff8c4865f1cb5c4e5f8e9d5997385965c9a Mon Sep 17 00:00:00 2001 From: jonadimovska Date: Wed, 3 Jun 2026 16:51:20 +0200 Subject: [PATCH 18/65] feat(client): client call event reporting - use public endpoint logic --- .../src/coordinator/connection/client.ts | 21 +++++++------------ .../client/src/stats/ClientEventReporter.ts | 6 ++---- 2 files changed, 10 insertions(+), 17 deletions(-) diff --git a/packages/client/src/coordinator/connection/client.ts b/packages/client/src/coordinator/connection/client.ts index 79c76f305c..ac61380acb 100644 --- a/packages/client/src/coordinator/connection/client.ts +++ b/packages/client/src/coordinator/connection/client.ts @@ -480,7 +480,6 @@ export class StreamClient { options: AxiosRequestConfig & { config?: AxiosRequestConfig & { maxBodyLength?: number }; publicEndpoint?: boolean; - skipConnectionId?: boolean; } = {}, ): Promise => { if (!options.publicEndpoint) { @@ -489,18 +488,14 @@ export class StreamClient { this.guestUserCreatePromise, ]); // we need to wait for presence of connection id before making requests - // (skipped for connection-independent requests like join telemetry, which - // must be deliverable even when the coordinator WS itself failed) - if (!options.skipConnectionId) { - try { - await this.connectionIdPromise; - } catch { - // in case connection id was rejected - // reconnection maybe in progress - // we can wait for healthy connection to resolve, which rejects when 15s timeout is reached - await this.wsConnection?._waitForHealthy(); - await this.connectionIdPromise; - } + try { + await this.connectionIdPromise; + } catch { + // in case connection id was rejected + // reconnection maybe in progress + // we can wait for healthy connection to resolve, which rejects when 15s timeout is reached + await this.wsConnection?._waitForHealthy(); + await this.connectionIdPromise; } } const requestConfig = this._enrichAxiosOptions(options); diff --git a/packages/client/src/stats/ClientEventReporter.ts b/packages/client/src/stats/ClientEventReporter.ts index 666d92311b..deb5ace2eb 100644 --- a/packages/client/src/stats/ClientEventReporter.ts +++ b/packages/client/src/stats/ClientEventReporter.ts @@ -172,9 +172,6 @@ export class ClientEventReporter { sid: generateUUIDv4(), attempts: 0, startedAt: Date.now(), - // snapshot the user id now: the failure event is emitted from - // StreamVideoClient after a failed connect has already cleared - // `streamClient.userID` via disconnectUser(). userIdSnapshot: this.getUserId(), }; this.send({ @@ -799,6 +796,7 @@ export class ClientEventReporter { private send = (body: Record) => { if (this.disposed) return; + void this.sendWithRetry(body); }; @@ -811,7 +809,7 @@ export class ClientEventReporter { 'post', '/call_client_event', { events: [body] }, - { skipConnectionId: true }, + { publicEndpoint: true }, ); return; } catch (err) { From 61832da53c3df42e8fdb50251b2847dc40cf45ca Mon Sep 17 00:00:00 2001 From: jonadimovska Date: Wed, 3 Jun 2026 18:09:00 +0200 Subject: [PATCH 19/65] feat(client): client call event reporting - switch key in call context map --- packages/client/src/Call.ts | 33 +++++++++++-------- .../client/src/stats/ClientEventReporter.ts | 12 +++---- 2 files changed, 26 insertions(+), 19 deletions(-) diff --git a/packages/client/src/Call.ts b/packages/client/src/Call.ts index a1eddcd85c..bc4df3eac1 100644 --- a/packages/client/src/Call.ts +++ b/packages/client/src/Call.ts @@ -424,8 +424,9 @@ export class Call { await withoutConcurrency(this.joinLeaveConcurrencyTag, async () => { if (this.initialized) return; - this.streamClient.clientEventReporter.registerCall(this.id, { + this.streamClient.clientEventReporter.registerCall(this.cid, { callType: this.type, + callId: this.id, getCallSessionId: () => this.state.session?.id ?? '', getSfuId: () => this.credentials?.server.edge_name ?? '', }); @@ -692,7 +693,7 @@ export class Call { * @internal */ reportBackendLeave = (reason: string) => { - this.streamClient.clientEventReporter.abort(this.id, { + this.streamClient.clientEventReporter.abort(this.cid, { code: 'BACKEND_LEAVE', reason, }); @@ -762,7 +763,7 @@ export class Call { this.lastStatsOptions = undefined; await this.subscriber?.dispose(); - this.streamClient.clientEventReporter.abort(this.id, { + this.streamClient.clientEventReporter.abort(this.cid, { code: 'CLIENT_ABORTED', reason: leaveReason, }); @@ -773,7 +774,7 @@ export class Call { await this.publisher?.dispose(); this.publisher = undefined; - this.streamClient.clientEventReporter.unregisterCall(this.id); + this.streamClient.clientEventReporter.unregisterCall(this.cid); await this.sfuClient?.leaveAndClose(leaveReason); this.sfuClient = undefined; @@ -1121,7 +1122,9 @@ export class Call { joinData.migrating_from_list = Array.from( sfuJoinFailures.keys(), ); - this.streamClient.clientEventReporter.startCorrelation(this.id); + this.streamClient.clientEventReporter.startCorrelation( + this.cid, + ); } } @@ -1139,13 +1142,17 @@ export class Call { }; private withJoinLifecycle = (op: () => Promise): Promise => - this.streamClient.clientEventReporter.withJoinLifecycle(this.id, op); + this.streamClient.clientEventReporter.withJoinLifecycle(this.cid, op); private trackCoordinatorJoin = (op: () => Promise): Promise => - this.streamClient.clientEventReporter.track(this.id, 'CoordinatorJoin', op); + this.streamClient.clientEventReporter.track( + this.cid, + 'CoordinatorJoin', + op, + ); private trackWsJoin = (op: () => Promise): Promise => { - return this.streamClient.clientEventReporter.track(this.id, 'WSJoin', op); + return this.streamClient.clientEventReporter.track(this.cid, 'WSJoin', op); }; /** @@ -1531,13 +1538,13 @@ export class Call { }, onPeerConnectionStateChange: (event) => { this.streamClient.clientEventReporter.onPeerConnectionStateChange( - this.id, + this.cid, event, ); }, onRemoteTrackUnmute: (trackType, trackId) => { this.streamClient.clientEventReporter.reportFirstFrame( - this.id, + this.cid, trackType, trackId, ); @@ -1978,7 +1985,7 @@ export class Call { private registerReconnectHandlers = () => { // handles the legacy "goAway" event const unregisterGoAway = this.on('goAway', () => { - this.streamClient.clientEventReporter.captureWsError(this.id, { + this.streamClient.clientEventReporter.captureWsError(this.cid, { code: 'SFU_GO_AWAY', reason: 'SFU goAway received during WS join', }); @@ -1993,7 +2000,7 @@ export class Call { const { reconnectStrategy: strategy, error } = e; if (!SfuJoinError.isJoinErrorCode(e)) { const code = error?.code ? ErrorCode[error.code] : 'REQUEST_TIMEOUT'; - this.streamClient.clientEventReporter.captureWsError(this.id, { + this.streamClient.clientEventReporter.captureWsError(this.cid, { code: code ?? 'REQUEST_TIMEOUT', reason: error?.message || 'SFU error during WS join', }); @@ -2024,7 +2031,7 @@ export class Call { this.tracer.trace('network.changed', e); if (!e.online) { this.logger.debug('[Reconnect] Going offline'); - this.streamClient.clientEventReporter.captureWsError(this.id, { + this.streamClient.clientEventReporter.captureWsError(this.cid, { code: 'NETWORK_OFFLINE', reason: 'Device offline', }); diff --git a/packages/client/src/stats/ClientEventReporter.ts b/packages/client/src/stats/ClientEventReporter.ts index deb5ace2eb..c981f51b7a 100644 --- a/packages/client/src/stats/ClientEventReporter.ts +++ b/packages/client/src/stats/ClientEventReporter.ts @@ -45,6 +45,7 @@ export type ClientEventStandardCode = export type CallReportContext = { callType: string; + callId: string; getCallSessionId: () => string; getSfuId: () => string; }; @@ -768,18 +769,17 @@ export class ClientEventReporter { }; private buildCommon = ( - callId: string, + cid: string, stage: ClientEventStage, pair: StagePairState, ): Record => { - const ctx = this.callContexts.get(callId); - const callType = ctx?.callType ?? ''; + const ctx = this.callContexts.get(cid); const coordinatorConnectId = this.getCoordinatorConnectId(); return { user_id: this.getUserId(), - type: callType, - id: callId, - call_cid: `${callType}:${callId}`, + type: ctx?.callType ?? '', + id: ctx?.callId ?? '', + call_cid: cid, stage, event_session_id: pair.sid, ...(pair.joinAttemptIdSnapshot && { From 228f71279e34d14cfdfc23d11aa5090753086f18 Mon Sep 17 00:00:00 2001 From: jonadimovska Date: Thu, 4 Jun 2026 15:43:43 +0200 Subject: [PATCH 20/65] feat(client): client call event reporting - improve device handling --- packages/client/src/devices/DeviceManager.ts | 15 ++++ .../client/src/stats/ClientEventReporter.ts | 86 ++++++++++--------- 2 files changed, 62 insertions(+), 39 deletions(-) diff --git a/packages/client/src/devices/DeviceManager.ts b/packages/client/src/devices/DeviceManager.ts index 5487932851..12331373d5 100644 --- a/packages/client/src/devices/DeviceManager.ts +++ b/packages/client/src/devices/DeviceManager.ts @@ -112,6 +112,21 @@ export abstract class DeviceManager< this.handleDisconnectedOrReplacedDevices(); } + if ( + !isReactNative() && + (this.trackType === TrackType.AUDIO || this.trackType === TrackType.VIDEO) + ) { + this.subscriptions.push( + createSubscription(this.state.browserPermissionState$, (state) => { + this.call.streamClient.clientEventReporter?.reportMediaPermission( + this.call.cid, + this.trackType, + state, + ); + }), + ); + } + if (this.devicePersistence.enabled) { this.subscriptions.push( createSubscription( diff --git a/packages/client/src/stats/ClientEventReporter.ts b/packages/client/src/stats/ClientEventReporter.ts index c981f51b7a..4dcaee39d4 100644 --- a/packages/client/src/stats/ClientEventReporter.ts +++ b/packages/client/src/stats/ClientEventReporter.ts @@ -8,11 +8,6 @@ import { import { SfuJoinError } from '../errors'; import { videoLoggerSystem } from '../logger'; import type { PeerConnectionStateChangeEvent } from '../rtc'; -import { createSubscription } from '../store/rxUtils'; -import { - getAudioBrowserPermission, - getVideoBrowserPermission, -} from '../devices'; import type { BrowserPermissionState } from '../devices/BrowserPermission'; export type ClientEventPeerConnection = 'publish' | 'subscribe'; @@ -109,14 +104,13 @@ export class ClientEventReporter { private callContexts = new Map(); private joinAttemptIds = new Map(); + private activeMediaPermissionJoinCallIds = new Set(); private coordinatorPairs = new Map(); private wsPairs = new Map(); private peerConnectionPairs = new Map(); private peerConnectionContexts = new Map(); private pcEverConnected = new Map(); private mediaPermissionSession?: MediaPermissionSession; - private mediaPermissionUnsubscribe?: () => void; - private currentCallId?: string; private firstFrameReported = new Set(); constructor(options: ClientEventReporterOptions) { @@ -210,34 +204,20 @@ export class ClientEventReporter { sdk_version: this.sdkVersion, }); - private ensureMediaPermissionWatcher = () => { - if (this.mediaPermissionUnsubscribe) return; - const unsubscribeCamera = createSubscription( - getVideoBrowserPermission().asStateObservable(), - (state) => - this.reportMediaPermission('camera', toMediaPermissionState(state)), - ); - const unsubscribeMicrophone = createSubscription( - getAudioBrowserPermission().asStateObservable(), - (state) => - this.reportMediaPermission('microphone', toMediaPermissionState(state)), - ); - this.mediaPermissionUnsubscribe = () => { - unsubscribeCamera(); - unsubscribeMicrophone(); - }; - }; - reportMediaPermission = ( - device: MediaPermissionDevice, - state: MediaPermissionState, + callId: string, + trackType: TrackType, + state: BrowserPermissionState, ) => { + const device = toMediaPermissionDevice(trackType); + if (!device) return; + const session: MediaPermissionSession = this.mediaPermissionSession ?? { camera: 'NOT_INITIATED', microphone: 'NOT_INITIATED', }; this.mediaPermissionSession = session; - session[device] = state; + session[device] = toMediaPermissionState(state); const anyPrompting = session.camera === 'INITIATED' || session.microphone === 'INITIATED'; @@ -248,15 +228,20 @@ export class ClientEventReporter { session.closeTimer = undefined; } if (!session.pair) { - const callId = this.currentCallId; - if (!callId) return; + const joinAttemptId = this.joinAttemptIds.get(callId); + if ( + !joinAttemptId || + !this.activeMediaPermissionJoinCallIds.has(callId) + ) { + return; + } session.callId = callId; session.pair = { sid: generateUUIDv4(), attempts: 0, startedAt: Date.now(), - joinAttemptIdSnapshot: this.joinAttemptIds.get(callId), + joinAttemptIdSnapshot: joinAttemptId, }; this.emitMediaPermission(session, 'initiated'); } @@ -278,8 +263,9 @@ export class ClientEventReporter { if (!pair || !callId) return; const states = { - microphone: session.microphone, - camera: session.camera, + microphone_permission_status: session.microphone, + camera_permission_status: session.camera, + screen_share_status: 'NOT_INITIATED', }; if (eventType === 'initiated') { this.send({ @@ -335,20 +321,16 @@ export class ClientEventReporter { }; registerCall = (callId: string, ctx: CallReportContext) => { - console.log('registerCall', callId); this.callContexts.set(callId, ctx); - this.currentCallId = callId; - this.ensureMediaPermissionWatcher(); }; unregisterCall = (callId: string) => { - console.log('unregisterCall', callId); this.callContexts.delete(callId); this.joinAttemptIds.delete(callId); + this.activeMediaPermissionJoinCallIds.delete(callId); this.coordinatorPairs.delete(callId); this.wsPairs.delete(callId); - - if (this.currentCallId === callId) this.currentCallId = undefined; + this.closeMediaPermissionSession(callId); this.firstFrameReported.delete(`${callId}:FirstVideoFrame`); this.firstFrameReported.delete(`${callId}:FirstAudioFrame`); @@ -364,6 +346,7 @@ export class ClientEventReporter { startCorrelation = (callId: string) => { this.closeCallPairs(callId); this.joinAttemptIds.set(callId, generateUUIDv4()); + this.activeMediaPermissionJoinCallIds.add(callId); // a fresh attempt (e.g. full rejoin) re-reports the first frame this.firstFrameReported.delete(`${callId}:FirstVideoFrame`); this.firstFrameReported.delete(`${callId}:FirstAudioFrame`); @@ -380,6 +363,8 @@ export class ClientEventReporter { } catch (err) { this.closeCallPairs(callId); throw err; + } finally { + this.activeMediaPermissionJoinCallIds.delete(callId); } }; @@ -448,6 +433,16 @@ export class ClientEventReporter { if (this.wsPairs.get(callId)) this.failWs(callId); }; + private closeMediaPermissionSession = (callId: string) => { + const session = this.mediaPermissionSession; + if (!session || session.callId !== callId) return; + + if (session.closeTimer) { + clearTimeout(session.closeTimer); + } + this.mediaPermissionSession = undefined; + }; + private emitJoinInitiated = (callId: string) => { const joinAttemptId = this.joinAttemptIds.get(callId); if (!joinAttemptId) return; @@ -838,6 +833,19 @@ export class ClientEventReporter { }; } +const toMediaPermissionDevice = ( + trackType: TrackType, +): MediaPermissionDevice | undefined => { + switch (trackType) { + case TrackType.AUDIO: + return 'microphone'; + case TrackType.VIDEO: + return 'camera'; + default: + return undefined; + } +}; + const toMediaPermissionState = ( state: BrowserPermissionState, ): MediaPermissionState => { From bf76c100ea888d3038fdfffadf3c3f7f9c6366ac Mon Sep 17 00:00:00 2001 From: jonadimovska Date: Thu, 4 Jun 2026 15:52:41 +0200 Subject: [PATCH 21/65] feat(client): client call event reporting - rename event_session_id to stage_id --- packages/client/src/stats/ClientEventReporter.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/client/src/stats/ClientEventReporter.ts b/packages/client/src/stats/ClientEventReporter.ts index 4dcaee39d4..c34b6dd019 100644 --- a/packages/client/src/stats/ClientEventReporter.ts +++ b/packages/client/src/stats/ClientEventReporter.ts @@ -195,7 +195,7 @@ export class ClientEventReporter { ): Record => ({ user_id: pair.userIdSnapshot ?? this.getUserId(), stage: 'CoordinatorWS', - event_session_id: pair.sid, + stage_id: pair.sid, ...(this.coordinatorConnectId && { coordinator_connect_id: this.coordinatorConnectId, }), @@ -776,7 +776,7 @@ export class ClientEventReporter { id: ctx?.callId ?? '', call_cid: cid, stage, - event_session_id: pair.sid, + stage_id: pair.sid, ...(pair.joinAttemptIdSnapshot && { join_attempt_id: pair.joinAttemptIdSnapshot, }), From a891af58fcad287b6690a4dab22b7f54adcd66f8 Mon Sep 17 00:00:00 2001 From: jonadimovska Date: Thu, 4 Jun 2026 19:03:33 +0200 Subject: [PATCH 22/65] feat(client): client call event reporting - simplify permission flow --- packages/client/src/devices/DeviceManager.ts | 15 -- .../devices/__tests__/CameraManager.test.ts | 4 + .../devices/__tests__/DeviceManager.test.ts | 4 + .../__tests__/MicrophoneManager.test.ts | 4 + .../devices/__tests__/SpeakerManager.test.ts | 4 + .../client/src/stats/ClientEventReporter.ts | 170 ++++-------------- 6 files changed, 48 insertions(+), 153 deletions(-) diff --git a/packages/client/src/devices/DeviceManager.ts b/packages/client/src/devices/DeviceManager.ts index 12331373d5..5487932851 100644 --- a/packages/client/src/devices/DeviceManager.ts +++ b/packages/client/src/devices/DeviceManager.ts @@ -112,21 +112,6 @@ export abstract class DeviceManager< this.handleDisconnectedOrReplacedDevices(); } - if ( - !isReactNative() && - (this.trackType === TrackType.AUDIO || this.trackType === TrackType.VIDEO) - ) { - this.subscriptions.push( - createSubscription(this.state.browserPermissionState$, (state) => { - this.call.streamClient.clientEventReporter?.reportMediaPermission( - this.call.cid, - this.trackType, - state, - ); - }), - ); - } - if (this.devicePersistence.enabled) { this.subscriptions.push( createSubscription( diff --git a/packages/client/src/devices/__tests__/CameraManager.test.ts b/packages/client/src/devices/__tests__/CameraManager.test.ts index e871914e53..f48f75470e 100644 --- a/packages/client/src/devices/__tests__/CameraManager.test.ts +++ b/packages/client/src/devices/__tests__/CameraManager.test.ts @@ -54,6 +54,10 @@ vi.mock('../../Call.ts', () => { }; }); +vi.mock('../../stats/ClientEventReporter', () => ({ + ClientEventReporter: vi.fn(() => ({})), +})); + vi.mock('../../helpers/compatibility.ts', () => { console.log('MOCKING mobile device'); return { diff --git a/packages/client/src/devices/__tests__/DeviceManager.test.ts b/packages/client/src/devices/__tests__/DeviceManager.test.ts index 6e4c282b0c..d1688ec5e6 100644 --- a/packages/client/src/devices/__tests__/DeviceManager.test.ts +++ b/packages/client/src/devices/__tests__/DeviceManager.test.ts @@ -29,6 +29,10 @@ vi.mock('../../Call.ts', () => { }; }); +vi.mock('../../stats/ClientEventReporter', () => ({ + ClientEventReporter: vi.fn(() => ({})), +})); + vi.mock('../devices.ts', () => { console.log('MOCKING devices API'); return { diff --git a/packages/client/src/devices/__tests__/MicrophoneManager.test.ts b/packages/client/src/devices/__tests__/MicrophoneManager.test.ts index cbaa5d3222..fa68e461e0 100644 --- a/packages/client/src/devices/__tests__/MicrophoneManager.test.ts +++ b/packages/client/src/devices/__tests__/MicrophoneManager.test.ts @@ -80,6 +80,10 @@ vi.mock('../../Call.ts', () => { }; }); +vi.mock('../../stats/ClientEventReporter', () => ({ + ClientEventReporter: vi.fn(() => ({})), +})); + describe('MicrophoneManager', () => { let manager: MicrophoneManager; let call: Call; diff --git a/packages/client/src/devices/__tests__/SpeakerManager.test.ts b/packages/client/src/devices/__tests__/SpeakerManager.test.ts index 181b1e0ac5..7bc42bad60 100644 --- a/packages/client/src/devices/__tests__/SpeakerManager.test.ts +++ b/packages/client/src/devices/__tests__/SpeakerManager.test.ts @@ -29,6 +29,10 @@ vi.mock('../devices.ts', () => { }; }); +vi.mock('../../stats/ClientEventReporter', () => ({ + ClientEventReporter: vi.fn(() => ({})), +})); + describe('SpeakerManager.test', () => { let manager: SpeakerManager; let storageKey: string; diff --git a/packages/client/src/stats/ClientEventReporter.ts b/packages/client/src/stats/ClientEventReporter.ts index c34b6dd019..ddbfc28cb4 100644 --- a/packages/client/src/stats/ClientEventReporter.ts +++ b/packages/client/src/stats/ClientEventReporter.ts @@ -8,7 +8,12 @@ import { import { SfuJoinError } from '../errors'; import { videoLoggerSystem } from '../logger'; import type { PeerConnectionStateChangeEvent } from '../rtc'; -import type { BrowserPermissionState } from '../devices/BrowserPermission'; +import { + getAudioBrowserPermission, + getVideoBrowserPermission, +} from '../devices'; +import type { BrowserPermission } from '../devices/BrowserPermission'; +import { getCurrentValue } from '../store/rxUtils'; export type ClientEventPeerConnection = 'publish' | 'subscribe'; @@ -22,13 +27,7 @@ export type ClientEventStage = | 'FirstVideoFrame' | 'FirstAudioFrame'; -export type MediaPermissionDevice = 'camera' | 'microphone'; - -export type MediaPermissionState = - | 'NOT_INITIATED' - | 'INITIATED' - | 'GRANTED' - | 'FAILED'; +export type MediaPermissionState = 'GRANTED' | 'NOT_GRANTED'; export type ClientEventStandardCode = | 'CLIENT_ABORTED' @@ -78,16 +77,6 @@ type PeerConnectionContext = { wasPreviouslyConnected: boolean; }; -const MEDIA_PERMISSION_BATCH_QUIET_MS = 500; - -type MediaPermissionSession = { - pair?: StagePairState; - callId?: string; - camera: MediaPermissionState; - microphone: MediaPermissionState; - closeTimer?: ReturnType; -}; - const pcKey = (callId: string, role: ClientEventPeerConnection): string => `${callId}:${role}`; @@ -104,13 +93,11 @@ export class ClientEventReporter { private callContexts = new Map(); private joinAttemptIds = new Map(); - private activeMediaPermissionJoinCallIds = new Set(); private coordinatorPairs = new Map(); private wsPairs = new Map(); private peerConnectionPairs = new Map(); private peerConnectionContexts = new Map(); private pcEverConnected = new Map(); - private mediaPermissionSession?: MediaPermissionSession; private firstFrameReported = new Set(); constructor(options: ClientEventReporterOptions) { @@ -204,85 +191,27 @@ export class ClientEventReporter { sdk_version: this.sdkVersion, }); - reportMediaPermission = ( - callId: string, - trackType: TrackType, - state: BrowserPermissionState, - ) => { - const device = toMediaPermissionDevice(trackType); - if (!device) return; + private emitMediaPermission = (callId: string) => { + if (!this.callContexts.has(callId)) return; - const session: MediaPermissionSession = this.mediaPermissionSession ?? { - camera: 'NOT_INITIATED', - microphone: 'NOT_INITIATED', + const pair: StagePairState = { + sid: generateUUIDv4(), + attempts: 0, + startedAt: Date.now(), + joinAttemptIdSnapshot: this.joinAttemptIds.get(callId), }; - this.mediaPermissionSession = session; - session[device] = toMediaPermissionState(state); - - const anyPrompting = - session.camera === 'INITIATED' || session.microphone === 'INITIATED'; - - if (anyPrompting) { - if (session.closeTimer) { - clearTimeout(session.closeTimer); - session.closeTimer = undefined; - } - if (!session.pair) { - const joinAttemptId = this.joinAttemptIds.get(callId); - if ( - !joinAttemptId || - !this.activeMediaPermissionJoinCallIds.has(callId) - ) { - return; - } - session.callId = callId; - session.pair = { - sid: generateUUIDv4(), - attempts: 0, - startedAt: Date.now(), - joinAttemptIdSnapshot: joinAttemptId, - }; - this.emitMediaPermission(session, 'initiated'); - } - } else if (session.pair && !session.closeTimer) { - session.closeTimer = setTimeout(() => { - this.emitMediaPermission(session, 'completed'); - session.pair = undefined; - session.callId = undefined; - session.closeTimer = undefined; - }, MEDIA_PERMISSION_BATCH_QUIET_MS); - } - }; - - private emitMediaPermission = ( - session: MediaPermissionSession, - eventType: 'initiated' | 'completed', - ) => { - const { pair, callId } = session; - if (!pair || !callId) return; - - const states = { - microphone_permission_status: session.microphone, - camera_permission_status: session.camera, - screen_share_status: 'NOT_INITIATED', - }; - if (eventType === 'initiated') { - this.send({ - ...this.buildCommon(callId, 'MediaDevicePermission', pair), - ...states, - event_type: 'initiated', - }); - } else { - this.send({ - ...this.buildCommon(callId, 'MediaDevicePermission', pair), - ...states, - event_type: 'completed', - outcome: 'success', - retry_count_attempt: 0, - elapsed_time: Date.now() - pair.startedAt, - }); - } + this.send({ + ...this.buildCommon(callId, 'MediaDevicePermission', pair), + ...this.sessionIdField(callId), + microphone_permission_status: readPermissionStatus( + getAudioBrowserPermission(), + ), + camera_permission_status: readPermissionStatus( + getVideoBrowserPermission(), + ), + event_type: 'initiated', + }); }; reportFirstFrame = ( @@ -327,10 +256,8 @@ export class ClientEventReporter { unregisterCall = (callId: string) => { this.callContexts.delete(callId); this.joinAttemptIds.delete(callId); - this.activeMediaPermissionJoinCallIds.delete(callId); this.coordinatorPairs.delete(callId); this.wsPairs.delete(callId); - this.closeMediaPermissionSession(callId); this.firstFrameReported.delete(`${callId}:FirstVideoFrame`); this.firstFrameReported.delete(`${callId}:FirstAudioFrame`); @@ -346,11 +273,11 @@ export class ClientEventReporter { startCorrelation = (callId: string) => { this.closeCallPairs(callId); this.joinAttemptIds.set(callId, generateUUIDv4()); - this.activeMediaPermissionJoinCallIds.add(callId); // a fresh attempt (e.g. full rejoin) re-reports the first frame this.firstFrameReported.delete(`${callId}:FirstVideoFrame`); this.firstFrameReported.delete(`${callId}:FirstAudioFrame`); this.emitJoinInitiated(callId); + this.emitMediaPermission(callId); }; withJoinLifecycle = async ( @@ -363,8 +290,6 @@ export class ClientEventReporter { } catch (err) { this.closeCallPairs(callId); throw err; - } finally { - this.activeMediaPermissionJoinCallIds.delete(callId); } }; @@ -433,16 +358,6 @@ export class ClientEventReporter { if (this.wsPairs.get(callId)) this.failWs(callId); }; - private closeMediaPermissionSession = (callId: string) => { - const session = this.mediaPermissionSession; - if (!session || session.callId !== callId) return; - - if (session.closeTimer) { - clearTimeout(session.closeTimer); - } - this.mediaPermissionSession = undefined; - }; - private emitJoinInitiated = (callId: string) => { const joinAttemptId = this.joinAttemptIds.get(callId); if (!joinAttemptId) return; @@ -833,33 +748,12 @@ export class ClientEventReporter { }; } -const toMediaPermissionDevice = ( - trackType: TrackType, -): MediaPermissionDevice | undefined => { - switch (trackType) { - case TrackType.AUDIO: - return 'microphone'; - case TrackType.VIDEO: - return 'camera'; - default: - return undefined; - } -}; - -const toMediaPermissionState = ( - state: BrowserPermissionState, -): MediaPermissionState => { - switch (state) { - case 'prompting': - return 'INITIATED'; - case 'granted': - return 'GRANTED'; - case 'denied': - return 'FAILED'; - default: - return 'NOT_INITIATED'; - } -}; +const readPermissionStatus = ( + permission: BrowserPermission, +): MediaPermissionState => + getCurrentValue(permission.asStateObservable()) === 'granted' + ? 'GRANTED' + : 'NOT_GRANTED'; const errorMessage = (err: unknown): string => err instanceof Error ? err.message : String(err); From a5c3bea07a4a3f28b1e3ba46ffd0ea6a64107cb8 Mon Sep 17 00:00:00 2001 From: jonadimovska Date: Thu, 4 Jun 2026 19:14:59 +0200 Subject: [PATCH 23/65] feat(client): client call event reporting - map states correctly --- .../client/src/stats/ClientEventReporter.ts | 52 +++++++++---------- 1 file changed, 26 insertions(+), 26 deletions(-) diff --git a/packages/client/src/stats/ClientEventReporter.ts b/packages/client/src/stats/ClientEventReporter.ts index ddbfc28cb4..1c9fd14efa 100644 --- a/packages/client/src/stats/ClientEventReporter.ts +++ b/packages/client/src/stats/ClientEventReporter.ts @@ -12,13 +12,15 @@ import { getAudioBrowserPermission, getVideoBrowserPermission, } from '../devices'; -import type { BrowserPermission } from '../devices/BrowserPermission'; +import type { + BrowserPermission, + BrowserPermissionState, +} from '../devices/BrowserPermission'; import { getCurrentValue } from '../store/rxUtils'; export type ClientEventPeerConnection = 'publish' | 'subscribe'; export type ClientEventStage = - | 'JoinInitiated' | 'CoordinatorWS' | 'MediaDevicePermission' | 'CoordinatorJoin' @@ -27,7 +29,11 @@ export type ClientEventStage = | 'FirstVideoFrame' | 'FirstAudioFrame'; -export type MediaPermissionState = 'GRANTED' | 'NOT_GRANTED'; +export type MediaPermissionState = + | 'INITIATED' + | 'FAILED' + | 'GRANTED' + | 'NOT_INITIATED'; export type ClientEventStandardCode = | 'CLIENT_ABORTED' @@ -276,7 +282,6 @@ export class ClientEventReporter { // a fresh attempt (e.g. full rejoin) re-reports the first frame this.firstFrameReported.delete(`${callId}:FirstVideoFrame`); this.firstFrameReported.delete(`${callId}:FirstAudioFrame`); - this.emitJoinInitiated(callId); this.emitMediaPermission(callId); }; @@ -358,24 +363,6 @@ export class ClientEventReporter { if (this.wsPairs.get(callId)) this.failWs(callId); }; - private emitJoinInitiated = (callId: string) => { - const joinAttemptId = this.joinAttemptIds.get(callId); - if (!joinAttemptId) return; - const coordinatorConnectId = this.getCoordinatorConnectId(); - this.send({ - user_id: this.getUserId(), - stage: 'JoinInitiated', - join_attempt_id: joinAttemptId, - ...(coordinatorConnectId && { - coordinator_connect_id: coordinatorConnectId, - }), - timestamp: new Date().toISOString(), - user_agent: this.userAgent, - sdk_version: this.sdkVersion, - event_type: 'initiated', - }); - }; - private beginAttempt = ( callId: string, stage: 'CoordinatorJoin' | 'WSJoin', @@ -750,10 +737,23 @@ export class ClientEventReporter { const readPermissionStatus = ( permission: BrowserPermission, -): MediaPermissionState => - getCurrentValue(permission.asStateObservable()) === 'granted' - ? 'GRANTED' - : 'NOT_GRANTED'; +): MediaPermissionState => { + const state = getCurrentValue( + permission.asStateObservable(), + ); + + switch (state) { + case 'granted': + return 'GRANTED'; + case 'denied': + return 'FAILED'; + case 'prompting': + return 'INITIATED'; + case 'prompt': + default: + return 'NOT_INITIATED'; + } +}; const errorMessage = (err: unknown): string => err instanceof Error ? err.message : String(err); From 6ff2a8e651aff3b8c9c9d73af5792e3d88963fdb Mon Sep 17 00:00:00 2001 From: jonadimovska Date: Fri, 5 Jun 2026 09:51:52 +0200 Subject: [PATCH 24/65] feat(client): client call event reporting - fix tests --- packages/client/src/devices/__tests__/CameraManager.test.ts | 4 +++- packages/client/src/devices/__tests__/DeviceManager.test.ts | 4 +++- .../client/src/devices/__tests__/MicrophoneManager.test.ts | 4 +++- packages/client/src/devices/__tests__/SpeakerManager.test.ts | 4 +++- 4 files changed, 12 insertions(+), 4 deletions(-) diff --git a/packages/client/src/devices/__tests__/CameraManager.test.ts b/packages/client/src/devices/__tests__/CameraManager.test.ts index c5d7282ac4..abdf6c2a8a 100644 --- a/packages/client/src/devices/__tests__/CameraManager.test.ts +++ b/packages/client/src/devices/__tests__/CameraManager.test.ts @@ -57,7 +57,9 @@ vi.mock('../../Call.ts', () => { }); vi.mock('../../stats/ClientEventReporter', () => ({ - ClientEventReporter: vi.fn(() => ({})), + ClientEventReporter: vi.fn(function () { + return {}; + }), })); vi.mock('../../helpers/compatibility.ts', () => { diff --git a/packages/client/src/devices/__tests__/DeviceManager.test.ts b/packages/client/src/devices/__tests__/DeviceManager.test.ts index 6e24222016..ca40271cfb 100644 --- a/packages/client/src/devices/__tests__/DeviceManager.test.ts +++ b/packages/client/src/devices/__tests__/DeviceManager.test.ts @@ -32,7 +32,9 @@ vi.mock('../../Call.ts', () => { }); vi.mock('../../stats/ClientEventReporter', () => ({ - ClientEventReporter: vi.fn(() => ({})), + ClientEventReporter: vi.fn(function () { + return {}; + }), })); vi.mock('../devices.ts', () => { diff --git a/packages/client/src/devices/__tests__/MicrophoneManager.test.ts b/packages/client/src/devices/__tests__/MicrophoneManager.test.ts index c04375c0a9..7dac95da43 100644 --- a/packages/client/src/devices/__tests__/MicrophoneManager.test.ts +++ b/packages/client/src/devices/__tests__/MicrophoneManager.test.ts @@ -83,7 +83,9 @@ vi.mock('../../Call.ts', () => { }); vi.mock('../../stats/ClientEventReporter', () => ({ - ClientEventReporter: vi.fn(() => ({})), + ClientEventReporter: vi.fn(function () { + return {}; + }), })); describe('MicrophoneManager', () => { diff --git a/packages/client/src/devices/__tests__/SpeakerManager.test.ts b/packages/client/src/devices/__tests__/SpeakerManager.test.ts index 7bc42bad60..6b6b029312 100644 --- a/packages/client/src/devices/__tests__/SpeakerManager.test.ts +++ b/packages/client/src/devices/__tests__/SpeakerManager.test.ts @@ -30,7 +30,9 @@ vi.mock('../devices.ts', () => { }); vi.mock('../../stats/ClientEventReporter', () => ({ - ClientEventReporter: vi.fn(() => ({})), + ClientEventReporter: vi.fn(function () { + return {}; + }), })); describe('SpeakerManager.test', () => { From 5e9710d2c3db3c99ca10a8399120ff8e532f66b9 Mon Sep 17 00:00:00 2001 From: jonadimovska Date: Fri, 5 Jun 2026 10:29:42 +0200 Subject: [PATCH 25/65] feat(client): client call event reporting - add back JoinInitiated --- .../client/src/stats/ClientEventReporter.ts | 21 ++++++++++++++++++- 1 file changed, 20 insertions(+), 1 deletion(-) diff --git a/packages/client/src/stats/ClientEventReporter.ts b/packages/client/src/stats/ClientEventReporter.ts index 1c9fd14efa..191d1e1f8a 100644 --- a/packages/client/src/stats/ClientEventReporter.ts +++ b/packages/client/src/stats/ClientEventReporter.ts @@ -21,6 +21,7 @@ import { getCurrentValue } from '../store/rxUtils'; export type ClientEventPeerConnection = 'publish' | 'subscribe'; export type ClientEventStage = + | 'JoinInitiated' | 'CoordinatorWS' | 'MediaDevicePermission' | 'CoordinatorJoin' @@ -279,9 +280,9 @@ export class ClientEventReporter { startCorrelation = (callId: string) => { this.closeCallPairs(callId); this.joinAttemptIds.set(callId, generateUUIDv4()); - // a fresh attempt (e.g. full rejoin) re-reports the first frame this.firstFrameReported.delete(`${callId}:FirstVideoFrame`); this.firstFrameReported.delete(`${callId}:FirstAudioFrame`); + this.emitJoinInitiated(callId); this.emitMediaPermission(callId); }; @@ -363,6 +364,24 @@ export class ClientEventReporter { if (this.wsPairs.get(callId)) this.failWs(callId); }; + private emitJoinInitiated = (callId: string) => { + const joinAttemptId = this.joinAttemptIds.get(callId); + if (!joinAttemptId) return; + const coordinatorConnectId = this.getCoordinatorConnectId(); + this.send({ + user_id: this.getUserId(), + stage: 'JoinInitiated', + join_attempt_id: joinAttemptId, + ...(coordinatorConnectId && { + coordinator_connect_id: coordinatorConnectId, + }), + timestamp: new Date().toISOString(), + user_agent: this.userAgent, + sdk_version: this.sdkVersion, + event_type: 'initiated', + }); + }; + private beginAttempt = ( callId: string, stage: 'CoordinatorJoin' | 'WSJoin', From df28f523e3c82e3700cffe740f6b452890b12f18 Mon Sep 17 00:00:00 2001 From: jonadimovska Date: Fri, 5 Jun 2026 11:21:45 +0200 Subject: [PATCH 26/65] feat(client): client call event reporting - add back JoinInitiated --- packages/client/src/Call.ts | 3 ++- packages/client/src/rtc/Subscriber.ts | 2 +- packages/client/src/rtc/types.ts | 1 + packages/client/src/stats/ClientEventReporter.ts | 5 +++-- 4 files changed, 7 insertions(+), 4 deletions(-) diff --git a/packages/client/src/Call.ts b/packages/client/src/Call.ts index acd4760933..fe1c1e7f1a 100644 --- a/packages/client/src/Call.ts +++ b/packages/client/src/Call.ts @@ -1546,11 +1546,12 @@ export class Call { event, ); }, - onRemoteTrackUnmute: (trackType, trackId) => { + onRemoteTrackUnmute: (trackType, trackId, sfuId) => { this.streamClient.clientEventReporter.reportFirstFrame( this.cid, trackType, trackId, + sfuId, ); }, }; diff --git a/packages/client/src/rtc/Subscriber.ts b/packages/client/src/rtc/Subscriber.ts index 3705905718..384bf418dc 100644 --- a/packages/client/src/rtc/Subscriber.ts +++ b/packages/client/src/rtc/Subscriber.ts @@ -94,7 +94,7 @@ export class Subscriber extends BasePeerConnection { track.addEventListener('unmute', () => { this.logger.info(`[onTrack]: Track unmuted: ${trackDebugInfo}`); this.setRemoteTrackInterrupted(trackId, trackType, false); - this.onRemoteTrackUnmute?.(trackType, track.id); + this.onRemoteTrackUnmute?.(trackType, track.id, this.sfuClient.edgeName); }); track.addEventListener('ended', () => { this.logger.info(`[onTrack]: Track ended: ${trackDebugInfo}`); diff --git a/packages/client/src/rtc/types.ts b/packages/client/src/rtc/types.ts index da246ddaba..025130454a 100644 --- a/packages/client/src/rtc/types.ts +++ b/packages/client/src/rtc/types.ts @@ -79,6 +79,7 @@ export type OnPeerConnectionStateChange = ( export type OnRemoteTrackUnmute = ( trackType: TrackType, trackId: string, + sfuId: string, ) => void; export type BasePeerConnectionOpts = { diff --git a/packages/client/src/stats/ClientEventReporter.ts b/packages/client/src/stats/ClientEventReporter.ts index 191d1e1f8a..27f727220c 100644 --- a/packages/client/src/stats/ClientEventReporter.ts +++ b/packages/client/src/stats/ClientEventReporter.ts @@ -225,6 +225,7 @@ export class ClientEventReporter { callId: string, trackType: TrackType, trackId: string, + sfuId?: string, ) => { const stage = trackType === TrackType.VIDEO @@ -246,11 +247,11 @@ export class ClientEventReporter { joinAttemptIdSnapshot: this.joinAttemptIds.get(callId), }; - const sfuId = this.getSfuId(callId); + const resolvedSfuId = sfuId || this.getSfuId(callId); this.send({ ...this.buildCommon(callId, stage, pair), ...this.sessionIdField(callId), - ...(sfuId && { sfu_id: sfuId }), + ...(resolvedSfuId && { sfu_id: resolvedSfuId }), track_id: trackId, event_type: 'initiated', }); From f43c46b36e34e9f92bc07a99168632a732b914a1 Mon Sep 17 00:00:00 2001 From: jonadimovska Date: Fri, 5 Jun 2026 12:35:27 +0200 Subject: [PATCH 27/65] feat(client): client call event reporting - fix issue with missing sfu id --- packages/client/src/Call.ts | 22 +++++++++++++--------- 1 file changed, 13 insertions(+), 9 deletions(-) diff --git a/packages/client/src/Call.ts b/packages/client/src/Call.ts index fe1c1e7f1a..355eef5006 100644 --- a/packages/client/src/Call.ts +++ b/packages/client/src/Call.ts @@ -424,13 +424,6 @@ export class Call { await withoutConcurrency(this.joinLeaveConcurrencyTag, async () => { if (this.initialized) return; - this.streamClient.clientEventReporter.registerCall(this.cid, { - callType: this.type, - callId: this.id, - getCallSessionId: () => this.state.session?.id ?? '', - getSfuId: () => this.credentials?.server.edge_name ?? '', - }); - this.leaveCallHooks.add( this.on('all', (event) => { // update state with the latest event data @@ -1141,8 +1134,19 @@ export class Call { } }; - private withJoinLifecycle = (op: () => Promise): Promise => - this.streamClient.clientEventReporter.withJoinLifecycle(this.cid, op); + private withJoinLifecycle = (op: () => Promise): Promise => { + this.streamClient.clientEventReporter.registerCall(this.cid, { + callType: this.type, + callId: this.id, + getCallSessionId: () => this.state.session?.id ?? '', + getSfuId: () => this.credentials?.server.edge_name ?? '', + }); + + return this.streamClient.clientEventReporter.withJoinLifecycle( + this.cid, + op, + ); + }; private trackCoordinatorJoin = (op: () => Promise): Promise => this.streamClient.clientEventReporter.track( From 78c282e1b520ae7eb7801eeb401cb6bf0d0fdec3 Mon Sep 17 00:00:00 2001 From: jonadimovska Date: Fri, 5 Jun 2026 16:32:10 +0200 Subject: [PATCH 28/65] feat(client): client call event reporting - add panel --- .../components/ChaosPanel/ChaosPanel.tsx | 309 +++++++++++++++ .../components/ChaosPanel/chaos.ts | 369 ++++++++++++++++++ .../react/react-dogfood/pages/_app.tsx | 15 +- 3 files changed, 692 insertions(+), 1 deletion(-) create mode 100644 sample-apps/react/react-dogfood/components/ChaosPanel/ChaosPanel.tsx create mode 100644 sample-apps/react/react-dogfood/components/ChaosPanel/chaos.ts diff --git a/sample-apps/react/react-dogfood/components/ChaosPanel/ChaosPanel.tsx b/sample-apps/react/react-dogfood/components/ChaosPanel/ChaosPanel.tsx new file mode 100644 index 0000000000..e20b91cd26 --- /dev/null +++ b/sample-apps/react/react-dogfood/components/ChaosPanel/ChaosPanel.tsx @@ -0,0 +1,309 @@ +import { useEffect, useState } from 'react'; +import { + type ChaosState, + type CoordinatorMode, + type CoordinatorWsMode, + type WsMode, + getChaosController, +} from './chaos'; + +type Mode = { + value: T; + label: string; + withN?: boolean; +}; + +const COORD_MODES: Mode[] = [ + { value: 'off', label: 'Off (passthrough)' }, + { + value: 'fail-always', + label: 'Always 503 → exhaust retries → CoordinatorJoin completed/failure', + }, + { + value: 'fail-then-succeed', + label: + 'Fail N times then succeed → CoordinatorJoin completed/success with retry_count_attempt: N', + withN: true, + }, +]; + +const COORD_WS_MODES: Mode[] = [ + { value: 'off', label: 'Off (passthrough)' }, + { + value: 'fail-always', + label: 'Always close → exhaust retries → CoordinatorWS connect failure', + }, + { + value: 'fail-then-succeed', + label: + 'Close N times then succeed → CoordinatorWS connects with retry_count_attempt: N', + withN: true, + }, +]; + +const WS_MODES: Mode[] = [ + { value: 'off', label: 'Off (passthrough)' }, + { + value: 'fail-always', + label: 'Always close → WSJoin completed/failure after retries', + }, + { + value: 'fail-then-succeed', + label: + 'Close N times then succeed → WSJoin completed/success with retry_count_attempt: N', + withN: true, + }, + { + value: 'sfu-full-always', + label: + 'Always SFU_FULL → migrate every retry → WSJoin completed/failure with code=SFU_FULL each attempt', + }, + { + value: 'sfu-full-then-succeed', + label: + 'SFU_FULL N times then succeed → N WSJoin completed/failure (SFU_FULL) + final WSJoin completed/success', + withN: true, + }, +]; + +const useChaosState = (): ChaosState => { + const controller = getChaosController(); + const [state, setState] = useState(() => controller.getState()); + useEffect( + () => controller.subscribe(() => setState(controller.getState())), + [controller], + ); + return state; +}; + +const isAnyActive = (s: ChaosState) => + s.coordinator.mode !== 'off' || + s.coordinatorWs.mode !== 'off' || + s.ws.mode !== 'off'; + +export const ChaosPanel = () => { + const [open, setOpen] = useState(false); + const state = useChaosState(); + const controller = getChaosController(); + const active = isAnyActive(state); + + return ( + <> + + {open && ( +
setOpen(false)} + style={{ + position: 'fixed', + inset: 0, + zIndex: 9999, + background: 'rgba(15, 23, 42, 0.35)', + display: 'flex', + justifyContent: 'flex-end', + }} + > +
e.stopPropagation()} + style={{ + width: 'min(560px, 100vw)', + height: '100%', + overflow: 'auto', + background: '#ffffff', + boxShadow: '-18px 0 40px rgba(15, 23, 42, 0.18)', + padding: 20, + }} + > +
+

Chaos Test Panel

+ +
+ +

+ Apply scenarios before joining a call. Reset to + clear. +

+ +
+
+
+ +
+ +
+
+
+ )} + + ); +}; + +type SectionProps = { + title: string; + description: string; + modes: Mode[]; + state: { mode: T; failCount: number; remaining: number }; + onSelect: (mode: T, failCount: number) => void; +}; + +const Section = ({ + title, + description, + modes, + state, + onSelect, +}: SectionProps) => { + const [n, setN] = useState(state.failCount); + const showRemaining = state.mode.endsWith('-then-succeed'); + + return ( +
+

{title}

+

+ {description} +

+
+ {modes.map((m) => ( +
+ + {m.withN && ( + + )} +
+ ))} + {showRemaining && ( + + Remaining: {state.remaining} + + )} +
+
+ ); +}; diff --git a/sample-apps/react/react-dogfood/components/ChaosPanel/chaos.ts b/sample-apps/react/react-dogfood/components/ChaosPanel/chaos.ts new file mode 100644 index 0000000000..616bab9f18 --- /dev/null +++ b/sample-apps/react/react-dogfood/components/ChaosPanel/chaos.ts @@ -0,0 +1,369 @@ +import { SfuEvents, SfuModels } from '@stream-io/video-react-sdk'; + +export type CoordinatorMode = 'off' | 'fail-always' | 'fail-then-succeed'; +export type CoordinatorWsMode = 'off' | 'fail-always' | 'fail-then-succeed'; +export type WsMode = + | 'off' + | 'fail-always' + | 'fail-then-succeed' + | 'sfu-full-always' + | 'sfu-full-then-succeed'; + +type WsFailureKind = 'close' | 'sfu-full'; + +export type ChaosState = { + coordinator: { mode: CoordinatorMode; failCount: number; remaining: number }; + coordinatorWs: { + mode: CoordinatorWsMode; + failCount: number; + remaining: number; + }; + ws: { mode: WsMode; failCount: number; remaining: number }; +}; + +const COORDINATOR_PATTERN = /\/call\/[^/]+\/[^/]+\/join(\?|$)/; +const COORDINATOR_WS_PATTERN = /\/video\/connect(?:\?|$)/; + +const isSfuWebSocket = (url: string) => { + if (!url) return false; + if (/\/video\/connect(?:\?|$)/.test(url)) return false; + if (/chat/i.test(url)) return false; + if (/[?&](cid|user_session_id)=/.test(url)) return true; + return /sfu|signal/i.test(url); +}; + +type Listener = () => void; + +class ChaosController { + private state: ChaosState = { + coordinator: { mode: 'off', failCount: 1, remaining: 0 }, + coordinatorWs: { mode: 'off', failCount: 1, remaining: 0 }, + ws: { mode: 'off', failCount: 1, remaining: 0 }, + }; + private listeners = new Set(); + private patched = false; + private originals: { + fetch: typeof fetch; + XMLHttpRequest: typeof XMLHttpRequest; + WebSocket: typeof WebSocket; + } | null = null; + + getState = (): ChaosState => structuredClone(this.state); + + subscribe = (cb: Listener): (() => void) => { + this.listeners.add(cb); + return () => { + this.listeners.delete(cb); + }; + }; + + setCoordinator = (mode: CoordinatorMode, failCount = 1) => { + this.state.coordinator = { mode, failCount, remaining: failCount }; + this.applyIfNeeded(); + this.notify(); + }; + + setCoordinatorWs = (mode: CoordinatorWsMode, failCount = 1) => { + this.state.coordinatorWs = { mode, failCount, remaining: failCount }; + this.applyIfNeeded(); + this.notify(); + }; + + setWs = (mode: WsMode, failCount = 1) => { + this.state.ws = { mode, failCount, remaining: failCount }; + this.applyIfNeeded(); + this.notify(); + }; + + reset = () => { + this.state = { + coordinator: { mode: 'off', failCount: 1, remaining: 0 }, + coordinatorWs: { mode: 'off', failCount: 1, remaining: 0 }, + ws: { mode: 'off', failCount: 1, remaining: 0 }, + }; + this.unpatch(); + this.notify(); + }; + + private notify = () => { + this.listeners.forEach((cb) => cb()); + }; + + private isAnyActive = () => + this.state.coordinator.mode !== 'off' || + this.state.coordinatorWs.mode !== 'off' || + this.state.ws.mode !== 'off'; + + private applyIfNeeded = () => { + if (this.isAnyActive()) this.patch(); + else this.unpatch(); + }; + + private patch = () => { + if (this.patched || typeof window === 'undefined') return; + this.originals = { + fetch: window.fetch.bind(window), + XMLHttpRequest: window.XMLHttpRequest, + WebSocket: window.WebSocket, + }; + window.fetch = this.makePatchedFetch(this.originals.fetch); + (window as { XMLHttpRequest: typeof XMLHttpRequest }).XMLHttpRequest = + this.makePatchedXHR(this.originals.XMLHttpRequest); + (window as { WebSocket: typeof WebSocket }).WebSocket = + this.makePatchedWebSocket(this.originals.WebSocket); + this.patched = true; + }; + + private unpatch = () => { + if (!this.patched || !this.originals) return; + window.fetch = this.originals.fetch; + (window as { XMLHttpRequest: typeof XMLHttpRequest }).XMLHttpRequest = + this.originals.XMLHttpRequest; + (window as { WebSocket: typeof WebSocket }).WebSocket = + this.originals.WebSocket; + this.originals = null; + this.patched = false; + }; + + shouldFailCoordinator = (url: string) => { + if (!COORDINATOR_PATTERN.test(url)) return false; + const c = this.state.coordinator; + if (c.mode === 'off') return false; + if (c.mode === 'fail-always') return true; + if (c.mode === 'fail-then-succeed' && c.remaining > 0) { + c.remaining--; + this.notify(); + return true; + } + return false; + }; + + shouldFailCoordinatorWs = (url: string) => { + if (!COORDINATOR_WS_PATTERN.test(url)) return false; + const c = this.state.coordinatorWs; + if (c.mode === 'off') return false; + if (c.mode === 'fail-always') return true; + if (c.mode === 'fail-then-succeed' && c.remaining > 0) { + c.remaining--; + this.notify(); + return true; + } + return false; + }; + + shouldFailWs = (url: string): WsFailureKind | null => { + if (!isSfuWebSocket(url)) return null; + const w = this.state.ws; + if (w.mode === 'off') return null; + if (w.mode === 'fail-always') return 'close'; + if (w.mode === 'sfu-full-always') return 'sfu-full'; + if (w.mode === 'fail-then-succeed' && w.remaining > 0) { + w.remaining--; + this.notify(); + return 'close'; + } + if (w.mode === 'sfu-full-then-succeed' && w.remaining > 0) { + w.remaining--; + this.notify(); + return 'sfu-full'; + } + return null; + }; + + private makePatchedFetch = + (orig: typeof fetch): typeof fetch => + (input, init) => { + const url = + typeof input === 'string' + ? input + : input instanceof URL + ? input.toString() + : input.url; + if (this.shouldFailCoordinator(url)) { + return Promise.resolve( + new Response( + JSON.stringify({ + code: 9999, + message: 'Chaos: simulated coordinator 503', + StatusCode: 503, + duration: '0ms', + }), + { + status: 503, + statusText: 'Service Unavailable (chaos)', + headers: { 'Content-Type': 'application/json' }, + }, + ), + ); + } + return orig(input, init); + }; + + private makePatchedXHR = (Orig: typeof XMLHttpRequest) => { + return class PatchedXHR extends Orig { + private _chaosUrl?: string; + + open( + method: string, + url: string | URL, + async?: boolean, + username?: string | null, + password?: string | null, + ): void { + this._chaosUrl = + typeof url === 'string' ? url : (url as URL).toString(); + if (async === undefined) { + super.open(method, url); + } else { + super.open(method, url, async, username, password); + } + } + + send(body?: Document | XMLHttpRequestBodyInit | null): void { + if ( + this._chaosUrl && + getChaosController().shouldFailCoordinator(this._chaosUrl) + ) { + setTimeout(() => { + const responseBody = JSON.stringify({ + code: 9999, + message: 'Chaos: simulated coordinator 503', + StatusCode: 503, + duration: '0ms', + }); + const overrides: Record = { + readyState: 4, + status: 503, + statusText: 'Service Unavailable (chaos)', + responseText: responseBody, + response: responseBody, + responseURL: this._chaosUrl, + responseType: '', + }; + for (const [key, value] of Object.entries(overrides)) { + try { + Object.defineProperty(this, key, { + configurable: true, + get: () => value, + }); + } catch { + // ignore + } + } + ( + this as unknown as { getAllResponseHeaders: () => string } + ).getAllResponseHeaders = () => + 'content-type: application/json\r\n'; + this.dispatchEvent(new Event('readystatechange')); + this.dispatchEvent(new Event('load')); + this.dispatchEvent(new Event('loadend')); + }, 0); + return; + } + super.send(body); + } + } as unknown as typeof XMLHttpRequest; + }; + + private makePatchedWebSocket = (Orig: typeof WebSocket) => { + const Patched = function PatchedWebSocket( + this: WebSocket, + url: string | URL, + protocols?: string | string[], + ) { + const urlStr = typeof url === 'string' ? url : url.toString(); + const controller = getChaosController(); + if (controller.shouldFailCoordinatorWs(urlStr)) { + const ws = new Orig(urlStr, protocols); + Promise.resolve().then(() => { + try { + ws.close(4000, 'Chaos: simulated coordinator WS open failure'); + } catch { + // ignore + } + }); + return ws; + } + const failure = controller.shouldFailWs(urlStr); + if (failure === 'close') { + const ws = new Orig(urlStr, protocols); + Promise.resolve().then(() => { + try { + ws.close(4000, 'Chaos: simulated SFU WS open failure'); + } catch { + // ignore + } + }); + return ws; + } + if (failure === 'sfu-full') { + const ws = new Orig(urlStr, protocols); + ws.binaryType = 'arraybuffer'; + const sendOriginal = ws.send.bind(ws); + let injected = false; + ws.send = (data: string | ArrayBufferLike | Blob | ArrayBufferView) => { + sendOriginal(data); + if (injected) return; + let isJoinRequest = false; + try { + let bytes: Uint8Array | undefined; + if (data instanceof ArrayBuffer) { + bytes = new Uint8Array(data); + } else if (ArrayBuffer.isView(data)) { + bytes = new Uint8Array( + data.buffer, + data.byteOffset, + data.byteLength, + ); + } + if (bytes) { + const req = SfuEvents.SfuRequest.fromBinary(bytes); + isJoinRequest = req.requestPayload?.oneofKind === 'joinRequest'; + } + } catch { + // ignore + } + if (!isJoinRequest) return; + injected = true; + const errorEvent = SfuEvents.SfuEvent.create({ + eventPayload: { + oneofKind: 'error', + error: { + error: { + code: SfuModels.ErrorCode.SFU_FULL, + message: 'Chaos: simulated SFU_FULL', + shouldRetry: true, + }, + reconnectStrategy: SfuModels.WebsocketReconnectStrategy.REJOIN, + }, + }, + }); + const payload = SfuEvents.SfuEvent.toBinary(errorEvent); + const buffer = payload.buffer.slice( + payload.byteOffset, + payload.byteOffset + payload.byteLength, + ) as ArrayBuffer; + Promise.resolve().then(() => { + try { + ws.dispatchEvent(new MessageEvent('message', { data: buffer })); + } catch { + // ignore + } + }); + }; + return ws; + } + return new Orig(urlStr, protocols); + } as unknown as typeof WebSocket; + Patched.prototype = Orig.prototype; + Object.setPrototypeOf(Patched, Orig); + return Patched; + }; +} + +let singleton: ChaosController | null = null; + +export const getChaosController = (): ChaosController => { + if (!singleton) singleton = new ChaosController(); + return singleton; +}; diff --git a/sample-apps/react/react-dogfood/pages/_app.tsx b/sample-apps/react/react-dogfood/pages/_app.tsx index 6123769b2b..f72709535e 100644 --- a/sample-apps/react/react-dogfood/pages/_app.tsx +++ b/sample-apps/react/react-dogfood/pages/_app.tsx @@ -1,7 +1,7 @@ import '@stream-io/video-styling/dist/css/embedded.css'; import 'stream-chat-react/dist/css/index.css'; import '../style/index.scss'; -import { ComponentType } from 'react'; +import { ComponentType, useEffect, useState } from 'react'; import { Session } from 'next-auth'; import Head from 'next/head'; import dynamic from 'next/dynamic'; @@ -9,6 +9,7 @@ import { SessionProvider } from 'next-auth/react'; import { StreamTheme } from '@stream-io/video-react-sdk'; import { SettingsProvider } from '../context/SettingsContext'; import { AppEnvironmentProvider } from '../context/AppEnvironmentContext'; +import { ChaosPanel } from '../components/ChaosPanel/ChaosPanel'; const GoogleAnalytics = dynamic( () => import('@next/third-parties/google').then((mod) => mod.GoogleAnalytics), @@ -47,6 +48,7 @@ export default function App({ + @@ -55,3 +57,14 @@ export default function App({ ); } + +const ChaosPanelGate = () => { + const [enabled, setEnabled] = useState(false); + + useEffect(() => { + setEnabled(new URLSearchParams(window.location.search).has('debug')); + }, []); + + if (!enabled) return null; + return ; +}; From 876270d6e94e837b25680dfdbe9a45d34bc65f14 Mon Sep 17 00:00:00 2001 From: jonadimovska Date: Fri, 5 Jun 2026 17:56:42 +0200 Subject: [PATCH 29/65] feat(client): client call event reporting - report completed only for succesfull initiated stages --- .../client/src/stats/ClientEventReporter.ts | 67 ++++++++++++++----- 1 file changed, 50 insertions(+), 17 deletions(-) diff --git a/packages/client/src/stats/ClientEventReporter.ts b/packages/client/src/stats/ClientEventReporter.ts index 27f727220c..a830117554 100644 --- a/packages/client/src/stats/ClientEventReporter.ts +++ b/packages/client/src/stats/ClientEventReporter.ts @@ -76,6 +76,7 @@ type StagePairState = { joinAttemptIdSnapshot?: string; userIdSnapshot?: string; lastError?: StageError; + initiatedDelivery?: Promise; }; type PeerConnectionContext = { @@ -142,7 +143,7 @@ export class ClientEventReporter { } const { reason, code } = pair.lastError; - this.send({ + this.sendCompleted(pair, { ...this.buildCoordinatorWsCommon(pair), event_type: 'completed', outcome: 'failure', @@ -163,7 +164,7 @@ export class ClientEventReporter { startedAt: Date.now(), userIdSnapshot: this.getUserId(), }; - this.send({ + this.coordinatorWsPair.initiatedDelivery = this.sendTracked({ ...this.buildCoordinatorWsCommon(this.coordinatorWsPair), event_type: 'initiated', }); @@ -174,7 +175,7 @@ export class ClientEventReporter { private succeedCoordinatorWs = () => { const pair = this.coordinatorWsPair; if (!pair) return; - this.send({ + this.sendCompleted(pair, { ...this.buildCoordinatorWsCommon(pair), event_type: 'completed', outcome: 'success', @@ -421,7 +422,7 @@ export class ClientEventReporter { joinAttemptIdSnapshot: this.joinAttemptIds.get(callId), }; this.coordinatorPairs.set(callId, pair); - this.send({ + pair.initiatedDelivery = this.sendTracked({ ...this.buildCommon(callId, 'CoordinatorJoin', pair), event_type: 'initiated', }); @@ -432,7 +433,7 @@ export class ClientEventReporter { private succeedCoordinator = (callId: string) => { const pair = this.coordinatorPairs.get(callId); if (!pair) return; - this.send({ + this.sendCompleted(pair, { ...this.buildCommon(callId, 'CoordinatorJoin', pair), ...this.sessionIdField(callId), event_type: 'completed', @@ -450,7 +451,7 @@ export class ClientEventReporter { return; } const { reason, code } = pair.lastError; - this.send({ + this.sendCompleted(pair, { ...this.buildCommon(callId, 'CoordinatorJoin', pair), ...this.sessionIdField(callId), event_type: 'completed', @@ -474,7 +475,7 @@ export class ClientEventReporter { }; this.wsPairs.set(callId, pair); const sfuId = this.getSfuId(callId); - this.send({ + pair.initiatedDelivery = this.sendTracked({ ...this.buildCommon(callId, 'WSJoin', pair), ...this.sessionIdField(callId), ...(sfuId && { sfu_id: sfuId }), @@ -488,7 +489,7 @@ export class ClientEventReporter { const pair = this.wsPairs.get(callId); if (!pair) return; const sfuId = this.getSfuId(callId); - this.send({ + this.sendCompleted(pair, { ...this.buildCommon(callId, 'WSJoin', pair), ...this.sessionIdField(callId), ...(sfuId && { sfu_id: sfuId }), @@ -508,7 +509,7 @@ export class ClientEventReporter { } const { reason, code } = pair.lastError; const sfuId = this.getSfuId(callId); - this.send({ + this.sendCompleted(pair, { ...this.buildCommon(callId, 'WSJoin', pair), ...this.sessionIdField(callId), event_type: 'completed', @@ -599,7 +600,7 @@ export class ClientEventReporter { this.peerConnectionContexts.set(key, pcContext); this.peerConnectionPairs.set(key, pair); - this.send({ + pair.initiatedDelivery = this.sendTracked({ ...this.buildCommon(callId, 'PeerConnectionConnect', pair), ...this.sessionIdField(callId), peer_connection: role, @@ -621,7 +622,7 @@ export class ClientEventReporter { const pcContext = this.peerConnectionContexts.get(key); if (!pair || !pcContext) return; - this.send({ + this.sendCompleted(pair, { ...this.buildCommon(callId, 'PeerConnectionConnect', pair), ...this.sessionIdField(callId), peer_connection: role, @@ -655,7 +656,7 @@ export class ClientEventReporter { const finalReason = pair.lastError?.reason ?? reason; const finalCode = pair.lastError?.code ?? code; - this.send({ + this.sendCompleted(pair, { ...this.buildCommon(callId, 'PeerConnectionConnect', pair), ...this.sessionIdField(callId), peer_connection: role, @@ -711,15 +712,46 @@ export class ClientEventReporter { }; }; + // Fire-and-forget send for standalone events (no `initiated`/`completed` + // pairing, e.g. JoinInitiated, MediaDevicePermission, FirstFrame). private send = (body: Record) => { if (this.disposed) return; void this.sendWithRetry(body); }; - private sendWithRetry = async (body: Record) => { + // Send and report whether it was actually delivered. Used to track the + // delivery of `initiated` events so the paired `completed` can be gated on it. + private sendTracked = (body: Record): Promise => { + if (this.disposed) return Promise.resolve(false); + return this.sendWithRetry(body); + }; + + // Emit a `completed` event only if its `initiated` was delivered. If the + // `initiated` never made it (e.g. dropped while offline), drop the + // `completed` too — losing both is preferable to an orphaned `completed`. + private sendCompleted = ( + pair: StagePairState, + body: Record, + ) => { + const gate = pair.initiatedDelivery ?? Promise.resolve(true); + void gate.then((delivered) => { + if (delivered) { + this.send(body); + } else { + this.logger.debug( + 'Skipping completed event; its initiated was not delivered', + body.stage, + ); + } + }); + }; + + private sendWithRetry = async ( + body: Record, + ): Promise => { for (let attempt = 0; attempt < 5; attempt++) { - if (this.disposed) return; + if (this.disposed) return false; try { await this.streamClient.doAxiosRequest( @@ -728,7 +760,7 @@ export class ClientEventReporter { { events: [body] }, { publicEndpoint: true }, ); - return; + return true; } catch (err) { const status = (err as { response?: { status?: number } })?.response ?.status; @@ -738,7 +770,7 @@ export class ClientEventReporter { body.stage, body.event_type, ); - return; + return false; } if (attempt === 4) { this.logger.debug( @@ -747,11 +779,12 @@ export class ClientEventReporter { body.event_type, err, ); - return; + return false; } await sleep(retryInterval(attempt)); } } + return false; }; } From f832286679fd92a647726f31dec2c8240bbba7a8 Mon Sep 17 00:00:00 2001 From: jonadimovska Date: Sat, 6 Jun 2026 21:36:52 +0200 Subject: [PATCH 30/65] feat(client): client call event reporting - simplify peer connection state handling and emit initiated on connecting instead of checking --- packages/client/src/Call.ts | 1 + packages/client/src/rtc/BasePeerConnection.ts | 20 ++-- packages/client/src/rtc/types.ts | 18 ++-- .../client/src/stats/ClientEventReporter.ts | 92 +++++++------------ 4 files changed, 58 insertions(+), 73 deletions(-) diff --git a/packages/client/src/Call.ts b/packages/client/src/Call.ts index 355eef5006..844fd8974d 100644 --- a/packages/client/src/Call.ts +++ b/packages/client/src/Call.ts @@ -1140,6 +1140,7 @@ export class Call { callId: this.id, getCallSessionId: () => this.state.session?.id ?? '', getSfuId: () => this.credentials?.server.edge_name ?? '', + getUserSessionId: () => this.sfuClient?.sessionId ?? '', }); return this.streamClient.clientEventReporter.withJoinLifecycle( diff --git a/packages/client/src/rtc/BasePeerConnection.ts b/packages/client/src/rtc/BasePeerConnection.ts index fefc17f0a3..a16b4df399 100644 --- a/packages/client/src/rtc/BasePeerConnection.ts +++ b/packages/client/src/rtc/BasePeerConnection.ts @@ -337,9 +337,10 @@ export abstract class BasePeerConnection { private onConnectionStateChange = async () => { const state = this.pc.connectionState; this.logger.debug(`Connection state changed`, state); - if (state === 'failed') { - this.fireOnPeerConnectionStateChange(); - } + this.fireOnPeerConnectionStateChange({ + stateType: 'peerConnection', + state, + }); if (this.tracer && (state === 'connected' || state === 'failed')) { try { const stats = await this.stats.get(); @@ -368,18 +369,19 @@ export abstract class BasePeerConnection { private onIceConnectionStateChange = () => { const state = this.pc.iceConnectionState; this.logger.debug(`ICE connection state changed`, state); - this.fireOnPeerConnectionStateChange(); + this.fireOnPeerConnectionStateChange({ stateType: 'ice', state }); this.handleConnectionStateUpdate(state); }; - private fireOnPeerConnectionStateChange = () => { + private fireOnPeerConnectionStateChange = ( + event: + | { stateType: 'ice'; state: RTCIceConnectionState } + | { stateType: 'peerConnection'; state: RTCPeerConnectionState }, + ) => { try { this.onPeerConnectionStateChange?.({ peerType: this.peerType, - iceConnectionState: this.pc.iceConnectionState, - peerConnectionState: this.pc.connectionState, - sfuId: this.sfuClient.edgeName, - userSessionId: this.sfuClient.sessionId, + ...event, }); } catch (err) { this.logger.warn('onPeerConnectionStateChange listener threw', err); diff --git a/packages/client/src/rtc/types.ts b/packages/client/src/rtc/types.ts index 025130454a..cd575b17d9 100644 --- a/packages/client/src/rtc/types.ts +++ b/packages/client/src/rtc/types.ts @@ -59,13 +59,17 @@ export type OnIceConnected = (peerType: PeerType) => void; * consumers (e.g. `ClientEventReporter`). Fired on every transition of * either `iceConnectionState` or `peerConnectionState`. */ -export type PeerConnectionStateChangeEvent = { - peerType: PeerType; - iceConnectionState: RTCIceConnectionState; - peerConnectionState: RTCPeerConnectionState; - sfuId: string; - userSessionId: string; -}; +export type PeerConnectionStateChangeEvent = + | { + peerType: PeerType; + stateType: 'ice'; + state: RTCIceConnectionState; + } + | { + peerType: PeerType; + stateType: 'peerConnection'; + state: RTCPeerConnectionState; + }; export type OnPeerConnectionStateChange = ( event: PeerConnectionStateChangeEvent, diff --git a/packages/client/src/stats/ClientEventReporter.ts b/packages/client/src/stats/ClientEventReporter.ts index a830117554..78b98a3b3e 100644 --- a/packages/client/src/stats/ClientEventReporter.ts +++ b/packages/client/src/stats/ClientEventReporter.ts @@ -49,6 +49,7 @@ export type CallReportContext = { callId: string; getCallSessionId: () => string; getSfuId: () => string; + getUserSessionId: () => string; }; export type ClientEventReporterOptions = { @@ -79,7 +80,7 @@ type StagePairState = { initiatedDelivery?: Promise; }; -type PeerConnectionContext = { +type PeerConnectionPairState = StagePairState & { sfuId: string; userSessionId: string; wasPreviouslyConnected: boolean; @@ -103,8 +104,7 @@ export class ClientEventReporter { private joinAttemptIds = new Map(); private coordinatorPairs = new Map(); private wsPairs = new Map(); - private peerConnectionPairs = new Map(); - private peerConnectionContexts = new Map(); + private peerConnectionPairs = new Map(); private pcEverConnected = new Map(); private firstFrameReported = new Set(); @@ -274,7 +274,6 @@ export class ClientEventReporter { for (const role of ['publish', 'subscribe'] as const) { const key = pcKey(callId, role); this.peerConnectionPairs.delete(key); - this.peerConnectionContexts.delete(key); this.pcEverConnected.delete(key); } }; @@ -530,7 +529,7 @@ export class ClientEventReporter { const role: ClientEventPeerConnection = event.peerType === PeerType.SUBSCRIBER ? 'subscribe' : 'publish'; - if (event.iceConnectionState === 'failed') { + if (event.stateType === 'ice' && event.state === 'failed') { this.emitPeerConnectionFailure( callId, role, @@ -541,7 +540,7 @@ export class ClientEventReporter { return; } - if (event.peerConnectionState === 'failed') { + if (event.stateType === 'peerConnection' && event.state === 'failed') { this.emitPeerConnectionFailure( callId, role, @@ -552,15 +551,14 @@ export class ClientEventReporter { return; } - switch (event.iceConnectionState) { - case 'checking': - this.openOrSupersedePeerConnectionPair(callId, role, { - sfuId: event.sfuId, - userSessionId: event.userSessionId, - }); + if (event.stateType !== 'peerConnection') return; + + switch (event.state) { + case 'connecting': + if (this.peerConnectionPairs.has(pcKey(callId, role))) return; + this.openPeerConnectionPair(callId, role); break; case 'connected': - case 'completed': this.emitPeerConnectionSuccess(callId, role); this.pcEverConnected.set(pcKey(callId, role), true); break; @@ -569,45 +567,30 @@ export class ClientEventReporter { } }; - private openOrSupersedePeerConnectionPair = ( + private openPeerConnectionPair = ( callId: string, role: ClientEventPeerConnection, - ctx: { sfuId: string; userSessionId: string }, ) => { const key = pcKey(callId, role); - if (this.peerConnectionPairs.get(key)) { - this.emitPeerConnectionFailure( - callId, - role, - 'ICE_CONNECTIVITY_FAILED', - 'Superseded by new ICE attempt', - 'NOT_CONNECTED', - ); - } - - const pcContext: PeerConnectionContext = { - sfuId: ctx.sfuId || this.getSfuId(callId), - userSessionId: ctx.userSessionId, - wasPreviouslyConnected: this.pcEverConnected.get(key) === true, - }; - - const pair: StagePairState = { + const pair: PeerConnectionPairState = { sid: generateUUIDv4(), attempts: 0, startedAt: Date.now(), joinAttemptIdSnapshot: this.joinAttemptIds.get(callId), + sfuId: this.getSfuId(callId), + userSessionId: this.getUserSessionId(callId), + wasPreviouslyConnected: this.pcEverConnected.get(key) === true, }; - this.peerConnectionContexts.set(key, pcContext); this.peerConnectionPairs.set(key, pair); pair.initiatedDelivery = this.sendTracked({ ...this.buildCommon(callId, 'PeerConnectionConnect', pair), ...this.sessionIdField(callId), peer_connection: role, - was_previously_connected: pcContext.wasPreviouslyConnected, - ...(pcContext.sfuId && { sfu_id: pcContext.sfuId }), - ...(pcContext.userSessionId && { - user_session_id: pcContext.userSessionId, + was_previously_connected: pair.wasPreviouslyConnected, + ...(pair.sfuId && { sfu_id: pair.sfuId }), + ...(pair.userSessionId && { + user_session_id: pair.userSessionId, }), event_type: 'initiated', }); @@ -619,17 +602,16 @@ export class ClientEventReporter { ) => { const key = pcKey(callId, role); const pair = this.peerConnectionPairs.get(key); - const pcContext = this.peerConnectionContexts.get(key); - if (!pair || !pcContext) return; + if (!pair) return; this.sendCompleted(pair, { ...this.buildCommon(callId, 'PeerConnectionConnect', pair), ...this.sessionIdField(callId), peer_connection: role, - was_previously_connected: pcContext.wasPreviouslyConnected, - ...(pcContext.sfuId && { sfu_id: pcContext.sfuId }), - ...(pcContext.userSessionId && { - user_session_id: pcContext.userSessionId, + was_previously_connected: pair.wasPreviouslyConnected, + ...(pair.sfuId && { sfu_id: pair.sfuId }), + ...(pair.userSessionId && { + user_session_id: pair.userSessionId, }), event_type: 'completed', outcome: 'success', @@ -637,7 +619,6 @@ export class ClientEventReporter { elapsed_time: Date.now() - pair.startedAt, }); this.peerConnectionPairs.delete(key); - this.peerConnectionContexts.delete(key); }; private emitPeerConnectionFailure = ( @@ -649,37 +630,34 @@ export class ClientEventReporter { ) => { const key = pcKey(callId, role); const pair = this.peerConnectionPairs.get(key); - const pcContext = this.peerConnectionContexts.get(key); - if (!pair || !pcContext) return; - - applyError(pair, { reason, code, severity: SEVERITY.SERVER }); - const finalReason = pair.lastError?.reason ?? reason; - const finalCode = pair.lastError?.code ?? code; + if (!pair) return; this.sendCompleted(pair, { ...this.buildCommon(callId, 'PeerConnectionConnect', pair), ...this.sessionIdField(callId), peer_connection: role, - was_previously_connected: pcContext.wasPreviouslyConnected, - ...(pcContext.userSessionId && { - user_session_id: pcContext.userSessionId, + was_previously_connected: pair.wasPreviouslyConnected, + ...(pair.userSessionId && { + user_session_id: pair.userSessionId, }), - ...(pcContext.sfuId && { sfu_id: pcContext.sfuId }), + ...(pair.sfuId && { sfu_id: pair.sfuId }), event_type: 'completed', outcome: 'failure', retry_count_attempt: 0, elapsed_time: Date.now() - pair.startedAt, ice_state: iceState, - retry_failure_reason: finalReason, - retry_failure_code: finalCode, + retry_failure_reason: reason, + retry_failure_code: code, }); this.peerConnectionPairs.delete(key); - this.peerConnectionContexts.delete(key); }; private getSfuId = (callId: string): string => this.callContexts.get(callId)?.getSfuId() ?? ''; + private getUserSessionId = (callId: string): string => + this.callContexts.get(callId)?.getUserSessionId() ?? ''; + private sessionIdField = (callId: string): Record => { const callSessionId = this.callContexts.get(callId)?.getCallSessionId() ?? ''; From 95bf3a38ddf9c44e41439e62b560256932b47fa2 Mon Sep 17 00:00:00 2001 From: jonadimovska Date: Sat, 6 Jun 2026 22:33:08 +0200 Subject: [PATCH 31/65] feat(client): client call event reporting - rename to cid --- .../client/src/stats/ClientEventReporter.ts | 256 +++++++++--------- 1 file changed, 126 insertions(+), 130 deletions(-) diff --git a/packages/client/src/stats/ClientEventReporter.ts b/packages/client/src/stats/ClientEventReporter.ts index 78b98a3b3e..36e0ffa4a0 100644 --- a/packages/client/src/stats/ClientEventReporter.ts +++ b/packages/client/src/stats/ClientEventReporter.ts @@ -86,8 +86,8 @@ type PeerConnectionPairState = StagePairState & { wasPreviouslyConnected: boolean; }; -const pcKey = (callId: string, role: ClientEventPeerConnection): string => - `${callId}:${role}`; +const pcKey = (cid: string, role: ClientEventPeerConnection): string => + `${cid}:${role}`; export class ClientEventReporter { private readonly logger = videoLoggerSystem.getLogger('ClientEventReporter'); @@ -199,19 +199,19 @@ export class ClientEventReporter { sdk_version: this.sdkVersion, }); - private emitMediaPermission = (callId: string) => { - if (!this.callContexts.has(callId)) return; + private emitMediaPermission = (cid: string) => { + if (!this.callContexts.has(cid)) return; const pair: StagePairState = { sid: generateUUIDv4(), attempts: 0, startedAt: Date.now(), - joinAttemptIdSnapshot: this.joinAttemptIds.get(callId), + joinAttemptIdSnapshot: this.joinAttemptIds.get(cid), }; this.send({ - ...this.buildCommon(callId, 'MediaDevicePermission', pair), - ...this.sessionIdField(callId), + ...this.buildCommon(cid, 'MediaDevicePermission', pair), + ...this.sessionIdField(cid), microphone_permission_status: readPermissionStatus( getAudioBrowserPermission(), ), @@ -223,7 +223,7 @@ export class ClientEventReporter { }; reportFirstFrame = ( - callId: string, + cid: string, trackType: TrackType, trackId: string, sfuId?: string, @@ -236,7 +236,7 @@ export class ClientEventReporter { : undefined; if (!stage) return; - const key = `${callId}:${stage}`; + const key = `${cid}:${stage}`; if (this.firstFrameReported.has(key)) return; this.firstFrameReported.add(key); @@ -245,79 +245,79 @@ export class ClientEventReporter { sid: generateUUIDv4(), attempts: 0, startedAt: Date.now(), - joinAttemptIdSnapshot: this.joinAttemptIds.get(callId), + joinAttemptIdSnapshot: this.joinAttemptIds.get(cid), }; - const resolvedSfuId = sfuId || this.getSfuId(callId); + const resolvedSfuId = sfuId || this.getSfuId(cid); this.send({ - ...this.buildCommon(callId, stage, pair), - ...this.sessionIdField(callId), + ...this.buildCommon(cid, stage, pair), + ...this.sessionIdField(cid), ...(resolvedSfuId && { sfu_id: resolvedSfuId }), track_id: trackId, event_type: 'initiated', }); }; - registerCall = (callId: string, ctx: CallReportContext) => { - this.callContexts.set(callId, ctx); + registerCall = (cid: string, ctx: CallReportContext) => { + this.callContexts.set(cid, ctx); }; - unregisterCall = (callId: string) => { - this.callContexts.delete(callId); - this.joinAttemptIds.delete(callId); - this.coordinatorPairs.delete(callId); - this.wsPairs.delete(callId); + unregisterCall = (cid: string) => { + this.callContexts.delete(cid); + this.joinAttemptIds.delete(cid); + this.coordinatorPairs.delete(cid); + this.wsPairs.delete(cid); - this.firstFrameReported.delete(`${callId}:FirstVideoFrame`); - this.firstFrameReported.delete(`${callId}:FirstAudioFrame`); + this.firstFrameReported.delete(`${cid}:FirstVideoFrame`); + this.firstFrameReported.delete(`${cid}:FirstAudioFrame`); for (const role of ['publish', 'subscribe'] as const) { - const key = pcKey(callId, role); + const key = pcKey(cid, role); this.peerConnectionPairs.delete(key); this.pcEverConnected.delete(key); } }; - startCorrelation = (callId: string) => { - this.closeCallPairs(callId); - this.joinAttemptIds.set(callId, generateUUIDv4()); - this.firstFrameReported.delete(`${callId}:FirstVideoFrame`); - this.firstFrameReported.delete(`${callId}:FirstAudioFrame`); - this.emitJoinInitiated(callId); - this.emitMediaPermission(callId); + startCorrelation = (cid: string) => { + this.closeCallPairs(cid); + this.joinAttemptIds.set(cid, generateUUIDv4()); + this.firstFrameReported.delete(`${cid}:FirstVideoFrame`); + this.firstFrameReported.delete(`${cid}:FirstAudioFrame`); + this.emitJoinInitiated(cid); + this.emitMediaPermission(cid); }; withJoinLifecycle = async ( - callId: string, + cid: string, op: () => Promise, ): Promise => { - this.startCorrelation(callId); + this.startCorrelation(cid); try { return await op(); } catch (err) { - this.closeCallPairs(callId); + this.closeCallPairs(cid); throw err; } }; track = async ( - callId: string, + cid: string, stage: 'CoordinatorJoin' | 'WSJoin', op: () => Promise, ): Promise => { - this.beginAttempt(callId, stage); + this.beginAttempt(cid, stage); try { const result = await op(); - this.succeedAttempt(callId, stage); + this.succeedAttempt(cid, stage); return result; } catch (err) { - this.applyStageError(callId, stage, err); + this.applyStageError(cid, stage, err); throw err; } }; - captureWsError = (callId: string, opts: { code: string; reason: string }) => { - const pair = this.wsPairs.get(callId); + captureWsError = (cid: string, opts: { code: string; reason: string }) => { + const pair = this.wsPairs.get(cid); if (!pair) return; applyError(pair, { @@ -327,32 +327,32 @@ export class ClientEventReporter { }); }; - close = (callId: string) => { - this.closeCallPairs(callId); + close = (cid: string) => { + this.closeCallPairs(cid); }; abort = ( - callId: string, + cid: string, opts: { code: 'CLIENT_ABORTED' | 'BACKEND_LEAVE'; reason: string }, ) => { const { code, reason } = opts; const stageError: StageError = { code, reason, severity: SEVERITY.CLIENT }; - applyError(this.coordinatorPairs.get(callId), stageError); - applyError(this.wsPairs.get(callId), stageError); + applyError(this.coordinatorPairs.get(cid), stageError); + applyError(this.wsPairs.get(cid), stageError); - this.failCoordinator(callId); - this.failWs(callId); + this.failCoordinator(cid); + this.failWs(cid); this.emitPeerConnectionFailure( - callId, + cid, 'publish', code, reason, 'NOT_CONNECTED', ); this.emitPeerConnectionFailure( - callId, + cid, 'subscribe', code, reason, @@ -360,13 +360,13 @@ export class ClientEventReporter { ); }; - private closeCallPairs = (callId: string) => { - if (this.coordinatorPairs.get(callId)) this.failCoordinator(callId); - if (this.wsPairs.get(callId)) this.failWs(callId); + private closeCallPairs = (cid: string) => { + if (this.coordinatorPairs.get(cid)) this.failCoordinator(cid); + if (this.wsPairs.get(cid)) this.failWs(cid); }; - private emitJoinInitiated = (callId: string) => { - const joinAttemptId = this.joinAttemptIds.get(callId); + private emitJoinInitiated = (cid: string) => { + const joinAttemptId = this.joinAttemptIds.get(cid); if (!joinAttemptId) return; const coordinatorConnectId = this.getCoordinatorConnectId(); this.send({ @@ -383,76 +383,73 @@ export class ClientEventReporter { }); }; - private beginAttempt = ( - callId: string, - stage: 'CoordinatorJoin' | 'WSJoin', - ) => { - if (stage === 'CoordinatorJoin') this.beginCoordinatorAttempt(callId); - else this.beginWsAttempt(callId); + private beginAttempt = (cid: string, stage: 'CoordinatorJoin' | 'WSJoin') => { + if (stage === 'CoordinatorJoin') this.beginCoordinatorAttempt(cid); + else this.beginWsAttempt(cid); }; private succeedAttempt = ( - callId: string, + cid: string, stage: 'CoordinatorJoin' | 'WSJoin', ) => { - if (stage === 'CoordinatorJoin') this.succeedCoordinator(callId); - else this.succeedWs(callId); + if (stage === 'CoordinatorJoin') this.succeedCoordinator(cid); + else this.succeedWs(cid); }; private applyStageError = ( - callId: string, + cid: string, stage: 'CoordinatorJoin' | 'WSJoin', err: unknown, ) => { if (stage === 'CoordinatorJoin') { - applyError(this.coordinatorPairs.get(callId), mapHttpError(err)); + applyError(this.coordinatorPairs.get(cid), mapHttpError(err)); } else { - applyError(this.wsPairs.get(callId), mapWsJoinError(err)); + applyError(this.wsPairs.get(cid), mapWsJoinError(err)); } }; - private beginCoordinatorAttempt = (callId: string) => { - let pair = this.coordinatorPairs.get(callId); + private beginCoordinatorAttempt = (cid: string) => { + let pair = this.coordinatorPairs.get(cid); if (!pair) { pair = { sid: generateUUIDv4(), attempts: 0, startedAt: Date.now(), - joinAttemptIdSnapshot: this.joinAttemptIds.get(callId), + joinAttemptIdSnapshot: this.joinAttemptIds.get(cid), }; - this.coordinatorPairs.set(callId, pair); + this.coordinatorPairs.set(cid, pair); pair.initiatedDelivery = this.sendTracked({ - ...this.buildCommon(callId, 'CoordinatorJoin', pair), + ...this.buildCommon(cid, 'CoordinatorJoin', pair), event_type: 'initiated', }); } pair.attempts++; }; - private succeedCoordinator = (callId: string) => { - const pair = this.coordinatorPairs.get(callId); + private succeedCoordinator = (cid: string) => { + const pair = this.coordinatorPairs.get(cid); if (!pair) return; this.sendCompleted(pair, { - ...this.buildCommon(callId, 'CoordinatorJoin', pair), - ...this.sessionIdField(callId), + ...this.buildCommon(cid, 'CoordinatorJoin', pair), + ...this.sessionIdField(cid), event_type: 'completed', outcome: 'success', retry_count_attempt: pair.attempts - 1, elapsed_time: Date.now() - pair.startedAt, }); - this.coordinatorPairs.delete(callId); + this.coordinatorPairs.delete(cid); }; - private failCoordinator = (callId: string) => { - const pair = this.coordinatorPairs.get(callId); + private failCoordinator = (cid: string) => { + const pair = this.coordinatorPairs.get(cid); if (!pair || !pair.lastError) { - this.coordinatorPairs.delete(callId); + this.coordinatorPairs.delete(cid); return; } const { reason, code } = pair.lastError; this.sendCompleted(pair, { - ...this.buildCommon(callId, 'CoordinatorJoin', pair), - ...this.sessionIdField(callId), + ...this.buildCommon(cid, 'CoordinatorJoin', pair), + ...this.sessionIdField(cid), event_type: 'completed', outcome: 'failure', retry_count_attempt: pair.attempts - 1, @@ -460,23 +457,23 @@ export class ClientEventReporter { retry_failure_reason: reason, retry_failure_code: code, }); - this.coordinatorPairs.delete(callId); + this.coordinatorPairs.delete(cid); }; - private beginWsAttempt = (callId: string) => { - let pair = this.wsPairs.get(callId); + private beginWsAttempt = (cid: string) => { + let pair = this.wsPairs.get(cid); if (!pair) { pair = { sid: generateUUIDv4(), attempts: 0, startedAt: Date.now(), - joinAttemptIdSnapshot: this.joinAttemptIds.get(callId), + joinAttemptIdSnapshot: this.joinAttemptIds.get(cid), }; - this.wsPairs.set(callId, pair); - const sfuId = this.getSfuId(callId); + this.wsPairs.set(cid, pair); + const sfuId = this.getSfuId(cid); pair.initiatedDelivery = this.sendTracked({ - ...this.buildCommon(callId, 'WSJoin', pair), - ...this.sessionIdField(callId), + ...this.buildCommon(cid, 'WSJoin', pair), + ...this.sessionIdField(cid), ...(sfuId && { sfu_id: sfuId }), event_type: 'initiated', }); @@ -484,33 +481,33 @@ export class ClientEventReporter { pair.attempts++; }; - private succeedWs = (callId: string) => { - const pair = this.wsPairs.get(callId); + private succeedWs = (cid: string) => { + const pair = this.wsPairs.get(cid); if (!pair) return; - const sfuId = this.getSfuId(callId); + const sfuId = this.getSfuId(cid); this.sendCompleted(pair, { - ...this.buildCommon(callId, 'WSJoin', pair), - ...this.sessionIdField(callId), + ...this.buildCommon(cid, 'WSJoin', pair), + ...this.sessionIdField(cid), ...(sfuId && { sfu_id: sfuId }), event_type: 'completed', outcome: 'success', retry_count_attempt: pair.attempts - 1, elapsed_time: Date.now() - pair.startedAt, }); - this.wsPairs.delete(callId); + this.wsPairs.delete(cid); }; - private failWs = (callId: string) => { - const pair = this.wsPairs.get(callId); + private failWs = (cid: string) => { + const pair = this.wsPairs.get(cid); if (!pair || !pair.lastError) { - this.wsPairs.delete(callId); + this.wsPairs.delete(cid); return; } const { reason, code } = pair.lastError; - const sfuId = this.getSfuId(callId); + const sfuId = this.getSfuId(cid); this.sendCompleted(pair, { - ...this.buildCommon(callId, 'WSJoin', pair), - ...this.sessionIdField(callId), + ...this.buildCommon(cid, 'WSJoin', pair), + ...this.sessionIdField(cid), event_type: 'completed', outcome: 'failure', retry_count_attempt: pair.attempts - 1, @@ -519,11 +516,11 @@ export class ClientEventReporter { retry_failure_reason: reason, retry_failure_code: code, }); - this.wsPairs.delete(callId); + this.wsPairs.delete(cid); }; onPeerConnectionStateChange = ( - callId: string, + cid: string, event: PeerConnectionStateChangeEvent, ) => { const role: ClientEventPeerConnection = @@ -531,7 +528,7 @@ export class ClientEventReporter { if (event.stateType === 'ice' && event.state === 'failed') { this.emitPeerConnectionFailure( - callId, + cid, role, 'ICE_CONNECTIVITY_FAILED', 'ICE connectivity checks failed', @@ -542,7 +539,7 @@ export class ClientEventReporter { if (event.stateType === 'peerConnection' && event.state === 'failed') { this.emitPeerConnectionFailure( - callId, + cid, role, 'DTLS_CONNECTIVITY_FAILED', 'DTLS connectivity checks failed', @@ -555,12 +552,12 @@ export class ClientEventReporter { switch (event.state) { case 'connecting': - if (this.peerConnectionPairs.has(pcKey(callId, role))) return; - this.openPeerConnectionPair(callId, role); + if (this.peerConnectionPairs.has(pcKey(cid, role))) return; + this.openPeerConnectionPair(cid, role); break; case 'connected': - this.emitPeerConnectionSuccess(callId, role); - this.pcEverConnected.set(pcKey(callId, role), true); + this.emitPeerConnectionSuccess(cid, role); + this.pcEverConnected.set(pcKey(cid, role), true); break; default: break; @@ -568,24 +565,24 @@ export class ClientEventReporter { }; private openPeerConnectionPair = ( - callId: string, + cid: string, role: ClientEventPeerConnection, ) => { - const key = pcKey(callId, role); + const key = pcKey(cid, role); const pair: PeerConnectionPairState = { sid: generateUUIDv4(), attempts: 0, startedAt: Date.now(), - joinAttemptIdSnapshot: this.joinAttemptIds.get(callId), - sfuId: this.getSfuId(callId), - userSessionId: this.getUserSessionId(callId), + joinAttemptIdSnapshot: this.joinAttemptIds.get(cid), + sfuId: this.getSfuId(cid), + userSessionId: this.getUserSessionId(cid), wasPreviouslyConnected: this.pcEverConnected.get(key) === true, }; this.peerConnectionPairs.set(key, pair); pair.initiatedDelivery = this.sendTracked({ - ...this.buildCommon(callId, 'PeerConnectionConnect', pair), - ...this.sessionIdField(callId), + ...this.buildCommon(cid, 'PeerConnectionConnect', pair), + ...this.sessionIdField(cid), peer_connection: role, was_previously_connected: pair.wasPreviouslyConnected, ...(pair.sfuId && { sfu_id: pair.sfuId }), @@ -597,16 +594,16 @@ export class ClientEventReporter { }; private emitPeerConnectionSuccess = ( - callId: string, + cid: string, role: ClientEventPeerConnection, ) => { - const key = pcKey(callId, role); + const key = pcKey(cid, role); const pair = this.peerConnectionPairs.get(key); if (!pair) return; this.sendCompleted(pair, { - ...this.buildCommon(callId, 'PeerConnectionConnect', pair), - ...this.sessionIdField(callId), + ...this.buildCommon(cid, 'PeerConnectionConnect', pair), + ...this.sessionIdField(cid), peer_connection: role, was_previously_connected: pair.wasPreviouslyConnected, ...(pair.sfuId && { sfu_id: pair.sfuId }), @@ -622,19 +619,19 @@ export class ClientEventReporter { }; private emitPeerConnectionFailure = ( - callId: string, + cid: string, role: ClientEventPeerConnection, code: ClientEventStandardCode, reason: string, iceState: 'CONNECTED' | 'FAILED' | 'NOT_CONNECTED', ) => { - const key = pcKey(callId, role); + const key = pcKey(cid, role); const pair = this.peerConnectionPairs.get(key); if (!pair) return; this.sendCompleted(pair, { - ...this.buildCommon(callId, 'PeerConnectionConnect', pair), - ...this.sessionIdField(callId), + ...this.buildCommon(cid, 'PeerConnectionConnect', pair), + ...this.sessionIdField(cid), peer_connection: role, was_previously_connected: pair.wasPreviouslyConnected, ...(pair.userSessionId && { @@ -652,15 +649,14 @@ export class ClientEventReporter { this.peerConnectionPairs.delete(key); }; - private getSfuId = (callId: string): string => - this.callContexts.get(callId)?.getSfuId() ?? ''; + private getSfuId = (cid: string): string => + this.callContexts.get(cid)?.getSfuId() ?? ''; - private getUserSessionId = (callId: string): string => - this.callContexts.get(callId)?.getUserSessionId() ?? ''; + private getUserSessionId = (cid: string): string => + this.callContexts.get(cid)?.getUserSessionId() ?? ''; - private sessionIdField = (callId: string): Record => { - const callSessionId = - this.callContexts.get(callId)?.getCallSessionId() ?? ''; + private sessionIdField = (cid: string): Record => { + const callSessionId = this.callContexts.get(cid)?.getCallSessionId() ?? ''; return callSessionId ? { call_session_id: callSessionId } : {}; }; From 5b5f7f8ed54530408f197a9142cb987fcd2c6ba3 Mon Sep 17 00:00:00 2001 From: jonadimovska Date: Sat, 6 Jun 2026 22:45:22 +0200 Subject: [PATCH 32/65] feat(client): client call event reporting - simplify onRemoteTrackUnmute --- packages/client/src/Call.ts | 3 +-- packages/client/src/rtc/Subscriber.ts | 2 +- packages/client/src/rtc/types.ts | 1 - 3 files changed, 2 insertions(+), 4 deletions(-) diff --git a/packages/client/src/Call.ts b/packages/client/src/Call.ts index 844fd8974d..d07d8d39db 100644 --- a/packages/client/src/Call.ts +++ b/packages/client/src/Call.ts @@ -1551,12 +1551,11 @@ export class Call { event, ); }, - onRemoteTrackUnmute: (trackType, trackId, sfuId) => { + onRemoteTrackUnmute: (trackType, trackId) => { this.streamClient.clientEventReporter.reportFirstFrame( this.cid, trackType, trackId, - sfuId, ); }, }; diff --git a/packages/client/src/rtc/Subscriber.ts b/packages/client/src/rtc/Subscriber.ts index 384bf418dc..3705905718 100644 --- a/packages/client/src/rtc/Subscriber.ts +++ b/packages/client/src/rtc/Subscriber.ts @@ -94,7 +94,7 @@ export class Subscriber extends BasePeerConnection { track.addEventListener('unmute', () => { this.logger.info(`[onTrack]: Track unmuted: ${trackDebugInfo}`); this.setRemoteTrackInterrupted(trackId, trackType, false); - this.onRemoteTrackUnmute?.(trackType, track.id, this.sfuClient.edgeName); + this.onRemoteTrackUnmute?.(trackType, track.id); }); track.addEventListener('ended', () => { this.logger.info(`[onTrack]: Track ended: ${trackDebugInfo}`); diff --git a/packages/client/src/rtc/types.ts b/packages/client/src/rtc/types.ts index cd575b17d9..4d15f0a87a 100644 --- a/packages/client/src/rtc/types.ts +++ b/packages/client/src/rtc/types.ts @@ -83,7 +83,6 @@ export type OnPeerConnectionStateChange = ( export type OnRemoteTrackUnmute = ( trackType: TrackType, trackId: string, - sfuId: string, ) => void; export type BasePeerConnectionOpts = { From 68f5121760446b8a10a53227f1719d04ef0172d9 Mon Sep 17 00:00:00 2001 From: jonadimovska Date: Sat, 6 Jun 2026 22:46:49 +0200 Subject: [PATCH 33/65] feat(client): client call event reporting - simplify onRemoteTrackUnmute --- packages/client/src/stats/ClientEventReporter.ts | 9 ++------- 1 file changed, 2 insertions(+), 7 deletions(-) diff --git a/packages/client/src/stats/ClientEventReporter.ts b/packages/client/src/stats/ClientEventReporter.ts index 36e0ffa4a0..0024f5a5fc 100644 --- a/packages/client/src/stats/ClientEventReporter.ts +++ b/packages/client/src/stats/ClientEventReporter.ts @@ -222,12 +222,7 @@ export class ClientEventReporter { }); }; - reportFirstFrame = ( - cid: string, - trackType: TrackType, - trackId: string, - sfuId?: string, - ) => { + reportFirstFrame = (cid: string, trackType: TrackType, trackId: string) => { const stage = trackType === TrackType.VIDEO ? 'FirstVideoFrame' @@ -248,7 +243,7 @@ export class ClientEventReporter { joinAttemptIdSnapshot: this.joinAttemptIds.get(cid), }; - const resolvedSfuId = sfuId || this.getSfuId(cid); + const resolvedSfuId = this.getSfuId(cid); this.send({ ...this.buildCommon(cid, stage, pair), ...this.sessionIdField(cid), From de439f7bf86d2b817f96a89ad666cd8099d4995e Mon Sep 17 00:00:00 2001 From: jonadimovska Date: Sat, 6 Jun 2026 22:47:41 +0200 Subject: [PATCH 34/65] feat(client): client call event reporting - drop comments --- packages/client/src/stats/ClientEventReporter.ts | 7 ------- 1 file changed, 7 deletions(-) diff --git a/packages/client/src/stats/ClientEventReporter.ts b/packages/client/src/stats/ClientEventReporter.ts index 0024f5a5fc..c04e852723 100644 --- a/packages/client/src/stats/ClientEventReporter.ts +++ b/packages/client/src/stats/ClientEventReporter.ts @@ -681,24 +681,17 @@ export class ClientEventReporter { }; }; - // Fire-and-forget send for standalone events (no `initiated`/`completed` - // pairing, e.g. JoinInitiated, MediaDevicePermission, FirstFrame). private send = (body: Record) => { if (this.disposed) return; void this.sendWithRetry(body); }; - // Send and report whether it was actually delivered. Used to track the - // delivery of `initiated` events so the paired `completed` can be gated on it. private sendTracked = (body: Record): Promise => { if (this.disposed) return Promise.resolve(false); return this.sendWithRetry(body); }; - // Emit a `completed` event only if its `initiated` was delivered. If the - // `initiated` never made it (e.g. dropped while offline), drop the - // `completed` too — losing both is preferable to an orphaned `completed`. private sendCompleted = ( pair: StagePairState, body: Record, From 636151c55109c627ce1a2dc0025d5110db89617b Mon Sep 17 00:00:00 2001 From: jonadimovska Date: Mon, 8 Jun 2026 12:06:26 +0200 Subject: [PATCH 35/65] feat(client): client call event reporting - simplify --- packages/client/src/Call.ts | 4 ---- .../src/coordinator/connection/client.ts | 13 ++++++------ .../client/src/stats/ClientEventReporter.ts | 21 +++++++------------ 3 files changed, 13 insertions(+), 25 deletions(-) diff --git a/packages/client/src/Call.ts b/packages/client/src/Call.ts index d07d8d39db..d8b6dc1a7e 100644 --- a/packages/client/src/Call.ts +++ b/packages/client/src/Call.ts @@ -2040,10 +2040,6 @@ export class Call { this.tracer.trace('network.changed', e); if (!e.online) { this.logger.debug('[Reconnect] Going offline'); - this.streamClient.clientEventReporter.captureWsError(this.cid, { - code: 'NETWORK_OFFLINE', - reason: 'Device offline', - }); if (!this.hasJoinedOnce) return; this.lastOfflineTimestamp = Date.now(); // create a new task that would resolve when the network is available diff --git a/packages/client/src/coordinator/connection/client.ts b/packages/client/src/coordinator/connection/client.ts index ac61380acb..58b02fd9be 100644 --- a/packages/client/src/coordinator/connection/client.ts +++ b/packages/client/src/coordinator/connection/client.ts @@ -152,13 +152,7 @@ export class StreamClient { this.logger = videoLoggerSystem.getLogger('coordinator'); - const { clientAppIdentifier = {} } = this.options; - this.clientEventReporter = new ClientEventReporter({ - streamClient: this, - sdkVersion: - clientAppIdentifier.sdkVersion ?? process.env.PKG_VERSION ?? '0.0.0', - userAgent: this.getUserAgent(), - }); + this.clientEventReporter = new ClientEventReporter({ streamClient: this }); } getAuthType = () => { @@ -630,6 +624,11 @@ export class StreamClient { return await this.wsConnection.connect(this.defaultWSTimeout); }; + getSdkVersion = (): string => + this.options.clientAppIdentifier?.sdkVersion || + process.env.PKG_VERSION || + '0.0.0'; + getUserAgent = (): string => { if (!this.cachedUserAgent) { const { clientAppIdentifier = {} } = this.options; diff --git a/packages/client/src/stats/ClientEventReporter.ts b/packages/client/src/stats/ClientEventReporter.ts index c04e852723..3b5b2b02d8 100644 --- a/packages/client/src/stats/ClientEventReporter.ts +++ b/packages/client/src/stats/ClientEventReporter.ts @@ -40,22 +40,19 @@ export type ClientEventStandardCode = | 'CLIENT_ABORTED' | 'BACKEND_LEAVE' | 'REQUEST_TIMEOUT' - | 'NETWORK_OFFLINE' | 'ICE_CONNECTIVITY_FAILED' | 'DTLS_CONNECTIVITY_FAILED'; export type CallReportContext = { callType: string; callId: string; - getCallSessionId: () => string; getSfuId: () => string; + getCallSessionId: () => string; getUserSessionId: () => string; }; export type ClientEventReporterOptions = { streamClient: StreamClient; - sdkVersion: string; - userAgent: string; }; const SEVERITY = { @@ -93,8 +90,6 @@ export class ClientEventReporter { private readonly logger = videoLoggerSystem.getLogger('ClientEventReporter'); private readonly streamClient: StreamClient; - private readonly sdkVersion: string; - private readonly userAgent: string; private disposed = false; private coordinatorConnectId?: string; @@ -110,8 +105,6 @@ export class ClientEventReporter { constructor(options: ClientEventReporterOptions) { this.streamClient = options.streamClient; - this.sdkVersion = options.sdkVersion; - this.userAgent = options.userAgent; } private getUserId = (): string => this.streamClient.userID ?? ''; @@ -195,8 +188,8 @@ export class ClientEventReporter { coordinator_connect_id: this.coordinatorConnectId, }), timestamp: new Date().toISOString(), - user_agent: this.userAgent, - sdk_version: this.sdkVersion, + user_agent: this.streamClient.getUserAgent(), + sdk_version: this.streamClient.getSdkVersion(), }); private emitMediaPermission = (cid: string) => { @@ -372,8 +365,8 @@ export class ClientEventReporter { coordinator_connect_id: coordinatorConnectId, }), timestamp: new Date().toISOString(), - user_agent: this.userAgent, - sdk_version: this.sdkVersion, + user_agent: this.streamClient.getUserAgent(), + sdk_version: this.streamClient.getSdkVersion(), event_type: 'initiated', }); }; @@ -676,8 +669,8 @@ export class ClientEventReporter { coordinator_connect_id: coordinatorConnectId, }), timestamp: new Date().toISOString(), - user_agent: this.userAgent, - sdk_version: this.sdkVersion, + user_agent: this.streamClient.getUserAgent(), + sdk_version: this.streamClient.getSdkVersion(), }; }; From 565e66a92913c6938d2449180ca1488375de32ac Mon Sep 17 00:00:00 2001 From: jonadimovska Date: Mon, 8 Jun 2026 12:55:40 +0200 Subject: [PATCH 36/65] feat(client): client call event reporting - add tests --- .../client/src/stats/ClientEventReporter.ts | 2 +- .../__tests__/ClientEventReporter.test.ts | 265 ++++++++++++++++++ 2 files changed, 266 insertions(+), 1 deletion(-) create mode 100644 packages/client/src/stats/__tests__/ClientEventReporter.test.ts diff --git a/packages/client/src/stats/ClientEventReporter.ts b/packages/client/src/stats/ClientEventReporter.ts index 3b5b2b02d8..f6fa73116b 100644 --- a/packages/client/src/stats/ClientEventReporter.ts +++ b/packages/client/src/stats/ClientEventReporter.ts @@ -89,7 +89,7 @@ const pcKey = (cid: string, role: ClientEventPeerConnection): string => export class ClientEventReporter { private readonly logger = videoLoggerSystem.getLogger('ClientEventReporter'); - private readonly streamClient: StreamClient; + private streamClient: StreamClient; private disposed = false; private coordinatorConnectId?: string; diff --git a/packages/client/src/stats/__tests__/ClientEventReporter.test.ts b/packages/client/src/stats/__tests__/ClientEventReporter.test.ts new file mode 100644 index 0000000000..12db82309e --- /dev/null +++ b/packages/client/src/stats/__tests__/ClientEventReporter.test.ts @@ -0,0 +1,265 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { fromPartial } from '@total-typescript/shoehorn'; +import { of } from 'rxjs'; +import { ClientEventReporter, CallReportContext } from '../ClientEventReporter'; +import type { StreamClient } from '../../coordinator/connection/client'; +import { PeerType, TrackType } from '../../gen/video/sfu/models/models'; + +vi.mock('../../devices', () => ({ + getAudioBrowserPermission: () => ({ asStateObservable: () => of('granted') }), + getVideoBrowserPermission: () => ({ asStateObservable: () => of('granted') }), +})); + +const flush = () => new Promise((resolve) => setTimeout(resolve, 0)); + +describe('ClientEventReporter', () => { + const cid = 'default:call-1'; + let doAxiosRequest: ReturnType; + let reporter: ClientEventReporter; + let connectId: string; + + const postedEvents = (): Array> => + doAxiosRequest.mock.calls.map((call) => call[2].events[0]); + + beforeEach(() => { + doAxiosRequest = vi.fn().mockResolvedValue({}); + const streamClient = fromPartial({ + userID: 'user-1', + doAxiosRequest, + getUserAgent: () => 'test-agent', + getSdkVersion: () => '1.0.0', + }); + reporter = new ClientEventReporter({ streamClient }); + connectId = reporter.mintCoordinatorConnectId(); + + const ctx: CallReportContext = { + callType: 'default', + callId: 'call-1', + getCallSessionId: () => 'session-1', + getSfuId: () => 'sfu-1', + getUserSessionId: () => 'user-session-1', + }; + reporter.registerCall(cid, ctx); + }); + + it('emits an initiated then a completed event on success', async () => { + reporter.startCorrelation(cid); + await reporter.track(cid, 'CoordinatorJoin', () => Promise.resolve('ok')); + await flush(); + + const events = postedEvents().filter((e) => e.stage === 'CoordinatorJoin'); + expect(events).toHaveLength(2); + expect(events[0]).toMatchObject({ + event_type: 'initiated', + call_cid: cid, + user_id: 'user-1', + coordinator_connect_id: connectId, + }); + expect(events[1]).toMatchObject({ + event_type: 'completed', + outcome: 'success', + retry_count_attempt: 0, + }); + + expect(events[0].stage_id).toBe(events[1].stage_id); + expect(events[0].join_attempt_id).toBeTruthy(); + }); + + it('folds in-stage retries into a single pair', async () => { + reporter.startCorrelation(cid); + await expect( + reporter.track(cid, 'CoordinatorJoin', () => + Promise.reject(new Error('boom')), + ), + ).rejects.toThrow('boom'); + await reporter.track(cid, 'CoordinatorJoin', () => Promise.resolve('ok')); + await flush(); + + const events = postedEvents().filter((e) => e.stage === 'CoordinatorJoin'); + const initiated = events.filter((e) => e.event_type === 'initiated'); + const completed = events.filter((e) => e.event_type === 'completed'); + + expect(initiated).toHaveLength(1); + expect(completed).toHaveLength(1); + expect(completed[0]).toMatchObject({ + outcome: 'success', + retry_count_attempt: 1, + }); + }); + + it('emits a failure completion on abort', async () => { + reporter.startCorrelation(cid); + void reporter.track(cid, 'CoordinatorJoin', () => new Promise(() => {})); + reporter.abort(cid, { code: 'CLIENT_ABORTED', reason: 'user left' }); + await flush(); + + const completed = postedEvents().filter( + (e) => e.stage === 'CoordinatorJoin' && e.event_type === 'completed', + ); + + expect(completed).toHaveLength(1); + expect(completed[0]).toMatchObject({ + outcome: 'failure', + retry_failure_code: 'CLIENT_ABORTED', + retry_failure_reason: 'user left', + }); + }); + + it('emits a JoinInitiated event when correlation starts', async () => { + reporter.startCorrelation(cid); + await flush(); + + const join = postedEvents().filter((e) => e.stage === 'JoinInitiated'); + expect(join).toHaveLength(1); + expect(join[0]).toMatchObject({ + event_type: 'initiated', + coordinator_connect_id: connectId, + }); + expect(join[0].join_attempt_id).toBeTruthy(); + }); + + it('does not retry events rejected with a 4xx', async () => { + doAxiosRequest.mockRejectedValue({ response: { status: 400 } }); + reporter.reportFirstFrame(cid, TrackType.VIDEO, 'track-1'); + await flush(); + + expect(doAxiosRequest).toHaveBeenCalledTimes(1); + }); + + it('tracks the coordinator websocket connection', async () => { + await reporter.trackCoordinatorWs(() => Promise.resolve('ok')); + await flush(); + + const events = postedEvents().filter((e) => e.stage === 'CoordinatorWS'); + expect(events).toHaveLength(2); + expect(events[0]).toMatchObject({ + event_type: 'initiated', + coordinator_connect_id: connectId, + }); + expect(events[1]).toMatchObject({ + event_type: 'completed', + outcome: 'success', + }); + }); + + it('reports a failed coordinator websocket connection on close', async () => { + await expect( + reporter.trackCoordinatorWs(() => Promise.reject(new Error('ws down'))), + ).rejects.toThrow('ws down'); + reporter.closeCoordinatorWs(); + await flush(); + + const completed = postedEvents().filter( + (e) => e.stage === 'CoordinatorWS' && e.event_type === 'completed', + ); + expect(completed).toHaveLength(1); + expect(completed[0]).toMatchObject({ + outcome: 'failure', + retry_failure_code: 'NETWORK_ERROR', + }); + }); + + it('includes sfu_id and call_session_id on a WSJoin completion', async () => { + reporter.startCorrelation(cid); + await reporter.track(cid, 'WSJoin', () => Promise.resolve('ok')); + await flush(); + + const events = postedEvents().filter((e) => e.stage === 'WSJoin'); + expect(events).toHaveLength(2); + expect(events[1]).toMatchObject({ + event_type: 'completed', + outcome: 'success', + sfu_id: 'sfu-1', + call_session_id: 'session-1', + }); + }); + + it('reports media device permission status on correlation start', async () => { + reporter.startCorrelation(cid); + await flush(); + + const perm = postedEvents().filter( + (e) => e.stage === 'MediaDevicePermission', + ); + expect(perm).toHaveLength(1); + expect(perm[0]).toMatchObject({ + event_type: 'initiated', + microphone_permission_status: 'GRANTED', + camera_permission_status: 'GRANTED', + }); + }); + + it('reports the first video frame only once', async () => { + reporter.startCorrelation(cid); + reporter.reportFirstFrame(cid, TrackType.VIDEO, 'track-1'); + reporter.reportFirstFrame(cid, TrackType.VIDEO, 'track-1'); + await flush(); + + const frames = postedEvents().filter((e) => e.stage === 'FirstVideoFrame'); + expect(frames).toHaveLength(1); + expect(frames[0]).toMatchObject({ + event_type: 'initiated', + track_id: 'track-1', + sfu_id: 'sfu-1', + }); + }); + + it('tracks a publisher peer connection connect', async () => { + reporter.startCorrelation(cid); + reporter.onPeerConnectionStateChange(cid, { + peerType: PeerType.PUBLISHER_UNSPECIFIED, + stateType: 'peerConnection', + state: 'connecting', + }); + reporter.onPeerConnectionStateChange(cid, { + peerType: PeerType.PUBLISHER_UNSPECIFIED, + stateType: 'peerConnection', + state: 'connected', + }); + await flush(); + + const events = postedEvents().filter( + (e) => e.stage === 'PeerConnectionConnect', + ); + expect(events).toHaveLength(2); + expect(events[0]).toMatchObject({ + event_type: 'initiated', + peer_connection: 'publish', + was_previously_connected: false, + sfu_id: 'sfu-1', + user_session_id: 'user-session-1', + }); + expect(events[1]).toMatchObject({ + event_type: 'completed', + outcome: 'success', + peer_connection: 'publish', + }); + }); + + it('reports an ICE failure on the peer connection', async () => { + reporter.startCorrelation(cid); + reporter.onPeerConnectionStateChange(cid, { + peerType: PeerType.SUBSCRIBER, + stateType: 'peerConnection', + state: 'connecting', + }); + reporter.onPeerConnectionStateChange(cid, { + peerType: PeerType.SUBSCRIBER, + stateType: 'ice', + state: 'failed', + }); + await flush(); + + const completed = postedEvents().filter( + (e) => + e.stage === 'PeerConnectionConnect' && e.event_type === 'completed', + ); + expect(completed).toHaveLength(1); + expect(completed[0]).toMatchObject({ + outcome: 'failure', + peer_connection: 'subscribe', + retry_failure_code: 'ICE_CONNECTIVITY_FAILED', + ice_state: 'FAILED', + }); + }); +}); From dda5bf4154e487e894cf074a7e3d6253d58cb87a Mon Sep 17 00:00:00 2001 From: jonadimovska Date: Mon, 8 Jun 2026 13:42:41 +0200 Subject: [PATCH 37/65] feat(client): client call event reporting - add join reason --- packages/client/src/Call.ts | 20 +++++++++--- .../client/src/stats/ClientEventReporter.ts | 21 +++++++++++-- .../__tests__/ClientEventReporter.test.ts | 31 +++++++++++++------ 3 files changed, 57 insertions(+), 15 deletions(-) diff --git a/packages/client/src/Call.ts b/packages/client/src/Call.ts index d8b6dc1a7e..8d1d38a789 100644 --- a/packages/client/src/Call.ts +++ b/packages/client/src/Call.ts @@ -142,6 +142,7 @@ import { import { createStatsReporter, getSdkSignature, + JoinReason, SfuStatsReporter, StatsReporter, Tracer, @@ -1085,7 +1086,7 @@ export class Call { const joinData: JoinCallData = data; maxJoinRetries = Math.max(maxJoinRetries, 1); try { - await this.withJoinLifecycle(async () => { + await this.withJoinLifecycle('first-attempt', async () => { for (let attempt = 0; attempt < maxJoinRetries; attempt++) { try { this.logger.trace(`Joining call (${attempt})`, this.cid); @@ -1117,6 +1118,7 @@ export class Call { ); this.streamClient.clientEventReporter.startCorrelation( this.cid, + 'first-attempt', ); } } @@ -1134,7 +1136,10 @@ export class Call { } }; - private withJoinLifecycle = (op: () => Promise): Promise => { + private withJoinLifecycle = ( + joinReason: JoinReason, + op: () => Promise, + ): Promise => { this.streamClient.clientEventReporter.registerCall(this.cid, { callType: this.type, callId: this.id, @@ -1145,6 +1150,7 @@ export class Call { return this.streamClient.clientEventReporter.withJoinLifecycle( this.cid, + joinReason, op, ); }; @@ -1914,7 +1920,13 @@ export class Call { const reconnectStartTime = Date.now(); this.reconnectStrategy = WebsocketReconnectStrategy.REJOIN; this.state.setCallingState(CallingState.RECONNECTING); - await this.withJoinLifecycle(() => this.doJoin(this.joinCallData)); + const joinReason: JoinReason = + this.reconnectReason === ReconnectReason.NETWORK_BACK_ONLINE + ? 'network-available' + : 'full-rejoin'; + await this.withJoinLifecycle(joinReason, () => + this.doJoin(this.joinCallData), + ); await this.restorePublishedTracks(); this.restoreSubscribedTracks(); this.sfuStatsReporter?.sendReconnectionTime( @@ -1946,7 +1958,7 @@ export class Call { try { const currentSfu = currentSfuClient.edgeName; - await this.withJoinLifecycle(() => + await this.withJoinLifecycle('migration', () => this.doJoin({ ...this.joinCallData, migrating_from: currentSfu, diff --git a/packages/client/src/stats/ClientEventReporter.ts b/packages/client/src/stats/ClientEventReporter.ts index f6fa73116b..c6f9817506 100644 --- a/packages/client/src/stats/ClientEventReporter.ts +++ b/packages/client/src/stats/ClientEventReporter.ts @@ -36,6 +36,12 @@ export type MediaPermissionState = | 'GRANTED' | 'NOT_INITIATED'; +export type JoinReason = + | 'first-attempt' + | 'network-available' + | 'migration' + | 'full-rejoin'; + export type ClientEventStandardCode = | 'CLIENT_ABORTED' | 'BACKEND_LEAVE' @@ -72,6 +78,7 @@ type StagePairState = { attempts: number; startedAt: number; joinAttemptIdSnapshot?: string; + joinReasonSnapshot?: JoinReason; userIdSnapshot?: string; lastError?: StageError; initiatedDelivery?: Promise; @@ -97,6 +104,7 @@ export class ClientEventReporter { private callContexts = new Map(); private joinAttemptIds = new Map(); + private joinReasons = new Map(); private coordinatorPairs = new Map(); private wsPairs = new Map(); private peerConnectionPairs = new Map(); @@ -253,6 +261,7 @@ export class ClientEventReporter { unregisterCall = (cid: string) => { this.callContexts.delete(cid); this.joinAttemptIds.delete(cid); + this.joinReasons.delete(cid); this.coordinatorPairs.delete(cid); this.wsPairs.delete(cid); @@ -266,9 +275,10 @@ export class ClientEventReporter { } }; - startCorrelation = (cid: string) => { + startCorrelation = (cid: string, joinReason: JoinReason) => { this.closeCallPairs(cid); this.joinAttemptIds.set(cid, generateUUIDv4()); + this.joinReasons.set(cid, joinReason); this.firstFrameReported.delete(`${cid}:FirstVideoFrame`); this.firstFrameReported.delete(`${cid}:FirstAudioFrame`); this.emitJoinInitiated(cid); @@ -277,9 +287,10 @@ export class ClientEventReporter { withJoinLifecycle = async ( cid: string, + joinReason: JoinReason, op: () => Promise, ): Promise => { - this.startCorrelation(cid); + this.startCorrelation(cid, joinReason); try { return await op(); } catch (err) { @@ -404,10 +415,14 @@ export class ClientEventReporter { attempts: 0, startedAt: Date.now(), joinAttemptIdSnapshot: this.joinAttemptIds.get(cid), + joinReasonSnapshot: this.joinReasons.get(cid), }; this.coordinatorPairs.set(cid, pair); pair.initiatedDelivery = this.sendTracked({ ...this.buildCommon(cid, 'CoordinatorJoin', pair), + ...(pair.joinReasonSnapshot && { + join_reason: pair.joinReasonSnapshot, + }), event_type: 'initiated', }); } @@ -420,6 +435,7 @@ export class ClientEventReporter { this.sendCompleted(pair, { ...this.buildCommon(cid, 'CoordinatorJoin', pair), ...this.sessionIdField(cid), + ...(pair.joinReasonSnapshot && { join_reason: pair.joinReasonSnapshot }), event_type: 'completed', outcome: 'success', retry_count_attempt: pair.attempts - 1, @@ -438,6 +454,7 @@ export class ClientEventReporter { this.sendCompleted(pair, { ...this.buildCommon(cid, 'CoordinatorJoin', pair), ...this.sessionIdField(cid), + ...(pair.joinReasonSnapshot && { join_reason: pair.joinReasonSnapshot }), event_type: 'completed', outcome: 'failure', retry_count_attempt: pair.attempts - 1, diff --git a/packages/client/src/stats/__tests__/ClientEventReporter.test.ts b/packages/client/src/stats/__tests__/ClientEventReporter.test.ts index 12db82309e..ce45c0b30e 100644 --- a/packages/client/src/stats/__tests__/ClientEventReporter.test.ts +++ b/packages/client/src/stats/__tests__/ClientEventReporter.test.ts @@ -43,7 +43,7 @@ describe('ClientEventReporter', () => { }); it('emits an initiated then a completed event on success', async () => { - reporter.startCorrelation(cid); + reporter.startCorrelation(cid, 'first-attempt'); await reporter.track(cid, 'CoordinatorJoin', () => Promise.resolve('ok')); await flush(); @@ -54,19 +54,32 @@ describe('ClientEventReporter', () => { call_cid: cid, user_id: 'user-1', coordinator_connect_id: connectId, + join_reason: 'first-attempt', }); expect(events[1]).toMatchObject({ event_type: 'completed', outcome: 'success', retry_count_attempt: 0, + join_reason: 'first-attempt', }); expect(events[0].stage_id).toBe(events[1].stage_id); expect(events[0].join_attempt_id).toBeTruthy(); }); + it('carries the join_reason given to startCorrelation', async () => { + reporter.startCorrelation(cid, 'migration'); + await reporter.track(cid, 'CoordinatorJoin', () => Promise.resolve('ok')); + await flush(); + + const events = postedEvents().filter((e) => e.stage === 'CoordinatorJoin'); + expect(events).toHaveLength(2); + expect(events[0]).toMatchObject({ join_reason: 'migration' }); + expect(events[1]).toMatchObject({ join_reason: 'migration' }); + }); + it('folds in-stage retries into a single pair', async () => { - reporter.startCorrelation(cid); + reporter.startCorrelation(cid, 'first-attempt'); await expect( reporter.track(cid, 'CoordinatorJoin', () => Promise.reject(new Error('boom')), @@ -88,7 +101,7 @@ describe('ClientEventReporter', () => { }); it('emits a failure completion on abort', async () => { - reporter.startCorrelation(cid); + reporter.startCorrelation(cid, 'first-attempt'); void reporter.track(cid, 'CoordinatorJoin', () => new Promise(() => {})); reporter.abort(cid, { code: 'CLIENT_ABORTED', reason: 'user left' }); await flush(); @@ -106,7 +119,7 @@ describe('ClientEventReporter', () => { }); it('emits a JoinInitiated event when correlation starts', async () => { - reporter.startCorrelation(cid); + reporter.startCorrelation(cid, 'first-attempt'); await flush(); const join = postedEvents().filter((e) => e.stage === 'JoinInitiated'); @@ -160,7 +173,7 @@ describe('ClientEventReporter', () => { }); it('includes sfu_id and call_session_id on a WSJoin completion', async () => { - reporter.startCorrelation(cid); + reporter.startCorrelation(cid, 'first-attempt'); await reporter.track(cid, 'WSJoin', () => Promise.resolve('ok')); await flush(); @@ -175,7 +188,7 @@ describe('ClientEventReporter', () => { }); it('reports media device permission status on correlation start', async () => { - reporter.startCorrelation(cid); + reporter.startCorrelation(cid, 'first-attempt'); await flush(); const perm = postedEvents().filter( @@ -190,7 +203,7 @@ describe('ClientEventReporter', () => { }); it('reports the first video frame only once', async () => { - reporter.startCorrelation(cid); + reporter.startCorrelation(cid, 'first-attempt'); reporter.reportFirstFrame(cid, TrackType.VIDEO, 'track-1'); reporter.reportFirstFrame(cid, TrackType.VIDEO, 'track-1'); await flush(); @@ -205,7 +218,7 @@ describe('ClientEventReporter', () => { }); it('tracks a publisher peer connection connect', async () => { - reporter.startCorrelation(cid); + reporter.startCorrelation(cid, 'first-attempt'); reporter.onPeerConnectionStateChange(cid, { peerType: PeerType.PUBLISHER_UNSPECIFIED, stateType: 'peerConnection', @@ -237,7 +250,7 @@ describe('ClientEventReporter', () => { }); it('reports an ICE failure on the peer connection', async () => { - reporter.startCorrelation(cid); + reporter.startCorrelation(cid, 'first-attempt'); reporter.onPeerConnectionStateChange(cid, { peerType: PeerType.SUBSCRIBER, stateType: 'peerConnection', From e3a144d68d7a5224f400d20d7b2beefa1627ce5c Mon Sep 17 00:00:00 2001 From: jonadimovska Date: Mon, 8 Jun 2026 14:07:26 +0200 Subject: [PATCH 38/65] feat(client): client call event reporting - simplify --- packages/client/src/stats/ClientEventReporter.ts | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/packages/client/src/stats/ClientEventReporter.ts b/packages/client/src/stats/ClientEventReporter.ts index c6f9817506..acc38ecfc9 100644 --- a/packages/client/src/stats/ClientEventReporter.ts +++ b/packages/client/src/stats/ClientEventReporter.ts @@ -117,7 +117,8 @@ export class ClientEventReporter { private getUserId = (): string => this.streamClient.userID ?? ''; - getCoordinatorConnectId = (): string => this.coordinatorConnectId ?? ''; + private getCoordinatorConnectId = (): string => + this.coordinatorConnectId ?? ''; mintCoordinatorConnectId = (): string => { this.coordinatorConnectId = generateUUIDv4(); @@ -277,10 +278,12 @@ export class ClientEventReporter { startCorrelation = (cid: string, joinReason: JoinReason) => { this.closeCallPairs(cid); + this.joinAttemptIds.set(cid, generateUUIDv4()); this.joinReasons.set(cid, joinReason); this.firstFrameReported.delete(`${cid}:FirstVideoFrame`); this.firstFrameReported.delete(`${cid}:FirstAudioFrame`); + this.emitJoinInitiated(cid); this.emitMediaPermission(cid); }; From e9bddecc43b239342c88623aaee61ff1149d5dcd Mon Sep 17 00:00:00 2001 From: jonadimovska Date: Mon, 8 Jun 2026 14:14:46 +0200 Subject: [PATCH 39/65] feat(client): client call event reporting - simplify --- .../client/src/stats/ClientEventReporter.ts | 17 ++++++----------- 1 file changed, 6 insertions(+), 11 deletions(-) diff --git a/packages/client/src/stats/ClientEventReporter.ts b/packages/client/src/stats/ClientEventReporter.ts index acc38ecfc9..a54f057bc9 100644 --- a/packages/client/src/stats/ClientEventReporter.ts +++ b/packages/client/src/stats/ClientEventReporter.ts @@ -115,11 +115,6 @@ export class ClientEventReporter { this.streamClient = options.streamClient; } - private getUserId = (): string => this.streamClient.userID ?? ''; - - private getCoordinatorConnectId = (): string => - this.coordinatorConnectId ?? ''; - mintCoordinatorConnectId = (): string => { this.coordinatorConnectId = generateUUIDv4(); return this.coordinatorConnectId; @@ -164,7 +159,7 @@ export class ClientEventReporter { sid: generateUUIDv4(), attempts: 0, startedAt: Date.now(), - userIdSnapshot: this.getUserId(), + userIdSnapshot: this.streamClient.userID, }; this.coordinatorWsPair.initiatedDelivery = this.sendTracked({ ...this.buildCoordinatorWsCommon(this.coordinatorWsPair), @@ -190,7 +185,7 @@ export class ClientEventReporter { private buildCoordinatorWsCommon = ( pair: StagePairState, ): Record => ({ - user_id: pair.userIdSnapshot ?? this.getUserId(), + user_id: pair.userIdSnapshot ?? this.streamClient.userID, stage: 'CoordinatorWS', stage_id: pair.sid, ...(this.coordinatorConnectId && { @@ -370,9 +365,9 @@ export class ClientEventReporter { private emitJoinInitiated = (cid: string) => { const joinAttemptId = this.joinAttemptIds.get(cid); if (!joinAttemptId) return; - const coordinatorConnectId = this.getCoordinatorConnectId(); + const coordinatorConnectId = this.coordinatorConnectId; this.send({ - user_id: this.getUserId(), + user_id: this.streamClient.userID, stage: 'JoinInitiated', join_attempt_id: joinAttemptId, ...(coordinatorConnectId && { @@ -674,9 +669,9 @@ export class ClientEventReporter { pair: StagePairState, ): Record => { const ctx = this.callContexts.get(cid); - const coordinatorConnectId = this.getCoordinatorConnectId(); + const coordinatorConnectId = this.coordinatorConnectId; return { - user_id: this.getUserId(), + user_id: this.streamClient.userID, type: ctx?.callType ?? '', id: ctx?.callId ?? '', call_cid: cid, From fde4319841ea9e45c1f51c115e19b9fe89242932 Mon Sep 17 00:00:00 2001 From: jonadimovska Date: Mon, 8 Jun 2026 14:29:47 +0200 Subject: [PATCH 40/65] feat(client): client call event reporting - simplify error handling --- .../client/src/stats/ClientEventReporter.ts | 13 ++++++--- .../__tests__/ClientEventReporter.test.ts | 28 ++++++++++++++++++- 2 files changed, 36 insertions(+), 5 deletions(-) diff --git a/packages/client/src/stats/ClientEventReporter.ts b/packages/client/src/stats/ClientEventReporter.ts index a54f057bc9..3d078d4aed 100644 --- a/packages/client/src/stats/ClientEventReporter.ts +++ b/packages/client/src/stats/ClientEventReporter.ts @@ -1,4 +1,5 @@ import { ErrorCode, PeerType, TrackType } from '../gen/video/sfu/models/models'; +import { ErrorFromResponse } from '../coordinator/connection/types'; import type { StreamClient } from '../coordinator/connection/client'; import { generateUUIDv4, @@ -801,16 +802,20 @@ const applyError = (pair: StagePairState | undefined, next: StageError) => { const mapHttpError = (err: unknown): StageError => { const reason = errorMessage(err); - const status = (err as { response?: { status?: number } })?.response?.status; if (isTimeout(err)) { return { reason, code: 'REQUEST_TIMEOUT', severity: SEVERITY.TRANSPORT }; } - if (typeof status === 'number' && status >= 500) { - return { reason, code: `HTTP_${status}`, severity: SEVERITY.SERVER }; + + if (err instanceof ErrorFromResponse) { + return { + reason: `HTTP ${err.status}: ${err.message}`, + code: err.code != null ? String(err.code) : 'SERVER_ERROR', + severity: SEVERITY.SERVER, + }; } - return { reason, code: 'NETWORK_ERROR', severity: SEVERITY.TRANSPORT }; + return { reason, code: 'NETWORK_OFFLINE', severity: SEVERITY.TRANSPORT }; }; const mapWsJoinError = (err: unknown): StageError => { diff --git a/packages/client/src/stats/__tests__/ClientEventReporter.test.ts b/packages/client/src/stats/__tests__/ClientEventReporter.test.ts index ce45c0b30e..374b50442d 100644 --- a/packages/client/src/stats/__tests__/ClientEventReporter.test.ts +++ b/packages/client/src/stats/__tests__/ClientEventReporter.test.ts @@ -3,6 +3,8 @@ import { fromPartial } from '@total-typescript/shoehorn'; import { of } from 'rxjs'; import { ClientEventReporter, CallReportContext } from '../ClientEventReporter'; import type { StreamClient } from '../../coordinator/connection/client'; +import { ErrorFromResponse } from '../../coordinator/connection/types'; +import type { AxiosResponse } from 'axios'; import { PeerType, TrackType } from '../../gen/video/sfu/models/models'; vi.mock('../../devices', () => ({ @@ -168,7 +170,31 @@ describe('ClientEventReporter', () => { expect(completed).toHaveLength(1); expect(completed[0]).toMatchObject({ outcome: 'failure', - retry_failure_code: 'NETWORK_ERROR', + retry_failure_code: 'NETWORK_OFFLINE', + }); + }); + + it('forwards the backend error code and status on a server error', async () => { + const err = new ErrorFromResponse({ + message: 'server boom', + code: 16, + status: 500, + response: fromPartial({}), + unrecoverable: false, + }); + await expect( + reporter.trackCoordinatorWs(() => Promise.reject(err)), + ).rejects.toBe(err); + reporter.closeCoordinatorWs(); + await flush(); + + const completed = postedEvents().filter( + (e) => e.stage === 'CoordinatorWS' && e.event_type === 'completed', + ); + expect(completed[0]).toMatchObject({ + outcome: 'failure', + retry_failure_code: '16', + retry_failure_reason: 'HTTP 500: server boom', }); }); From cd3049b2dffd7c571244771606d21a4f05114fb2 Mon Sep 17 00:00:00 2001 From: jonadimovska Date: Mon, 8 Jun 2026 14:48:19 +0200 Subject: [PATCH 41/65] feat(client): client call event reporting - omit permission for react native --- packages/client/src/stats/ClientEventReporter.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/packages/client/src/stats/ClientEventReporter.ts b/packages/client/src/stats/ClientEventReporter.ts index 3d078d4aed..eddf7f3c16 100644 --- a/packages/client/src/stats/ClientEventReporter.ts +++ b/packages/client/src/stats/ClientEventReporter.ts @@ -7,6 +7,7 @@ import { sleep, } from '../coordinator/connection/utils'; import { SfuJoinError } from '../errors'; +import { isReactNative } from '../helpers/platforms'; import { videoLoggerSystem } from '../logger'; import type { PeerConnectionStateChangeEvent } from '../rtc'; import { @@ -199,6 +200,7 @@ export class ClientEventReporter { private emitMediaPermission = (cid: string) => { if (!this.callContexts.has(cid)) return; + if (isReactNative()) return; const pair: StagePairState = { sid: generateUUIDv4(), From 57d3045257b8e1582deef92999d0bbf408bd899b Mon Sep 17 00:00:00 2001 From: jonadimovska Date: Mon, 8 Jun 2026 14:54:41 +0200 Subject: [PATCH 42/65] feat(client): client call event reporting - add jsdoc --- packages/client/src/Call.ts | 3 +++ 1 file changed, 3 insertions(+) diff --git a/packages/client/src/Call.ts b/packages/client/src/Call.ts index 8d1d38a789..83631046a0 100644 --- a/packages/client/src/Call.ts +++ b/packages/client/src/Call.ts @@ -1136,6 +1136,7 @@ export class Call { } }; + /** Runs a join attempt under client event reporting, starting a new join correlation (`joinReason`) before `op`. */ private withJoinLifecycle = ( joinReason: JoinReason, op: () => Promise, @@ -1155,6 +1156,7 @@ export class Call { ); }; + /** Wraps the coordinator `JoinCall` request to emit the `CoordinatorJoin` reporting pair. */ private trackCoordinatorJoin = (op: () => Promise): Promise => this.streamClient.clientEventReporter.track( this.cid, @@ -1162,6 +1164,7 @@ export class Call { op, ); + /** Wraps the SFU signaling WebSocket join to emit the `WSJoin` reporting pair. */ private trackWsJoin = (op: () => Promise): Promise => { return this.streamClient.clientEventReporter.track(this.cid, 'WSJoin', op); }; From e0fcb0aadeae60a4346e8d3a5cbbf26abfa5d569 Mon Sep 17 00:00:00 2001 From: jonadimovska Date: Mon, 8 Jun 2026 15:11:06 +0200 Subject: [PATCH 43/65] feat(client): client call event reporting - remove duplicated line --- packages/client/src/Call.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/packages/client/src/Call.ts b/packages/client/src/Call.ts index 83631046a0..79cc8ba67e 100644 --- a/packages/client/src/Call.ts +++ b/packages/client/src/Call.ts @@ -762,7 +762,6 @@ export class Call { reason: leaveReason, }); - this.subscriber?.dispose(); this.subscriber = undefined; await this.publisher?.dispose(); From b33a5b459063e909a3d3a63e224538b91346c450 Mon Sep 17 00:00:00 2001 From: jonadimovska Date: Mon, 8 Jun 2026 17:54:05 +0200 Subject: [PATCH 44/65] feat(client): client call event reporting - implement the first video frame rendered instead of on unmute event --- packages/client/src/Call.ts | 45 +++++++++++- .../helpers/__tests__/firstVideoFrame.test.ts | 70 +++++++++++++++++++ .../client/src/helpers/firstVideoFrame.ts | 38 ++++++++++ 3 files changed, 151 insertions(+), 2 deletions(-) create mode 100644 packages/client/src/helpers/__tests__/firstVideoFrame.test.ts create mode 100644 packages/client/src/helpers/firstVideoFrame.ts diff --git a/packages/client/src/Call.ts b/packages/client/src/Call.ts index 79cc8ba67e..36f18c589b 100644 --- a/packages/client/src/Call.ts +++ b/packages/client/src/Call.ts @@ -151,6 +151,7 @@ import { AudioBindingsWatchdog } from './helpers/AudioBindingsWatchdog'; import { BlockedAudioTracker } from './helpers/BlockedAudioTracker'; import { TrackSubscriptionManager } from './helpers/TrackSubscriptionManager'; import { DynascaleManager } from './helpers/DynascaleManager'; +import { createFirstVideoFrameDetector } from './helpers/firstVideoFrame'; import { ViewportTracker } from './helpers/ViewportTracker'; import { PermissionsContext } from './permissions'; import { CallTypes } from './CallType'; @@ -1560,6 +1561,8 @@ export class Call { ); }, onRemoteTrackUnmute: (trackType, trackId) => { + if (trackType !== TrackType.AUDIO) return; + this.streamClient.clientEventReporter.reportFirstFrame( this.cid, trackType, @@ -3224,13 +3227,25 @@ export class Call { sessionId: string, trackType: VideoTrackType, ) => { - const unbind = this.dynascaleManager?.bindVideoElement( + const unbindDynascale = this.dynascaleManager?.bindVideoElement( videoElement, sessionId, trackType, ); - if (!unbind) return; + const stopFirstFrameDetector = this.bindFirstVideoFrameDetector( + videoElement, + sessionId, + trackType, + ); + + if (!unbindDynascale && !stopFirstFrameDetector) return; + + const unbind = () => { + stopFirstFrameDetector?.(); + unbindDynascale?.(); + }; + this.leaveCallHooks.add(unbind); return () => { this.leaveCallHooks.delete(unbind); @@ -3238,6 +3253,32 @@ export class Call { }; }; + private bindFirstVideoFrameDetector = ( + videoElement: HTMLVideoElement, + sessionId: string, + trackType: VideoTrackType, + ) => { + if (trackType !== 'videoTrack') return; + + return createFirstVideoFrameDetector(videoElement, () => { + this.reportFirstRenderedVideoFrame(sessionId); + }); + }; + + private reportFirstRenderedVideoFrame = (sessionId: string) => { + const participant = this.state.findParticipantBySessionId(sessionId); + if (participant?.isLocalParticipant) return; + + const trackId = participant?.videoStream?.getVideoTracks()[0]?.id; + if (!trackId) return; + + this.streamClient.clientEventReporter.reportFirstFrame( + this.cid, + TrackType.VIDEO, + trackId, + ); + }; + /** * Binds a DOM