@multiplayer-app/session-recorder-react-native 1.3.16 → 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 (53) 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/recorder/index.js +15 -1
  12. package/lib/module/recorder/index.js.map +1 -1
  13. package/lib/module/services/api.service.js +24 -2
  14. package/lib/module/services/api.service.js.map +1 -1
  15. package/lib/module/services/crashBuffer.service.js +248 -0
  16. package/lib/module/services/crashBuffer.service.js.map +1 -0
  17. package/lib/module/services/socket.service.js +9 -2
  18. package/lib/module/services/socket.service.js.map +1 -1
  19. package/lib/module/session-recorder.js +152 -6
  20. package/lib/module/session-recorder.js.map +1 -1
  21. package/lib/module/types/session-recorder.js.map +1 -1
  22. package/lib/typescript/src/config/constants.d.ts +1 -0
  23. package/lib/typescript/src/config/constants.d.ts.map +1 -1
  24. package/lib/typescript/src/config/defaults.d.ts.map +1 -1
  25. package/lib/typescript/src/config/session-recorder.d.ts.map +1 -1
  26. package/lib/typescript/src/otel/CrashBufferSpanProcessor.d.ts +18 -0
  27. package/lib/typescript/src/otel/CrashBufferSpanProcessor.d.ts.map +1 -0
  28. package/lib/typescript/src/otel/index.d.ts +8 -0
  29. package/lib/typescript/src/otel/index.d.ts.map +1 -1
  30. package/lib/typescript/src/recorder/index.d.ts +8 -1
  31. package/lib/typescript/src/recorder/index.d.ts.map +1 -1
  32. package/lib/typescript/src/services/api.service.d.ts +27 -2
  33. package/lib/typescript/src/services/api.service.d.ts.map +1 -1
  34. package/lib/typescript/src/services/crashBuffer.service.d.ts +46 -0
  35. package/lib/typescript/src/services/crashBuffer.service.d.ts.map +1 -0
  36. package/lib/typescript/src/services/socket.service.d.ts +4 -3
  37. package/lib/typescript/src/services/socket.service.d.ts.map +1 -1
  38. package/lib/typescript/src/session-recorder.d.ts +8 -0
  39. package/lib/typescript/src/session-recorder.d.ts.map +1 -1
  40. package/lib/typescript/src/types/session-recorder.d.ts +18 -0
  41. package/lib/typescript/src/types/session-recorder.d.ts.map +1 -1
  42. package/package.json +2 -2
  43. package/src/config/constants.ts +3 -0
  44. package/src/config/defaults.ts +5 -0
  45. package/src/config/session-recorder.ts +5 -0
  46. package/src/otel/CrashBufferSpanProcessor.ts +61 -0
  47. package/src/otel/index.ts +90 -34
  48. package/src/recorder/index.ts +30 -3
  49. package/src/services/api.service.ts +68 -13
  50. package/src/services/crashBuffer.service.ts +327 -0
  51. package/src/services/socket.service.ts +36 -22
  52. package/src/session-recorder.ts +226 -19
  53. package/src/types/session-recorder.ts +18 -0
@@ -1,7 +1,7 @@
1
1
  import {
2
2
  SessionType,
3
3
  type ISession,
4
- type IUserAttributes
4
+ type IUserAttributes,
5
5
  } from '@multiplayer-app/session-recorder-common';
6
6
  import { Observable } from 'lib0/observable';
7
7
  import { type eventWithTime } from '@rrweb/types';
