diff --git a/docs/PROGRESS.md b/docs/PROGRESS.md index a2d33d2a..5a2001ba 100644 --- a/docs/PROGRESS.md +++ b/docs/PROGRESS.md @@ -82,6 +82,7 @@ Read this first; then `docs/design/sdk-design.md` (Draft v3) for the full archit - `createUiBreadcrumbSource` / `createUiBreadcrumbProvider` (**UI breadcrumbs**, 2026-08-31): the state-change half of the DOM interaction split — capture-phase/passive listeners for `change`/`submit`/`focusin` → `client.addBreadcrumb` (so the app's `breadcrumbFilter` runs) in Android `BreadcrumbInputGesture`'s shape: `{type:'user', category:'ui.change'|'ui.submit'|'ui.focus', level:'info', data:{'view.id','view.class','view.tag'}, timestamp: the DOM event's own moment (`performance.timeOrigin + Event.timeStamp`, clamped to now)}`. `type:'user'` is Android's literal for a user-ORIGINATED breadcrumb — **not** the `*.user` stream (these are `breadcrumbs` entries). Why not `input`: `InputEvent.type` is Android's `InputEventStage` (`unknown|begin|move|end|scroll|keydown|keyup`), which a DOM-only value cannot join, and Android already splits its input dispatcher (→ `input.json`) from its gesture dispatcher (→ breadcrumbs). Reuses `describeTarget`; a sensitive/app-masked target **drops the breadcrumb outright** (the focus/change rhythm on a masked field is itself a side channel). Gated by `captureInteractions` (the option means "do not watch what I click and type", whatever stream it lands on). Wired into `@bugsee/browser` **and** `@bugsee/webview` (which also now declares `breadcrumbs` in `hello.caps`). - `createDomSnapshot` / `createViewtreeSnapshotSource` (view hierarchy: an at-report DOM-tree snapshot → `viewtree`, via the core `reportSnapshots` pull-seam) — reuses `describeTarget` per node + rounded `getBoundingClientRect`; masked subtrees collapse to `{tag, masked, rect}`; bounded (maxNodes/maxDepth) + per-node throw-isolated. Gated by `captureViewHierarchy`. See §7. - **`client.showReportDialog(options?)`** (2026-10-11 → `docs/design/report-dialog.md`): the web counterpart of the mobile `showReportDialog()` — a built-in bug-report form shown as a modal over a shaded page, sent as a `code_dialog` report through the new core **`client.upload(options)`** (Android `Bugsee.upload`: not deduped or rate-limited, still runs the report handler). Same fields/order/strings/colours/validation as mobile (summary, description, severity, labels, email, `v` footer); the reporting-UI launch options are Android's canonical `com.bugsee.option.reporting.ui.*` keys with the same defaults (`reportSummaryRequired`, `reportDescriptionRequired`, `reportEmailRequired`, `reportLabelsEnabled`, `reportLabelsRequired`, `reportPrioritySelectorEnabled`, `reportDefaultBugPriority`). Built with DOM APIs only inside ONE shadow root on ONE host element (page CSS cannot reach it; user text is never parsed as HTML) and shown with `.showModal()` so it sits in the TOP LAYER — above any z-index and above a modal the page already has open (verified in real Chromium); a fixed max-z-index overlay is the fallback. Esc / a click on the shading is a cancel with a discard prompt for an edited form; focus and scroll are restored on close; a changed email is remembered as the user identifier. **No screenshot in v1** (the report already carries the DOM snapshot). Resolves `null` on cancel / already open / not running / no DOM. Tested under jsdom (`report-dialog.test.ts`, mutation-checked) and end to end through a real `launch()` (`report-dialog.launch.test.ts`). +- **Mobile-parity options (T6, `docs/design/mobile-api-alignment.md`):** `captureLogsLevel`, `captureBreadcrumbs` (JS default stays `true`), `detectHttpErrors` (`createHttpErrorDetectionProvider` in `@bugsee/capture`, mechanism `http-error`, own rate budget), `captureNetworkDefaultSanitizer`, `reportDefaultCrashPriority` / `reportDefaultErrorPriority` (applied in the client before the report handler, only to severities that were derived), and `getDefaultNetworkSanitizer()` for composing with a custom network filter. - **`launch(appToken, options)`** — the browser composition root (fetch/DOM analog of node's): fetch transport (internal-tagged), api/uploader/upload pipeline, browser env, in-memory store (or IndexedDB-backed when `persist:true`), gated capture providers (console→log; network umbrella, NO `node:http`; system traces; system events; user-interaction input) + detection providers, `client.launch()`. **No `process.exit` path** (the browser flushes via the pipeline/`pagehide`; `stop()` only clears the carrier). `persist:true` builds an IndexedDB durable bundle queue (crash recovery across reload — `recover()` deferred to the store's `whenReady`) + the durable IndexedDB chunk capture store (`createIdbChunkCaptureStore` in its own `bugsee-capture` db). `maxDataSize` defaults to 10 MB. **Session replay is ON by default** (2026-08-27) — parity with the iOS/Android SDKs, which record by default; `replay: false` is the opt-out and is the ONLY value on which `import('@bugsee/replay')` is never evaluated, so an errors-only integration still pays nothing for rrweb (the lazy chunk + the opt-out carry the bundle-size budget, not the default). Masking stays fail-closed on the default path (mask all text/inputs, block all media); `replay.canvas` stays opt-in. Returns the started `BugseeClient`. ### Integration shims — `@bugsee/integration-shims` (tier-3 leaf, slice #13) diff --git a/docs/design/mobile-api-alignment.md b/docs/design/mobile-api-alignment.md index 7359f5da..9bf876a1 100644 --- a/docs/design/mobile-api-alignment.md +++ b/docs/design/mobile-api-alignment.md @@ -21,8 +21,33 @@ Each task is test-first with the mutator loop, keeps the coverage gate, and land | T8 | `logException` options: `domain`; check what `mergingRules` does on iOS | Android `ExceptionOptions.Domain`, iOS `BugseeExceptionLoggingOptions` | | | T9 | Browser: persist the user identifier across page loads, as iOS does | iOS `setUserIdentifier` | storage-failure tolerant | +## Options added by T6 + +Launch options (all runtimes through `COMMON_OPTION_DEFINITIONS` in `@bugsee/core`; sent in `sdk.options` +dots-to-colons like every other option). `detectHttpErrors` is wired in `@bugsee/browser` and `@bugsee/node`. + +| Friendly | Canonical (`com.bugsee.option.` + ...) | Default | Notes | +|---|---|---|---| +| `captureLogsLevel` | `capture.logs.level` | `'verbose'` | Console and `client.log()` records less severe than the level are dropped before the log filter. | +| `captureBreadcrumbs` | `capture.breadcrumbs` | `true` (mobile: `false`) | `false` drops every breadcrumb: `addBreadcrumb`, the UI ones and adapter ones. See Kept differences. | +| `detectHttpErrors` | `detect.http-errors` | `false` | fetch / XHR / `node:http` responses with status >= 400, or a request with no response, file an `error` report with mechanism `http-error`. Own rate budget (20 per minute). The report text carries the method, status and URL without query string or credentials. | +| `captureNetworkDefaultSanitizer` | `capture.network.default-sanitizer` | `true` | Friendly name for the existing key. A network filter still replaces the default; see `getDefaultNetworkSanitizer()`. | +| `reportDefaultCrashPriority` | `reporting.defaults.crash-priority` | `'blocker'` | Severity of a crash report that was given none. | +| `reportDefaultErrorPriority` | `reporting.defaults.error-priority` | `'high'` | Same for error reports (`logException`, unhandled rejections, http errors). | + +An explicit per-call severity (`logException(e, { severity })`) and a report handler's edits win over the default. +Native-crash recovery (Electron) builds its report outside the client and keeps `blocker`. +Invalid values warn through `onError` and fall back to the default. + +`getDefaultNetworkSanitizer()` (exported from `@bugsee/capture` and the `@bugsee/bugsee` entries) returns +`{ sanitize, sanitizeUrl, sanitizeErrorMessage }`, the same object the built-in sanitizer uses. Call it from inside a +`setNetworkEventFilter` filter to keep the default redaction. + ## Kept differences (documented, not changed) +- `captureBreadcrumbs` defaults to `true`, not mobile's `false`: UI-interaction breadcrumbs (click / change / submit / + focus) are a core web signal and were already always on. The option can now switch them off. + - `launch` is async in the browser (lazy-loaded extensions) and synchronous on Node. - JS filters return a value; mobile filters take a callback. - No native triggers (shake, screenshot, notification bar, broadcast), no video options, `Appearance` or `setWrapper`. diff --git a/packages/browser/src/launch.ts b/packages/browser/src/launch.ts index 15caa2e2..8e1015a7 100644 --- a/packages/browser/src/launch.ts +++ b/packages/browser/src/launch.ts @@ -8,6 +8,7 @@ import { } from '@bugsee/browser-utils'; import { createConsoleInterceptor, + createHttpErrorDetectionProvider, createInputProvider, createLogCaptureProvider, createSystemEventsProvider, @@ -178,10 +179,22 @@ export interface BugseeLaunchOptions { /** Capture console output as logs. Default true. */ captureLogs?: boolean; + /** Minimum level of a log record to capture: `error | warning | info | debug | verbose`. Default `verbose`. */ + captureLogsLevel?: 'error' | 'warning' | 'info' | 'debug' | 'verbose'; + /** + * Capture breadcrumbs (`addBreadcrumb` and the automatic UI-interaction ones). Default true. Mobile SDKs + * default this to false; on the web UI breadcrumbs are a core signal, so JS keeps them on. + */ + captureBreadcrumbs?: boolean; /** Capture network (fetch/xhr/ws/sse/webtransport). Default true. */ captureNetwork?: boolean; /** Capture request/response bodies (bounded read). Default true. */ captureNetworkBodies?: boolean; + /** + * Apply the built-in network PII sanitizer (URL, headers, bodies, error text). Default true. A + * `setNetworkEventFilter` filter REPLACES it; compose with `getDefaultNetworkSanitizer()`. + */ + captureNetworkDefaultSanitizer?: boolean; /** Max captured request/response body size in bytes. Default 20480. */ maxNetworkBodySize?: number; /** Capture a body even when its Content-Type is missing/blank. Default false. */ @@ -214,6 +227,12 @@ export interface BugseeLaunchOptions { reportDefaultBugPriority?: SeverityName; /** Detect window errors + unhandled rejections. Default true. */ detectCrashes?: boolean; + /** Report failed fetch/XHR exchanges (status >= 400, or no response) as `error` reports. Default false. */ + detectHttpErrors?: boolean; + /** Severity a crash report starts with. Default `blocker`. */ + reportDefaultCrashPriority?: SeverityName; + /** Severity an error report starts with. Default `high`. */ + reportDefaultErrorPriority?: SeverityName; /** * Session replay (rrweb). **On by default** — parity with the iOS/Android SDKs, which record by default. * An options object customises it; `false` opts out entirely. Masking is FAIL-CLOSED (mask all @@ -641,6 +660,8 @@ export async function launchCore( // Detection providers: window error → crash, unhandledrejection → error. client.addDetectionProvider(createWindowErrorProvider(win)); client.addDetectionProvider(createUnhandledRejectionProvider(win)); + // Failed fetch/xhr exchanges → `error` reports (mechanism http-error). Off unless `detectHttpErrors`. + client.addDetectionProvider(createHttpErrorDetectionProvider(network.interceptor)); client.launch(); diff --git a/packages/browser/src/mobile-options.launch.test.ts b/packages/browser/src/mobile-options.launch.test.ts new file mode 100644 index 00000000..03a7bef6 --- /dev/null +++ b/packages/browser/src/mobile-options.launch.test.ts @@ -0,0 +1,250 @@ +// @vitest-environment jsdom +import 'fake-indexeddb/auto'; +import { + createMemoryCaptureStore, + type HttpRequestOptions, + type HttpResponse, + type HttpTransport, +} from '@bugsee/core'; +import { strFromU8, unzipSync } from '@bugsee/util'; +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { resetBrowserDeviceIdCache } from './device-id'; +import type { BrowserProbe } from './environment'; +import { type Bugsee, type BugseeLaunchOptions, launch } from './launch'; + +// Mobile-parity launch options (T6), end to end through the real browser composition root: the friendly +// name resolves to the canonical key, reaches `sdk.options` on the wire, and changes what the uploaded +// bundle contains. + +const probe: BrowserProbe = { + userAgent: () => + 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/119.0.0.0 Safari/537.36', + uaDataPlatform: () => 'macOS', + locale: () => 'en-US', + utcOffsetMinutes: () => 0, + screenWidth: () => 1280, + screenHeight: () => 720, + pixelRatio: () => 1, + deviceMemoryBytes: () => undefined, + cpuCount: () => undefined, +}; + +const json = (value: unknown): Uint8Array => new TextEncoder().encode(JSON.stringify(value)); +const transport = (): ReturnType> => + vi.fn(async (url: string): Promise => { + if (url.endsWith('/v2/sessions')) { + return { status: 200, headers: {}, body: json({ access_token: 'access' }) }; + } + if (url.endsWith('/v2/issues')) { + return { + status: 200, + headers: {}, + body: json({ endpoint: 'https://s3.test/put', issueId: 'i1', recordingId: 'r1' }), + }; + } + return { status: 200, headers: {}, body: new Uint8Array() }; + }); + +const clients: Bugsee[] = []; +const realFetch = globalThis.fetch; +const start = async (over: Partial = {}) => { + const http = transport(); + const client = await launch('tok', { + window, + document, + transport: http, + systemProbe: probe, + systemMetricsSampler: () => [], + captureNetwork: false, + replay: false, + captureStore: createMemoryCaptureStore({ maxRecordingTimeMs: Number.POSITIVE_INFINITY }), + deviceIdEnv: { + localStorage: { + getItem: () => 'a1b2c3d4-e5f6-4789-a012-3456789abcde', + setItem() {}, + removeItem() {}, + }, + randomUUID: () => 'a1b2c3d4-e5f6-4789-a012-3456789abcde', + }, + ...over, + }); + clients.push(client); + return { client, http }; +}; + +afterEach(async () => { + await Promise.all(clients.splice(0).map((c) => c.stop())); + globalThis.fetch = realFetch; + resetBrowserDeviceIdCache(); + delete (globalThis as { __BUGSEE__?: unknown }).__BUGSEE__; +}); + +const uploads = (http: ReturnType) => + http.mock.calls + .filter(([url]) => url === 'https://s3.test/put') + .map(([, options]) => unzipSync((options as HttpRequestOptions).body as Uint8Array)); +const read = (files: Record, name: string): unknown => + files[name] === undefined ? undefined : JSON.parse(strFromU8(files[name])); +const requestOf = (files: Record) => + read(files, 'request.json') as { + severity: number; + source: { type: string; mechanism?: string }; + summary?: string; + environment: { sdk: { options: Record } }; + }; +const fileNames = (files: Record) => Object.keys(files); + +describe('mobile-parity options reach sdk.options', () => { + it('sends the defaults dots-to-colons', async () => { + const { client, http } = await start(); + await client.upload({ summary: 'x' }); + const options = requestOf(uploads(http)[0] as Record).environment.sdk + .options; + expect(options).toMatchObject({ + 'com:bugsee:option:capture:logs:level': 'verbose', + 'com:bugsee:option:capture:breadcrumbs': true, + 'com:bugsee:option:detect:http-errors': false, + 'com:bugsee:option:capture:network:default-sanitizer': true, + 'com:bugsee:option:reporting:defaults:crash-priority': 'blocker', + 'com:bugsee:option:reporting:defaults:error-priority': 'high', + }); + }); + + it('sends the values the app launched with', async () => { + const { client, http } = await start({ + captureLogsLevel: 'warning', + captureBreadcrumbs: false, + detectHttpErrors: true, + captureNetworkDefaultSanitizer: false, + reportDefaultCrashPriority: 'critical', + reportDefaultErrorPriority: 'low', + }); + await client.upload({ summary: 'x' }); + const options = requestOf(uploads(http)[0] as Record).environment.sdk + .options; + expect(options).toMatchObject({ + 'com:bugsee:option:capture:logs:level': 'warning', + 'com:bugsee:option:capture:breadcrumbs': false, + 'com:bugsee:option:detect:http-errors': true, + 'com:bugsee:option:capture:network:default-sanitizer': false, + 'com:bugsee:option:reporting:defaults:crash-priority': 'critical', + 'com:bugsee:option:reporting:defaults:error-priority': 'low', + }); + }); +}); + +describe('captureBreadcrumbs', () => { + it('records breadcrumbs by default', async () => { + const { client, http } = await start(); + client.addBreadcrumb({ message: 'kept' }); + await client.upload({ summary: 'x' }); + expect(fileNames(uploads(http)[0] as Record)).toContain('breadcrumbs'); + }); + + it('records none when switched off', async () => { + const { client, http } = await start({ captureBreadcrumbs: false }); + client.addBreadcrumb({ message: 'dropped' }); + await client.upload({ summary: 'x' }); + expect(fileNames(uploads(http)[0] as Record)).not.toContain('breadcrumbs'); + }); +}); + +describe('captureLogsLevel', () => { + it('captures console records at or above the level only', async () => { + const { client, http } = await start({ captureLogsLevel: 'warning' }); + console.info('chatty-info-line'); + console.error('important-error-line'); + await client.upload({ summary: 'x' }); + const files = uploads(http)[0] as Record; + const logText = Object.entries(files) + .filter(([name]) => name.startsWith('log')) + .map(([, bytes]) => strFromU8(bytes)) + .join('\n'); + expect(logText).toContain('important-error-line'); + expect(logText).not.toContain('chatty-info-line'); + }); +}); + +describe('reportDefault{Crash,Error}Priority', () => { + const fireWindowError = () => + window.dispatchEvent(new ErrorEvent('error', { message: 'boom', error: new Error('boom') })); + + it('gives a detected crash the configured severity', async () => { + const { client, http } = await start({ reportDefaultCrashPriority: 'low' }); + fireWindowError(); + await client.flush(2000); + expect(requestOf(uploads(http)[0] as Record).severity).toBe(1); // low + }); + + it('keeps blocker for a crash by default', async () => { + const { client, http } = await start(); + fireWindowError(); + await client.flush(2000); + expect(requestOf(uploads(http)[0] as Record).severity).toBe(5); + }); + + it('gives logException the configured error severity, and an explicit severity still wins', async () => { + const { client, http } = await start({ reportDefaultErrorPriority: 'critical' }); + await client.logException(new Error('a')); + await client.logException(new Error('b'), { severity: 'low' }); + const [first, second] = uploads(http).map((f) => requestOf(f).severity); + expect([first, second]).toEqual([4, 1]); // critical, then the explicit low + }); +}); + +describe('detectHttpErrors', () => { + const stubFetch = (status: number) => { + globalThis.fetch = vi.fn(async () => new Response('nope', { status })) as typeof fetch; + }; + + it('reports a failed fetch as an http-error error report', async () => { + stubFetch(503); + const { client, http } = await start({ detectHttpErrors: true }); + await fetch('https://api.example.com/orders?token=SECRET'); + await client.flush(2000); + const request = requestOf(uploads(http)[0] as Record); + expect(request.source).toMatchObject({ type: 'error', mechanism: 'http-error' }); + expect(request.summary).toBe('HTTP 503 GET https://api.example.com/orders'); + expect(JSON.stringify(request)).not.toContain('SECRET'); + }); + + it('is off by default: a failed fetch files nothing', async () => { + stubFetch(503); + const { client, http } = await start(); + await fetch('https://api.example.com/orders'); + await client.flush(2000); + expect(uploads(http)).toHaveLength(0); + }); + + it('does not report a successful fetch', async () => { + stubFetch(200); + const { client, http } = await start({ detectHttpErrors: true }); + await fetch('https://api.example.com/orders'); + await client.flush(2000); + expect(uploads(http)).toHaveLength(0); + }); +}); + +describe('captureNetworkDefaultSanitizer', () => { + const networkText = async (over: Partial) => { + globalThis.fetch = vi.fn(async () => new Response('ok', { status: 200 })) as typeof fetch; + const { client, http } = await start({ captureNetwork: true, ...over }); + await fetch('https://api.example.com/orders?token=SECRETVALUE'); + await client.upload({ summary: 'x' }); + const files = uploads(http)[0] as Record; + return Object.entries(files) + .filter(([name]) => name.startsWith('network')) + .map(([, bytes]) => strFromU8(bytes)) + .join('\n'); + }; + + it('redacts secrets in captured URLs by default', async () => { + const text = await networkText({}); + expect(text).toContain('api.example.com/orders'); + expect(text).not.toContain('SECRETVALUE'); + }); + + it('launch(token, { captureNetworkDefaultSanitizer: false }) leaves them raw', async () => { + expect(await networkText({ captureNetworkDefaultSanitizer: false })).toContain('SECRETVALUE'); + }); +}); diff --git a/packages/bugsee/src/index.bun.ts b/packages/bugsee/src/index.bun.ts index a3d7cf68..7759c123 100644 --- a/packages/bugsee/src/index.bun.ts +++ b/packages/bugsee/src/index.bun.ts @@ -3,6 +3,9 @@ // @bugsee/bun's composition root, so the Bun-specific defaults are not silently lost. See ./bun. export type { Bugsee } from '@bugsee/bun'; +// The built-in network redaction, for composing with a custom `setNetworkEventFilter` (which REPLACES the +// default sanitizer rather than layering on it). +export { type DefaultNetworkSanitizer, getDefaultNetworkSanitizer } from '@bugsee/capture'; // Manual-instrumentation argument types — the curated public surface every adapter re-exports so users // can type their own event()/addBreadcrumb()/logException() calls without reaching into @bugsee/core. export type { diff --git a/packages/bugsee/src/index.deno.ts b/packages/bugsee/src/index.deno.ts index e4b8dad9..8cd7b077 100644 --- a/packages/bugsee/src/index.deno.ts +++ b/packages/bugsee/src/index.deno.ts @@ -2,6 +2,9 @@ // because Deno sets both). Re-exports the server SDK surface + the umbrella launch() bound to // @bugsee/deno's composition root, so the Deno-specific defaults are not silently lost. See ./deno. +// The built-in network redaction, for composing with a custom `setNetworkEventFilter` (which REPLACES the +// default sanitizer rather than layering on it). +export { type DefaultNetworkSanitizer, getDefaultNetworkSanitizer } from '@bugsee/capture'; // Manual-instrumentation argument types — the curated public surface every adapter re-exports so users // can type their own event()/addBreadcrumb()/logException() calls without reaching into @bugsee/core. export type { diff --git a/packages/bugsee/src/index.node.test.ts b/packages/bugsee/src/index.node.test.ts index b3a94bb3..9b6b68a3 100644 --- a/packages/bugsee/src/index.node.test.ts +++ b/packages/bugsee/src/index.node.test.ts @@ -20,6 +20,12 @@ describe('@bugsee/bugsee node entry — re-exports needed by every backend adapt expect(nodeEntry.RequestContextStoreToken).toBeDefined(); }); + it('re-exports getDefaultNetworkSanitizer so a custom network filter can keep the default redaction', () => { + expect( + nodeEntry.getDefaultNetworkSanitizer().sanitizeUrl('https://a.test/x?token=SECRET'), + ).not.toContain('SECRET'); + }); + it('re-exports the RequestContextStore type (F-5) so app code can type a resolved store', () => { const store = {} as RequestContextStore; expect(store).toBeDefined(); diff --git a/packages/bugsee/src/index.node.ts b/packages/bugsee/src/index.node.ts index e5205bb4..6729deba 100644 --- a/packages/bugsee/src/index.node.ts +++ b/packages/bugsee/src/index.node.ts @@ -1,6 +1,9 @@ // bugsee — the NODE entry (selected via the package `exports` "node" condition). Re-exports the Node SDK // surface + the umbrella launch() (Node composition root + on-by-default extensions). See ./node. +// The built-in network redaction, for composing with a custom `setNetworkEventFilter` (which REPLACES the +// default sanitizer rather than layering on it). +export { type DefaultNetworkSanitizer, getDefaultNetworkSanitizer } from '@bugsee/capture'; // Manual-instrumentation argument types — the curated public surface every adapter re-exports so users // can type their own event()/addBreadcrumb()/logException() calls without reaching into @bugsee/core. export type { diff --git a/packages/bugsee/src/index.ts b/packages/bugsee/src/index.ts index 029a896e..8b4b55b0 100644 --- a/packages/bugsee/src/index.ts +++ b/packages/bugsee/src/index.ts @@ -3,6 +3,9 @@ // adds launch() = the platform composition root + the on-by-default extensions (@bugsee/performance: // web-vitals + transactions + http spans) wired in. See docs/design/sdk-design.md §5. export type { Bugsee, ReportDialogOptions, ReportDialogStrings } from '@bugsee/browser'; +// The built-in network redaction, for composing with a custom `setNetworkEventFilter` (which REPLACES the +// default sanitizer rather than layering on it). +export { type DefaultNetworkSanitizer, getDefaultNetworkSanitizer } from '@bugsee/capture'; // Manual-instrumentation argument types — the curated public surface every adapter re-exports so users // can type their own event()/addBreadcrumb()/logException() calls without reaching into @bugsee/core. export type { diff --git a/packages/bugsee/src/public-api.test.ts b/packages/bugsee/src/public-api.test.ts index b9ef810f..3571b83a 100644 --- a/packages/bugsee/src/public-api.test.ts +++ b/packages/bugsee/src/public-api.test.ts @@ -27,6 +27,11 @@ describe('@bugsee/bugsee public API surface', () => { expect([attr, input, crumb, options]).toHaveLength(4); }); + it('exports getDefaultNetworkSanitizer so a custom network filter can keep the default redaction', () => { + const sanitizer = publicApi.getDefaultNetworkSanitizer(); + expect(sanitizer.sanitizeUrl('https://a.test/x?token=SECRET')).not.toContain('SECRET'); + }); + it('re-exports the types of the manual-report API (upload) and of the report dialog', () => { const upload = {} as UploadOptions; const dialog = {} as ReportDialogOptions; diff --git a/packages/capture/src/http-error-provider.test.ts b/packages/capture/src/http-error-provider.test.ts new file mode 100644 index 00000000..637ddce0 --- /dev/null +++ b/packages/capture/src/http-error-provider.test.ts @@ -0,0 +1,217 @@ +import { + type Client, + type Clock, + createMultiKeyEmitter, + type MultiKeyEmitter, + type ReportingRequest, +} from '@bugsee/core'; +import { BugseeOption, type NetworkEvent, type NetworkStage } from '@bugsee/protocol'; +import { describe, expect, it, vi } from 'vitest'; +import { createHttpErrorDetectionProvider } from './http-error-provider'; +import type { NetworkSource } from './network-provider'; + +const mkSource = (): MultiKeyEmitter> => createMultiKeyEmitter(); +const netEvent = (over: Partial = {}): NetworkEvent => ({ + timestamp: 1, + id: 'n1', + sequence: 'n1', + mechanism: 'fetch', + url: 'https://api.example.com/orders/42', + method: 'GET', + type: 'complete', + status: 500, + ...over, +}); +const fixedClock = (monotonic = { now: 0 }): Clock => ({ + wallNow: () => 0, + monotonicNow: () => monotonic.now, +}); + +const setup = (options?: Parameters[1]) => { + const source = mkSource(); + const provider = createHttpErrorDetectionProvider(source, options); + const report = vi.fn<(request: ReportingRequest) => void>(); + provider.start({} as Client, report); + return { source, provider, report }; +}; + +describe('createHttpErrorDetectionProvider', () => { + it('is named "http-error" and gated by the detectHttpErrors option', () => { + const provider = createHttpErrorDetectionProvider(mkSource()); + expect(provider.name).toBe('http-error'); + expect(provider.controllingOption).toBe(BugseeOption.DetectHttpErrors); + }); + + it('reports a failed response (>= 400) as an error report with mechanism http-error', () => { + const { source, report } = setup(); + source.emit('complete', netEvent({ status: 503, statusText: 'Service Unavailable' })); + expect(report).toHaveBeenCalledTimes(1); + const request = report.mock.calls[0]?.[0] as ReportingRequest; + expect(request.source).toEqual({ type: 'error', mechanism: 'http-error' }); + expect(request.report.type).toBe('error'); + expect(request.report.summary).toBe('HTTP 503 GET https://api.example.com/orders/42'); + expect(request.report.description).toBe( + 'GET https://api.example.com/orders/42 -> 503 Service Unavailable', + ); + }); + + it('treats 400 as the first failing status and 399 as success', () => { + const { source, report } = setup(); + source.emit('complete', netEvent({ status: 399 })); + expect(report).not.toHaveBeenCalled(); + source.emit('complete', netEvent({ status: 400 })); + expect(report).toHaveBeenCalledTimes(1); + }); + + it('ignores successful responses and a complete with no status', () => { + const { source, report } = setup(); + source.emit('complete', netEvent({ status: 200 })); + source.emit('complete', netEvent({ status: undefined })); + expect(report).not.toHaveBeenCalled(); + }); + + it('reports a network failure (error stage) with the redacted failure text', () => { + const { source, report } = setup(); + source.emit( + 'error', + netEvent({ + type: 'error', + status: undefined, + customError: 'TypeError: Failed to fetch https://api.example.com/orders/42?token=SECRET', + }), + ); + const request = report.mock.calls[0]?.[0] as ReportingRequest; + expect(request.report.summary).toBe( + 'HTTP request failed: GET https://api.example.com/orders/42', + ); + expect(request.report.description).not.toContain('SECRET'); + expect(request.report.description).toContain('Failed to fetch'); + }); + + it('reports an error stage with no failure text using the bare summary', () => { + const { source, report } = setup(); + source.emit('error', netEvent({ type: 'error', status: undefined, customError: undefined })); + const request = report.mock.calls[0]?.[0] as ReportingRequest; + expect(request.report.description).toBe('GET https://api.example.com/orders/42 failed'); + }); + + it('keeps the query string and fragment (where secrets live) out of the summary and description', () => { + const { source, report } = setup(); + source.emit( + 'complete', + netEvent({ url: 'https://api.example.com/x?api_key=SECRET#frag', status: 404 }), + ); + const request = report.mock.calls[0]?.[0] as ReportingRequest; + expect(request.report.summary).toBe('HTTP 404 GET https://api.example.com/x'); + expect(JSON.stringify(request.report)).not.toContain('SECRET'); + }); + + it('runs the path through the default URL redaction (a token-shaped path segment)', () => { + const { source, report } = setup(); + source.emit( + 'complete', + netEvent({ + url: 'https://a.test/confirm/eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiIxIn0.abcdefghijk', + status: 404, + }), + ); + const request = report.mock.calls[0]?.[0] as ReportingRequest; + expect(request.report.summary).toBe('HTTP 404 GET https://a.test/confirm/'); + }); + + it('strips URL credentials, including a password containing an @', () => { + const { source, report } = setup(); + source.emit('complete', netEvent({ url: 'https://user:p@ss@api.example.com/x', status: 401 })); + const request = report.mock.calls[0]?.[0] as ReportingRequest; + expect(request.report.summary).toBe('HTTP 401 GET https://api.example.com/x'); + }); + + it('strips URL credentials', () => { + const { source, report } = setup(); + source.emit( + 'complete', + netEvent({ url: 'https://user:hunter2@api.example.com/x', status: 401 }), + ); + expect(JSON.stringify(report.mock.calls[0]?.[0])).not.toContain('hunter2'); + }); + + it.each(['fetch', 'xhr', 'http'] as const)('covers the %s mechanism', (mechanism) => { + const { source, report } = setup(); + source.emit('complete', netEvent({ mechanism, status: 500 })); + expect(report).toHaveBeenCalledTimes(1); + }); + + it.each([ + 'ws', + 'sse', + 'webtransport', + 'sendBeacon', + ] as const)('ignores the %s mechanism', (mechanism) => { + const { source, report } = setup(); + source.emit('complete', netEvent({ mechanism, status: 500 })); + source.emit('error', netEvent({ mechanism, type: 'error', status: undefined })); + expect(report).not.toHaveBeenCalled(); + }); + + it('ignores before / redirect / abort stages even when they carry a failing status', () => { + const { source, report } = setup(); + source.emit('before', netEvent({ type: 'before', status: 500 })); + source.emit('redirect', netEvent({ type: 'redirect', status: 500 })); + source.emit('abort', netEvent({ type: 'abort', status: 500 })); + expect(report).not.toHaveBeenCalled(); + }); + + it('is rate limited: beyond the budget further failures are dropped, and the window reopens', () => { + const monotonic = { now: 0 }; + const { source, report } = setup({ + clock: fixedClock(monotonic), + rateLimit: { limit: 2, windowMs: 1000 }, + }); + for (let i = 0; i < 5; i++) { + source.emit('complete', netEvent({ status: 500 })); + } + expect(report).toHaveBeenCalledTimes(2); + monotonic.now = 1001; + source.emit('complete', netEvent({ status: 500 })); + expect(report).toHaveBeenCalledTimes(3); + }); + + it('has a default budget (not unbounded) on the system clock', () => { + const { source, report } = setup(); + for (let i = 0; i < 200; i++) { + source.emit('complete', netEvent({ status: 500 })); + } + expect(report.mock.calls.length).toBeGreaterThan(0); + expect(report.mock.calls.length).toBeLessThan(200); + }); + + it('stop releases the source subscription (so the interceptor can unpatch)', () => { + const off = vi.fn(); + const source = { + on: vi.fn(), + once: vi.fn(), + onAny: vi.fn(() => off), + } as unknown as NetworkSource; + const provider = createHttpErrorDetectionProvider(source); + provider.start({} as Client, vi.fn()); + expect(source.onAny).toHaveBeenCalledTimes(1); + expect(off).not.toHaveBeenCalled(); + provider.stop(); + expect(off).toHaveBeenCalledTimes(1); + }); + + it('stop unsubscribes from the source', () => { + const { source, provider, report } = setup(); + provider.stop(); + source.emit('complete', netEvent({ status: 500 })); + expect(report).not.toHaveBeenCalled(); + }); + + it('never throws into the network path on a malformed event', () => { + const { source, report } = setup(); + expect(() => + source.emit('complete', netEvent({ url: 42 as unknown as string, status: 500 })), + ).not.toThrow(); + expect(report).toHaveBeenCalledTimes(1); + }); +}); diff --git a/packages/capture/src/http-error-provider.ts b/packages/capture/src/http-error-provider.ts new file mode 100644 index 00000000..880d37cf --- /dev/null +++ b/packages/capture/src/http-error-provider.ts @@ -0,0 +1,125 @@ +import { + type Clock, + createRateLimiter, + createSystemClock, + type DetectionProvider, + DetectionProviderBase, + neverThrow, + type RateLimiterOptions, +} from '@bugsee/core'; +import { + BugseeOption, + type NetworkEvent, + type NetworkMechanism, + sanitizeErrorMessage, + sanitizeUrl, +} from '@bugsee/protocol'; + +import type { NetworkSource } from './network-provider'; + +// `detectHttpErrors` (Android `DetectAndReportHttpErrors`): a failed HTTP exchange — a response with +// status >= 400, or a request that never got a response — becomes an `error` report with mechanism +// `http-error`. It is a detection provider that SUBSCRIBES to the network sources (pub/sub design), so it +// adds no hook of its own and sees exactly what network capture sees (the SDK's own traffic is already +// skipped by the interceptors). Off by default, as on mobile. + +/** Request/response transports. ws/sse/webtransport have no status; sendBeacon is fire-and-forget. */ +const HTTP_MECHANISMS: ReadonlySet = new Set(['fetch', 'xhr', 'http']); + +// A failing endpoint that is polled, or a page that retries in a loop, would otherwise file a report per +// attempt. This is a budget of its own, so that such a storm cannot also exhaust the client's capture +// budget for real crashes. 20 per minute: enough to see the pattern, far from a flood. +const DEFAULT_RATE_LIMIT: RateLimiterOptions = { limit: 20, windowMs: 60_000 }; + +export interface HttpErrorProviderOptions { + /** Monotonic time source for the rate limiter; injectable. Default the system clock. */ + clock?: Clock; + /** Reports per rolling window. Default 20 per 60 s. */ + rateLimit?: RateLimiterOptions; +} + +/** + * The request target for a report's text: scheme, host and path only. The query string and fragment are + * dropped entirely (that is where tokens and ids live, and keeping them would also make every request its + * own issue), credentials are dropped, and the remainder is passed through the default URL redaction. + */ +const reportableUrl = (raw: unknown): string => { + const url = String(raw); + const match = /^([a-z][a-z0-9+.-]*:\/\/)(?:[^/?#]*@)?([^/?#]*)([^?#]*)/i.exec(url); + const stripped = + match !== null ? `${match[1]}${match[2]}${match[3]}` : (url.split(/[?#]/)[0] ?? ''); + return sanitizeUrl(stripped); +}; + +class HttpErrorDetectionProvider extends DetectionProviderBase { + readonly name = 'http-error'; + readonly controllingOption = BugseeOption.DetectHttpErrors; + readonly #source: NetworkSource; + readonly #limiter: { tryAcquire(): boolean }; + #off: (() => void) | null = null; + + constructor(source: NetworkSource, options: HttpErrorProviderOptions) { + super(); + this.#source = source; + this.#limiter = createRateLimiter( + options.clock ?? createSystemClock(), + options.rateLimit ?? DEFAULT_RATE_LIMIT, + ); + } + + protected onStart(): void { + this.#off = this.#source.onAny((stage, event) => { + // Contained: this runs inside the network interceptor's emit, which is the application's request. + neverThrow(() => this.#onNetwork(stage, event)); + }); + } + + protected override onStop(): void { + this.#off?.(); + this.#off = null; + } + + #onNetwork(stage: string, event: NetworkEvent): void { + if (!HTTP_MECHANISMS.has(event.mechanism)) { + return; + } + const failedResponse = stage === 'complete' && (event.status ?? 0) >= 400; + if (!failedResponse && stage !== 'error') { + return; + } + if (!this.#limiter.tryAcquire()) { + return; + } + const target = `${event.method} ${reportableUrl(event.url)}`; + if (failedResponse) { + const statusText = event.statusText ? ` ${sanitizeErrorMessage(event.statusText)}` : ''; + this.handleReportingRequest( + this.createErrorReport({ + mechanism: 'http-error', + summary: `HTTP ${event.status} ${target}`, + description: `${target} -> ${event.status}${statusText}`, + }), + ); + return; + } + const cause = + typeof event.customError === 'string' && event.customError !== '' + ? `: ${sanitizeErrorMessage(event.customError)}` + : ''; + this.handleReportingRequest( + this.createErrorReport({ + mechanism: 'http-error', + summary: `HTTP request failed: ${target}`, + description: `${target} failed${cause}`, + }), + ); + } +} + +/** The HTTP-error detection provider: network sources → `error` reports. Gated by `detectHttpErrors`. */ +export function createHttpErrorDetectionProvider( + source: NetworkSource, + options: HttpErrorProviderOptions = {}, +): DetectionProvider { + return new HttpErrorDetectionProvider(source, options); +} diff --git a/packages/capture/src/index.ts b/packages/capture/src/index.ts index 6c0f6769..186888bb 100644 --- a/packages/capture/src/index.ts +++ b/packages/capture/src/index.ts @@ -17,6 +17,10 @@ export { type FetchInterceptorOptions, type FetchTarget, } from './fetch-interceptor'; +export { + createHttpErrorDetectionProvider, + type HttpErrorProviderOptions, +} from './http-error-provider'; // `InputSource`/`InputProviderOptions` (./input-provider) are structural-typing helpers with no external // consumer — callers pass a value that satisfies `InputSource` (e.g. browser's `BrowserInputSource`) // without ever naming the type, and no caller passes provider options. Not re-exported (R3-13). @@ -30,7 +34,12 @@ export { } from './install-network-capture'; export { createLogCaptureProvider } from './log-provider'; export { createNetworkInterceptor } from './network-interceptor'; -export { createNetworkCaptureProvider, type NetworkSource } from './network-provider'; +export { + createNetworkCaptureProvider, + type DefaultNetworkSanitizer, + getDefaultNetworkSanitizer, + type NetworkSource, +} from './network-provider'; export { createRequestDecoratorRegistry, type OutgoingRequest, diff --git a/packages/capture/src/log-provider.test.ts b/packages/capture/src/log-provider.test.ts index 24f45ddb..0ad924eb 100644 --- a/packages/capture/src/log-provider.test.ts +++ b/packages/capture/src/log-provider.test.ts @@ -87,6 +87,49 @@ describe('createLogCaptureProvider', () => { expect((entries?.[0]?.data as LogEvent).message).toBe('X'); }); + describe('captureLogsLevel', () => { + const at = (level: LogEvent['level'], message: string): LogEvent => ({ + timestamp: 1, + level, + source: 'console', + message, + }); + const run = async (opts: OptionsContainer, events: LogEvent[]) => { + const store = mkStore(); + const source = mkSource(); + const p = createLogCaptureProvider(source); + p.init(buildInit(store)); + p.start(opts); + for (const e of events) { + source.emit('log', e); + } + return (await drainLog(store))?.map((e) => (e.data as LogEvent).message) ?? []; + }; + const level = (value: unknown): OptionsContainer => + createOptionsContainer({ [BugseeOption.CaptureLogsLevel]: value }); + + it('drops console records below the configured level', async () => { + const all = [at('error', 'e'), at('warning', 'w'), at('info', 'i'), at('debug', 'd')]; + expect(await run(level('warning'), all)).toEqual(['e', 'w']); + expect(await run(level('error'), all)).toEqual(['e']); + }); + + it('captures every level by default (verbose) and for an unknown level', async () => { + const all = [at('error', 'e'), at('verbose', 'v')]; + expect(await run(options, all)).toEqual(['e', 'v']); + expect(await run(level('chatty'), all)).toEqual(['e', 'v']); + }); + + it('applies before the user log filter (a dropped level never reaches it)', async () => { + const seen = vi.fn((e: LogEvent) => e); + const filters = createFilterStore(vi.fn()); + filters.log = seen; + publishFilters(filters); + expect(await run(level('error'), [at('info', 'i'), at('error', 'e')])).toEqual(['e']); + expect(seen).toHaveBeenCalledTimes(1); + }); + }); + it('captures multiple events in order', async () => { const store = mkStore(); const source = mkSource(); diff --git a/packages/capture/src/log-provider.ts b/packages/capture/src/log-provider.ts index b4b99db0..bfac72eb 100644 --- a/packages/capture/src/log-provider.ts +++ b/packages/capture/src/log-provider.ts @@ -3,10 +3,14 @@ import { CaptureProviderBase, type EventSubscribable, getFilters, + isLogLevelEnabled, type LogEvent, + type OptionsContainer, + parseLogLevelName, runFilter, } from '@bugsee/core'; import { BugseeOption, logLevelToWire } from '@bugsee/protocol'; +import type { LogLevelName } from '@bugsee/types'; // Runtime-agnostic log capture CONSUMER (design §16.1): subscribes to a log SOURCE and routes each // LogEvent to the aggregator as a `log` capture entry. The source is any emitter with a 'log' stage — @@ -38,14 +42,22 @@ class LogCaptureProvider extends CaptureProviderBase { readonly controllingOption = BugseeOption.CaptureLogs; readonly #source: LogSource; #off: (() => void) | null = null; + #level: LogLevelName = 'verbose'; constructor(source: LogSource) { super(); this.#source = source; } - protected onStart(): void { + protected onStart(options: OptionsContainer): void { + // `captureLogsLevel`: records less severe than the level are never captured. An unrecognised value + // keeps everything — the client reports it once through onError at launch. + this.#level = + parseLogLevelName(options.get(BugseeOption.CaptureLogsLevel, 'verbose')) ?? 'verbose'; this.#off = this.#source.on('log', (event) => { + if (!isLogLevelEnabled(event.level, this.#level)) { + return; + } // Live per-event log filter (from the carrier's client); may mutate or DROP the entry. const filters = getFilters(); if (filters?.log) { diff --git a/packages/capture/src/network-provider.test.ts b/packages/capture/src/network-provider.test.ts index 91c72237..9c19d93b 100644 --- a/packages/capture/src/network-provider.test.ts +++ b/packages/capture/src/network-provider.test.ts @@ -16,7 +16,7 @@ import { } from '@bugsee/core'; import { BugseeOption, type NetworkEvent, type NetworkStage, sanitizeUrl } from '@bugsee/protocol'; import { afterEach, describe, expect, it, vi } from 'vitest'; -import { createNetworkCaptureProvider } from './network-provider'; +import { createNetworkCaptureProvider, getDefaultNetworkSanitizer } from './network-provider'; // Publish a filter store as the singleton client's `filters` service on the global carrier (the // provider reads it via getFilters()); reset between tests. @@ -844,3 +844,41 @@ describe('network URL absolutization', () => { expect(captured.url).not.toContain('MATRIXPATHSECRET'); }); }); + +describe('getDefaultNetworkSanitizer', () => { + const sanitizer = getDefaultNetworkSanitizer(); + + it('redacts a URL, an error message and a whole event exactly as the built-in sanitizer does', () => { + const raw = netEvent({ + url: 'https://api/x?token=SECRET', + customError: 'failed GET https://api/x?token=SECRET', + custom: { headers: { Authorization: 'Bearer SECRET' } }, + }); + expect(sanitizer.sanitizeUrl('https://api/x?token=SECRET')).toBe( + sanitizeUrl('https://api/x?token=SECRET'), + ); + expect(sanitizer.sanitizeUrl('https://api/x?token=SECRET')).not.toContain('SECRET'); + expect(sanitizer.sanitizeErrorMessage('failed GET https://api/x?token=SECRET')).not.toContain( + 'SECRET', + ); + const out = sanitizer.sanitize(raw); + expect(JSON.stringify(out)).not.toContain('SECRET'); + expect(JSON.stringify(raw)).toContain('SECRET'); // the input is not mutated + }); + + it('lets a custom network filter re-apply the default redaction (which a filter otherwise replaces)', async () => { + const store = mkStore(); + const source = mkSource(); + const filters = createFilterStore(vi.fn()); + filters.network = (e) => (e.url.includes('/health') ? null : sanitizer.sanitize(e)); + publishFilters(filters); + const p = createNetworkCaptureProvider(source); + p.init(buildInit(store)); + p.start(options); + source.emit('complete', netEvent({ url: 'https://api/x?token=SECRET' })); + source.emit('complete', netEvent({ url: 'https://api/health' })); + const captured = (await drainNetwork(store))?.map((e) => e.data as NetworkEvent); + expect(captured).toHaveLength(1); + expect(captured?.[0]?.url).not.toContain('SECRET'); + }); +}); diff --git a/packages/capture/src/network-provider.ts b/packages/capture/src/network-provider.ts index 661f48ca..c437fb52 100644 --- a/packages/capture/src/network-provider.ts +++ b/packages/capture/src/network-provider.ts @@ -130,6 +130,34 @@ const sanitize = (event: NetworkEvent): NetworkEvent => { }; }; +/** + * The built-in network redaction, as an object (Android `Bugsee.getDefaultNetworkSanitizer()`). + * + * Installing a network filter REPLACES the default sanitizer, so a filter that only drops or tweaks events + * silently ships URLs, headers and bodies unredacted. Call `sanitize` from inside the filter to keep the + * default redaction and then add your own rules on top. + */ +export interface DefaultNetworkSanitizer { + /** Redacts the URL, sensitive headers, body and error text of an event; returns a new event (or the same + * one when there is nothing to redact). Does not mutate its input. */ + sanitize(event: NetworkEvent): NetworkEvent; + /** Redacts sensitive query/fragment parameters and `user:pass@` credentials in a URL. */ + sanitizeUrl(url: string): string; + /** Redacts URLs and secrets quoted inside a free-text error message. */ + sanitizeErrorMessage(message: string): string; +} + +const DEFAULT_NETWORK_SANITIZER: DefaultNetworkSanitizer = { + sanitize, + sanitizeUrl, + sanitizeErrorMessage, +}; + +/** The built-in network sanitizer, for composing with a custom `setNetworkEventFilter` filter. */ +export function getDefaultNetworkSanitizer(): DefaultNetworkSanitizer { + return DEFAULT_NETWORK_SANITIZER; +} + class NetworkCaptureProvider extends CaptureProviderBase { readonly name = 'network'; readonly controllingOption = BugseeOption.CaptureNetwork; diff --git a/packages/core/src/client.test.ts b/packages/core/src/client.test.ts index 411f7b90..24915edc 100644 --- a/packages/core/src/client.test.ts +++ b/packages/core/src/client.test.ts @@ -2300,3 +2300,159 @@ describe('createClient — prepareUpload (capture frozen at preparation)', () => expect(enqueue).toHaveBeenCalledTimes(1); }); }); + +describe('createClient — mobile-parity options (T6)', () => { + const memStore = () => createMemoryCaptureStore({ maxRecordingTimeMs: Number.POSITIVE_INFINITY }); + const drain = async (store: CaptureStore, type: FileType) => + (await createCaptureExporter(store).drain()).get(type); + const noScheduler = { setInterval: () => 'h', clearInterval: () => {} }; + + describe('captureBreadcrumbs', () => { + it('drops addBreadcrumb when the option is switched off', async () => { + const store = memStore(); + const client = createClient({ + captureStore: store, + clock: fixedClock(1000), + isEnabled: (key) => key !== 'com.bugsee.option.capture.breadcrumbs', + }); + client.addBreadcrumb({ message: 'clicked' }); + expect(await drain(store, 'breadcrumbs')).toBeUndefined(); + }); + + it('keeps capturing when the option is on (the JS default)', async () => { + const store = memStore(); + const client = createClient({ captureStore: store, clock: fixedClock(1000) }); + client.addBreadcrumb({ message: 'clicked' }); + expect(await drain(store, 'breadcrumbs')).toHaveLength(1); + }); + }); + + describe('captureLogsLevel', () => { + const levelled = (level: unknown, onError?: (e: unknown) => void) => { + const store = memStore(); + const client = createClient({ + captureStore: store, + clock: fixedClock(1000), + launchOptions: createOptionsContainer({ 'com.bugsee.option.capture.logs.level': level }), + ...(onError !== undefined ? { onError } : {}), + }); + return { store, client }; + }; + + it('drops client.log records below the level and keeps the rest', async () => { + const { store, client } = levelled('warning'); + client.log('chatty', 'info'); + client.log('verbose-ish', 'debug'); + client.log('bad', 'error'); + client.log('careful', 'warning'); + const messages = (await drain(store, 'log'))?.map( + (e) => (e.data as { message: string }).message, + ); + expect(messages).toEqual(['bad', 'careful']); + }); + + it('also applies to a numeric wire level', async () => { + const { store, client } = levelled('error'); + client.log('x', 3 as unknown as 'info'); + expect(await drain(store, 'log')).toBeUndefined(); + }); + + it('captures everything by default (verbose)', async () => { + const store = memStore(); + const client = createClient({ captureStore: store, clock: fixedClock(1000) }); + client.log('v', 'verbose'); + expect(await drain(store, 'log')).toHaveLength(1); + }); + + it('warns through onError and falls back to verbose on an unknown level', async () => { + const onError = vi.fn(); + const { store, client } = levelled('chatty', onError); + expect(onError).toHaveBeenCalledTimes(1); + expect(String((onError.mock.calls[0]?.[0] as Error).message)).toBe( + 'captureLogsLevel must be one of error | warning | info | debug | verbose (got chatty); using verbose', + ); + client.log('v', 'verbose'); + expect(await drain(store, 'log')).toHaveLength(1); + }); + }); + + describe('reportDefaultCrashPriority / reportDefaultErrorPriority', () => { + const configured = (values: Record, onError?: (e: unknown) => void) => { + const report = vi.fn(async () => ({ ok: true })); + const provider = detectionProvider('d'); + const client = createClient({ + triggerPipeline: { report } as TriggerPipeline, + launchOptions: createOptionsContainer(values), + scheduler: noScheduler, + ...(onError !== undefined ? { onError } : {}), + }); + client.addDetectionProvider(provider); + client.launch(); + const sink = (provider.start as ReturnType).mock.calls[0]?.[1] as ( + r: ReportingRequest, + ) => void; + return { client, report, sink }; + }; + const CRASH = 'com.bugsee.option.reporting.defaults.crash-priority'; + const ERROR = 'com.bugsee.option.reporting.defaults.error-priority'; + const severities = (report: { mock: { calls: unknown[][] } }) => + report.mock.calls.map((c) => (c[0] as ReportingRequest).report.severity); + + it('applies the configured crash priority to a detected crash with no explicit severity', () => { + const { report, sink } = configured({ [CRASH]: 'critical' }); + sink(createReportingRequest({ source: { type: 'crash', mechanism: 'uncaught' } })); + expect(severities(report)).toEqual(['critical']); + }); + + it('applies the configured error priority to a detected error and to logException', async () => { + const { client, report, sink } = configured({ [ERROR]: 'low' }); + sink(createReportingRequest({ source: { type: 'error' } })); + await client.logException(new Error('x')); + expect(severities(report)).toEqual(['low', 'low']); + }); + + it('lets an explicit per-call severity win over the configured default', async () => { + const { client, report, sink } = configured({ [ERROR]: 'low', [CRASH]: 'low' }); + sink(createReportingRequest({ source: { type: 'crash' }, severity: 'blocker' })); + await client.logException(new Error('x'), { severity: 'critical' }); + expect(severities(report)).toEqual(['blocker', 'critical']); + }); + + it('keeps the mobile defaults (crash blocker, error high) when unconfigured', async () => { + const { client, report, sink } = configured({}); + sink(createReportingRequest({ source: { type: 'crash' } })); + await client.logException(new Error('x')); + expect(severities(report)).toEqual(['blocker', 'high']); + }); + + it('does not touch a bug-type report (that default is reportDefaultBugPriority)', async () => { + const { client, report } = configured({ [CRASH]: 'low', [ERROR]: 'low' }); + await client.upload({}); + expect(severities(report)).toEqual(['high']); + }); + + it('lets the report handler still rewrite the severity afterwards', () => { + const { client, report, sink } = configured({ [CRASH]: 'low' }); + client.setReportHandler({ + before: (request) => { + request.report.severity = 'medium'; + return request; + }, + }); + sink(createReportingRequest({ source: { type: 'crash' } })); + expect(severities(report)).toEqual(['medium']); + }); + + it('warns through onError and falls back to the mobile default for an invalid value', () => { + const onError = vi.fn(); + const { report, sink } = configured({ [CRASH]: 'urgent', [ERROR]: 7 }, onError); + const messages = onError.mock.calls.map((c) => String((c[0] as Error).message)); + expect(messages).toEqual([ + 'reportDefaultCrashPriority must be one of verylow | low | medium | high | critical | blocker (got urgent); using blocker', + 'reportDefaultErrorPriority must be one of verylow | low | medium | high | critical | blocker (got 7); using high', + ]); + sink(createReportingRequest({ source: { type: 'crash' } })); + expect(severities(report)).toEqual(['blocker']); + }); + }); +}); diff --git a/packages/core/src/client.ts b/packages/core/src/client.ts index 5baad255..622f6954 100644 --- a/packages/core/src/client.ts +++ b/packages/core/src/client.ts @@ -1,5 +1,6 @@ import type { FileType } from '@bugsee/protocol'; import { + BugseeOption, type EnvironmentEnvelope, type LogLevel, logLevelToWire, @@ -57,12 +58,18 @@ import { callSiteFrames, parseV8Stack, type StackFrame } from './stack'; export type { Breadcrumb, BreadcrumbInput } from './events'; import { createExtensionRegistry } from './extension-registry'; +import { isLogLevelEnabled, parseLogLevelName } from './log-level'; import { createMemoryCaptureStore } from './memory-capture-store'; 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, + hasDefaultSeverity, + parseSeverityName, + type ReportingRequest, +} from './reporting'; import { type ContextProvider, ContextProviderToken, type RequestContext } from './request-context'; import type { ServiceRegistrar, ServiceResolver } from './services'; import { @@ -201,6 +208,25 @@ export interface BugseeClient extends Client, ServiceResolver, ServiceRegistrar // Redaction filters (§4.1#4 / §7.1): each runs per captured event to mutate it or DROP it (return // null). A network filter REPLACES the built-in sanitizer. Pass null to clear. Settable any time. + /** + * Filter captured network events: mutate one, or return null to drop it. + * + * **A network filter REPLACES the built-in sanitizer — it does not layer on top of it.** Once a filter is + * set, nothing is redacted unless the filter does it: URLs, headers, bodies and error text are captured + * as-is. To keep the default redaction and add rules of your own, call the default sanitizer from inside + * the filter: + * + * ```ts + * import { getDefaultNetworkSanitizer } from '@bugsee/bugsee'; + * const defaults = getDefaultNetworkSanitizer(); // { sanitize, sanitizeUrl, sanitizeErrorMessage } + * client.setNetworkEventFilter((event) => + * event.url.includes('/health') ? null : defaults.sanitize(event), + * ); + * ``` + * + * The filter runs once per stage (`before`, `complete`, `error`, …) of a request, so a veto must be + * returned for every stage of the requests you want gone. + */ setNetworkEventFilter(filter: NetworkEventFilter | null): void; setLogEventFilter(filter: LogEventFilter | null): void; setBreadcrumbFilter(filter: BreadcrumbFilter | null): void; @@ -429,6 +455,43 @@ export function createClient(options: CreateClientOptions = {}): BugseeClient { const captureProviderInit: CaptureProviderInit = { operations, captureAggregator }; const captureCoordinator = createCaptureCoordinator(captureProviderInit); const launchOptions = options.launchOptions ?? createOptionsContainer(); + // Launch options that arrive as untrusted strings are normalised once, here, with a warning: a typo + // ('urgent', 'warn') must not leave a report with no severity or silently change what is captured. + const logsLevelOption = launchOptions.get(BugseeOption.CaptureLogsLevel, 'verbose'); + const logsLevel = parseLogLevelName(logsLevelOption) ?? 'verbose'; + if (parseLogLevelName(logsLevelOption) === undefined) { + onError( + new Error( + `captureLogsLevel must be one of error | warning | info | debug | verbose (got ${String(logsLevelOption)}); using verbose`, + ), + ); + } + const resolvePriority = ( + key: string, + friendly: string, + fallback: 'blocker' | 'high', + ): SeverityName => { + const configured = launchOptions.get(key, fallback); + const parsed = parseSeverityName(configured); + if (parsed === undefined) { + onError( + new Error( + `${friendly} must be one of verylow | low | medium | high | critical | blocker (got ${String(configured)}); using ${fallback}`, + ), + ); + } + return parsed ?? fallback; + }; + const crashPriority = resolvePriority( + BugseeOption.ReportingDefaultCrashPriority, + 'reportDefaultCrashPriority', + 'blocker', + ); + const errorPriority = resolvePriority( + BugseeOption.ReportingDefaultErrorPriority, + 'reportDefaultErrorPriority', + 'high', + ); const detectionCoordinator = createDetectionCoordinator(); const extensionRegistry = createExtensionRegistry(); const scheduler = options.scheduler ?? defaultScheduler; @@ -624,8 +687,19 @@ export function createClient(options: CreateClientOptions = {}): BugseeClient { }; // Apply the report handler's `before` (mutate/veto) at report entry — before assembly. null = veto. - const applyReportBefore = (request: ReportingRequest): ReportingRequest | null => - runFilter(filters.report?.before ?? null, request, onError); + // Before the handler runs, a crash/error report whose severity was only DERIVED takes the configured + // `reportDefault{Crash,Error}Priority`; the handler (and any explicit severity) still wins. + const applyReportBefore = (request: ReportingRequest): ReportingRequest | null => { + const { report } = request; + if (hasDefaultSeverity(report)) { + if (report.type === 'crash') { + report.severity = crashPriority; + } else if (report.type === 'error') { + report.severity = errorPriority; + } + } + return runFilter(filters.report?.before ?? null, request, onError); + }; // Submit a finalized (post-before-filter) report. When a capture-recovery marker hook is present, // persist a pending marker (with the incident-time attributes + user identifier) BEFORE assembly, then @@ -794,7 +868,7 @@ export function createClient(options: CreateClientOptions = {}): BugseeClient { }, addBreadcrumb(breadcrumb: BreadcrumbInput): void { - if (killed) { + if (killed || !isEnabled(BugseeOption.CaptureBreadcrumbs)) { return; } const timestamp = breadcrumb.timestamp ?? clock.wallNow(); @@ -808,7 +882,7 @@ export function createClient(options: CreateClientOptions = {}): BugseeClient { }, log(message: string, level: LogLevel | LogLevelName = 'info', timestamp?: number): void { - if (killed) { + if (killed || !isLogLevelEnabled(level, logsLevel)) { return; } const ts = timestamp ?? clock.wallNow(); diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 22821791..7ab64c8e 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -158,6 +158,7 @@ export { type ReconcileOwnQueue, runLaunchRecovery, } from './launch-recovery'; +export { isLogLevelEnabled, parseLogLevelName } from './log-level'; export { createMemoryCaptureStore, type MemoryCaptureStoreOptions, @@ -198,6 +199,8 @@ export { } from './report-marker-store'; export { createReportingRequest, + hasDefaultSeverity, + parseSeverityName, type Report, type ReportAttachment, type ReportingRequest, diff --git a/packages/core/src/log-level.test.ts b/packages/core/src/log-level.test.ts new file mode 100644 index 00000000..45b65058 --- /dev/null +++ b/packages/core/src/log-level.test.ts @@ -0,0 +1,40 @@ +import { LogLevel } from '@bugsee/protocol'; +import { describe, expect, it } from 'vitest'; +import { isLogLevelEnabled, parseLogLevelName } from './log-level'; + +describe('parseLogLevelName', () => { + it.each(['error', 'warning', 'info', 'debug', 'verbose'])('accepts %s', (name) => { + expect(parseLogLevelName(name)).toBe(name); + }); + + it('forgives case and surrounding space', () => { + expect(parseLogLevelName(' WARNING ')).toBe('warning'); + }); + + it.each(['warn', 'trace', '', 3, undefined, null, {}])('rejects %j', (value) => { + expect(parseLogLevelName(value)).toBeUndefined(); + }); +}); + +describe('isLogLevelEnabled', () => { + it('admits a level at or above (more severe than) the threshold', () => { + expect(isLogLevelEnabled('error', 'warning')).toBe(true); + expect(isLogLevelEnabled('warning', 'warning')).toBe(true); + }); + + it('refuses a level below the threshold', () => { + expect(isLogLevelEnabled('info', 'warning')).toBe(false); + expect(isLogLevelEnabled('verbose', 'error')).toBe(false); + }); + + it('admits everything at the verbose threshold', () => { + for (const level of ['error', 'warning', 'info', 'debug', 'verbose'] as const) { + expect(isLogLevelEnabled(level, 'verbose')).toBe(true); + } + }); + + it('accepts the numeric wire level too', () => { + expect(isLogLevelEnabled(LogLevel.Debug, 'info')).toBe(false); + expect(isLogLevelEnabled(LogLevel.Info, 'info')).toBe(true); + }); +}); diff --git a/packages/core/src/log-level.ts b/packages/core/src/log-level.ts new file mode 100644 index 00000000..3cbf734c --- /dev/null +++ b/packages/core/src/log-level.ts @@ -0,0 +1,26 @@ +import { type LogLevel, logLevelToWire } from '@bugsee/protocol'; +import type { LogLevelName } from '@bugsee/types'; + +// `captureLogsLevel` (Android `Options.CaptureLogsLevel`): the minimum severity of a log record worth +// capturing. Wire levels ascend in VERBOSITY (1=Error … 5=Verbose), so "at or above the threshold" is +// numerically `<=`. + +const NAMES: readonly string[] = ['error', 'warning', 'info', 'debug', 'verbose']; + +/** A log-level name from untrusted input (a launch option): the canonical name, else `undefined`. */ +export function parseLogLevelName(value: unknown): LogLevelName | undefined { + if (typeof value !== 'string') { + return undefined; + } + const name = value.trim().toLowerCase(); + return NAMES.includes(name) ? (name as LogLevelName) : undefined; +} + +/** True when a record of `level` is at least as severe as `threshold` (so it should be captured). */ +export function isLogLevelEnabled( + level: LogLevel | LogLevelName, + threshold: LogLevelName, +): boolean { + const wire = typeof level === 'string' ? logLevelToWire(level) : level; + return wire <= logLevelToWire(threshold); +} diff --git a/packages/core/src/options.test.ts b/packages/core/src/options.test.ts index 11b48569..20fbbaf8 100644 --- a/packages/core/src/options.test.ts +++ b/packages/core/src/options.test.ts @@ -113,6 +113,21 @@ describe('COMMON_OPTION_DEFINITIONS', () => { expect(byFriendly.get('maxRecordingTime')?.key).toBe(BugseeOption.Duration); }); + it('maps the mobile-parity options (T6) to their canonical identifiers and defaults', () => { + const expected: Array<[string, string, unknown]> = [ + ['captureLogsLevel', BugseeOption.CaptureLogsLevel, 'verbose'], + ['captureBreadcrumbs', BugseeOption.CaptureBreadcrumbs, true], + ['detectHttpErrors', BugseeOption.DetectHttpErrors, false], + ['captureNetworkDefaultSanitizer', BugseeOption.CaptureNetworkDefaultSanitizer, true], + ['reportDefaultCrashPriority', BugseeOption.ReportingDefaultCrashPriority, 'blocker'], + ['reportDefaultErrorPriority', BugseeOption.ReportingDefaultErrorPriority, 'high'], + ]; + for (const [friendly, key, def] of expected) { + expect(byFriendly.get(friendly)?.key).toBe(key); + expect(byFriendly.get(friendly)?.default).toBe(def); + } + }); + it('defaults capture/detect toggles on and the recording duration to 60s', () => { expect(byFriendly.get('captureLogs')?.default).toBe(true); expect(byFriendly.get('detectCrashes')?.default).toBe(true); @@ -137,6 +152,12 @@ describe('COMMON_OPTION_DEFINITIONS', () => { [BugseeOption.CaptureSystemEvents]: true, [BugseeOption.DetectCrash]: true, [BugseeOption.Duration]: 60, + [BugseeOption.CaptureLogsLevel]: 'verbose', + [BugseeOption.CaptureBreadcrumbs]: true, + [BugseeOption.DetectHttpErrors]: false, + [BugseeOption.CaptureNetworkDefaultSanitizer]: true, + [BugseeOption.ReportingDefaultCrashPriority]: 'blocker', + [BugseeOption.ReportingDefaultErrorPriority]: 'high', }); }); }); diff --git a/packages/core/src/options.ts b/packages/core/src/options.ts index 8b8f4044..fab65adf 100644 --- a/packages/core/src/options.ts +++ b/packages/core/src/options.ts @@ -77,4 +77,24 @@ export const COMMON_OPTION_DEFINITIONS: readonly OptionDefinition[] = [ { friendly: 'captureSystemEvents', key: BugseeOption.CaptureSystemEvents, default: true }, { friendly: 'detectCrashes', key: BugseeOption.DetectCrash, default: true }, { friendly: 'maxRecordingTime', key: BugseeOption.Duration, default: 60 }, + // Mobile-parity options (Android OptionsDescriptors). `captureBreadcrumbs` deliberately defaults to TRUE + // here although mobile defaults it to false — see docs/design/mobile-api-alignment.md, Kept differences. + { friendly: 'captureLogsLevel', key: BugseeOption.CaptureLogsLevel, default: 'verbose' }, + { friendly: 'captureBreadcrumbs', key: BugseeOption.CaptureBreadcrumbs, default: true }, + { friendly: 'detectHttpErrors', key: BugseeOption.DetectHttpErrors, default: false }, + { + friendly: 'captureNetworkDefaultSanitizer', + key: BugseeOption.CaptureNetworkDefaultSanitizer, + default: true, + }, + { + friendly: 'reportDefaultCrashPriority', + key: BugseeOption.ReportingDefaultCrashPriority, + default: 'blocker', + }, + { + friendly: 'reportDefaultErrorPriority', + key: BugseeOption.ReportingDefaultErrorPriority, + default: 'high', + }, ]; diff --git a/packages/core/src/reporting.test.ts b/packages/core/src/reporting.test.ts index 00624800..ce0bad75 100644 --- a/packages/core/src/reporting.test.ts +++ b/packages/core/src/reporting.test.ts @@ -1,6 +1,11 @@ import { describe, expect, it, vi } from 'vitest'; import type { CrashJson } from './crash'; -import { createReportingRequest, type ReportingTriggerType } from './reporting'; +import { + createReportingRequest, + hasDefaultSeverity, + parseSeverityName, + type ReportingTriggerType, +} from './reporting'; describe('createReportingRequest', () => { it('carries the source and uses a given id for both request and report', () => { @@ -71,6 +76,17 @@ describe('createReportingRequest', () => { expect(req.report.severity).toBe('medium'); }); + it('marks a report whose severity was derived (not given) so a configured default can replace it', () => { + const derived = createReportingRequest({ source: { type: 'crash' }, id: 'x' }); + const explicit = createReportingRequest({ + source: { type: 'crash' }, + severity: 'blocker', + id: 'y', + }); + expect(hasDefaultSeverity(derived.report)).toBe(true); + expect(hasDefaultSeverity(explicit.report)).toBe(false); + }); + it('passes through summary, description, email, labels and signatures', () => { const req = createReportingRequest({ source: { type: 'code_upload' }, @@ -112,3 +128,17 @@ describe('createReportingRequest', () => { expect(req.source.mechanism).toBeUndefined(); }); }); + +describe('parseSeverityName', () => { + it.each(['verylow', 'low', 'medium', 'high', 'critical', 'blocker'])('accepts %s', (name) => { + expect(parseSeverityName(name)).toBe(name); + }); + + it('forgives case and surrounding space', () => { + expect(parseSeverityName(' Critical ')).toBe('critical'); + }); + + it.each(['urgent', '', 3, undefined, null])('rejects %j', (value) => { + expect(parseSeverityName(value)).toBeUndefined(); + }); +}); diff --git a/packages/core/src/reporting.ts b/packages/core/src/reporting.ts index a966f71b..4a5d1bb5 100644 --- a/packages/core/src/reporting.ts +++ b/packages/core/src/reporting.ts @@ -93,6 +93,34 @@ function defaultType(trigger: ReportingTriggerType): IssueType { return 'bug'; } +const SEVERITY_NAMES: readonly string[] = [ + 'verylow', + 'low', + 'medium', + 'high', + 'critical', + 'blocker', +]; + +/** A severity name from untrusted input (a launch option): the canonical name, else `undefined`. */ +export function parseSeverityName(value: unknown): SeverityName | undefined { + if (typeof value !== 'string') { + return undefined; + } + const name = value.trim().toLowerCase(); + return SEVERITY_NAMES.includes(name) ? (name as SeverityName) : undefined; +} + +// Reports whose severity was DERIVED from their type rather than given. The client replaces those with +// the configured `reportDefault{Crash,Error}Priority`; an explicit severity always wins. Tracked +// out-of-band so the `Report` shape (and everything that serializes or compares it) is unchanged. +const derivedSeverity = new WeakSet(); + +/** True when the report's severity is the built-in per-type default, not one a caller asked for. */ +export function hasDefaultSeverity(report: Report): boolean { + return derivedSeverity.has(report); +} + function defaultSeverity(type: IssueType): SeverityName { return type === 'crash' ? 'blocker' : 'high'; } @@ -117,5 +145,8 @@ export function createReportingRequest( ...(init.crash !== undefined ? { crash: init.crash } : {}), ...(init.attachments !== undefined ? { attachments: init.attachments } : {}), }; + if (init.severity === undefined) { + derivedSeverity.add(report); + } return { id, source: init.source, report }; } diff --git a/packages/node/src/launch.ts b/packages/node/src/launch.ts index 38bb4e4f..300eeb7d 100644 --- a/packages/node/src/launch.ts +++ b/packages/node/src/launch.ts @@ -2,6 +2,7 @@ import { tmpdir } from 'node:os'; import process from 'node:process'; import { createConsoleInterceptor, + createHttpErrorDetectionProvider, createLogCaptureProvider, createSystemEventsProvider, createSystemTracesProvider, @@ -196,6 +197,15 @@ export interface BugseeLaunchOptions { unhandledRejections?: UnhandledRejectionMode; /** Capture request/response bodies (bounded read). Default true. */ captureNetworkBodies?: boolean; + /** + * Apply the built-in network PII sanitizer (URL, headers, bodies, error text). Default true. A + * `setNetworkEventFilter` filter REPLACES it; compose with `getDefaultNetworkSanitizer()`. + */ + captureNetworkDefaultSanitizer?: boolean; + /** Minimum level of a log record to capture: `error | warning | info | debug | verbose`. Default `verbose`. */ + captureLogsLevel?: 'error' | 'warning' | 'info' | 'debug' | 'verbose'; + /** Capture breadcrumbs (`addBreadcrumb` and adapter-supplied ones). Default true (mobile defaults to false). */ + captureBreadcrumbs?: boolean; /** Max captured request/response body size in bytes. Default 20480. */ maxNetworkBodySize?: number; /** Capture a body even when its Content-Type is missing/blank. Default false. */ @@ -222,6 +232,12 @@ export interface BugseeLaunchOptions { captureSystemEvents?: boolean; /** Detect uncaught exceptions + unhandled rejections. Default true. */ detectCrashes?: boolean; + /** Report failed outbound HTTP exchanges (status >= 400, or no response) as `error` reports. Default false. */ + detectHttpErrors?: boolean; + /** Severity a crash report starts with: `verylow | low | medium | high | critical | blocker`. Default `blocker`. */ + reportDefaultCrashPriority?: 'verylow' | 'low' | 'medium' | 'high' | 'critical' | 'blocker'; + /** Severity an error report starts with. Default `high`. */ + reportDefaultErrorPriority?: 'verylow' | 'low' | 'medium' | 'high' | 'critical' | 'blocker'; /** Detect main-thread/event-loop hangs and report them (Android BugseeDetectionHang). Default true. */ detectHangs?: boolean; /** @@ -792,6 +808,8 @@ export function launchCore(appToken: string, options: BugseeLaunchOptions = {}): // and never goes through `logException`, so a client-configured enricher was skipped for every // uncaught crash — the one case local variables exist for. client.addDetectionProvider(createUncaughtExceptionProvider(proc, enrichFrames)); + // Failed outbound HTTP exchanges → `error` reports (mechanism http-error). Off unless `detectHttpErrors`. + client.addDetectionProvider(createHttpErrorDetectionProvider(network.interceptor)); if ((options.unhandledRejections ?? 'preserve') !== 'none') { client.addDetectionProvider(createUnhandledRejectionProvider(proc, enrichFrames)); } diff --git a/packages/node/src/mobile-options.launch.test.ts b/packages/node/src/mobile-options.launch.test.ts new file mode 100644 index 00000000..9ffab480 --- /dev/null +++ b/packages/node/src/mobile-options.launch.test.ts @@ -0,0 +1,228 @@ +import { + createMemoryCaptureStore, + type HttpRequestOptions, + type HttpResponse, + type HttpTransport, +} from '@bugsee/core'; +import { strFromU8, unzipSync } from '@bugsee/util'; +import { afterEach, describe, expect, it, vi } from 'vitest'; +import type { SystemProbe } from './environment'; +import { type BugseeLaunchOptions, launch, type NodeRuntime } from './launch'; + +// Mobile-parity launch options (T6), end to end through the real Node composition root: the friendly name +// resolves to the canonical key, reaches `sdk.options` on the wire, and changes what is uploaded. + +const probe: SystemProbe = { + platformType: () => 'node', + runtimeVersion: () => '20.1.2', + osType: () => 'Linux', + osPlatform: () => 'darwin', + osRelease: () => '6.0', + osArch: () => 'arm64', + machine: () => 'x86_64', + cpuCount: () => 8, + totalMemory: () => 16_000, + freeMemory: () => 4_000, + utcOffsetMinutes: () => 0, + locale: () => 'en-US', +}; + +const jsonBody = (obj: unknown): Uint8Array => new Uint8Array(Buffer.from(JSON.stringify(obj))); +const uploadTransport = () => + vi.fn(async (url: string, _options: HttpRequestOptions = {}) => { + if (url.endsWith('/v2/sessions')) { + return { status: 200, headers: {}, body: jsonBody({ access_token: 'access' }) }; + } + if (url.endsWith('/v2/issues')) { + return { + status: 200, + headers: {}, + body: jsonBody({ endpoint: 'https://s3.test/put', issueId: 'i1', recordingId: 'r1' }), + }; + } + return { status: 200, headers: {}, body: new Uint8Array() } satisfies HttpResponse; + }); + +const fakeProcess = (): NodeRuntime => { + const listeners = new Map void>>(); + const proc = { + on(event: string, listener: (...args: unknown[]) => void) { + listeners.set(event, [...(listeners.get(event) ?? []), listener]); + return proc; + }, + off(event: string, listener: (...args: unknown[]) => void) { + listeners.set( + event, + (listeners.get(event) ?? []).filter((l) => l !== listener), + ); + return proc; + }, + listeners: (event: string) => [...(listeners.get(event) ?? [])], + stderr: { write: () => true }, + exit: vi.fn(), + kill: vi.fn(), + pid: process.pid, + fire: (event: string, ...args: unknown[]) => { + for (const l of listeners.get(event) ?? []) { + l(...args); + } + }, + }; + return proc as unknown as NodeRuntime; +}; + +const clients: ReturnType[] = []; +const realFetch = globalThis.fetch; +afterEach(async () => { + await Promise.all(clients.splice(0).map((c) => c.stop())); + globalThis.fetch = realFetch; + vi.restoreAllMocks(); + delete (globalThis as { __BUGSEE__?: unknown }).__BUGSEE__; +}); + +const start = (over: Partial = {}) => { + const transport = uploadTransport(); + const proc = fakeProcess(); + const client = launch('tok', { + process: proc, + transport, + systemProbe: probe, + systemMetricsSampler: () => [], + captureNetwork: false, + exitOnUncaught: false, + captureStore: createMemoryCaptureStore({ maxRecordingTimeMs: Number.POSITIVE_INFINITY }), + ...over, + }); + clients.push(client); + return { + client, + transport, + proc: proc as unknown as { fire: (e: string, ...a: unknown[]) => void }, + }; +}; + +const uploads = (transport: ReturnType) => + transport.mock.calls + .filter(([url]) => url === 'https://s3.test/put') + .map(([, options]) => unzipSync((options as HttpRequestOptions).body as Uint8Array)); +type Files = Record; +const requestOf = (files: Files) => + JSON.parse(strFromU8(files['request.json'] as Uint8Array)) as { + severity: number; + source: { type: string; mechanism?: string }; + summary?: string; + environment: { sdk: { options: Record } }; + }; + +describe('mobile-parity options reach sdk.options', () => { + it('sends the defaults and the launched values dots-to-colons', async () => { + const a = start(); + await a.client.upload({ summary: 'x' }); + expect(requestOf(uploads(a.transport)[0] as Files).environment.sdk.options).toMatchObject({ + 'com:bugsee:option:capture:logs:level': 'verbose', + 'com:bugsee:option:capture:breadcrumbs': true, + 'com:bugsee:option:detect:http-errors': false, + 'com:bugsee:option:capture:network:default-sanitizer': true, + 'com:bugsee:option:reporting:defaults:crash-priority': 'blocker', + 'com:bugsee:option:reporting:defaults:error-priority': 'high', + }); + }); + + it('sends the values the app launched with', async () => { + const b = start({ + captureLogsLevel: 'error', + captureBreadcrumbs: false, + detectHttpErrors: true, + captureNetworkDefaultSanitizer: false, + reportDefaultCrashPriority: 'critical', + reportDefaultErrorPriority: 'low', + }); + await b.client.upload({ summary: 'x' }); + expect(requestOf(uploads(b.transport)[0] as Files).environment.sdk.options).toMatchObject({ + 'com:bugsee:option:capture:logs:level': 'error', + 'com:bugsee:option:capture:breadcrumbs': false, + 'com:bugsee:option:detect:http-errors': true, + 'com:bugsee:option:capture:network:default-sanitizer': false, + 'com:bugsee:option:reporting:defaults:crash-priority': 'critical', + 'com:bugsee:option:reporting:defaults:error-priority': 'low', + }); + }); +}); + +describe('captureBreadcrumbs / captureLogsLevel', () => { + it('drops breadcrumbs when switched off and keeps them by default', async () => { + const off = start({ captureBreadcrumbs: false }); + off.client.addBreadcrumb({ message: 'dropped' }); + await off.client.upload({ summary: 'x' }); + expect(Object.keys(uploads(off.transport)[0] as Files)).not.toContain('breadcrumbs'); + }); + + it('keeps breadcrumbs by default', async () => { + const on = start(); + on.client.addBreadcrumb({ message: 'kept' }); + await on.client.upload({ summary: 'x' }); + expect(Object.keys(uploads(on.transport)[0] as Files)).toContain('breadcrumbs'); + }); + + it('captures console records at or above the level only', async () => { + const { client, transport } = start({ captureLogsLevel: 'warning' }); + console.info('chatty-info-line'); + console.error('important-error-line'); + await client.upload({ summary: 'x' }); + const files = uploads(transport)[0] as Files; + const logText = Object.entries(files) + .filter(([name]) => name.startsWith('log')) + .map(([, bytes]) => strFromU8(bytes)) + .join('\n'); + expect(logText).toContain('important-error-line'); + expect(logText).not.toContain('chatty-info-line'); + }); +}); + +describe('reportDefault{Crash,Error}Priority', () => { + it('gives a detected crash the configured severity (low = 1), and blocker (5) by default', async () => { + const low = start({ reportDefaultCrashPriority: 'low' }); + low.proc.fire('uncaughtException', new Error('boom')); + await vi.waitFor(() => expect(uploads(low.transport)).toHaveLength(1)); + expect(requestOf(uploads(low.transport)[0] as Files).severity).toBe(1); + }); + + it('keeps blocker for a crash by default', async () => { + const dflt = start(); + dflt.proc.fire('uncaughtException', new Error('boom')); + await vi.waitFor(() => expect(uploads(dflt.transport)).toHaveLength(1)); + expect(requestOf(uploads(dflt.transport)[0] as Files).severity).toBe(5); + }); + + it('gives logException the configured error severity; an explicit severity still wins', async () => { + const { client, transport } = start({ reportDefaultErrorPriority: 'critical' }); + await client.logException(new Error('a')); + await client.logException(new Error('b'), { severity: 'low' }); + expect(uploads(transport).map((f) => requestOf(f).severity)).toEqual([4, 1]); + }); +}); + +describe('detectHttpErrors', () => { + const stubFetch = (status: number) => { + globalThis.fetch = vi.fn(async () => new Response('nope', { status })) as typeof fetch; + }; + + it('reports a failed outbound fetch as an http-error error report, without the query string', async () => { + stubFetch(502); + const { client, transport } = start({ detectHttpErrors: true }); + await fetch('https://api.example.com/orders?token=SECRET'); + await client.flush(2000); + const request = requestOf(uploads(transport)[0] as Files); + expect(request.source).toMatchObject({ type: 'error', mechanism: 'http-error' }); + expect(request.summary).toBe('HTTP 502 GET https://api.example.com/orders'); + expect(JSON.stringify(request)).not.toContain('SECRET'); + }); + + it('is off by default', async () => { + stubFetch(502); + const { client, transport } = start(); + await fetch('https://api.example.com/orders'); + await client.flush(2000); + expect(uploads(transport)).toHaveLength(0); + }); +}); diff --git a/packages/protocol/src/options.test.ts b/packages/protocol/src/options.test.ts index e74ed407..96f2fc1d 100644 --- a/packages/protocol/src/options.test.ts +++ b/packages/protocol/src/options.test.ts @@ -94,6 +94,19 @@ describe('BugseeOption canonical identifiers', () => { ); }); + // Added for mobile parity (T6): byte for byte Android's `Options.*` strings. + it('match the Android log-level, breadcrumb, http-error and default-priority identifiers', () => { + expect(BugseeOption.CaptureLogsLevel).toBe('com.bugsee.option.capture.logs.level'); + expect(BugseeOption.CaptureBreadcrumbs).toBe('com.bugsee.option.capture.breadcrumbs'); + expect(BugseeOption.DetectHttpErrors).toBe('com.bugsee.option.detect.http-errors'); + expect(BugseeOption.ReportingDefaultCrashPriority).toBe( + 'com.bugsee.option.reporting.defaults.crash-priority', + ); + expect(BugseeOption.ReportingDefaultErrorPriority).toBe( + 'com.bugsee.option.reporting.defaults.error-priority', + ); + }); + it('are all namespaced under com.bugsee.option. and unique', () => { const keys = Object.values(BugseeOption); expect(keys.every((k) => k.startsWith('com.bugsee.option.'))).toBe(true); diff --git a/packages/protocol/src/options.ts b/packages/protocol/src/options.ts index 1c154dfb..7eaeafbd 100644 --- a/packages/protocol/src/options.ts +++ b/packages/protocol/src/options.ts @@ -42,6 +42,13 @@ export const BugseeOption = { CaptureNetworkBodySizeLimit: 'com.bugsee.option.capture.network.body-size-limit', /** Capture a body even when its Content-Type is missing/blank (default off). */ CaptureNetworkBodyWithoutType: 'com.bugsee.option.capture.network.body-without-type', + /** Minimum console/log level captured: `error | warning | info | debug | verbose`. Default `verbose`. */ + CaptureLogsLevel: 'com.bugsee.option.capture.logs.level', + /** + * Capture breadcrumbs (manual `addBreadcrumb` and the automatic UI/route ones). Mobile defaults this + * to false; JS defaults it to TRUE because UI-interaction breadcrumbs are a core web signal. + */ + CaptureBreadcrumbs: 'com.bugsee.option.capture.breadcrumbs', /** Apply the default network PII sanitizer. */ CaptureNetworkDefaultSanitizer: 'com.bugsee.option.capture.network.default-sanitizer', /** Capture periodic system traces (memory / cpu / event-loop lag). */ @@ -60,6 +67,8 @@ export const BugseeOption = { CaptureViewHierarchy: 'com.bugsee.option.capture.view-hierarchy', /** Detect uncaught exceptions / unhandled rejections and report them. */ DetectCrash: 'com.bugsee.option.detect.crash', + /** Report failed HTTP responses (>= 400) and network failures as `error` reports. Default false. */ + DetectHttpErrors: 'com.bugsee.option.detect.http-errors', /** Detect main-thread/event-loop hangs (Android `BugseeDetectionHang` parity). */ DetectHang: 'com.bugsee.option.detect.hang', /** Hang escalation thresholds in ms (Android-canonical defaults 3000 / 5000 / 10000). */ @@ -84,6 +93,10 @@ export const BugseeOption = { ReportingUiPrioritySelectorEnabled: 'com.bugsee.option.reporting.ui.priority-selector-enabled', /** Severity a bug report starts with: `low | medium | high | critical | blocker`. Default `high`. */ ReportingDefaultBugPriority: 'com.bugsee.option.reporting.defaults.bug-priority', + /** Severity a crash report starts with. Default `blocker`. */ + ReportingDefaultCrashPriority: 'com.bugsee.option.reporting.defaults.crash-priority', + /** Severity an error report starts with. Default `high`. */ + ReportingDefaultErrorPriority: 'com.bugsee.option.reporting.defaults.error-priority', } as const; /** A canonical option identifier value (one of {@link BugseeOption}'s string values). */ @@ -96,6 +109,9 @@ export type BugseeOptionKey = (typeof BugseeOption)[keyof typeof BugseeOption]; */ export interface BugseeOptionTypes { 'com.bugsee.option.capture.logs': boolean; + 'com.bugsee.option.capture.logs.level': string; + 'com.bugsee.option.capture.breadcrumbs': boolean; + 'com.bugsee.option.detect.http-errors': boolean; 'com.bugsee.option.capture.network': boolean; 'com.bugsee.option.capture.network.bodies': boolean; 'com.bugsee.option.capture.network.body-size-limit': number; @@ -119,4 +135,6 @@ export interface BugseeOptionTypes { 'com.bugsee.option.reporting.ui.labels-required': boolean; 'com.bugsee.option.reporting.ui.priority-selector-enabled': boolean; 'com.bugsee.option.reporting.defaults.bug-priority': string; + 'com.bugsee.option.reporting.defaults.crash-priority': string; + 'com.bugsee.option.reporting.defaults.error-priority': string; }