From adecf42ec1503c56fe9ca97363b6e23e31e84f65 Mon Sep 17 00:00:00 2001 From: Sebastian Alex Date: Fri, 27 Jun 2025 11:25:09 +0200 Subject: [PATCH 1/5] node: overhaul of database and storage [INT-355] --- packages/node/src/BacktraceClient.ts | 103 ++++++++++---- packages/node/src/BacktraceConfiguration.ts | 33 ++++- .../src/attachment/BacktraceFileAttachment.ts | 19 ++- .../src/attachment/FileAttachmentsManager.ts | 17 ++- .../node/src/attachment/isFileAttachment.ts | 9 ++ .../src/breadcrumbs/FileBreadcrumbsStorage.ts | 24 ++-- .../src/builder/BacktraceClientBuilder.ts | 13 +- .../node/src/builder/BacktraceClientSetup.ts | 12 +- packages/node/src/common/asyncGenerator.ts | 7 + .../AttachmentBacktraceDatabaseRecord.ts | 97 +++++++++++++ ...tBacktraceDatabaseRecordWithAttachments.ts | 103 ++++++++++++++ packages/node/src/database/index.ts | 2 + packages/node/src/database/utils.ts | 8 ++ packages/node/src/index.ts | 3 +- packages/node/src/storage/BacktraceStorage.ts | 19 +++ .../storage/BacktraceStorageModuleFactory.ts | 5 + packages/node/src/storage/FsNodeFileSystem.ts | 79 ----------- .../src/storage/NodeFsBacktraceStorage.ts | 134 ++++++++++++++++++ packages/node/src/storage/index.ts | 4 +- .../src/storage/interfaces/NodeFileSystem.ts | 9 -- packages/node/src/streams/fileChunkSink.ts | 9 +- .../_mocks/{fileSystem.ts => storage.ts} | 34 ++--- .../FileBreadcrumbsStorage.spec.ts | 14 +- .../node/tests/streams/fileChunkSink.spec.ts | 15 +- 24 files changed, 584 insertions(+), 188 deletions(-) create mode 100644 packages/node/src/attachment/isFileAttachment.ts create mode 100644 packages/node/src/common/asyncGenerator.ts create mode 100644 packages/node/src/database/AttachmentBacktraceDatabaseRecord.ts create mode 100644 packages/node/src/database/ReportBacktraceDatabaseRecordWithAttachments.ts create mode 100644 packages/node/src/database/index.ts create mode 100644 packages/node/src/database/utils.ts create mode 100644 packages/node/src/storage/BacktraceStorage.ts create mode 100644 packages/node/src/storage/BacktraceStorageModuleFactory.ts delete mode 100644 packages/node/src/storage/FsNodeFileSystem.ts create mode 100644 packages/node/src/storage/NodeFsBacktraceStorage.ts delete mode 100644 packages/node/src/storage/interfaces/NodeFileSystem.ts rename packages/node/tests/_mocks/{fileSystem.ts => storage.ts} (57%) diff --git a/packages/node/src/BacktraceClient.ts b/packages/node/src/BacktraceClient.ts index e1b223dc..226303be 100644 --- a/packages/node/src/BacktraceClient.ts +++ b/packages/node/src/BacktraceClient.ts @@ -7,7 +7,7 @@ import { SessionFiles, VariableDebugIdMapProvider, } from '@backtrace/sdk-core'; -import path from 'path'; +import nodeFs from 'fs'; import { BacktraceConfiguration, BacktraceSetupConfiguration } from './BacktraceConfiguration.js'; import { BacktraceNodeRequestHandler } from './BacktraceNodeRequestHandler.js'; import { AGENT } from './agentDefinition.js'; @@ -17,39 +17,85 @@ import { FileBreadcrumbsStorage } from './breadcrumbs/FileBreadcrumbsStorage.js' import { BacktraceClientBuilder } from './builder/BacktraceClientBuilder.js'; import { BacktraceNodeClientSetup } from './builder/BacktraceClientSetup.js'; import { NodeOptionReader } from './common/NodeOptionReader.js'; +import { toArray } from './common/asyncGenerator.js'; import { NodeDiagnosticReportConverter } from './converter/NodeDiagnosticReportConverter.js'; -import { FsNodeFileSystem } from './storage/FsNodeFileSystem.js'; -import { NodeFileSystem } from './storage/interfaces/NodeFileSystem.js'; +import { + AttachmentBacktraceDatabaseRecordSender, + AttachmentBacktraceDatabaseRecordSerializer, +} from './database/AttachmentBacktraceDatabaseRecord.js'; +import { + ReportBacktraceDatabaseRecordWithAttachmentsFactory, + ReportBacktraceDatabaseRecordWithAttachmentsSender, + ReportBacktraceDatabaseRecordWithAttachmentsSerializer, +} from './database/ReportBacktraceDatabaseRecordWithAttachments.js'; +import { assertDatabasePath } from './database/utils.js'; +import { BacktraceStorageModule } from './storage/BacktraceStorage.js'; +import { BacktraceStorageModuleFactory } from './storage/BacktraceStorageModuleFactory.js'; +import { NodeFsBacktraceStorageModuleFactory } from './storage/NodeFsBacktraceStorage.js'; export class BacktraceClient extends BacktraceCoreClient { private _listeners: Record = {}; - protected get nodeFileSystem() { - return this.fileSystem as NodeFileSystem | undefined; + protected readonly storageFactory: BacktraceStorageModuleFactory; + protected readonly fs: typeof nodeFs; + + protected get databaseNodeFsStorage() { + return this.databaseStorage as BacktraceStorageModule | undefined; } constructor(clientSetup: BacktraceNodeClientSetup) { - const fileSystem = clientSetup.fileSystem ?? new FsNodeFileSystem(); + const storageFactory = clientSetup.storageFactory ?? new NodeFsBacktraceStorageModuleFactory(); + const fs = clientSetup.fs ?? nodeFs; + const storage = + clientSetup.database?.storage ?? + (clientSetup.options.database?.enable + ? storageFactory.create({ + path: assertDatabasePath(clientSetup.options.database.path), + createDirectory: clientSetup.options.database.createDatabaseDirectory, + fs, + }) + : undefined); + super({ sdkOptions: AGENT, requestHandler: new BacktraceNodeRequestHandler(clientSetup.options), debugIdMapProvider: new VariableDebugIdMapProvider(global as DebugIdContainer), + database: + clientSetup.options.database?.enable && storage + ? { + storage, + reportRecordFactory: ReportBacktraceDatabaseRecordWithAttachmentsFactory.default(), + ...clientSetup.database, + recordSenders: (submission) => ({ + report: new ReportBacktraceDatabaseRecordWithAttachmentsSender(submission), + attachment: new AttachmentBacktraceDatabaseRecordSender(submission), + ...clientSetup.database?.recordSenders?.(submission), + }), + recordSerializers: { + report: new ReportBacktraceDatabaseRecordWithAttachmentsSerializer(storage), + attachment: new AttachmentBacktraceDatabaseRecordSerializer(fs), + ...clientSetup.database?.recordSerializers, + }, + } + : undefined, ...clientSetup, - fileSystem, options: { ...clientSetup.options, attachments: clientSetup.options.attachments?.map(transformAttachment), }, }); + this.storageFactory = storageFactory; + this.fs = fs; + const breadcrumbsManager = this.modules.get(BreadcrumbsManager); - if (breadcrumbsManager && this.sessionFiles) { - breadcrumbsManager.setStorage(FileBreadcrumbsStorage.factory(this.sessionFiles, fileSystem)); + if (breadcrumbsManager && this.sessionFiles && storage) { + breadcrumbsManager.setStorage(FileBreadcrumbsStorage.factory(this.sessionFiles, storage)); } - if (this.sessionFiles && clientSetup.options.database?.captureNativeCrashes) { - this.addModule(FileAttributeManager, FileAttributeManager.create(fileSystem)); - this.addModule(FileAttachmentsManager, FileAttachmentsManager.create(fileSystem)); + if (this.sessionFiles && storage && clientSetup.options.database?.captureNativeCrashes) { + this.addModule(FileAttributeManager, FileAttributeManager.create(storage)); + this.addModule(FileAttachmentsManager, FileAttachmentsManager.create(storage)); } } @@ -58,6 +104,7 @@ export class BacktraceClient extends BacktraceCoreClient try { super.initialize(); + this.captureUnhandledErrors( this.options.captureUnhandledErrors, this.options.captureUnhandledPromiseRejections, @@ -242,18 +289,19 @@ export class BacktraceClient extends BacktraceCoreClient } private async loadNodeCrashes() { - if (!this.database || !this.nodeFileSystem || !this.options.database?.captureNativeCrashes) { + if (!this.database || !this.options.database?.captureNativeCrashes) { return; } const reportName = process.report?.filename; - const databasePath = process.report?.directory - ? process.report.directory - : (this.options.database?.path ?? process.cwd()); + const storage = this.storageFactory.create({ + path: process.report?.directory ? process.report.directory : (this.options.database?.path ?? process.cwd()), + fs: this.fs, + }); let databaseFiles: string[]; try { - databaseFiles = await this.nodeFileSystem.readDir(databasePath); + databaseFiles = await toArray(storage.keys()); } catch { return; } @@ -271,11 +319,14 @@ export class BacktraceClient extends BacktraceCoreClient const reports: [path: string, report: BacktraceReport, sessionFiles?: SessionFiles][] = []; for (const recordName of recordNames) { - const recordPath = path.join(databasePath, recordName); try { - const recordJson = await this.nodeFileSystem.readFile(recordPath); + const recordJson = await storage.get(recordName); + if (!recordJson) { + continue; + } + const report = converter.convert(JSON.parse(recordJson)); - reports.push([recordPath, report]); + reports.push([recordName, report]); } catch { // Do nothing, skip the report } @@ -292,17 +343,15 @@ export class BacktraceClient extends BacktraceCoreClient currentSession = currentSession?.getPreviousSession(); } - for (const [recordPath, report, session] of reports) { + for (const [recordName, report, session] of reports) { try { if (session) { - report.attachments.push( - ...FileBreadcrumbsStorage.getSessionAttachments(session, this.nodeFileSystem), - ); + report.attachments.push(...FileBreadcrumbsStorage.getSessionAttachments(session, storage)); - const fileAttributes = FileAttributeManager.createFromSession(session, this.nodeFileSystem); + const fileAttributes = FileAttributeManager.createFromSession(session, storage); Object.assign(report.attributes, await fileAttributes.get()); - const fileAttachments = FileAttachmentsManager.createFromSession(session, this.nodeFileSystem); + const fileAttachments = FileAttachmentsManager.createFromSession(session, storage); report.attachments.push(...(await fileAttachments.get())); report.attributes['application.session'] = session.sessionId; @@ -318,7 +367,7 @@ export class BacktraceClient extends BacktraceCoreClient // Do nothing, skip the report } finally { try { - await this.nodeFileSystem.unlink(recordPath); + await storage.remove(recordName); } catch { // Do nothing } diff --git a/packages/node/src/BacktraceConfiguration.ts b/packages/node/src/BacktraceConfiguration.ts index b7f39251..d3bb1980 100644 --- a/packages/node/src/BacktraceConfiguration.ts +++ b/packages/node/src/BacktraceConfiguration.ts @@ -1,10 +1,37 @@ -import { BacktraceAttachment, BacktraceConfiguration as CoreConfiguration } from '@backtrace/sdk-core'; +import { + BacktraceAttachment, + BacktraceConfiguration as CoreConfiguration, + DisabledBacktraceDatabaseConfiguration as CoreDisabledBacktraceDatabaseConfiguration, + EnabledBacktraceDatabaseConfiguration as CoreEnabledBacktraceDatabaseConfiguration, +} from '@backtrace/sdk-core'; import { Readable } from 'stream'; -export interface BacktraceSetupConfiguration extends Omit { +export interface EnabledBacktraceDatabaseConfiguration extends CoreEnabledBacktraceDatabaseConfiguration { + /** + * Path where the SDK can store data. + */ + path: string; + /** + * Determine if the directory should be auto created by the SDK. + * @default true + */ + createDatabaseDirectory?: boolean; +} + +export interface DisabledBacktraceDatabaseConfiguration + extends CoreDisabledBacktraceDatabaseConfiguration, + Omit, 'enable'> {} + +export type BacktraceDatabaseConfiguration = + | EnabledBacktraceDatabaseConfiguration + | DisabledBacktraceDatabaseConfiguration; + +export interface BacktraceSetupConfiguration extends Omit { attachments?: Array | string>; + database?: BacktraceDatabaseConfiguration; } -export interface BacktraceConfiguration extends Omit { +export interface BacktraceConfiguration extends Omit { attachments?: BacktraceAttachment[]; + database?: BacktraceDatabaseConfiguration; } diff --git a/packages/node/src/attachment/BacktraceFileAttachment.ts b/packages/node/src/attachment/BacktraceFileAttachment.ts index c507eff6..ec87e344 100644 --- a/packages/node/src/attachment/BacktraceFileAttachment.ts +++ b/packages/node/src/attachment/BacktraceFileAttachment.ts @@ -1,8 +1,8 @@ -import { BacktraceFileAttachment as CoreBacktraceFileAttachment } from '@backtrace/sdk-core'; +import { BacktraceSyncStorage, BacktraceFileAttachment as CoreBacktraceFileAttachment } from '@backtrace/sdk-core'; import fs from 'fs'; import path from 'path'; import { Readable } from 'stream'; -import { NodeFileSystem } from '../storage/interfaces/NodeFileSystem.js'; +import { BacktraceStreamStorage } from '../storage/BacktraceStorage.js'; export class BacktraceFileAttachment implements CoreBacktraceFileAttachment { public readonly name: string; @@ -10,15 +10,22 @@ export class BacktraceFileAttachment implements CoreBacktraceFileAttachment new BacktraceFileAttachment(path, name)); } catch { @@ -74,6 +77,6 @@ export class FileAttachmentsManager implements BacktraceModule { .filter((f): f is BacktraceFileAttachment => f instanceof BacktraceFileAttachment) .map((f) => [f.filePath, f.name]); - await this._fileSystem.writeFile(this._fileName, JSON.stringify(fileAttachments)); + await this._storage.set(this._fileName, JSON.stringify(fileAttachments)); } } diff --git a/packages/node/src/attachment/isFileAttachment.ts b/packages/node/src/attachment/isFileAttachment.ts new file mode 100644 index 00000000..dc462c9b --- /dev/null +++ b/packages/node/src/attachment/isFileAttachment.ts @@ -0,0 +1,9 @@ +import { BacktraceAttachment } from '@backtrace/sdk-core'; +import { BacktraceFileAttachment } from './BacktraceFileAttachment.js'; + +export function isFileAttachment(attachment: BacktraceAttachment): attachment is BacktraceFileAttachment { + return ( + attachment instanceof BacktraceFileAttachment || + ('filePath' in attachment && typeof attachment.filePath === 'string') + ); +} diff --git a/packages/node/src/breadcrumbs/FileBreadcrumbsStorage.ts b/packages/node/src/breadcrumbs/FileBreadcrumbsStorage.ts index d0d694a7..81df4c87 100644 --- a/packages/node/src/breadcrumbs/FileBreadcrumbsStorage.ts +++ b/packages/node/src/breadcrumbs/FileBreadcrumbsStorage.ts @@ -1,6 +1,8 @@ import { BacktraceAttachment, BacktraceAttachmentProvider, + BacktraceStorage, + BacktraceSyncStorage, Breadcrumb, BreadcrumbLogLevel, BreadcrumbsStorage, @@ -15,7 +17,7 @@ import { import path from 'path'; import { Readable, Writable } from 'stream'; import { BacktraceFileAttachment } from '../attachment/index.js'; -import { NodeFileSystem } from '../storage/interfaces/NodeFileSystem.js'; +import { BacktraceStreamStorage } from '../storage/BacktraceStorage.js'; import { chunkifier, ChunkSplitterFactory } from '../streams/chunkifier.js'; import { combinedChunkSplitter } from '../streams/combinedChunkSplitter.js'; import { FileChunkSink } from '../streams/fileChunkSink.js'; @@ -36,7 +38,7 @@ export class FileBreadcrumbsStorage implements BreadcrumbsStorage { constructor( session: SessionFiles, - private readonly _fileSystem: NodeFileSystem, + private readonly _storage: BacktraceStorage & BacktraceSyncStorage & BacktraceStreamStorage, private readonly _limits: BreadcrumbsStorageLimits, ) { const splitters: ChunkSplitterFactory[] = []; @@ -52,7 +54,7 @@ export class FileBreadcrumbsStorage implements BreadcrumbsStorage { this._sink = new FileChunkSink({ maxFiles: 2, - fs: this._fileSystem, + storage: this._storage, file: (n) => session.getFileName(FileBreadcrumbsStorage.getFileName(n)), }); @@ -71,22 +73,28 @@ export class FileBreadcrumbsStorage implements BreadcrumbsStorage { }); } - public static getSessionAttachments(session: SessionFiles, fileSystem?: NodeFileSystem) { + public static getSessionAttachments( + session: SessionFiles, + storage?: BacktraceStorage & BacktraceSyncStorage & BacktraceStreamStorage, + ) { const files = session .getSessionFiles() .filter((f) => path.basename(f).startsWith(FILE_PREFIX)) .slice(0, 2); - return files.map((file) => new BacktraceFileAttachment(file, path.basename(file), fileSystem)); + return files.map((file) => new BacktraceFileAttachment(file, path.basename(file), storage)); } - public static factory(session: SessionFiles, fileSystem: NodeFileSystem): BreadcrumbsStorageFactory { - return ({ limits }) => new FileBreadcrumbsStorage(session, fileSystem, limits); + public static factory( + session: SessionFiles, + storage: BacktraceStorage & BacktraceSyncStorage & BacktraceStreamStorage, + ): BreadcrumbsStorageFactory { + return ({ limits }) => new FileBreadcrumbsStorage(session, storage, limits); } public getAttachments(): BacktraceAttachment[] { const files = [...this._sink.files].map((f) => f.path.toString('utf-8')); - return files.map((f) => new BacktraceFileAttachment(f, path.basename(f), this._fileSystem)); + return files.map((f) => new BacktraceFileAttachment(f, path.basename(f), this._storage)); } public getAttachmentProviders(): BacktraceAttachmentProvider[] { diff --git a/packages/node/src/builder/BacktraceClientBuilder.ts b/packages/node/src/builder/BacktraceClientBuilder.ts index 1bf45008..f2467270 100644 --- a/packages/node/src/builder/BacktraceClientBuilder.ts +++ b/packages/node/src/builder/BacktraceClientBuilder.ts @@ -9,9 +9,12 @@ import { ProcessStatusAttributeProvider, } from '../attributes/index.js'; import { BacktraceClient } from '../BacktraceClient.js'; +import { BacktraceStorageModuleFactory } from '../storage/BacktraceStorageModuleFactory.js'; import { BacktraceClientSetup, BacktraceNodeClientSetup } from './BacktraceClientSetup.js'; export class BacktraceClientBuilder extends BacktraceCoreClientBuilder { + private storageFactory?: BacktraceStorageModuleFactory; + constructor(clientSetup: BacktraceNodeClientSetup) { super({ ...clientSetup, @@ -26,8 +29,16 @@ export class BacktraceClientBuilder extends BacktraceCoreClientBuilder {} -export type BacktraceNodeClientSetup = Omit & { +export type BacktraceNodeClientSetup = Omit & { readonly options: BacktraceSetupConfiguration; - readonly fileSystem?: NodeFileSystem; + readonly storageFactory?: BacktraceStorageModuleFactory; + readonly fs?: typeof nodeFs; + readonly database?: Omit, 'storage'> & { + readonly storage?: BacktraceStorageModule; + }; }; diff --git a/packages/node/src/common/asyncGenerator.ts b/packages/node/src/common/asyncGenerator.ts new file mode 100644 index 00000000..4aee5f33 --- /dev/null +++ b/packages/node/src/common/asyncGenerator.ts @@ -0,0 +1,7 @@ +export async function toArray(generator: AsyncGenerator): Promise { + const result: T[] = []; + for await (const element of generator) { + result.push(element); + } + return result; +} diff --git a/packages/node/src/database/AttachmentBacktraceDatabaseRecord.ts b/packages/node/src/database/AttachmentBacktraceDatabaseRecord.ts new file mode 100644 index 00000000..cc27324f --- /dev/null +++ b/packages/node/src/database/AttachmentBacktraceDatabaseRecord.ts @@ -0,0 +1,97 @@ +import { + BacktraceAttachment, + BacktraceDatabaseRecord, + BacktraceDatabaseRecordFactory, + BacktraceReportSubmission, + BacktraceReportSubmissionResult, + BacktraceSubmitResponse, + jsonEscaper, +} from '@backtrace/sdk-core'; +import { BacktraceDatabaseRecordSender } from '@backtrace/sdk-core/lib/modules/database/BacktraceDatabaseRecordSender.js'; +import { BacktraceDatabaseRecordSerializer } from '@backtrace/sdk-core/lib/modules/database/BacktraceDatabaseRecordSerializer.js'; +import nodeFs from 'fs'; +import { BacktraceFileAttachment } from '../attachment/BacktraceFileAttachment.js'; +import { isFileAttachment } from '../attachment/isFileAttachment.js'; + +export interface AttachmentBacktraceDatabaseRecord extends BacktraceDatabaseRecord<'attachment'> { + readonly rxid: string; + readonly attachment: BacktraceAttachment; + readonly sessionId: string; +} + +export class AttachmentBacktraceDatabaseRecordSerializer + implements BacktraceDatabaseRecordSerializer +{ + public readonly type = 'attachment'; + + constructor(private readonly _fs: typeof nodeFs) {} + + public save(record: AttachmentBacktraceDatabaseRecord): string | undefined { + if (!isFileAttachment(record.attachment)) { + return undefined; + } + + return JSON.stringify(record, jsonEscaper()); + } + + public load(json: string): AttachmentBacktraceDatabaseRecord | undefined { + try { + const record = JSON.parse(json) as BacktraceDatabaseRecord; + if (record.type !== this.type) { + return undefined; + } + + const attachmentRecord = record as AttachmentBacktraceDatabaseRecord; + if (!isFileAttachment(attachmentRecord.attachment)) { + return undefined; + } + + const attachment = new BacktraceFileAttachment( + attachmentRecord.attachment.filePath, + attachmentRecord.attachment.name, + this._fs, + ); + + return { + ...attachmentRecord, + attachment, + }; + } catch { + return undefined; + } + } +} + +export class AttachmentBacktraceDatabaseRecordSender + implements BacktraceDatabaseRecordSender +{ + public readonly type = 'attachment'; + + constructor(private readonly _reportSubmission: BacktraceReportSubmission) {} + + public send( + record: AttachmentBacktraceDatabaseRecord, + abortSignal?: AbortSignal, + ): Promise> { + return this._reportSubmission.sendAttachment(record.rxid, record.attachment, abortSignal); + } +} + +export class AttachmentBacktraceDatabaseRecordFactory { + constructor(private readonly _reportFactory: BacktraceDatabaseRecordFactory) {} + + public static default() { + return new AttachmentBacktraceDatabaseRecordFactory(new BacktraceDatabaseRecordFactory()); + } + + public create(rxid: string, sessionId: string, attachment: BacktraceAttachment): AttachmentBacktraceDatabaseRecord { + const record: AttachmentBacktraceDatabaseRecord = { + ...this._reportFactory.create('attachment'), + sessionId, + rxid, + attachment, + }; + + return record; + } +} diff --git a/packages/node/src/database/ReportBacktraceDatabaseRecordWithAttachments.ts b/packages/node/src/database/ReportBacktraceDatabaseRecordWithAttachments.ts new file mode 100644 index 00000000..5d32cff8 --- /dev/null +++ b/packages/node/src/database/ReportBacktraceDatabaseRecordWithAttachments.ts @@ -0,0 +1,103 @@ +import { + BacktraceAttachment, + BacktraceData, + BacktraceDatabaseRecord, + BacktraceReportSubmission, + BacktraceReportSubmissionResult, + BacktraceSubmitResponse, + BacktraceSyncStorage, + DefaultReportBacktraceDatabaseRecordFactory, + jsonEscaper, + ReportBacktraceDatabaseRecord, + ReportBacktraceDatabaseRecordFactory, +} from '@backtrace/sdk-core'; +import { BacktraceDatabaseRecordSender } from '@backtrace/sdk-core/lib/modules/database/BacktraceDatabaseRecordSender.js'; +import { BacktraceDatabaseRecordSerializer } from '@backtrace/sdk-core/lib/modules/database/BacktraceDatabaseRecordSerializer.js'; +import { BacktraceFileAttachment } from '../attachment/BacktraceFileAttachment.js'; +import { isFileAttachment } from '../attachment/isFileAttachment.js'; +import { BacktraceStreamStorage } from '../storage/BacktraceStorage.js'; + +export interface ReportBacktraceDatabaseRecordWithAttachments extends ReportBacktraceDatabaseRecord { + readonly attachments: BacktraceAttachment[]; +} + +export class ReportBacktraceDatabaseRecordWithAttachmentsSerializer + implements BacktraceDatabaseRecordSerializer +{ + public readonly type = 'report'; + + constructor(private readonly _storage: BacktraceSyncStorage & BacktraceStreamStorage) {} + + public save(record: ReportBacktraceDatabaseRecordWithAttachments): string { + return JSON.stringify( + { + ...record, + attachments: record.attachments.filter(isFileAttachment), + } satisfies ReportBacktraceDatabaseRecordWithAttachments, + jsonEscaper(), + ); + } + + public load(json: string): ReportBacktraceDatabaseRecordWithAttachments | undefined { + try { + const record = JSON.parse(json) as BacktraceDatabaseRecord; + if (record.type !== this.type) { + return undefined; + } + + const reportRecord = record as ReportBacktraceDatabaseRecordWithAttachments; + if (reportRecord.attachments) { + return { + ...reportRecord, + attachments: reportRecord.attachments + .filter(isFileAttachment) + .map((a) => new BacktraceFileAttachment(a.filePath, a.name, this._storage)), + }; + } + + return { + ...reportRecord, + attachments: [], + }; + } catch { + return undefined; + } + } +} + +export class ReportBacktraceDatabaseRecordWithAttachmentsSender + implements BacktraceDatabaseRecordSender +{ + public readonly type = 'report'; + + constructor(private readonly _reportSubmission: BacktraceReportSubmission) {} + + public send( + record: ReportBacktraceDatabaseRecordWithAttachments, + abortSignal?: AbortSignal, + ): Promise> { + return this._reportSubmission.send(record.data, record.attachments, abortSignal); + } +} + +export class ReportBacktraceDatabaseRecordWithAttachmentsFactory implements ReportBacktraceDatabaseRecordFactory { + constructor(private readonly _defaultFactory: ReportBacktraceDatabaseRecordFactory) {} + + public static default() { + return new ReportBacktraceDatabaseRecordWithAttachmentsFactory( + DefaultReportBacktraceDatabaseRecordFactory.default(), + ); + } + + public create( + data: BacktraceData, + attachments: BacktraceAttachment[], + ): ReportBacktraceDatabaseRecordWithAttachments { + const record = this._defaultFactory.create(data, attachments); + + return { + ...record, + attachments: attachments.filter((a) => a instanceof BacktraceFileAttachment), + }; + } +} diff --git a/packages/node/src/database/index.ts b/packages/node/src/database/index.ts new file mode 100644 index 00000000..0c4ade51 --- /dev/null +++ b/packages/node/src/database/index.ts @@ -0,0 +1,2 @@ +export * from './AttachmentBacktraceDatabaseRecord.js'; +export * from './ReportBacktraceDatabaseRecordWithAttachments.js'; diff --git a/packages/node/src/database/utils.ts b/packages/node/src/database/utils.ts new file mode 100644 index 00000000..8ac2d567 --- /dev/null +++ b/packages/node/src/database/utils.ts @@ -0,0 +1,8 @@ +export function assertDatabasePath(path: string) { + if (!path) { + throw new Error( + 'Missing mandatory path to the database. Please define the database.path option in the configuration.', + ); + } + return path; +} diff --git a/packages/node/src/index.ts b/packages/node/src/index.ts index 8837d1a5..877003c0 100644 --- a/packages/node/src/index.ts +++ b/packages/node/src/index.ts @@ -6,9 +6,9 @@ export { BacktraceStackFrame, BacktraceStackTraceConverter, BreadcrumbLogLevel, - BreadcrumbType, BreadcrumbsEventSubscriber, BreadcrumbsManager, + BreadcrumbType, RawBreadcrumb, } from '@backtrace/sdk-core'; export * from './attachment/index.js'; @@ -19,4 +19,5 @@ export * from './BacktraceNodeRequestHandler.js'; export * from './breadcrumbs/index.js'; export * from './builder/BacktraceClientBuilder.js'; export * from './builder/BacktraceClientSetup.js'; +export * from './database/index.js'; export * from './storage/index.js'; diff --git a/packages/node/src/storage/BacktraceStorage.ts b/packages/node/src/storage/BacktraceStorage.ts new file mode 100644 index 00000000..1cba1640 --- /dev/null +++ b/packages/node/src/storage/BacktraceStorage.ts @@ -0,0 +1,19 @@ +import { BacktraceStorageModule as CoreBacktraceStorageModule } from '@backtrace/sdk-core'; +import nodeFs from 'fs'; +import { BacktraceConfiguration } from '../BacktraceConfiguration.js'; + +export interface ReadonlyBacktraceStreamStorage { + createReadStream(key: string): nodeFs.ReadStream; +} + +export interface BacktraceStreamStorage extends ReadonlyBacktraceStreamStorage { + createWriteStream(key: string): nodeFs.WriteStream; +} + +export type BacktraceStorageModule = CoreBacktraceStorageModule & BacktraceStreamStorage; + +export interface BacktraceStorageModuleOptions { + readonly path: string; + readonly createDirectory?: boolean; + readonly fs?: typeof nodeFs; +} diff --git a/packages/node/src/storage/BacktraceStorageModuleFactory.ts b/packages/node/src/storage/BacktraceStorageModuleFactory.ts new file mode 100644 index 00000000..c4fefcc4 --- /dev/null +++ b/packages/node/src/storage/BacktraceStorageModuleFactory.ts @@ -0,0 +1,5 @@ +import { BacktraceStorageModule, BacktraceStorageModuleOptions } from './BacktraceStorage.js'; + +export interface BacktraceStorageModuleFactory { + create(options: BacktraceStorageModuleOptions): BacktraceStorageModule; +} diff --git a/packages/node/src/storage/FsNodeFileSystem.ts b/packages/node/src/storage/FsNodeFileSystem.ts deleted file mode 100644 index 56772f00..00000000 --- a/packages/node/src/storage/FsNodeFileSystem.ts +++ /dev/null @@ -1,79 +0,0 @@ -import { BacktraceAttachment } from '@backtrace/sdk-core'; -import fs from 'fs'; -import { BacktraceFileAttachment } from '../attachment/index.js'; -import { NodeFileSystem } from './interfaces/NodeFileSystem.js'; - -export class FsNodeFileSystem implements NodeFileSystem { - public readDir(dir: string): Promise { - return fs.promises.readdir(dir); - } - - public readDirSync(dir: string): string[] { - return fs.readdirSync(dir); - } - - public createDir(dir: string): Promise { - return fs.promises.mkdir(dir, { recursive: true }) as Promise; - } - - public createDirSync(dir: string): void { - fs.mkdirSync(dir, { recursive: true }); - } - - public readFile(path: string): Promise { - return fs.promises.readFile(path, 'utf-8'); - } - - public readFileSync(path: string): string { - return fs.readFileSync(path, 'utf-8'); - } - - public writeFile(path: string, content: string): Promise { - return fs.promises.writeFile(path, content); - } - - public writeFileSync(path: string, content: string): void { - fs.writeFileSync(path, content); - } - - public unlink(path: string): Promise { - return fs.promises.unlink(path); - } - - public unlinkSync(path: string): void { - fs.unlinkSync(path); - } - - public rename(oldPath: string, newPath: string): Promise { - return fs.promises.rename(oldPath, newPath); - } - - public renameSync(oldPath: string, newPath: string): void { - fs.renameSync(oldPath, newPath); - } - - public createWriteStream(path: string): fs.WriteStream { - return fs.createWriteStream(path, 'utf-8'); - } - - public createReadStream(path: string): fs.ReadStream { - return fs.createReadStream(path, 'utf-8'); - } - - public async exists(path: string): Promise { - try { - await fs.promises.stat(path); - return true; - } catch { - return false; - } - } - - public existsSync(path: string): boolean { - return fs.existsSync(path); - } - - public createAttachment(path: string, name?: string): BacktraceAttachment { - return new BacktraceFileAttachment(path, name); - } -} diff --git a/packages/node/src/storage/NodeFsBacktraceStorage.ts b/packages/node/src/storage/NodeFsBacktraceStorage.ts new file mode 100644 index 00000000..79446d67 --- /dev/null +++ b/packages/node/src/storage/NodeFsBacktraceStorage.ts @@ -0,0 +1,134 @@ +import nodeFs from 'fs'; +import path from 'path'; +import { BacktraceStorageModule, BacktraceStorageModuleOptions } from './BacktraceStorage.js'; +import { BacktraceStorageModuleFactory } from './BacktraceStorageModuleFactory.js'; + +export class NodeFsBacktraceStorage implements BacktraceStorageModule { + private readonly _path: string; + private readonly _fs: typeof nodeFs; + private readonly _createDirectory: boolean; + + constructor(options: BacktraceStorageModuleOptions) { + this._path = options.path; + this._fs = options.fs ?? nodeFs; + this._createDirectory = !!options.createDirectory; + } + + public initialize() { + if (this._createDirectory) { + this._fs.mkdirSync(this._path, { recursive: true }); + } + } + + public setSync(key: string, value: string): boolean { + try { + this._fs.writeFileSync(this.resolvePath(key), value); + return true; + } catch { + return false; + } + } + + public removeSync(key: string): boolean { + try { + this._fs.unlinkSync(this.resolvePath(key)); + return true; + } catch { + return false; + } + } + + public getSync(key: string): string | undefined { + try { + return this._fs.readFileSync(this.resolvePath(key), 'utf-8'); + } catch { + return undefined; + } + } + + public hasSync(key: string): boolean { + try { + this._fs.statSync(this.resolvePath(key)); + return true; + } catch { + return false; + } + } + + public async set(key: string, value: string): Promise { + try { + await this._fs.promises.writeFile(this.resolvePath(key), value); + return true; + } catch { + return false; + } + } + + public async remove(key: string): Promise { + try { + await this._fs.promises.unlink(this.resolvePath(key)); + return true; + } catch { + return false; + } + } + + public async get(key: string): Promise { + try { + return await this._fs.promises.readFile(this.resolvePath(key), 'utf-8'); + } catch { + return undefined; + } + } + + public async has(key: string): Promise { + try { + await this._fs.promises.stat(this.resolvePath(key)); + return true; + } catch { + return false; + } + } + + public *keysSync(): Generator { + try { + for (const entry of this._fs.readdirSync(this._path, { withFileTypes: true })) { + if (entry.isFile()) { + yield entry.name; + } + } + } catch { + return; + } + } + + public async *keys(): AsyncGenerator { + try { + for (const entry of await this._fs.promises.readdir(this._path, { withFileTypes: true })) { + if (entry.isFile()) { + yield entry.name; + } + } + } catch { + return; + } + } + + public createWriteStream(key: string): nodeFs.WriteStream { + return nodeFs.createWriteStream(this.resolvePath(key), 'utf-8'); + } + + public createReadStream(key: string): nodeFs.ReadStream { + return nodeFs.createReadStream(this.resolvePath(key), 'utf-8'); + } + + protected resolvePath(key: string) { + return path.resolve(this._path, key); + } +} + +export class NodeFsBacktraceStorageModuleFactory implements BacktraceStorageModuleFactory { + public create(options: BacktraceStorageModuleOptions): BacktraceStorageModule { + return new NodeFsBacktraceStorage(options); + } +} diff --git a/packages/node/src/storage/index.ts b/packages/node/src/storage/index.ts index 2219c93b..e44b69ad 100644 --- a/packages/node/src/storage/index.ts +++ b/packages/node/src/storage/index.ts @@ -1,2 +1,2 @@ -export * from './FsNodeFileSystem.js'; -export * from './interfaces/NodeFileSystem.js'; +export * from './BacktraceStorage.js'; +export * from './NodeFsBacktraceStorage.js'; diff --git a/packages/node/src/storage/interfaces/NodeFileSystem.ts b/packages/node/src/storage/interfaces/NodeFileSystem.ts deleted file mode 100644 index 75b1db66..00000000 --- a/packages/node/src/storage/interfaces/NodeFileSystem.ts +++ /dev/null @@ -1,9 +0,0 @@ -import { FileSystem } from '@backtrace/sdk-core'; -import { ReadStream, WriteStream } from 'fs'; - -export interface NodeFileSystem extends FileSystem { - createReadStream(path: string): ReadStream; - createWriteStream(path: string): WriteStream; - rename(oldPath: string, newPath: string): Promise; - renameSync(oldPath: string, newPath: string): void; -} diff --git a/packages/node/src/streams/fileChunkSink.ts b/packages/node/src/streams/fileChunkSink.ts index 494acabb..92ef5038 100644 --- a/packages/node/src/streams/fileChunkSink.ts +++ b/packages/node/src/streams/fileChunkSink.ts @@ -1,6 +1,7 @@ +import { BacktraceStorage } from '@backtrace/sdk-core'; import EventEmitter from 'events'; import fs from 'fs'; -import { NodeFileSystem } from '../storage/interfaces/NodeFileSystem.js'; +import { BacktraceStreamStorage } from '../storage/BacktraceStorage.js'; import { ChunkSink } from './chunkifier.js'; interface FileChunkSinkOptions { @@ -17,7 +18,7 @@ interface FileChunkSinkOptions { /** * File system implementation to use. */ - readonly fs: NodeFileSystem; + readonly storage: BacktraceStorage & BacktraceStreamStorage; } /** @@ -64,13 +65,13 @@ export class FileChunkSink extends EventEmitter { private createStream(n: number) { const path = this._options.file(n); - return (this._options.fs ?? fs).createWriteStream(path); + return this._options.storage.createWriteStream(path); } private emitDeleteOrDelete(file: fs.WriteStream) { // If 'delete' event is not handled, delete the file if (!this.emit('delete', file)) { - this._options.fs.unlink(file.path.toString('utf-8')).catch(() => { + this._options.storage.remove(file.path.toString('utf-8')).catch(() => { // Do nothing on error }); } diff --git a/packages/node/tests/_mocks/fileSystem.ts b/packages/node/tests/_mocks/storage.ts similarity index 57% rename from packages/node/tests/_mocks/fileSystem.ts rename to packages/node/tests/_mocks/storage.ts index 51e3a9a3..99f6c9bb 100644 --- a/packages/node/tests/_mocks/fileSystem.ts +++ b/packages/node/tests/_mocks/storage.ts @@ -1,28 +1,20 @@ // eslint-disable-next-line @typescript-eslint/ban-ts-comment // @ts-ignore The following import fails due to missing extension, but it cannot have one (it imports a .ts file) -import { MockedFileSystem, mockFileSystem } from '@backtrace/sdk-core/tests/_mocks/fileSystem'; +import { mockBacktraceStorage } from '@backtrace/sdk-core/tests/_mocks/storage'; +import type { MockedBacktraceStorage } from '@backtrace/sdk-core/tests/_mocks/storage.js'; import { ReadStream, WriteStream } from 'fs'; -import path from 'path'; import { Readable, Writable } from 'stream'; -import { NodeFileSystem } from '../../src/storage/interfaces/NodeFileSystem.js'; +import { NodeFsBacktraceStorage } from '../../src/storage/NodeFsBacktraceStorage.js'; -export function mockStreamFileSystem(files?: Record): MockedFileSystem { - const fs = mockFileSystem(files); +export function mockStreamFileSystem( + files?: Record, +): MockedBacktraceStorage> { + const fs = mockBacktraceStorage(files); return { ...fs, - rename: jest.fn().mockImplementation((oldPath: string, newPath: string) => { - const old = fs.files[path.resolve(oldPath)]; - delete fs.files[path.resolve(oldPath)]; - fs.files[path.resolve(newPath)] = old; - return Promise.resolve(); - }), - renameSync: jest.fn().mockImplementation((oldPath: string, newPath: string) => { - const old = fs.files[path.resolve(oldPath)]; - delete fs.files[path.resolve(oldPath)]; - fs.files[path.resolve(newPath)] = old; - }), + ofPath: jest.fn().mockImplementation(() => mockStreamFileSystem()), createWriteStream: jest.fn().mockImplementation((p: string) => { const writable = new Writable({ @@ -33,11 +25,10 @@ export function mockStreamFileSystem(files?: Record): MockedFile ? chunk : String(chunk).toString(); - const fullPath = path.resolve(p); - if (!fs.files[fullPath]) { - fs.files[fullPath] = str; + if (!fs.files[p]) { + fs.files[p] = str; } else { - fs.files[fullPath] += str; + fs.files[p] += str; } callback && callback(); @@ -48,8 +39,7 @@ export function mockStreamFileSystem(files?: Record): MockedFile }), createReadStream: jest.fn().mockImplementation((p: string) => { - const fullPath = path.resolve(p); - const file = fs.files[fullPath]; + const file = fs.files[p]; if (!file) { throw new Error(`File ${p} does not exist`); } diff --git a/packages/node/tests/breadcrumbs/FileBreadcrumbsStorage.spec.ts b/packages/node/tests/breadcrumbs/FileBreadcrumbsStorage.spec.ts index 2fc66bb2..862fde0f 100644 --- a/packages/node/tests/breadcrumbs/FileBreadcrumbsStorage.spec.ts +++ b/packages/node/tests/breadcrumbs/FileBreadcrumbsStorage.spec.ts @@ -3,7 +3,7 @@ import assert from 'assert'; import { Readable } from 'stream'; import { promisify } from 'util'; import { FileBreadcrumbsStorage } from '../../src/breadcrumbs/FileBreadcrumbsStorage.js'; -import { mockStreamFileSystem } from '../_mocks/fileSystem.js'; +import { mockStreamFileSystem } from '../_mocks/storage.js'; async function readToEnd(readable: Readable) { return new Promise((resolve, reject) => { @@ -34,7 +34,7 @@ const nextTick = promisify(process.nextTick); describe('FileBreadcrumbsStorage', () => { it('should return added breadcrumbs', async () => { const fs = mockStreamFileSystem(); - const session = new SessionFiles(fs, '.', 'sessionId'); + const session = new SessionFiles(fs, 'sessionId'); const breadcrumbs: RawBreadcrumb[] = [ { @@ -108,7 +108,7 @@ describe('FileBreadcrumbsStorage', () => { it('should return added breadcrumbs in two attachments', async () => { const fs = mockStreamFileSystem(); - const session = new SessionFiles(fs, '.', 'sessionId'); + const session = new SessionFiles(fs, 'sessionId'); const breadcrumbs: RawBreadcrumb[] = [ { @@ -190,7 +190,7 @@ describe('FileBreadcrumbsStorage', () => { it('should return no more than maximumBreadcrumbs breadcrumbs', async () => { const fs = mockStreamFileSystem(); - const session = new SessionFiles(fs, '.', 'sessionId'); + const session = new SessionFiles(fs, 'sessionId'); const breadcrumbs: RawBreadcrumb[] = [ { @@ -262,7 +262,7 @@ describe('FileBreadcrumbsStorage', () => { it('should return breadcrumbs up to the json size', async () => { const fs = mockStreamFileSystem(); - const session = new SessionFiles(fs, '.', 'sessionId'); + const session = new SessionFiles(fs, 'sessionId'); const breadcrumbs: RawBreadcrumb[] = [ { @@ -330,7 +330,7 @@ describe('FileBreadcrumbsStorage', () => { it('should return attachments with a valid name from getAttachments', async () => { const fs = mockStreamFileSystem(); - const session = new SessionFiles(fs, '.', 'sessionId'); + const session = new SessionFiles(fs, 'sessionId'); const breadcrumbs: RawBreadcrumb[] = [ { @@ -374,7 +374,7 @@ describe('FileBreadcrumbsStorage', () => { it('should return attachments with a valid name from getAttachmentProviders', async () => { const fs = mockStreamFileSystem(); - const session = new SessionFiles(fs, '.', 'sessionId'); + const session = new SessionFiles(fs, 'sessionId'); const breadcrumbs: RawBreadcrumb[] = [ { diff --git a/packages/node/tests/streams/fileChunkSink.spec.ts b/packages/node/tests/streams/fileChunkSink.spec.ts index d35b94dd..9d69027a 100644 --- a/packages/node/tests/streams/fileChunkSink.spec.ts +++ b/packages/node/tests/streams/fileChunkSink.spec.ts @@ -1,7 +1,6 @@ -import path from 'path'; import { Writable } from 'stream'; import { FileChunkSink } from '../../src/streams/fileChunkSink.js'; -import { mockStreamFileSystem } from '../_mocks/fileSystem.js'; +import { mockStreamFileSystem } from '../_mocks/storage.js'; function writeAndClose(stream: Writable, value: string) { return new Promise((resolve, reject) => { @@ -20,7 +19,7 @@ describe('fileChunkSink', () => { it('should create a filestream with name from filename', async () => { const fs = mockStreamFileSystem(); const filename = 'abc'; - const sink = new FileChunkSink({ file: () => filename, maxFiles: Infinity, fs }); + const sink = new FileChunkSink({ file: () => filename, maxFiles: Infinity, storage: fs }); const stream = sink.getSink()(0); expect(stream.path).toEqual(filename); @@ -28,8 +27,7 @@ describe('fileChunkSink', () => { it('should create a filestream each time it is called', async () => { const fs = mockStreamFileSystem(); - const dir = 'test'; - const sink = new FileChunkSink({ file: (n) => path.join(dir, n.toString()), maxFiles: Infinity, fs }); + const sink = new FileChunkSink({ file: (n) => n.toString(), maxFiles: Infinity, storage: fs }); const expected = [0, 2, 5]; for (const n of expected) { @@ -37,15 +35,14 @@ describe('fileChunkSink', () => { await writeAndClose(stream, 'a'); } - const actual = await fs.readDir(dir); + const actual = [...fs.keysSync()]; expect(actual.sort(sortString)).toEqual(expected.map((e) => e.toString()).sort(sortString)); }); it('should remove previous files if count exceeds maxFiles', async () => { const fs = mockStreamFileSystem(); - const dir = 'test'; const maxFiles = 3; - const sink = new FileChunkSink({ file: (n) => path.join(dir, n.toString()), maxFiles, fs }); + const sink = new FileChunkSink({ file: (n) => n.toString(), maxFiles, storage: fs }); const files = [0, 2, 5, 6, 79, 81, 38, -1, 3]; const expected = files.slice(-maxFiles); @@ -54,7 +51,7 @@ describe('fileChunkSink', () => { await writeAndClose(stream, 'a'); } - const actual = await fs.readDir(dir); + const actual = [...fs.keysSync()]; expect(actual.sort(sortString)).toEqual(expected.map((e) => e.toString()).sort(sortString)); }); }); From a050bb3c938bb6e00b6771bf2b9cfa9ff748e3e9 Mon Sep 17 00:00:00 2001 From: Sebastian Alex Date: Fri, 27 Jun 2025 11:51:37 +0200 Subject: [PATCH 2/5] node: amend BacktraceFileAttachment to implement BacktraceAttachment [INT-355] --- packages/node/src/attachment/BacktraceFileAttachment.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/node/src/attachment/BacktraceFileAttachment.ts b/packages/node/src/attachment/BacktraceFileAttachment.ts index ec87e344..aed97062 100644 --- a/packages/node/src/attachment/BacktraceFileAttachment.ts +++ b/packages/node/src/attachment/BacktraceFileAttachment.ts @@ -1,10 +1,10 @@ -import { BacktraceSyncStorage, BacktraceFileAttachment as CoreBacktraceFileAttachment } from '@backtrace/sdk-core'; +import { BacktraceAttachment, BacktraceSyncStorage } from '@backtrace/sdk-core'; import fs from 'fs'; import path from 'path'; import { Readable } from 'stream'; import { BacktraceStreamStorage } from '../storage/BacktraceStorage.js'; -export class BacktraceFileAttachment implements CoreBacktraceFileAttachment { +export class BacktraceFileAttachment implements BacktraceAttachment { public readonly name: string; constructor( From aa485a0173bc48f6880703bafda17275d2a0d7de Mon Sep 17 00:00:00 2001 From: Sebastian Alex Date: Fri, 27 Jun 2025 12:08:00 +0200 Subject: [PATCH 3/5] node: add BacktraceFileAttachmentFactory [INT-355] --- packages/node/src/BacktraceClient.ts | 29 ++++++++++++++----- .../src/attachment/BacktraceFileAttachment.ts | 29 +++++++++++-------- .../src/attachment/FileAttachmentsManager.ts | 17 +++++++---- .../src/attachment/transformAttachments.ts | 18 +++++++----- .../src/breadcrumbs/FileBreadcrumbsStorage.ts | 15 +++++----- .../src/builder/BacktraceClientBuilder.ts | 11 +++++-- .../AttachmentBacktraceDatabaseRecord.ts | 8 ++--- ...tBacktraceDatabaseRecordWithAttachments.ts | 8 ++--- 8 files changed, 82 insertions(+), 53 deletions(-) diff --git a/packages/node/src/BacktraceClient.ts b/packages/node/src/BacktraceClient.ts index 226303be..6d97867b 100644 --- a/packages/node/src/BacktraceClient.ts +++ b/packages/node/src/BacktraceClient.ts @@ -11,6 +11,10 @@ import nodeFs from 'fs'; import { BacktraceConfiguration, BacktraceSetupConfiguration } from './BacktraceConfiguration.js'; import { BacktraceNodeRequestHandler } from './BacktraceNodeRequestHandler.js'; import { AGENT } from './agentDefinition.js'; +import { + BacktraceFileAttachmentFactory, + NodeFsBacktraceFileAttachmentFactory, +} from './attachment/BacktraceFileAttachment.js'; import { FileAttachmentsManager } from './attachment/FileAttachmentsManager.js'; import { transformAttachment } from './attachment/transformAttachments.js'; import { FileBreadcrumbsStorage } from './breadcrumbs/FileBreadcrumbsStorage.js'; @@ -37,6 +41,7 @@ export class BacktraceClient extends BacktraceCoreClient private _listeners: Record = {}; protected readonly storageFactory: BacktraceStorageModuleFactory; + protected readonly fileAttachmentFactory: BacktraceFileAttachmentFactory; protected readonly fs: typeof nodeFs; protected get databaseNodeFsStorage() { @@ -46,6 +51,7 @@ export class BacktraceClient extends BacktraceCoreClient constructor(clientSetup: BacktraceNodeClientSetup) { const storageFactory = clientSetup.storageFactory ?? new NodeFsBacktraceStorageModuleFactory(); const fs = clientSetup.fs ?? nodeFs; + const fileAttachmentFactory = new NodeFsBacktraceFileAttachmentFactory(fs); const storage = clientSetup.database?.storage ?? (clientSetup.options.database?.enable @@ -72,8 +78,8 @@ export class BacktraceClient extends BacktraceCoreClient ...clientSetup.database?.recordSenders?.(submission), }), recordSerializers: { - report: new ReportBacktraceDatabaseRecordWithAttachmentsSerializer(storage), - attachment: new AttachmentBacktraceDatabaseRecordSerializer(fs), + report: new ReportBacktraceDatabaseRecordWithAttachmentsSerializer(fileAttachmentFactory), + attachment: new AttachmentBacktraceDatabaseRecordSerializer(fileAttachmentFactory), ...clientSetup.database?.recordSerializers, }, } @@ -81,21 +87,24 @@ export class BacktraceClient extends BacktraceCoreClient ...clientSetup, options: { ...clientSetup.options, - attachments: clientSetup.options.attachments?.map(transformAttachment), + attachments: clientSetup.options.attachments?.map(transformAttachment(fileAttachmentFactory)), }, }); this.storageFactory = storageFactory; + this.fileAttachmentFactory = fileAttachmentFactory; this.fs = fs; const breadcrumbsManager = this.modules.get(BreadcrumbsManager); if (breadcrumbsManager && this.sessionFiles && storage) { - breadcrumbsManager.setStorage(FileBreadcrumbsStorage.factory(this.sessionFiles, storage)); + breadcrumbsManager.setStorage( + FileBreadcrumbsStorage.factory(this.sessionFiles, storage, this.fileAttachmentFactory), + ); } if (this.sessionFiles && storage && clientSetup.options.database?.captureNativeCrashes) { this.addModule(FileAttributeManager, FileAttributeManager.create(storage)); - this.addModule(FileAttachmentsManager, FileAttachmentsManager.create(storage)); + this.addModule(FileAttachmentsManager, FileAttachmentsManager.create(storage, fileAttachmentFactory)); } } @@ -346,12 +355,18 @@ export class BacktraceClient extends BacktraceCoreClient for (const [recordName, report, session] of reports) { try { if (session) { - report.attachments.push(...FileBreadcrumbsStorage.getSessionAttachments(session, storage)); + report.attachments.push( + ...FileBreadcrumbsStorage.getSessionAttachments(session, this.fileAttachmentFactory), + ); const fileAttributes = FileAttributeManager.createFromSession(session, storage); Object.assign(report.attributes, await fileAttributes.get()); - const fileAttachments = FileAttachmentsManager.createFromSession(session, storage); + const fileAttachments = FileAttachmentsManager.createFromSession( + session, + storage, + this.fileAttachmentFactory, + ); report.attachments.push(...(await fileAttachments.get())); report.attributes['application.session'] = session.sessionId; diff --git a/packages/node/src/attachment/BacktraceFileAttachment.ts b/packages/node/src/attachment/BacktraceFileAttachment.ts index aed97062..269decea 100644 --- a/packages/node/src/attachment/BacktraceFileAttachment.ts +++ b/packages/node/src/attachment/BacktraceFileAttachment.ts @@ -1,8 +1,7 @@ -import { BacktraceAttachment, BacktraceSyncStorage } from '@backtrace/sdk-core'; -import fs from 'fs'; +import { BacktraceAttachment } from '@backtrace/sdk-core'; +import nodeFs from 'fs'; import path from 'path'; import { Readable } from 'stream'; -import { BacktraceStreamStorage } from '../storage/BacktraceStorage.js'; export class BacktraceFileAttachment implements BacktraceAttachment { public readonly name: string; @@ -10,22 +9,28 @@ export class BacktraceFileAttachment implements BacktraceAttachment { constructor( public readonly filePath: string, name?: string, - private readonly _fs: typeof fs | (BacktraceSyncStorage & BacktraceStreamStorage) = fs, + private readonly _fs: typeof nodeFs = nodeFs, ) { this.name = name ?? path.basename(this.filePath); } public get(): Readable | undefined { - if ('hasSync' in this._fs) { - if (!this._fs.hasSync(this.filePath)) { - return undefined; - } - } else { - if (!this._fs.existsSync(this.filePath)) { - return undefined; - } + if (!this._fs.existsSync(this.filePath)) { + return undefined; } return this._fs.createReadStream(this.filePath); } } + +export interface BacktraceFileAttachmentFactory { + create(filePath: string, name?: string): BacktraceFileAttachment; +} + +export class NodeFsBacktraceFileAttachmentFactory implements BacktraceFileAttachmentFactory { + constructor(private readonly _fs: typeof nodeFs = nodeFs) {} + + public create(filePath: string, name?: string): BacktraceFileAttachment { + return new BacktraceFileAttachment(filePath, name, this._fs); + } +} diff --git a/packages/node/src/attachment/FileAttachmentsManager.ts b/packages/node/src/attachment/FileAttachmentsManager.ts index 1a54373b..83a24f69 100644 --- a/packages/node/src/attachment/FileAttachmentsManager.ts +++ b/packages/node/src/attachment/FileAttachmentsManager.ts @@ -5,7 +5,7 @@ import { BacktraceStorage, SessionFiles, } from '@backtrace/sdk-core'; -import { BacktraceFileAttachment } from './BacktraceFileAttachment.js'; +import { BacktraceFileAttachment, BacktraceFileAttachmentFactory } from './BacktraceFileAttachment.js'; const ATTACHMENT_FILE_NAME = 'bt-attachments'; @@ -16,16 +16,21 @@ export class FileAttachmentsManager implements BacktraceModule { constructor( private readonly _storage: BacktraceStorage, + private readonly _fileAttachmentFactory: BacktraceFileAttachmentFactory, private _fileName?: string, ) {} - public static create(storage: BacktraceStorage) { - return new FileAttachmentsManager(storage); + public static create(storage: BacktraceStorage, fileAttachmentFactory: BacktraceFileAttachmentFactory) { + return new FileAttachmentsManager(storage, fileAttachmentFactory); } - public static createFromSession(sessionFiles: SessionFiles, fileSystem: BacktraceStorage) { + public static createFromSession( + sessionFiles: SessionFiles, + fileSystem: BacktraceStorage, + fileAttachmentFactory: BacktraceFileAttachmentFactory, + ) { const fileName = sessionFiles.getFileName(ATTACHMENT_FILE_NAME); - return new FileAttachmentsManager(fileSystem, fileName); + return new FileAttachmentsManager(fileSystem, fileAttachmentFactory, fileName); } public initialize(): void { @@ -61,7 +66,7 @@ export class FileAttachmentsManager implements BacktraceModule { return []; } const attachments = JSON.parse(content) as SavedAttachment[]; - return attachments.map(([path, name]) => new BacktraceFileAttachment(path, name)); + return attachments.map(([path, name]) => this._fileAttachmentFactory.create(path, name)); } catch { return []; } diff --git a/packages/node/src/attachment/transformAttachments.ts b/packages/node/src/attachment/transformAttachments.ts index fe3f1907..f98a189b 100644 --- a/packages/node/src/attachment/transformAttachments.ts +++ b/packages/node/src/attachment/transformAttachments.ts @@ -1,15 +1,17 @@ import { BacktraceAttachment } from '@backtrace/sdk-core'; import { Readable } from 'stream'; import { BacktraceSetupConfiguration } from '../BacktraceConfiguration.js'; -import { BacktraceFileAttachment } from './BacktraceFileAttachment.js'; +import { BacktraceFileAttachmentFactory } from './BacktraceFileAttachment.js'; /** * Transform a client attachment into the attachment model. */ -export function transformAttachment( - attachment: NonNullable[number] | BacktraceAttachment, -): BacktraceAttachment { - return typeof attachment === 'string' - ? new BacktraceFileAttachment(attachment) - : (attachment as BacktraceAttachment); -} +export const transformAttachment = + (fileAttachmentFactory: BacktraceFileAttachmentFactory) => + ( + attachment: NonNullable[number] | BacktraceAttachment, + ): BacktraceAttachment => { + return typeof attachment === 'string' + ? fileAttachmentFactory.create(attachment) + : (attachment as BacktraceAttachment); + }; diff --git a/packages/node/src/breadcrumbs/FileBreadcrumbsStorage.ts b/packages/node/src/breadcrumbs/FileBreadcrumbsStorage.ts index 81df4c87..5e7d9c33 100644 --- a/packages/node/src/breadcrumbs/FileBreadcrumbsStorage.ts +++ b/packages/node/src/breadcrumbs/FileBreadcrumbsStorage.ts @@ -16,7 +16,7 @@ import { } from '@backtrace/sdk-core'; import path from 'path'; import { Readable, Writable } from 'stream'; -import { BacktraceFileAttachment } from '../attachment/index.js'; +import { BacktraceFileAttachmentFactory } from '../attachment/index.js'; import { BacktraceStreamStorage } from '../storage/BacktraceStorage.js'; import { chunkifier, ChunkSplitterFactory } from '../streams/chunkifier.js'; import { combinedChunkSplitter } from '../streams/combinedChunkSplitter.js'; @@ -39,6 +39,7 @@ export class FileBreadcrumbsStorage implements BreadcrumbsStorage { constructor( session: SessionFiles, private readonly _storage: BacktraceStorage & BacktraceSyncStorage & BacktraceStreamStorage, + private readonly _fileAttachmentFactory: BacktraceFileAttachmentFactory, private readonly _limits: BreadcrumbsStorageLimits, ) { const splitters: ChunkSplitterFactory[] = []; @@ -73,28 +74,26 @@ export class FileBreadcrumbsStorage implements BreadcrumbsStorage { }); } - public static getSessionAttachments( - session: SessionFiles, - storage?: BacktraceStorage & BacktraceSyncStorage & BacktraceStreamStorage, - ) { + public static getSessionAttachments(session: SessionFiles, fileAttachmentFactory: BacktraceFileAttachmentFactory) { const files = session .getSessionFiles() .filter((f) => path.basename(f).startsWith(FILE_PREFIX)) .slice(0, 2); - return files.map((file) => new BacktraceFileAttachment(file, path.basename(file), storage)); + return files.map((file) => fileAttachmentFactory.create(file, path.basename(file))); } public static factory( session: SessionFiles, storage: BacktraceStorage & BacktraceSyncStorage & BacktraceStreamStorage, + fileAttachmentFactory: BacktraceFileAttachmentFactory, ): BreadcrumbsStorageFactory { - return ({ limits }) => new FileBreadcrumbsStorage(session, storage, limits); + return ({ limits }) => new FileBreadcrumbsStorage(session, storage, fileAttachmentFactory, limits); } public getAttachments(): BacktraceAttachment[] { const files = [...this._sink.files].map((f) => f.path.toString('utf-8')); - return files.map((f) => new BacktraceFileAttachment(f, path.basename(f), this._storage)); + return files.map((f) => this._fileAttachmentFactory.create(f, path.basename(f))); } public getAttachmentProviders(): BacktraceAttachmentProvider[] { diff --git a/packages/node/src/builder/BacktraceClientBuilder.ts b/packages/node/src/builder/BacktraceClientBuilder.ts index f2467270..30621509 100644 --- a/packages/node/src/builder/BacktraceClientBuilder.ts +++ b/packages/node/src/builder/BacktraceClientBuilder.ts @@ -1,5 +1,4 @@ import { BacktraceCoreClientBuilder } from '@backtrace/sdk-core'; -import { transformAttachment } from '../attachment/transformAttachments.js'; import { ApplicationInformationAttributeProvider, LinuxProcessStatusAttributeProvider, @@ -9,18 +8,22 @@ import { ProcessStatusAttributeProvider, } from '../attributes/index.js'; import { BacktraceClient } from '../BacktraceClient.js'; +import { BacktraceSetupConfiguration } from '../BacktraceConfiguration.js'; import { BacktraceStorageModuleFactory } from '../storage/BacktraceStorageModuleFactory.js'; import { BacktraceClientSetup, BacktraceNodeClientSetup } from './BacktraceClientSetup.js'; export class BacktraceClientBuilder extends BacktraceCoreClientBuilder { + private attachments: BacktraceSetupConfiguration['attachments']; private storageFactory?: BacktraceStorageModuleFactory; constructor(clientSetup: BacktraceNodeClientSetup) { super({ ...clientSetup, - options: { ...clientSetup.options, attachments: clientSetup.options.attachments?.map(transformAttachment) }, + options: { ...clientSetup.options, attachments: [] }, }); + this.attachments = clientSetup.options.attachments; + this.addAttributeProvider(new ApplicationInformationAttributeProvider()); this.addAttributeProvider(new ProcessStatusAttributeProvider()); this.addAttributeProvider(new MachineAttributeProvider()); @@ -38,6 +41,10 @@ export class BacktraceClientBuilder extends BacktraceCoreClientBuilder { @@ -24,7 +23,7 @@ export class AttachmentBacktraceDatabaseRecordSerializer { public readonly type = 'attachment'; - constructor(private readonly _fs: typeof nodeFs) {} + constructor(private readonly _fileAttachmentFactory: BacktraceFileAttachmentFactory) {} public save(record: AttachmentBacktraceDatabaseRecord): string | undefined { if (!isFileAttachment(record.attachment)) { @@ -46,10 +45,9 @@ export class AttachmentBacktraceDatabaseRecordSerializer return undefined; } - const attachment = new BacktraceFileAttachment( + const attachment = this._fileAttachmentFactory.create( attachmentRecord.attachment.filePath, attachmentRecord.attachment.name, - this._fs, ); return { diff --git a/packages/node/src/database/ReportBacktraceDatabaseRecordWithAttachments.ts b/packages/node/src/database/ReportBacktraceDatabaseRecordWithAttachments.ts index 5d32cff8..bedf7e88 100644 --- a/packages/node/src/database/ReportBacktraceDatabaseRecordWithAttachments.ts +++ b/packages/node/src/database/ReportBacktraceDatabaseRecordWithAttachments.ts @@ -5,7 +5,6 @@ import { BacktraceReportSubmission, BacktraceReportSubmissionResult, BacktraceSubmitResponse, - BacktraceSyncStorage, DefaultReportBacktraceDatabaseRecordFactory, jsonEscaper, ReportBacktraceDatabaseRecord, @@ -13,9 +12,8 @@ import { } from '@backtrace/sdk-core'; import { BacktraceDatabaseRecordSender } from '@backtrace/sdk-core/lib/modules/database/BacktraceDatabaseRecordSender.js'; import { BacktraceDatabaseRecordSerializer } from '@backtrace/sdk-core/lib/modules/database/BacktraceDatabaseRecordSerializer.js'; -import { BacktraceFileAttachment } from '../attachment/BacktraceFileAttachment.js'; +import { BacktraceFileAttachment, BacktraceFileAttachmentFactory } from '../attachment/BacktraceFileAttachment.js'; import { isFileAttachment } from '../attachment/isFileAttachment.js'; -import { BacktraceStreamStorage } from '../storage/BacktraceStorage.js'; export interface ReportBacktraceDatabaseRecordWithAttachments extends ReportBacktraceDatabaseRecord { readonly attachments: BacktraceAttachment[]; @@ -26,7 +24,7 @@ export class ReportBacktraceDatabaseRecordWithAttachmentsSerializer { public readonly type = 'report'; - constructor(private readonly _storage: BacktraceSyncStorage & BacktraceStreamStorage) {} + constructor(private readonly _fileAttachmentFactory: BacktraceFileAttachmentFactory) {} public save(record: ReportBacktraceDatabaseRecordWithAttachments): string { return JSON.stringify( @@ -51,7 +49,7 @@ export class ReportBacktraceDatabaseRecordWithAttachmentsSerializer ...reportRecord, attachments: reportRecord.attachments .filter(isFileAttachment) - .map((a) => new BacktraceFileAttachment(a.filePath, a.name, this._storage)), + .map((a) => this._fileAttachmentFactory.create(a.filePath, a.name)), }; } From 03f43002199879d380b7e3264b4590d5c9d561b1 Mon Sep 17 00:00:00 2001 From: Sebastian Alex Date: Fri, 27 Jun 2025 14:26:17 +0200 Subject: [PATCH 4/5] node: use SessionId in AttachmentBacktraceDatabaseRecordFactory --- .../src/database/AttachmentBacktraceDatabaseRecord.ts | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/packages/node/src/database/AttachmentBacktraceDatabaseRecord.ts b/packages/node/src/database/AttachmentBacktraceDatabaseRecord.ts index af93685b..d6136c59 100644 --- a/packages/node/src/database/AttachmentBacktraceDatabaseRecord.ts +++ b/packages/node/src/database/AttachmentBacktraceDatabaseRecord.ts @@ -6,6 +6,7 @@ import { BacktraceReportSubmissionResult, BacktraceSubmitResponse, jsonEscaper, + SessionId, } from '@backtrace/sdk-core'; import { BacktraceDatabaseRecordSender } from '@backtrace/sdk-core/lib/modules/database/BacktraceDatabaseRecordSender.js'; import { BacktraceDatabaseRecordSerializer } from '@backtrace/sdk-core/lib/modules/database/BacktraceDatabaseRecordSerializer.js'; @@ -15,7 +16,7 @@ import { isFileAttachment } from '../attachment/isFileAttachment.js'; export interface AttachmentBacktraceDatabaseRecord extends BacktraceDatabaseRecord<'attachment'> { readonly rxid: string; readonly attachment: BacktraceAttachment; - readonly sessionId: string; + readonly sessionId: SessionId; } export class AttachmentBacktraceDatabaseRecordSerializer @@ -82,7 +83,11 @@ export class AttachmentBacktraceDatabaseRecordFactory { return new AttachmentBacktraceDatabaseRecordFactory(new BacktraceDatabaseRecordFactory()); } - public create(rxid: string, sessionId: string, attachment: BacktraceAttachment): AttachmentBacktraceDatabaseRecord { + public create( + rxid: string, + sessionId: SessionId, + attachment: BacktraceAttachment, + ): AttachmentBacktraceDatabaseRecord { const record: AttachmentBacktraceDatabaseRecord = { ...this._reportFactory.create('attachment'), sessionId, From 23c058ac3d7fb7492e324b52e95bf27162d1f09e Mon Sep 17 00:00:00 2001 From: Sebastian Alex Date: Fri, 27 Jun 2025 14:38:00 +0200 Subject: [PATCH 5/5] node: limit used nodeFs functions, fix unit tests [INT-355] --- packages/node/src/BacktraceClient.ts | 3 +- .../src/attachment/BacktraceFileAttachment.ts | 5 ++- .../node/src/builder/BacktraceClientSetup.ts | 4 +- packages/node/src/storage/BacktraceStorage.ts | 3 +- .../src/storage/NodeFsBacktraceStorage.ts | 7 ++-- packages/node/src/storage/nodeFs.ts | 19 +++++++++ packages/node/tests/_mocks/storage.ts | 23 ++++++----- .../FileBreadcrumbsStorage.spec.ts | 39 ++++++++++--------- .../node/tests/streams/fileChunkSink.spec.ts | 8 ++-- 9 files changed, 70 insertions(+), 41 deletions(-) create mode 100644 packages/node/src/storage/nodeFs.ts diff --git a/packages/node/src/BacktraceClient.ts b/packages/node/src/BacktraceClient.ts index 6d97867b..86c5eb59 100644 --- a/packages/node/src/BacktraceClient.ts +++ b/packages/node/src/BacktraceClient.ts @@ -36,13 +36,14 @@ import { assertDatabasePath } from './database/utils.js'; import { BacktraceStorageModule } from './storage/BacktraceStorage.js'; import { BacktraceStorageModuleFactory } from './storage/BacktraceStorageModuleFactory.js'; import { NodeFsBacktraceStorageModuleFactory } from './storage/NodeFsBacktraceStorage.js'; +import { NodeFs } from './storage/nodeFs.js'; export class BacktraceClient extends BacktraceCoreClient { private _listeners: Record = {}; protected readonly storageFactory: BacktraceStorageModuleFactory; protected readonly fileAttachmentFactory: BacktraceFileAttachmentFactory; - protected readonly fs: typeof nodeFs; + protected readonly fs: NodeFs; protected get databaseNodeFsStorage() { return this.databaseStorage as BacktraceStorageModule | undefined; diff --git a/packages/node/src/attachment/BacktraceFileAttachment.ts b/packages/node/src/attachment/BacktraceFileAttachment.ts index 269decea..424bce33 100644 --- a/packages/node/src/attachment/BacktraceFileAttachment.ts +++ b/packages/node/src/attachment/BacktraceFileAttachment.ts @@ -2,6 +2,7 @@ import { BacktraceAttachment } from '@backtrace/sdk-core'; import nodeFs from 'fs'; import path from 'path'; import { Readable } from 'stream'; +import { NodeFs } from '../storage/nodeFs.js'; export class BacktraceFileAttachment implements BacktraceAttachment { public readonly name: string; @@ -9,7 +10,7 @@ export class BacktraceFileAttachment implements BacktraceAttachment { constructor( public readonly filePath: string, name?: string, - private readonly _fs: typeof nodeFs = nodeFs, + private readonly _fs: Pick = nodeFs, ) { this.name = name ?? path.basename(this.filePath); } @@ -28,7 +29,7 @@ export interface BacktraceFileAttachmentFactory { } export class NodeFsBacktraceFileAttachmentFactory implements BacktraceFileAttachmentFactory { - constructor(private readonly _fs: typeof nodeFs = nodeFs) {} + constructor(private readonly _fs: Pick = nodeFs) {} public create(filePath: string, name?: string): BacktraceFileAttachment { return new BacktraceFileAttachment(filePath, name, this._fs); diff --git a/packages/node/src/builder/BacktraceClientSetup.ts b/packages/node/src/builder/BacktraceClientSetup.ts index c63ced7e..342af79d 100644 --- a/packages/node/src/builder/BacktraceClientSetup.ts +++ b/packages/node/src/builder/BacktraceClientSetup.ts @@ -1,15 +1,15 @@ import { PartialCoreClientSetup } from '@backtrace/sdk-core'; -import nodeFs from 'fs'; import { BacktraceSetupConfiguration } from '../BacktraceConfiguration.js'; import { BacktraceStorageModule } from '../storage/BacktraceStorage.js'; import { BacktraceStorageModuleFactory } from '../storage/BacktraceStorageModuleFactory.js'; +import { NodeFs } from '../storage/nodeFs.js'; export interface BacktraceClientSetup extends PartialCoreClientSetup<'sdkOptions' | 'requestHandler'> {} export type BacktraceNodeClientSetup = Omit & { readonly options: BacktraceSetupConfiguration; readonly storageFactory?: BacktraceStorageModuleFactory; - readonly fs?: typeof nodeFs; + readonly fs?: NodeFs; readonly database?: Omit, 'storage'> & { readonly storage?: BacktraceStorageModule; }; diff --git a/packages/node/src/storage/BacktraceStorage.ts b/packages/node/src/storage/BacktraceStorage.ts index 1cba1640..88934965 100644 --- a/packages/node/src/storage/BacktraceStorage.ts +++ b/packages/node/src/storage/BacktraceStorage.ts @@ -1,6 +1,7 @@ import { BacktraceStorageModule as CoreBacktraceStorageModule } from '@backtrace/sdk-core'; import nodeFs from 'fs'; import { BacktraceConfiguration } from '../BacktraceConfiguration.js'; +import { NodeFs } from './nodeFs.js'; export interface ReadonlyBacktraceStreamStorage { createReadStream(key: string): nodeFs.ReadStream; @@ -15,5 +16,5 @@ export type BacktraceStorageModule = CoreBacktraceStorageModule & { + readonly promises: Pick<(typeof nodeFs)['promises'], 'readdir' | 'stat' | 'writeFile' | 'unlink' | 'readFile'>; +}; diff --git a/packages/node/tests/_mocks/storage.ts b/packages/node/tests/_mocks/storage.ts index 99f6c9bb..d92944b1 100644 --- a/packages/node/tests/_mocks/storage.ts +++ b/packages/node/tests/_mocks/storage.ts @@ -1,20 +1,25 @@ // eslint-disable-next-line @typescript-eslint/ban-ts-comment // @ts-ignore The following import fails due to missing extension, but it cannot have one (it imports a .ts file) import { mockBacktraceStorage } from '@backtrace/sdk-core/tests/_mocks/storage'; + +import { BacktraceStorageModule } from '@backtrace/sdk-core'; import type { MockedBacktraceStorage } from '@backtrace/sdk-core/tests/_mocks/storage.js'; import { ReadStream, WriteStream } from 'fs'; import { Readable, Writable } from 'stream'; import { NodeFsBacktraceStorage } from '../../src/storage/NodeFsBacktraceStorage.js'; +import { NodeFs } from '../../src/storage/nodeFs.js'; + +type MockedFs = Pick; -export function mockStreamFileSystem( +export function mockNodeStorageAndFs( files?: Record, -): MockedBacktraceStorage> { - const fs = mockBacktraceStorage(files); +): MockedBacktraceStorage> { + const storage = mockBacktraceStorage(files) as MockedBacktraceStorage>; return { - ...fs, + ...storage, - ofPath: jest.fn().mockImplementation(() => mockStreamFileSystem()), + existsSync: jest.fn().mockImplementation((p: string) => p in storage.files), createWriteStream: jest.fn().mockImplementation((p: string) => { const writable = new Writable({ @@ -25,10 +30,10 @@ export function mockStreamFileSystem( ? chunk : String(chunk).toString(); - if (!fs.files[p]) { - fs.files[p] = str; + if (!storage.files[p]) { + storage.files[p] = str; } else { - fs.files[p] += str; + storage.files[p] += str; } callback && callback(); @@ -39,7 +44,7 @@ export function mockStreamFileSystem( }), createReadStream: jest.fn().mockImplementation((p: string) => { - const file = fs.files[p]; + const file = storage.files[p]; if (!file) { throw new Error(`File ${p} does not exist`); } diff --git a/packages/node/tests/breadcrumbs/FileBreadcrumbsStorage.spec.ts b/packages/node/tests/breadcrumbs/FileBreadcrumbsStorage.spec.ts index 862fde0f..4b521cde 100644 --- a/packages/node/tests/breadcrumbs/FileBreadcrumbsStorage.spec.ts +++ b/packages/node/tests/breadcrumbs/FileBreadcrumbsStorage.spec.ts @@ -3,7 +3,8 @@ import assert from 'assert'; import { Readable } from 'stream'; import { promisify } from 'util'; import { FileBreadcrumbsStorage } from '../../src/breadcrumbs/FileBreadcrumbsStorage.js'; -import { mockStreamFileSystem } from '../_mocks/storage.js'; +import { NodeFsBacktraceFileAttachmentFactory } from '../../src/index.js'; +import { mockNodeStorageAndFs } from '../_mocks/storage.js'; async function readToEnd(readable: Readable) { return new Promise((resolve, reject) => { @@ -33,8 +34,8 @@ const nextTick = promisify(process.nextTick); describe('FileBreadcrumbsStorage', () => { it('should return added breadcrumbs', async () => { - const fs = mockStreamFileSystem(); - const session = new SessionFiles(fs, 'sessionId'); + const fs = mockNodeStorageAndFs(); + const session = new SessionFiles(fs, { id: 'sessionId', timestamp: Date.now() }); const breadcrumbs: RawBreadcrumb[] = [ { @@ -86,7 +87,7 @@ describe('FileBreadcrumbsStorage', () => { }, ]; - const storage = new FileBreadcrumbsStorage(session, fs, { + const storage = new FileBreadcrumbsStorage(session, fs, new NodeFsBacktraceFileAttachmentFactory(fs), { maximumBreadcrumbs: 100, }); @@ -107,8 +108,8 @@ describe('FileBreadcrumbsStorage', () => { }); it('should return added breadcrumbs in two attachments', async () => { - const fs = mockStreamFileSystem(); - const session = new SessionFiles(fs, 'sessionId'); + const fs = mockNodeStorageAndFs(); + const session = new SessionFiles(fs, { id: 'sessionId', timestamp: Date.now() }); const breadcrumbs: RawBreadcrumb[] = [ { @@ -163,7 +164,7 @@ describe('FileBreadcrumbsStorage', () => { }, ]; - const storage = new FileBreadcrumbsStorage(session, fs, { + const storage = new FileBreadcrumbsStorage(session, fs, new NodeFsBacktraceFileAttachmentFactory(fs), { maximumBreadcrumbs: 4, }); @@ -189,8 +190,8 @@ describe('FileBreadcrumbsStorage', () => { }); it('should return no more than maximumBreadcrumbs breadcrumbs', async () => { - const fs = mockStreamFileSystem(); - const session = new SessionFiles(fs, 'sessionId'); + const fs = mockNodeStorageAndFs(); + const session = new SessionFiles(fs, { id: 'sessionId', timestamp: Date.now() }); const breadcrumbs: RawBreadcrumb[] = [ { @@ -235,7 +236,7 @@ describe('FileBreadcrumbsStorage', () => { }, ]; - const storage = new FileBreadcrumbsStorage(session, fs, { + const storage = new FileBreadcrumbsStorage(session, fs, new NodeFsBacktraceFileAttachmentFactory(fs), { maximumBreadcrumbs: 2, }); @@ -261,8 +262,8 @@ describe('FileBreadcrumbsStorage', () => { }); it('should return breadcrumbs up to the json size', async () => { - const fs = mockStreamFileSystem(); - const session = new SessionFiles(fs, 'sessionId'); + const fs = mockNodeStorageAndFs(); + const session = new SessionFiles(fs, { id: 'sessionId', timestamp: Date.now() }); const breadcrumbs: RawBreadcrumb[] = [ { @@ -302,7 +303,7 @@ describe('FileBreadcrumbsStorage', () => { }, ]; - const storage = new FileBreadcrumbsStorage(session, fs, { + const storage = new FileBreadcrumbsStorage(session, fs, new NodeFsBacktraceFileAttachmentFactory(fs), { maximumBreadcrumbs: 100, maximumTotalBreadcrumbsSize: JSON.stringify(expectedMain[0]).length + 10, }); @@ -329,8 +330,8 @@ describe('FileBreadcrumbsStorage', () => { }); it('should return attachments with a valid name from getAttachments', async () => { - const fs = mockStreamFileSystem(); - const session = new SessionFiles(fs, 'sessionId'); + const fs = mockNodeStorageAndFs(); + const session = new SessionFiles(fs, { id: 'sessionId', timestamp: Date.now() }); const breadcrumbs: RawBreadcrumb[] = [ { @@ -354,7 +355,7 @@ describe('FileBreadcrumbsStorage', () => { }, ]; - const storage = new FileBreadcrumbsStorage(session, fs, { + const storage = new FileBreadcrumbsStorage(session, fs, new NodeFsBacktraceFileAttachmentFactory(fs), { maximumBreadcrumbs: 4, }); @@ -373,8 +374,8 @@ describe('FileBreadcrumbsStorage', () => { }); it('should return attachments with a valid name from getAttachmentProviders', async () => { - const fs = mockStreamFileSystem(); - const session = new SessionFiles(fs, 'sessionId'); + const fs = mockNodeStorageAndFs(); + const session = new SessionFiles(fs, { id: 'sessionId', timestamp: Date.now() }); const breadcrumbs: RawBreadcrumb[] = [ { @@ -398,7 +399,7 @@ describe('FileBreadcrumbsStorage', () => { }, ]; - const storage = new FileBreadcrumbsStorage(session, fs, { + const storage = new FileBreadcrumbsStorage(session, fs, new NodeFsBacktraceFileAttachmentFactory(fs), { maximumBreadcrumbs: 4, }); diff --git a/packages/node/tests/streams/fileChunkSink.spec.ts b/packages/node/tests/streams/fileChunkSink.spec.ts index 9d69027a..a3e457ff 100644 --- a/packages/node/tests/streams/fileChunkSink.spec.ts +++ b/packages/node/tests/streams/fileChunkSink.spec.ts @@ -1,6 +1,6 @@ import { Writable } from 'stream'; import { FileChunkSink } from '../../src/streams/fileChunkSink.js'; -import { mockStreamFileSystem } from '../_mocks/storage.js'; +import { mockNodeStorageAndFs } from '../_mocks/storage.js'; function writeAndClose(stream: Writable, value: string) { return new Promise((resolve, reject) => { @@ -17,7 +17,7 @@ function sortString(a: string, b: string) { describe('fileChunkSink', () => { it('should create a filestream with name from filename', async () => { - const fs = mockStreamFileSystem(); + const fs = mockNodeStorageAndFs(); const filename = 'abc'; const sink = new FileChunkSink({ file: () => filename, maxFiles: Infinity, storage: fs }); @@ -26,7 +26,7 @@ describe('fileChunkSink', () => { }); it('should create a filestream each time it is called', async () => { - const fs = mockStreamFileSystem(); + const fs = mockNodeStorageAndFs(); const sink = new FileChunkSink({ file: (n) => n.toString(), maxFiles: Infinity, storage: fs }); const expected = [0, 2, 5]; @@ -40,7 +40,7 @@ describe('fileChunkSink', () => { }); it('should remove previous files if count exceeds maxFiles', async () => { - const fs = mockStreamFileSystem(); + const fs = mockNodeStorageAndFs(); const maxFiles = 3; const sink = new FileChunkSink({ file: (n) => n.toString(), maxFiles, storage: fs }); const files = [0, 2, 5, 6, 79, 81, 38, -1, 3];