From 9041efbd9b539d074e7f4e4b7fb1081d45b8cb39 Mon Sep 17 00:00:00 2001 From: Alexey Karimov Date: Mon, 12 Oct 2026 08:08:38 +0500 Subject: [PATCH 1/2] feat(core): user attachments on reports MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Public ReportAttachmentInput ({name, data, mimeType?, fileName?}), writable via ReportHandler.before (report.userAttachments / addReportAttachment) and UploadOptions.attachments. Mobile limits (3 x 3 MB) enforced at assembly; drops go to onError. Bundle: .attachment.bgsfile + manifest name/attrs (Android layout). 🤖 Generated with [Claude Code](https://claude.com/claude-code) --- packages/browser/src/index.ts | 2 + packages/bugsee/src/index.bun.ts | 1 + packages/bugsee/src/index.deno.ts | 1 + packages/bugsee/src/index.edge-light.ts | 1 + packages/bugsee/src/index.node.ts | 1 + packages/bugsee/src/index.ts | 1 + packages/bugsee/src/index.worker.ts | 1 + packages/bugsee/src/index.workerd.ts | 1 + packages/core/src/attachments.test.ts | 181 ++++++++++++++++++ packages/core/src/attachments.ts | 133 +++++++++++++ packages/core/src/bundle-assembler.test.ts | 76 +++++++- packages/core/src/bundle-assembler.ts | 20 ++ packages/core/src/client.test.ts | 49 +++++ packages/core/src/client.ts | 12 +- packages/core/src/index.test-d.ts | 18 +- packages/core/src/index.ts | 6 + packages/core/src/reporting.ts | 25 +++ .../instrumentation-tests/app/scenario.ts | 9 + .../test/instrumentation.e2e.ts | 23 +++ 19 files changed, 558 insertions(+), 3 deletions(-) create mode 100644 packages/core/src/attachments.test.ts create mode 100644 packages/core/src/attachments.ts diff --git a/packages/browser/src/index.ts b/packages/browser/src/index.ts index e0d2f3ef..d03b156f 100644 --- a/packages/browser/src/index.ts +++ b/packages/browser/src/index.ts @@ -2,6 +2,8 @@ // Browser platform: fetch transport, IndexedDB storage, window error handlers, stack-trace parser, // and the launch() composition root. Tier 2. See docs/design/sdk-design.md §5. +// Public report-attachment input type (T3), so `UploadOptions.attachments` / `ReportHandler.before` code types without reaching into core. +export type { ReportAttachmentInput } from '@bugsee/core'; export { COMPONENT_ATTRIBUTE, componentNameFromElement } from './component-name'; export { createUnhandledRejectionProvider, diff --git a/packages/bugsee/src/index.bun.ts b/packages/bugsee/src/index.bun.ts index a3d7cf68..791817fc 100644 --- a/packages/bugsee/src/index.bun.ts +++ b/packages/bugsee/src/index.bun.ts @@ -10,6 +10,7 @@ export type { Breadcrumb, BreadcrumbInput, LogExceptionOptions, + ReportAttachmentInput, UploadOptions, } from '@bugsee/core'; export type { BugseeSpanProcessor } from '@bugsee/opentelemetry'; diff --git a/packages/bugsee/src/index.deno.ts b/packages/bugsee/src/index.deno.ts index e4b8dad9..58c43808 100644 --- a/packages/bugsee/src/index.deno.ts +++ b/packages/bugsee/src/index.deno.ts @@ -9,6 +9,7 @@ export type { Breadcrumb, BreadcrumbInput, LogExceptionOptions, + ReportAttachmentInput, UploadOptions, } from '@bugsee/core'; export type { Bugsee } from '@bugsee/deno'; diff --git a/packages/bugsee/src/index.edge-light.ts b/packages/bugsee/src/index.edge-light.ts index cc642641..c754b47a 100644 --- a/packages/bugsee/src/index.edge-light.ts +++ b/packages/bugsee/src/index.edge-light.ts @@ -9,6 +9,7 @@ export type { Breadcrumb, BreadcrumbInput, LogExceptionOptions, + ReportAttachmentInput, UploadOptions, } from '@bugsee/core'; export * from '@bugsee/vercel-edge'; diff --git a/packages/bugsee/src/index.node.ts b/packages/bugsee/src/index.node.ts index e5205bb4..7ee7c20e 100644 --- a/packages/bugsee/src/index.node.ts +++ b/packages/bugsee/src/index.node.ts @@ -8,6 +8,7 @@ export type { Breadcrumb, BreadcrumbInput, LogExceptionOptions, + ReportAttachmentInput, UploadOptions, } from '@bugsee/core'; // F-6: the `HttpTransport` primitive (+ its request/response option shapes), so a custom transport (a diff --git a/packages/bugsee/src/index.ts b/packages/bugsee/src/index.ts index 029a896e..88b49bed 100644 --- a/packages/bugsee/src/index.ts +++ b/packages/bugsee/src/index.ts @@ -10,6 +10,7 @@ export type { Breadcrumb, BreadcrumbInput, LogExceptionOptions, + ReportAttachmentInput, UploadOptions, } from '@bugsee/core'; // The SpanProcessor type users receive via `onOtelSpanProcessor` to register on their TracerProvider. diff --git a/packages/bugsee/src/index.worker.ts b/packages/bugsee/src/index.worker.ts index b46088ce..b25f7f5c 100644 --- a/packages/bugsee/src/index.worker.ts +++ b/packages/bugsee/src/index.worker.ts @@ -10,6 +10,7 @@ export type { Breadcrumb, BreadcrumbInput, LogExceptionOptions, + ReportAttachmentInput, UploadOptions, } from '@bugsee/core'; export * from '@bugsee/webworker'; diff --git a/packages/bugsee/src/index.workerd.ts b/packages/bugsee/src/index.workerd.ts index 15f11020..eb11ab96 100644 --- a/packages/bugsee/src/index.workerd.ts +++ b/packages/bugsee/src/index.workerd.ts @@ -12,5 +12,6 @@ export type { Breadcrumb, BreadcrumbInput, LogExceptionOptions, + ReportAttachmentInput, UploadOptions, } from '@bugsee/core'; diff --git a/packages/core/src/attachments.test.ts b/packages/core/src/attachments.test.ts new file mode 100644 index 00000000..7a1cb5a1 --- /dev/null +++ b/packages/core/src/attachments.test.ts @@ -0,0 +1,181 @@ +import { describe, expect, it, vi } from 'vitest'; +import { + addReportAttachment, + MAX_USER_ATTACHMENT_BYTES, + MAX_USER_ATTACHMENTS, + resolveUserAttachments, + sanitizeAttachmentFileName, +} from './attachments'; +import { createReportingRequest } from './reporting'; + +const fixedName = (): string => 'rand'; + +describe('limits', () => { + it('mirror the mobile SDKs: 3 attachments of 3 MB', () => { + expect(MAX_USER_ATTACHMENTS).toBe(3); + expect(MAX_USER_ATTACHMENT_BYTES).toBe(3 * 1024 * 1024); + }); +}); + +describe('sanitizeAttachmentFileName', () => { + it.each([ + ['report.txt', 'report.txt'], + ['../../etc/passwd', 'passwd'], + ['..\\..\\win\\system.ini', 'system.ini'], + ['/abs/path/a.log', 'a.log'], + ['a\u0000b\nc.txt', 'abc.txt'], + ['..', 'attachment'], + ['.', 'attachment'], + ['', 'attachment'], + ['dir/', 'attachment'], + [' ', 'attachment'], + ])('%j -> %j', (input, expected) => { + expect(sanitizeAttachmentFileName(input)).toBe(expected); + }); + + it('caps the length at 128 characters', () => { + expect(sanitizeAttachmentFileName('a'.repeat(300))).toHaveLength(128); + }); +}); + +describe('resolveUserAttachments', () => { + it('returns [] for undefined input', () => { + expect(resolveUserAttachments(undefined, vi.fn(), fixedName)).toEqual([]); + }); + + it('resolves bytes and strings with defaults and a namespaced bundle file name', () => { + const bytes = new Uint8Array([1, 2, 3]); + const out = resolveUserAttachments( + [ + { name: 'blob', data: bytes }, + { name: 'note', data: 'héllo', mimeType: 'text/markdown', fileName: 'n.md' }, + { name: 'text', data: 'x' }, + ], + vi.fn(), + (() => { + let n = 0; + return () => { + n += 1; + return `r${n}`; + }; + })(), + ); + expect(out).toEqual([ + { + bundleName: 'r1.attachment.bgsfile', + name: 'blob', + mimeType: 'application/octet-stream', + fileName: 'blob', + data: bytes, + }, + { + bundleName: 'r2.attachment.bgsfile', + name: 'note', + mimeType: 'text/markdown', + fileName: 'n.md', + data: 'héllo', + }, + { + bundleName: 'r3.attachment.bgsfile', + name: 'text', + mimeType: 'text/plain', + fileName: 'text', + data: 'x', + }, + ]); + }); + + it('sanitizes a traversal fileName and derives it from the name when absent', () => { + const [a, b] = resolveUserAttachments( + [ + { name: 'a', data: 'x', fileName: '../../evil.sh' }, + { name: '../b.txt', data: 'x' }, + ], + vi.fn(), + fixedName, + ); + expect(a?.fileName).toBe('evil.sh'); + expect(b?.fileName).toBe('b.txt'); + expect(a?.bundleName).not.toContain('/'); + }); + + it('drops attachments past the third and reports each', () => { + const onError = vi.fn(); + const inputs = [1, 2, 3, 4, 5].map((n) => ({ name: `f${n}`, data: 'x' })); + const out = resolveUserAttachments(inputs, onError, fixedName); + expect(out.map((a) => a.name)).toEqual(['f1', 'f2', 'f3']); + expect(onError).toHaveBeenCalledTimes(2); + expect((onError.mock.calls[0]?.[0] as Error).message).toMatch(/f4/); + expect((onError.mock.calls[0]?.[0] as Error).message).toMatch(/at most 3/); + }); + + it('drops an oversize attachment (bytes and utf-8 strings) without counting it against the budget', () => { + const onError = vi.fn(); + const out = resolveUserAttachments( + [ + { name: 'big', data: new Uint8Array(MAX_USER_ATTACHMENT_BYTES + 1) }, + { name: 'bigstr', data: '€'.repeat(MAX_USER_ATTACHMENT_BYTES / 3 + 1) }, + { name: 'exact', data: new Uint8Array(MAX_USER_ATTACHMENT_BYTES) }, + { name: 'ok', data: 'x' }, + ], + onError, + fixedName, + ); + expect(out.map((a) => a.name)).toEqual(['exact', 'ok']); + expect(onError).toHaveBeenCalledTimes(2); + expect((onError.mock.calls[0]?.[0] as Error).message).toMatch(/big.*3 MB/); + }); + + it('drops malformed entries and reports them', () => { + const onError = vi.fn(); + const bad = [ + null, + 'str', + { name: '', data: 'x' }, + { name: 5, data: 'x' }, + { name: 'nodata' }, + { name: 'num', data: 42 }, + ] as never; + expect(resolveUserAttachments(bad, onError, fixedName)).toEqual([]); + expect(onError).toHaveBeenCalledTimes(6); + }); + + it('reports a non-array input and attaches nothing', () => { + const onError = vi.fn(); + expect(resolveUserAttachments('nope' as never, onError, fixedName)).toEqual([]); + expect(onError).toHaveBeenCalledTimes(1); + }); + + it('uses a random 20-char default bundle name per attachment', () => { + const [a, b] = resolveUserAttachments( + [ + { name: 'a', data: 'x' }, + { name: 'b', data: 'x' }, + ], + vi.fn(), + ); + expect(a?.bundleName).toMatch(/^[a-z0-9]{20}\.attachment\.bgsfile$/); + expect(a?.bundleName).not.toBe(b?.bundleName); + }); + + it('never lets two attachments share a bundle name even if the generator repeats', () => { + const out = resolveUserAttachments( + [ + { name: 'a', data: 'x' }, + { name: 'b', data: 'x' }, + ], + vi.fn(), + fixedName, + ); + expect(new Set(out.map((a) => a.bundleName)).size).toBe(2); + }); +}); + +describe('addReportAttachment', () => { + it('appends to the report without replacing earlier ones', () => { + const { report } = createReportingRequest({ source: { type: 'code_upload' } }); + addReportAttachment(report, { name: 'a', data: 'x' }); + addReportAttachment(report, { name: 'b', data: 'y' }); + expect(report.userAttachments?.map((a) => a.name)).toEqual(['a', 'b']); + }); +}); diff --git a/packages/core/src/attachments.ts b/packages/core/src/attachments.ts new file mode 100644 index 00000000..ae8ff479 --- /dev/null +++ b/packages/core/src/attachments.ts @@ -0,0 +1,133 @@ +import { utf8ByteLength } from '@bugsee/util'; +import type { Report, ReportAttachmentInput } from './reporting'; + +// Application-supplied report attachments (Android `Report.addAttachment`, iOS `BugseeAttachment`). +// Limits mirror the mobile SDKs: at most 3 attachments of 3 MB each. An attachment that breaks a rule is +// DROPPED and reported through `onError` — a report is never lost, and the app never sees a throw, because +// the attachment usually rides a crash report that is the only record of the incident. + +/** Mobile parity: attachments per report. */ +export const MAX_USER_ATTACHMENTS = 3; +/** Mobile parity: bytes per attachment (3 MB). */ +export const MAX_USER_ATTACHMENT_BYTES = 3 * 1024 * 1024; + +const MAX_FILE_NAME_LENGTH = 128; +const FALLBACK_FILE_NAME = 'attachment'; +const ALPHANUMERIC = 'abcdefghijklmnopqrstuvwxyz0123456789'; +// ASCII control characters (U+0000-U+001F, U+007F); built from code points so the source has no literal controls. +const CONTROL_CHARS = new RegExp( + `[${String.fromCharCode(0)}-${String.fromCharCode(31)}${String.fromCharCode(127)}]`, + 'g', +); + +/** An attachment after limits/naming: ready for the assembler. */ +export interface ResolvedUserAttachment { + /** The member name inside the bundle zip: `.attachment.bgsfile` (never user-controlled). */ + bundleName: string; + /** Display label (manifest `name`). */ + name: string; + mimeType: string; + /** Sanitized download name; never contains a path separator. */ + fileName: string; + data: Uint8Array | string; +} + +/** + * Reduce an arbitrary string to a safe single path segment: control characters removed, anything up to the + * last `/` or `\` dropped, `.`/`..`/empty replaced by a fixed fallback, capped at 128 characters. + */ +export function sanitizeAttachmentFileName(raw: string): string { + const base = (raw.replace(CONTROL_CHARS, '').split(/[\\/]/).pop() as string).trim(); + if (base === '' || base === '.' || base === '..') { + return FALLBACK_FILE_NAME; + } + return base.slice(0, MAX_FILE_NAME_LENGTH); +} + +function randomBundleName(): string { + let name = ''; + for (let i = 0; i < 20; i += 1) { + name += ALPHANUMERIC.charAt(Math.floor(Math.random() * ALPHANUMERIC.length)); + } + return name; +} + +function label(input: unknown): string { + const name = (input as { name?: unknown } | null)?.name; + return typeof name === 'string' && name !== '' ? `"${name}"` : '(unnamed)'; +} + +/** + * Validate and name the application's attachments. Order is preserved; the budget is spent only by attachments + * that are accepted, so an oversize one never uses up a slot. `generateName` is injectable for tests. + */ +export function resolveUserAttachments( + inputs: readonly ReportAttachmentInput[] | undefined, + onError: (error: unknown) => void, + generateName: () => string = randomBundleName, +): ResolvedUserAttachment[] { + if (inputs === undefined) { + return []; + } + if (!Array.isArray(inputs)) { + onError(new Error('Report attachments must be an array; none were attached')); + return []; + } + const out: ResolvedUserAttachment[] = []; + const used = new Set(); + for (const input of inputs as readonly unknown[]) { + const candidate = input as Partial | null; + if ( + typeof candidate !== 'object' || + candidate === null || + typeof candidate.name !== 'string' || + candidate.name === '' || + (typeof candidate.data !== 'string' && !(candidate.data instanceof Uint8Array)) + ) { + onError( + new Error( + `Report attachment ${label(input)} is malformed (needs a name and string or Uint8Array data); dropped`, + ), + ); + continue; + } + if (out.length >= MAX_USER_ATTACHMENTS) { + onError( + new Error( + `Report attachment ${label(input)} dropped: at most ${MAX_USER_ATTACHMENTS} attachments per report`, + ), + ); + continue; + } + const { name, data } = candidate as ReportAttachmentInput; + const size = typeof data === 'string' ? utf8ByteLength(data) : data.byteLength; + if (size > MAX_USER_ATTACHMENT_BYTES) { + onError( + new Error( + `Report attachment ${label(input)} dropped: ${size} bytes exceeds the 3 MB limit`, + ), + ); + continue; + } + let bundleName = `${generateName()}.attachment.bgsfile`; + for (let n = 2; used.has(bundleName); n += 1) { + bundleName = `${generateName()}${n}.attachment.bgsfile`; + } + used.add(bundleName); + out.push({ + bundleName, + name, + mimeType: + candidate.mimeType ?? + (typeof data === 'string' ? 'text/plain' : 'application/octet-stream'), + fileName: sanitizeAttachmentFileName(candidate.fileName ?? name), + data, + }); + } + return out; +} + +/** Append an attachment to a report (the helper form of writing `report.userAttachments` directly). */ +export function addReportAttachment(report: Report, attachment: ReportAttachmentInput): void { + report.userAttachments = [...(report.userAttachments ?? []), attachment]; +} diff --git a/packages/core/src/bundle-assembler.test.ts b/packages/core/src/bundle-assembler.test.ts index adfd4d98..a50a3993 100644 --- a/packages/core/src/bundle-assembler.test.ts +++ b/packages/core/src/bundle-assembler.test.ts @@ -1,6 +1,6 @@ import type { EnvironmentEnvelope, FileType } from '@bugsee/protocol'; import { strFromU8, unzipSync } from '@bugsee/util'; -import { describe, expect, it } from 'vitest'; +import { describe, expect, it, vi } from 'vitest'; import { assembleBundle, type BundleAssemblyContext } from './bundle-assembler'; import { CaptureDataEntryBase } from './capture-data-entry'; import type { CaptureDataEntry } from './contracts'; @@ -662,3 +662,77 @@ describe('assembleBundle — native crash (minidump + attachments)', () => { expect(z.manifest.files).not.toContainEqual(expect.objectContaining({ type: 'attachment' })); }); }); + +describe('assembleBundle — user attachments', () => { + it('writes each under .attachment.bgsfile and registers name + attrs in the manifest', () => { + const bytes = new Uint8Array([9, 8, 7]); + const request = createReportingRequest({ + source: { type: 'code_upload' }, + id: 'r1', + userAttachments: [ + { name: 'trace', data: bytes, mimeType: 'application/x-trace', fileName: '../trace.bin' }, + { name: 'note', data: 'héllo' }, + ], + }); + const bundle = assembleBundle(request, new Map(), context()); + const z = unzip(bundle.body); + const entries = z.manifest.files.filter((f: { type: string }) => f.type === 'attachment'); + expect(entries).toHaveLength(2); + expect(entries[0].filename).toMatch(/^[a-z0-9]{20}\.attachment\.bgsfile$/); + expect(entries[0]).toMatchObject({ + type: 'attachment', + name: 'trace', + attrs: { name: 'trace', mimeType: 'application/x-trace', fileName: 'trace.bin' }, + }); + expect(entries[1].attrs).toEqual({ name: 'note', mimeType: 'text/plain', fileName: 'note' }); + const files = unzipSync(bundle.body); + expect(files[entries[0].filename]).toEqual(bytes); + expect(strFromU8(files[entries[1].filename] as Uint8Array)).toBe('héllo'); + expect(z.names).not.toContain('../trace.bin'); + }); + + it('coexists with internal attachments, which keep their verbatim file name', () => { + const request = createReportingRequest({ + source: { type: 'crash' }, + id: 'r1', + attachments: [{ name: 'dump-1.dmp', data: new Uint8Array([1]) }], + userAttachments: [{ name: 'a', data: 'x' }], + }); + const z = unzip(assembleBundle(request, new Map(), context()).body); + expect(z.names).toContain('dump-1.dmp'); + expect(z.manifest.files.filter((f: { type: string }) => f.type === 'attachment')).toHaveLength( + 2, + ); + }); + + it('applies the 3 x 3 MB limits and reports drops through context.onError', () => { + const onError = vi.fn(); + const request = createReportingRequest({ + source: { type: 'code_upload' }, + id: 'r1', + userAttachments: [ + { name: 'a', data: 'x' }, + { name: 'huge', data: new Uint8Array(3 * 1024 * 1024 + 1) }, + { name: 'b', data: 'x' }, + { name: 'c', data: 'x' }, + { name: 'd', data: 'x' }, + ], + }); + const z = unzip(assembleBundle(request, new Map(), { ...context(), onError }).body); + const names = z.manifest.files + .filter((f: { type: string }) => f.type === 'attachment') + .map((f: { name: string }) => f.name); + expect(names).toEqual(['a', 'b', 'c']); + expect(onError).toHaveBeenCalledTimes(2); + }); + + it('drops silently when no onError is wired', () => { + const request = createReportingRequest({ + source: { type: 'code_upload' }, + id: 'r1', + userAttachments: [{ name: '', data: 'x' }], + }); + const z = unzip(assembleBundle(request, new Map(), context()).body); + expect(z.manifest.files).not.toContainEqual(expect.objectContaining({ type: 'attachment' })); + }); +}); diff --git a/packages/core/src/bundle-assembler.ts b/packages/core/src/bundle-assembler.ts index f2bf759d..69049f28 100644 --- a/packages/core/src/bundle-assembler.ts +++ b/packages/core/src/bundle-assembler.ts @@ -15,6 +15,7 @@ import { VIDEO_AUX_VERSION, } from '@bugsee/protocol'; import type { AttributeValue, IssueType } from '@bugsee/types'; +import { resolveUserAttachments } from './attachments'; import { type BundleFile, writeBundleZip } from './bundle-writer'; import type { Clock } from './clock'; import type { CaptureDataEntry } from './contracts'; @@ -57,6 +58,8 @@ export interface BundleAssemblyContext { * JSON path — keeping core codec-free. The encoder receives the type's ordered payloads (`entry.data`). */ fileEncoders?: Partial Uint8Array>>; + /** Sink for non-fatal problems (a dropped user attachment). Absent → they are dropped silently. */ + onError?: (error: unknown) => void; } const ALPHANUMERIC = 'abcdefghijklmnopqrstuvwxyz0123456789'; @@ -206,6 +209,23 @@ export function assembleBundle( typedFiles.push({ name: attachment.name, data: attachment.data }); } + // Application attachments (`Report.userAttachments`, mobile `Report.addAttachment`): limits applied here, + // the one place every report path passes through. The zip member is `.attachment.bgsfile` — the + // mobile layout, never derived from user input — and the label/MIME/download name ride in the manifest + // entry (`name` + `attrs`), which is where the worker and viewer read them. + for (const att of resolveUserAttachments( + report.userAttachments, + context.onError ?? (() => undefined), + )) { + files.push({ + filename: att.bundleName, + type: 'attachment', + name: att.name, + attrs: { name: att.name, mimeType: att.mimeType, fileName: att.fileName }, + }); + typedFiles.push({ name: att.bundleName, data: att.data }); + } + // Manifest attributes: the request context's attributes (when present) merged OVER the global ones. const attrs = requestContext?.attributes !== undefined diff --git a/packages/core/src/client.test.ts b/packages/core/src/client.test.ts index 411f7b90..f72b28e9 100644 --- a/packages/core/src/client.test.ts +++ b/packages/core/src/client.test.ts @@ -2,6 +2,7 @@ import type { EnvironmentEnvelope, FileType } from '@bugsee/protocol'; import { createServiceContainer, defineService, serviceToken } from '@bugsee/service'; import { strFromU8, unzipSync } from '@bugsee/util'; import { describe, expect, it, vi } from 'vitest'; +import { addReportAttachment } from './attachments'; import { CaptureDataEntryBase } from './capture-data-entry'; import { createCaptureExporter } from './capture-exporter'; import { createClient, type Scheduler, SchedulerToken } from './client'; @@ -2118,6 +2119,54 @@ describe('createClient — upload (manual report)', () => { expect(sent().severity).toBe(4); // critical on the wire }); + it('attaches UploadOptions.attachments to the uploaded bundle and drops over-limit ones to onError', async () => { + const { uploadPipeline, enqueue } = fakeUpload(); + const onError = vi.fn(); + const client = createClient({ uploadPipeline, appToken: 'tok', getEnvironment, onError }); + + await client.upload({ + attachments: [ + { name: 'one', data: 'a' }, + { name: 'two', data: 'b' }, + { name: 'three', data: 'c' }, + { name: 'four', data: 'd' }, + ], + }); + + const bundle = enqueue.mock.calls[0]?.[0] as Bundle; + const files = unzipSync(bundle.body) as Record; + const manifest = JSON.parse(strFromU8(files['manifest.json'] as Uint8Array)); + const names = manifest.files + .filter((f: { type: string }) => f.type === 'attachment') + .map((f: { name: string }) => f.name); + expect(names).toEqual(['one', 'two', 'three']); + expect(onError).toHaveBeenCalledTimes(1); + }); + + it('lets ReportHandler.before add attachments with addReportAttachment', async () => { + const { uploadPipeline, enqueue } = fakeUpload(); + const client = createClient({ uploadPipeline, appToken: 'tok', getEnvironment }); + client.setReportHandler({ + before: (request) => { + addReportAttachment(request.report, { + name: 'state.json', + data: '{"a":1}', + mimeType: 'application/json', + }); + return request; + }, + }); + + await client.upload(); + + const bundle = enqueue.mock.calls[0]?.[0] as Bundle; + const files = unzipSync(bundle.body) as Record; + const manifest = JSON.parse(strFromU8(files['manifest.json'] as Uint8Array)); + const entry = manifest.files.find((f: { type: string }) => f.type === 'attachment'); + expect(entry.attrs.mimeType).toBe('application/json'); + expect(strFromU8(files[entry.filename] as Uint8Array)).toBe('{"a":1}'); + }); + it('defaults to a high-severity bug with nothing else set', async () => { const { client, sent } = upload(); diff --git a/packages/core/src/client.ts b/packages/core/src/client.ts index 5baad255..bd0b9317 100644 --- a/packages/core/src/client.ts +++ b/packages/core/src/client.ts @@ -62,7 +62,11 @@ import { createOperationDispatcher } from './operation-dispatcher'; import { createOptionsContainer } from './options'; import { createRateLimiter, type RateLimiterOptions } from './rate-limiter'; import type { ReportMarkerStore } from './report-marker-store'; -import { createReportingRequest, type ReportingRequest } from './reporting'; +import { + createReportingRequest, + type ReportAttachmentInput, + type ReportingRequest, +} from './reporting'; import { type ContextProvider, ContextProviderToken, type RequestContext } from './request-context'; import type { ServiceRegistrar, ServiceResolver } from './services'; import { @@ -170,6 +174,8 @@ export interface UploadOptions { * shows and filters on. */ trigger?: 'code_upload' | 'code_dialog'; + /** Files to attach (3 of 3 MB each at most; over-limit ones are dropped and reported via `onError`). */ + attachments?: ReportAttachmentInput[]; } /** @@ -522,6 +528,7 @@ export function createClient(options: CreateClientOptions = {}): BugseeClient { attributes: identity.attributes, userIdentifier: identity.userIdentifier, clock, + onError, ...(captured !== undefined ? { requestContext: captured } : {}), ...(options.bundleFileName !== undefined ? { fileName: options.bundleFileName } : {}), ...(options.fileEncoders !== undefined ? { fileEncoders: options.fileEncoders } : {}), @@ -547,6 +554,9 @@ export function createClient(options: CreateClientOptions = {}): BugseeClient { ...(uploadOptions.severity !== undefined ? { severity: uploadOptions.severity } : {}), ...(uploadOptions.labels !== undefined ? { labels: uploadOptions.labels } : {}), ...(uploadOptions.email !== undefined ? { email: uploadOptions.email } : {}), + ...(uploadOptions.attachments !== undefined + ? { userAttachments: uploadOptions.attachments } + : {}), }); const handled = applyReportBefore(request); if (handled === null) { diff --git a/packages/core/src/index.test-d.ts b/packages/core/src/index.test-d.ts index 3f9571b3..fb2de4c2 100644 --- a/packages/core/src/index.test-d.ts +++ b/packages/core/src/index.test-d.ts @@ -9,6 +9,8 @@ import type { BreadcrumbInput, CreateClientOptions, LogExceptionOptions, + ReportAttachmentInput, + UploadOptions, } from './index'; // createClient's parameter type — home of the onError injection seam. @@ -25,10 +27,24 @@ const crumb: Breadcrumb = { message: 'clicked', timestamp: 1 }; const mechanism: Mechanism = 'programmatic'; const logOpts: LogExceptionOptions = { mechanism, severity: 'high', labels: ['p1'] }; +// Attachments: string or bytes data; mimeType/fileName optional; reachable from UploadOptions. +const attachment: ReportAttachmentInput = { name: 'log', data: new Uint8Array(1) }; +const uploadOpts: UploadOptions = { + attachments: [attachment, { name: 't', data: 'text', mimeType: 'text/plain' }], +}; + // --- Negatives --- +// @ts-expect-error data is required. +export const badAttachment: ReportAttachmentInput = { name: 'x' }; // @ts-expect-error onError must accept the thrown value; a wrong-arity handler is rejected. export const badOptions: CreateClientOptions = { onError: (_a: string, _b: string) => {} }; // @ts-expect-error the base Breadcrumb requires `timestamp` (BreadcrumbInput makes it optional). export const badCrumb: Breadcrumb = { message: 'x' }; -export type IndexAssertions = [typeof options, typeof crumbInput, typeof crumb, typeof logOpts]; +export type IndexAssertions = [ + typeof options, + typeof crumbInput, + typeof crumb, + typeof logOpts, + typeof uploadOpts, +]; diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 22821791..1d99707e 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -14,6 +14,11 @@ export { serviceToken, } from '@bugsee/service'; export type { AttributeValue } from '@bugsee/types'; +export { + addReportAttachment, + MAX_USER_ATTACHMENT_BYTES, + MAX_USER_ATTACHMENTS, +} from './attachments'; export { type BugseeApiOptions, createBugseeApi } from './bugsee-api'; export { assembleBundle, type BundleAssemblyContext } from './bundle-assembler'; export { createBundleUploader } from './bundle-uploader'; @@ -200,6 +205,7 @@ export { createReportingRequest, type Report, type ReportAttachment, + type ReportAttachmentInput, type ReportingRequest, type ReportingRequestInit, type ReportingSource, diff --git a/packages/core/src/reporting.ts b/packages/core/src/reporting.ts index a966f71b..7bc6110f 100644 --- a/packages/core/src/reporting.ts +++ b/packages/core/src/reporting.ts @@ -9,6 +9,22 @@ export interface ReportAttachment { data: Uint8Array | string; } +/** + * A file the application attaches to a report (Android `Report.addAttachment`, iOS `BugseeAttachment`). + * At most 3 of 3 MB each travel with a report; the + * rest are dropped and reported through `onError`, never thrown. + */ +export interface ReportAttachmentInput { + /** Human-readable label shown for the attachment. */ + name: string; + /** The content; a string is encoded as UTF-8. */ + data: Uint8Array | string; + /** MIME type; `text/plain` for a string and `application/octet-stream` for bytes when omitted. */ + mimeType?: string; + /** Suggested download file name (directory parts are stripped); derived from `name` when omitted. */ + fileName?: string; +} + // Report assembly request (Android BugseeReportingRequest / ReportingSource / Report parity). A // detection provider (or a manual entry point) builds a ReportingRequest and submits it; the trigger // pipeline assembles it into request.json + a bundle. This replaces the lightweight TriggerHint. @@ -52,6 +68,12 @@ export interface Report { crash?: CrashJson | NativeCrashJson; /** Extra binary/text files written verbatim into the bundle (e.g. a native-crash `.dmp`). */ attachments?: readonly ReportAttachment[]; + /** + * Files the APPLICATION attaches (see {@link ReportAttachmentInput}); writable from `ReportHandler.before` + * or with `addReportAttachment`. Limits and naming are applied at assembly, so an over-limit entry + * is dropped there rather than throwing here. + */ + userAttachments?: ReportAttachmentInput[]; } /** A report being assembled (Android ReportingRequest), carrying its source and metadata. */ @@ -78,6 +100,8 @@ export interface ReportingRequestInit { crash?: CrashJson | NativeCrashJson; /** Extra binary/text bundle files (see {@link Report.attachments}). */ attachments?: readonly ReportAttachment[]; + /** Application-supplied attachments (see {@link Report.userAttachments}). */ + userAttachments?: readonly ReportAttachmentInput[]; } const defaultGenerateId = (): string => @@ -116,6 +140,7 @@ export function createReportingRequest( ...(init.email !== undefined ? { email: init.email } : {}), ...(init.crash !== undefined ? { crash: init.crash } : {}), ...(init.attachments !== undefined ? { attachments: init.attachments } : {}), + ...(init.userAttachments !== undefined ? { userAttachments: [...init.userAttachments] } : {}), }; return { id, source: init.source, report }; } diff --git a/packages/instrumentation-tests/app/scenario.ts b/packages/instrumentation-tests/app/scenario.ts index 30b05de5..5966f63e 100644 --- a/packages/instrumentation-tests/app/scenario.ts +++ b/packages/instrumentation-tests/app/scenario.ts @@ -62,6 +62,15 @@ async function runMainScenario(launch: LaunchFn, collectorUrl: string): Promise< // A logged exception → an error report bundle (drains the capture window: logs + network). await client.logException(new Error('e2e instrumented failure')); + // An explicit upload carrying application attachments (one text, one binary, one with a traversal file name). + await client.upload({ + summary: 'e2e attachment upload', + attachments: [ + { name: 'state.json', data: '{"cart":3}', mimeType: 'application/json' }, + { name: 'blob', data: new Uint8Array([0, 1, 2, 255]), fileName: '../../etc/blob.bin' }, + ], + }); + // A deliberate main-thread hang past the fair threshold → the worker watchdog fires an AppHang report. // The CPU profiler keeps sampling through the stall, so `e2eHangSpin` lands in the profile attached to it. // diff --git a/packages/instrumentation-tests/test/instrumentation.e2e.ts b/packages/instrumentation-tests/test/instrumentation.e2e.ts index 6fd215cf..b6982999 100644 --- a/packages/instrumentation-tests/test/instrumentation.e2e.ts +++ b/packages/instrumentation-tests/test/instrumentation.e2e.ts @@ -164,6 +164,29 @@ describe.each( expect(collector.echoHits).toBeGreaterThanOrEqual(1); }); + it('delivered the application attachments under namespaced names with manifest metadata', () => { + const up = bundles.find((b) => b.request.summary === 'e2e attachment upload'); + expect(up, 'no upload bundle was delivered').toBeDefined(); + const bundle = up as ParsedBundle; + const manifest = parseJson<{ + files: { filename: string; type: string; name?: string; attrs?: Record }[]; + }>(bundle.files['manifest.json']); + const atts = manifest.files.filter((f) => f.type === 'attachment'); + expect(atts.map((f) => f.name)).toEqual(['state.json', 'blob']); + for (const f of atts) { + expect(f.filename).toMatch(/^[a-z0-9]{20}\.attachment\.bgsfile$/); + } + expect(atts[0]?.attrs).toMatchObject({ + mimeType: 'application/json', + fileName: 'state.json', + }); + expect(atts[1]?.attrs).toMatchObject({ fileName: 'blob.bin' }); + expect(strFromU8(bundle.files[atts[0]?.filename as string] as Uint8Array)).toBe('{"cart":3}'); + expect(Array.from(bundle.files[atts[1]?.filename as string] as Uint8Array)).toEqual([ + 0, 1, 2, 255, + ]); + }); + it('delivered an error report bundle with logs, the captured network request, and a CPU profile', () => { const err = bundles.find((b) => b.request.source.mechanism === 'programmatic'); expect(err, 'no programmatic error bundle was delivered').toBeDefined(); From 5e350aae9619d1e9cb82c18b456cadcffa8da867 Mon Sep 17 00:00:00 2001 From: Alexey Karimov Date: Mon, 12 Oct 2026 13:39:22 +0500 Subject: [PATCH 2/2] ci: retrigger checks and automated review MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 🤖 Generated with [Claude Code](https://claude.com/claude-code)