@multiplayer-app/session-recorder-react-native 1.3.15 → 1.3.21

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (56) hide show
  1. package/lib/module/config/constants.js +3 -0
  2. package/lib/module/config/constants.js.map +1 -1
  3. package/lib/module/config/defaults.js +5 -1
  4. package/lib/module/config/defaults.js.map +1 -1
  5. package/lib/module/config/session-recorder.js +5 -1
  6. package/lib/module/config/session-recorder.js.map +1 -1
  7. package/lib/module/otel/CrashBufferSpanProcessor.js +41 -0
  8. package/lib/module/otel/CrashBufferSpanProcessor.js.map +1 -0
  9. package/lib/module/otel/index.js +28 -9
  10. package/lib/module/otel/index.js.map +1 -1
  11. package/lib/module/patch/fetch.js +71 -11
  12. package/lib/module/patch/fetch.js.map +1 -1
  13. package/lib/module/recorder/index.js +15 -1
  14. package/lib/module/recorder/index.js.map +1 -1
  15. package/lib/module/services/api.service.js +24 -2
  16. package/lib/module/services/api.service.js.map +1 -1
  17. package/lib/module/services/crashBuffer.service.js +248 -0
  18. package/lib/module/services/crashBuffer.service.js.map +1 -0
  19. package/lib/module/services/socket.service.js +9 -2
  20. package/lib/module/services/socket.service.js.map +1 -1
  21. package/lib/module/session-recorder.js +152 -6
  22. package/lib/module/session-recorder.js.map +1 -1
  23. package/lib/module/types/session-recorder.js.map +1 -1
  24. package/lib/typescript/src/config/constants.d.ts +1 -0
  25. package/lib/typescript/src/config/constants.d.ts.map +1 -1
  26. package/lib/typescript/src/config/defaults.d.ts.map +1 -1
  27. package/lib/typescript/src/config/session-recorder.d.ts.map +1 -1
  28. package/lib/typescript/src/otel/CrashBufferSpanProcessor.d.ts +18 -0
  29. package/lib/typescript/src/otel/CrashBufferSpanProcessor.d.ts.map +1 -0
  30. package/lib/typescript/src/otel/index.d.ts +8 -0
  31. package/lib/typescript/src/otel/index.d.ts.map +1 -1
  32. package/lib/typescript/src/recorder/index.d.ts +8 -1
  33. package/lib/typescript/src/recorder/index.d.ts.map +1 -1
  34. package/lib/typescript/src/services/api.service.d.ts +27 -2
  35. package/lib/typescript/src/services/api.service.d.ts.map +1 -1
  36. package/lib/typescript/src/services/crashBuffer.service.d.ts +46 -0
  37. package/lib/typescript/src/services/crashBuffer.service.d.ts.map +1 -0
  38. package/lib/typescript/src/services/socket.service.d.ts +4 -3
  39. package/lib/typescript/src/services/socket.service.d.ts.map +1 -1
  40. package/lib/typescript/src/session-recorder.d.ts +8 -0
  41. package/lib/typescript/src/session-recorder.d.ts.map +1 -1
  42. package/lib/typescript/src/types/session-recorder.d.ts +18 -0
  43. package/lib/typescript/src/types/session-recorder.d.ts.map +1 -1
  44. package/package.json +2 -2
  45. package/src/config/constants.ts +3 -0
  46. package/src/config/defaults.ts +5 -0
  47. package/src/config/session-recorder.ts +5 -0
  48. package/src/otel/CrashBufferSpanProcessor.ts +61 -0
  49. package/src/otel/index.ts +90 -34
  50. package/src/patch/fetch.ts +73 -11
  51. package/src/recorder/index.ts +30 -3
  52. package/src/services/api.service.ts +68 -13
  53. package/src/services/crashBuffer.service.ts +327 -0
  54. package/src/services/socket.service.ts +36 -22
  55. package/src/session-recorder.ts +226 -19
  56. package/src/types/session-recorder.ts +18 -0
