Skip to content

Commit 71bb842

Browse files
authored
bugfix: deliver breadcrumbs to native crash reports (#392)
Breadcrumbs never reached native crash reports: nothing pushed the chunk files or breadcrumbs.lastId after init, and rotation never triggered because exact-boundary splits were dropped.
1 parent 5502f0c commit 71bb842

6 files changed

Lines changed: 175 additions & 4 deletions

File tree

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

Lines changed: 16 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,9 @@ import { ReactNativeRequestHandler } from './ReactNativeRequestHandler';
2424
import { ReactStackTraceConverter } from './ReactStackTraceConverter';
2525
import { type FileSystem } from './storage/FileSystem';
2626

27+
// Must match the private attribute name BreadcrumbsManager sets on JS reports.
28+
const BREADCRUMB_ATTRIBUTE_NAME = 'breadcrumbs.lastId';
29+
2730
export class BacktraceClient extends BacktraceCoreClient<BacktraceConfiguration> {
2831
private _crashReporter?: CrashReporter;
2932
private _anrWatchdogHandler?: AnrWatchdogHandler;
@@ -59,7 +62,11 @@ export class BacktraceClient extends BacktraceCoreClient<BacktraceConfiguration>
5962

6063
const breadcrumbsManager = this.modules.get(BreadcrumbsManager);
6164
if (breadcrumbsManager && this.sessionFiles) {
62-
breadcrumbsManager.setStorage(FileBreadcrumbsStorage.factory(this.sessionFiles, fileSystem));
65+
breadcrumbsManager.setStorage(
66+
FileBreadcrumbsStorage.factory(this.sessionFiles, fileSystem, (lastBreadcrumbId) =>
67+
this.refreshNativeBreadcrumbs(lastBreadcrumbId),
68+
),
69+
);
6370
}
6471

6572
this.attributeManager.attributeEvents.on(
@@ -158,6 +165,14 @@ export class BacktraceClient extends BacktraceCoreClient<BacktraceConfiguration>
158165
this._anrWatchdogHandler?.start(this, anr.timeout ?? 0, anr.disableWhenDebuggerAttached ?? false);
159166
}
160167

168+
private refreshNativeBreadcrumbs(lastBreadcrumbId: number) {
169+
if (!this._crashReporter) {
170+
return;
171+
}
172+
this._crashReporter.updateAttachments(this.attachments);
173+
this._crashReporter.updateAttributes({ [BREADCRUMB_ATTRIBUTE_NAME]: lastBreadcrumbId });
174+
}
175+
161176
private initializeNativeCrashReporter(): CrashReporter | undefined {
162177
if (!this.options.database?.enable) {
163178
return;

‎packages/react-native/src/breadcrumbs/FileBreadcrumbsStorage.ts‎

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -36,11 +36,13 @@ export class FileBreadcrumbsStorage implements BreadcrumbsStorage {
3636
session: SessionFiles,
3737
private readonly _fileSystem: FileSystem,
3838
private readonly _limits: BreadcrumbsStorageLimits,
39+
onFilesChange?: (lastBreadcrumbId: number) => void,
3940
) {
4041
this._sink = new FileChunkSink({
4142
maxFiles: 2,
4243
fs: this._fileSystem,
4344
file: (n) => session.getFileName(FileBreadcrumbsStorage.getFileName(n)),
45+
onFilesChange: onFilesChange && (() => onFilesChange(this._lastBreadcrumbId)),
4446
});
4547

4648
const splitters: ChunkSplitterFactory<string>[] = [];
@@ -72,8 +74,12 @@ export class FileBreadcrumbsStorage implements BreadcrumbsStorage {
7274
this._destinationWriter = this._destinationStream.getWriter();
7375
}
7476

75-
public static factory(session: SessionFiles, fileSystem: FileSystem): BreadcrumbsStorageFactory {
76-
return ({ limits }) => new FileBreadcrumbsStorage(session, fileSystem, limits);
77+
public static factory(
78+
session: SessionFiles,
79+
fileSystem: FileSystem,
80+
onFilesChange?: (lastBreadcrumbId: number) => void,
81+
): BreadcrumbsStorageFactory {
82+
return ({ limits }) => new FileBreadcrumbsStorage(session, fileSystem, limits, onFilesChange);
7783
}
7884

7985
public getAttachments(): BacktraceFileAttachment[] {

‎packages/react-native/src/storage/FileChunkSink.ts‎

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,11 @@ interface FileChunkSinkOptions {
1717
* File system to use.
1818
*/
1919
readonly fs: FileSystem;
20+
21+
/**
22+
* Called after the tracked file set changes.
23+
*/
24+
readonly onFilesChange?: () => void;
2025
}
2126

2227
/**
@@ -57,6 +62,7 @@ export class FileChunkSink {
5762
return (n) => {
5863
const stream = this.createStream(n);
5964
this._streamTracker.push(stream);
65+
this._options.onFilesChange?.();
6066
return stream;
6167
};
6268
}

‎packages/react-native/src/storage/combinedChunkSplitter.ts‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,8 @@ export function combinedChunkSplitter<W extends Chunk>(
2121
for (const splitter of splitters) {
2222
const [c1, c2] = splitter(chunk);
2323
chunk = c1;
24-
if (c2) {
24+
// An empty second chunk still means "split here"; dropping it loses the split.
25+
if (c2 !== undefined) {
2526
// Prepend second chunk to the rest
2627
rest.unshift(c2);
2728
}
Lines changed: 107 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,107 @@
1+
import { NativeModules } from 'react-native';
2+
import { promisify } from 'util';
3+
import { mockStreamFileSystem } from './_mocks/fileSystem';
4+
5+
// This package's jest config replaces the react-native preset's setupFiles, so the real Platform throws.
6+
jest.mock('react-native', () => ({
7+
NativeModules: {},
8+
Platform: {
9+
OS: 'ios',
10+
select: (options: Record<string, unknown>) => (options.ios !== undefined ? options.ios : options.default),
11+
},
12+
}));
13+
14+
jest.mock('../src/common/platformHelper', () => ({
15+
version: () => '0.81.6',
16+
}));
17+
18+
const nativeMock = {
19+
initialize: jest.fn(),
20+
useAttributes: jest.fn(),
21+
useAttachments: jest.fn(),
22+
crash: jest.fn(),
23+
};
24+
25+
// CrashReporter caches BacktraceReactNative in a static field, so the mock has to land before the module loads.
26+
NativeModules.BacktraceReactNative = nativeMock;
27+
NativeModules.BacktraceDirectoryProvider = { applicationDirectory: () => '/' };
28+
(globalThis as unknown as { RN$Bridgeless: boolean }).RN$Bridgeless = true;
29+
30+
/* eslint-disable @typescript-eslint/no-var-requires */
31+
const { BacktraceClient } = require('../src/BacktraceClient');
32+
const { CrashReporter } = require('../src/crashReporter/CrashReporter');
33+
/* eslint-enable @typescript-eslint/no-var-requires */
34+
35+
const nextTick = promisify(process.nextTick);
36+
37+
function createClient() {
38+
return new BacktraceClient({
39+
options: {
40+
url: 'https://submit.backtrace.io/universe/token/json',
41+
database: { enable: true, captureNativeCrashes: true, path: '/backtrace' },
42+
metrics: { enable: false },
43+
breadcrumbs: { maximumBreadcrumbs: 4 },
44+
userAttributes: { application: 'nativeBreadcrumbPropagation', 'application.version': '1.0.0' },
45+
},
46+
fileSystem: mockStreamFileSystem(),
47+
});
48+
}
49+
50+
function breadcrumbPathsSentToNative(): string[] {
51+
return nativeMock.useAttachments.mock.calls
52+
.flatMap((call) => call[0])
53+
.filter((p: string) => p.includes('breadcrumb'));
54+
}
55+
56+
async function settle() {
57+
for (let i = 0; i < 10; i++) {
58+
await nextTick();
59+
}
60+
}
61+
62+
describe('BacktraceClient native breadcrumb propagation', () => {
63+
beforeEach(() => {
64+
jest.clearAllMocks();
65+
// Static, so initialize() would be a no-op after the first test.
66+
(CrashReporter as unknown as { initialized: boolean }).initialized = false;
67+
});
68+
69+
it('Should tell the native crash reporter about the breadcrumb files created after rotation', async () => {
70+
const client = createClient();
71+
client.initialize();
72+
nativeMock.useAttachments.mockClear();
73+
74+
for (let i = 0; i < 20; i++) {
75+
client.breadcrumbs?.info(`breadcrumb-${i}`);
76+
await nextTick();
77+
}
78+
await settle();
79+
80+
const sentLater = breadcrumbPathsSentToNative();
81+
expect(sentLater.some((p) => /bt-breadcrumbs-[1-9]/.test(p))).toBe(true);
82+
83+
const calls = nativeMock.useAttachments.mock.calls;
84+
const lastPaths = calls[calls.length - 1][0].filter((p: string) => p.includes('breadcrumb'));
85+
expect(lastPaths.length).toBeGreaterThan(0);
86+
expect(lastPaths.some((p: string) => p.includes('bt-breadcrumbs-0_'))).toBe(false);
87+
});
88+
89+
it('Should send a fresh last breadcrumb id to the native crash reporter on every rotation', async () => {
90+
const client = createClient();
91+
client.initialize();
92+
nativeMock.useAttributes.mockClear();
93+
94+
for (let i = 0; i < 20; i++) {
95+
client.breadcrumbs?.info(`breadcrumb-${i}`);
96+
await nextTick();
97+
}
98+
await settle();
99+
100+
const pushed = nativeMock.useAttributes.mock.calls
101+
.map((call) => call[0]['breadcrumbs.lastId'])
102+
.filter((value) => value !== undefined)
103+
.map(Number);
104+
expect(pushed.length).toBeGreaterThan(1);
105+
expect(pushed[pushed.length - 1]).toBeGreaterThan(pushed[0]);
106+
});
107+
});
Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
import { combinedChunkSplitter } from '../../src/storage/combinedChunkSplitter';
2+
import { lengthChunkSplitter } from '../../src/storage/lengthChunkSplitter';
3+
import { lineChunkSplitter } from '../../src/storage/lineChunkSplitter';
4+
5+
const join = (chunks: string[]) => chunks.join('');
6+
7+
describe('combinedChunkSplitter', () => {
8+
it('should split when any splitter splits', () => {
9+
const splitter = combinedChunkSplitter(join, lengthChunkSplitter(4), lineChunkSplitter(10));
10+
11+
const [c1, c2] = splitter('abcdefgh');
12+
13+
expect(c1).toEqual('abcd');
14+
expect(c2).toEqual('efgh');
15+
});
16+
17+
it('should not split when no splitter splits', () => {
18+
const splitter = combinedChunkSplitter(join, lineChunkSplitter(10), lengthChunkSplitter(1000));
19+
20+
const [c1, c2] = splitter('line-1\n');
21+
22+
expect(c1).toEqual('line-1\n');
23+
expect(c2).toBeUndefined();
24+
});
25+
26+
it('should keep the split when the second chunk is empty', () => {
27+
const splitter = combinedChunkSplitter(join, lineChunkSplitter(2), lengthChunkSplitter(1000, 'skip'));
28+
29+
splitter('line-1\n');
30+
const [c1, c2] = splitter('line-2\n');
31+
32+
expect(c1).toEqual('line-2\n');
33+
expect(c2).toBeDefined();
34+
expect(c2?.length).toEqual(0);
35+
});
36+
});

0 commit comments

Comments
 (0)