import { resourceFromAttributes } from '@opentelemetry/resources'; import { W3CTraceContextPropagator, type ExportResult, } from '@opentelemetry/core'; import { BatchSpanProcessor, type ReadableSpan, } from '@opentelemetry/sdk-trace-base'; import * as SemanticAttributes from '@opentelemetry/semantic-conventions'; import { registerInstrumentations } from '@opentelemetry/instrumentation'; import { SessionType, SessionRecorderSdk, SessionRecorderIdGenerator, SessionRecorderBrowserTraceExporter, ATTR_MULTIPLAYER_SESSION_ID, MULTIPLAYER_TRACE_CLIENT_ID_LENGTH, SessionRecorderTraceIdRatioBasedSampler, } from '@multiplayer-app/session-recorder-common'; import { type TracerReactNativeConfig } from '../types'; import { getInstrumentations } from './instrumentations'; import { getExporterEndpoint } from './helpers'; import { getPlatformAttributes } from '../utils/platform'; import { WebTracerProvider } from '@opentelemetry/sdk-trace-web'; import { CrashBufferService } from '../services/crashBuffer.service'; import { CrashBufferSpanProcessor } from './CrashBufferSpanProcessor'; const clientIdGenerator = SessionRecorderSdk.getIdGenerator( MULTIPLAYER_TRACE_CLIENT_ID_LENGTH ); export class TracerReactNativeSDK { clientId = ''; private tracerProvider?: WebTracerProvider; private config?: TracerReactNativeConfig; private sessionId = ''; private idGenerator?: SessionRecorderIdGenerator; private exporter?: SessionRecorderBrowserTraceExporter; private globalErrorHandlerRegistered = false; private crashBuffer?: CrashBufferService; constructor() {} private _setSessionId( sessionId: string, sessionType: SessionType = SessionType.MANUAL ) { this.sessionId = sessionId; if (!this.idGenerator) { throw new Error('Id generator not initialized'); } this.idGenerator.setSessionId(sessionId, sessionType, this.clientId); } init(options: TracerReactNativeConfig): void { this.config = options; this.clientId = clientIdGenerator(); const { application, version, environment } = this.config; this.idGenerator = new SessionRecorderIdGenerator(); this._setSessionId('', SessionType.SESSION_CACHE); this.exporter = new SessionRecorderBrowserTraceExporter({ apiKey: options.apiKey, url: getExporterEndpoint(options.exporterEndpoint), }); const resourceAttributes = resourceFromAttributes({ [SemanticAttributes.SEMRESATTRS_SERVICE_NAME]: application, [SemanticAttributes.SEMRESATTRS_SERVICE_VERSION]: version, [SemanticAttributes.SEMRESATTRS_DEPLOYMENT_ENVIRONMENT]: environment, ...getPlatformAttributes(), }); SessionRecorderSdk.setResourceAttributes(resourceAttributes.attributes); this.tracerProvider = new WebTracerProvider({ resource: resourceAttributes, idGenerator: this.idGenerator, sampler: new SessionRecorderTraceIdRatioBasedSampler( this.config.sampleTraceRatio ), spanProcessors: [ this._getSpanSessionIdProcessor(), new BatchSpanProcessor(this.exporter), new CrashBufferSpanProcessor( this.crashBuffer, this.exporter.serializeSpan ), ], }); this.tracerProvider.register({ propagator: new W3CTraceContextPropagator(), }); // Register instrumentations registerInstrumentations({ tracerProvider: this.tracerProvider, instrumentations: getInstrumentations(this.config), }); this._registerGlobalErrorHandlers(); } setCrashBuffer( crashBuffer: CrashBufferService | undefined, windowMs?: number ): void { this.crashBuffer = crashBuffer; if ( crashBuffer && typeof windowMs === 'number' && Number.isFinite(windowMs) ) { crashBuffer.setDefaultWindowMs(windowMs); } } async exportTraces( spans: ReadableSpan[] ): Promise { if (!this.exporter) { throw new Error('Trace exporter not initialized'); } if (!spans || spans.length === 0) { return Promise.resolve(); } const readableSpans = spans.map((s: any) => TracerReactNativeSDK._toReadableSpanLike(s) ); return new Promise((resolve) => { this.exporter?.exportBuffer(readableSpans, (result) => { resolve(result); }); }); } start(sessionId: string, sessionType: SessionType): void { if (!this.tracerProvider) { throw new Error( 'Configuration not initialized. Call init() before start().' ); } this._setSessionId(sessionId, sessionType); } stop(): void { if (!this.tracerProvider) { throw new Error( 'Configuration not initialized. Call init() before start().' ); } this._setSessionId('', SessionType.SESSION_CACHE); } setApiKey(apiKey: string): void { if (!this.exporter) { throw new Error( 'Configuration not initialized. Call init() before setApiKey().' ); } this.exporter.setApiKey(apiKey); } /** * Capture an exception as an error span/event. * If there is an active span, the exception will be recorded on it. * Otherwise, a short-lived span will be created to hold the exception event. */ captureException(error: Error, errorInfo?: Record): void { if (!error) return; SessionRecorderSdk.captureException(error, errorInfo); } private _getSpanSessionIdProcessor() { return { onStart: (span: any) => { if (this.sessionId) { span.setAttribute(ATTR_MULTIPLAYER_SESSION_ID, this.sessionId); } }, onEnd: () => {}, shutdown: () => Promise.resolve(), forceFlush: () => Promise.resolve(), }; } private _registerGlobalErrorHandlers(): void { if (this.globalErrorHandlerRegistered) return; // React Native global error handler const ErrorUtilsRef: any = (global as any).ErrorUtils; if (ErrorUtilsRef && typeof ErrorUtilsRef.setGlobalHandler === 'function') { const previous = ErrorUtilsRef.getGlobalHandler?.(); ErrorUtilsRef.setGlobalHandler((error: any, isFatal?: boolean) => { try { const err = error instanceof Error ? error : new Error(String(error?.message || error)); this.captureException(err); } finally { if (typeof previous === 'function') { try { previous(error, isFatal); } catch (_e) { /* ignore */ } } } }); this.globalErrorHandlerRegistered = true; } } private static _toReadableSpanLike(span: any): ReadableSpan { if ( span && typeof span.spanContext === 'function' && span.instrumentationScope ) { return span as ReadableSpan; } const spanContext = typeof span?.spanContext === 'function' ? span.spanContext() : span?._spanContext; const normalizedCtx = spanContext || ({ traceId: span?.traceId, spanId: span?.spanId, traceFlags: span?.traceFlags, traceState: span?.traceState, } as any); const instrumentationScope = span?.instrumentationScope || span?.instrumentationLibrary || ({ name: 'multiplayer-buffer', version: undefined, schemaUrl: undefined, } as any); const normalizedScope = { name: instrumentationScope?.name || 'multiplayer-buffer', version: instrumentationScope?.version, schemaUrl: instrumentationScope?.schemaUrl, }; const resource = span?.resource || { attributes: {}, asyncAttributesPending: false, }; const parentSpanId = span?.parentSpanId; return { name: span?.name || '', kind: span?.kind, spanContext: () => normalizedCtx, parentSpanContext: parentSpanId ? ({ traceId: normalizedCtx?.traceId, spanId: parentSpanId, traceFlags: normalizedCtx?.traceFlags, traceState: normalizedCtx?.traceState, } as any) : undefined, startTime: span?.startTime, endTime: span?.endTime ?? span?.startTime, duration: span?.duration, status: span?.status, attributes: span?.attributes || {}, links: span?.links || [], events: span?.events || [], ended: typeof span?.ended === 'boolean' ? span.ended : true, droppedAttributesCount: span?.droppedAttributesCount || 0, droppedEventsCount: span?.droppedEventsCount || 0, droppedLinksCount: span?.droppedLinksCount || 0, resource, instrumentationScope: normalizedScope as any, } as any; } }