@telnyx/react-native-voice-sdk
Version:
Telnyx React Native Voice SDK - A complete WebRTC voice calling solution
732 lines (630 loc) • 24.6 kB
text/typescript
import log from 'loglevel';
import type { RTCPeerConnection as RTCPeerConnectionType } from 'react-native-webrtc';
import { SDK_VERSION } from './env';
import { LogCollector } from './log-collector';
import type {
CallReportConfig,
CallReportPayload,
CallReportSummary,
StatsInterval,
AudioInboundStats,
AudioOutboundStats,
ConnectionStats,
IceCandidateInfo,
IceCandidatePairStats,
LocalAudioSourceStats,
LocalAudioTrackSnapshot,
TransportStats,
CallReportFlushReason,
} from './call-report-models';
const MAX_STATS_BUFFER = 360; // ~30 min at 5s intervals
const STATS_FLUSH_THRESHOLD = 300; // ~25 min
const LOGS_FLUSH_THRESHOLD = 800;
const MAX_RETRY_ATTEMPTS = 3;
const RETRY_BASE_DELAY_MS = 1000;
const INITIAL_COLLECTION_INTERVAL_SECONDS = 1;
const INITIAL_COLLECTION_DURATION_MS = 10_000;
const DEFAULT_INTERMEDIATE_REPORT_INTERVAL_MS = 180_000;
function round4(val: number | null | undefined): number | null {
if (val == null || isNaN(val)) return null;
return Math.round(val * 10000) / 10000;
}
export class CallReportCollector {
private config: CallReportConfig;
private peerConnection: RTCPeerConnectionType | null = null;
private logCollector: LogCollector;
private statsBuffer: StatsInterval[] = [];
private statsTimer: ReturnType<typeof setTimeout> | null = null;
private intervalStart: Date | null = null;
private callStartTime: Date = new Date();
private callEndTime: Date | null = null;
private segmentIndex = 0;
private isFlushing = false;
private started = false;
private stopped = false;
private lastIntermediateFlushTime: Date = this.callStartTime;
private lastLocalAudioTrackSnapshotJson: string | null = null;
// Previous values for delta calculations
private prevOutboundBytes = 0;
private prevOutboundTimestamp = 0;
private prevInboundBytes = 0;
private prevInboundTimestamp = 0;
private prevInboundAudioEnergy: number | undefined;
private prevInboundSamplesDuration: number | undefined;
private prevOutboundAudioEnergy: number | undefined;
private prevOutboundSamplesDuration: number | undefined;
// Sample accumulators for interval averages
private outboundAudioLevels: number[] = [];
private inboundAudioLevels: number[] = [];
private inboundJitters: number[] = [];
private roundTripTimes: number[] = [];
/** Called when intermediate flush is needed (long calls) */
public onFlushNeeded: (() => void) | null = null;
constructor(config: CallReportConfig) {
this.config = config;
this.logCollector = new LogCollector(config.callReportMaxLogEntries, config.callReportLogLevel);
this.logCollector.add('info', 'CallReportCollector: Starting stats and log collection', {
interval: config.callReportInterval,
logLevel: config.callReportLogLevel,
});
}
/** Log a structured event */
log(
level: 'debug' | 'info' | 'warn' | 'error',
message: string,
context?: Record<string, unknown>
): void {
this.logCollector.add(level, message, context);
}
/** Start periodic stats collection. Call after peer connection is available. */
start(peerConnection: RTCPeerConnectionType): void {
if (this.started) return;
if (!peerConnection) {
log.warn('[CallReportCollector] Cannot start: no peer connection');
return;
}
this.peerConnection = peerConnection;
this.started = true;
this.stopped = false;
this.callStartTime = new Date();
this.intervalStart = new Date();
this.lastIntermediateFlushTime = this.intervalStart;
log.debug('[CallReportCollector] Starting stats collection');
this.scheduleNextCollection();
}
/** Stop collection and return final payload */
async stop(): Promise<void> {
this.stopped = true;
if (this.statsTimer) {
clearTimeout(this.statsTimer);
this.statsTimer = null;
}
this.callEndTime = new Date();
if (this.peerConnection && this.intervalStart) {
await this.collectInterval(true);
}
this.started = false;
}
/** Build the final payload for POST */
buildPayload(summary: CallReportSummary): CallReportPayload {
const payload: CallReportPayload = {
summary,
stats: [...this.statsBuffer],
logs: this.logCollector.getLogs(),
};
if (this.segmentIndex > 0) {
payload.segment = this.segmentIndex;
}
return payload;
}
/**
* Build an intermediate flush payload and destructively drain buffers.
* Intermediate summaries are bounded to the flushed segment window.
*/
flush(summary: CallReportSummary, flushReason?: CallReportFlushReason): CallReportPayload {
const currentSegment = this.segmentIndex;
this.segmentIndex++;
const stats = this.statsBuffer;
this.statsBuffer = [];
const logs = this.logCollector.drain();
const now = new Date();
this.lastIntermediateFlushTime = now;
return {
summary: {
...summary,
durationSeconds: (now.getTime() - this.callStartTime.getTime()) / 1000,
startTimestamp: this.callStartTime.toISOString(),
endTimestamp: now.toISOString(),
},
stats,
logs,
segment: currentSegment,
...(flushReason ? { flushReason } : {}),
};
}
/** POST the payload to voice-sdk-proxy */
async sendPayload(
payload: CallReportPayload,
callReportId: string,
host: string,
voiceSdkId?: string | null
): Promise<void> {
const endpoint = this.buildEndpoint(host);
if (!endpoint) {
log.error('[CallReportCollector] Failed to construct endpoint URL from host:', host);
return;
}
if (!callReportId) {
log.warn('[CallReportCollector] Cannot post call report: missing call_report_id');
return;
}
let body: string;
try {
body = JSON.stringify(payload);
} catch (err) {
log.error('[CallReportCollector] Failed to encode payload:', err);
return;
}
log.debug(
`[CallReportCollector] Sending payload (endpoint: ${endpoint}, intervals: ${payload.stats.length}, logs: ${payload.logs.length}, segment: ${payload.segment ?? 'final'}, callId: ${payload.summary.callId})`
);
const headers: Record<string, string> = {
'Content-Type': 'application/json',
'x-call-report-id': callReportId,
'x-call-id': payload.summary.callId,
};
if (voiceSdkId) {
headers['x-voice-sdk-id'] = voiceSdkId;
}
await this.executeUpload(endpoint, headers, body, 1);
}
// --- Private ---
private scheduleNextCollection(): void {
if (this.stopped || !this.peerConnection || !this.intervalStart || this.statsTimer) {
return;
}
const intervalSeconds = this.collectionIntervalFor(this.intervalStart);
this.statsTimer = setTimeout(() => {
this.statsTimer = null;
this.collectInterval().finally(() => {
this.scheduleNextCollection();
});
}, intervalSeconds * 1000);
}
private collectionIntervalFor(intervalStart: Date): number {
const defaultInterval =
typeof this.config.callReportInterval === 'number' && this.config.callReportInterval > 0
? this.config.callReportInterval
: 5;
if (intervalStart.getTime() - this.callStartTime.getTime() < INITIAL_COLLECTION_DURATION_MS) {
return Math.min(INITIAL_COLLECTION_INTERVAL_SECONDS, defaultInterval);
}
return defaultInterval;
}
private async collectInterval(isFinal: boolean = false): Promise<void> {
if (!this.peerConnection) return;
const intervalEnd = new Date();
const intervalStart = this.intervalStart || intervalEnd;
this.intervalStart = intervalEnd;
try {
const stats = await (this.peerConnection as any).getStats();
const interval = this.processStats(stats, intervalStart, intervalEnd);
const elapsedMs = intervalEnd.getTime() - intervalStart.getTime();
if (!isFinal && elapsedMs < this.collectionIntervalFor(intervalStart) * 1000) {
return;
}
if (this.statsBuffer.length >= MAX_STATS_BUFFER) {
this.statsBuffer.shift();
}
this.statsBuffer.push(interval);
this.checkFlushThresholds();
} catch (err) {
log.error('[CallReportCollector] Error collecting stats:', err);
}
}
private processStats(stats: any, intervalStart: Date, intervalEnd: Date): StatsInterval {
let outbound: AudioOutboundStats | null = null;
let inbound: AudioInboundStats | null = null;
let connection: ConnectionStats | null = null;
let ice: IceCandidatePairStats | undefined;
let transport: TransportStats | undefined;
let selectedCandidatePair: any = null;
let outboundReport: any = null;
// Accumulate samples during this interval
const outAudioLevels = this.outboundAudioLevels;
const inAudioLevels = this.inboundAudioLevels;
const inJitters = this.inboundJitters;
const rtts = this.roundTripTimes;
stats.forEach((report: any) => {
switch (report.type) {
case 'outbound-rtp':
if (report.kind === 'audio') {
outboundReport = report;
const now = report.timestamp;
const bytes = report.bytesSent ?? 0;
let bitrateAvg: number | null = null;
if (this.prevOutboundTimestamp > 0) {
const timeDelta = now - this.prevOutboundTimestamp;
if (timeDelta > 0) {
bitrateAvg = ((bytes - this.prevOutboundBytes) * 8 * 1000) / timeDelta;
}
}
this.prevOutboundBytes = bytes;
this.prevOutboundTimestamp = now;
const outboundAudioLevel = this.getOutboundAudioLevel(stats, report);
if (outboundAudioLevel != null) {
outAudioLevels.push(outboundAudioLevel);
}
const localTrack = this.getLocalAudioTrackSnapshot();
const mediaSource = this.getOutboundAudioSourceStats(stats, report);
this.logLocalAudioTrackSnapshot(localTrack, mediaSource);
outbound = {
packetsSent: report.packetsSent ?? 0,
bytesSent: bytes,
audioLevelAvg: round4(avg(outAudioLevels)),
bitrateAvg: round4(bitrateAvg),
...(localTrack ? { localTrack } : {}),
...(mediaSource ? { mediaSource } : {}),
};
}
break;
case 'inbound-rtp':
if (report.kind === 'audio') {
const now = report.timestamp;
const bytes = report.bytesReceived ?? 0;
let bitrateAvg: number | null = null;
if (this.prevInboundTimestamp > 0) {
const timeDelta = now - this.prevInboundTimestamp;
if (timeDelta > 0) {
bitrateAvg = ((bytes - this.prevInboundBytes) * 8 * 1000) / timeDelta;
}
}
this.prevInboundBytes = bytes;
this.prevInboundTimestamp = now;
if (report.jitter != null) inJitters.push(report.jitter * 1000); // sec → ms
const inboundAudioLevel = this.getInboundAudioLevel(stats, report);
if (inboundAudioLevel != null) inAudioLevels.push(inboundAudioLevel);
inbound = {
packetsReceived: report.packetsReceived ?? 0,
bytesReceived: bytes,
packetsLost: report.packetsLost ?? 0,
packetsDiscarded: report.packetsDiscarded ?? 0,
jitterBufferDelay: report.jitterBufferDelay ?? null,
jitterBufferEmittedCount: report.jitterBufferEmittedCount ?? null,
totalSamplesReceived: report.totalSamplesReceived ?? null,
concealedSamples: report.concealedSamples ?? null,
concealmentEvents: report.concealmentEvents ?? null,
audioLevelAvg: round4(avg(inAudioLevels)),
jitterAvg: round4(avg(inJitters)),
bitrateAvg: round4(bitrateAvg),
};
}
break;
case 'candidate-pair':
if (report.nominated || report.state === 'succeeded') {
selectedCandidatePair = report;
if (report.currentRoundTripTime != null) {
rtts.push(report.currentRoundTripTime);
}
connection = {
roundTripTimeAvg: round4(avg(rtts)),
packetsSent: report.packetsSent ?? 0,
packetsReceived: report.packetsReceived ?? 0,
bytesSent: report.bytesSent ?? 0,
bytesReceived: report.bytesReceived ?? 0,
};
}
break;
case 'transport':
transport = this.buildTransportStats(report);
break;
case 'media-source':
if (!outboundReport && report.kind === 'audio' && report.audioLevel != null) {
outAudioLevels.push(report.audioLevel);
}
break;
}
});
if (selectedCandidatePair) {
ice = {
id: selectedCandidatePair.id,
state: selectedCandidatePair.state,
nominated: selectedCandidatePair.nominated,
writable: selectedCandidatePair.writable,
requestsSent: selectedCandidatePair.requestsSent,
responsesReceived: selectedCandidatePair.responsesReceived,
...(this.resolveCandidate(stats, selectedCandidatePair.localCandidateId)
? { local: this.resolveCandidate(stats, selectedCandidatePair.localCandidateId) }
: {}),
...(this.resolveCandidate(stats, selectedCandidatePair.remoteCandidateId)
? { remote: this.resolveCandidate(stats, selectedCandidatePair.remoteCandidateId) }
: {}),
};
}
// Clear accumulators for next interval
this.outboundAudioLevels = [];
this.inboundAudioLevels = [];
this.inboundJitters = [];
this.roundTripTimes = [];
return {
intervalStartUtc: intervalStart.toISOString(),
intervalEndUtc: intervalEnd.toISOString(),
audio: { outbound, inbound },
connection,
...(ice ? { ice } : {}),
...(transport ? { transport } : {}),
};
}
private checkFlushThresholds(): void {
if (this.isFlushing) return;
const now = new Date();
const msSinceLastFlush = now.getTime() - this.lastIntermediateFlushTime.getTime();
if (
this.statsBuffer.length >= STATS_FLUSH_THRESHOLD ||
this.logCollector.count >= LOGS_FLUSH_THRESHOLD ||
msSinceLastFlush >= DEFAULT_INTERMEDIATE_REPORT_INTERVAL_MS
) {
log.debug(
`[CallReportCollector] Flush threshold reached (stats: ${this.statsBuffer.length}/${STATS_FLUSH_THRESHOLD}, logs: ${this.logCollector.count}/${LOGS_FLUSH_THRESHOLD}, msSinceLastFlush: ${msSinceLastFlush})`
);
this.lastIntermediateFlushTime = now;
this.isFlushing = true;
try {
this.onFlushNeeded?.();
} finally {
this.isFlushing = false;
}
}
}
private resolveCandidate(stats: any, candidateId?: string): IceCandidateInfo | undefined {
if (!candidateId) return undefined;
const report = typeof stats.get === 'function' ? stats.get(candidateId) : undefined;
if (!report) return undefined;
const info = this.withoutUndefined({
address: report.address ?? report.ip,
port: report.port,
candidateType: report.candidateType,
protocol: report.protocol,
networkType: report.networkType,
});
return Object.keys(info).length > 0 ? info : undefined;
}
private buildTransportStats(report: any): TransportStats | undefined {
const transport = this.withoutUndefined({
iceState: report.iceState,
dtlsState: report.dtlsState,
srtpCipher: report.srtpCipher,
tlsVersion: report.tlsVersion,
selectedCandidatePairChanges: report.selectedCandidatePairChanges,
});
return Object.keys(transport).length > 0 ? transport : undefined;
}
private getOutboundMediaSource(stats: any, outboundAudio: any): any | undefined {
if (outboundAudio.mediaSourceId && typeof stats.get === 'function') {
const mediaSource = stats.get(outboundAudio.mediaSourceId);
if (mediaSource) return mediaSource;
}
let mediaSource: any | undefined;
stats.forEach((report: any) => {
if (!mediaSource && report.type === 'media-source' && report.kind === 'audio') {
mediaSource = report;
}
});
return mediaSource;
}
private getOutboundAudioSourceStats(
stats: any,
outboundAudio: any
): LocalAudioSourceStats | undefined {
const mediaSource = this.getOutboundMediaSource(stats, outboundAudio);
if (!mediaSource) return undefined;
const snapshot = this.withoutUndefined({
id: mediaSource.id,
trackIdentifier: mediaSource.trackIdentifier,
audioLevel: mediaSource.audioLevel,
totalAudioEnergy: mediaSource.totalAudioEnergy,
totalSamplesDuration: mediaSource.totalSamplesDuration,
echoReturnLoss: mediaSource.echoReturnLoss,
echoReturnLossEnhancement: mediaSource.echoReturnLossEnhancement,
});
return Object.keys(snapshot).length > 0 ? snapshot : undefined;
}
private getLocalAudioTrackSnapshot(): LocalAudioTrackSnapshot | undefined {
try {
const sender = (this.peerConnection as any)
?.getSenders?.()
?.find((item: any) => item.track?.kind === 'audio');
const track = sender?.track;
if (!track) return undefined;
const settings = track.getSettings?.();
const settingsSnapshot = this.withoutUndefined({
deviceId: settings?.deviceId,
groupId: settings?.groupId,
channelCount: settings?.channelCount,
sampleRate: settings?.sampleRate,
sampleSize: settings?.sampleSize,
latency: settings?.latency,
echoCancellation: settings?.echoCancellation,
noiseSuppression: settings?.noiseSuppression,
autoGainControl: settings?.autoGainControl,
});
const snapshot = this.withoutUndefined({
id: track.id,
label: track.label,
enabled: track.enabled,
muted: track.muted,
readyState: track.readyState,
contentHint: track.contentHint,
...(Object.keys(settingsSnapshot).length > 0 ? { settings: settingsSnapshot } : {}),
});
return Object.keys(snapshot).length > 0 ? snapshot : undefined;
} catch (error) {
log.debug('[CallReportCollector] Unable to snapshot local audio track:', error);
return undefined;
}
}
private getOutboundAudioLevel(stats: any, outboundAudio: any): number | null {
const mediaSource = this.getOutboundMediaSource(stats, outboundAudio);
if (mediaSource?.audioLevel != null) {
return mediaSource.audioLevel;
}
if (mediaSource) {
const level = this.computeAudioLevelFromEnergy(
mediaSource.totalAudioEnergy,
mediaSource.totalSamplesDuration,
'outbound'
);
if (level != null) return level;
}
return this.getTrackAudioLevel(stats, outboundAudio.trackId);
}
private getInboundAudioLevel(stats: any, inboundAudio: any): number | null {
if (inboundAudio.audioLevel != null) {
return inboundAudio.audioLevel;
}
const level = this.computeAudioLevelFromEnergy(
inboundAudio.totalAudioEnergy,
inboundAudio.totalSamplesDuration,
'inbound'
);
if (level != null) return level;
return this.getTrackAudioLevel(stats, inboundAudio.trackId);
}
private computeAudioLevelFromEnergy(
currentEnergy: number | undefined,
currentDuration: number | undefined,
direction: 'inbound' | 'outbound'
): number | null {
if (currentEnergy == null || currentDuration == null) return null;
const prevEnergy =
direction === 'inbound' ? this.prevInboundAudioEnergy : this.prevOutboundAudioEnergy;
const prevDuration =
direction === 'inbound' ? this.prevInboundSamplesDuration : this.prevOutboundSamplesDuration;
if (direction === 'inbound') {
this.prevInboundAudioEnergy = currentEnergy;
this.prevInboundSamplesDuration = currentDuration;
} else {
this.prevOutboundAudioEnergy = currentEnergy;
this.prevOutboundSamplesDuration = currentDuration;
}
if (prevEnergy == null || prevDuration == null) return null;
const deltaEnergy = currentEnergy - prevEnergy;
const deltaDuration = currentDuration - prevDuration;
if (deltaDuration <= 0) return null;
return Math.min(1, Math.max(0, Math.sqrt(deltaEnergy / deltaDuration)));
}
private getTrackAudioLevel(stats: any, trackId?: string): number | null {
if (!trackId || typeof stats.get !== 'function') return null;
return stats.get(trackId)?.audioLevel ?? null;
}
private logLocalAudioTrackSnapshot(
localAudioTrack?: LocalAudioTrackSnapshot,
localAudioSource?: LocalAudioSourceStats
): void {
if (!localAudioTrack || Object.keys(localAudioTrack).length === 0) return;
const snapshotJson = JSON.stringify(this.sortObjectKeys(localAudioTrack));
if (snapshotJson === this.lastLocalAudioTrackSnapshotJson) return;
this.lastLocalAudioTrackSnapshotJson = snapshotJson;
log.debug('[CallReportCollector] Local audio track snapshot', {
localTrack: localAudioTrack,
mediaSource: localAudioSource,
});
}
private withoutUndefined<T extends Record<string, unknown>>(obj: T): T {
return Object.keys(obj).reduce<Record<string, unknown>>((clean, key) => {
const value = obj[key];
if (value !== undefined) {
clean[key] = value;
}
return clean;
}, {}) as T;
}
private sortObjectKeys(value: unknown): unknown {
if (Array.isArray(value)) {
return value.map((item) => this.sortObjectKeys(item));
}
if (value && typeof value === 'object') {
return Object.keys(value as Record<string, unknown>)
.sort()
.reduce<Record<string, unknown>>((sorted, key) => {
sorted[key] = this.sortObjectKeys((value as Record<string, unknown>)[key]);
return sorted;
}, {});
}
return value;
}
private buildEndpoint(host: string): string | null {
try {
// Convert ws(s) URL to http(s)
let httpHost = host.replace(/^wss:\/\//, 'https://').replace(/^ws:\/\//, 'http://');
// If no protocol, default to https
if (!httpHost.startsWith('http')) {
httpHost = `https://${httpHost}`;
}
const url = new URL(httpHost);
url.pathname = '/call_report';
// Remove any query params from the WS URL
url.search = '';
return url.toString();
} catch (err) {
log.error('[CallReportCollector] Invalid host URL:', host, err);
return null;
}
}
private async executeUpload(
endpoint: string,
headers: Record<string, string>,
body: string,
attempt: number
): Promise<void> {
try {
const response = await fetch(endpoint, {
method: 'POST',
headers,
body,
});
if (response.ok) {
log.debug(`[CallReportCollector] Successfully posted report (status: ${response.status})`);
return;
}
const status = response.status;
const responseBody = await response.text().catch(() => '');
if (status >= 400 && status < 500) {
log.error(
`[CallReportCollector] Failed to post report (attempt ${attempt}, status: ${status}, error: ${responseBody})`
);
// Don't retry client errors
return;
}
// 5xx — retry
log.warn(
`[CallReportCollector] Server error posting report (attempt ${attempt}, status: ${status})`
);
await this.retryIfNeeded(endpoint, headers, body, attempt);
} catch (err) {
log.error(`[CallReportCollector] Error posting report (attempt ${attempt}):`, err);
await this.retryIfNeeded(endpoint, headers, body, attempt);
}
}
private async retryIfNeeded(
endpoint: string,
headers: Record<string, string>,
body: string,
attempt: number
): Promise<void> {
if (attempt >= MAX_RETRY_ATTEMPTS) {
log.error(`[CallReportCollector] All ${MAX_RETRY_ATTEMPTS} upload attempts failed`);
return;
}
const delay = RETRY_BASE_DELAY_MS * Math.pow(2, attempt - 1);
log.debug(
`[CallReportCollector] Retrying in ${delay}ms (attempt ${attempt + 1}/${MAX_RETRY_ATTEMPTS})`
);
await new Promise((resolve) => setTimeout(resolve, delay));
await this.executeUpload(endpoint, headers, body, attempt + 1);
}
}
function avg(values: number[]): number | null {
if (values.length === 0) return null;
return values.reduce((a, b) => a + b, 0) / values.length;
}