Skip to content
6 changes: 6 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,12 @@
> make sure you follow our [migration guide](https://docs.sentry.io/platforms/react-native/migration/) first.
<!-- prettier-ignore-end -->

## Unreleased

### Features

- Add mobile replay attributes to logs ([#5165](https://github.com/getsentry/sentry-react-native/pull/5165))

## 7.1.0

### Fixes
Expand Down
46 changes: 37 additions & 9 deletions packages/core/src/js/integrations/logEnricherIntegration.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ export const logEnricherIntegration = (): Integration => {
cacheLogContext().then(
() => {
client.on('beforeCaptureLog', (log: Log) => {
processLog(log);
processLog(log, client);
});
},
reason => {
Expand All @@ -28,6 +28,25 @@ export const logEnricherIntegration = (): Integration => {

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

/**
* Sets a log attribute if the value exists and the attribute key is not already present.
*
* @param logAttributes - The log attributes object to modify.
* @param key - The attribute key to set.
* @param value - The value to set (only sets if truthy and key not present).
* @param setEvenIfPresent - Whether to set the attribute if it is present. Defaults to true.
*/
function setLogAttribute(
logAttributes: Record<string, unknown>,
key: string,
value: unknown,
setEvenIfPresent = true,
): void {
if (value && (!logAttributes[key] || setEvenIfPresent)) {
logAttributes[key] = value;
}
}

async function cacheLogContext(): Promise<void> {
try {
const response = await NATIVE.fetchNativeLogAttributes();
Expand All @@ -52,16 +71,25 @@ async function cacheLogContext(): Promise<void> {
return Promise.resolve();
}

function processLog(log: Log): void {
function processLog(log: Log, client: ReactNativeClient): void {
if (NativeCache === undefined) {
return;
}

log.attributes = log.attributes ?? {};
NativeCache.brand && (log.attributes['device.brand'] = NativeCache.brand);
NativeCache.model && (log.attributes['device.model'] = NativeCache.model);
NativeCache.family && (log.attributes['device.family'] = NativeCache.family);
NativeCache.os && (log.attributes['os.name'] = NativeCache.os);
NativeCache.version && (log.attributes['os.version'] = NativeCache.version);
NativeCache.release && (log.attributes['sentry.release'] = NativeCache.release);
// Save log.attributes to a new variable
const logAttributes = log.attributes ?? {};

// Use setLogAttribute with the variable instead of direct assignment
setLogAttribute(logAttributes, 'device.brand', NativeCache.brand);
setLogAttribute(logAttributes, 'device.model', NativeCache.model);
setLogAttribute(logAttributes, 'device.family', NativeCache.family);
setLogAttribute(logAttributes, 'os.name', NativeCache.os);
setLogAttribute(logAttributes, 'os.version', NativeCache.version);
setLogAttribute(logAttributes, 'sentry.release', NativeCache.release);

const replay = client.getIntegrationByName<Integration & { getReplayId: () => string | null }>('MobileReplay');
setLogAttribute(logAttributes, 'sentry.replay_id', replay?.getReplayId());

// Set log.attributes to the variable
log.attributes = logAttributes;
}
7 changes: 7 additions & 0 deletions packages/core/src/js/replay/mobilereplay.ts
Original file line number Diff line number Diff line change
Expand Up @@ -95,6 +95,7 @@ function mergeOptions(initOptions: Partial<MobileReplayOptions>): Required<Mobil

type MobileReplayIntegration = Integration & {
options: Required<MobileReplayOptions>;
getReplayId: () => string | null;
};

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

function getReplayId(): string | null {
return NATIVE.getCurrentReplayId();
}

// TODO: When adding manual API, ensure overlap with the web replay so users can use the same API interchangeably
// https://github.com/getsentry/sentry-javascript/blob/develop/packages/replay-internal/src/integration.ts#L45
return {
name: MOBILE_REPLAY_INTEGRATION_NAME,
setup,
processEvent,
options: options,
getReplayId: getReplayId,
};
};

const mobileReplayIntegrationNoop = (): MobileReplayIntegration => {
return {
name: MOBILE_REPLAY_INTEGRATION_NAME,
options: defaultOptions,
getReplayId: () => null, // Mock implementation for noop version
};
};
112 changes: 112 additions & 0 deletions packages/core/test/integrations/logEnricherIntegration.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ describe('LogEnricher Integration', () => {
let mockClient: jest.Mocked<Client>;
let mockOn: jest.Mock;
let mockFetchNativeLogAttributes: jest.Mock;
let mockGetIntegrationByName: jest.Mock;

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

mockOn = jest.fn();
mockFetchNativeLogAttributes = jest.fn();
mockGetIntegrationByName = jest.fn();

mockClient = {
on: mockOn,
getIntegrationByName: mockGetIntegrationByName,
} as unknown as jest.Mocked<Client>;

(NATIVE as jest.Mocked<typeof NATIVE>).fetchNativeLogAttributes = mockFetchNativeLogAttributes;
Expand Down Expand Up @@ -404,4 +407,113 @@ describe('LogEnricher Integration', () => {
expect(mockOn).toHaveBeenCalledWith('beforeCaptureLog', expect.any(Function));
});
});

describe('replay log functionality', () => {
let logHandler: (log: Log) => void;
let mockLog: Log;
let mockGetIntegrationByName: jest.Mock;

beforeEach(async () => {
const integration = logEnricherIntegration();

const mockNativeResponse: NativeDeviceContextsResponse = {
contexts: {
device: {
brand: 'Apple',
model: 'iPhone 14',
family: 'iPhone',
} as Record<string, unknown>,
os: {
name: 'iOS',
version: '16.0',
} as Record<string, unknown>,
release: '1.0.0' as unknown as Record<string, unknown>,
},
};

mockFetchNativeLogAttributes.mockResolvedValue(mockNativeResponse);
mockGetIntegrationByName = jest.fn();

mockClient = {
on: mockOn,
getIntegrationByName: mockGetIntegrationByName,
} as unknown as jest.Mocked<Client>;

integration.setup(mockClient);

triggerAfterInit();

await jest.runAllTimersAsync();

const beforeCaptureLogCall = mockOn.mock.calls.find(call => call[0] === 'beforeCaptureLog');
expect(beforeCaptureLogCall).toBeDefined();
logHandler = beforeCaptureLogCall![1] as (log: Log) => void;

mockLog = {
message: 'Test log message',
level: 'info',
attributes: {},
};
});

it('should add replay_id when MobileReplay integration is available and returns a replay ID', () => {
const mockReplayId = 'replay-123-abc';
const mockReplayIntegration = {
getReplayId: jest.fn().mockReturnValue(mockReplayId),
};

mockGetIntegrationByName.mockReturnValue(mockReplayIntegration);

logHandler(mockLog);

expect(mockLog.attributes).toEqual({
'device.brand': 'Apple',
'device.model': 'iPhone 14',
'device.family': 'iPhone',
'os.name': 'iOS',
'os.version': '16.0',
'sentry.release': '1.0.0',
'sentry.replay_id': mockReplayId,
});
expect(mockGetIntegrationByName).toHaveBeenCalledWith('MobileReplay');
expect(mockReplayIntegration.getReplayId).toHaveBeenCalled();
});

it('should not add replay_id when MobileReplay integration returns null', () => {
const mockReplayIntegration = {
getReplayId: jest.fn().mockReturnValue(null),
};

mockGetIntegrationByName.mockReturnValue(mockReplayIntegration);

logHandler(mockLog);

expect(mockLog.attributes).toEqual({
'device.brand': 'Apple',
'device.model': 'iPhone 14',
'device.family': 'iPhone',
'os.name': 'iOS',
'os.version': '16.0',
'sentry.release': '1.0.0',
});
expect(mockGetIntegrationByName).toHaveBeenCalledWith('MobileReplay');
expect(mockReplayIntegration.getReplayId).toHaveBeenCalled();
});

it('should not add replay_id when MobileReplay integration is not available', () => {
mockGetIntegrationByName.mockReturnValue(undefined);

logHandler(mockLog);

expect(mockLog.attributes).toEqual({
'device.brand': 'Apple',
'device.model': 'iPhone 14',
'device.family': 'iPhone',
'os.name': 'iOS',
'os.version': '16.0',
'sentry.release': '1.0.0',
});
expect(mockGetIntegrationByName).toHaveBeenCalledWith('MobileReplay');
});
});
});
Loading