Skip to content

Commit 51371eb

Browse files
authored
Merge branch 'main' into cocoa-v9
2 parents a7b5f8f + a206511 commit 51371eb

File tree

4 files changed

+242
-10
lines changed

4 files changed

+242
-10
lines changed

.github/workflows/ready-to-merge-workflow.yml

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,9 +19,11 @@ jobs:
1919
- name: Check for exact 'ready-to-merge' label
2020
if: ${{ inputs.is-pr }}
2121
id: check-label
22+
env:
23+
LABELS: ${{ inputs.labels }}
2224
run: |
2325
# Use jq to check for exact label match (avoids substring matching issues with contains())
24-
if echo '${{ inputs.labels }}' | jq -e '.[] | select(.name == "ready-to-merge")' > /dev/null; then
26+
if echo "$LABELS" | jq -e '.[] | select(.name == "ready-to-merge")' > /dev/null; then
2527
echo "label_found=true" >> $GITHUB_OUTPUT
2628
else
2729
echo "label_found=false" >> $GITHUB_OUTPUT

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@
1414
- Improves Expo Router integration to optionally include full paths to components instead of just component names ([#5414](https://github.com/getsentry/sentry-react-native/pull/5414))
1515
- Report slow and frozen frames as TTID/TTFD span data ([#5419](https://github.com/getsentry/sentry-react-native/pull/5419))
1616
- Report slow and frozen frames on spans created through the API ([#5420](https://github.com/getsentry/sentry-react-native/issues/5420))
17+
- Improve performance by adding caching to `getReplayId` ([#5449](https://github.com/getsentry/sentry-react-native/pull/5449))
1718

1819
### Fixes
1920

packages/core/src/js/replay/mobilereplay.ts

Lines changed: 34 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -166,6 +166,25 @@ export const mobileReplayIntegration = (initOptions: MobileReplayOptions = defau
166166

167167
const options = mergeOptions(initOptions);
168168

169+
// Cache the replay ID in JavaScript to avoid excessive bridge calls
170+
// This will be updated when we know the replay ID changes (e.g., after captureReplay)
171+
let cachedReplayId: string | null = null;
172+
173+
function updateCachedReplayId(replayId: string | null): void {
174+
cachedReplayId = replayId;
175+
}
176+
177+
function getCachedReplayId(): string | null {
178+
if (cachedReplayId !== null) {
179+
return cachedReplayId;
180+
}
181+
const nativeReplayId = NATIVE.getCurrentReplayId();
182+
if (nativeReplayId) {
183+
cachedReplayId = nativeReplayId;
184+
}
185+
return nativeReplayId;
186+
}
187+
169188
async function processEvent(event: Event, hint: EventHint): Promise<Event> {
170189
const hasException = event.exception?.values && event.exception.values.length > 0;
171190
if (!hasException) {
@@ -192,19 +211,23 @@ export const mobileReplayIntegration = (initOptions: MobileReplayOptions = defau
192211
}
193212

194213
const replayId = await NATIVE.captureReplay(isHardCrash(event));
195-
if (!replayId) {
214+
if (replayId) {
215+
updateCachedReplayId(replayId);
216+
debug.log(
217+
`[Sentry] ${MOBILE_REPLAY_INTEGRATION_NAME} Captured recording replay ${replayId} for event ${event.event_id}.`,
218+
);
219+
} else {
220+
// Check if there's an ongoing recording and update cache if found
196221
const recordingReplayId = NATIVE.getCurrentReplayId();
197222
if (recordingReplayId) {
223+
updateCachedReplayId(recordingReplayId);
198224
debug.log(
199225
`[Sentry] ${MOBILE_REPLAY_INTEGRATION_NAME} assign already recording replay ${recordingReplayId} for event ${event.event_id}.`,
200226
);
201227
} else {
228+
updateCachedReplayId(null);
202229
debug.log(`[Sentry] ${MOBILE_REPLAY_INTEGRATION_NAME} not sampled for event ${event.event_id}.`);
203230
}
204-
} else {
205-
debug.log(
206-
`[Sentry] ${MOBILE_REPLAY_INTEGRATION_NAME} Captured recording replay ${replayId} for event ${event.event_id}.`,
207-
);
208231
}
209232

210233
return event;
@@ -215,13 +238,16 @@ export const mobileReplayIntegration = (initOptions: MobileReplayOptions = defau
215238
return;
216239
}
217240

241+
// Initialize the cached replay ID on setup
242+
cachedReplayId = NATIVE.getCurrentReplayId();
243+
218244
client.on('createDsc', (dsc: DynamicSamplingContext) => {
219245
if (dsc.replay_id) {
220246
return;
221247
}
222248

223-
// TODO: For better performance, we should emit replayId changes on native, and hold the replayId value in JS
224-
const currentReplayId = NATIVE.getCurrentReplayId();
249+
// Use cached replay ID to avoid bridge calls
250+
const currentReplayId = getCachedReplayId();
225251
if (currentReplayId) {
226252
dsc.replay_id = currentReplayId;
227253
}
@@ -231,7 +257,7 @@ export const mobileReplayIntegration = (initOptions: MobileReplayOptions = defau
231257
}
232258

233259
function getReplayId(): string | null {
234-
return NATIVE.getCurrentReplayId();
260+
return getCachedReplayId();
235261
}
236262

237263
// TODO: When adding manual API, ensure overlap with the web replay so users can use the same API interchangeably

packages/core/test/replay/mobilereplay.test.ts

Lines changed: 204 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import { afterEach, beforeEach, describe, expect, it, jest } from '@jest/globals';
2-
import type { Event, EventHint } from '@sentry/core';
2+
import type { Client, DynamicSamplingContext, Event, EventHint } from '@sentry/core';
33
import { mobileReplayIntegration } from '../../src/js/replay/mobilereplay';
44
import * as environment from '../../src/js/utils/environment';
55
import { NATIVE } from '../../src/js/wrapper';
@@ -279,4 +279,207 @@ describe('Mobile Replay Integration', () => {
279279
expect(integration.processEvent).toBeUndefined();
280280
});
281281
});
282+
283+
describe('replay ID caching', () => {
284+
let mockClient: jest.Mocked<Client>;
285+
let mockOn: jest.Mock;
286+
287+
beforeEach(() => {
288+
mockOn = jest.fn();
289+
mockClient = {
290+
on: mockOn,
291+
} as unknown as jest.Mocked<Client>;
292+
// Reset mocks for each test
293+
jest.clearAllMocks();
294+
});
295+
296+
it('should initialize cache with native replay ID on setup', () => {
297+
const initialReplayId = 'initial-replay-id';
298+
mockGetCurrentReplayId.mockReturnValue(initialReplayId);
299+
300+
const integration = mobileReplayIntegration();
301+
if (integration.setup) {
302+
integration.setup(mockClient);
303+
}
304+
305+
expect(mockGetCurrentReplayId).toHaveBeenCalledTimes(1);
306+
expect(mockOn).toHaveBeenCalledWith('createDsc', expect.any(Function));
307+
});
308+
309+
it('should use cached replay ID in createDsc handler to avoid bridge calls', () => {
310+
const cachedReplayId = 'cached-replay-id';
311+
mockGetCurrentReplayId.mockReturnValue(cachedReplayId);
312+
313+
const integration = mobileReplayIntegration();
314+
if (integration.setup) {
315+
integration.setup(mockClient);
316+
}
317+
318+
// Extract the createDsc handler BEFORE clearing mocks
319+
const createDscCall = mockOn.mock.calls.find(call => call[0] === 'createDsc');
320+
expect(createDscCall).toBeDefined();
321+
const createDscHandler = createDscCall![1] as (dsc: DynamicSamplingContext) => void;
322+
323+
// Clear the mock to track subsequent calls
324+
jest.clearAllMocks();
325+
326+
// Call the handler multiple times
327+
const dsc1: Partial<DynamicSamplingContext> = {};
328+
const dsc2: Partial<DynamicSamplingContext> = {};
329+
const dsc3: Partial<DynamicSamplingContext> = {};
330+
331+
createDscHandler(dsc1 as DynamicSamplingContext);
332+
createDscHandler(dsc2 as DynamicSamplingContext);
333+
createDscHandler(dsc3 as DynamicSamplingContext);
334+
335+
// Should not call native bridge after initial setup
336+
expect(mockGetCurrentReplayId).not.toHaveBeenCalled();
337+
expect(dsc1.replay_id).toBe(cachedReplayId);
338+
expect(dsc2.replay_id).toBe(cachedReplayId);
339+
expect(dsc3.replay_id).toBe(cachedReplayId);
340+
});
341+
342+
it('should not override existing replay_id in createDsc handler', () => {
343+
const cachedReplayId = 'cached-replay-id';
344+
mockGetCurrentReplayId.mockReturnValue(cachedReplayId);
345+
346+
const integration = mobileReplayIntegration();
347+
if (integration.setup) {
348+
integration.setup(mockClient);
349+
}
350+
351+
const createDscCall = mockOn.mock.calls.find(call => call[0] === 'createDsc');
352+
const createDscHandler = createDscCall![1] as (dsc: DynamicSamplingContext) => void;
353+
354+
const dsc: Partial<DynamicSamplingContext> = {
355+
replay_id: 'existing-replay-id',
356+
};
357+
358+
createDscHandler(dsc as DynamicSamplingContext);
359+
360+
expect(dsc.replay_id).toBe('existing-replay-id');
361+
});
362+
363+
it('should update cache when captureReplay returns a new replay ID', async () => {
364+
const initialReplayId = 'initial-replay-id';
365+
const newReplayId = 'new-replay-id';
366+
mockGetCurrentReplayId.mockReturnValue(initialReplayId);
367+
mockCaptureReplay.mockResolvedValue(newReplayId);
368+
369+
const integration = mobileReplayIntegration();
370+
if (integration.setup) {
371+
integration.setup(mockClient);
372+
}
373+
374+
const event: Event = {
375+
event_id: 'test-event-id',
376+
exception: {
377+
values: [{ type: 'Error', value: 'Test error' }],
378+
},
379+
};
380+
const hint: EventHint = {};
381+
382+
if (integration.processEvent) {
383+
await integration.processEvent(event, hint);
384+
}
385+
386+
// Verify cache was updated by checking getReplayId
387+
expect(integration.getReplayId()).toBe(newReplayId);
388+
389+
// Extract the createDsc handler BEFORE clearing mocks
390+
const createDscCall = mockOn.mock.calls.find(call => call[0] === 'createDsc');
391+
expect(createDscCall).toBeDefined();
392+
const createDscHandler = createDscCall![1] as (dsc: DynamicSamplingContext) => void;
393+
394+
// Clear the mock to track subsequent calls
395+
jest.clearAllMocks();
396+
397+
const dsc: Partial<DynamicSamplingContext> = {};
398+
createDscHandler(dsc as DynamicSamplingContext);
399+
400+
expect(dsc.replay_id).toBe(newReplayId);
401+
expect(mockGetCurrentReplayId).not.toHaveBeenCalled();
402+
});
403+
404+
it('should update cache when ongoing recording is detected', async () => {
405+
const initialReplayId = 'initial-replay-id';
406+
const ongoingReplayId = 'ongoing-replay-id';
407+
mockGetCurrentReplayId.mockReturnValue(initialReplayId);
408+
mockCaptureReplay.mockResolvedValue(null);
409+
// After captureReplay returns null, getCurrentReplayId should return ongoing recording
410+
mockGetCurrentReplayId.mockReturnValueOnce(initialReplayId).mockReturnValue(ongoingReplayId);
411+
412+
const integration = mobileReplayIntegration();
413+
if (integration.setup) {
414+
integration.setup(mockClient);
415+
}
416+
417+
const event: Event = {
418+
event_id: 'test-event-id',
419+
exception: {
420+
values: [{ type: 'Error', value: 'Test error' }],
421+
},
422+
};
423+
const hint: EventHint = {};
424+
425+
if (integration.processEvent) {
426+
await integration.processEvent(event, hint);
427+
}
428+
429+
// Verify cache was updated with ongoing recording ID
430+
expect(integration.getReplayId()).toBe(ongoingReplayId);
431+
});
432+
433+
it('should clear cache when no recording is in progress', async () => {
434+
const initialReplayId = 'initial-replay-id';
435+
mockGetCurrentReplayId.mockReturnValue(initialReplayId);
436+
mockCaptureReplay.mockResolvedValue(null);
437+
// After captureReplay returns null, getCurrentReplayId should return null (no recording)
438+
mockGetCurrentReplayId.mockReturnValueOnce(initialReplayId).mockReturnValue(null);
439+
440+
const integration = mobileReplayIntegration();
441+
if (integration.setup) {
442+
integration.setup(mockClient);
443+
}
444+
445+
const event: Event = {
446+
event_id: 'test-event-id',
447+
exception: {
448+
values: [{ type: 'Error', value: 'Test error' }],
449+
},
450+
};
451+
const hint: EventHint = {};
452+
453+
if (integration.processEvent) {
454+
await integration.processEvent(event, hint);
455+
}
456+
457+
// Verify cache was cleared
458+
expect(integration.getReplayId()).toBeNull();
459+
});
460+
461+
it('should use cached value in getReplayId to avoid bridge calls', () => {
462+
const cachedReplayId = 'cached-replay-id';
463+
mockGetCurrentReplayId.mockReturnValue(cachedReplayId);
464+
465+
const integration = mobileReplayIntegration();
466+
if (integration.setup) {
467+
integration.setup(mockClient);
468+
}
469+
470+
// Clear the mock to track subsequent calls
471+
jest.clearAllMocks();
472+
473+
// Call getReplayId multiple times
474+
const id1 = integration.getReplayId();
475+
const id2 = integration.getReplayId();
476+
const id3 = integration.getReplayId();
477+
478+
// Should not call native bridge after initial setup
479+
expect(mockGetCurrentReplayId).not.toHaveBeenCalled();
480+
expect(id1).toBe(cachedReplayId);
481+
expect(id2).toBe(cachedReplayId);
482+
expect(id3).toBe(cachedReplayId);
483+
});
484+
});
282485
});

0 commit comments

Comments
 (0)