diff --git a/examples/sdk/reactNative/src/actions/actions.ts b/examples/sdk/reactNative/src/actions/actions.ts index a495e519..6b492327 100644 --- a/examples/sdk/reactNative/src/actions/actions.ts +++ b/examples/sdk/reactNative/src/actions/actions.ts @@ -1,4 +1,4 @@ -import { BacktraceClient } from '@backtrace/react-native'; +import { BacktraceClient, BreadcrumbLogLevel, BreadcrumbType } from '@backtrace/react-native'; import { Alert, Platform } from 'react-native'; import { actions as androidActions } from './android/action'; @@ -125,6 +125,17 @@ export function generateActions(client: BacktraceClient) { client.addAttribute({ time: value }); }, }, + { + name: 'Add a breadcrumb', + platform, + action: async () => { + const timestamp = Date.now(); + notify(`Adding manual breadcrumb`); + client.breadcrumbs?.addBreadcrumb('Manual breadcrumb', BreadcrumbLogLevel.Info, BreadcrumbType.User, { + timestamp, + }); + }, + }, ...Platform.select({ android: androidActions, default: [], diff --git a/packages/react-native/src/BacktraceClient.ts b/packages/react-native/src/BacktraceClient.ts index fe076eff..10916c9c 100644 --- a/packages/react-native/src/BacktraceClient.ts +++ b/packages/react-native/src/BacktraceClient.ts @@ -10,6 +10,8 @@ import { } from '@backtrace/sdk-core'; import { NativeModules, Platform } from 'react-native'; import { type BacktraceConfiguration } from './BacktraceConfiguration'; +import { ReactNativeRequestHandler } from './ReactNativeRequestHandler'; +import { ReactStackTraceConverter } from './ReactStackTraceConverter'; import { FileBreadcrumbsStorage } from './breadcrumbs/FileBreadcrumbsStorage'; import { BacktraceClientBuilder } from './builder/BacktraceClientBuilder'; import type { BacktraceClientSetup } from './builder/BacktraceClientSetup'; @@ -17,8 +19,6 @@ import { version } from './common/platformHelper'; import { CrashReporter } from './crashReporter/CrashReporter'; import { generateUnhandledExceptionHandler } from './handlers'; import { type ExceptionHandler } from './handlers/ExceptionHandler'; -import { ReactNativeRequestHandler } from './ReactNativeRequestHandler'; -import { ReactStackTraceConverter } from './ReactStackTraceConverter'; import { type FileSystem } from './storage/FileSystem'; export class BacktraceClient extends BacktraceCoreClient { @@ -55,13 +55,7 @@ export class BacktraceClient extends BacktraceCoreClient const breadcrumbsManager = this.modules.get(BreadcrumbsManager); if (breadcrumbsManager && this.sessionFiles) { - breadcrumbsManager.setStorage( - FileBreadcrumbsStorage.create( - fileSystem, - this.sessionFiles, - (clientSetup.options.breadcrumbs?.maximumBreadcrumbs ?? 100) || 100, - ), - ); + breadcrumbsManager.setStorage(FileBreadcrumbsStorage.factory(fileSystem, this.sessionFiles)); } this.attributeManager.attributeEvents.on( diff --git a/packages/react-native/src/attachment/BacktraceFileAttachment.ts b/packages/react-native/src/attachment/BacktraceFileAttachment.ts index 87fcf3c0..ff939731 100644 --- a/packages/react-native/src/attachment/BacktraceFileAttachment.ts +++ b/packages/react-native/src/attachment/BacktraceFileAttachment.ts @@ -2,7 +2,7 @@ import { type BacktraceFileAttachment as CoreBacktraceFileAttachment } from '@ba import { Platform } from 'react-native'; import { type FileSystem } from '../storage/'; import { type FileLocation } from '../types/FileLocation'; -export class BacktraceFileAttachment implements CoreBacktraceFileAttachment { +export class BacktraceFileAttachment implements CoreBacktraceFileAttachment { public readonly name: string; public readonly mimeType: string; @@ -18,7 +18,7 @@ export class BacktraceFileAttachment implements CoreBacktraceFileAttachment { this._uploadUri = Platform.OS === 'android' ? `file://${this.filePath}` : this.filePath; } - public get(): FileLocation | string | undefined { + public get(): FileLocation | undefined { const exists = this._fileSystemProvider.existsSync(this.filePath); if (!exists) { diff --git a/packages/react-native/src/breadcrumbs/AlternatingFileWriter.ts b/packages/react-native/src/breadcrumbs/AlternatingFileWriter.ts index 4dadccd9..0cfbac81 100644 --- a/packages/react-native/src/breadcrumbs/AlternatingFileWriter.ts +++ b/packages/react-native/src/breadcrumbs/AlternatingFileWriter.ts @@ -4,6 +4,7 @@ import { type StreamWriter } from '../storage/StreamWriter'; export class AlternatingFileWriter { private _streamId?: string; private _count = 0; + private _size = 0; private _disposed = false; private readonly _streamWriter: StreamWriter; @@ -13,14 +14,12 @@ export class AlternatingFileWriter { private _currentAppendedLog?: string; constructor( + private readonly _fileSystem: FileSystem, private readonly _mainFile: string, private readonly _fallbackFile: string, - private readonly _fileCapacity: number, - private readonly _fileSystem: FileSystem, + private readonly _maxLines?: number, + private readonly _maxSize?: number, ) { - if (this._fileCapacity <= 0) { - throw new Error('File capacity may not be less or equal to 0.'); - } this._streamWriter = this._fileSystem.streamWriter; } @@ -42,7 +41,8 @@ export class AlternatingFileWriter { return; } - this.prepareBreadcrumbStream(); + const appendLength = this._currentAppendedLog.length + 1; + this.prepareBreadcrumbStream(appendLength); if (!this._streamId) { this._logQueue.unshift(this._currentAppendedLog); @@ -53,10 +53,39 @@ export class AlternatingFileWriter { // if the queue is full and we can save more item in a batch // try to save as much as possible to speed up potential native operations this._count += 1; + this._size += appendLength; + const logsToAppend = [this._currentAppendedLog]; - const restAppendingLogs = this._logQueue.splice(0, this._fileCapacity - this._count); + let logsToTake = 0; + let currentCount = this._count; + let currentSize = this._size; + + for (let i = 0; i < this._logQueue.length; i++) { + const log = this._logQueue[i]; + if (!log) { + continue; + } + + const logLength = log.length + 1; + + if (currentCount + 1 > (this._maxLines ?? Infinity)) { + break; + } + + if (currentSize + logLength >= (this._maxSize ?? Infinity)) { + break; + } + + logsToTake++; + currentCount++; + currentSize += logLength; + } + + const restAppendingLogs = this._logQueue.splice(0, logsToTake); this._count = this._count + restAppendingLogs.length; + this._size += restAppendingLogs.reduce((sum, l) => sum + l.length + 1, 0); + logsToAppend.push(...restAppendingLogs); this._streamWriter @@ -76,24 +105,32 @@ export class AlternatingFileWriter { }); } - private prepareBreadcrumbStream() { + private prepareBreadcrumbStream(newSize: number) { if (!this._streamId) { this._streamId = this._streamWriter.create(this._mainFile); - } else if (this._count >= this._fileCapacity) { + } else if (this._count >= (this._maxLines ?? Infinity) || this._size + newSize >= (this._maxSize ?? Infinity)) { + this.switchFile(); + } + } + + private switchFile() { + if (this._streamId) { const closeResult = this._streamWriter.close(this._streamId); if (!closeResult) { return; } - this._streamId = undefined; + } - const renameResult = this._fileSystem.copySync(this._mainFile, this._fallbackFile); - if (!renameResult) { - return; - } - this._streamId = this._streamWriter.create(this._mainFile); + this._streamId = undefined; - this._count = 0; + const renameResult = this._fileSystem.copySync(this._mainFile, this._fallbackFile); + if (!renameResult) { + return; } + this._streamId = this._streamWriter.create(this._mainFile); + + this._count = 0; + this._size = 0; } public dispose() { diff --git a/packages/react-native/src/breadcrumbs/FileBreadcrumbsStorage.ts b/packages/react-native/src/breadcrumbs/FileBreadcrumbsStorage.ts index 6ca27320..7ea5b55d 100644 --- a/packages/react-native/src/breadcrumbs/FileBreadcrumbsStorage.ts +++ b/packages/react-native/src/breadcrumbs/FileBreadcrumbsStorage.ts @@ -1,16 +1,20 @@ import { BreadcrumbLogLevel, BreadcrumbType, - jsonEscaper, SessionFiles, TimeHelper, + jsonEscaper, type BacktraceAttachment, + type BacktraceAttachmentProvider, type Breadcrumb, type BreadcrumbsStorage, + type BreadcrumbsStorageFactory, + type BreadcrumbsStorageLimits, type RawBreadcrumb, } from '@backtrace/sdk-core'; import { BacktraceFileAttachment } from '..'; import { type FileSystem } from '../storage'; +import type { FileLocation } from '../types/FileLocation'; import { AlternatingFileWriter } from './AlternatingFileWriter'; const FILE_PREFIX = 'bt-breadcrumbs'; @@ -27,29 +31,45 @@ export class FileBreadcrumbsStorage implements BreadcrumbsStorage { private readonly _fileSystem: FileSystem, private readonly _mainFile: string, private readonly _fallbackFile: string, - maximumBreadcrumbs: number, + private readonly _limits: BreadcrumbsStorageLimits, ) { this._writer = new AlternatingFileWriter( + _fileSystem, _mainFile, _fallbackFile, - Math.floor(maximumBreadcrumbs / 2), - _fileSystem, + this._limits.maximumBreadcrumbs ? Math.floor(this._limits.maximumBreadcrumbs / 2) : undefined, + this._limits.maximumTotalBreadcrumbsSize, ); } - public static create(fileSystem: FileSystem, session: SessionFiles, maximumBreadcrumbs: number) { - const file1 = session.getFileName(this.getFileName(0)); - const file2 = session.getFileName(this.getFileName(1)); - return new FileBreadcrumbsStorage(fileSystem, file1, file2, maximumBreadcrumbs); + public static factory(fileSystem: FileSystem, session: SessionFiles): BreadcrumbsStorageFactory { + return ({ limits }) => { + const file1 = session.getFileName(this.getFileName(0)); + const file2 = session.getFileName(this.getFileName(1)); + return new FileBreadcrumbsStorage(fileSystem, file1, file2, limits); + }; } - public getAttachments(): BacktraceAttachment[] { + public getAttachments(): [BacktraceAttachment, BacktraceAttachment] { return [ new BacktraceFileAttachment(this._fileSystem, this._mainFile, 'bt-breadcrumbs-0'), new BacktraceFileAttachment(this._fileSystem, this._fallbackFile, 'bt-breadcrumbs-1'), ]; } + public getAttachmentProviders(): BacktraceAttachmentProvider[] { + return [ + { + get: () => new BacktraceFileAttachment(this._fileSystem, this._mainFile, 'bt-breadcrumbs-0'), + type: 'dynamic', + }, + { + get: () => new BacktraceFileAttachment(this._fileSystem, this._fallbackFile, 'bt-breadcrumbs-1'), + type: 'dynamic', + }, + ]; + } + public add(rawBreadcrumb: RawBreadcrumb): number { const breadcrumbType = BreadcrumbType[rawBreadcrumb.type]; if (!breadcrumbType) { @@ -73,6 +93,14 @@ export class FileBreadcrumbsStorage implements BreadcrumbsStorage { }; const breadcrumbJson = JSON.stringify(breadcrumb, jsonEscaper()); + const sizeLimit = this._limits.maximumTotalBreadcrumbsSize; + if (sizeLimit !== undefined) { + const jsonLength = breadcrumbJson.length + 1; // newline + if (jsonLength > sizeLimit) { + return id; + } + } + this._writer.writeLine(breadcrumbJson); return id; diff --git a/packages/react-native/tests/_mocks/fileSystem.ts b/packages/react-native/tests/_mocks/fileSystem.ts new file mode 100644 index 00000000..1a1aa61f --- /dev/null +++ b/packages/react-native/tests/_mocks/fileSystem.ts @@ -0,0 +1,66 @@ +import { MockedFileSystem, mockFileSystem } from '@backtrace/sdk-core/tests/_mocks/fileSystem'; +import { randomUUID } from 'crypto'; +import path from 'path'; +import { StreamWriter } from '../../src'; +import { FileSystem } from '../../src/storage/FileSystem'; + +function mockStreamWriter(files: Record): StreamWriter { + const streams = new Map(); + return { + create(source) { + const id = randomUUID(); + const fullPath = path.resolve(source); + streams.set(id, fullPath); + files[fullPath] = ''; + return id; + }, + append(key, content) { + const source = streams.get(key); + if (!source) { + return Promise.resolve(false); + } + files[source] += content; + return Promise.resolve(true); + }, + close(key) { + const source = streams.get(key); + if (!source) { + return false; + } + + streams.delete(key); + return true; + }, + }; +} + +export function mockReactFileSystem(files?: Record): MockedFileSystem { + const fs = mockFileSystem(files); + + return { + ...fs, + + copy: jest.fn().mockImplementation((sourceFile: string, destinationFile: string) => { + const src = fs.files[path.resolve(sourceFile)]; + if (!src) { + return Promise.resolve(false); + } + + fs.files[path.resolve(destinationFile)] = src; + return Promise.resolve(true); + }), + + copySync: jest.fn().mockImplementation((sourceFile: string, destinationFile: string) => { + const src = fs.files[path.resolve(sourceFile)]; + if (!src) { + return false; + } + + fs.files[path.resolve(destinationFile)] = src; + return true; + }), + + applicationDirectory: jest.fn().mockReturnValue(path.resolve('.')), + streamWriter: mockStreamWriter(fs.files), + }; +} diff --git a/packages/react-native/tests/breadcrumbs/FileBreadcrumbsStorage.spec.ts b/packages/react-native/tests/breadcrumbs/FileBreadcrumbsStorage.spec.ts new file mode 100644 index 00000000..ee565d5c --- /dev/null +++ b/packages/react-native/tests/breadcrumbs/FileBreadcrumbsStorage.spec.ts @@ -0,0 +1,315 @@ +import { Breadcrumb, BreadcrumbLogLevel, BreadcrumbType, RawBreadcrumb } from '@backtrace/sdk-core'; +import { MockedFileSystem } from '@backtrace/sdk-core/tests/_mocks/fileSystem'; +import assert from 'assert'; +import { promisify } from 'util'; +import { FileSystem } from '../../src'; +import { FileBreadcrumbsStorage } from '../../src/breadcrumbs/FileBreadcrumbsStorage'; +import { FileLocation } from '../../src/types/FileLocation'; +import { mockReactFileSystem } from '../_mocks/fileSystem'; + +function loadBreadcrumbs(fs: MockedFileSystem, location: FileLocation): Breadcrumb[] { + return fs + .readFileSync(location.filepath) + .split('\n') + .filter((n) => !!n) + .map((x) => { + try { + return JSON.parse(x); + } catch (err) { + throw new Error(`failed to parse "${x}": ${err}`); + } + }); +} + +const nextTick = promisify(process.nextTick); + +describe('FileBreadcrumbsStorage', () => { + it('should return added breadcrumbs', async () => { + const fs = mockReactFileSystem(); + + const breadcrumbs: RawBreadcrumb[] = [ + { + level: BreadcrumbLogLevel.Info, + message: 'a', + type: BreadcrumbType.Manual, + attributes: { + foo: 'bar', + }, + }, + { + level: BreadcrumbLogLevel.Debug, + message: 'b', + type: BreadcrumbType.Http, + }, + { + level: BreadcrumbLogLevel.Warning, + message: 'c', + type: BreadcrumbType.Navigation, + attributes: {}, + }, + ]; + + const expectedMain: Breadcrumb[] = [ + { + id: expect.any(Number), + level: 'info', + message: 'a', + timestamp: expect.any(Number), + type: 'manual', + attributes: { + foo: 'bar', + }, + }, + { + id: expect.any(Number), + level: 'debug', + message: 'b', + timestamp: expect.any(Number), + type: 'http', + }, + { + id: expect.any(Number), + level: 'warning', + message: 'c', + timestamp: expect.any(Number), + type: 'navigation', + attributes: {}, + }, + ]; + + const storage = new FileBreadcrumbsStorage(fs, 'breadcrumbs-1', 'breadcrumbs-2', { + maximumBreadcrumbs: 100, + }); + + for (const breadcrumb of breadcrumbs) { + storage.add(breadcrumb); + } + + // FileBreadcrumbsStorage is asynchronous in nature + await nextTick(); + + const [mainAttachment] = storage.getAttachments(); + + const mainLocation = mainAttachment.get(); + assert(mainLocation); + + const actualMain = loadBreadcrumbs(fs, mainLocation); + expect(actualMain).toEqual(expectedMain); + }); + + it('should return added breadcrumbs in two attachments', async () => { + const fs = mockReactFileSystem(); + + const breadcrumbs: RawBreadcrumb[] = [ + { + level: BreadcrumbLogLevel.Info, + message: 'a', + type: BreadcrumbType.Manual, + attributes: { + foo: 'bar', + }, + }, + { + level: BreadcrumbLogLevel.Debug, + message: 'b', + type: BreadcrumbType.Http, + }, + { + level: BreadcrumbLogLevel.Warning, + message: 'c', + type: BreadcrumbType.Navigation, + attributes: {}, + }, + ]; + + const expectedMain: Breadcrumb[] = [ + { + id: expect.any(Number), + level: 'warning', + message: 'c', + timestamp: expect.any(Number), + type: 'navigation', + attributes: {}, + }, + ]; + + const expectedFallback: Breadcrumb[] = [ + { + id: expect.any(Number), + level: 'info', + message: 'a', + timestamp: expect.any(Number), + type: 'manual', + attributes: { + foo: 'bar', + }, + }, + { + id: expect.any(Number), + level: 'debug', + message: 'b', + timestamp: expect.any(Number), + type: 'http', + }, + ]; + + const storage = new FileBreadcrumbsStorage(fs, 'breadcrumbs-1', 'breadcrumbs-2', { + maximumBreadcrumbs: 4, + }); + + for (const breadcrumb of breadcrumbs) { + storage.add(breadcrumb); + } + + // FileBreadcrumbsStorage is asynchronous in nature + await nextTick(); + + const [mainAttachment, fallbackAttachment] = storage.getAttachments(); + + const mainLocation = mainAttachment.get(); + const fallbackLocation = fallbackAttachment.get(); + assert(mainLocation); + assert(fallbackLocation); + + const actualMain = loadBreadcrumbs(fs, mainLocation); + const actualFallback = loadBreadcrumbs(fs, fallbackLocation); + expect(actualMain).toEqual(expectedMain); + expect(actualFallback).toEqual(expectedFallback); + }); + + it('should return no more than maximumBreadcrumbs breadcrumbs', async () => { + const fs = mockReactFileSystem(); + + const breadcrumbs: RawBreadcrumb[] = [ + { + level: BreadcrumbLogLevel.Info, + message: 'a', + type: BreadcrumbType.Manual, + attributes: { + foo: 'bar', + }, + }, + { + level: BreadcrumbLogLevel.Debug, + message: 'b', + type: BreadcrumbType.Http, + }, + { + level: BreadcrumbLogLevel.Warning, + message: 'c', + type: BreadcrumbType.Navigation, + attributes: {}, + }, + ]; + + const expectedMain: Breadcrumb[] = [ + { + id: expect.any(Number), + level: 'warning', + message: 'c', + timestamp: expect.any(Number), + type: 'navigation', + attributes: {}, + }, + ]; + + const expectedFallback: Breadcrumb[] = [ + { + id: expect.any(Number), + level: 'debug', + message: 'b', + timestamp: expect.any(Number), + type: 'http', + }, + ]; + + const storage = new FileBreadcrumbsStorage(fs, 'breadcrumbs-1', 'breadcrumbs-2', { + maximumBreadcrumbs: 2, + }); + + for (const breadcrumb of breadcrumbs) { + storage.add(breadcrumb); + } + + // FileBreadcrumbsStorage is asynchronous in nature + await nextTick(); + + const [mainAttachment, fallbackAttachment] = storage.getAttachments(); + + const mainLocation = mainAttachment.get(); + const fallbackLocation = fallbackAttachment.get(); + assert(mainLocation); + assert(fallbackLocation); + + const actualMain = loadBreadcrumbs(fs, mainLocation); + const actualFallback = loadBreadcrumbs(fs, fallbackLocation); + expect(actualMain).toEqual(expectedMain); + expect(actualFallback).toEqual(expectedFallback); + }); + + it('should return breadcrumbs up to the json size', async () => { + const fs = mockReactFileSystem(); + + const breadcrumbs: RawBreadcrumb[] = [ + { + level: BreadcrumbLogLevel.Debug, + message: 'a', + type: BreadcrumbType.Http, + }, + { + level: BreadcrumbLogLevel.Debug, + message: 'b', + type: BreadcrumbType.Http, + }, + { + level: BreadcrumbLogLevel.Debug, + message: 'c', + type: BreadcrumbType.Http, + }, + ]; + + const expectedMain: Breadcrumb[] = [ + { + id: expect.any(Number), + level: 'debug', + message: 'c', + timestamp: expect.any(Number), + type: 'http', + }, + ]; + + const expectedFallback: Breadcrumb[] = [ + { + id: expect.any(Number), + level: 'debug', + message: 'b', + timestamp: expect.any(Number), + type: 'http', + }, + ]; + + const storage = new FileBreadcrumbsStorage(fs, 'breadcrumbs-1', 'breadcrumbs-2', { + maximumBreadcrumbs: 100, + maximumTotalBreadcrumbsSize: JSON.stringify(expectedMain[0]).length + 10, + }); + + for (const breadcrumb of breadcrumbs) { + storage.add(breadcrumb); + } + + // FileBreadcrumbsStorage is asynchronous in nature + await nextTick(); + + const [mainAttachment, fallbackAttachment] = storage.getAttachments(); + + const mainLocation = mainAttachment.get(); + const fallbackLocation = fallbackAttachment.get(); + assert(mainLocation); + assert(fallbackLocation); + + const actualMain = loadBreadcrumbs(fs, mainLocation); + const actualFallback = loadBreadcrumbs(fs, fallbackLocation); + expect(actualMain).toEqual(expectedMain); + expect(actualFallback).toEqual(expectedFallback); + }); +});