@edraj/sauron-browser 1.0.0 → 1.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -1,15 +1,20 @@
1
1
  # @edraj/sauron-browser
2
2
 
3
- Browser SDK for **Sauron** — error reporting + product analytics in one small
4
- package. Captures uncaught errors and unhandled promise rejections
5
- automatically, records breadcrumbs, exposes `track()` / `identify()`, and
6
- batches gzips queues envelopes (offline-safe) before POSTing them to the
7
- Sauron ingest gateway.
3
+ Client-side SDK for **Sauron** — error reporting and product analytics for the
4
+ browser in one small package. It runs in the page (or any browser-like host) and
5
+ posts a canonical JSON envelope to the Sauron ingest gateway. For a Node.js
6
+ process an API server, a worker, a CLI use the server SDK
7
+ [`@edraj/sauron-node`](../node) instead; this package assumes browser globals
8
+ (`window`, `document`, `localStorage`) and ships a public, write-only DSN key.
8
9
 
9
- - Zero-config auto-instrumentation: `window.onerror`, `onunhandledrejection`,
10
- `console`, DOM clicks, `fetch`, `XMLHttpRequest`, and SPA history navigation.
11
- - One runtime dependency (`fflate`, used only as a gzip fallback).
12
- - Ships ESM + CJS + type definitions. `sideEffects: false`, tree-shakeable.
10
+ - Auto-instruments `window.onerror`, `onunhandledrejection`, `console`, DOM
11
+ clicks, `fetch`, `XMLHttpRequest`, and SPA History navigations out of the box.
12
+ - Opt-in performance transactions (navigation timing, per-`fetch` HTTP spans,
13
+ SPA route spans) and opt-in screen tracking.
14
+ - Batches, gzips, retries with jitter, and parks failed envelopes in a
15
+ `localStorage` queue that drains on the next page load or `online` event.
16
+ - One runtime dependency (`fflate`, lazily imported only as a gzip fallback).
17
+ - Ships ESM + CJS + type declarations, `sideEffects: false`, tree-shakeable.
13
18
 
14
19
  ## Install
15
20
 
@@ -17,20 +22,17 @@ Sauron ingest gateway.
17
22
  npm install @edraj/sauron-browser
18
23
  ```
19
24
 
25
+ Node >= 18 is required for the build/test tooling (`engines.node`). The shipped
26
+ bundle targets ES2020 and needs no polyfills in evergreen browsers.
27
+
20
28
  ## Quick start
21
29
 
22
30
  ```ts
23
31
  import { Sauron } from '@edraj/sauron-browser';
24
32
 
25
33
  Sauron.init({
26
- dsn: 'https://pk_test@ingest.sauron.dev/42',
27
- environment: 'production',
34
+ dsn: 'https://pk_test@ingest.example.com/42',
28
35
  release: 'web@1.4.2',
29
- sampleRate: 1, // fraction of errors to send
30
- maxBreadcrumbs: 50,
31
- beforeSend(item) { // PII escape hatch — return null to drop
32
- return item;
33
- },
34
36
  });
35
37
 
36
38
  Sauron.identify('u_123', { plan: 'pro' });
@@ -41,69 +43,1022 @@ try {
41
43
  } catch (err) {
42
44
  Sauron.captureException(err);
43
45
  }