@@ -20,7 +20,8 @@ import {
20
20
  import {
21
21
  SESSION_STOPPED_EVENT,
22
22
  REMOTE_SESSION_RECORDING_START,
23
- REMOTE_SESSION_RECORDING_STOP
23
+ REMOTE_SESSION_RECORDING_STOP,
24
+ SESSION_SAVE_BUFFER_EVENT,
24
25
  } from './config';
25
26
  import { getFormattedDate, isSessionActive, getNavigatorInfo } from './utils';
26
27
  import {
@@ -31,6 +32,7 @@ import { BASE_CONFIG, getSessionRecorderConfig } from './config';
31
32
 
32
33
  import { StorageService } from './services/storage.service';
33
34
  import { NetworkService } from './services/network.service';
35
+ import { CrashBufferService } from './services/crashBuffer.service';
34
36
  import {
35
37
  ApiService,
36
38
  type StartSessionRequest,
@@ -42,7 +44,8 @@ type SessionRecorderEvents = 'state-change' | 'init';
42
44
 
43
45
  class SessionRecorder
44
46
  extends Observable<SessionRecorderEvents>
45
- implements ISessionRecorder, EventRecorder {
47
+ implements ISessionRecorder, EventRecorder
48
+ {
46
49
  private _configs: SessionRecorderConfigs;
47
50
  private _apiService = new ApiService();
48
51
  private _socketService = new SocketService();
@@ -50,6 +53,8 @@ class SessionRecorder
50
53
  private _recorder = new RecorderReactNativeSDK();
51
54
  private _storageService = StorageService.getInstance();
52
55
  private _networkService = NetworkService.getInstance();
56
+ private _crashBuffer = CrashBufferService.getInstance();
57
+ private _isFlushingBuffer: boolean = false;
53
58
  private _startRequestController: AbortController | null = null;
54
59
 
55
60
  // Whether the session recorder is initialized
@@ -162,7 +167,10 @@ class SessionRecorder
162
167
  /**
163
168
  * Capture an exception manually and send it as an error trace.
164
169
  */
165
- public captureException(error: unknown, errorInfo?: Record<string, any>): void {
170
+ public captureException(
171
+ error: unknown,
172
+ errorInfo?: Record<string, any>
173
+ ): void {
166
174
  try {
167
175
  const normalizedError = this._normalizeError(error);
168
176
  const normalizedErrorInfo = this._normalizeErrorInfo(errorInfo);
@@ -172,6 +180,119 @@ class SessionRecorder
172
180
  }
173
181
  }
174
182
 
183
+ public async flushBuffer(payload?: { reason?: string }): Promise<any> {
184
+ if (!this._configs?.buffering?.enabled) return null;
185
+ if (this._isFlushingBuffer) return null;
186
+ if (this.sessionState !== SessionState.stopped || this.sessionId)
187
+ return null;
188
+
189
+ const windowMs = Math.max(
190
+ 10_000,
191
+ (this._configs.buffering.windowMinutes || 2) * 60 * 1000
192
+ );
193
+
194
+ this._isFlushingBuffer = true;
195
+ try {
196
+ const reason = payload?.reason || 'manual';
197
+ await this._crashBuffer.setAttrs({
198
+ sessionAttributes: this.sessionAttributes,
199
+ resourceAttributes: getNavigatorInfo(),
200
+ userAttributes: this._userAttributes,
201
+ });
202
+
203
+ const snapshot = await this._crashBuffer.snapshot(windowMs);
204
+ if (
205
+ snapshot.rrwebEvents.length === 0 &&
206
+ snapshot.otelSpans.length === 0
207
+ ) {
208
+ return null;
209
+ }
210
+
211
+ const request: StartSessionRequest = {
212
+ name: `${this._configs.application} ${getFormattedDate(new Date())}`,
213
+ stoppedAt: new Date().toISOString(),
214
+ sessionAttributes: this.sessionAttributes,
215
+ resourceAttributes: getNavigatorInfo(),
216
+ ...(this._userAttributes
217
+ ? { userAttributes: this._userAttributes }
218
+ : {}),
219
+ debugSessionData: {
220
+ meta: {
221
+ reason,
222
+ windowMs: snapshot.windowMs,
223
+ fromTs: snapshot.fromTs,
224
+ toTs: snapshot.toTs,
225
+ },
226
+ events: snapshot.rrwebEvents,
227
+ spans: snapshot.otelSpans.map((s) => s.span),
228
+ attrs: snapshot.attrs,
229
+ },
230
+ };
231
+
232
+ try {
233
+ const res = await this._apiService.startSession(request);
234
+ await this._crashBuffer.clear();
235
+ return res;
236
+ } catch (_e) {
237
+ // swallow: flush is best-effort; never throw into app code
238
+ return null;
239
+ }
240
+ } finally {
241
+ this._isFlushingBuffer = false;
242
+ }
243
+ }
244
+
245
+ private async _flushBuffer(sessionId: string): Promise<any> {
246
+ if (!this._configs?.buffering?.enabled) return null;
247
+ if (this._isFlushingBuffer) return null;
248
+ if (this.sessionState !== SessionState.stopped || this.sessionId)
249
+ return null;
250
+
251
+ const windowMs = Math.max(
252
+ 10_000,
253
+ (this._configs.buffering.windowMinutes || 2) * 60 * 1000
254
+ );
255
+
256
+ this._isFlushingBuffer = true;
257
+ try {
258
+ const snapshot = await this._crashBuffer.snapshot(windowMs);
259
+ if (
260
+ snapshot.rrwebEvents.length === 0 &&
261
+ snapshot.otelSpans.length === 0
262
+ ) {
263
+ return null;
264
+ }
265
+ const spans = snapshot.otelSpans.map((s) => s.span);
266
+ const events = snapshot.rrwebEvents.map((e) => e.event);
267
+ await Promise.all([
268
+ this._tracer.exportTraces(spans),
269
+ this._apiService.exportEvents(sessionId, { events }),
270
+ this._apiService.updateSessionAttributes(sessionId, {
271
+ name: this._getSessionName(),
272
+ sessionAttributes: this.sessionAttributes,
273
+ resourceAttributes: getNavigatorInfo(),
274
+ userAttributes: this._userAttributes || undefined,
275
+ }),
276
+ ]);
277
+ } catch (_e) {
278
+ // swallow: flush is best-effort; never throw into app code
279
+ } finally {
280
+ await this._crashBuffer.clear();
281
+ this._isFlushingBuffer = false;
282
+ }
283
+ }
284
+
285
+ private async _createExceptionSession(span: any): Promise<void> {
286
+ try {
287
+ const session = await this._apiService.createErrorSession({ span });
288
+ if (session) {
289
+ void this._flushBuffer(session._id);
290
+ }
291
+ } catch (_ignored) {
292
+ // best-effort
293
+ }
294
+ }
295
+
175
296
  private async _loadStoredSessionData(): Promise<void> {
176
297
  try {
177
298
  await StorageService.initialize();
@@ -216,14 +337,39 @@ class SessionRecorder
216
337
  this._configs.captureHeaders
217
338
  );
218
339
 
340
+ // Crash buffer wiring (RN): set BEFORE tracer init so early spans don't get exported.
341
+ const bufferEnabled = Boolean(this._configs.buffering?.enabled);
342
+ const windowMs = Math.max(
343
+ 10_000,
344
+ (this._configs.buffering?.windowMinutes || 2) * 60 * 1000
345
+ );
346
+ this._tracer.setCrashBuffer(
347
+ bufferEnabled ? this._crashBuffer : undefined,
348
+ windowMs
349
+ );
350
+
219
351
  this._tracer.init(this._configs);
220
352
  this._apiService.init(this._configs);
221
353
  this._socketService.init({
222
354
  apiKey: this._configs.apiKey,
223
355
  socketUrl: this._configs.apiBaseUrl,
224
- keepAlive: this._configs.useWebsocket
356
+ keepAlive: this._configs.useWebsocket,
357
+ clientId: this._tracer.clientId,
225
358
  });
226
- this._recorder.init(this._configs, this._socketService);
359
+
360
+ this._recorder.init(
361
+ this._configs,
362
+ this._socketService,
363
+ bufferEnabled ? this._crashBuffer : undefined,
364
+ { enabled: bufferEnabled, windowMs }
365
+ );
366
+
367
+ this._crashBuffer.on('error-span-appended', (payload) => {
368
+ if (this.sessionState !== SessionState.stopped || this.sessionId) return;
369
+ if (!payload.span) return;
370
+ this._createExceptionSession(payload.span);
371
+ });
372
+
227
373
  await this._networkService.init();
228
374
  this._setupNetworkCallbacks();
229
375
  this._registerSocketServiceListeners();
@@ -234,10 +380,42 @@ class SessionRecorder
234
380
  this.sessionState === SessionState.paused)
235
381
  ) {
236
382
  this._start();
383
+ } else {
384
+ this._startBufferOnlyRecording();
237
385
  }
238
386
  this.emit('init', []);
239
387
  }
240
388
 
389
+ private _startBufferOnlyRecording(): void {
390
+ if (!this._configs?.buffering?.enabled) return;
391
+ if (this.sessionState !== SessionState.stopped || this.sessionId) return;
392
+
393
+ const windowMs = Math.max(
394
+ 10_000,
395
+ (this._configs.buffering.windowMinutes || 2) * 60 * 1000
396
+ );
397
+
398
+ // Best-effort: persist current attrs so flush has context.
399
+ this._crashBuffer.setAttrs({
400
+ sessionAttributes: this.sessionAttributes,
401
+ resourceAttributes: getNavigatorInfo(),
402
+ userAttributes: this._userAttributes,
403
+ });
404
+
405
+ // Wire buffer into tracer + recorder (only used when sessionId is null).
406
+ this._tracer.setCrashBuffer(this._crashBuffer, windowMs);
407
+ this._recorder.init(this._configs, this._socketService, this._crashBuffer, {
408
+ enabled: true,
409
+ windowMs,
410
+ });
411
+
412
+ // Start capturing events without an active debug session id.
413
+ try {
414
+ this._recorder.stop();
415
+ } catch (_e) {}
416
+ this._recorder.start(null, SessionType.MANUAL);
417
+ }
418
+
241
419
  /**
242
420
  * Register socket service event listeners
243
421
  */
@@ -248,18 +426,32 @@ class SessionRecorder
248
426
  });
249
427
 
250
428
  this._socketService.on(REMOTE_SESSION_RECORDING_START, (payload: any) => {
251
- logger.info('SessionRecorder', 'Remote session recording started', payload);
429
+ logger.info(
430
+ 'SessionRecorder',
431
+ 'Remote session recording started',
432
+ payload
433
+ );
252
434
  if (this.sessionState === SessionState.stopped) {
253
435
  this.start();
254
436
  }
255
437
  });
256
438
 
257
439
  this._socketService.on(REMOTE_SESSION_RECORDING_STOP, (payload: any) => {
258
- logger.info('SessionRecorder', 'Remote session recording stopped', payload);
440
+ logger.info(
441
+ 'SessionRecorder',
442
+ 'Remote session recording stopped',
443
+ payload
444
+ );
259
445
  if (this.sessionState !== SessionState.stopped) {
260
446
  this.stop();
261
447
  }
262
448
  });
449
+
450
+ this._socketService.on(SESSION_SAVE_BUFFER_EVENT, (payload: any) => {
451
+ if (payload?.debugSession?._id) {
452
+ void this._flushBuffer(payload.debugSession._id);
453
+ }
454
+ });
263
455
  }
264
456
 
265
457
  /**
@@ -409,7 +601,7 @@ class SessionRecorder
409
601
  sessionAttributes: this.sessionAttributes,
410
602
  resourceAttributes: getNavigatorInfo(),
411
603
  stoppedAt: Date.now(),
412
- name: this._getSessionName()
604
+ name: this._getSessionName(),
413
605
  }
414
606
  );
415
607
 
@@ -486,7 +678,9 @@ class SessionRecorder
486
678
  sessionAttributes: this.sessionAttributes,
487
679
  resourceAttributes: getNavigatorInfo(),
488
680
  name: this._getSessionName(),
489
- ...(this._userAttributes ? { userAttributes: this._userAttributes } : {}),
681
+ ...(this._userAttributes
682
+ ? { userAttributes: this._userAttributes }
683
+ : {}),
490
684
  };
491
685
  const request: StartSessionRequest = !this.continuousRecording
492
686
  ? payload
@@ -519,6 +713,10 @@ class SessionRecorder
519
713
  this.sessionType = this.sessionType;
520
714
 
521
715
  if (this.sessionId) {
716
+ // Switch from buffer-only recording to session recording cleanly.
717
+ try {
718
+ this._recorder.stop();
719
+ } catch (_e) {}
522
720
  this._tracer.start(this.sessionId, this.sessionType);
523
721
  this._recorder.start(this.sessionId, this.sessionType);
524
722
  if (this.session) {
@@ -533,15 +731,16 @@ class SessionRecorder
533
731
  private _stop(): void {
534
732
  this.sessionState = SessionState.stopped;
535
733
  this._socketService.unsubscribeFromSession(true);
536
- this._tracer.shutdown();
734
+ this._tracer.stop();
537
735
  this._recorder.stop();
736
+ this._startBufferOnlyRecording();
538
737
  }
539
738
 
540
739
  /**
541
740
  * Pause the session tracing and recording
542
741
  */
543
742
  private _pause(): void {
544
- this._tracer.shutdown();
743
+ this._tracer.stop();
545
744
  this._recorder.stop();
546
745
  this.sessionState = SessionState.paused;
547
746
  }
@@ -551,7 +750,10 @@ class SessionRecorder
551
750
  */
552
751
  private _resume(): void {
553
752
  if (this.sessionId) {
554
- this._tracer.setSessionId(this.sessionId, this.sessionType);
753
+ this._tracer.start(this.sessionId, this.sessionType);
754
+ try {
755
+ this._recorder.stop();
756
+ } catch (_e) {}
555
757
  this._recorder.start(this.sessionId, this.sessionType);
556
758
  }
557
759
  this.sessionState = SessionState.started;
@@ -729,18 +931,19 @@ class SessionRecorder
729
931
  }
730
932
  }