@@ -0,0 +1,327 @@
1
+ import { Platform } from 'react-native';
2
+ import type {
3
+ CrashBufferEventMap,
4
+ CrashBufferEventName,
5
+ CrashBufferErrorSpanAppendedEvent,
6
+ CrashBuffer,
7
+ } from '@multiplayer-app/session-recorder-common';
8
+ import { SpanStatusCode } from '@opentelemetry/api';
9
+
10
+ // Safe import for AsyncStorage with web fallback
11
+ let AsyncStorage: any = null;
12
+ const isWeb = Platform.OS === 'web';
13
+
14
+ if (!isWeb) {
15
+ try {
16
+ AsyncStorage = require('@react-native-async-storage/async-storage').default;
17
+ } catch (_error) {
18
+ AsyncStorage = null;
19
+ }
20
+ } else {
21
+ AsyncStorage = {
22
+ getItem: (_key: string) => Promise.resolve(null),
23
+ setItem: (_key: string, _value: string) => Promise.resolve(undefined),
24
+ removeItem: (_key: string) => Promise.resolve(undefined),
25
+ multiRemove: (_keys: string[]) => Promise.resolve(undefined),
26
+ multiGet: (_keys: string[]) => Promise.resolve([]),
27
+ multiSet: (_pairs: Array<[string, string]>) => Promise.resolve(undefined),
28
+ };
29
+ }
30
+
31
+ type RecordKind = 'rrweb' | 'span';
32
+
33
+ type IndexEntry = {
34
+ id: string;
35
+ ts: number;
36
+ kind: RecordKind;
37
+ };
38
+
39
+ const INDEX_KEY = 'mp_crash_buffer_index_v1';
40
+ const ATTRS_KEY = 'mp_crash_buffer_attrs_v1';
41
+ const RECORD_PREFIX = 'mp_crash_buffer_rec_v1:';
42
+
43
+ const randomId = (): string =>
44
+ `${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 10)}`;
45
+
46
+ export type CrashBufferSnapshot = {
47
+ rrwebEvents: Array<{ ts: number; event: any }>;
48
+ otelSpans: Array<{ ts: number; span: any }>;
49
+ attrs: any | null;
50
+ windowMs: number;
51
+ fromTs: number;
52
+ toTs: number;
53
+ };
54
+
55
+ export class CrashBufferService implements CrashBuffer {
56
+ private static instance: CrashBufferService | null = null;
57
+
58
+ private index: IndexEntry[] = [];
59
+ private indexLoaded = false;
60
+ private lastPruneAt = 0;
61
+ private opChain: Promise<any> = Promise.resolve();
62
+ private defaultWindowMs: number = 1 * 60 * 1000;
63
+ private listeners = new Map<
64
+ CrashBufferEventName,
65
+ Set<(payload: CrashBufferEventMap[CrashBufferEventName]) => void>
66
+ >();
67
+
68
+ static getInstance(): CrashBufferService {
69
+ if (!CrashBufferService.instance) {
70
+ CrashBufferService.instance = new CrashBufferService();
71
+ }
72
+ return CrashBufferService.instance;
73
+ }
74
+
75
+ private enqueue<T>(fn: () => Promise<T>): Promise<T> {
76
+ const next = this.opChain.then(fn, fn);
77
+ // Preserve chain, but don't leak rejections.
78
+ this.opChain = next.then(
79
+ () => undefined,
80
+ () => undefined
81
+ );
82
+ return next;
83
+ }
84
+
85
+ private async ensureIndexLoaded(): Promise<void> {
86
+ if (this.indexLoaded) return;
87
+ if (!AsyncStorage) {
88
+ this.indexLoaded = true;
89
+ this.index = [];
90
+ return;
91
+ }
92
+ try {
93
+ const raw = await AsyncStorage.getItem(INDEX_KEY);
94
+ this.index = raw ? JSON.parse(raw) : [];
95
+ } catch (_e) {
96
+ this.index = [];
97
+ } finally {
98
+ this.indexLoaded = true;
99
+ }
100
+ }
101
+
102
+ async setAttrs(attrs: any): Promise<void> {
103
+ return this.enqueue(async () => {
104
+ if (!AsyncStorage) return;
105
+ try {
106
+ await AsyncStorage.setItem(ATTRS_KEY, JSON.stringify(attrs || null));
107
+ } catch (_e) {
108
+ // best-effort
109
+ }
110
+ });
111
+ }
112
+
113
+ async appendEvent(
114
+ payload: { ts: number; event: any },
115
+ windowMs?: number
116
+ ): Promise<void> {
117
+ return this.appendRecord(
118
+ 'rrweb',
119
+ payload.ts,
120
+ payload,
121
+ windowMs ?? this.defaultWindowMs
122
+ );
123
+ }
124
+
125
+ async appendSpans(
126
+ payload: Array<{ ts: number; span: any }>,
127
+ windowMs?: number
128
+ ): Promise<void> {
129
+ if (!payload.length) return;
130
+ const effectiveWindowMs = windowMs ?? this.defaultWindowMs;
131
+ return this.enqueue(async () => {
132
+ if (!AsyncStorage) return;
133
+ await this.ensureIndexLoaded();
134
+
135
+ const pairs: Array<[string, string]> = [];
136
+ let errorEvent: CrashBufferErrorSpanAppendedEvent | null = null;
137
+ for (const p of payload) {
138
+ const id = randomId();
139
+ const key = `${RECORD_PREFIX}${id}`;
140
+ const entry: IndexEntry = { id, ts: p.ts, kind: 'span' };
141
+ this.index.push(entry);
142
+ pairs.push([key, JSON.stringify(p)]);
143
+ if (!errorEvent && p?.span?.status?.code === SpanStatusCode.ERROR) {
144
+ errorEvent = { ts: p.ts, span: p.span };
145
+ }
146
+ }
147
+
148
+ try {
149
+ await AsyncStorage.multiSet(pairs);
150
+ await AsyncStorage.setItem(INDEX_KEY, JSON.stringify(this.index));
151
+ } catch (_e) {
152
+ // best-effort
153
+ }
154
+
155
+ this.pruneSoon(effectiveWindowMs);
156
+
157
+ if (errorEvent) {
158
+ this._emit('error-span-appended', errorEvent);
159
+ }
160
+ });
161
+ }
162
+
163
+ setDefaultWindowMs(windowMs: number): void {
164
+ this.defaultWindowMs = Math.max(10_000, windowMs || 1 * 60 * 1000);
165
+ }
166
+
167
+ on<E extends CrashBufferEventName>(
168
+ event: E,
169
+ listener: (payload: CrashBufferEventMap[E]) => void
170
+ ): () => void {
171
+ const set = this.listeners.get(event) || new Set();
172
+ set.add(listener as any);
173
+ this.listeners.set(event, set as any);
174
+ return () => this.off(event, listener as any);
175
+ }
176
+
177
+ off<E extends CrashBufferEventName>(
178
+ event: E,
179
+ listener: (payload: CrashBufferEventMap[E]) => void
180
+ ): void {
181
+ const set = this.listeners.get(event);
182
+ if (!set) return;
183
+ set.delete(listener as any);
184
+ if (set.size === 0) this.listeners.delete(event);
185
+ }
186
+
187
+ private _emit<E extends CrashBufferEventName>(
188
+ event: E,
189
+ payload: CrashBufferEventMap[E]
190
+ ): void {
191
+ const set = this.listeners.get(event);
192
+ if (!set || set.size === 0) return;
193
+ for (const fn of Array.from(set)) {
194
+ try {
195
+ (fn as any)(payload);
196
+ } catch (_e) {
197
+ // never throw into app code
198
+ }
199
+ }
200
+ }
201
+
202
+ private async appendRecord(
203
+ kind: RecordKind,
204
+ ts: number,
205
+ payload: any,
206
+ windowMs: number
207
+ ): Promise<void> {
208
+ return this.enqueue(async () => {
209
+ if (!AsyncStorage) return;
210
+ await this.ensureIndexLoaded();
211
+
212
+ const id = randomId();
213
+ const key = `${RECORD_PREFIX}${id}`;
214
+ const entry: IndexEntry = { id, ts, kind };
215
+ this.index.push(entry);
216
+
217
+ try {
218
+ await AsyncStorage.setItem(key, JSON.stringify(payload));
219
+ await AsyncStorage.setItem(INDEX_KEY, JSON.stringify(this.index));
220
+ } catch (_e) {
221
+ // best-effort
222
+ }
223
+
224
+ this.pruneSoon(windowMs);
225
+ });
226
+ }
227
+
228
+ private pruneSoon(windowMs: number) {
229
+ const now = Date.now();
230
+ if (now - this.lastPruneAt < 2000) return;
231
+ this.lastPruneAt = now;
232
+ const cutoff = Math.max(0, now - windowMs);
233
+ void this.pruneOlderThan(cutoff);
234
+ }
235
+
236
+ async pruneOlderThan(cutoffTs: number): Promise<void> {
237
+ return this.enqueue(async () => {
238
+ if (!AsyncStorage) return;
239
+ await this.ensureIndexLoaded();
240
+ const toRemove = this.index.filter((e) => e.ts < cutoffTs);
241
+ if (toRemove.length === 0) return;
242
+
243
+ const removeKeys = toRemove.map((e) => `${RECORD_PREFIX}${e.id}`);
244
+ this.index = this.index.filter((e) => e.ts >= cutoffTs);
245
+
246
+ try {
247
+ await AsyncStorage.multiRemove(removeKeys);
248
+ await AsyncStorage.setItem(INDEX_KEY, JSON.stringify(this.index));
249
+ } catch (_e) {
250
+ // best-effort
251
+ }
252
+ });
253
+ }
254
+
255
+ async snapshot(
256
+ windowMs?: number,
257
+ now: number = Date.now()
258
+ ): Promise<CrashBufferSnapshot> {
259
+ return this.enqueue(async () => {
260
+ await this.ensureIndexLoaded();
261
+ const effectiveWindowMs = windowMs ?? this.defaultWindowMs;
262
+ const toTs = now;
263
+ const fromTs = Math.max(0, toTs - effectiveWindowMs);
264
+ const entries = this.index.filter((e) => e.ts >= fromTs && e.ts <= toTs);
265
+ const keys = entries.map((e) => `${RECORD_PREFIX}${e.id}`);
266
+
267
+ let pairs: Array<[string, string | null]> = [];
268
+ try {
269
+ pairs = AsyncStorage ? await AsyncStorage.multiGet(keys) : [];
270
+ } catch (_e) {
271
+ pairs = [];
272
+ }
273
+
274
+ const byKey = new Map<string, any>();
275
+ for (const [k, v] of pairs) {
276
+ if (!v) continue;
277
+ try {
278
+ byKey.set(k, JSON.parse(v));
279
+ } catch (_e) {
280
+ // ignore
281
+ }
282
+ }
283
+
284
+ const rrwebEvents: Array<{ ts: number; event: any }> = [];
285
+ const otelSpans: Array<{ ts: number; span: any }> = [];
286
+
287
+ for (const e of entries.sort((a, b) => a.ts - b.ts)) {
288
+ const key = `${RECORD_PREFIX}${e.id}`;
289
+ const payload = byKey.get(key);
290
+ if (!payload) continue;
291
+ if (e.kind === 'rrweb') rrwebEvents.push(payload);
292
+ if (e.kind === 'span') otelSpans.push(payload);
293
+ }
294
+
295
+ let attrs: any | null = null;
296
+ try {
297
+ const raw = AsyncStorage ? await AsyncStorage.getItem(ATTRS_KEY) : null;
298
+ attrs = raw ? JSON.parse(raw) : null;
299
+ } catch (_e) {
300
+ attrs = null;
301
+ }
302
+
303
+ return {
304
+ rrwebEvents,
305
+ otelSpans,
306
+ attrs,
307
+ windowMs: effectiveWindowMs,
308
+ fromTs,
309
+ toTs,
310
+ };
311
+ });
312
+ }
313
+
314
+ async clear(): Promise<void> {
315
+ return this.enqueue(async () => {
316
+ if (!AsyncStorage) return;
317
+ await this.ensureIndexLoaded();
318
+ const keys = this.index.map((e) => `${RECORD_PREFIX}${e.id}`);
319
+ this.index = [];
320
+ try {
321
+ await AsyncStorage.multiRemove([INDEX_KEY, ATTRS_KEY, ...keys]);
322
+ } catch (_e) {
323
+ // best-effort
324
+ }
325
+ });
326
+ }
327
+ }
@@ -13,8 +13,13 @@ import {
13
13
  REMOTE_SESSION_RECORDING_START,
14
14
  REMOTE_SESSION_RECORDING_STOP,
15
15
  SESSION_STARTED_EVENT,
16
+ SESSION_SAVE_BUFFER_EVENT,
16
17
  } from '../config';
