import { Platform } from 'react-native' import { EventType } from '@rrweb/types' import type { CrashBufferEventMap, CrashBufferEventName, CrashBufferErrorSpanAppendedEvent, CrashBuffer, CrashBufferRrwebEventPayload, CrashBufferOtelSpanPayload, CrashBufferOtelSpanBatchPayload, CrashBufferSnapshot, } from '@multiplayer-app/session-recorder-common' import { SpanStatusCode } from '@opentelemetry/api' // Safe import for AsyncStorage with web fallback let AsyncStorage: any = null const isWeb = Platform.OS === 'web' if (!isWeb) { try { AsyncStorage = require('@react-native-async-storage/async-storage').default } catch (_error) { AsyncStorage = null } } else { AsyncStorage = { getItem: (_key: string) => Promise.resolve(null), setItem: (_key: string, _value: string) => Promise.resolve(undefined), removeItem: (_key: string) => Promise.resolve(undefined), multiRemove: (_keys: string[]) => Promise.resolve(undefined), multiGet: (_keys: string[]) => Promise.resolve([]), multiSet: (_pairs: Array<[string, string]>) => Promise.resolve(undefined), } } type RecordKind = 'rrweb' | 'span' type IndexEntry = { id: string ts: number kind: RecordKind } const INDEX_KEY = 'mp_crash_buffer_index_v1' const ATTRS_KEY = 'mp_crash_buffer_attrs_v1' const RECORD_PREFIX = 'mp_crash_buffer_rec_v1:' const randomId = (): string => `${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 10)}` export class CrashBufferService implements CrashBuffer { private static instance: CrashBufferService | null = null private index: IndexEntry[] = [] private indexLoaded = false private lastPruneAt = 0 private opChain: Promise = Promise.resolve() private defaultWindowMs: number = 0.5 * 60 * 1000 private lastSeenEventTs: number = 0 private listeners = new Map< CrashBufferEventName, Set<(payload: CrashBufferEventMap[CrashBufferEventName]) => void> >() static getInstance(): CrashBufferService { if (!CrashBufferService.instance) { CrashBufferService.instance = new CrashBufferService() } return CrashBufferService.instance } private enqueue(fn: () => Promise): Promise { const next = this.opChain.then(fn, fn) // Preserve chain, but don't leak rejections. this.opChain = next.then( () => undefined, () => undefined, ) return next } private async ensureIndexLoaded(): Promise { if (this.indexLoaded) return if (!AsyncStorage) { this.indexLoaded = true this.index = [] return } try { const raw = await AsyncStorage.getItem(INDEX_KEY) this.index = raw ? JSON.parse(raw) : [] } catch (_e) { this.index = [] } finally { this.indexLoaded = true } } async setAttrs(attrs: any): Promise { return this.enqueue(async () => { if (!AsyncStorage) return try { await AsyncStorage.setItem(ATTRS_KEY, JSON.stringify(attrs || null)) } catch (_e) { // best-effort } }) } async appendEvent( payload: CrashBufferRrwebEventPayload, windowMs?: number, ): Promise { const ts = payload?.ts ?? Date.now() this.lastSeenEventTs = Math.max(this.lastSeenEventTs, ts) const rawEventType = (payload as any)?.event?.eventType ?? (payload as any)?.event?.type const isFullSnapshot = Boolean(payload.isFullSnapshot) || rawEventType === EventType.FullSnapshot const record: CrashBufferRrwebEventPayload = { ...payload, ts, isFullSnapshot, } return this.appendRecord( 'rrweb', record.ts, record, windowMs ?? this.defaultWindowMs, ) } async appendSpans( payload: CrashBufferOtelSpanBatchPayload, windowMs?: number, ): Promise { if (!payload.length) return const effectiveWindowMs = windowMs ?? this.defaultWindowMs return this.enqueue(async () => { if (!AsyncStorage) return await this.ensureIndexLoaded() const pairs: Array<[string, string]> = [] let errorEvent: CrashBufferErrorSpanAppendedEvent | null = null for (const p of payload) { const id = randomId() const key = `${RECORD_PREFIX}${id}` const entry: IndexEntry = { id, ts: p.ts, kind: 'span' } this.index.push(entry) pairs.push([key, JSON.stringify(p)]) if (!errorEvent && p?.span?.status?.code === SpanStatusCode.ERROR) { errorEvent = { ts: p.ts, span: p.span } } } try { await AsyncStorage.multiSet(pairs) await AsyncStorage.setItem(INDEX_KEY, JSON.stringify(this.index)) } catch (_e) { // best-effort } this.pruneSoon(effectiveWindowMs) if (errorEvent) { this._emit('error-span-appended', errorEvent) } }) } setDefaultWindowMs(windowMs: number): void { this.defaultWindowMs = Math.max(10_000, windowMs || 0.5 * 60 * 1000) } on( event: E, listener: (payload: CrashBufferEventMap[E]) => void, ): () => void { const set = this.listeners.get(event) || new Set() set.add(listener as any) this.listeners.set(event, set as any) return () => this.off(event, listener as any) } off( event: E, listener: (payload: CrashBufferEventMap[E]) => void, ): void { const set = this.listeners.get(event) if (!set) return set.delete(listener as any) if (set.size === 0) this.listeners.delete(event) } private _emit( event: E, payload: CrashBufferEventMap[E], ): void { const set = this.listeners.get(event) if (!set || set.size === 0) return for (const fn of Array.from(set)) { try { ;(fn as any)(payload) } catch (_e) { // never throw into app code } } } private async appendRecord( kind: RecordKind, ts: number, payload: any, windowMs: number, ): Promise { return this.enqueue(async () => { if (!AsyncStorage) return await this.ensureIndexLoaded() const id = randomId() const key = `${RECORD_PREFIX}${id}` const entry: IndexEntry = { id, ts, kind } this.index.push(entry) try { await AsyncStorage.setItem(key, JSON.stringify(payload)) await AsyncStorage.setItem(INDEX_KEY, JSON.stringify(this.index)) } catch (_e) { // best-effort } this.pruneSoon(windowMs) }) } private pruneSoon(windowMs: number) { const now = Date.now() if (now - this.lastPruneAt < 2000) return this.lastPruneAt = now const cutoff = Math.max(0, now - windowMs) void this.pruneOlderThan(cutoff) } async pruneOlderThan(cutoffTs: number): Promise { return this.enqueue(async () => { if (!AsyncStorage) return await this.ensureIndexLoaded() const toRemove = this.index.filter((e) => e.ts < cutoffTs) if (toRemove.length === 0) return const removeKeys = toRemove.map((e) => `${RECORD_PREFIX}${e.id}`) this.index = this.index.filter((e) => e.ts >= cutoffTs) try { await AsyncStorage.multiRemove(removeKeys) await AsyncStorage.setItem(INDEX_KEY, JSON.stringify(this.index)) } catch (_e) { // best-effort } }) } async snapshot( windowMs?: number, now: number = Date.now(), ): Promise { return this.enqueue(async () => { await this.ensureIndexLoaded() const effectiveWindowMs = windowMs ?? this.defaultWindowMs const toTs = now const fromTs = Math.max(0, toTs - effectiveWindowMs) const entries = this.index.filter((e) => e.ts >= fromTs && e.ts <= toTs) const keys = entries.map((e) => `${RECORD_PREFIX}${e.id}`) let pairs: Array<[string, string | null]> = [] try { pairs = AsyncStorage ? await AsyncStorage.multiGet(keys) : [] } catch (_e) { pairs = [] } const byKey = new Map() for (const [k, v] of pairs) { if (!v) continue try { byKey.set(k, JSON.parse(v)) } catch (_e) { // ignore } } const allEvents: CrashBufferRrwebEventPayload[] = [] const allSpans: CrashBufferOtelSpanPayload[] = [] for (const e of entries.sort((a, b) => a.ts - b.ts)) { const key = `${RECORD_PREFIX}${e.id}` const payload = byKey.get(key) if (!payload) continue if (e.kind === 'rrweb') allEvents.push(payload) if (e.kind === 'span') allSpans.push(payload) } // Mirror browser semantics: // - Ensure the rrweb stream starts at Meta -> FullSnapshot (or is empty). // - Only include spans from the replayable window onward. const eventsSorted = allEvents.slice().sort((a, b) => a.ts - b.ts) const firstSnapshotIdx = eventsSorted.findIndex((e) => { const t = (e as any)?.event?.eventType ?? (e as any)?.event?.type return t === EventType.FullSnapshot }) if (firstSnapshotIdx < 0) { return { events: [], spans: [], startedAt: fromTs, stoppedAt: toTs, } } let startIdx = firstSnapshotIdx for (let i = firstSnapshotIdx - 1; i >= 0; i--) { const t = (eventsSorted[i] as any)?.event?.eventType ?? (eventsSorted[i] as any)?.event?.type if (t === EventType.Meta) { startIdx = i break } } const rrwebEvents = eventsSorted.slice(startIdx) const firstEvent = rrwebEvents[0] const replayStartedAt = typeof firstEvent?.ts === 'number' ? firstEvent.ts : fromTs const otelSpans = allSpans .filter((s) => typeof s?.ts === 'number' && s.ts >= replayStartedAt) .sort((a, b) => a.ts - b.ts) return { spans: otelSpans, events: rrwebEvents, stoppedAt: toTs, startedAt: replayStartedAt, } }) } async clear(): Promise { return this.enqueue(async () => { if (!AsyncStorage) return await this.ensureIndexLoaded() const keys = this.index.map((e) => `${RECORD_PREFIX}${e.id}`) this.index = [] this.lastSeenEventTs = 0 try { await AsyncStorage.multiRemove([INDEX_KEY, ATTRS_KEY, ...keys]) } catch (_e) { // best-effort } }) } }