731
933
 
732
-
733
934
  /**
734
935
  * Normalize an error info object to a Record<string, any>
735
936
  * @param errorInfo - the error info to normalize
736
937
  * @returns the normalized error info
737
938
  */
738
- private _normalizeErrorInfo(errorInfo?: Record<string, any>): Record<string, any> {
739
- if (!errorInfo) return {}
939
+ private _normalizeErrorInfo(
940
+ errorInfo?: Record<string, any>
941
+ ): Record<string, any> {
942
+ if (!errorInfo) return {};
740
943
  try {
741
- return JSON.parse(JSON.stringify(errorInfo))
944
+ return JSON.parse(JSON.stringify(errorInfo));
742
945
  } catch (_e) {
743
- return { errorInfo: String(errorInfo) }
946
+ return { errorInfo: String(errorInfo) };
744
947
  }
745
948
  }
746
949
 
@@ -749,7 +952,11 @@ class SessionRecorder
749
952
  * @returns the session name
750
953
  */
751
954
  private _getSessionName(date: Date = new Date()): string {
752
- const userName = this.sessionAttributes?.userName || this._userAttributes?.userName || this._userAttributes?.name || '';
955
+ const userName =
956
+ this.sessionAttributes?.userName ||
957
+ this._userAttributes?.userName ||
958
+ this._userAttributes?.name ||
959
+ '';
753
960
  return userName
754
961
  ? `${userName}'s session on ${getFormattedDate(date, { month: 'short', day: 'numeric' })}`
755
962
  : `Session on ${getFormattedDate(date)}`;
@@ -162,6 +162,18 @@ export interface SessionRecorderOptions {
162
162
  * @default true
163
163
  */
164
164
  useWebsocket?: boolean
165
+
166
+ /**
167
+ * (Optional) Client-side crash buffer configuration.
168
+ * When enabled, the SDK keeps a rolling window of recent events + traces
169
+ * even if the user did not start a manual/continuous recording.
170
+ */
171
+ buffering?: {
172
+ /** Enable/disable buffering. @default true */
173
+ enabled?: boolean
174
+ /** Rolling window size (minutes). @default 1 */
175
+ windowMinutes?: number
176
+ }
165
177
  }
166
178
 
167
179
  /**
@@ -349,6 +361,12 @@ export interface ISessionRecorder {
349
361
  * Capture an exception and send it as an error trace
350
362
  */
351
363
  captureException(error: unknown, errorInfo?: Record<string, any>): void;
364
+
365
+ /**
366
+ * Flush the local crash buffer by creating a debug session and uploading buffered data.
367
+ * No-op if a live recording is currently active.
368
+ */
369
+ flushBuffer(payload?: { reason?: string }): Promise<any>;
352
370
  }
353
371
 
354
372
  /**