Skip to content

Commit 6469ab0

Browse files
Feat(Logs): Add mobile replay attributes to logs (#5165)
* set replay id to logs * add tests * yarn fix * add changelog * move changelog * extra space * Update CHANGELOG.md
1 parent de3e501 commit 6469ab0

File tree

4 files changed

+162
-9
lines changed

4 files changed

+162
-9
lines changed

CHANGELOG.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,12 @@
66
> make sure you follow our [migration guide](https://docs.sentry.io/platforms/react-native/migration/) first.
77
<!-- prettier-ignore-end -->
88
9+
## Unreleased
10+
11+
### Features
12+
13+
- Add mobile replay attributes to logs ([#5165](https://github.com/getsentry/sentry-react-native/pull/5165))
14+
915
## 7.1.0
1016

1117
### Fixes

packages/core/src/js/integrations/logEnricherIntegration.ts

Lines changed: 37 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,7 @@ export const logEnricherIntegration = (): Integration => {
1414
cacheLogContext().then(
1515
() => {
1616
client.on('beforeCaptureLog', (log: Log) => {
17-
processLog(log);
17+
processLog(log, client);
1818
});
1919
},
2020
reason => {
@@ -28,6 +28,25 @@ export const logEnricherIntegration = (): Integration => {
2828

2929
let NativeCache: Record<string, unknown> | undefined = undefined;
3030

31+
/**
32+
* Sets a log attribute if the value exists and the attribute key is not already present.
33+
*
34+
* @param logAttributes - The log attributes object to modify.
35+
* @param key - The attribute key to set.
36+
* @param value - The value to set (only sets if truthy and key not present).
37+
* @param setEvenIfPresent - Whether to set the attribute if it is present. Defaults to true.
38+
*/
39+
function setLogAttribute(
40+
logAttributes: Record<string, unknown>,
41+
key: string,
42+
value: unknown,
43+
setEvenIfPresent = true,
44+
): void {
45+
if (value && (!logAttributes[key] || setEvenIfPresent)) {
46+
logAttributes[key] = value;
47+
}
48+
}
49+
3150
async function cacheLogContext(): Promise<void> {
3251
try {
3352
const response = await NATIVE.fetchNativeLogAttributes();
@@ -52,16 +71,25 @@ async function cacheLogContext(): Promise<void> {
5271
return Promise.resolve();
5372
}
5473

55-
function processLog(log: Log): void {
74+
function processLog(log: Log, client: ReactNativeClient): void {
5675
if (NativeCache === undefined) {
5776
return;
5877
}
5978

60-
log.attributes = log.attributes ?? {};
61-
NativeCache.brand && (log.attributes['device.brand'] = NativeCache.brand);
62-
NativeCache.model && (log.attributes['device.model'] = NativeCache.model);
63-
NativeCache.family && (log.attributes['device.family'] = NativeCache.family);
64-
NativeCache.os && (log.attributes['os.name'] = NativeCache.os);
65-
NativeCache.version && (log.attributes['os.version'] = NativeCache.version);
66-
NativeCache.release && (log.attributes['sentry.release'] = NativeCache.release);
79+
// Save log.attributes to a new variable
80+
const logAttributes = log.attributes ?? {};
81+
82+
// Use setLogAttribute with the variable instead of direct assignment
83+
setLogAttribute(logAttributes, 'device.brand', NativeCache.brand);
84+
setLogAttribute(logAttributes, 'device.model', NativeCache.model);
85+
setLogAttribute(logAttributes, 'device.family', NativeCache.family);
86+
setLogAttribute(logAttributes, 'os.name', NativeCache.os);
87+
setLogAttribute(logAttributes, 'os.version', NativeCache.version);
88+
setLogAttribute(logAttributes, 'sentry.release', NativeCache.release);
89+
90+
const replay = client.getIntegrationByName<Integration & { getReplayId: () => string | null }>('MobileReplay');
91+
setLogAttribute(logAttributes, 'sentry.replay_id', replay?.getReplayId());
92+
93+
// Set log.attributes to the variable
94+
log.attributes = logAttributes;
6795
}

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

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -95,6 +95,7 @@ function mergeOptions(initOptions: Partial<MobileReplayOptions>): Required<Mobil
9595

9696
type MobileReplayIntegration = Integration & {
9797
options: Required<MobileReplayOptions>;
98+
getReplayId: () => string | null;
9899
};
99100

100101
/**
@@ -173,19 +174,25 @@ export const mobileReplayIntegration = (initOptions: MobileReplayOptions = defau
173174
client.on('beforeAddBreadcrumb', enrichXhrBreadcrumbsForMobileReplay);
174175
}
175176

177+
function getReplayId(): string | null {
178+
return NATIVE.getCurrentReplayId();
179+
}
180+
176181
// TODO: When adding manual API, ensure overlap with the web replay so users can use the same API interchangeably
177182
// https://github.com/getsentry/sentry-javascript/blob/develop/packages/replay-internal/src/integration.ts#L45
178183
return {
179184
name: MOBILE_REPLAY_INTEGRATION_NAME,
180185
setup,
181186
processEvent,
182187
options: options,
188+
getReplayId: getReplayId,
183189
};
184190
};
185191

186192
const mobileReplayIntegrationNoop = (): MobileReplayIntegration => {
187193
return {
188194
name: MOBILE_REPLAY_INTEGRATION_NAME,
189195
options: defaultOptions,
196+
getReplayId: () => null, // Mock implementation for noop version
190197
};
191198
};

packages/core/test/integrations/logEnricherIntegration.test.ts

Lines changed: 112 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@ describe('LogEnricher Integration', () => {
2727
let mockClient: jest.Mocked<Client>;
2828
let mockOn: jest.Mock;
2929
let mockFetchNativeLogAttributes: jest.Mock;
30+
let mockGetIntegrationByName: jest.Mock;
3031

3132
const triggerAfterInit = () => {
3233
const afterInitCallback = mockOn.mock.calls.find(call => call[0] === 'afterInit')?.[1] as (() => void) | undefined;
@@ -40,9 +41,11 @@ describe('LogEnricher Integration', () => {
4041

4142
mockOn = jest.fn();
4243
mockFetchNativeLogAttributes = jest.fn();
44+
mockGetIntegrationByName = jest.fn();
4345

4446
mockClient = {
4547
on: mockOn,
48+
getIntegrationByName: mockGetIntegrationByName,
4649
} as unknown as jest.Mocked<Client>;
4750

4851
(NATIVE as jest.Mocked<typeof NATIVE>).fetchNativeLogAttributes = mockFetchNativeLogAttributes;
@@ -404,4 +407,113 @@ describe('LogEnricher Integration', () => {
404407
expect(mockOn).toHaveBeenCalledWith('beforeCaptureLog', expect.any(Function));
405408
});
406409
});
410+
411+
describe('replay log functionality', () => {
412+
let logHandler: (log: Log) => void;
413+
let mockLog: Log;
414+
let mockGetIntegrationByName: jest.Mock;
415+
416+
beforeEach(async () => {
417+
const integration = logEnricherIntegration();
418+
419+
const mockNativeResponse: NativeDeviceContextsResponse = {
420+
contexts: {
421+
device: {
422+
brand: 'Apple',
423+
model: 'iPhone 14',
424+
family: 'iPhone',
425+
} as Record<string, unknown>,
426+
os: {
427+
name: 'iOS',
428+
version: '16.0',
429+
} as Record<string, unknown>,
430+
release: '1.0.0' as unknown as Record<string, unknown>,
431+
},
432+
};
433+
434+
mockFetchNativeLogAttributes.mockResolvedValue(mockNativeResponse);
435+
mockGetIntegrationByName = jest.fn();
436+
437+
mockClient = {
438+
on: mockOn,
439+
getIntegrationByName: mockGetIntegrationByName,
440+
} as unknown as jest.Mocked<Client>;
441+
442+
integration.setup(mockClient);
443+
444+
triggerAfterInit();
445+
446+
await jest.runAllTimersAsync();
447+
448+
const beforeCaptureLogCall = mockOn.mock.calls.find(call => call[0] === 'beforeCaptureLog');
449+
expect(beforeCaptureLogCall).toBeDefined();
450+
logHandler = beforeCaptureLogCall![1] as (log: Log) => void;
451+
452+
mockLog = {
453+
message: 'Test log message',
454+
level: 'info',
455+
attributes: {},
456+
};
457+
});
458+
459+
it('should add replay_id when MobileReplay integration is available and returns a replay ID', () => {
460+
const mockReplayId = 'replay-123-abc';
461+
const mockReplayIntegration = {
462+
getReplayId: jest.fn().mockReturnValue(mockReplayId),
463+
};
464+
465+
mockGetIntegrationByName.mockReturnValue(mockReplayIntegration);
466+
467+
logHandler(mockLog);
468+
469+
expect(mockLog.attributes).toEqual({
470+
'device.brand': 'Apple',
471+
'device.model': 'iPhone 14',
472+
'device.family': 'iPhone',
473+
'os.name': 'iOS',
474+
'os.version': '16.0',
475+
'sentry.release': '1.0.0',
476+
'sentry.replay_id': mockReplayId,
477+
});
478+
expect(mockGetIntegrationByName).toHaveBeenCalledWith('MobileReplay');
479+
expect(mockReplayIntegration.getReplayId).toHaveBeenCalled();
480+
});
481+
482+
it('should not add replay_id when MobileReplay integration returns null', () => {
483+
const mockReplayIntegration = {
484+
getReplayId: jest.fn().mockReturnValue(null),
485+
};
486+
487+
mockGetIntegrationByName.mockReturnValue(mockReplayIntegration);
488+
489+
logHandler(mockLog);
490+
491+
expect(mockLog.attributes).toEqual({
492+
'device.brand': 'Apple',
493+
'device.model': 'iPhone 14',
494+
'device.family': 'iPhone',
495+
'os.name': 'iOS',
496+
'os.version': '16.0',
497+
'sentry.release': '1.0.0',
498+
});
499+
expect(mockGetIntegrationByName).toHaveBeenCalledWith('MobileReplay');
500+
expect(mockReplayIntegration.getReplayId).toHaveBeenCalled();
501+
});
502+
503+
it('should not add replay_id when MobileReplay integration is not available', () => {
504+
mockGetIntegrationByName.mockReturnValue(undefined);
505+
506+
logHandler(mockLog);
507+
508+
expect(mockLog.attributes).toEqual({
509+
'device.brand': 'Apple',
510+
'device.model': 'iPhone 14',
511+
'device.family': 'iPhone',
512+
'os.name': 'iOS',
513+
'os.version': '16.0',
514+
'sentry.release': '1.0.0',
515+
});
516+
expect(mockGetIntegrationByName).toHaveBeenCalledWith('MobileReplay');
517+
});
518+
});
407519
});

0 commit comments

Comments
 (0)