Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions docs/PROGRESS.md
Original file line number Diff line number Diff line change
Expand Up @@ -83,6 +83,7 @@ Read this first; then `docs/design/sdk-design.md` (Draft v3) for the full archit
- `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<sdk>` 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 `<dialog>.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.
- **`client.notify(title, body?, severity?, fields?, urgent?)`** (T7, `docs/design/mobile-api-alignment.md` "T7 findings"): relays a message to the app's Slack / webhook integrations through the existing collector route `POST /v2/notifications` (app token in the body, no session, public CORS, no app-type check; verified in the appserver source). Core `createNotifyRelay` (`notify-relay.ts`): in-memory queue, batches of 50 / 256 KB, `notifyFlushDelay` window (default 0), `urgent` skips it, 2 s to 60 s backoff on transient failure, per-item `failed` retried; `BugseeApi.notify` classifies the answer; `flush()`/`stop()` deliver. Wired in browser and node. **Not persisted** (mobile persists to disk); Vercel edge / web worker / Cloudflare have no sender yet.
- **`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)
Expand Down
64 changes: 63 additions & 1 deletion docs/design/mobile-api-alignment.md
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ Each task is test-first with the mutator loop, keeps the coverage gate, and land
| T4 | Blackout: `startBlackout` / `endBlackout` / `isBlackout`; suspends replay, input and viewtree capture while logs and network continue | `Bugsee.startBlackout` | emits the T1 events once T1 lands |
| T5 | Launch state: `getStatus()` with the Stopped/Launching/Launched/Stopping enum, `relaunch(options?)`, `getLaunchOptions()`, `getHostLaunchOptions()`; verify launch → stop → launch works | `getStatus`, `relaunch`, `getLaunchOptions` | |
| T6 | Missing options: `captureLogsLevel`, `captureBreadcrumbs`, `detectHttpErrors`, `captureNetworkDefaultSanitizer`, `reportDefaultCrashPriority`, `reportDefaultErrorPriority`; `getDefaultNetworkSanitizer()` so a custom filter can reuse the default redaction | `OptionsDescriptors` keys under `com.bugsee.option.*` | canonical keys already exist for some |
| T7 | `notify(title, body?, severity?, fields?, urgent?)` | `Bugsee.notify` | needs a backend check first: which endpoint carries it, and does the collector accept it from a browser |
| T7 | `notify(title, body?, severity?, fields?, urgent?)` | `Bugsee.notify` | **Done.** The collector already accepts it from a browser; see "T7 findings" |
| 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 |

Expand All @@ -43,6 +43,68 @@ Invalid values warn through `onError` and fall back to the default.
`{ sanitize, sanitizeUrl, sanitizeErrorMessage }`, the same object the built-in sanitizer uses. Call it from inside a
`setNetworkEventFilter` filter to keep the default redaction.

## T7 findings: `notify()`

Read from Android (`library/.../relay/*`, `BugseeInternal.notify`), iOS (`BGSNotificationRelay*`, `Bugsee.h`) and the
appserver (`code/components/app/notification-relay/*`, `code/routing/routes/api/v2.routes.js:116-124`,
`config/default.js` `web.cors.public`).

**What it is.** A message relayed to the app's configured messaging integrations (Slack, webhooks, ...) by the
collector. It never enters the report pipeline: no video, logs or events, no issue, no bundle upload.

**Endpoint.** `POST {api}/v2/notifications`. Body `{ "app_token": "...", "notifications": [ item, ... ] }`; iOS also
sends `access_token` when it has one, Android authenticates through its session instead. The server needs neither:
`resolveOrgAndApp` (`notification-relay.service.js:~118-150`) takes the app from `body.app_token`, then `?app_token=`,
then a session. No access token and no `/v2/sessions` call are needed, so `notify()` works before the first report.

