diff --git a/packages/react-native/src/BacktraceClient.ts b/packages/react-native/src/BacktraceClient.ts index b3711609..9befe665 100644 --- a/packages/react-native/src/BacktraceClient.ts +++ b/packages/react-native/src/BacktraceClient.ts @@ -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 { private _crashReporter?: CrashReporter; private _anrWatchdogHandler?: AnrWatchdogHandler; @@ -59,7 +62,11 @@ export class BacktraceClient extends BacktraceCoreClient 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( @@ -158,6 +165,14 @@ export class BacktraceClient extends BacktraceCoreClient 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; diff --git a/packages/react-native/src/breadcrumbs/FileBreadcrumbsStorage.ts b/packages/react-native/src/breadcrumbs/FileBreadcrumbsStorage.ts index 82ba0e5d..2b094357 100644 --- a/packages/react-native/src/breadcrumbs/FileBreadcrumbsStorage.ts +++ b/packages/react-native/src/breadcrumbs/FileBreadcrumbsStorage.ts @@ -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[] = []; @@ -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[] { diff --git a/packages/react-native/src/storage/FileChunkSink.ts b/packages/react-native/src/storage/FileChunkSink.ts index 32d11b36..5cc42a9e 100644 --- a/packages/react-native/src/storage/FileChunkSink.ts +++ b/packages/react-native/src/storage/FileChunkSink.ts @@ -17,6 +17,11 @@ interface FileChunkSinkOptions { * File system to use. */ readonly fs: FileSystem; + + /** + * Called after the tracked file set changes. + */ + readonly onFilesChange?: () => void; } /** @@ -57,6 +62,7 @@ export class FileChunkSink { return (n) => { const stream = this.createStream(n); this._streamTracker.push(stream); + this._options.onFilesChange?.(); return stream; }; } diff --git a/packages/react-native/src/storage/combinedChunkSplitter.ts b/packages/react-native/src/storage/combinedChunkSplitter.ts index b2891edc..de6a98de 100644 --- a/packages/react-native/src/storage/combinedChunkSplitter.ts +++ b/packages/react-native/src/storage/combinedChunkSplitter.ts @@ -21,7 +21,8 @@ export function combinedChunkSplitter( 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); } diff --git a/packages/react-native/tests/nativeBreadcrumbPropagationTests.spec.ts b/packages/react-native/tests/nativeBreadcrumbPropagationTests.spec.ts new file mode 100644 index 00000000..bbdb201d --- /dev/null +++ b/packages/react-native/tests/nativeBreadcrumbPropagationTests.spec.ts @@ -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) => (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]); + }); +}); diff --git a/packages/react-native/tests/storage/combinedChunkSplitter.spec.ts b/packages/react-native/tests/storage/combinedChunkSplitter.spec.ts new file mode 100644 index 00000000..9b890f7c --- /dev/null +++ b/packages/react-native/tests/storage/combinedChunkSplitter.spec.ts @@ -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); + }); +});