Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 16 additions & 1 deletion packages/react-native/src/BacktraceClient.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,9 @@ import { ReactNativeRequestHandler } from './ReactNativeRequestHandler';
import { ReactStackTraceConverter } from './ReactStackTraceConverter';
import { type FileSystem } from './storage/FileSystem';

// Must match the private attribute name BreadcrumbsManager sets on JS reports.
const BREADCRUMB_ATTRIBUTE_NAME = 'breadcrumbs.lastId';

export class BacktraceClient extends BacktraceCoreClient<BacktraceConfiguration> {
private _crashReporter?: CrashReporter;
private _anrWatchdogHandler?: AnrWatchdogHandler;
Expand Down Expand Up @@ -59,7 +62,11 @@ export class BacktraceClient extends BacktraceCoreClient<BacktraceConfiguration>

const breadcrumbsManager = this.modules.get(BreadcrumbsManager);
if (breadcrumbsManager && this.sessionFiles) {
breadcrumbsManager.setStorage(FileBreadcrumbsStorage.factory(this.sessionFiles, fileSystem));
breadcrumbsManager.setStorage(
FileBreadcrumbsStorage.factory(this.sessionFiles, fileSystem, (lastBreadcrumbId) =>
this.refreshNativeBreadcrumbs(lastBreadcrumbId),
),
);
}

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

private refreshNativeBreadcrumbs(lastBreadcrumbId: number) {
if (!this._crashReporter) {
return;
}
this._crashReporter.updateAttachments(this.attachments);
this._crashReporter.updateAttributes({ [BREADCRUMB_ATTRIBUTE_NAME]: lastBreadcrumbId });
}

private initializeNativeCrashReporter(): CrashReporter | undefined {
if (!this.options.database?.enable) {
return;
Expand Down
10 changes: 8 additions & 2 deletions packages/react-native/src/breadcrumbs/FileBreadcrumbsStorage.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,11 +36,13 @@ export class FileBreadcrumbsStorage implements BreadcrumbsStorage {
session: SessionFiles,
private readonly _fileSystem: FileSystem,
private readonly _limits: BreadcrumbsStorageLimits,
onFilesChange?: (lastBreadcrumbId: number) => void,
) {
this._sink = new FileChunkSink({
maxFiles: 2,
fs: this._fileSystem,
file: (n) => session.getFileName(FileBreadcrumbsStorage.getFileName(n)),
onFilesChange: onFilesChange && (() => onFilesChange(this._lastBreadcrumbId)),
});

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

public static factory(session: SessionFiles, fileSystem: FileSystem): BreadcrumbsStorageFactory {
return ({ limits }) => new FileBreadcrumbsStorage(session, fileSystem, limits);
public static factory(
session: SessionFiles,
fileSystem: FileSystem,
onFilesChange?: (lastBreadcrumbId: number) => void,
): BreadcrumbsStorageFactory {
return ({ limits }) => new FileBreadcrumbsStorage(session, fileSystem, limits, onFilesChange);
}

public getAttachments(): BacktraceFileAttachment[] {
Expand Down
6 changes: 6 additions & 0 deletions packages/react-native/src/storage/FileChunkSink.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,11 @@ interface FileChunkSinkOptions {
* File system to use.
*/
readonly fs: FileSystem;

/**
* Called after the tracked file set changes.
*/
readonly onFilesChange?: () => void;
}

/**
Expand Down Expand Up @@ -57,6 +62,7 @@ export class FileChunkSink {
return (n) => {
const stream = this.createStream(n);
this._streamTracker.push(stream);
this._options.onFilesChange?.();
return stream;
};
}
Expand Down
3 changes: 2 additions & 1 deletion packages/react-native/src/storage/combinedChunkSplitter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,8 @@ export function combinedChunkSplitter<W extends Chunk>(
for (const splitter of splitters) {
const [c1, c2] = splitter(chunk);
chunk = c1;
if (c2) {
// An empty second chunk still means "split here"; dropping it loses the split.
if (c2 !== undefined) {
// Prepend second chunk to the rest
rest.unshift(c2);
}
Expand Down
107 changes: 107 additions & 0 deletions packages/react-native/tests/nativeBreadcrumbPropagationTests.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,107 @@
import { NativeModules } from 'react-native';
import { promisify } from 'util';
import { mockStreamFileSystem } from './_mocks/fileSystem';

// This package's jest config replaces the react-native preset's setupFiles, so the real Platform throws.
jest.mock('react-native', () => ({
NativeModules: {},
Platform: {
OS: 'ios',
select: (options: Record<string, unknown>) => (options.ios !== undefined ? options.ios : options.default),
},
}));

jest.mock('../src/common/platformHelper', () => ({
version: () => '0.81.6',
}));

const nativeMock = {
initialize: jest.fn(),
useAttributes: jest.fn(),
useAttachments: jest.fn(),
crash: jest.fn(),
};

// CrashReporter caches BacktraceReactNative in a static field, so the mock has to land before the module loads.
NativeModules.BacktraceReactNative = nativeMock;
NativeModules.BacktraceDirectoryProvider = { applicationDirectory: () => '/' };
(globalThis as unknown as { RN$Bridgeless: boolean }).RN$Bridgeless = true;

/* eslint-disable @typescript-eslint/no-var-requires */
const { BacktraceClient } = require('../src/BacktraceClient');
const { CrashReporter } = require('../src/crashReporter/CrashReporter');
/* eslint-enable @typescript-eslint/no-var-requires */

const nextTick = promisify(process.nextTick);

function createClient() {
return new BacktraceClient({
options: {
url: 'https://submit.backtrace.io/universe/token/json',
database: { enable: true, captureNativeCrashes: true, path: '/backtrace' },
metrics: { enable: false },
breadcrumbs: { maximumBreadcrumbs: 4 },
userAttributes: { application: 'nativeBreadcrumbPropagation', 'application.version': '1.0.0' },
},
fileSystem: mockStreamFileSystem(),
});
}

function breadcrumbPathsSentToNative(): string[] {
return nativeMock.useAttachments.mock.calls
.flatMap((call) => call[0])
.filter((p: string) => p.includes('breadcrumb'));
}

async function settle() {
for (let i = 0; i < 10; i++) {
await nextTick();
}
}

describe('BacktraceClient native breadcrumb propagation', () => {
beforeEach(() => {
jest.clearAllMocks();
// Static, so initialize() would be a no-op after the first test.
(CrashReporter as unknown as { initialized: boolean }).initialized = false;
});

it('Should tell the native crash reporter about the breadcrumb files created after rotation', async () => {
const client = createClient();
client.initialize();
nativeMock.useAttachments.mockClear();

for (let i = 0; i < 20; i++) {
client.breadcrumbs?.info(`breadcrumb-${i}`);
await nextTick();
}
await settle();

const sentLater = breadcrumbPathsSentToNative();
expect(sentLater.some((p) => /bt-breadcrumbs-[1-9]/.test(p))).toBe(true);

const calls = nativeMock.useAttachments.mock.calls;
const lastPaths = calls[calls.length - 1][0].filter((p: string) => p.includes('breadcrumb'));
expect(lastPaths.length).toBeGreaterThan(0);
expect(lastPaths.some((p: string) => p.includes('bt-breadcrumbs-0_'))).toBe(false);
});

it('Should send a fresh last breadcrumb id to the native crash reporter on every rotation', async () => {
const client = createClient();
client.initialize();
nativeMock.useAttributes.mockClear();

for (let i = 0; i < 20; i++) {
client.breadcrumbs?.info(`breadcrumb-${i}`);
await nextTick();
}
await settle();

const pushed = nativeMock.useAttributes.mock.calls
.map((call) => call[0]['breadcrumbs.lastId'])
.filter((value) => value !== undefined)
.map(Number);
expect(pushed.length).toBeGreaterThan(1);
expect(pushed[pushed.length - 1]).toBeGreaterThan(pushed[0]);
});
});
36 changes: 36 additions & 0 deletions packages/react-native/tests/storage/combinedChunkSplitter.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
import { combinedChunkSplitter } from '../../src/storage/combinedChunkSplitter';
import { lengthChunkSplitter } from '../../src/storage/lengthChunkSplitter';
import { lineChunkSplitter } from '../../src/storage/lineChunkSplitter';

const join = (chunks: string[]) => chunks.join('');

describe('combinedChunkSplitter', () => {
it('should split when any splitter splits', () => {
const splitter = combinedChunkSplitter(join, lengthChunkSplitter(4), lineChunkSplitter(10));

const [c1, c2] = splitter('abcdefgh');

expect(c1).toEqual('abcd');
expect(c2).toEqual('efgh');
});

it('should not split when no splitter splits', () => {
const splitter = combinedChunkSplitter(join, lineChunkSplitter(10), lengthChunkSplitter(1000));

const [c1, c2] = splitter('line-1\n');

expect(c1).toEqual('line-1\n');
expect(c2).toBeUndefined();
});

it('should keep the split when the second chunk is empty', () => {
const splitter = combinedChunkSplitter(join, lineChunkSplitter(2), lengthChunkSplitter(1000, 'skip'));

splitter('line-1\n');
const [c1, c2] = splitter('line-2\n');

expect(c1).toEqual('line-2\n');
expect(c2).toBeDefined();
expect(c2?.length).toEqual(0);
});
});
Loading