**Item** (`RelayNotification`, identical on both platforms; the server's `sanitizeItem` keeps exactly these keys):
`id` (client UUID, the dedup and status key), `timestamp` (ms), `title` (required), `body`, `severity` (1 to 5, the same
numbers as report severity), `fields` (string to string), `urgent` (true only), `delivery` (`immediate` or `deferred`,
stamped when it is sent), `user`, `environment`, `attributes` (snapshots taken at the call).
An item without an `id` or a non-empty `title` is `invalid` server-side.

**Response.** `{ ok: true, result: { notifications: { <id>: accepted | skipped | invalid | failed } } }`.
`failed` means the enqueue failed and the item should be sent again; the other three are final. `429 QuotaExceeded`
(`retryAfter`), `503 ServerTooBusy` (version blacklist), `401 InvalidAppToken`, `403 KillSdk`, `400 InvalidBatch`,
`500 EnqueueFailed` (with the per-item map). If relay is switched off for the app the server still answers
`accepted`, so the SDK drops the batch rather than retrying forever.

**Browser access (the question this task was blocked on): yes, it works.**
- `v2.routes.js:116-124` puts `middleware.common.publicCors` on `/notifications`, the same policy as `POST /sessions`:
origin `*`, no credentials, methods `POST, OPTIONS`, preflight answered by the route.
- Allowed request headers (`config/default.js:249-253`): `origin, content-type, accept, authorization, x-app-token,
x-client-type, x-bugsee-internal, x-tid`. The JS SDK's headers (`content-type`, `accept`, `x-client-type`,
`x-bugsee-internal`, `x-app-token`) are all inside it, and the browser tier sends no `user-agent`.
- No application-type check on this route: `utils.isValidForClient` runs only in `session.service.js:979` and
`issue.service.js:1131`. A `javascript` application (`x-client-type: javascript`) is therefore fine, and so is any
other type.
- Authentication is the app token, which a browser SDK ships in the page anyway; the abuse limiter is the server's
(`notification-relay.limiter.js`, unlimited in v1).
- Nothing is needed from the backend.

**Mobile semantics reproduced.**
- No-op for an empty title, before `launch()`, after `stop()`, and when the SDK was killed.
- Batch limits 50 notifications / 256 KB; one notification at most 256 KB (dropped with an `onError` otherwise).
- Flush window `notifyFlushDelay` (`com.bugsee.option.config.notify-flush-delay`, default 0 ms). The first
notification arms the window and later ones join it. Delivery is stamped `immediate` when the delay is 0 and
`deferred` otherwise; an `urgent` notification skips the window and is sent alone, stamped `immediate`
(Android `RelayNotification.flushDelivery`).
- Retry: network error, 408, 429, 5xx, or a per-item `failed` back off 2 s doubling to 60 s, keeping order. Other
4xx and a `{ ok: false }` envelope drop the batch (the error router answers HTTP 200 for a refusal).
- `fields` keeps only string values; severity is the report severity names.
- `client.flush()` and `client.stop()` deliver what is pending, so `await bugsee.stop()` before exit loses nothing.

**Kept differences.**
- The queue is **in memory**, not persisted. Mobile writes each notification to disk and a background job sends it
after a restart. A page closed or a process killed with an unsent notification loses it. A durable queue would
need its own IndexedDB / file store with multi-instance coexistence, which the bundle queue only gets through the
per-instance prefix and liveness machinery, so it is not cheap. Use `urgent`, `flush()` or a short `notifyFlushDelay`
where loss matters. The queue is capped at 1000 (oldest dropped) and survives `stop()` then `launch()` on one page.
- Wired in `@bugsee/browser` and `@bugsee/node` (so Bun, Deno and the umbrella). The Vercel edge, web worker and
Cloudflare entries compile `notify()` but have no sender, so it is a silent no-op there; an isolate that is frozen
after the response would lose a queued message anyway.
- A rejected or oversize notification is reported through `onError`, never thrown.

## Kept differences (documented, not changed)

