Skip to content

Commit 86ba31e

Browse files
authored
fix(react-native): forward attributes added after init to native crash reports (#384)
Attributes added after initialize() never reached native crash reports, since the crash reporter instance was never assigned and the update listener would silently no-op.
1 parent d53fac4 commit 86ba31e

2 files changed

Lines changed: 104 additions & 2 deletions

File tree

‎packages/react-native/src/BacktraceClient.ts‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,7 @@ import { ReactStackTraceConverter } from './ReactStackTraceConverter';
2323
import { type FileSystem } from './storage/FileSystem';
2424

2525
export class BacktraceClient extends BacktraceCoreClient<BacktraceConfiguration> {
26-
private readonly _crashReporter?: CrashReporter;
26+
private _crashReporter?: CrashReporter;
2727
private readonly _exceptionHandler: ExceptionHandler = generateUnhandledExceptionHandler();
2828

2929
public crash(): void {
@@ -76,7 +76,7 @@ export class BacktraceClient extends BacktraceCoreClient<BacktraceConfiguration>
7676
this.options.captureUnhandledPromiseRejections,
7777
);
7878

79-
this.initializeNativeCrashReporter();
79+
this._crashReporter = this.initializeNativeCrashReporter();
8080
} finally {
8181
lockId && this.sessionFiles?.unlockPreviousSessions(lockId);
8282
}
Lines changed: 102 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,102 @@
1+
import { NativeModules } from 'react-native';
2+
import { mockStreamFileSystem } from './_mocks/fileSystem';
3+
4+
// This package's jest config replaces the react-native preset's setupFiles, so the real Platform throws.
5+
jest.mock('react-native', () => ({
6+
NativeModules: {},
7+
Platform: {
8+
OS: 'ios',
9+
select: (options: Record<string, unknown>) => (options.ios !== undefined ? options.ios : options.default),
10+
},
11+
}));
12+
13+
jest.mock('../src/common/platformHelper', () => ({
14+
version: () => '0.81.6',
15+
}));
16+
17+
const nativeMock = {
18+
initialize: jest.fn(),
19+
useAttributes: jest.fn(),
20+
crash: jest.fn(),
21+
};
22+
23+
// CrashReporter caches BacktraceReactNative in a static field, so the mock has to land before the module loads.
24+
NativeModules.BacktraceReactNative = nativeMock;
25+
NativeModules.BacktraceDirectoryProvider = { applicationDirectory: () => '/' };
26+
(globalThis as unknown as { RN$Bridgeless: boolean }).RN$Bridgeless = true;
27+
28+
/* eslint-disable @typescript-eslint/no-var-requires */
29+
const { BacktraceClient } = require('../src/BacktraceClient');
30+
const { CrashReporter } = require('../src/crashReporter/CrashReporter');
31+
/* eslint-enable @typescript-eslint/no-var-requires */
32+
33+
function createClient() {
34+
return new BacktraceClient({
35+
options: {
36+
url: 'https://submit.backtrace.io/universe/token/json',
37+
database: { enable: true, captureNativeCrashes: true, path: '/backtrace' },
38+
metrics: { enable: false },
39+
breadcrumbs: { enable: false },
40+
// Normally supplied by the native attribute providers; the core client rejects init without them.
41+
userAttributes: { application: 'nativeAttributePropagation', 'application.version': '1.0.0' },
42+
},
43+
fileSystem: mockStreamFileSystem(),
44+
});
45+
}
46+
47+
// Every update carries the full scoped set rather than a delta, so merge the calls before asserting.
48+
function attributesSentToNative(): Record<string, string> {
49+
return Object.assign({}, ...nativeMock.useAttributes.mock.calls.map((call) => call[0]));
50+
}
51+
52+
describe('BacktraceClient native attribute propagation', () => {
53+
beforeEach(() => {
54+
jest.clearAllMocks();
55+
// initialized is static, and would otherwise make initialize() a no-op for every test after the first.
56+
(CrashReporter as unknown as { initialized: boolean }).initialized = false;
57+
});
58+
59+
it('Should pass the attributes known at initialization to the native crash reporter', () => {
60+
createClient().initialize();
61+
62+
expect(nativeMock.initialize).toHaveBeenCalledTimes(1);
63+
expect(nativeMock.initialize.mock.calls[0][2]['error.type']).toBe('Crash');
64+
});
65+
66+
it('Should forward an attribute added after initialization to the native crash reporter', () => {
67+
const client = createClient();
68+
client.initialize();
69+
nativeMock.useAttributes.mockClear();
70+
71+
client.addAttribute({ 'session.id': 'abc-123' });
72+
73+
expect(nativeMock.useAttributes).toHaveBeenCalled();
74+
expect(attributesSentToNative()['session.id']).toBe('abc-123');
75+
});
76+
77+
it('Should forward every later attribute update, stringifying non-string values', () => {
78+
const client = createClient();
79+
client.initialize();
80+
nativeMock.useAttributes.mockClear();
81+
82+
client.addAttribute({ 'session.id': 'abc-123' });
83+
client.addAttribute({ 'retry.count': 7 });
84+
85+
const forwarded = attributesSentToNative();
86+
expect(forwarded['session.id']).toBe('abc-123');
87+
expect(forwarded['retry.count']).toBe('7');
88+
});
89+
90+
it('Should NOT forward attributes once the client is disposed', () => {
91+
const client = createClient();
92+
client.initialize();
93+
client.addAttribute({ 'before.dispose': 'yes' });
94+
expect(nativeMock.useAttributes).toHaveBeenCalled();
95+
96+
client.dispose();
97+
nativeMock.useAttributes.mockClear();
98+
client.addAttribute({ 'after.dispose': 'no' });
99+
100+
expect(nativeMock.useAttributes).not.toHaveBeenCalled();
101+
});
102+
});

0 commit comments

Comments
 (0)