diff --git a/docs/PROGRESS.md b/docs/PROGRESS.md index 5a2001ba..89b06089 100644 --- a/docs/PROGRESS.md +++ b/docs/PROGRESS.md @@ -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` 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. +- **`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) diff --git a/docs/design/mobile-api-alignment.md b/docs/design/mobile-api-alignment.md index 9bf876a1..495f1e4f 100644 --- a/docs/design/mobile-api-alignment.md +++ b/docs/design/mobile-api-alignment.md @@ -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 | @@ -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: { : 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 / diff --git a/packages/browser/src/launch.ts b/packages/browser/src/launch.ts index ef7ee8f6..cf8c6316 100644 --- a/packages/browser/src/launch.ts +++ b/packages/browser/src/launch.ts @@ -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 @@ -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, diff --git a/packages/browser/src/notify.launch.test.ts b/packages/browser/src/notify.launch.test.ts new file mode 100644 index 00000000..23f32d9f --- /dev/null +++ b/packages/browser/src/notify.launch.test.ts @@ -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(async (url: string): Promise => { + 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 = {}, 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) => + 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>; + }; + 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; + 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); + }); +}); diff --git a/packages/core/src/bugsee-api.test.ts b/packages/core/src/bugsee-api.test.ts index 7a0eb119..4604a157 100644 --- a/packages/core/src/bugsee-api.test.ts +++ b/packages/core/src/bugsee-api.test.ts @@ -408,6 +408,108 @@ describe('createBugseeApi — a NON-2xx that also carries a collector code', () }); }); +describe('createBugseeApi — notify', () => { + const item = { id: 'n1', timestamp: 5, title: 'Deploy done' }; + const ok = (notifications?: Record): HttpResponse => + enveloped(notifications === undefined ? {} : { notifications }); + + it('POSTs the batch to /v2/notifications with the app token in the body, and no session', async () => { + const { transport, calls } = recorder(() => ok({ n1: 'accepted' })); + const second = { id: 'n2', timestamp: 6, title: 't' }; + const result = await api(transport).notify([item, second]); + expect(result).toEqual({ outcome: 'sent' }); + expect(calls).toHaveLength(1); + expect(calls[0]?.url).toBe('https://api.test/v2/notifications'); + expect(calls[0]?.options?.method).toBe('POST'); + expect(bodyJson(calls[0]?.options)).toEqual({ + app_token: 'app-1', + notifications: [item, second], + }); + const headers = headersOf(calls[0]?.options); + expect(headers['x-app-token']).toBe('app-1'); + expect(headers['x-client-type']).toBe('javascript'); + expect(headers['content-type']).toBe('application/json'); + expect(headers.authorization).toBeUndefined(); + }); + + it('treats a missing status map as every item sent', async () => { + const { transport } = recorder(() => ok()); + expect(await api(transport).notify([item])).toEqual({ outcome: 'sent' }); + }); + + it('reads a status map that is not wrapped in the v2 envelope', async () => { + const { transport } = recorder(() => ({ + status: 200, + headers: {}, + body: enc({ notifications: { n1: 'failed' } }), + })); + expect(await api(transport).notify([item])).toEqual({ outcome: 'retry', retryIds: ['n1'] }); + }); + + it('asks to retry only the items the collector marked failed', async () => { + const { transport } = recorder(() => + ok({ a: 'accepted', b: 'failed', c: 'skipped', d: 'invalid' }), + ); + const batch = ['a', 'b', 'c', 'd'].map((id) => ({ id, timestamp: 1, title: id })); + expect(await api(transport).notify(batch)).toEqual({ outcome: 'retry', retryIds: ['b'] }); + }); + + it('settles an item the map does not mention', async () => { + const { transport } = recorder(() => ok({ other: 'failed' })); + expect(await api(transport).notify([item])).toEqual({ outcome: 'sent' }); + }); + + it('ignores a malformed status map and a non-object body', async () => { + for (const body of [enc({ ok: true, result: { notifications: 'x' } }), enc(null), enc('s')]) { + const { transport } = recorder(() => ({ status: 200, headers: {}, body })); + expect(await api(transport).notify([item])).toEqual({ outcome: 'sent' }); + } + }); + + it('drops the batch when the collector rejects it inside a 200 envelope', async () => { + const { transport } = recorder(() => rejected('InvalidAppToken', 'bad', 401)); + expect(await api(transport).notify([item])).toEqual({ outcome: 'rejected' }); + }); + + it('retries on 408, 429 and every 5xx without a status map', async () => { + for (const status of [408, 429, 500, 503]) { + const { transport } = recorder(() => ({ status, headers: {}, body: enc({}) })); + expect(await api(transport).notify([item])).toEqual({ outcome: 'retry' }); + } + }); + + it('on a 5xx settles the items the collector named as not failed', async () => { + const { transport } = recorder(() => ({ + status: 500, + headers: {}, + body: enc({ ok: false, notifications: { n1: 'accepted', n2: 'failed' } }), + })); + const batch = ['n1', 'n2'].map((id) => ({ id, timestamp: 1, title: id })); + expect(await api(transport).notify(batch)).toEqual({ outcome: 'retry', retryIds: ['n2'] }); + }); + + it('drops the batch on any other 4xx', async () => { + for (const status of [400, 401, 403, 404, 499]) { + const { transport } = recorder(() => ({ status, headers: {}, body: enc({}) })); + expect(await api(transport).notify([item])).toEqual({ outcome: 'rejected' }); + } + }); + + it('retries when the transport throws or a 200 body is not JSON', async () => { + const throwing = async (): Promise => { + throw new Error('offline'); + }; + expect(await api(throwing).notify([item])).toEqual({ outcome: 'retry' }); + const { transport } = recorder(() => ({ status: 200, headers: {}, body: strToU8('') })); + expect(await api(transport).notify([item])).toEqual({ outcome: 'retry' }); + }); + + it('retries on a non-success status below 400', async () => { + const { transport } = recorder(() => ({ status: 302, headers: {}, body: enc({}) })); + expect(await api(transport).notify([item])).toEqual({ outcome: 'retry' }); + }); +}); + describe('createBugseeApi — invalidateSession', () => { it('forces the next ensureSession to re-authenticate', async () => { const { transport, calls } = recorder(() => sessionOk()); diff --git a/packages/core/src/bugsee-api.ts b/packages/core/src/bugsee-api.ts index d6795faf..74929aac 100644 --- a/packages/core/src/bugsee-api.ts +++ b/packages/core/src/bugsee-api.ts @@ -2,7 +2,14 @@ import type { EnvironmentEnvelope, RequestJson } from '@bugsee/protocol'; import type { AccessToken, IssueId, RecordingId } from '@bugsee/types'; import { randomId, strFromU8 } from '@bugsee/util'; import { BugseeError } from './errors'; -import type { BugseeApi, HttpResponse, HttpTransport, IssueCreateResult } from './transport'; +import type { + BugseeApi, + HttpResponse, + HttpTransport, + IssueCreateResult, + NotifyItem, + NotifyResult, +} from './transport'; // The control-plane BugseeApi (design §7.5/§8.1/§8.2) — platform-agnostic logic over an injected // HttpTransport. Lazily creates a session (POST /v2/sessions), memoizes the access token, and @@ -116,6 +123,56 @@ function httpFailure(what: string, response: HttpResponse): BugseeError { ); } +/** The per-notification verdicts of a relay answer (`{ notifications: { : status } }`), or `undefined`. */ +function notifyStatusMap(decoded: unknown): Record | undefined { + if (typeof decoded !== 'object' || decoded === null) { + return undefined; + } + const envelope = decoded as { result?: { notifications?: unknown }; notifications?: unknown }; + const map = envelope.result?.notifications ?? envelope.notifications; + return typeof map === 'object' && map !== null ? (map as Record) : undefined; +} + +/** + * Map the relay's answer onto what the sender should do (Android `NotificationRelayUploadJob.upload`). + * + * - 2xx / 5xx: the per-item map decides — only an item the collector called `failed` is worth sending + * again (`accepted`, `skipped` and `invalid` are final). A 5xx with no map retries everything. + * - 408 / 429 and anything else non-2xx below 400 or unparseable: retry. + * - other 4xx: rejected. + * - a `{ ok: false }` envelope inside a 200 is the collector refusing the batch: rejected (the error + * router forces HTTP 200, so retrying would repeat the same refusal forever). + */ +function classifyNotify(response: HttpResponse, items: readonly NotifyItem[]): NotifyResult { + const { status } = response; + if (status === 408 || status === 429) { + return { outcome: 'retry' }; + } + if (status >= 400 && status < 500) { + return { outcome: 'rejected' }; + } + const success = isOk(status); + if (!success && status < 500) { + return { outcome: 'retry' }; + } + let decoded: unknown; + try { + decoded = decode(response.body); + } catch { + // An intermediary's page, not the collector: 5xx or garbage with a 200 — try again. + return { outcome: 'retry' }; + } + if (success && (decoded as V2Envelope | null)?.ok === false) { + return { outcome: 'rejected' }; + } + const map = notifyStatusMap(decoded); + if (map === undefined) { + return success ? { outcome: 'sent' } : { outcome: 'retry' }; + } + const retryIds = items.map((item) => item.id).filter((id) => map[id] === 'failed'); + return retryIds.length > 0 ? { outcome: 'retry', retryIds } : { outcome: 'sent' }; +} + export function createBugseeApi(transport: HttpTransport, options: BugseeApiOptions): BugseeApi { const { baseUrl, appToken, sdkVersion } = options; const sessionId = options.sessionId ?? randomId(); @@ -203,5 +260,22 @@ export function createBugseeApi(transport: HttpTransport, options: BugseeApiOpti invalidateSession(): void { accessToken = null; }, + + async notify(items: readonly NotifyItem[]): Promise { + // The relay authenticates with the app token alone (appserver `notification-relay.service.js` + // `resolveOrgAndApp` reads `body.app_token`), so no session is opened for it — `notify()` works + // before the first report and while the session endpoint is failing. + let response: HttpResponse; + try { + response = await transport(`${baseUrl}/v2/notifications`, { + method: 'POST', + headers: baseHeaders(), + body: JSON.stringify({ app_token: appToken, notifications: items }), + }); + } catch { + return { outcome: 'retry' }; // offline / DNS / aborted: the batch is still wanted + } + return classifyNotify(response, items); + }, }; } diff --git a/packages/core/src/client-notify.test.ts b/packages/core/src/client-notify.test.ts new file mode 100644 index 00000000..9392f269 --- /dev/null +++ b/packages/core/src/client-notify.test.ts @@ -0,0 +1,343 @@ +import type { EnvironmentEnvelope } from '@bugsee/protocol'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { createClient } from './client'; +import { BugseeError } from './errors'; +import { createMemoryCaptureStore } from './memory-capture-store'; +import { createOptionsContainer } from './options'; +import type { NotifyItem, NotifyResult, UploadPipeline } from './transport'; + +// `client.notify()` (T7) through the real client: the launch gate, option parsing, the snapshot taken at +// the call, and the flush/stop drain. The relay's own batching and retry rules are in notify-relay.test.ts. + +const env: EnvironmentEnvelope = { + platform: { type: 'web', version: '1' }, + runtime: { type: 'web', version: '' }, + sdk: { version: '0', type: 'javascript' }, +}; +const FLUSH_DELAY = 'com.bugsee.option.config.notify-flush-delay'; +const noScheduler = { setInterval: () => 'h', clearInterval: () => {} }; +type Sender = (items: readonly NotifyItem[]) => Promise; + +let batches: NotifyItem[][]; +beforeEach(() => { + vi.useFakeTimers(); + batches = []; +}); +afterEach(() => { + vi.useRealTimers(); +}); + +const make = ( + over: { + options?: Record; + sender?: Sender | null; + onError?: (e: unknown) => void; + uploadPipeline?: UploadPipeline; + } = {}, +) => { + const sender = vi.fn(async (items) => { + batches.push(items.map((item) => ({ ...item }))); + return { outcome: 'sent' }; + }); + const onError = over.onError ?? vi.fn(); + const client = createClient({ + captureStore: createMemoryCaptureStore({ maxRecordingTimeMs: Number.POSITIVE_INFINITY }), + scheduler: noScheduler, + launchOptions: createOptionsContainer(over.options ?? {}), + ...(over.sender === null ? {} : { notifySender: over.sender ?? sender }), + getEnvironment: () => env, + appToken: 'tok', + ...(over.uploadPipeline !== undefined ? { uploadPipeline: over.uploadPipeline } : {}), + onError, + }); + return { client, sender, onError }; +}; + +describe('notify — gating', () => { + it('is a no-op until launch() has been called', async () => { + const { client, sender } = make(); + client.notify('too early'); + await vi.advanceTimersByTimeAsync(10_000); + expect(sender).not.toHaveBeenCalled(); + }); + + it('sends once launched, with the title, body, severity, fields and urgent flag', async () => { + const { client } = make(); + client.launch(); + client.notify('Deploy finished', 'v1.2 is live', 'high', { env: 'prod' }, true); + await vi.advanceTimersByTimeAsync(0); + expect(batches[0]?.[0]).toMatchObject({ + title: 'Deploy finished', + body: 'v1.2 is live', + severity: 3, + fields: { env: 'prod' }, + urgent: true, + }); + }); + + it('accepts the title alone', async () => { + const { client } = make(); + client.launch(); + client.notify('just a title'); + await vi.advanceTimersByTimeAsync(0); + expect(batches[0]?.[0]).toMatchObject({ title: 'just a title' }); + expect(batches[0]?.[0]).not.toHaveProperty('body'); + expect(batches[0]?.[0]).not.toHaveProperty('urgent'); + }); + + it('ignores an empty title', async () => { + const { client, sender, onError } = make(); + client.launch(); + client.notify(''); + await vi.advanceTimersByTimeAsync(10_000); + expect(sender).not.toHaveBeenCalled(); + expect(onError).not.toHaveBeenCalled(); + }); + + it('is a no-op after stop(), and works again after a re-launch', async () => { + const { client, sender } = make(); + client.launch(); + await client.stop(); + client.notify('stopped'); + await vi.advanceTimersByTimeAsync(10_000); + expect(sender).not.toHaveBeenCalled(); + client.launch(); + client.notify('back'); + await vi.advanceTimersByTimeAsync(0); + expect(batches[0]?.[0]).toMatchObject({ title: 'back' }); + }); + + it('is a silent no-op when the platform supplied no sender', async () => { + const { client, onError } = make({ sender: null }); + client.launch(); + expect(() => client.notify('nowhere to go')).not.toThrow(); + await vi.advanceTimersByTimeAsync(10_000); + expect(onError).not.toHaveBeenCalled(); + }); + + it('is a no-op once the collector has killed the SDK', async () => { + const { client, sender } = make({ + uploadPipeline: { + enqueue: async () => ({ + ok: false, + error: new BugseeError('off', 0, { fatal: true, serverCode: 99_099 }), + }), + flush: async () => true, + drop: () => {}, + }, + }); + client.launch(); + await client.logException(new Error('boom')); + client.notify('after kill'); + await vi.advanceTimersByTimeAsync(10_000); + expect(sender).not.toHaveBeenCalled(); + }); + + it('stops sending what was queued when the collector kills the SDK', async () => { + const { client, sender } = make({ + options: { [FLUSH_DELAY]: 5000 }, + uploadPipeline: { + enqueue: async () => ({ + ok: false, + error: new BugseeError('off', 0, { fatal: true, serverCode: 99_099 }), + }), + flush: async () => true, + drop: () => {}, + }, + }); + client.launch(); + client.notify('queued before the kill'); + await client.logException(new Error('boom')); + await vi.advanceTimersByTimeAsync(10_000); + expect(sender).not.toHaveBeenCalled(); + }); + + it('never throws, even if the relay does', async () => { + const { client, onError } = make(); + client.launch(); + const hostile = new Proxy( + {}, + { + ownKeys: () => { + throw new Error('keys'); + }, + }, + ); + expect(() => client.notify('t', 'b', 'high', hostile as Record)).not.toThrow(); + expect(onError).toHaveBeenCalledWith(expect.objectContaining({ message: 'keys' })); + }); +}); + +describe('notify — snapshot and the flush delay option', () => { + it('stamps the user, attributes and environment as of the call', async () => { + const { client } = make(); + client.launch(); + client.setUserIdentifier('user-7'); + client.setAttribute('plan', 'pro'); + client.notify('hello'); + client.setUserIdentifier('user-8'); + client.clearAllAttributes(); + await vi.advanceTimersByTimeAsync(0); + expect(batches[0]?.[0]).toMatchObject({ + user: { id: 'user-7' }, + attributes: { plan: 'pro' }, + environment: env, + }); + }); + + it('keeps the user and attributes when the environment cannot be built', async () => { + const onError = vi.fn(); + const client = createClient({ + captureStore: createMemoryCaptureStore({ maxRecordingTimeMs: Number.POSITIVE_INFINITY }), + scheduler: noScheduler, + notifySender: async (items) => { + batches.push(items.map((item) => ({ ...item }))); + return { outcome: 'sent' }; + }, + getEnvironment: () => { + throw new Error('no env'); + }, + appToken: 'tok', + onError, + }); + client.launch(); + client.setUserIdentifier('u'); + client.notify('x'); + await vi.advanceTimersByTimeAsync(0); + expect(batches[0]?.[0]).toMatchObject({ user: { id: 'u' } }); + expect(batches[0]?.[0]).not.toHaveProperty('environment'); + expect(onError).toHaveBeenCalledWith(expect.objectContaining({ message: 'no env' })); + }); + + it('omits the environment when the platform supplied no builder', async () => { + const client = createClient({ + scheduler: noScheduler, + notifySender: async (items) => { + batches.push(items.map((item) => ({ ...item }))); + return { outcome: 'sent' }; + }, + }); + client.launch(); + client.notify('x'); + await vi.advanceTimersByTimeAsync(0); + expect(batches[0]?.[0]).not.toHaveProperty('environment'); + }); + + it('defaults the flush delay to 0: a burst leaves as one immediate batch', async () => { + const { client } = make(); + client.launch(); + client.notify('a'); + client.notify('b'); + await vi.advanceTimersByTimeAsync(0); + expect(batches).toHaveLength(1); + expect(batches[0]?.map((i) => i.delivery)).toEqual(['immediate', 'immediate']); + }); + + it('honours notifyFlushDelay and stamps the batch deferred', async () => { + const { client } = make({ options: { [FLUSH_DELAY]: 3000 } }); + client.launch(); + client.notify('a'); + await vi.advanceTimersByTimeAsync(2999); + expect(batches).toEqual([]); + await vi.advanceTimersByTimeAsync(1); + expect(batches[0]?.[0]).toMatchObject({ title: 'a', delivery: 'deferred' }); + }); + + it('lets an urgent notification skip the delay', async () => { + const { client } = make({ options: { [FLUSH_DELAY]: 60_000 } }); + client.launch(); + client.notify('now', undefined, undefined, undefined, true); + await vi.advanceTimersByTimeAsync(0); + expect(batches[0]?.[0]).toMatchObject({ title: 'now', delivery: 'immediate' }); + }); + + it.each([ + ['negative', -5], + ['not a number', 'soon'], + ['NaN', Number.NaN], + ['Infinity', Number.POSITIVE_INFINITY], + ['null', null], + ])('falls back to 0 for a %s notifyFlushDelay', async (_label, value) => { + const { client } = make({ options: { [FLUSH_DELAY]: value } }); + client.launch(); + client.notify('a'); + await vi.advanceTimersByTimeAsync(0); + expect(batches).toHaveLength(1); + expect(batches[0]?.[0]?.delivery).toBe('immediate'); // the window really is 0, not merely short + }); + + it('accepts a fractional delay by flooring it', async () => { + const { client } = make({ options: { [FLUSH_DELAY]: 10.9 } }); + client.launch(); + client.notify('a'); + await vi.advanceTimersByTimeAsync(9); + expect(batches).toEqual([]); + await vi.advanceTimersByTimeAsync(1); + expect(batches).toHaveLength(1); + }); +}); + +describe('notify — flush() and stop()', () => { + it('flush() sends pending notifications without waiting for the delay', async () => { + const { client } = make({ options: { [FLUSH_DELAY]: 60_000 } }); + client.launch(); + client.notify('a'); + await expect(client.flush()).resolves.toBe(true); + expect(batches).toHaveLength(1); + }); + + it('flush() is false while a notification cannot be delivered', async () => { + const { client } = make({ sender: async () => ({ outcome: 'retry' }) }); + client.launch(); + client.notify('a'); + await expect(client.flush()).resolves.toBe(false); + }); + + it('stop() delivers what is pending, then no timer fires for anything left over', async () => { + const { client, sender } = make({ options: { [FLUSH_DELAY]: 60_000 } }); + client.launch(); + client.notify('a'); + await expect(client.stop()).resolves.toBe(true); + expect(batches[0]?.[0]).toMatchObject({ title: 'a' }); + expect(sender).toHaveBeenCalledTimes(1); + }); + + it('stop() cancels the retry of a notification that could not be delivered', async () => { + const send = vi.fn(async () => ({ outcome: 'retry' })); + const { client } = make({ sender: send }); + client.launch(); + client.notify('a'); + await client.stop(); + const calls = send.mock.calls.length; + await vi.advanceTimersByTimeAsync(120_000); + expect(send).toHaveBeenCalledTimes(calls); + }); + + it('stop(timeout) does not wait forever for a sender that never answers', async () => { + const { client } = make({ sender: () => new Promise(() => {}) }); + client.launch(); + client.notify('a'); + const stopped = client.stop(500); + await vi.advanceTimersByTimeAsync(500); + await expect(stopped).resolves.toBe(false); + }); + + it('flush() is false when the report uploads did not drain, even if the notifications did', async () => { + const { client } = make({ + uploadPipeline: { + enqueue: async () => ({ ok: true }), + flush: async () => false, + drop: () => {}, + }, + }); + client.launch(); + client.notify('a'); + await expect(client.flush()).resolves.toBe(false); + expect(batches).toHaveLength(1); + }); + + it('flush() with no notifications and no relay is still true', async () => { + const { client } = make({ sender: null }); + await expect(client.flush()).resolves.toBe(true); + }); +}); diff --git a/packages/core/src/client.ts b/packages/core/src/client.ts index d2552e0c..43d36d50 100644 --- a/packages/core/src/client.ts +++ b/packages/core/src/client.ts @@ -54,6 +54,7 @@ import { runFilter, type SpanFilter, } from './filters'; +import { createNotifyRelay } from './notify-relay'; import { callSiteFrames, parseV8Stack, type StackFrame } from './stack'; export type { Breadcrumb, BreadcrumbInput } from './events'; @@ -84,6 +85,8 @@ import type { ServiceRegistrar, ServiceResolver } from './services'; import { type Bundle, isUploadSettled, + type NotifyItem, + type NotifyResult, type UploadPipeline, UploadPipelineToken, type UploadResult, @@ -298,6 +301,24 @@ export interface BugseeClient extends Client, ServiceResolver, ServiceRegistrar /** Capture an exception (instance-deduped + rate-limited) and trigger a report. */ logException(error: unknown, options?: LogExceptionOptions): Promise; + /** + * Relay a message to the app's configured messaging integrations (Slack, webhooks, ...) — mobile + * `Bugsee.notify`. It skips the bug-report pipeline: no video, logs or events, just the message. + * + * Notifications are queued, sent in batches (window: the `notifyFlushDelay` launch option, default 0 = + * the end of the current tick) and retried with backoff. `urgent` sends this one at once, ahead of + * the window. An empty `title`, a call before `launch()` or after `stop()`, and a SDK killed by the + * collector are silent no-ops. `fields` keeps only string values. `flush()`/`stop()` deliver what is + * pending. Unlike mobile the queue is in memory: a page closed with an unsent message loses it. + */ + notify( + title: string, + body?: string, + severity?: SeverityName, + fields?: Record, + urgent?: boolean, + ): void; + /** * Send a report on request (Android `Bugsee.upload`). Unlike {@link logException} it is neither * instance-deduped nor rate-limited — every call is something a person or the app explicitly wanted @@ -399,6 +420,11 @@ export interface CreateClientOptions { * {@link FrameEnricher}. */ onReportSite?: (error: unknown) => void; + /** + * Delivers a `notify()` batch (the platform wires this to `BugseeApi.notify`). Without it `notify()` is + * a silent no-op, as on a build that has no collector transport. + */ + notifySender?: (items: readonly NotifyItem[]) => Promise; /** Capture storage backend (disk/IndexedDB on platform tiers). Default in-memory. */ captureStore?: CaptureStore; /** The internal service container (the per-process DI registry). Default a fresh one. */ @@ -627,6 +653,37 @@ export function createClient(options: CreateClientOptions = {}): InternalBugseeC // Build the trigger pipeline from the report assembler when its inputs are present (unless an // override is injected). assemble reads the exporter drain + the live environment/attributes. const { uploadPipeline, appToken, getEnvironment } = options; + // `notify()` (T7). The window is read once: a non-number, negative or non-finite value is treated as the + // default 0 rather than reported, because a typo here only changes batching, never what is sent. + const notifyDelayOption = launchOptions.get(BugseeOption.NotifyFlushDelay, 0); + const notifyFlushDelayMs = + typeof notifyDelayOption === 'number' && + Number.isFinite(notifyDelayOption) && + notifyDelayOption > 0 + ? Math.floor(notifyDelayOption) + : 0; + const notifyRelay = + options.notifySender === undefined + ? undefined + : createNotifyRelay({ + send: options.notifySender, + clock, + flushDelayMs: notifyFlushDelayMs, + onError: onErrorSafe, + snapshot: () => { + let builtEnvironment: EnvironmentEnvelope | undefined; + try { + builtEnvironment = getEnvironment?.(); + } catch (error) { + onError(error); // the message still goes out, without the device block + } + return { + userIdentifier: environment.getUserIdentifier(), + attributes: environment.getAllAttributes(), + ...(builtEnvironment !== undefined ? { environment: builtEnvironment } : {}), + }; + }, + }); // The platform's assembled upload orchestrator, when present, is also a container service (DI Phase 3). if (uploadPipeline !== undefined) { services.addService(defineService(UploadPipelineToken, () => uploadPipeline)); @@ -782,9 +839,14 @@ export function createClient(options: CreateClientOptions = {}): InternalBugseeC // Drain in-flight reports (each resolves post-upload) then any directly-enqueued uploads, bounded // by `timeout` so a hung assemble/upload can't block shutdown. Resolves true if drained in time. const drainPending = (timeout?: number): Promise => { - const drained = Promise.allSettled([...pendingReports]).then( - () => uploadPipeline?.flush(timeout) ?? true, - ); + const drained = Promise.allSettled([...pendingReports]).then(async () => { + // Notifications go out alongside the report uploads; either one still pending makes this false. + const [uploaded, notified] = await Promise.all([ + uploadPipeline?.flush(timeout) ?? true, + notifyRelay?.flush() ?? true, + ]); + return uploaded && notified; + }); if (timeout === undefined) { return drained; } @@ -808,6 +870,7 @@ export function createClient(options: CreateClientOptions = {}): InternalBugseeC } killed = true; onError(error); + notifyRelay?.suspend(); // a switched-off SDK sends nothing more, notifications included if (launched) { launched = false; haltCapture(); @@ -1145,6 +1208,30 @@ export function createClient(options: CreateClientOptions = {}): InternalBugseeC return sendUpload(uploadOptions); }, + notify( + title: string, + body?: string, + severity?: SeverityName, + fields?: Record, + urgent?: boolean, + ): void { + // `killed` implies `!launched` (the kill-state halts the client and a killed client never re-launches). + if (!launched) { + return; + } + try { + notifyRelay?.enqueue({ + title, + ...(body !== undefined ? { body } : {}), + ...(severity !== undefined ? { severity } : {}), + ...(fields !== undefined ? { fields } : {}), + ...(urgent !== undefined ? { urgent } : {}), + }); + } catch (error) { + onError(error); // notify() must never throw into the host (hostile `fields`, ...) + } + }, + prepareUpload(): InternalPreparedUpload | null { if (stopped || killed) { return null; @@ -1257,6 +1344,9 @@ export function createClient(options: CreateClientOptions = {}): InternalBugseeC return drainPending(timeout) .finally(() => { stopping = false; + // A notification still waiting (offline) keeps its place for a re-launch but stops retrying: a + // stopped SDK must not keep a timer, and so a request, alive. + notifyRelay?.suspend(); }) .then((drained) => { // After `stopping` clears, so a listener that reads getStatus() sees Stopped, not Stopping. diff --git a/packages/core/src/durable-upload-pipeline.test.ts b/packages/core/src/durable-upload-pipeline.test.ts index 0c4487b8..a7b83fd3 100644 --- a/packages/core/src/durable-upload-pipeline.test.ts +++ b/packages/core/src/durable-upload-pipeline.test.ts @@ -224,6 +224,7 @@ describe('createDurableUploadPipeline', () => { throw new Error('unused'); }, invalidateSession: () => {}, + notify: async () => ({ outcome: 'sent' }), }, uploader: { putBundle }, sleep: async () => {}, diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 174d91cc..4d548216 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -202,6 +202,14 @@ export { recoverNativeCrashes, } from './native-crash-recovery'; export { guarded, neverThrow } from './never-throw'; +export { + createNotifyRelay, + type NotifyInput, + type NotifyRelay, + type NotifyRelayOptions, + type NotifySnapshot, + type RelayTimers, +} from './notify-relay'; export { createOperationDispatcher } from './operation-dispatcher'; export { COMMON_OPTION_DEFINITIONS, @@ -273,6 +281,8 @@ export type { HttpResponse, HttpTransport, IssueCreateResult, + NotifyItem, + NotifyResult, OutcomeCategory, PutBundleOptions, PutResult, diff --git a/packages/core/src/notify-relay.test.ts b/packages/core/src/notify-relay.test.ts new file mode 100644 index 00000000..6486f799 --- /dev/null +++ b/packages/core/src/notify-relay.test.ts @@ -0,0 +1,781 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { createNotifyRelay, type NotifyRelayOptions } from './notify-relay'; +import type { NotifyItem, NotifyResult } from './transport'; + +// The relay runs on the REAL global timers, driven by vitest fake timers, so the default-timer path is +// the one under test. `send` is the only seam; everything else is observed through what it receives. + +type Send = (items: readonly NotifyItem[]) => Promise; +const SENT: NotifyResult = { outcome: 'sent' }; + +let sends: Array; +let ids: number; + +const make = (over: Partial = {}, send?: Send) => { + const sendFn = vi.fn(async (items) => { + sends.push(items.map((item) => ({ ...item }))); + return send ? send(items) : SENT; + }); + const onError = vi.fn(); + const relay = createNotifyRelay({ + send: sendFn, + clock: { wallNow: () => 1_700_000_000_000, monotonicNow: () => 0 }, + flushDelayMs: 0, + newId: () => `id${++ids}`, + onError, + ...over, + }); + return { relay, send: sendFn, onError }; +}; + +beforeEach(() => { + vi.useFakeTimers(); + sends = []; + ids = 0; +}); +afterEach(() => { + vi.useRealTimers(); +}); + +describe('enqueue — validation and the wire shape', () => { + it('sends title only with an id and a timestamp, and the immediate delivery stamp at delay 0', async () => { + const { relay } = make(); + relay.enqueue({ title: 'Deploy finished' }); + await vi.advanceTimersByTimeAsync(0); + expect(sends).toEqual([ + [ + { + id: 'id1', + timestamp: 1_700_000_000_000, + title: 'Deploy finished', + delivery: 'immediate', + }, + ], + ]); + }); + + it('carries body, the numeric severity, string fields and the urgent flag', async () => { + const { relay } = make(); + relay.enqueue({ + title: 't', + body: 'details', + severity: 'critical', + fields: { env: 'prod', region: 'eu' }, + urgent: true, + }); + await vi.advanceTimersByTimeAsync(0); + expect(sends[0]?.[0]).toMatchObject({ + title: 't', + body: 'details', + severity: 4, + fields: { env: 'prod', region: 'eu' }, + urgent: true, + }); + }); + + it('maps every severity name to its wire value, and accepts the low alias', async () => { + const { relay } = make(); + const names = ['verylow', 'low', 'medium', 'high', 'critical', 'blocker'] as const; + for (const severity of names) { + relay.enqueue({ title: severity, severity }); + } + await vi.advanceTimersByTimeAsync(0); + expect(sends[0]?.map((item) => item.severity)).toEqual([1, 1, 2, 3, 4, 5]); + }); + + it('ignores an empty or non-string title: nothing is sent, nothing is reported', async () => { + const { relay, onError } = make(); + relay.enqueue({ title: '' }); + relay.enqueue({ title: undefined as unknown as string }); + relay.enqueue({ title: 42 as unknown as string }); + await vi.advanceTimersByTimeAsync(10_000); + expect(sends).toEqual([]); + expect(onError).not.toHaveBeenCalled(); + }); + + it('omits what is malformed: a non-string body, an unknown severity, non-string fields', async () => { + const { relay } = make(); + relay.enqueue({ + title: 't', + body: 5 as unknown as string, + severity: 'urgent' as unknown as 'high', + fields: { ok: 'yes', n: 1, o: {}, nil: null } as unknown as Record, + }); + relay.enqueue({ + title: 'u', + fields: { n: 1 } as unknown as Record, + }); + relay.enqueue({ title: 'v', fields: ['a'] as unknown as Record }); + relay.enqueue({ title: 'w', fields: null as unknown as Record }); + await vi.advanceTimersByTimeAsync(0); + const [t, u, v, w] = sends[0] as NotifyItem[]; + expect(t).not.toHaveProperty('body'); + expect(t).not.toHaveProperty('severity'); + expect(t?.fields).toEqual({ ok: 'yes' }); + expect(u).not.toHaveProperty('fields'); + expect(v).not.toHaveProperty('fields'); + expect(w).not.toHaveProperty('fields'); + }); + + it('keeps an empty-string body (the caller asked for it)', async () => { + const { relay } = make(); + relay.enqueue({ title: 't', body: '' }); + await vi.advanceTimersByTimeAsync(0); + expect(sends[0]?.[0]).toHaveProperty('body', ''); + }); + + it('treats only urgent === true as urgent', async () => { + const { relay } = make(); + relay.enqueue({ title: 't', urgent: 'yes' as unknown as boolean }); + await vi.advanceTimersByTimeAsync(0); + expect(sends[0]?.[0]).not.toHaveProperty('urgent'); + }); + + it('stamps the user, environment and attributes snapshotted at the call', async () => { + let user: string | null = 'u-1'; + const env = { sdk: { version: '1', type: 'javascript' } }; + const { relay } = make({ + snapshot: () => ({ + userIdentifier: user, + attributes: { plan: 'pro' }, + environment: env as never, + }), + }); + relay.enqueue({ title: 'a' }); + user = 'u-2'; + await vi.advanceTimersByTimeAsync(0); + expect(sends[0]?.[0]).toMatchObject({ + user: { id: 'u-1' }, + attributes: { plan: 'pro' }, + environment: env, + }); + }); + + it('omits an absent user, empty attributes and a throwing environment', async () => { + const { relay, onError } = make({ + snapshot: () => ({ userIdentifier: null, attributes: {}, environment: undefined }), + }); + relay.enqueue({ title: 'a' }); + await vi.advanceTimersByTimeAsync(0); + expect(sends[0]?.[0]).not.toHaveProperty('user'); + expect(sends[0]?.[0]).not.toHaveProperty('attributes'); + expect(sends[0]?.[0]).not.toHaveProperty('environment'); + + const throwing = make({ + snapshot: () => { + throw new Error('snapshot broke'); + }, + }); + throwing.relay.enqueue({ title: 'b' }); + await vi.advanceTimersByTimeAsync(0); + expect(sends[1]?.[0]).toMatchObject({ title: 'b' }); + expect(throwing.onError).toHaveBeenCalledWith( + expect.objectContaining({ message: 'snapshot broke' }), + ); + expect(onError).not.toHaveBeenCalled(); + }); + + it('refuses a notification larger than 256 KB and says so', async () => { + const { relay, onError } = make(); + relay.enqueue({ title: 'big', body: 'x'.repeat(256 * 1024) }); + await vi.advanceTimersByTimeAsync(0); + expect(sends).toEqual([]); + expect(onError).toHaveBeenCalledWith( + expect.objectContaining({ message: expect.stringMatching(/256 KB/) }), + ); + expect(relay.pending()).toBe(0); + }); + + it('counts the size in UTF-8 bytes, not characters', async () => { + const { relay, onError } = make(); + // 100k three-byte characters = 300 KB of bytes, 100k of characters. + relay.enqueue({ title: 'big', body: '€'.repeat(100_000) }); + await vi.advanceTimersByTimeAsync(0); + expect(sends).toEqual([]); + expect(onError).toHaveBeenCalled(); + }); + + it('accepts a notification just under the limit', async () => { + const { relay } = make(); + relay.enqueue({ title: 'ok', body: 'x'.repeat(200 * 1024) }); + await vi.advanceTimersByTimeAsync(0); + expect(sends).toHaveLength(1); + }); +}); + +describe('batching and the flush delay', () => { + it('coalesces a burst into one request at delay 0', async () => { + const { relay } = make(); + relay.enqueue({ title: 'a' }); + relay.enqueue({ title: 'b' }); + relay.enqueue({ title: 'c' }); + expect(sends).toEqual([]); // nothing goes out synchronously + await vi.advanceTimersByTimeAsync(0); + expect(sends).toHaveLength(1); + expect(sends[0]?.map((i) => i.title)).toEqual(['a', 'b', 'c']); + }); + + it('waits notifyFlushDelay after the first notification and stamps them deferred', async () => { + const { relay } = make({ flushDelayMs: 5000 }); + relay.enqueue({ title: 'a' }); + await vi.advanceTimersByTimeAsync(4999); + expect(sends).toEqual([]); + relay.enqueue({ title: 'b' }); // joins the pending window, does not extend it + await vi.advanceTimersByTimeAsync(1); + expect(sends).toHaveLength(1); + expect(sends[0]?.map((i) => [i.title, i.delivery])).toEqual([ + ['a', 'deferred'], + ['b', 'deferred'], + ]); + }); + + it('sends an urgent notification at once, alone, stamped immediate, ahead of the window', async () => { + const { relay } = make({ flushDelayMs: 5000 }); + relay.enqueue({ title: 'calm' }); + relay.enqueue({ title: 'now', urgent: true }); + await vi.advanceTimersByTimeAsync(0); + expect(sends).toHaveLength(1); + expect(sends[0]).toHaveLength(1); + expect(sends[0]?.[0]).toMatchObject({ title: 'now', urgent: true, delivery: 'immediate' }); + await vi.advanceTimersByTimeAsync(5000); + expect(sends).toHaveLength(2); + expect(sends[1]?.[0]).toMatchObject({ title: 'calm', delivery: 'deferred' }); + }); + + it('stamps an urgent notification that rides a normal flush as deferred', async () => { + // Mobile: an urgent item that was NOT sent ahead (it failed, then is retried in the FIFO) is deferred. + let calls = 0; + const { relay } = make({ flushDelayMs: 0 }, async () => { + calls += 1; + return calls === 1 ? { outcome: 'retry' } : SENT; + }); + relay.enqueue({ title: 'u', urgent: true }); + await vi.advanceTimersByTimeAsync(0); + expect(sends[0]?.[0]).toMatchObject({ delivery: 'immediate' }); + await vi.advanceTimersByTimeAsync(2000); + expect(sends[1]?.[0]).toMatchObject({ title: 'u', delivery: 'deferred' }); + }); + + it('splits a queue into batches of at most 50 notifications', async () => { + const { relay } = make(); + for (let i = 0; i < 120; i++) { + relay.enqueue({ title: `n${i}` }); + } + await vi.advanceTimersByTimeAsync(0); + expect(sends.map((batch) => batch.length)).toEqual([50, 50, 20]); + expect(sends[0]?.[0]?.title).toBe('n0'); + expect(sends[2]?.[19]?.title).toBe('n119'); + }); + + it('splits a batch before it would pass 256 KB', async () => { + const { relay } = make(); + for (let i = 0; i < 4; i++) { + relay.enqueue({ title: `n${i}`, body: 'x'.repeat(100 * 1024) }); + } + await vi.advanceTimersByTimeAsync(0); + expect(sends.map((batch) => batch.length)).toEqual([2, 2]); + }); + + it('fills a batch up to, but not past, 256 KB', async () => { + const { relay } = make(); + for (let i = 0; i < 3; i++) { + relay.enqueue({ title: `n${i}`, body: 'x'.repeat(90 * 1024) }); // 3 of them are ~270 KB + } + await vi.advanceTimersByTimeAsync(0); + expect(sends.map((batch) => batch.length)).toEqual([2, 1]); + }); + + it('treats exactly 256 KB as a full batch that still fits', async () => { + const { relay } = make(); + // The size is measured as queued, before the delivery stamp, so that is the shell to size against. + const shell = JSON.stringify({ + id: 'id1', + timestamp: 1_700_000_000_000, + title: 't', + body: '', + }).length; + const body = 'x'.repeat(128 * 1024 - shell); + relay.enqueue({ title: 't', body }); + relay.enqueue({ title: 't', body }); + relay.enqueue({ title: 't', body }); + await vi.advanceTimersByTimeAsync(0); + expect(sends.map((batch) => batch.length)).toEqual([2, 1]); + }); + + it('arms one window for a whole burst, not one per notification', () => { + const { relay } = make({ flushDelayMs: 100 }); + relay.enqueue({ title: 'a' }); + relay.enqueue({ title: 'b' }); + relay.enqueue({ title: 'c' }); + expect(vi.getTimerCount()).toBe(1); + }); + + it('still sends a single notification that alone is near the batch ceiling', async () => { + const { relay } = make(); + relay.enqueue({ title: 'solo', body: 'x'.repeat(255 * 1024) }); + await vi.advanceTimersByTimeAsync(0); + expect(sends.map((batch) => batch.length)).toEqual([1]); + }); + + it('starts a new window for a notification that arrives after a flush', async () => { + const { relay } = make({ flushDelayMs: 100 }); + relay.enqueue({ title: 'a' }); + await vi.advanceTimersByTimeAsync(100); + relay.enqueue({ title: 'b' }); + await vi.advanceTimersByTimeAsync(100); + expect(sends.map((batch) => batch.map((i) => i.title))).toEqual([['a'], ['b']]); + }); + + it('does not start a second drain while one is in flight, and picks up what arrived meanwhile', async () => { + let release: (() => void) | undefined; + const gate = new Promise((resolve) => { + release = resolve; + }); + let first = true; + const { relay, send } = make({}, async () => { + if (first) { + first = false; + await gate; + } + return SENT; + }); + relay.enqueue({ title: 'a' }); + await vi.advanceTimersByTimeAsync(0); + relay.enqueue({ title: 'b' }); + await vi.advanceTimersByTimeAsync(0); + expect(send).toHaveBeenCalledTimes(1); // the second timer joined the running drain + release?.(); + await vi.advanceTimersByTimeAsync(0); + expect(sends.map((batch) => batch.map((i) => i.title))).toEqual([['a'], ['b']]); + }); +}); + +describe('retry and failure handling', () => { + it('keeps a batch on a transient failure and retries it after 2 s, then 4 s, doubling to a 60 s cap', async () => { + const { relay, send } = make({}, async () => ({ outcome: 'retry' })); + relay.enqueue({ title: 'a' }); + await vi.advanceTimersByTimeAsync(0); + expect(send).toHaveBeenCalledTimes(1); + await vi.advanceTimersByTimeAsync(1999); + expect(send).toHaveBeenCalledTimes(1); + await vi.advanceTimersByTimeAsync(1); + expect(send).toHaveBeenCalledTimes(2); + await vi.advanceTimersByTimeAsync(3999); + expect(send).toHaveBeenCalledTimes(2); + await vi.advanceTimersByTimeAsync(1); + expect(send).toHaveBeenCalledTimes(3); + // 8, 16, 32, then capped at 60 s. + for (const wait of [8000, 16000, 32000, 60000, 60000]) { + const before = send.mock.calls.length; + await vi.advanceTimersByTimeAsync(wait - 1); + expect(send).toHaveBeenCalledTimes(before); + await vi.advanceTimersByTimeAsync(1); + expect(send).toHaveBeenCalledTimes(before + 1); + } + expect(relay.pending()).toBe(1); + }); + + it('resets the backoff once a send succeeds', async () => { + let fail = true; + const { relay, send } = make({}, async () => (fail ? { outcome: 'retry' } : SENT)); + relay.enqueue({ title: 'a' }); + await vi.advanceTimersByTimeAsync(0); + await vi.advanceTimersByTimeAsync(2000); // 2nd attempt, fails -> next wait 4 s + fail = false; + await vi.advanceTimersByTimeAsync(4000); // succeeds + expect(relay.pending()).toBe(0); + fail = true; + relay.enqueue({ title: 'b' }); + await vi.advanceTimersByTimeAsync(0); + const calls = send.mock.calls.length; + await vi.advanceTimersByTimeAsync(2000); // back to the 2 s floor, not 8 s + expect(send).toHaveBeenCalledTimes(calls + 1); + }); + + it('retries only the ids the collector named, keeping the original order, and never resends the settled', async () => { + let calls = 0; + const { relay } = make({}, async (items) => { + calls += 1; + return calls === 1 + ? { outcome: 'retry', retryIds: [items[0]?.id as string, items[2]?.id as string] } + : SENT; + }); + for (const title of ['a', 'b', 'c']) { + relay.enqueue({ title }); + } + await vi.advanceTimersByTimeAsync(0); + await vi.advanceTimersByTimeAsync(2000); + expect(sends.map((batch) => batch.map((i) => i.title))).toEqual([ + ['a', 'b', 'c'], + ['a', 'c'], + ]); + expect(relay.pending()).toBe(0); + }); + + it('puts a retried batch ahead of newer notifications so order is preserved', async () => { + let calls = 0; + const { relay } = make({}, async () => { + calls += 1; + return calls === 1 ? { outcome: 'retry' } : SENT; + }); + relay.enqueue({ title: 'old' }); + await vi.advanceTimersByTimeAsync(0); + relay.enqueue({ title: 'new' }); + await vi.advanceTimersByTimeAsync(2000); + expect( + sends + .slice(1) + .flat() + .map((i) => i.title), + ).toEqual(['old', 'new']); + }); + + it('puts a failed batch back in front of notifications that arrived while it was in flight', async () => { + let release: (() => void) | undefined; + const gate = new Promise((resolve) => { + release = resolve; + }); + let calls = 0; + const { relay } = make({}, async () => { + calls += 1; + if (calls === 1) { + await gate; + return { outcome: 'retry' }; + } + return SENT; + }); + relay.enqueue({ title: 'old' }); + await vi.advanceTimersByTimeAsync(0); + relay.enqueue({ title: 'new' }); // arrives while 'old' is on the wire + expect(relay.pending()).toBe(2); + release?.(); + await vi.advanceTimersByTimeAsync(2000); + expect( + sends + .slice(1) + .flat() + .map((i) => i.title), + ).toEqual(['old', 'new']); + }); + + it('counts a notification in flight as pending until the request answers', async () => { + let release: (() => void) | undefined; + const gate = new Promise((resolve) => { + release = resolve; + }); + const { relay } = make({}, async () => { + await gate; + return SENT; + }); + relay.enqueue({ title: 'a' }); + relay.enqueue({ title: 'u', urgent: true }); + await vi.advanceTimersByTimeAsync(0); + expect(relay.pending()).toBe(2); + release?.(); + await vi.advanceTimersByTimeAsync(0); + expect(relay.pending()).toBe(0); + }); + + it('does not send newer notifications early while a retry is backing off', async () => { + let calls = 0; + const { relay, send } = make({}, async () => { + calls += 1; + return calls === 1 ? { outcome: 'retry' } : SENT; + }); + relay.enqueue({ title: 'a' }); + await vi.advanceTimersByTimeAsync(0); + relay.enqueue({ title: 'b' }); + await vi.advanceTimersByTimeAsync(1000); + expect(send).toHaveBeenCalledTimes(1); + await vi.advanceTimersByTimeAsync(1000); + expect(send).toHaveBeenCalledTimes(2); + expect(sends[1]?.map((i) => i.title)).toEqual(['a', 'b']); + }); + + it('drops a rejected batch, reports it, and carries on with the next one', async () => { + let calls = 0; + const { relay, onError } = make({}, async () => { + calls += 1; + return calls === 1 ? { outcome: 'rejected' } : SENT; + }); + for (let i = 0; i < 60; i++) { + relay.enqueue({ title: `n${i}` }); + } + await vi.advanceTimersByTimeAsync(0); + expect(sends.map((batch) => batch.length)).toEqual([50, 10]); + expect(onError).toHaveBeenCalledWith( + expect.objectContaining({ message: expect.stringMatching(/rejected/) }), + ); + expect(relay.pending()).toBe(0); + }); + + it('survives a sender that throws or rejects: the batch is retried', async () => { + let calls = 0; + const { relay } = make({}, async () => { + calls += 1; + if (calls === 1) { + throw new Error('boom'); + } + return SENT; + }); + relay.enqueue({ title: 'a' }); + await vi.advanceTimersByTimeAsync(0); + expect(relay.pending()).toBe(1); + await vi.advanceTimersByTimeAsync(2000); + expect(relay.pending()).toBe(0); + }); + + it('retries a failed urgent send through the queue', async () => { + let calls = 0; + const { relay } = make({ flushDelayMs: 1000 }, async () => { + calls += 1; + return calls === 1 ? { outcome: 'retry' } : SENT; + }); + relay.enqueue({ title: 'u', urgent: true }); + await vi.advanceTimersByTimeAsync(0); + expect(relay.pending()).toBe(1); + await vi.advanceTimersByTimeAsync(2000); + expect(relay.pending()).toBe(0); + expect(sends).toHaveLength(2); + }); + + it('drops a rejected urgent send', async () => { + const { relay, onError } = make({}, async () => ({ outcome: 'rejected' })); + relay.enqueue({ title: 'u', urgent: true }); + await vi.advanceTimersByTimeAsync(0); + expect(relay.pending()).toBe(0); + expect(onError).toHaveBeenCalled(); + }); + + it('keeps at most 1000 pending notifications, dropping the oldest', async () => { + const { relay, onError } = make({}, async () => ({ outcome: 'retry' })); + for (let i = 0; i < 1005; i++) { + relay.enqueue({ title: `n${i}` }); + } + expect(relay.pending()).toBe(1000); + expect(onError).toHaveBeenCalledTimes(5); + await vi.advanceTimersByTimeAsync(0); + expect(sends[0]?.[0]?.title).toBe('n5'); + }); + + it('still names an overflow when the sender fails after a retry put items back', async () => { + const { relay } = make({}, async () => ({ outcome: 'retry' })); + for (let i = 0; i < 1000; i++) { + relay.enqueue({ title: `n${i}` }); + } + await vi.advanceTimersByTimeAsync(0); // first 50 sent, failed, put back at the front + relay.enqueue({ title: 'late' }); + expect(relay.pending()).toBe(1000); // oldest dropped to admit it + }); +}); + +describe('flush and suspend', () => { + it('flush sends what is pending now, without waiting for the delay, and resolves true', async () => { + const { relay } = make({ flushDelayMs: 60_000 }); + relay.enqueue({ title: 'a' }); + relay.enqueue({ title: 'b' }); + await expect(relay.flush()).resolves.toBe(true); + expect(sends).toHaveLength(1); + expect(relay.pending()).toBe(0); + await vi.advanceTimersByTimeAsync(60_000); // the cancelled window does not fire again + expect(sends).toHaveLength(1); + }); + + it('flush leaves no timer behind: not the window, not a backoff', async () => { + let ok = false; + const { relay } = make({ flushDelayMs: 60_000 }, async () => + ok ? SENT : { outcome: 'retry' }, + ); + relay.enqueue({ title: 'a' }); + ok = true; + await relay.flush(); + expect(vi.getTimerCount()).toBe(0); + + ok = false; + relay.enqueue({ title: 'b' }); + await relay.flush(); // fails: one backoff timer is armed + expect(vi.getTimerCount()).toBe(1); + await relay.flush(); // fails again: the old backoff is replaced, not added to + expect(vi.getTimerCount()).toBe(1); + ok = true; + await relay.flush(); // delivers: the backoff has nothing left to do + expect(vi.getTimerCount()).toBe(0); + }); + + it('flush is false when an urgent send fails after the queue was already empty', async () => { + let release: (() => void) | undefined; + const gate = new Promise((resolve) => { + release = resolve; + }); + const { relay } = make({}, async () => { + await gate; + return { outcome: 'retry' }; + }); + relay.enqueue({ title: 'u', urgent: true }); + await vi.advanceTimersByTimeAsync(0); + const flushed = relay.flush(); + release?.(); + await expect(flushed).resolves.toBe(false); + expect(relay.pending()).toBe(1); + }); + + it('flush resolves true when there is nothing to send', async () => { + const { relay, send } = make(); + await expect(relay.flush()).resolves.toBe(true); + expect(send).not.toHaveBeenCalled(); + }); + + it('flush resolves false when the batch could not be delivered', async () => { + const { relay } = make({}, async () => ({ outcome: 'retry' })); + relay.enqueue({ title: 'a' }); + await expect(relay.flush()).resolves.toBe(false); + expect(relay.pending()).toBe(1); + }); + + it('flush joins a drain already running instead of starting another', async () => { + let release: (() => void) | undefined; + const gate = new Promise((resolve) => { + release = resolve; + }); + const { relay, send } = make({}, async () => { + await gate; + return SENT; + }); + relay.enqueue({ title: 'a' }); + await vi.advanceTimersByTimeAsync(0); + const flushed = relay.flush(); + release?.(); + await expect(flushed).resolves.toBe(true); + expect(send).toHaveBeenCalledTimes(1); + }); + + it('flush waits for an urgent send that is still in flight', async () => { + let release: (() => void) | undefined; + const gate = new Promise((resolve) => { + release = resolve; + }); + const { relay } = make({}, async () => { + await gate; + return SENT; + }); + relay.enqueue({ title: 'u', urgent: true }); + await vi.advanceTimersByTimeAsync(0); + let done = false; + const flushed = relay.flush().then((result) => { + done = true; + return result; + }); + await vi.advanceTimersByTimeAsync(0); + expect(done).toBe(false); + release?.(); + await expect(flushed).resolves.toBe(true); + }); + + it('flush retries immediately even while the backoff timer is pending, and re-arms it on failure', async () => { + let ok = false; + const { relay, send } = make({}, async () => (ok ? SENT : { outcome: 'retry' })); + relay.enqueue({ title: 'a' }); + await vi.advanceTimersByTimeAsync(0); + await expect(relay.flush()).resolves.toBe(false); + expect(send).toHaveBeenCalledTimes(2); + ok = true; + await vi.advanceTimersByTimeAsync(10_000); // the (single) re-armed retry delivers it + expect(relay.pending()).toBe(0); + expect(send).toHaveBeenCalledTimes(3); + }); + + it('suspend cancels the pending window and the backoff but keeps the queue', async () => { + const { relay, send } = make({ flushDelayMs: 1000 }); + relay.enqueue({ title: 'a' }); + relay.suspend(); + await vi.advanceTimersByTimeAsync(10_000); + expect(send).not.toHaveBeenCalled(); + expect(relay.pending()).toBe(1); + // a later notification re-arms scheduling and carries the old one with it + relay.enqueue({ title: 'b' }); + await vi.advanceTimersByTimeAsync(1000); + expect(sends[0]?.map((i) => i.title)).toEqual(['a', 'b']); + }); + + it('suspend also cancels a backoff timer', async () => { + const { relay, send } = make({}, async () => ({ outcome: 'retry' })); + relay.enqueue({ title: 'a' }); + await vi.advanceTimersByTimeAsync(0); + relay.suspend(); + await vi.advanceTimersByTimeAsync(120_000); + expect(send).toHaveBeenCalledTimes(1); + }); +}); + +describe('timers', () => { + it('unrefs its timers so a pending notification never keeps a process alive', async () => { + const unref = vi.fn(); + const handle = { unref }; + const timerHost = globalThis as unknown as { + setTimeout: (cb: () => void, ms: number) => unknown; + }; + const setTimeoutSpy = vi.spyOn(timerHost, 'setTimeout').mockReturnValue(handle as never); + try { + const { relay } = make(); + relay.enqueue({ title: 'a' }); + expect(unref).toHaveBeenCalledTimes(1); + } finally { + setTimeoutSpy.mockRestore(); + } + }); + + it('works with injected timers and a handle that has no unref', async () => { + const scheduled: Array<() => void> = []; + const cleared: unknown[] = []; + const { relay } = make({ + timers: { + setTimeout: (cb) => { + scheduled.push(cb); + return scheduled.length; + }, + clearTimeout: (handle) => { + cleared.push(handle); + }, + }, + }); + relay.enqueue({ title: 'a' }); + expect(scheduled).toHaveLength(1); + scheduled[0]?.(); + await vi.advanceTimersByTimeAsync(0); + expect(sends).toHaveLength(1); + relay.enqueue({ title: 'b' }); + relay.suspend(); + expect(cleared).toEqual([2]); + }); + + it('defaults the id to a fresh unique value per notification', async () => { + const relay = createNotifyRelay({ + send: async (items) => { + sends.push(items.map((item) => ({ ...item }))); + return SENT; + }, + clock: { wallNow: () => 1, monotonicNow: () => 0 }, + flushDelayMs: 0, + }); + relay.enqueue({ title: 'a' }); + relay.enqueue({ title: 'b' }); + await vi.advanceTimersByTimeAsync(0); + const [a, b] = sends[0] as NotifyItem[]; + expect(a?.id).toMatch(/^[0-9a-f]{20,}$/); + expect(a?.id).not.toBe(b?.id); + }); + + it('a throwing onError never breaks the relay', async () => { + const relay = createNotifyRelay({ + send: async () => ({ outcome: 'rejected' }), + clock: { wallNow: () => 1, monotonicNow: () => 0 }, + flushDelayMs: 0, + onError: () => { + throw new Error('sink'); + }, + }); + relay.enqueue({ title: 'a' }); + await vi.advanceTimersByTimeAsync(0); + expect(relay.pending()).toBe(0); + }); +}); diff --git a/packages/core/src/notify-relay.ts b/packages/core/src/notify-relay.ts new file mode 100644 index 00000000..20257142 --- /dev/null +++ b/packages/core/src/notify-relay.ts @@ -0,0 +1,307 @@ +import type { EnvironmentEnvelope } from '@bugsee/protocol'; +import { severityToWire } from '@bugsee/protocol'; +import type { AttributeValue, SeverityName } from '@bugsee/types'; +import { randomId, utf8ByteLength } from '@bugsee/util'; +import type { Clock } from './clock'; +import { parseSeverityName } from './reporting'; +import type { NotifyItem, NotifyResult } from './transport'; + +// The `notify()` relay (mobile `Bugsee.notify`, Android `BugseeNotificationRelayCoordinator`): a message to +// the app's configured messaging integrations (Slack, webhooks, ...) that skips the bug-report pipeline +// entirely — no video, logs or events. Messages are queued, sent in batches, and retried with backoff. +// +// Mobile persists the queue to disk so a message survives a restart. This relay is IN MEMORY (see +// docs/design/mobile-api-alignment.md, T7 findings): a page that closes with an unsent message loses it. +// `flush()` is the mitigation — `client.stop()`/`client.flush()` call it — and the queue survives a +// stop()/launch() cycle within one page. +// +// Limits, all from the mobile SDKs: a batch is at most 50 notifications / 256 KB, one notification at most +// 256 KB, and a failed batch is retried with a 2 s -> 60 s exponential backoff. + +const MAX_BATCH_COUNT = 50; +const MAX_BATCH_BYTES = 256 * 1024; +const MAX_ITEM_BYTES = 256 * 1024; +/** In-memory bound; mobile's equivalent is its storage count cap. The oldest message is dropped. */ +const MAX_PENDING = 1000; +const RETRY_INITIAL_MS = 2000; +const RETRY_MAX_MS = 60_000; + +export interface RelayTimers { + setTimeout(callback: () => void, ms: number): unknown; + clearTimeout(handle: unknown): void; +} + +const globalTimers = globalThis as unknown as { + setTimeout(cb: () => void, ms: number): unknown; + clearTimeout(handle: unknown): void; +}; +const defaultTimers: RelayTimers = { + setTimeout: (callback, ms) => { + const handle = globalTimers.setTimeout(callback, ms); + // A pending notification must not keep a Node process alive; no-op in the browser. + (handle as { unref?: () => void } | null)?.unref?.(); + return handle; + }, + clearTimeout: (handle) => globalTimers.clearTimeout(handle), +}; + +/** What `notify()` was called with. */ +export interface NotifyInput { + title: string; + body?: string; + severity?: SeverityName; + fields?: Record; + urgent?: boolean; +} + +/** The state stamped onto a notification at the moment `notify()` was called. */ +export interface NotifySnapshot { + userIdentifier?: string | null; + attributes?: Record; + environment?: EnvironmentEnvelope; +} + +export interface NotifyRelayOptions { + /** Deliver one batch. Must not throw; a throw is treated as a transient failure. */ + send: (items: readonly NotifyItem[]) => Promise; + clock: Clock; + /** Window (ms) after the first notification during which more can join its batch. 0 = this tick. */ + flushDelayMs: number; + /** User / attributes / environment as of the `notify()` call. A throw omits them (-> onError). */ + snapshot?: () => NotifySnapshot; + timers?: RelayTimers; + /** Notification id generator. Default `randomId()`. */ + newId?: () => string; + onError?: (error: unknown) => void; +} + +export interface NotifyRelay { + /** Validate, snapshot and queue a notification. Never throws; an invalid one is ignored. */ + enqueue(input: NotifyInput): void; + /** Send everything pending now. Resolves true when nothing is left pending. */ + flush(): Promise; + /** Cancel the scheduled flush and the backoff; the queue is kept. */ + suspend(): void; + /** Number of notifications not yet delivered: queued, or in a request that has not answered. */ + pending(): number; +} + +const jsonBytes = (item: NotifyItem): number => utf8ByteLength(JSON.stringify(item)); + +/** Only string-to-string entries are valid fields; anything else is dropped. */ +function sanitizeFields(fields: unknown): Record | undefined { + if (typeof fields !== 'object' || fields === null || Array.isArray(fields)) { + return undefined; + } + const out: Record = {}; + for (const [key, value] of Object.entries(fields)) { + if (typeof value === 'string') { + out[key] = value; + } + } + return Object.keys(out).length > 0 ? out : undefined; +} + +export function createNotifyRelay(options: NotifyRelayOptions): NotifyRelay { + const { send, clock, flushDelayMs } = options; + const timers = options.timers ?? defaultTimers; + const newId = options.newId ?? randomId; + const report = (error: unknown): void => { + try { + options.onError?.(error); + } catch { + // a throwing sink must not break delivery + } + }; + + let queue: NotifyItem[] = []; + let flushTimer: unknown = null; + let retryTimer: unknown = null; + let retryDelay = RETRY_INITIAL_MS; + let running: Promise | null = null; + /** Settles when every urgent send started so far has. A chain, so finished sends are not retained. */ + let urgentSends: Promise = Promise.resolve(); + let inFlightItems = 0; + + /** Keep at most MAX_PENDING, dropping the oldest. */ + const trim = (): void => { + while (queue.length > MAX_PENDING) { + queue.shift(); + report( + new Error(`notify: more than ${MAX_PENDING} notifications pending; dropped the oldest`), + ); + } + }; + + /** Settle one batch. true = done with it (delivered or dropped); false = some of it must be retried. */ + const dispatch = async (batch: NotifyItem[], skipAhead: boolean): Promise => { + const stamped = batch.map( + (item): NotifyItem => ({ + ...item, + delivery: + skipAhead || (item.urgent !== true && flushDelayMs <= 0) ? 'immediate' : 'deferred', + }), + ); + let result: NotifyResult; + inFlightItems += batch.length; + try { + result = await send(stamped); + } catch { + result = { outcome: 'retry' }; + } finally { + inFlightItems -= batch.length; + } + if (result.outcome === 'sent') { + retryDelay = RETRY_INITIAL_MS; + return true; + } + if (result.outcome === 'rejected') { + report(new Error(`notify: the collector rejected ${batch.length} notification(s); dropped`)); + return true; + } + const wanted = result.retryIds; + const back = wanted === undefined ? batch : batch.filter((item) => wanted.includes(item.id)); + queue = [...back, ...queue]; + trim(); + return false; + }; + + const takeBatch = (): NotifyItem[] => { + let bytes = 0; + let count = 0; + while (count < queue.length && count < MAX_BATCH_COUNT) { + // MAX_ITEM_BYTES <= MAX_BATCH_BYTES, so the first item always fits and a batch is never empty. + const size = jsonBytes(queue[count] as NotifyItem); + if (bytes + size > MAX_BATCH_BYTES) { + break; + } + bytes += size; + count += 1; + } + return queue.splice(0, count); + }; + + const drain = async (): Promise => { + while (queue.length > 0) { + if (!(await dispatch(takeBatch(), false))) { + scheduleRetry(); + return false; + } + } + clearRetryTimer(); // everything is out: a backoff armed by an earlier failure has nothing left to do + return true; + }; + + const clearFlushTimer = (): void => { + if (flushTimer !== null) { + timers.clearTimeout(flushTimer); + flushTimer = null; + } + }; + const clearRetryTimer = (): void => { + if (retryTimer !== null) { + timers.clearTimeout(retryTimer); + retryTimer = null; + } + }; + + const scheduleRetry = (): void => { + clearRetryTimer(); + const wait = retryDelay; + retryDelay = Math.min(retryDelay * 2, RETRY_MAX_MS); + retryTimer = timers.setTimeout(() => { + retryTimer = null; + void flush(); + }, wait); + }; + + const flush = async (): Promise => { + clearFlushTimer(); + running ??= drain().finally(() => { + running = null; + }); + const drained = await running; + await urgentSends; + return drained && queue.length === 0; + }; + + const scheduleFlush = (): void => { + // A backing-off retry already owns the next send; sending newer messages ahead of it would both + // hammer a failing endpoint and reorder the queue. + if (flushTimer !== null || retryTimer !== null) { + return; + } + flushTimer = timers.setTimeout(() => { + flushTimer = null; + void flush(); + }, flushDelayMs); + }; + + const sendUrgent = (item: NotifyItem): void => { + const attempt = dispatch([item], true).then((done) => { + if (!done) { + scheduleRetry(); + } + }); + urgentSends = Promise.allSettled([urgentSends, attempt]); + }; + + return { + enqueue(input: NotifyInput): void { + if (typeof input.title !== 'string' || input.title.length === 0) { + return; + } + const item: NotifyItem = { id: newId(), timestamp: clock.wallNow(), title: input.title }; + if (typeof input.body === 'string') { + item.body = input.body; + } + const severity = parseSeverityName(input.severity); + if (severity !== undefined) { + item.severity = severityToWire(severity); + } + const fields = sanitizeFields(input.fields); + if (fields !== undefined) { + item.fields = fields; + } + if (input.urgent === true) { + item.urgent = true; + } + try { + const snapshot = options.snapshot?.(); + if (snapshot?.userIdentifier) { + item.user = { id: snapshot.userIdentifier }; + } + if (snapshot?.attributes && Object.keys(snapshot.attributes).length > 0) { + item.attributes = snapshot.attributes; + } + if (snapshot?.environment !== undefined) { + item.environment = snapshot.environment; + } + } catch (error) { + report(error); + } + if (jsonBytes(item) > MAX_ITEM_BYTES) { + report(new Error('notify: notification is larger than 256 KB; ignored')); + return; + } + if (item.urgent === true) { + sendUrgent(item); + return; + } + queue.push(item); + trim(); + scheduleFlush(); + }, + + flush, + + suspend(): void { + clearFlushTimer(); + clearRetryTimer(); + }, + + pending(): number { + return queue.length + inFlightItems; + }, + }; +} diff --git a/packages/core/src/options.test.ts b/packages/core/src/options.test.ts index 0cce6231..9e0eb718 100644 --- a/packages/core/src/options.test.ts +++ b/packages/core/src/options.test.ts @@ -137,6 +137,11 @@ describe('COMMON_OPTION_DEFINITIONS', () => { } }); + it('maps notifyFlushDelay (T7) to the config.notify-flush-delay identifier, default 0 ms', () => { + expect(byFriendly.get('notifyFlushDelay')?.key).toBe(BugseeOption.NotifyFlushDelay); + expect(byFriendly.get('notifyFlushDelay')?.default).toBe(0); + }); + 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); @@ -167,6 +172,7 @@ describe('COMMON_OPTION_DEFINITIONS', () => { [BugseeOption.CaptureNetworkDefaultSanitizer]: true, [BugseeOption.ReportingDefaultCrashPriority]: 'blocker', [BugseeOption.ReportingDefaultErrorPriority]: 'high', + [BugseeOption.NotifyFlushDelay]: 0, }); }); }); diff --git a/packages/core/src/options.ts b/packages/core/src/options.ts index 77fcf871..e6bce7ae 100644 --- a/packages/core/src/options.ts +++ b/packages/core/src/options.ts @@ -106,4 +106,6 @@ export const COMMON_OPTION_DEFINITIONS: readonly OptionDefinition[] = [ key: BugseeOption.ReportingDefaultErrorPriority, default: 'high', }, + // `notify()` batching window in ms (Android `NotifyFlushDelay`); 0 = send as soon as the tick ends. + { friendly: 'notifyFlushDelay', key: BugseeOption.NotifyFlushDelay, default: 0 }, ]; diff --git a/packages/core/src/transport.test-d.ts b/packages/core/src/transport.test-d.ts index d4fde027..b07e6c90 100644 --- a/packages/core/src/transport.test-d.ts +++ b/packages/core/src/transport.test-d.ts @@ -42,6 +42,7 @@ const api: BugseeApi = { createIssue: async () => issueResult, renewUpload: async () => issueResult, invalidateSession: () => {}, + notify: async () => ({ outcome: 'sent' }), }; const uploader: BundleUploader = { diff --git a/packages/core/src/transport.ts b/packages/core/src/transport.ts index 9c191188..8d5f1dd0 100644 --- a/packages/core/src/transport.ts +++ b/packages/core/src/transport.ts @@ -56,6 +56,35 @@ export type PutResult = cause?: unknown; }; +/** + * One `notify()` message as POST /v2/notifications carries it (mobile `RelayNotification`; the collector + * reads these exact keys). `severity` is the numeric wire value; `delivery` is stamped at send. + */ +export interface NotifyItem { + id: string; + timestamp: number; + title: string; + body?: string; + severity?: number; + fields?: Record; + urgent?: true; + delivery?: 'immediate' | 'deferred'; + user?: { id: string }; + environment?: EnvironmentEnvelope; + attributes?: Record; +} + +/** + * What the collector decided about a notification batch. `sent`: every item is settled (delivered, or + * deliberately not relayed). `retry`: the items in `retryIds` (all of them when absent) should be sent + * again later — a network error, 408/429/5xx, or a per-item `failed`. `rejected`: a client error, which + * retrying cannot fix, so the batch is dropped. + */ +export type NotifyResult = + | { outcome: 'sent' } + | { outcome: 'rejected' } + | { outcome: 'retry'; retryIds?: readonly string[] }; + /** CONTROL PLANE — authenticated; orchestrates session + issue lifecycle (§7.5). */ export interface BugseeApi { /** @@ -76,6 +105,11 @@ export interface BugseeApi { ): Promise; /** Drop the cached access token; the next ensureSession re-acquires. */ invalidateSession(): void; + /** + * POST /v2/notifications (mobile `Bugsee.notify` relay). Authenticates with the app token alone, so it + * needs no session. Never throws: a transport failure is a `retry` result. + */ + notify(items: readonly NotifyItem[]): Promise; } /** PUT headers for the signed-URL upload (§8.3). */ diff --git a/packages/core/src/upload-pipeline.test.ts b/packages/core/src/upload-pipeline.test.ts index 3d610213..3bc60010 100644 --- a/packages/core/src/upload-pipeline.test.ts +++ b/packages/core/src/upload-pipeline.test.ts @@ -39,6 +39,7 @@ function fakeApi(over: Partial = {}): BugseeApi { createIssue: vi.fn(async () => issue), renewUpload: vi.fn(async () => ({ ...issue, endpoint: 'https://put/2' })), invalidateSession: vi.fn(), + notify: vi.fn(async () => ({ outcome: 'sent' as const })), ...over, }; } diff --git a/packages/node/src/launch.ts b/packages/node/src/launch.ts index 6a36dd33..583ccfb3 100644 --- a/packages/node/src/launch.ts +++ b/packages/node/src/launch.ts @@ -246,6 +246,11 @@ export interface BugseeLaunchOptions { reportDefaultCrashPriority?: 'verylow' | 'low' | 'medium' | 'high' | 'critical' | 'blocker'; /** Severity an error report starts with. Default `high`. */ reportDefaultErrorPriority?: 'verylow' | 'low' | 'medium' | 'high' | 'critical' | 'blocker'; + /** + * `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; /** Detect main-thread/event-loop hangs and report them (Android BugseeDetectionHang). Default true. */ detectHangs?: boolean; /** @@ -742,6 +747,8 @@ function buildLaunch(appToken: string, options: BugseeLaunchOptions = {}): Launc launchOptionsView: { effective: resolved.canonical, host: resolved.host }, services, // the internal container launch populated (transport + later seams) uploadPipeline, + // `client.notify()` rides the same transport and app token as the session/issue calls. + notifySender: (items) => api.notify(items), appToken, getEnvironment, captureStore, diff --git a/packages/node/src/notify.launch.test.ts b/packages/node/src/notify.launch.test.ts new file mode 100644 index 00000000..406f58b4 --- /dev/null +++ b/packages/node/src/notify.launch.test.ts @@ -0,0 +1,130 @@ +import { + createMemoryCaptureStore, + type HttpRequestOptions, + type HttpResponse, + type HttpTransport, +} from '@bugsee/core'; +import { afterEach, describe, expect, it, vi } from 'vitest'; +import type { SystemProbe } from './environment'; +import { type BugseeLaunchOptions, launch, type NodeRuntime } from './launch'; + +// `notify()` (T7) end to end through the real Node composition root: POST /v2/notifications over the same +// transport as the session/issue calls, authenticated by the app token in the body. + +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 transport = () => + vi.fn(async (url: string, _options: HttpRequestOptions = {}) => { + if (url.endsWith('/v2/notifications')) { + return { + status: 200, + headers: {}, + body: jsonBody({ ok: true, result: { notifications: {} } }), + } satisfies HttpResponse; + } + return { status: 200, headers: {}, body: jsonBody({ access_token: 'access' }) }; + }); + +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, + }; + return proc as unknown as NodeRuntime; +}; + +const clients: ReturnType[] = []; +afterEach(async () => { + await Promise.all(clients.splice(0).map((c) => c.stop())); + delete (globalThis as { __BUGSEE__?: unknown }).__BUGSEE__; +}); + +const start = (over: Partial = {}) => { + const http = transport(); + const client = launch('tok', { + process: fakeProcess(), + transport: http, + systemProbe: probe, + systemMetricsSampler: () => [], + captureNetwork: false, + exitOnUncaught: false, + captureStore: createMemoryCaptureStore({ maxRecordingTimeMs: Number.POSITIVE_INFINITY }), + ...over, + }); + clients.push(client); + return { client, http }; +}; + +const notifications = (http: ReturnType) => + http.mock.calls.filter(([url]) => url.endsWith('/v2/notifications')) as Array< + [string, HttpRequestOptions] + >; + +describe('node notify()', () => { + it('POSTs the message to /v2/notifications with the app token, without opening a session', async () => { + const { client, http } = start(); + client.notify('Job failed', 'nightly export', 'critical', { job: 'export' }, true); + await vi.waitFor(() => expect(notifications(http)).toHaveLength(1)); + + const [url, options] = notifications(http)[0] as [string, HttpRequestOptions]; + expect(url).toMatch(/\/v2\/notifications$/); + const body = JSON.parse(options.body as string) as { + app_token: string; + notifications: Array>; + }; + expect(body.app_token).toBe('tok'); + expect(body.notifications[0]).toMatchObject({ + title: 'Job failed', + body: 'nightly export', + severity: 4, + fields: { job: 'export' }, + urgent: true, + delivery: 'immediate', + }); + expect((options.headers as Record)['x-app-token']).toBe('tok'); + expect(http.mock.calls.some(([u]) => u.endsWith('/v2/sessions'))).toBe(false); + }); + + it('honours notifyFlushDelay and delivers on stop()', async () => { + const { client, http } = start({ notifyFlushDelay: 60_000 }); + client.notify('later'); + await new Promise((resolve) => setTimeout(resolve, 30)); + expect(notifications(http)).toHaveLength(0); + await client.stop(); + expect(notifications(http)).toHaveLength(1); + }); + + it('reports the delay among the launch options', () => { + const { client } = start({ notifyFlushDelay: 250 }); + expect(client.getLaunchOptions()['com.bugsee.option.config.notify-flush-delay']).toBe(250); + }); +}); diff --git a/packages/protocol/src/options.test.ts b/packages/protocol/src/options.test.ts index 96f2fc1d..9b77305e 100644 --- a/packages/protocol/src/options.test.ts +++ b/packages/protocol/src/options.test.ts @@ -107,6 +107,10 @@ describe('BugseeOption canonical identifiers', () => { ); }); + it('matches the Android notify flush delay identifier (T7)', () => { + expect(BugseeOption.NotifyFlushDelay).toBe('com.bugsee.option.config.notify-flush-delay'); + }); + 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 7eaeafbd..531dd658 100644 --- a/packages/protocol/src/options.ts +++ b/packages/protocol/src/options.ts @@ -79,6 +79,12 @@ export const BugseeOption = { Duration: 'com.bugsee.option.config.duration', /** Max captured data kept in the rolling buffer, in megabytes (memory/disk bound). */ MaxDataSize: 'com.bugsee.option.config.data-size', + /** + * `notify()` coalescing window in milliseconds (Android `Options.NotifyFlushDelay`). `0` (default) sends + * a burst of notifications as one request the moment the current tick ends; a larger value waits that + * long after the first one so more can join the batch. An `urgent` notification skips the wait. + */ + NotifyFlushDelay: 'com.bugsee.option.config.notify-flush-delay', /** Report dialog: the summary field must be filled in (Android `ReportingUISummaryRequired`). */ ReportingUiSummaryRequired: 'com.bugsee.option.reporting.ui.summary-required', /** Report dialog: the description field must be filled in. */ @@ -128,6 +134,7 @@ export interface BugseeOptionTypes { 'com.bugsee.option.detect.hang.level.severe': number; 'com.bugsee.option.config.duration': number; 'com.bugsee.option.config.data-size': number; + 'com.bugsee.option.config.notify-flush-delay': number; 'com.bugsee.option.reporting.ui.summary-required': boolean; 'com.bugsee.option.reporting.ui.description-required': boolean; 'com.bugsee.option.reporting.ui.email-required': boolean;