- `captureBreadcrumbs` defaults to `true`, not mobile's `false`: UI-interaction breadcrumbs (click / change / submit /
Expand Down
7 changes: 7 additions & 0 deletions packages/browser/src/launch.ts
Original file line number Diff line number Diff line change
Expand Up @@ -242,6 +242,11 @@ export interface BugseeLaunchOptions {
reportDefaultCrashPriority?: SeverityName;
/** Severity an error report starts with. Default `high`. */
reportDefaultErrorPriority?: SeverityName;
/**
* `notify()` batching window in ms: after the first notification, wait this long so more can join its
* request. Default 0 (send as soon as the current tick ends); `urgent` notifications skip the wait.
*/
notifyFlushDelay?: number;
/**
* 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
Expand Down Expand Up @@ -586,6 +591,8 @@ async function buildLaunch(
launchOptionsView: { effective: resolved.canonical, host: resolved.host },
services,
uploadPipeline,
// `client.notify()` rides the same transport and app token as the session/issue calls.
notifySender: (items) => api.notify(items),
appToken,
getEnvironment,
captureStore,
Expand Down
149 changes: 149 additions & 0 deletions packages/browser/src/notify.launch.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,149 @@
// @vitest-environment jsdom
import 'fake-indexeddb/auto';
import {
createMemoryCaptureStore,
type HttpRequestOptions,
type HttpResponse,
type HttpTransport,
} from '@bugsee/core';
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';

// `notify()` (T7) end to end through the real browser composition root: it reaches the collector's
// POST /v2/notifications through the SAME transport as the session and issue calls, authenticated by the
// app token in the body (no session), and the friendly `notifyFlushDelay` option reaches the relay.

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 = () =>
vi.fn<HttpTransport>(async (url: string): Promise<HttpResponse> => {
if (url.endsWith('/v2/notifications')) {
return {
status: 200,
headers: {},
body: json({ ok: true, result: { notifications: {} } }),
};
}
return { status: 200, headers: {}, body: json({ access_token: 'access' }) };
});

const clients: Bugsee[] = [];
const start = async (over: Partial<BugseeLaunchOptions> = {}, 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()));
resetBrowserDeviceIdCache();
delete (globalThis as { __BUGSEE__?: unknown }).__BUGSEE__;
});

const notifications = (http: ReturnType<typeof transport>) =>
http.mock.calls.filter(([url]) => url.endsWith('/v2/notifications')) as Array<
[string, HttpRequestOptions]
>;

describe('browser notify()', () => {
it('POSTs the message to /v2/notifications with the app token, without opening a session', async () => {
const { client, http } = await start();
client.notify('Deploy finished', 'v1.2 is live', 'high', { env: 'prod' });
await vi.waitFor(() => expect(notifications(http)).toHaveLength(1));

const [url, options] = notifications(http)[0] as [string, HttpRequestOptions];
expect(url).toMatch(/\/v2\/notifications$/);
expect(options.method).toBe('POST');
const body = JSON.parse(options.body as string) as {
app_token: string;
notifications: Array<Record<string, unknown>>;
};
expect(body.app_token).toBe('tok');
expect(body.notifications).toHaveLength(1);
expect(body.notifications[0]).toMatchObject({
title: 'Deploy finished',
body: 'v1.2 is live',
severity: 3,
fields: { env: 'prod' },
delivery: 'immediate',
});
expect(body.notifications[0]?.environment).toMatchObject({ sdk: { type: 'javascript' } });
const headers = options.headers as Record<string, string>;
expect(headers['x-app-token']).toBe('tok');
expect(headers['x-client-type']).toBe('javascript');
expect(headers.authorization).toBeUndefined();
expect(headers['user-agent']).toBeUndefined(); // a script-set UA turns the call into a preflight
expect(http.mock.calls.some(([u]) => u.endsWith('/v2/sessions'))).toBe(false);
});

it('batches a burst into one request', async () => {
const { client, http } = await start();
client.notify('a');
client.notify('b');
await vi.waitFor(() => expect(notifications(http)).toHaveLength(1));
const body = JSON.parse(notifications(http)[0]?.[1].body as string) as {
notifications: unknown[];
};
expect(body.notifications).toHaveLength(2);
});

it('honours notifyFlushDelay: nothing leaves until flush() or the window ends', async () => {
const { client, http } = await start({ notifyFlushDelay: 60_000 });
client.notify('later');
await new Promise((resolve) => setTimeout(resolve, 30));
expect(notifications(http)).toHaveLength(0);
await client.flush();
expect(notifications(http)).toHaveLength(1);
const item = (
JSON.parse(notifications(http)[0]?.[1].body as string) as {
notifications: Array<{ delivery: string }>;
}
).notifications[0];
expect(item?.delivery).toBe('deferred');
});

it('sends the delay on the wire as the canonical option', async () => {
const { client } = await start({ notifyFlushDelay: 250 });
expect(client.getLaunchOptions()['com.bugsee.option.config.notify-flush-delay']).toBe(250);
});

it('does nothing before launch has finished or after stop()', async () => {
const { client, http } = await start();
await client.stop();
client.notify('after stop');
await new Promise((resolve) => setTimeout(resolve, 30));
expect(notifications(http)).toHaveLength(0);
});
});
Loading
Loading