46
+
47
+ // Optional: force delivery now instead of waiting for the 5 s flush tick.
48
+ await Sauron.flush(2000);
44
49
  ```
45
50
 
46
- ## API
51
+ Uncaught errors and unhandled rejections need no code at all — `init()` installs
52
+ the global handlers.
47
53
 
48
- | Function | Description |
49
- | --- | --- |
50
- | `init(options)` | Initialize the SDK (idempotent). |
51
- | `captureException(err, hint?)` | Report an exception or any thrown value. |
52
- | `captureMessage(msg, level?)` | Report a plain message. |
53
- | `track(name, props?)` | Record a product-analytics event. |
54
- | `trackTransaction(input)` | Record a performance transaction (navigation / http / screen load). |
55
- | `identify(id, traits?)` | Associate the session with a user. |
56
- | `addBreadcrumb(crumb)` | Manually add a breadcrumb. |
57
- | `setUser(user \| null)` | Set or clear the current user. |
58
- | `flush(timeoutMs?)` | Send everything pending; resolves `false` on timeout. |
59
- | `close(timeoutMs?)` | Flush, then restore all patched globals. |
54
+ ## Configuration
55
+
56
+ `init(options)` takes an `InitOptions` object. Only `dsn` is required; anything
57
+ missing falls back to the default below (resolved in `resolveOptions()`).
60
58
 
61
- ### `init` options
59
+ | Option | Type | Default | Description |
60
+ | --- | --- | --- | --- |
61
+ | `dsn` | `string` | — **(required)** | `https://<public_key>@<host>/<environment_id>`. A non-string or empty value throws `Error`; a malformed URL throws `DsnError`. |
62
+ | `release` | `string` | `null` | Stamped on `header.release`; the part after the last `@` also becomes `context.app.version` (`web@1.4.2` → `1.4.2`). |
63
+ | `sampleRate` | `number` | `1` | Fraction of **error items** sent, clamped into `[0, 1]`. Applies to `captureException`, `captureMessage` and the global handlers only — events, identifies and transactions are never sampled. |
64
+ | `maxBreadcrumbs` | `number` | `50` | Ring-buffer size; oldest entries are evicted first. Negative values are treated as `0`, which disables breadcrumbs entirely. |
65
+ | `beforeSend` | `(item: EnvelopeItem, hint?: Hint) => EnvelopeItem \| null` | `undefined` | Runs on **every** item type just before the transport. Return `null` to drop. If it throws, the original item is sent and a warning is logged in `debug` mode. |
66
+ | `beforeBreadcrumb` | `(breadcrumb: Breadcrumb, hint?: Hint) => Breadcrumb \| null` | `undefined` | Runs on every breadcrumb before it enters the buffer. Return `null` to drop. Throwing keeps the original. |
67
+ | `transport` | `TransportOptions` | see below | Batching / queue tuning. |
68
+ | `performance` | `boolean` | `false` | Opt-in performance auto-capture (see [Automatic instrumentation](#automatic-instrumentation)). Manual `trackTransaction()` works regardless. |
69
+ | `screen` | `string` | `undefined` | Seeds the initial screen name. Seeding does **not** emit a `$screen` event — only a later `setScreen()` change does. |
70
+ | `screenTracking` | `boolean` | `false` | Opt-in: set the screen to the new path on every SPA History navigation (which emits `$screen`). `setScreen()` works regardless. |
71
+ | `tags` | `Record<string, string>` | `{}` | Default tags seeded into the global scope. |
72
+ | `contexts` | `Record<string, Record<string, unknown>>` | `{}` | Default named context blocks seeded into the global scope. |
73
+ | `extra` | `Record<string, unknown>` | `{}` | Default freeform values seeded into the global scope. |
74
+ | `debug` | `boolean` | `false` | Log SDK diagnostics to `console` with a `[sauron]` prefix. |
75
+
76
+ `TransportOptions`:
77
+
78
+ | Option | Type | Default | Description |
79
+ | --- | --- | --- | --- |
80
+ | `flushIntervalMs` | `number` | `5000` | Periodic flush cadence. A value `<= 0` disables the timer (you must call `flush()` yourself). |
81
+ | `maxBatch` | `number` | `30` | Items per envelope before an eager flush. Clamped into `[1, 1000]` — 1000 is the server's per-envelope item limit. |
82
+ | `maxQueueBytes` | `number` | `1048576` | Byte cap on the offline `localStorage` queue (1 MiB). Negative values are treated as `0`. |
83
+
84
+ Every option set at once:
62
85
 
63
86
  ```ts
87
+ import { Sauron } from '@edraj/sauron-browser';
88
+
64
89
  Sauron.init({
65
- dsn: string, // https://<public_key>@<host>/<project_id>
66
- environment?: string, // default "production"
67
- release?: string, // e.g. "web@1.4.2"
68
- sampleRate?: number, // default 1
69
- maxBreadcrumbs?: number, // default 50
70
- beforeSend?: (item, hint) => item | null,
71
- beforeBreadcrumb?: (crumb, hint) => crumb | null,
72
- transport?: {
73
- flushIntervalMs?: number, // default 5000
74
- maxBatch?: number, // default 30
75
- maxQueueBytes?: number, // default 1048576
90
+ dsn: 'https://pk_test@ingest.example.com/42',
91
+ release: 'web@1.4.2',
92
+ sampleRate: 0.5,
93
+ maxBreadcrumbs: 100,
94
+ beforeSend(item, hint) {
95
+ // `exception` is optional — a `captureMessage` item has none, and carries
96
+ // its text in `item.message` instead.
97
+ if (item.type === 'error' && item.exception?.value?.includes('token=')) {
98
+ return null; // PII escape hatch
99
+ }
100
+ return item;
101
+ },
102
+ beforeBreadcrumb(crumb) {
103
+ return crumb.category === 'console' ? null : crumb;
76
104
  },
77
- performance?: boolean, // auto-capture perf transactions (opt-in), default false
78
- debug?: boolean, // default false
105
+ transport: {
106
+ flushIntervalMs: 5000,
107
+ maxBatch: 30,
108
+ maxQueueBytes: 1048576,
109
+ },
110
+ performance: true,
111
+ screen: '/',
112
+ screenTracking: true,
113
+ tags: { tier: 'free' },
114
+ contexts: { deploy: { region: 'eu-west-1' } },
115
+ extra: { build: 'ci-42' },
116
+ debug: true,
117
+ });
118
+ ```
119
+
120
+ ## API reference
121
+
122
+ Everything is exported both as a named function and as a member of the `Sauron`
123
+ facade (also the default export). The two are the same function:
124
+
125
+ ```ts
126
+ import { Sauron } from '@edraj/sauron-browser'; // facade
127
+ import Sauron from '@edraj/sauron-browser'; // default export
128
+ import { init, captureException } from '@edraj/sauron-browser'; // named
129
+ ```
130
+
131
+ The facade carries `init`, `captureException`, `captureMessage`, `track`,
132
+ `trackTransaction`, `identify`, `addBreadcrumb`, `setUser`, `setTag`, `setTags`,
133
+ `setContext`, `setExtra`, `setScreen`, `getScreen`, `startWorkflow`,
134
+ `endWorkflow`, `cancelWorkflow`, `getWorkflow`, `flush`, `close` and
135
+ `getClient`.
136
+
137
+ Before `init()` every capture, analytics and scope function is a silent no-op,
138
+ `getScreen()`/`getWorkflow()` return `null`, `startWorkflow`/`endWorkflow`/
139
+ `cancelWorkflow` resolve to `{ status: 'disabled' }`, and `flush()`/`close()`
140
+ resolve to `false`. Nothing throws. After `close()` the capture and scope
141
+ functions stay no-ops (the client is disabled) and the screen and active
142
+ workflow are reset to `null`.
143
+
144
+ The same disabled state is also reached **automatically, mid-session**, the
145
+ moment the gateway answers a delivery with `401`/`403` (a revoked or invalid
146
+ DSN key) — no call to `close()`/`disable()` required. Check
147
+ [`isEnabled()`](#sauronclient) rather than assuming the SDK is live just
148
+ because you never explicitly disabled it.
149
+
150
+ ### `init(options)`
151
+
152
+ ```ts
153
+ function init(options: InitOptions): SauronClient
154
+ ```
155
+
156
+ | Parameter | Type | Default | Description |
157
+ | --- | --- | --- | --- |
158
+ | `options` | `InitOptions` | — (required) | See [Configuration](#configuration). |
159
+
160
+ Resolves defaults, parses the DSN, seeds `tags`/`contexts`/`extra` into the
161
+ global scope, installs the integrations, starts the flush timer and drains the
162
+ offline queue. Returns the live `SauronClient`.
163
+
164
+ Idempotent: a second `init()` tears the previous client down first (restoring
165
+ every patched global, clearing the current screen) before installing a fresh
166
+ one. Throws `Error` when `dsn` is missing or not a string, and `DsnError` when
167
+ the DSN itself is malformed.
168
+
169
+ ```ts
170
+ const client = Sauron.init({ dsn: 'https://pk_test@localhost:8081/1' });
171
+ client.options.release; // null
172
+ client.dsn.projectId; // '1'
173
+ ```
174
+
175
+ ### `captureException(err, hint?)`
176
+
177
+ ```ts
178
+ function captureException(err: unknown, hint?: Hint): void
179
+ ```
180
+
181
+ | Parameter | Type | Default | Description |
182
+ | --- | --- | --- | --- |
183
+ | `err` | `unknown` | — (required) | An `Error`, an error-like object (`name` + string `message`), a string, any object, or a primitive. Non-errors are reduced to `{type, value}` with an empty stack trace. |
184
+ | `hint` | `Hint` | `undefined` | Per-call overrides, also forwarded to `beforeSend`. |
185
+
186
+ Recognized `hint` keys:
187
+
188
+ | Key | Type | Default | Description |
189
+ | --- | --- | --- | --- |
190
+ | `level` | `Level` | `'error'` | `'debug' \| 'info' \| 'warning' \| 'error' \| 'fatal'`. |
191
+ | `mechanism` | `Mechanism` | `{ type: 'generic', handled: true }` | How the error reached the SDK. |
192
+ | `fingerprint` | `string[] \| null` | `null` | Overrides server-side grouping. |
193
+ | `screen` | `string` | current screen | Screen stamped on this item. |
194
+ | `event_id` | `string` | fresh UUID v4 | Correlation id for the report. |
195
+ | `message` | `string` | `undefined` | Human summary alongside the exception. |
196
+ | `tags` | `Record<string, string>` | `undefined` | Merged over scope tags (this call only). |
197
+ | `contexts` | `Record<string, Record<string, unknown>>` | `undefined` | Merged over scope contexts (this call only). |
198
+ | `extra` | `Record<string, unknown>` | `undefined` | Merged over scope extra (this call only). |
199
+
200
+ Any other key is passed through to `beforeSend` untouched. `originalException`
201
+ is always set to `err` on the hint the SDK hands to `beforeSend`. Returns
202
+ `void`; the item is buffered, not sent synchronously.
203
+
204
+ ```ts
205
+ try {
206
+ await placeOrder(orderId);
207
+ } catch (err) {
208
+ Sauron.captureException(err, {
209
+ level: 'fatal',
210
+ fingerprint: ['checkout', 'place-order'],
211
+ tags: { flow: 'checkout' },
212
+ contexts: { order: { id: orderId } },
213
+ extra: { retry_count: 2 },
214
+ });
215
+ }
216
+ ```
217
+
218
+ ### `captureMessage(message, level?, hint?)`
219
+
220
+ ```ts
221
+ function captureMessage(message: string, level?: Level, hint?: Hint): void
222
+ ```
223
+
224
+ | Parameter | Type | Default | Description |
225
+ | --- | --- | --- | --- |
226
+ | `message` | `string` | — (required) | Becomes the item's `message`. |
227
+ | `level` | `Level` | `'info'` | Severity. |
228
+ | `hint` | `Hint` | `undefined` | Only `fingerprint`, `event_id`, `tags`, `contexts` and `extra` are read here — unlike `captureException`, `hint.level`, `hint.mechanism` and `hint.screen` are ignored, and `hint.message` no longer applies because the `message` argument already occupies that field. |
229
+
230
+ Emits an error item that carries **no `exception` block at all** — a message is
231
+ not an exception — plus the current breadcrumb trail and the current screen.
232
+ Server-side it groups on the message-fallback fingerprint
233
+ (`message` + the message text, normalized), so distinct messages become distinct
234
+ issues instead of piling into one bucket keyed by a synthetic exception type.
235
+ Counts against `sampleRate` like any other error item. Returns `void`.
236
+
237
+ > Through 1.3.0 this shipped `exception: { type: null, value: message }`. The
238
+ > gateway's exception type is a non-nullable string, so that item failed to
239
+ > deserialize and the whole envelope came back `400 invalid_envelope` — and since
240
+ > a 400 is a non-retryable drop, **every other item batched with it (up to
241
+ > `maxBatch`, default 30) was silently lost too**. If you have a `beforeSend`
242
+ > hook or any code reading `item.exception` on message items, note that the field
243
+ > is now absent and `item.message` carries the text.
244
+
245
+ ```ts
246
+ Sauron.captureMessage('payment provider returned a soft decline', 'warning', {
247
+ tags: { provider: 'stripe' },
248
+ });
249
+ ```
250
+
251
+ ### `track(name, properties?, options?)`
252
+
253
+ ```ts
254
+ function track(
255
+ name: string,
256
+ properties?: Record<string, unknown>,
257
+ options?: TrackOptions,
258
+ ): void
259
+ ```
260
+
261
+ | Parameter | Type | Default | Description |
262
+ | --- | --- | --- | --- |
263
+ | `name` | `string` | — (required) | Event name. The SDK itself emits one reserved name, `$screen`. |
264
+ | `properties` | `Record<string, unknown>` | `{}` | Event properties, sent verbatim. |
265
+ | `options` | `TrackOptions` | `{}` | Per-call metadata, see below. |
266
+
267
+ `TrackOptions` (extends `CaptureOptions`):
268
+
269
+ | Field | Type | Default | Description |
270
+ | --- | --- | --- | --- |
271
+ | `tags` | `Record<string, string>` | `undefined` | Merged over scope tags. |
272
+ | `contexts` | `Record<string, Record<string, unknown>>` | `undefined` | Merged over scope contexts (per block name). |
273
+ | `extra` | `Record<string, unknown>` | `undefined` | Merged over scope extra. |
274
+ | `screen` | `string` | current screen | Screen stamped on this event only; does not change the current screen. |
275
+
276
+ The event carries `distinct_id` (the identified user id, else a lazily minted
277
+ `anon_<uuid>`), the session id and the screen. Events are never sampled.
278
+ Returns `void`.
279
+
280
+ ```ts
281
+ Sauron.track('checkout_completed', { cart_value: 42.5, currency: 'EUR' }, {
282
+ tags: { experiment: 'new-cart' },
283
+ contexts: { cart: { items: 3 } },
284
+ extra: { coupon: 'SUMMER' },
285
+ screen: '/checkout/confirm',
286
+ });
287
+ ```
288
+
289
+ ### `identify(id, traits?)`
290
+
291
+ ```ts
292
+ function identify(id: string, traits?: Record<string, unknown>): void
293
+ ```
294
+
295
+ | Parameter | Type | Default | Description |
296
+ | --- | --- | --- | --- |
297
+ | `id` | `string` | — (required) | The distinct id (your user id). |
298
+ | `traits` | `Record<string, unknown>` | `{}` | Traits stored on the scope user and sent on the identify item. |
299
+
300
+ Sets the scope user to `{ id, traits }` and emits an identify item whose
301
+ `anonymous_id` is the previously minted anonymous id, or `null` when the session
302
+ never needed one. Note that this replaces the whole scope user — an `email` set
303
+ earlier via `setUser()` is cleared; call `setUser()` after `identify()` if you
304
+ need it. Returns `void`.
305
+
306
+ ```ts
307
+ Sauron.identify('u_123', { plan: 'pro', signup_month: '2026-03' });
308
+ ```
309
+
310
+ ### `trackTransaction(input)`
311
+
312
+ ```ts
313
+ function trackTransaction(input: TransactionInput): void
314
+ ```
315
+
316
+ | Field of `input` | Type | Default | Description |
317
+ | --- | --- | --- | --- |
318
+ | `name` | `string` | — (required) | Transaction name, e.g. `GET /api/orders`. |
319
+ | `durationMs` | `number` | — (required) | Wall-clock span in milliseconds. |
320
+ | `op` | `string` | `'custom'` | One of `'navigation' \| 'http' \| 'resource' \| 'screen_load' \| 'custom'`. Anything else is coerced to `'custom'`. |
321
+ | `status` | `string \| null` | `null` | Free-form outcome, e.g. `'ok'` / `'error'`. |
322
+ | `httpMethod` | `string \| null` | `null` | For `http` ops. |
323
+ | `httpStatus` | `number \| null` | `null` | For `http` ops. |
324
+ | `url` | `string \| null` | `null` | For `http` ops. |
325
+
326
+ The item is stamped with the current distinct id, session id and timestamp.
327
+ Never sampled. Returns `void`.
328
+
329
+ ```ts
330
+ const started = performance.now();
331
+ const res = await fetch('/api/orders');
332
+ Sauron.trackTransaction({
333
+ name: 'GET /api/orders',
334
+ op: 'http',
335
+ durationMs: performance.now() - started,
336
+ status: res.ok ? 'ok' : 'error',
337
+ httpMethod: 'GET',
338
+ httpStatus: res.status,
339
+ url: '/api/orders',
79
340
  });
80
341
  ```
81
342
 
82
- ## Wire contract
343
+ ### `setScreen(name)`
344
+
345
+ ```ts
346
+ function setScreen(name: string): void
347
+ ```
348
+
349
+ | Parameter | Type | Default | Description |
350
+ | --- | --- | --- | --- |
351
+ | `name` | `string` | — (required) | The new screen/route name. |
352
+
353
+ Sets the current screen. On an actual change it also emits a `$screen` event
354
+ with `properties: { screen: name }` so dwell time can be computed server-side;
355
+ calling it again with the same name is a no-op. The current screen is stamped on
356
+ every subsequent event and error item. Returns `void`.
357
+
358
+ ```ts
359
+ router.afterEach((to) => Sauron.setScreen(to.path));
360
+ ```
361
+
362
+ ### `getScreen()`
363
+
364
+ ```ts
365
+ function getScreen(): string | null
366
+ ```
367
+
368
+ Returns the current screen name, or `null` when none was ever set (and after
369
+ `close()`, which resets it).
370
+
371
+ ```ts
372
+ if (Sauron.getScreen() !== '/checkout') Sauron.setScreen('/checkout');
373
+ ```
374
+
375
+ ### `startWorkflow(name, options?)`
376
+
377
+ ```ts
378
+ function startWorkflow(name: string, options?: { force?: boolean }): WorkflowResult
379
+ ```
380
+
381
+ | Parameter | Type | Default | Description |
382
+ | --- | --- | --- | --- |
383
+ | `name` | `string` | — (required) | Workflow name. Trimmed; rejected if empty after trimming or longer than 120 characters. |
384
+ | `options.force` | `boolean` | `false` | Replace an already-active workflow instead of rejecting the call. |
385
+
386
+ Starts a named, explicitly-bounded span of activity — e.g. `checkout`,
387
+ `onboarding` — and mints a fresh **client-generated UUID** as its
388
+ `workflowId`/wire `workflow_id`. While a workflow is active, every subsequent
389
+ `track`, `captureException`, `captureMessage` and `trackTransaction` call is
390
+ additionally stamped with `workflow_id` + `workflow_name`, alongside whatever
391
+ else it already carries. `startWorkflow` itself emits a reserved
392
+ `$workflow_start` event, stamped with the *new* workflow.
393
+
394
+ Workflows are entirely optional: an app that never calls `startWorkflow`
395
+ behaves exactly as before — no `workflow_id`/`workflow_name` fields are ever
396
+ added to any item.
397
+
398
+ Returns a `WorkflowResult`:
399
+
400
+ | `status` | Meaning |
401
+ | --- | --- |
402
+ | `'ok'` | Started (or replaced, with `force`). `workflowId` is the new id. |
403
+ | `'already_active'` | Another workflow is already active and `force` was not set. Nothing changed. |
404
+ | `'invalid_name'` | `name` was empty after trimming, or over 120 characters. Nothing changed. |
405
+ | `'disabled'` | Called before `init()`, after the client was closed/disabled, or after the transport auto-disabled itself on a `401`/`403` — also returned if an unexpected internal error occurred. Nothing changed. |
83
406
 
84
- The SDK POSTs a canonical envelope to `POST /api/{project_id}/envelope`:
407
+ With `force: true`, the previously-active workflow is closed first — emitting
408
+ `$workflow_cancel` for it with `reason: 'superseded'` — and then the new one
409
+ starts. Without `force`, an active workflow simply blocks the call (logged as
410
+ a warning in `debug` mode). Telemetry never throws: every precondition failure
411
+ returns a status instead.
412
+
413
+ `'disabled'` always means *nothing changed*, so it is never worth retrying
414
+ blindly. If the workflow started but its `$workflow_start` event could not be
415
+ delivered, you still get `'ok'` and a `workflowId` — the workflow is live and
416
+ stamping is active, and the server materializes the workflow from the first
417
+ stamped event it receives regardless.
418
+
419
+ ```ts
420
+ const result = Sauron.startWorkflow('checkout');
421
+ if (result.status === 'ok') {
422
+ console.log('workflow id', result.workflowId);
423
+ }
424
+
425
+ // Force-replace whatever workflow (if any) is currently active:
426
+ Sauron.startWorkflow('checkout', { force: true });
427
+ ```
428
+
429
+ ### `endWorkflow(name?)`
430
+
431
+ ```ts
432
+ function endWorkflow(name?: string): WorkflowResult
433
+ ```
434
+
435
+ | Parameter | Type | Default | Description |
436
+ | --- | --- | --- | --- |
437
+ | `name` | `string` | current workflow | If given, must match the active workflow's name or the call is rejected. |
438
+
439
+ Ends the active workflow: emits `$workflow_end` carrying `duration_ms` (the
440
+ time since `startWorkflow`), then clears the active workflow.
441
+
442
+ | `status` | Meaning |
443
+ | --- | --- |
444
+ | `'ok'` | Ended. `workflowId` is the id that was closed. |
445
+ | `'not_active'` | No workflow is active. Nothing changed. |
446
+ | `'name_mismatch'` | `name` was given but does not match the active workflow. Nothing changed. |
447
+ | `'disabled'` | Called before `init()`, after the client was closed/disabled, or after the transport auto-disabled itself on a `401`/`403` — also returned if an unexpected internal error occurred. Nothing changed. |
448
+
449
+ A `name` that is itself malformed — empty, whitespace-only, or over 120
450
+ characters — reports `'name_mismatch'`, not `'invalid_name'`: it cannot match
451
+ the active workflow, and the call named a workflow that is not the active one.
452
+ `'invalid_name'` is reserved for `startWorkflow`, where the name is the thing
453
+ being created rather than a guard on which workflow to close.
454
+
455
+ `'ok'` always means the workflow really is closed locally, even in the rare
456
+ case where the `$workflow_end` event itself could not be delivered — so it is
457
+ never correct to see `'ok'` and still have `getWorkflow()` return non-null.
458
+
459
+ ```ts
460
+ Sauron.startWorkflow('checkout');
461
+ // ... later
462
+ Sauron.endWorkflow(); // { status: 'ok', workflowId: '...' }
463
+ ```
464
+
465
+ ### `cancelWorkflow(name?, options?)`
466
+
467
+ ```ts
468
+ function cancelWorkflow(name?: string, options?: { reason?: string }): WorkflowResult
469
+ ```
470
+
471
+ | Parameter | Type | Default | Description |
472
+ | --- | --- | --- | --- |
473
+ | `name` | `string` | current workflow | If given, must match the active workflow's name or the call is rejected. |
474
+ | `options.reason` | `string` | `'user'` | Free-form cancellation reason. Trimmed and capped at 120 characters. |
475
+
476
+ Cancels the active workflow: emits `$workflow_cancel` carrying `duration_ms`
477
+ and `reason`, then clears the active workflow. Same status values and
478
+ preconditions as `endWorkflow` (`'ok'` / `'not_active'` / `'name_mismatch'` /
479
+ `'disabled'`), including the rule that a malformed `name` reports
480
+ `'name_mismatch'`. `startWorkflow(..., { force: true })` uses this internally
481
+ with `reason: 'superseded'` when it replaces an active workflow.
482
+
483
+ ```ts
484
+ Sauron.cancelWorkflow(); // reason defaults to 'user'
485
+ Sauron.cancelWorkflow('checkout', { reason: 'payment declined' });
486
+ ```
487
+
488
+ ### `getWorkflow()`
489
+
490
+ ```ts
491
+ function getWorkflow(): ActiveWorkflow | null
492
+ ```
493
+
494
+ Returns the active workflow — `{ workflowId, name, startedAt }` — or `null`
495
+ when none is active (including before `init()`, and after `close()`, which
496
+ resets it).
497
+
498
+ A workflow with no stamped activity for 30 minutes is surfaced as `abandoned`
499
+ when queried on the dashboard/API. That status is derived on read from the
500
+ last stamped event's timestamp — it is never stored, so there is nothing for
501
+ the client to do; an "abandoned" workflow that later receives another stamped
502
+ event simply reads as active again.
503
+
504
+ ```ts
505
+ const active = Sauron.getWorkflow();
506
+ if (active) {
507
+ console.log(`${active.name} running for`, Date.now() - Date.parse(active.startedAt), 'ms');
508
+ }
509
+ ```
510
+
511
+ ### `addBreadcrumb(breadcrumb, hint?)`
512
+
513
+ ```ts
514
+ function addBreadcrumb(breadcrumb: BreadcrumbInput, hint?: Hint): void
515
+ ```
516
+
517
+ | Field of `breadcrumb` | Type | Default | Description |
518
+ | --- | --- | --- | --- |
519
+ | `type` | `string` | `'default'` | Coarse kind, e.g. `'navigation'`. |
520
+ | `category` | `string` | `'default'` | Fine kind, e.g. `'ui.click'`, `'fetch'`. |
521
+ | `message` | `string \| null` | `null` | Short description. |
522
+ | `level` | `Level` | `'info'` | Severity. |
523
+ | `timestamp` | `string` | now, ISO-8601 UTC | Overrides the recorded time. |
524
+ | `data` | `Record<string, unknown> \| null` | `null` | Structured payload. |
525
+
526
+ | Parameter | Type | Default | Description |
527
+ | --- | --- | --- | --- |
528
+ | `hint` | `Hint` | `undefined` | Forwarded to `beforeBreadcrumb` only. |
529
+
530
+ The breadcrumb runs through `beforeBreadcrumb` and lands in the ring buffer.
531
+ Breadcrumbs are never sent on their own — the trail is copied onto every error
532
+ item. Returns `void`.
533
+
534
+ ```ts
535
+ Sauron.addBreadcrumb({
536
+ type: 'default',
537
+ category: 'auth',
538
+ level: 'info',
539
+ message: 'token refreshed',
540
+ data: { expires_in: 3600 },
541
+ });
542
+ ```
543
+
544
+ ### `setUser(user)`
545
+
546
+ ```ts
547
+ function setUser(user: UserInput): void
548
+ ```
549
+
550
+ | Field of `user` | Type | Default | Description |
551
+ | --- | --- | --- | --- |
552
+ | `id` | `string \| null` | `null` | User id; also becomes the `distinct_id` for later events. |
553
+ | `email` | `string \| null` | `null` | User email. |
554
+ | `traits` | `Record<string, unknown>` | `{}` | Arbitrary user traits. |
555
+
556
+ Pass `null` to clear the user entirely. The user is written to `context.user` on
557
+ every envelope, and onto `item.user` of error items while one is set. This is a
558
+ replace, not a merge. Returns `void`.
559
+
560
+ ```ts
561
+ Sauron.setUser({ id: 'u_123', email: 'ada@example.com', traits: { plan: 'pro' } });
562
+ Sauron.setUser(null); // on logout
563
+ ```
564
+
565
+ ### `setTag(key, value)`
566
+
567
+ ```ts
568
+ function setTag(key: string, value: string): void
569
+ ```
570
+
571
+ | Parameter | Type | Default | Description |
572
+ | --- | --- | --- | --- |
573
+ | `key` | `string` | — (required) | Tag key. |
574
+ | `value` | `string` | — (required) | Tag value (strings only — tags are indexed). |
575
+
576
+ Sets one tag on the global scope; it is lifted onto every later error and event
577
+ item. Returns `void`.
578
+
579
+ ```ts
580
+ Sauron.setTag('tenant', 'acme');
581
+ ```
582
+
583
+ ### `setTags(tags)`
584
+
585
+ ```ts
586
+ function setTags(tags: Record<string, string>): void
587
+ ```
588
+
589
+ | Parameter | Type | Default | Description |
590
+ | --- | --- | --- | --- |
591
+ | `tags` | `Record<string, string>` | — (required) | Batch of tags. |
592
+
593
+ Shallow-merges the batch into the scope, last-write-wins per key. Keys not
594
+ present are left alone. Returns `void`.
595
+
596
+ ```ts
597
+ Sauron.setTags({ tenant: 'acme', tier: 'enterprise' });
598
+ ```
599
+
600
+ ### `setContext(name, block)`
601
+
602
+ ```ts
603
+ function setContext(name: string, block: Record<string, unknown>): void
604
+ ```
605
+
606
+ | Parameter | Type | Default | Description |
607
+ | --- | --- | --- | --- |
608
+ | `name` | `string` | — (required) | Block name, e.g. `'order'`. |
609
+ | `block` | `Record<string, unknown>` | — (required) | The block's contents. |
610
+
611
+ Replaces the whole named block (no deep merge). Dev-owned contexts are distinct
612
+ from the machine-detected `context` on the envelope and never overwrite it.
613
+ Returns `void`.
614
+
615
+ ```ts
616
+ Sauron.setContext('order', { id: 7, total: 42.5 });
617
+ ```
618
+
619
+ ### `setExtra(key, value)`
620
+
621
+ ```ts
622
+ function setExtra(key: string, value: unknown): void
623
+ ```
624
+
625
+ | Parameter | Type | Default | Description |
626
+ | --- | --- | --- | --- |
627
+ | `key` | `string` | — (required) | Key in the freeform bag. |
628
+ | `value` | `unknown` | — (required) | Any JSON-serializable value. |
629
+
630
+ Sets one freeform value on the scope. Returns `void`.
631
+
632
+ ```ts
633
+ Sauron.setExtra('feature_flags', ['new-cart', 'fast-checkout']);
634
+ ```
635
+
636
+ ### `flush(timeoutMs?)`
637
+
638
+ ```ts
639
+ function flush(timeoutMs?: number): Promise<boolean>
640
+ ```
641
+
642
+ | Parameter | Type | Default | Description |
643
+ | --- | --- | --- | --- |
644
+ | `timeoutMs` | `number` | `undefined` (wait indefinitely) | Give up after this many milliseconds. |
645
+
646
+ Drains the offline queue, then posts everything buffered in `maxBatch`-sized
647
+ envelopes. Resolves `true` on completion, `false` if `timeoutMs` elapsed first
648
+ or if `init()` was never called. Resolves `true` immediately when the client has
649
+ been disabled by a 401/403.
650
+
651
+ ```ts
652
+ await Sauron.flush(2000);
653
+ ```
654
+
655
+ ### `close(timeoutMs?)`
656
+
657
+ ```ts
658
+ function close(timeoutMs?: number): Promise<boolean>
659
+ ```
660
+
661
+ | Parameter | Type | Default | Description |
662
+ | --- | --- | --- | --- |
663
+ | `timeoutMs` | `number` | `undefined` (wait indefinitely) | Passed straight to the inner flush. |
664
+
665
+ Flushes, then tears the SDK down: stops the flush timer and the `online`
666
+ listener, removes the unload listeners, clears the navigation hook and the
667
+ current screen, and restores every patched global in reverse order. Resolves to
668
+ the flush result. The client stays registered but disabled — call `init()` again
669
+ to restart.
670
+
671
+ ```ts
672
+ await Sauron.close(2000);
673
+ ```
674
+
675
+ ### `getClient()`
676
+
677
+ ```ts
678
+ function getClient(): SauronClient | null
679
+ ```
680
+
681
+ Returns the active client, or `null` before `init()`.
682
+
683
+ ```ts
684
+ const enabled = Sauron.getClient()?.isEnabled() ?? false;
685
+ ```
686
+
687
+ ### `SauronClient`
688
+
689
+ The client class, exported for typing and for the escape hatches below. You get
690
+ an instance from `init()` or `getClient()` — do not construct it yourself.
691
+
692
+ | Member | Signature | Description |
693
+ | --- | --- | --- |
694
+ | `options` | `readonly ResolvedOptions` | Fully-resolved options with defaults applied. |
695
+ | `dsn` | `readonly Dsn` | The parsed DSN. |
696
+ | `install()` | `(): void` | Install integrations + start the transport. Called by `init()`; a second call is a no-op. |
697
+ | `getScope()` | `(): Scope` | The mutable scope (user, breadcrumbs, tags, contexts, extra). |
698
+ | `isEnabled()` | `(): boolean` | `false` once the client has been explicitly `disable()`d/`teardown()`'d/`close()`d, **or** the transport has auto-disabled itself on a `401`/`403`. Computed from the transport's own state on every call, so a mid-session `401`/`403` flips this to `false` immediately — without the app ever calling `disable()`/`close()`. |
699
+ | `getDistinctId()` | `(): string \| null` | User id when identified, else the anonymous id (minting one if needed). |
700
+ | `getAnonymousId()` | `(): string \| null` | The anonymous id, or `null` if one was never needed. |
701
+ | `makeEnvelope(items)` | `(items: EnvelopeItem[]): Envelope` | Stamp a fresh envelope (new `sent_at`, current context) around `items`. |
702
+ | `addBreadcrumb(crumb, hint?)` | `(Breadcrumb, Hint?): void` | Full-shape breadcrumb, runs `beforeBreadcrumb`. |
703
+ | `captureItem(item, hint?)` | `(EnvelopeItem, Hint?): void` | Sampling + enrichment + workflow stamping + `beforeSend` + enqueue. |
704
+ | `flush(timeoutMs?)` | `(number?): Promise<boolean>` | Same as the module-level `flush`. |
705
+ | `disable()` | `(): void` | Stop accepting and sending; drops pending work. |
706
+ | `teardown()` | `(): void` | Restore globals and stop timers/listeners without flushing. |
707
+ | `close(timeoutMs?)` | `(number?): Promise<boolean>` | Flush, then `teardown()`. |
708
+
709
+ > **Workflow stamping happens inside `captureItem`.** That is why `track`,
710
+ > `captureException`, `captureMessage` and `trackTransaction` all pick up the active
711
+ > workflow automatically. If you hand-build an item and pass it to `captureItem` yourself,
712
+ > your own `workflow_id` / `workflow_name` win — the SDK will not overwrite them. Set
713
+ > **both or neither**: the server treats them as a pair and silently drops the attribution
714
+ > if only one is present, so the SDK logs a warning in that case. `identify` and
715
+ > breadcrumb-batch items are never stamped — the server has no workflow columns for them.
716
+
717
+ ```ts
718
+ import { getClient } from '@edraj/sauron-browser';
719
+
720
+ const trail = getClient()?.getScope().getBreadcrumbs() ?? [];
721
+ ```
722
+
723
+ ### `parseDsn(dsn)` and `DsnError`
724
+
725
+ ```ts
726
+ function parseDsn(dsn: string): Dsn
727
+ class DsnError extends Error
728
+ ```
729
+
730
+ | Parameter | Type | Default | Description |
731
+ | --- | --- | --- | --- |
732
+ | `dsn` | `string` | — (required) | `https://<public_key>@<host>/<environment_id>`. |
733
+
734
+ Returns a `Dsn` with `raw`, `publicKey`, `host` (`host:port`), `hostname`,
735
+ `protocol` (`http` or `https`, no colon), `projectId` (the DSN's path
736
+ segment — despite the name, this is the **environment** id since the ingest
737
+ key now lives on the environment, not the app), `envelopeUrl`
738
+ (`<protocol>://<host>/api/<environment_id>/envelope`) and `beaconUrl` (the same
739
+ URL with `?k=<public_key>`).
740
+
741
+ Throws `DsnError` (message prefixed `[sauron] invalid DSN:`) for an empty or
742
+ non-string value, an unparseable URL, a protocol other than `http`/`https`, a
743
+ missing public key, a DSN that carries a password component, a missing host, or
744
+ a missing environment-id path segment.
745
+
746
+ ```ts
747
+ import { parseDsn, DsnError } from '@edraj/sauron-browser';
748
+
749
+ try {
750
+ const dsn = parseDsn('https://pk_test@ingest.example.com/42');
751
+ console.log(dsn.envelopeUrl); // https://ingest.example.com/api/42/envelope
752
+ } catch (err) {
753
+ if (err instanceof DsnError) console.error(err.message);
754
+ }
755
+ ```
756
+
757
+ ### `buildEnvelope(header, context, items)`
758
+
759
+ ```ts
760
+ function buildEnvelope(
761
+ header: EnvelopeHeader,
762
+ context: Context,
763
+ items: EnvelopeItem[],
764
+ ): Envelope
765
+ ```
766
+
767
+ | Parameter | Type | Default | Description |
768
+ | --- | --- | --- | --- |
769
+ | `header` | `EnvelopeHeader` | — (required) | `dsn`, `sdk`, `sent_at`, `release`. |
770
+ | `context` | `Context` | — (required) | `device`, `os`, `app`, `runtime`, `user`. |
771
+ | `items` | `EnvelopeItem[]` | — (required) | The payload items. |
772
+
773
+ A pure constructor for the canonical envelope shape (`header`, `context`,
774
+ `items`, in that order). Useful for tests and for hand-rolled delivery.
775
+
776
+ ```ts
777
+ import { buildEnvelope, SDK_NAME, SDK_VERSION } from '@edraj/sauron-browser';
778
+
779
+ const envelope = buildEnvelope(
780
+ {
781
+ dsn: 'https://pk_test@localhost:8081/1',
782
+ sdk: { name: SDK_NAME, version: SDK_VERSION },
783
+ sent_at: new Date().toISOString(),
784
+ release: null,
785
+ },
786
+ context,
787
+ [item],
788
+ );
789
+ ```
790
+
791
+ ### `parseStackString(stack)`, `parseError(err)`, `isInAppFrame(filename)`
792
+
793
+ ```ts
794
+ function parseStackString(stack: string | undefined | null): Frame[]
795
+ function parseError(err: unknown): Frame[]
796
+ function isInAppFrame(filename: string | null): boolean
797
+ ```
798
+
799
+ | Parameter | Type | Default | Description |
800
+ | --- | --- | --- | --- |
801
+ | `stack` | `string \| undefined \| null` | — (required) | A raw `Error.stack` string. `null`/`undefined` yields `[]`. |
802
+ | `err` | `unknown` | — (required) | Any value; its string `.stack` is parsed, else `[]`. |
803
+ | `filename` | `string \| null` | — (required) | A frame filename. |
804
+
805
+ `parseStackString` handles both the V8/Chrome/Node/Edge (`at fn (file:line:col)`)
806
+ and the Firefox/Safari (`fn@file:line:col`) formats, skips non-frame lines,
807
+ caps at 50 frames keeping the ones nearest the crash, and returns them with the
808
+ **crashing frame last**. No symbolication happens client-side.
809
+
810
+ `isInAppFrame` returns `true` for bare/relative paths and same-origin absolute
811
+ URLs, and `false` for cross-origin URLs, `<anonymous>`, `node:*` and
812
+ `internal/*`.
813
+
814
+ ```ts
815
+ import { parseError, isInAppFrame } from '@edraj/sauron-browser';
816
+
817
+ const frames = parseError(new Error('boom'));
818
+ const appFrames = frames.filter((f) => isInAppFrame(f.filename));
819
+ ```
820
+
821
+ ### `SDK_NAME`, `SDK_VERSION`
822
+
823
+ ```ts
824
+ const SDK_NAME: string // 'sauron.javascript'
825
+ const SDK_VERSION: string // '1.4.0'
826
+ ```
827
+
828
+ The SDK identity embedded in `header.sdk` of every envelope.
829
+
830
+ ### Exported types
831
+
832
+ All wire-contract and option types are exported for your own typing:
833
+
834
+ - Enums / unions: `Level`, `ItemType`, `TransactionOp`, `WorkflowStatus`.
835
+ - Item shapes: `Frame`, `Mechanism`, `ExceptionValue`, `Breadcrumb`, `ErrorItem`,
836
+ `EventItem`, `IdentifyItem`, `BreadcrumbBatchItem`, `TransactionItem`,
837
+ `EnvelopeItem`.
838
+ - Envelope shapes: `DeviceContext`, `OsContext`, `AppContext`, `RuntimeContext`,
839
+ `UserContext`, `Context`, `SdkInfo`, `EnvelopeHeader`, `Envelope`.
840
+ - Input / option shapes: `Hint`, `UserInput`, `BeforeSend`, `BeforeBreadcrumb`,
841
+ `TransportOptions`, `InitOptions`, `CaptureOptions`, `TrackOptions`,
842
+ `ResolvedOptions`, `BreadcrumbInput`, `TransactionInput`, `Dsn`,
843
+ `WorkflowResult`, `ActiveWorkflow`.
844
+
845
+ ```ts
846
+ import type { EnvelopeItem, Hint, InitOptions } from '@edraj/sauron-browser';
847
+
848
+ const beforeSend = (item: EnvelopeItem, hint?: Hint): EnvelopeItem | null =>
849
+ item.type === 'error' ? item : null;
850
+ const options: InitOptions = { dsn: '...', beforeSend };
851
+ ```
852
+
853
+ ## Automatic instrumentation
854
+
855
+ `init()` patches the following globals. Every patch chains or defers to the
856
+ original — the app's own handlers, console output and `fetch` results are never
857
+ swallowed — and every one is restored by `close()`.
858
+
859
+ On by default:
860
+
861
+ | Global | What it records |
862
+ | --- | --- |
863
+ | `window.onerror` | Error item, mechanism `{ type: 'onerror', handled: false }`, level `error`. The previous handler is still called with its original arguments. |
864
+ | `window.onunhandledrejection` | Error item from `event.reason`, mechanism `{ type: 'onunhandledrejection', handled: false }`, level `error`. |
865
+ | `console.log/info/warn/error/debug` | Breadcrumb, category `console`, level mapped (`warn`→`warning`, `error`→`error`, `debug`→`debug`, else `info`), message = arguments joined and truncated to 512 chars, `data: { arguments: n }`. Output is untouched. |
866
+ | `document` click listener (capture, passive) | Breadcrumb, category `ui.click`, message = a `tag#id.class` selector (up to 3 classes). Element text and attribute values are never serialized. |
867
+ | `history.pushState` / `replaceState` / `popstate` | Breadcrumb, type `navigation`, category `history`, `data: { from, to }` as paths. Same-path transitions are skipped. |
868
+ | `fetch` | Breadcrumb, category `fetch`, message `METHOD url`, `data: { method, url, status_code }`, level `warning` for status >= 400. |
869
+ | `XMLHttpRequest.prototype.open` / `send` | Breadcrumb, category `xhr`, same shape as `fetch`. |
870
+ | `document` `visibilitychange` + window `pagehide` | Beacon flush of the pending batch on unload. |
871
+ | window `online` | Drains the offline queue. |
872
+
873
+ Opt-in:
874
+
875
+ | Option | What it adds |
876
+ | --- | --- |
877
+ | `performance: true` | A `navigation` transaction for the initial page load (Navigation Timing, captured on `load`), an `http` transaction per instrumented `fetch` (`name` = `METHOD /path`, `status` `ok`/`error`), and a `navigation` transaction per SPA route change measured over one animation frame. No-op when `document` is undefined. |
878
+ | `screenTracking: true` | Sets the screen to the new path on each SPA History navigation, which emits a `$screen` event on change. |
879
+
880
+ Two guards keep the SDK from observing itself: a reentrancy flag held while SDK
881
+ code runs, and a denylist on the DSN host. Requests the transport makes are
882
+ therefore never turned into breadcrumbs or transactions. Wrappers are tagged, so
883
+ a double `init()` never stacks two layers on the same global.
884
+
885
+ Integrations that need an absent global (no `document`, no `history`, no
886
+ `XMLHttpRequest`, no writable `localStorage`) simply skip installation, so
887
+ importing and initializing during SSR does not throw.
888
+
889
+ ## Scope & metadata
890
+
891
+ There is a single global scope per client, holding the user, the breadcrumb ring
892
+ buffer, `tags`, `contexts` and `extra`.
893
+
894
+ Precedence for `tags` / `contexts` / `extra`, lowest to highest:
895
+
896
+ 1. **`init` defaults** — `tags`, `contexts`, `extra` are seeded into the scope
897
+ when the client is constructed.
898
+ 2. **Scope setters** — `setTag`, `setTags`, `setContext`, `setExtra` write into
899
+ that same store, so they overwrite the init defaults for the keys they touch
900
+ (last write wins) and leave the rest alone.
901
+ 3. **Per-call overrides** — `hint.tags` / `hint.contexts` / `hint.extra` on
902
+ `captureException` and `captureMessage`, and `options.tags` /
903
+ `options.contexts` / `options.extra` on `track`. These win for that one item
904
+ and never mutate the scope.
905
+
906
+ The merge is shallow, per top-level key: a per-call tag replaces the scope tag
907
+ of the same key; a per-call **context block replaces the whole same-named scope
908
+ block** (no deep merge); other blocks and keys are preserved. When the merged
909
+ result is empty the field is omitted from the wire item entirely — the backend
910
+ defaults it to `{}`.
911
+
912
+ Other scope data:
913
+
914
+ - **user** — `setUser()` replaces it wholesale; `identify()` also replaces it
915
+ with `{ id, traits }`. It is written to `context.user` on every envelope, and
916
+ additionally onto `item.user` of error items while a user is set.
917
+ - **breadcrumbs** — capped at `maxBreadcrumbs`, FIFO eviction. The whole trail
918
+ is copied onto every error item; it is never sent on its own.
919
+ - **screen** — seeded by `init({ screen })`, changed by `setScreen()` (or
920
+ `screenTracking`). Stamped on every event and error item.
921
+ `TrackOptions.screen` overrides it for one event, `hint.screen` for one
922
+ `captureException`.
923
+ - **identity** — `device_id` persists in `localStorage` under
924
+ `sauron.device_id`; `session_id` persists in `sessionStorage` under
925
+ `sauron.session_id`. Both fall back to a per-process in-memory id when Web
926
+ Storage is unavailable.
927
+
928
+ ```ts
929
+ Sauron.init({ dsn, tags: { tier: 'free' }, extra: { build: 'ci-42' } });
930
+ Sauron.setTag('tier', 'pro'); // scope beats init default
931
+ Sauron.track('upgraded', {}, { tags: { tier: 'trial' } });
932
+ // -> event tags: { tier: 'trial' }, extra: { build: 'ci-42' }
933
+ ```
934
+
935
+ ## Bundlers & CDN
936
+
937
+ - `"type": "module"` with a dual build: `import` resolves `dist/index.js`
938
+ (ESM), `require` resolves `dist/index.cjs`, types come from
939
+ `dist/index.d.ts`. `package.json` itself is exported as `./package.json`;
940
+ nothing else is deep-importable.
941
+ - `"sideEffects": false` — bundlers may drop unused exports. Importing the
942
+ package does nothing on its own; instrumentation is installed by `init()`.
943
+ - Built with tsup, target `es2020`, with source maps and generated declarations.
944
+ - No UMD/IIFE build is shipped, so a plain `<script src="...">` global tag is
945
+ not supported. On a CDN, use a module script against an ESM-serving CDN:
946
+
947
+ ```html
948
+ <script type="module">
949
+ import { Sauron } from 'https://esm.sh/@edraj/sauron-browser@1.4.0';
950
+ Sauron.init({ dsn: 'https://pk_test@ingest.example.com/42' });
951
+ </script>
952
+ ```
953
+
954
+ - The only runtime dependency is `fflate`, imported dynamically and only when
955
+ the platform lacks `CompressionStream`. Bundlers will emit it as a separate
956
+ async chunk.
957
+ - Initialize as early as possible — errors thrown before `init()` are not
958
+ captured.
959
+
960
+ ## Transport & delivery
961
+
962
+ **Batching.** Items are buffered in memory and flushed every `flushIntervalMs`
963
+ (default 5000 ms), immediately once `maxBatch` items are pending (default 30,
964
+ clamped to `[1, 1000]`), and on demand via `flush()`/`close()`. Each `flush()`
965
+ drains the offline queue first, then posts the buffered items in
966
+ `maxBatch`-sized envelopes.
967
+
968
+ **Request.** `POST <protocol>://<host>/api/<environment_id>/envelope` with:
85
969
 
86
970
  ```
87
971
  Content-Type: application/json
88
- Content-Encoding: gzip # only when compressed (payloads ≳ 1 KB)
89
972
  X-Sauron-Key: <public_key>
973
+ Content-Encoding: gzip # only when the body was compressed
90
974
  ```
91
975
 
92
- On page unload, the pending batch is delivered via `navigator.sendBeacon` to
93
- `POST /api/{project_id}/envelope?k=<public_key>` (uncompressed JSON blob).
976
+ The body is the canonical envelope `header` + `context` + `items[]` —
977
+ identical across the JavaScript, Node, Python, Flutter and C# SDKs.
978
+
979
+ **Compression.** Payloads of 1024 bytes or more are gzipped with the native
980
+ `CompressionStream('gzip')` when available, falling back to a lazily imported
981
+ `fflate`. If neither works the envelope is sent uncompressed rather than
982
+ dropped. Smaller payloads are sent as plain JSON with no `Content-Encoding`.
983
+
984
+ **HTTP client.** The native `fetch` captured *before* the integrations wrap it,
985
+ so ingest traffic never instruments itself; `XMLHttpRequest` is the fallback
986
+ when `fetch` is absent. `keepalive: true` is set for bodies up to 64 KiB.
94
987
 
95
- The envelope shape (`header` + `context` + `items[]`) is identical across the
96
- JavaScript, Flutter, and Rust implementations — see `src/types.ts`.
988
+ **Response handling.**
989
+
990
+ | Status | Action |
991
+ | --- | --- |
992
+ | `200`, `202` | Success — drop the batch. |
993
+ | `400` | Non-retryable — drop the batch. |
994
+ | `401`, `403` | Disable the client permanently: pending work is dropped and nothing further is sent until the next `init()`. |
995
+ | `408` | Retry with backoff. |
996
+ | `413` | Split the batch in half and retry each half. A single item that is still too large is parked in the offline queue. |
997
+ | `429` | Wait `Retry-After` (seconds or HTTP-date, clamped to 30 s; 1000 ms if unparseable), then retry. |
998
+ | `5xx` | Retry with backoff. |
999
+ | other `4xx` | Drop the batch. |
1000
+ | network error / throw | Retry with backoff. |
1001
+
1002
+ Backoff is full-jitter: a uniform random delay in
1003
+ `[0, min(30_000, 1000 * 2^attempt)]` ms. After 5 retries the serialized envelope
1004
+ is parked in the offline queue.
1005
+
1006
+ **Offline queue.** A FIFO list under the `localStorage` key `sauron:queue:v1`,
1007
+ byte-capped at `maxQueueBytes` (default 1 MiB); the oldest entries are evicted
1008
+ first and at least one entry is always kept. It is drained at `init()`, at the
1009
+ start of every `flush()`, and on the window `online` event. If a drained
1010
+ envelope still fails, **it and every envelope behind it are re-parked at the head
1011
+ of the queue** (order preserved) and draining stops to avoid a tight loop — a
1012
+ single 500 on reconnect costs you nothing. Same on a 401/403: the client
1013
+ disables itself but the backlog is kept, so fixing the key and re-`init()`ing
1014
+ still delivers it. When `localStorage` is unavailable the queue is disabled and
1015
+ failed envelopes are dropped.
1016
+
1017
+ > Through 1.3.0 the drain deleted the whole `localStorage` backlog up front and
1018
+ > re-parked only the one envelope that failed, so everything queued behind it was
1019
+ > lost — the exact reconnect-then-one-500 scenario the queue exists for.
1020
+
1021
+ **Page unload.** On `visibilitychange` → `hidden` and on `pagehide`, the pending
1022
+ batch is chunked to 1000 items and handed to `navigator.sendBeacon` as an
1023
+ uncompressed `application/json` Blob posted to
1024
+ `POST /api/<environment_id>/envelope?k=<public_key>` (the key moves to the query
1025
+ string because beacons cannot set headers). Chunks larger than 64 KiB, or a
1026
+ `sendBeacon` that is unavailable or refuses, are parked in the offline queue for
1027
+ the next page load.
1028
+
1029
+ ## Troubleshooting
1030
+
1031
+ | Symptom | Cause | Fix |
1032
+ | --- | --- | --- |
1033
+ | Nothing arrives, no client-side errors | The gateway is not exposed at `/api/{environment_id}/envelope` on the host root. A DSN cannot express a path prefix, so the SDK posts to the root path and a proxy that serves ingest under a sub-path silently 404s. | Expose ingest at `/api/{environment_id}/envelope` on the DSN host root. |
1034
+ | Nothing arrives | `init()` was never called, or was called after the failing code ran. | Call `init()` first, as early in the page as possible. |
1035
+ | `[sauron] client disabled` in the console, or `isEnabled()` unexpectedly `false` mid-session | The gateway returned 401/403 — wrong, revoked or foreign-project public key. `isEnabled()` flips to `false` automatically; nothing else changes. | Fix the DSN key/project; re-`init()` after correcting. |
1036
+ | `DsnError` thrown at `init()` | Malformed DSN: bad protocol, missing public key, a password component, or a missing environment-id path segment. | Use `https://<public_key>@<host>/<environment_id>`. |
1037
+ | Only some errors show up | `sampleRate` below 1 (errors and messages are sampled; events, identifies and transactions are not). | Set `sampleRate: 1`. |
1038
+ | Errors arrive with no breadcrumbs | `maxBreadcrumbs: 0`, or `beforeBreadcrumb` returned `null`. | Raise `maxBreadcrumbs`; check the hook. |
1039
+ | Items disappear silently | `beforeSend` returned `null`, or it threw (the original is then sent and a warning logged). | Enable `debug: true` and read the `[sauron]` logs. |
1040
+ | Events lost when a tab closes after a busy session | The unload beacon chunk exceeded 64 KiB, or `sendBeacon` is unavailable. | Nothing to do — the payload is parked in `localStorage` and posted on the next page load. |
1041
+ | Nothing persists in private mode | `localStorage`/`sessionStorage` are blocked, so the offline queue is disabled and ids fall back to in-memory. | Expected; reduce `flushIntervalMs` to shorten the loss window. |
1042
+ | No transactions | `performance` defaults to `false`. | `init({ performance: true })`, or call `trackTransaction()` manually. |
1043
+ | Screen is always `null` | Neither `init({ screen })`, `setScreen()` nor `screenTracking: true` was used. | Set one of them. |
1044
+ | No debug output | `debug` defaults to `false`. | `init({ debug: true })`; logs are prefixed `[sauron]`. |
97
1045
 
98
1046
  ## Development
99
1047
 
100
1048
  ```bash
101
1049
  npm install
102
- npm run typecheck # tsc --noEmit
103
- npm run build # tsup -> dist/ (esm + cjs + d.ts)
104
- npm test # vitest
1050
+ npm run typecheck # tsc --noEmit
1051
+ npm test # vitest run
1052
+ npm run test:watch # vitest
1053
+ npm run build # tsup -> dist/ (esm + cjs + d.ts + sourcemaps)
1054
+ npm run dev # tsup --watch
105
1055
  ```
106
1056
 
1057
+ `npm run prepublishOnly` chains typecheck, tests and build.
1058
+
107
1059
  ## License
108
1060
 
109
1061
  AGPL-3.0-only — GNU Affero General Public License v3.0.
1062
+
1063
+ Repo: <https://github.com/edraj/sauron> — wiki:
1064
+ <https://github.com/edraj/sauron/wiki>