17
- import type { ISession, IUserAttributes } from '@multiplayer-app/session-recorder-common';
18
+ import {
19
+ type ISession,
20
+ type IUserAttributes,
21
+ ATTR_MULTIPLAYER_SESSION_CLIENT_ID,
22
+ } from '@multiplayer-app/session-recorder-common';
18
23
 
19
24
  const MAX_RECONNECTION_ATTEMPTS = 2;
20
25
 
@@ -22,12 +27,14 @@ export type SocketServiceEvents =
22
27
  | typeof SESSION_STOPPED_EVENT
23
28
  | typeof SESSION_AUTO_CREATED
24
29
  | typeof REMOTE_SESSION_RECORDING_START
25
- | typeof REMOTE_SESSION_RECORDING_STOP;
30
+ | typeof REMOTE_SESSION_RECORDING_STOP
31
+ | typeof SESSION_SAVE_BUFFER_EVENT;
26
32
 
27
33
  export interface SocketServiceOptions {
28
34
  apiKey: string;
29
35
  socketUrl: string;
30
36
  keepAlive?: boolean;
37
+ clientId?: string;
31
38
  }
32
39
 
33
40
  export class SocketService extends Observable<SocketServiceEvents> {
@@ -72,15 +79,13 @@ export class SocketService extends Observable<SocketServiceEvents> {
72
79
  */
73
80
  public updateConfigs(config: Partial<SocketServiceOptions>): void {
74
81
  // If any config changed, reconnect if connected
75
- const hasChanges = Object.keys(config).some(
76
- (key) => {
77
- const typedKey = key as keyof SocketServiceOptions;
78
- return (
79
- config[typedKey] !== undefined &&
80
- config[typedKey] !== this.options[typedKey]
81
- );
82
- }
83
- );
82
+ const hasChanges = Object.keys(config).some((key) => {
83
+ const typedKey = key as keyof SocketServiceOptions;
84
+ return (
85
+ config[typedKey] !== undefined &&
86
+ config[typedKey] !== this.options[typedKey]
87
+ );
88
+ });
84
89
 
85
90
  if (hasChanges) {
86
91
  this.options = { ...this.options, ...config };
@@ -106,6 +111,9 @@ export class SocketService extends Observable<SocketServiceEvents> {
106
111
  path: '/v0/radar/ws',
107
112
  auth: {
108
113
  'x-api-key': this.options.apiKey,
114
+ ...(this.options.clientId
115
+ ? { [ATTR_MULTIPLAYER_SESSION_CLIENT_ID]: this.options.clientId }
116
+ : {}),
109
117
  },
110
118
  reconnectionAttempts: 2,
111
119
  transports: ['websocket'],
@@ -146,6 +154,10 @@ export class SocketService extends Observable<SocketServiceEvents> {
146
154
  this.socket.on(REMOTE_SESSION_RECORDING_STOP, (data: any) => {
147
155
  this.emit(REMOTE_SESSION_RECORDING_STOP, [data]);
148
156
  });
157
+
158
+ this.socket.on(SESSION_SAVE_BUFFER_EVENT, (data: any) => {
159
+ this.emit(SESSION_SAVE_BUFFER_EVENT, [data]);
160
+ });
149
161
  }
150
162
 
151
163
  private checkReconnectionAttempts(): void {
@@ -156,10 +168,10 @@ export class SocketService extends Observable<SocketServiceEvents> {
156
168
 
157
169
  private emitSocketEvent(name: string, data: any): void {
158
170
  if (this.socket && this.isConnected) {
159
- this.socket.emit(name, data)
171
+ this.socket.emit(name, data);
160
172
  } else {
161
- this.queue.push({ data, name })
162
- this._initConnection()
173
+ this.queue.push({ data, name });
174
+ this._initConnection();
163
175
  }
164
176
  }
165
177
 
@@ -174,12 +186,10 @@ export class SocketService extends Observable<SocketServiceEvents> {
174
186
  }
175
187
  }
176
188
 
177
-
178
189
  public send(event: any): void {
179
- this.emitSocketEvent(SESSION_ADD_EVENT, event)
190
+ this.emitSocketEvent(SESSION_ADD_EVENT, event);
180
191
  }
181
192
 
182
-
183
193
  public subscribeToSession(session: ISession): void {
184
194
  this.sessionId = session.shortId || session._id;
185
195
  const payload = {
@@ -188,22 +198,26 @@ export class SocketService extends Observable<SocketServiceEvents> {
188
198
  debugSessionId: this.sessionId,
189
199
  sessionType: session.creationType,
190
200
  };
191
- this.emitSocketEvent(SESSION_SUBSCRIBE_EVENT, payload)
201
+ this.emitSocketEvent(SESSION_SUBSCRIBE_EVENT, payload);
192
202
  // use long id instead of short id
193
- this.emitSocketEvent(SESSION_STARTED_EVENT, { debugSessionId: session._id })
203
+ this.emitSocketEvent(SESSION_STARTED_EVENT, {
204
+ debugSessionId: session._id,
205
+ });
194
206
  }
195
207
 
196
208
  public unsubscribeFromSession(stopSession?: boolean) {
197
209
  if (this.sessionId) {
198
- this.emitSocketEvent(SESSION_UNSUBSCRIBE_EVENT, { debugSessionId: this.sessionId })
210
+ this.emitSocketEvent(SESSION_UNSUBSCRIBE_EVENT, {
211
+ debugSessionId: this.sessionId,
212
+ });
199
213
  if (stopSession) {
200
- this.emitSocketEvent(SESSION_STOPPED_EVENT, {})
214
+ this.emitSocketEvent(SESSION_STOPPED_EVENT, {});
201
215
  }
202
216
  }
203
217
  }
204
218
 
205
219
  public setUser(userAttributes: IUserAttributes | null): void {
206
- this.emitSocketEvent(SOCKET_SET_USER_EVENT, userAttributes)
220
+ this.emitSocketEvent(SOCKET_SET_USER_EVENT, userAttributes);
207
221
  }
208
222
 
209
223
  public close(): Promise<void> {