From b0cff9aa08c6511aefedaf7dcb6057a9e1dbb941 Mon Sep 17 00:00:00 2001 From: Kishan P Rao Date: Wed, 30 Sep 2026 15:47:32 +0200 Subject: [PATCH 1/3] react-native: upload breadcrumb file content on android --- .../library/BacktraceFileSystemProvider.java | 36 +++--- .../BacktraceFileSystemProviderTest.java | 36 ++++++ .../src/attachment/BacktraceFileAttachment.ts | 7 +- .../src/attachment/FileSnapshotAttachment.ts | 25 +++++ .../src/breadcrumbs/FileBreadcrumbsStorage.ts | 3 +- .../react-native/src/types/FileContent.ts | 5 + .../tests/fileSnapshotAttachmentTests.spec.ts | 105 ++++++++++++++++++ 7 files changed, 195 insertions(+), 22 deletions(-) create mode 100644 packages/react-native/android/src/test/java/backtrace/library/BacktraceFileSystemProviderTest.java create mode 100644 packages/react-native/src/attachment/FileSnapshotAttachment.ts create mode 100644 packages/react-native/src/types/FileContent.ts create mode 100644 packages/react-native/tests/fileSnapshotAttachmentTests.spec.ts diff --git a/packages/react-native/android/src/main/java/backtrace/library/BacktraceFileSystemProvider.java b/packages/react-native/android/src/main/java/backtrace/library/BacktraceFileSystemProvider.java index 3f9664793..16f67b221 100644 --- a/packages/react-native/android/src/main/java/backtrace/library/BacktraceFileSystemProvider.java +++ b/packages/react-native/android/src/main/java/backtrace/library/BacktraceFileSystemProvider.java @@ -11,14 +11,15 @@ import com.facebook.react.bridge.ReactMethod; import com.facebook.react.module.annotations.ReactModule; +import java.io.ByteArrayOutputStream; import java.io.File; import java.io.FileInputStream; import java.io.FileNotFoundException; import java.io.FileOutputStream; +import java.io.IOException; import java.nio.charset.StandardCharsets; import java.nio.file.Files; import java.nio.file.Path; -import java.util.Scanner; @ReactModule(name = BacktraceFileSystemProvider.NAME) public class BacktraceFileSystemProvider extends ReactContextBaseJavaModule { @@ -42,14 +43,8 @@ public String readFileSync(String path) { return null; } - try (Scanner scanner = new Scanner(file)) { - StringBuilder sb = new StringBuilder(); - - while (scanner.hasNext()) { - sb.append(scanner.nextLine()); - } - - return sb.toString(); + try { + return read(file); } catch (Exception e) { Log.d(LOG_TAG, e.getMessage()); return null; @@ -63,15 +58,8 @@ public void readFile(String path, Promise promise) { promise.reject(new FileNotFoundException(path)); return; } - try (Scanner scanner = new Scanner(file)) { - StringBuilder sb = new StringBuilder(); - - while (scanner.hasNext()) { - sb.append(scanner.nextLine()); - } - - scanner.close(); - promise.resolve(sb.toString()); + try { + promise.resolve(read(file)); } catch (Exception e) { Log.d(LOG_TAG, e.getMessage()); promise.reject(e); @@ -156,4 +144,16 @@ private boolean copy(String sourcePath, String destinationPath) { return false; } } + + private String read(File file) throws IOException { + try (FileInputStream inputStream = new FileInputStream(file); + ByteArrayOutputStream outputStream = new ByteArrayOutputStream()) { + byte[] buf = new byte[1024]; + int len; + while ((len = inputStream.read(buf)) > 0) { + outputStream.write(buf, 0, len); + } + return new String(outputStream.toByteArray(), StandardCharsets.UTF_8); + } + } } \ No newline at end of file diff --git a/packages/react-native/android/src/test/java/backtrace/library/BacktraceFileSystemProviderTest.java b/packages/react-native/android/src/test/java/backtrace/library/BacktraceFileSystemProviderTest.java new file mode 100644 index 000000000..d486f7fb7 --- /dev/null +++ b/packages/react-native/android/src/test/java/backtrace/library/BacktraceFileSystemProviderTest.java @@ -0,0 +1,36 @@ +package backtrace.library; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertNull; + +import java.io.File; +import java.io.FileOutputStream; +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import org.junit.Rule; +import org.junit.Test; +import org.junit.rules.TemporaryFolder; + +public class BacktraceFileSystemProviderTest { + private static final String BREADCRUMBS = "{\"id\":1,\"message\":\"café\"}\n{\"id\":2}\n"; + + @Rule + public TemporaryFolder folder = new TemporaryFolder(); + + @Test + public void readFileSyncReturnsTheExactContent() throws IOException { + File file = folder.newFile("bt-breadcrumbs-0"); + try (FileOutputStream out = new FileOutputStream(file)) { + out.write(BREADCRUMBS.getBytes(StandardCharsets.UTF_8)); + } + + assertEquals(BREADCRUMBS, new BacktraceFileSystemProvider(null).readFileSync(file.getPath())); + } + + @Test + public void readFileSyncReturnsNullForMissingFile() { + String path = new File(folder.getRoot(), "missing").getPath(); + + assertNull(new BacktraceFileSystemProvider(null).readFileSync(path)); + } +} diff --git a/packages/react-native/src/attachment/BacktraceFileAttachment.ts b/packages/react-native/src/attachment/BacktraceFileAttachment.ts index ff939731a..5c3504626 100644 --- a/packages/react-native/src/attachment/BacktraceFileAttachment.ts +++ b/packages/react-native/src/attachment/BacktraceFileAttachment.ts @@ -1,14 +1,15 @@ import { type BacktraceFileAttachment as CoreBacktraceFileAttachment } from '@backtrace/sdk-core'; import { Platform } from 'react-native'; import { type FileSystem } from '../storage/'; +import { type FileContent } from '../types/FileContent'; import { type FileLocation } from '../types/FileLocation'; -export class BacktraceFileAttachment implements CoreBacktraceFileAttachment { +export class BacktraceFileAttachment implements CoreBacktraceFileAttachment { public readonly name: string; public readonly mimeType: string; private readonly _uploadUri: string; constructor( - private readonly _fileSystemProvider: FileSystem, + protected readonly _fileSystemProvider: FileSystem, public readonly filePath: string, name?: string, mimeType?: string, @@ -18,7 +19,7 @@ export class BacktraceFileAttachment implements CoreBacktraceFileAttachment f.path); return files.map( - (f, i) => new BacktraceFileAttachment(this._fileSystem, f, `bt-breadcrumbs-${i}`, 'application/json'), + (f, i) => new FileSnapshotAttachment(this._fileSystem, f, `bt-breadcrumbs-${i}`, 'application/json'), ); } diff --git a/packages/react-native/src/types/FileContent.ts b/packages/react-native/src/types/FileContent.ts new file mode 100644 index 000000000..edf871de9 --- /dev/null +++ b/packages/react-native/src/types/FileContent.ts @@ -0,0 +1,5 @@ +export interface FileContent { + string: string; + name: string; + type: string; +} diff --git a/packages/react-native/tests/fileSnapshotAttachmentTests.spec.ts b/packages/react-native/tests/fileSnapshotAttachmentTests.spec.ts new file mode 100644 index 000000000..72e1b4913 --- /dev/null +++ b/packages/react-native/tests/fileSnapshotAttachmentTests.spec.ts @@ -0,0 +1,105 @@ +import { BreadcrumbLogLevel, BreadcrumbType, SessionFiles, type RawBreadcrumb } from '@backtrace/sdk-core'; +import assert from 'assert'; +import { Platform } from 'react-native'; +import ReactNativeFormData from 'react-native/Libraries/Network/FormData'; +import { promisify } from 'util'; +import { FileSnapshotAttachment } from '../src/attachment/FileSnapshotAttachment'; +import { FileBreadcrumbsStorage } from '../src/breadcrumbs/FileBreadcrumbsStorage'; +import { ReactNativeRequestHandler } from '../src/ReactNativeRequestHandler'; +import { mockStreamFileSystem } from './_mocks/fileSystem'; + +const nextTick = promisify(process.nextTick); +const filePath = '/breadcrumbs/bt-breadcrumbs-0'; +const submissionUrl = 'https://submit.backtrace.io/test/token/json'; + +function breadcrumb(message: string): RawBreadcrumb { + return { message, level: BreadcrumbLogLevel.Info, type: BreadcrumbType.Manual }; +} + +describe('FileSnapshotAttachment', () => { + const originalOS = Platform.OS; + const originalFetch = global.fetch; + const originalFormData = global.FormData; + + beforeEach(() => { + Platform.OS = 'android'; + }); + + afterEach(() => { + Platform.OS = originalOS; + global.fetch = originalFetch; + global.FormData = originalFormData; + }); + + it('returns the file content on Android', () => { + const fs = mockStreamFileSystem({ [filePath]: '{"id":1}\n{"id":2}\n' }); + const attachment = new FileSnapshotAttachment(fs, filePath, 'bt-breadcrumbs-0', 'application/json'); + + expect(attachment.get()).toEqual({ + string: '{"id":1}\n{"id":2}\n', + name: 'bt-breadcrumbs-0', + type: 'application/json', + }); + }); + + it('leaves out a line that is still being written', () => { + const fs = mockStreamFileSystem({ [filePath]: '{"id":1}\n{"id"' }); + const attachment = new FileSnapshotAttachment(fs, filePath, 'bt-breadcrumbs-0', 'application/json'); + + expect(attachment.get()).toEqual(expect.objectContaining({ string: '{"id":1}\n' })); + }); + + it('returns nothing when the file is missing', () => { + const attachment = new FileSnapshotAttachment( + mockStreamFileSystem(), + filePath, + 'bt-breadcrumbs-0', + 'application/json', + ); + + expect(attachment.get()).toBeUndefined(); + }); + + it('returns the file location on iOS', () => { + Platform.OS = 'ios'; + const fs = mockStreamFileSystem({ [filePath]: '{"id":1}\n' }); + const attachment = new FileSnapshotAttachment(fs, filePath, 'bt-breadcrumbs-0', 'application/json'); + + expect(attachment.get()).toEqual(expect.objectContaining({ uri: filePath })); + }); + + it('keeps breadcrumbs written during an upload out of that upload on Android', async () => { + global.FormData = ReactNativeFormData as unknown as typeof FormData; + const fs = mockStreamFileSystem(); + const session = new SessionFiles(fs, '.', { id: 'sessionId', timestamp: Date.now() }); + const storage = new FileBreadcrumbsStorage(session, fs, { maximumBreadcrumbs: 100 }); + storage.add(breadcrumb('before upload')); + await nextTick(); + + const [attachment] = storage.getAttachments(); + assert(attachment); + const contentBeforeUpload = fs.readFileSync(attachment.filePath); + + let body: ReactNativeFormData | undefined; + global.fetch = jest.fn(async (_: string, init: { body: ReactNativeFormData }) => { + body = init.body; + storage.add(breadcrumb('during upload')); + await nextTick(); + return { status: 200, json: () => Promise.resolve({}) }; + }) as unknown as typeof fetch; + + await new ReactNativeRequestHandler({ url: submissionUrl }).postError(submissionUrl, '{}', [attachment]); + + expect(fs.readFileSync(attachment.filePath)).toContain('during upload'); + expect(body?.getParts()).toContainEqual( + expect.objectContaining({ + fieldName: 'attachment_bt-breadcrumbs-0', + string: contentBeforeUpload, + headers: { + 'content-disposition': 'form-data; name="attachment_bt-breadcrumbs-0"; filename="bt-breadcrumbs-0"', + 'content-type': 'application/json', + }, + }), + ); + }); +}); From 29b2648474954b39713085e7ce5a116ad9e02e7c Mon Sep 17 00:00:00 2001 From: Kishan P Rao Date: Mon, 5 Oct 2026 16:59:50 +0200 Subject: [PATCH 2/3] react-native: log breadcrumbs during the send in android uploads --- .../src/attachment/FileSnapshotAttachment.ts | 47 ++++- .../src/breadcrumbs/FileBreadcrumbsStorage.ts | 53 ++++-- .../tests/fileSnapshotAttachmentTests.spec.ts | 166 ++++++++++++++++++ 3 files changed, 249 insertions(+), 17 deletions(-) diff --git a/packages/react-native/src/attachment/FileSnapshotAttachment.ts b/packages/react-native/src/attachment/FileSnapshotAttachment.ts index c256b4d18..c7bc41317 100644 --- a/packages/react-native/src/attachment/FileSnapshotAttachment.ts +++ b/packages/react-native/src/attachment/FileSnapshotAttachment.ts @@ -1,25 +1,64 @@ import { Platform } from 'react-native'; +import { type FileSystem } from '../storage/'; import { type FileContent } from '../types/FileContent'; import { type FileLocation } from '../types/FileLocation'; import { BacktraceFileAttachment } from './BacktraceFileAttachment'; +export interface FileSnapshotOptions { + readonly queuedLines?: () => readonly string[]; + readonly maximumLines?: number; + readonly maximumLength?: number; +} + // React Native on Android sizes a file part before sending it. A write in between aborts the upload. export class FileSnapshotAttachment extends BacktraceFileAttachment { + constructor( + fileSystemProvider: FileSystem, + filePath: string, + name?: string, + mimeType?: string, + private readonly _options: FileSnapshotOptions = {}, + ) { + super(fileSystemProvider, filePath, name, mimeType); + } + public get(): FileLocation | FileContent | undefined { if (Platform.OS !== 'android') { return super.get(); } - const content = this._fileSystemProvider.readFileSync(this.filePath); - const completeLines = content ? content.substring(0, content.lastIndexOf('\n') + 1) : ''; - if (!completeLines) { + const content = this._fileSystemProvider.readFileSync(this.filePath) || ''; + const completeLines = content.substring(0, content.lastIndexOf('\n') + 1); + const snapshot = this.newestLines(completeLines + this.linesNotInFile(completeLines)); + if (!snapshot) { return undefined; } return { - string: completeLines, + string: snapshot, name: this.name, type: this.mimeType, }; } + + private linesNotInFile(completeLines: string): string { + const queued = this._options.queuedLines?.() ?? []; + const lastLine = completeLines.substring(completeLines.lastIndexOf('\n', completeLines.length - 2) + 1); + // Queued lines up to the file's last line have already reached the disk. + return queued.slice(queued.indexOf(lastLine) + 1).join(''); + } + + private newestLines(snapshot: string): string { + const { maximumLines = Infinity, maximumLength = Infinity } = this._options; + const kept: string[] = []; + let length = 0; + for (const line of (snapshot.match(/[^\n]*\n/g) ?? []).reverse()) { + if (kept.length >= maximumLines || length + line.length > maximumLength) { + break; + } + kept.push(line); + length += line.length; + } + return kept.reverse().join(''); + } } diff --git a/packages/react-native/src/breadcrumbs/FileBreadcrumbsStorage.ts b/packages/react-native/src/breadcrumbs/FileBreadcrumbsStorage.ts index c45404859..de30c8f99 100644 --- a/packages/react-native/src/breadcrumbs/FileBreadcrumbsStorage.ts +++ b/packages/react-native/src/breadcrumbs/FileBreadcrumbsStorage.ts @@ -32,6 +32,9 @@ export class FileBreadcrumbsStorage implements BreadcrumbsStorage { private readonly _destinationStream: WritableStream; private readonly _destinationWriter: WritableStreamDefaultWriter; private readonly _sink: FileChunkSink; + private readonly _queuedLines = new Map(); + private readonly _firstFile: string; + private readonly _fileLimits: { maximumLines?: number; maximumLength?: number }; constructor( session: SessionFiles, @@ -39,22 +42,28 @@ export class FileBreadcrumbsStorage implements BreadcrumbsStorage { private readonly _limits: BreadcrumbsStorageLimits, onFilesChange?: (lastBreadcrumbId: number) => void, ) { + const file = (n: number) => session.getFileName(FileBreadcrumbsStorage.getFileName(n)); + this._firstFile = file(0); this._sink = new FileChunkSink({ maxFiles: 2, fs: this._fileSystem, - file: (n) => session.getFileName(FileBreadcrumbsStorage.getFileName(n)), + file, onFilesChange: onFilesChange && (() => onFilesChange(this._lastBreadcrumbId)), }); + const { maximumBreadcrumbs, maximumTotalBreadcrumbsSize } = this._limits; + const maximumLines = maximumBreadcrumbs !== undefined ? Math.ceil(maximumBreadcrumbs / 2) : undefined; + const maximumLength = + maximumTotalBreadcrumbsSize !== undefined ? Math.ceil(maximumTotalBreadcrumbsSize / 2) : undefined; + this._fileLimits = { maximumLines, maximumLength }; + const splitters: ChunkSplitterFactory[] = []; - const maximumBreadcrumbs = this._limits.maximumBreadcrumbs; - if (maximumBreadcrumbs !== undefined) { - splitters.push(() => lineChunkSplitter(Math.ceil(maximumBreadcrumbs / 2))); + if (maximumLines !== undefined) { + splitters.push(() => lineChunkSplitter(maximumLines)); } - const maximumTotalBreadcrumbsSize = this._limits.maximumTotalBreadcrumbsSize; - if (maximumTotalBreadcrumbsSize !== undefined) { - splitters.push(() => lengthChunkSplitter(Math.ceil(maximumTotalBreadcrumbsSize / 2), 'skip')); + if (maximumLength !== undefined) { + splitters.push(() => lengthChunkSplitter(maximumLength, 'skip')); } if (!splitters[0]) { @@ -84,9 +93,16 @@ export class FileBreadcrumbsStorage implements BreadcrumbsStorage { } public getAttachments(): BacktraceFileAttachment[] { - const files = [...this._sink.files].map((f) => f.path); + const files = + this._sink.files.length || !this._queuedLines.size + ? this._sink.files.map((f) => f.path) + : [this._firstFile]; return files.map( - (f, i) => new FileSnapshotAttachment(this._fileSystem, f, `bt-breadcrumbs-${i}`, 'application/json'), + (f, i) => + new FileSnapshotAttachment(this._fileSystem, f, `bt-breadcrumbs-${i}`, 'application/json', { + ...this._fileLimits, + queuedLines: () => (this.newestFile() === f ? [...this._queuedLines.values()] : []), + }), ); } @@ -121,14 +137,25 @@ export class FileBreadcrumbsStorage implements BreadcrumbsStorage { attributes: rawBreadcrumb.attributes, }; - const breadcrumbJson = JSON.stringify(breadcrumb, jsonEscaper()); - this._destinationWriter.write(breadcrumbJson + '\n').catch(() => { - // Fail silently here, there's not much we can do about this - }); + const line = JSON.stringify(breadcrumb, jsonEscaper()) + '\n'; + const { maximumLength } = this._fileLimits; + if (maximumLength === undefined || line.length <= maximumLength) { + this._queuedLines.set(id, line); + } + this._destinationWriter + .write(line) + .catch(() => { + // Fail silently here, there's not much we can do about this + }) + .finally(() => this._queuedLines.delete(id)); return id; } + private newestFile() { + return this._sink.files[this._sink.files.length - 1]?.path ?? this._firstFile; + } + private static getFileName(index: number) { return `${FILE_PREFIX}-${index}`; } diff --git a/packages/react-native/tests/fileSnapshotAttachmentTests.spec.ts b/packages/react-native/tests/fileSnapshotAttachmentTests.spec.ts index 72e1b4913..eab3d58be 100644 --- a/packages/react-native/tests/fileSnapshotAttachmentTests.spec.ts +++ b/packages/react-native/tests/fileSnapshotAttachmentTests.spec.ts @@ -1,14 +1,17 @@ import { BreadcrumbLogLevel, BreadcrumbType, SessionFiles, type RawBreadcrumb } from '@backtrace/sdk-core'; import assert from 'assert'; +import path from 'path'; import { Platform } from 'react-native'; import ReactNativeFormData from 'react-native/Libraries/Network/FormData'; import { promisify } from 'util'; +import { WritableStream } from 'web-streams-polyfill'; import { FileSnapshotAttachment } from '../src/attachment/FileSnapshotAttachment'; import { FileBreadcrumbsStorage } from '../src/breadcrumbs/FileBreadcrumbsStorage'; import { ReactNativeRequestHandler } from '../src/ReactNativeRequestHandler'; import { mockStreamFileSystem } from './_mocks/fileSystem'; const nextTick = promisify(process.nextTick); +const nextMacrotask = () => new Promise((resolve) => setImmediate(resolve)); const filePath = '/breadcrumbs/bt-breadcrumbs-0'; const submissionUrl = 'https://submit.backtrace.io/test/token/json'; @@ -16,6 +19,21 @@ function breadcrumb(message: string): RawBreadcrumb { return { message, level: BreadcrumbLogLevel.Info, type: BreadcrumbType.Manual }; } +function mockFileSystemWithNativeAppends() { + const fs = mockStreamFileSystem(); + fs.createWriteStream.mockImplementation((p: string) => { + const writable = new WritableStream({ + async write(str) { + await nextMacrotask(); + fs.files[path.resolve(p)] = (fs.files[path.resolve(p)] ?? '') + str; + }, + }); + (writable as { path?: string }).path = p; + return writable; + }); + return fs; +} + describe('FileSnapshotAttachment', () => { const originalOS = Platform.OS; const originalFetch = global.fetch; @@ -68,6 +86,66 @@ describe('FileSnapshotAttachment', () => { expect(attachment.get()).toEqual(expect.objectContaining({ uri: filePath })); }); + it('adds queued lines that have not reached the file', () => { + const fs = mockStreamFileSystem({ [filePath]: '{"id":1}\n' }); + const attachment = new FileSnapshotAttachment(fs, filePath, 'bt-breadcrumbs-0', 'application/json', { + queuedLines: () => ['{"id":2}\n'], + }); + + expect(attachment.get()).toEqual(expect.objectContaining({ string: '{"id":1}\n{"id":2}\n' })); + }); + + it('does not repeat queued lines that already reached the file', () => { + const fs = mockStreamFileSystem({ [filePath]: '{"id":1}\n{"id":2}\n' }); + const attachment = new FileSnapshotAttachment(fs, filePath, 'bt-breadcrumbs-0', 'application/json', { + queuedLines: () => ['{"id":2}\n', '{"id":3}\n'], + }); + + expect(attachment.get()).toEqual(expect.objectContaining({ string: '{"id":1}\n{"id":2}\n{"id":3}\n' })); + }); + + it('returns the queued lines when the file has none yet', () => { + const fs = mockStreamFileSystem({ [filePath]: '' }); + const attachment = new FileSnapshotAttachment(fs, filePath, 'bt-breadcrumbs-0', 'application/json', { + queuedLines: () => ['{"id":1}\n'], + }); + + expect(attachment.get()).toEqual(expect.objectContaining({ string: '{"id":1}\n' })); + }); + + it('uploads breadcrumbs logged right before the send on Android', async () => { + global.FormData = ReactNativeFormData as unknown as typeof FormData; + const fs = mockFileSystemWithNativeAppends(); + const session = new SessionFiles(fs, '.', { id: 'sessionId', timestamp: Date.now() }); + const storage = new FileBreadcrumbsStorage(session, fs, { maximumBreadcrumbs: 100 }); + storage.add(breadcrumb('written earlier')); + await nextMacrotask(); + await nextMacrotask(); + + storage.add(breadcrumb('logged right before the send')); + const [attachment] = storage.getAttachments(); + assert(attachment); + expect(fs.readFileSync(attachment.filePath)).toContain('written earlier'); + expect(fs.readFileSync(attachment.filePath)).not.toContain('logged right before the send'); + + let body: ReactNativeFormData | undefined; + global.fetch = jest.fn(async (_: string, init: { body: ReactNativeFormData }) => { + body = init.body; + return { status: 200, json: () => Promise.resolve({}) }; + }) as unknown as typeof fetch; + + await new ReactNativeRequestHandler({ url: submissionUrl }).postError(submissionUrl, '{}', [attachment]); + await nextMacrotask(); + await nextMacrotask(); + + const part = body?.getParts().find((p) => p.fieldName === 'attachment_bt-breadcrumbs-0'); + expect(part?.string).toEqual(fs.readFileSync(attachment.filePath)); + expect(part?.string?.match(/"message":"[^"]+"/g)).toEqual([ + '"message":"written earlier"', + '"message":"logged right before the send"', + ]); + }); + it('keeps breadcrumbs written during an upload out of that upload on Android', async () => { global.FormData = ReactNativeFormData as unknown as typeof FormData; const fs = mockStreamFileSystem(); @@ -102,4 +180,92 @@ describe('FileSnapshotAttachment', () => { }), ); }); + + describe('with FileBreadcrumbsStorage', () => { + async function settleWrites() { + for (let i = 0; i < 20; i++) { + await nextMacrotask(); + } + } + + function createStorage(limits: { maximumBreadcrumbs?: number; maximumTotalBreadcrumbsSize?: number }) { + const fs = mockFileSystemWithNativeAppends(); + const session = new SessionFiles(fs, '.', { id: 'sessionId', timestamp: Date.now() }); + return { fs, storage: new FileBreadcrumbsStorage(session, fs, limits) }; + } + + function uploadedMessages(attachments: FileSnapshotAttachment[]) { + return attachments + .map((a) => a.get()) + .flatMap((part) => (part && 'string' in part ? (part.string.match(/[^\n]+/g) ?? []) : [])) + .map((line) => JSON.parse(line).message); + } + + it('adds a queued line to the newest file only', async () => { + const { storage } = createStorage({ maximumBreadcrumbs: 4 }); + ['1', '2', '3'].forEach((m) => storage.add(breadcrumb(m))); + await settleWrites(); + + storage.add(breadcrumb('4')); + + expect(uploadedMessages(storage.getAttachments())).toEqual(['1', '2', '3', '4']); + }); + + it('does not repeat written lines in a newest file that is still empty', async () => { + const { fs, storage } = createStorage({ maximumBreadcrumbs: 4 }); + ['1', '2'].forEach((m) => storage.add(breadcrumb(m))); + await settleWrites(); + + storage.add(breadcrumb('3')); + await nextTick(); + const attachments = storage.getAttachments(); + + expect(attachments).toHaveLength(2); + expect(fs.readFileSync(attachments[1]?.filePath ?? '') ?? '').not.toContain('"3"'); + expect(uploadedMessages(attachments)).toEqual(['1', '2', '3']); + }); + + it('keeps queued lines out of a stored attachment whose file is no longer the newest', async () => { + const { storage } = createStorage({ maximumBreadcrumbs: 4 }); + storage.add(breadcrumb('1')); + await settleWrites(); + const stored = storage.getAttachments(); + + ['2', '3', '4', '5'].forEach((m) => storage.add(breadcrumb(m))); + await settleWrites(); + storage.add(breadcrumb('6')); + + expect(stored.map((a) => a.get())).toEqual([undefined]); + }); + + it('keeps a burst of queued lines within the file limits', async () => { + const { storage } = createStorage({ maximumBreadcrumbs: 4 }); + storage.add(breadcrumb('1')); + await settleWrites(); + + ['2', '3', '4', '5', '6'].forEach((m) => storage.add(breadcrumb(m))); + + expect(uploadedMessages(storage.getAttachments())).toEqual(['5', '6']); + }); + + it('does not upload a queued line that is too long to be written', async () => { + const { fs, storage } = createStorage({ maximumTotalBreadcrumbsSize: 400 }); + storage.add(breadcrumb('small')); + await settleWrites(); + + storage.add(breadcrumb('x'.repeat(700))); + const attachments = storage.getAttachments(); + + expect(uploadedMessages(attachments)).toEqual(['small']); + await settleWrites(); + expect(Object.values(fs.files).join('')).not.toContain('x'.repeat(700)); + }); + + it('uploads queued lines before the first file exists', () => { + const { storage } = createStorage({ maximumBreadcrumbs: 4 }); + storage.add(breadcrumb('1')); + + expect(uploadedMessages(storage.getAttachments())).toEqual(['1']); + }); + }); }); From 071297d624bff512acd22ff17fab58c92eba0265 Mon Sep 17 00:00:00 2001 From: Kishan P Rao Date: Mon, 5 Oct 2026 18:29:54 +0200 Subject: [PATCH 3/3] react-native: fix ci test --- .../react-native/tests/fileSnapshotAttachmentTests.spec.ts | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/packages/react-native/tests/fileSnapshotAttachmentTests.spec.ts b/packages/react-native/tests/fileSnapshotAttachmentTests.spec.ts index eab3d58be..3d5de9bd8 100644 --- a/packages/react-native/tests/fileSnapshotAttachmentTests.spec.ts +++ b/packages/react-native/tests/fileSnapshotAttachmentTests.spec.ts @@ -1,4 +1,5 @@ import { BreadcrumbLogLevel, BreadcrumbType, SessionFiles, type RawBreadcrumb } from '@backtrace/sdk-core'; +import { AbortController as ReactNativeAbortController } from 'abort-controller'; import assert from 'assert'; import path from 'path'; import { Platform } from 'react-native'; @@ -39,6 +40,11 @@ describe('FileSnapshotAttachment', () => { const originalFetch = global.fetch; const originalFormData = global.FormData; + beforeAll(() => { + // React Native installs this global from abort-controller. Node 14 in CI has none. + global.AbortController ??= ReactNativeAbortController as unknown as typeof AbortController; + }); + beforeEach(() => { Platform.OS = 'android'; });