@edraj/sauron-node 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,10 +1,23 @@
1
1
  # @edraj/sauron-node
2
2
 
3
- Server-side Node/TypeScript SDK for [Sauron](https://sauron.dev) — dispatch
4
- product-analytics events and captured exceptions from your Node backends.
3
+ Server-side Node/TypeScript SDK for
4
+ [Sauron](https://github.com/edraj/sauron) — dispatch product-analytics events,
5
+ captured exceptions and performance transactions from your Node backends.
5
6
 
6
- This is the **server-side** SDK (no browser/DOM/auto-instrumentation). For the
7
- browser, use `@edraj/sauron-browser` (`sdks/js`).
7
+ This is the **server-side** SDK. It has no browser, DOM or automatic
8
+ instrumentation: nothing is captured unless you call it (or explicitly opt in to
9
+ the process-level hooks). If you are instrumenting a web page, use
10
+ `@edraj/sauron-browser` (`sdks/js`) instead.
11
+
12
+ - Manual capture: `track`, `captureException`, `captureMessage`, `identify`,
13
+ `trackTransaction`.
14
+ - Per-request isolation via `AsyncLocalStorage` — concurrent requests never leak
15
+ user/tags/breadcrumbs into each other.
16
+ - Opt-in `uncaughtException` / `unhandledRejection` capture and
17
+ `beforeExit`/`SIGTERM`/`SIGINT` graceful flush. Both default off.
18
+ - Buffered background transport: byte-bounded queue, optional FIFO disk
19
+ persistence, gzip, exponential-backoff retry honoring `Retry-After`.
20
+ - Zero runtime dependencies — global `fetch`, `node:zlib`, `node:async_hooks`.
8
21
 
9
22
  ## Install
10
23
 
@@ -12,127 +25,1174 @@ browser, use `@edraj/sauron-browser` (`sdks/js`).
12
25
  npm install @edraj/sauron-node
13
26
  ```
14
27
 
15
- Requires Node >= 18 (uses the global `fetch` and `zlib`).
28
+ Requires **Node >= 18** (`engines.node`), for the global `fetch`. The package is
29
+ ESM-only (`"type": "module"`) and ships its own `.d.ts`.
16
30
 
17
- ## Usage
31
+ ## Quick start
18
32
 
19
33
  ```ts
20
- import {
21
- init,
22
- track,
23
- captureException,
24
- captureMessage,
25
- identify,
26
- trackTransaction,
27
- addBreadcrumb,
28
- withScope,
29
- setUser,
30
- setTag,
31
- flush,
32
- close,
33
- } from '@edraj/sauron-node';
34
+ import { init, track, captureException, close } from '@edraj/sauron-node';
34
35
 
35
36
  init({
36
- dsn: 'https://<public_key>@<host>/<project_id>',
37
- environment: 'production',
38
- release: '1.4.2',
39
- // Opt-in (both default off):
40
- autoCaptureUnhandled: true, // capture uncaughtException / unhandledRejection
41
- autoShutdown: true, // flush on beforeExit / SIGTERM / SIGINT
37
+ dsn: 'https://<public_key>@<host>/<environment_id>',
38
+ release: 'api@1.4.2',
42
39
  });
43
40
 
44
- // Product analytics — distinctId is required.
45
41
  track('order_completed', 'user-123', { total: 42.5, currency: 'USD' });
46
42
 
47
- // Exceptions
48
43
  try {
49
- doWork();
44
+ await chargeCard();
50
45
  } catch (err) {
51
- captureException(err, { user: { id: 'user-123' }, tags: { area: 'checkout' } });
46
+ captureException(err, { tags: { area: 'checkout' } });
52
47
  }
53
48
 
54
- captureMessage('cache warm-up finished', 'info');
55
- identify('user-123', { plan: 'pro' });
49
+ // Buffered items go out on the 5s timer; flush explicitly before exit.
50
+ await close();
51
+ ```
52
+
53
+ The SDK POSTs a canonical JSON envelope (`header` + `context` + `items[]`) to
54
+ `POST /api/{environment_id}/envelope` with an `X-Sauron-Key: <public_key>` header,
55
+ adding `Content-Encoding: gzip` once the body crosses the gzip threshold.
56
+
57
+ ## Configuration
56
58
 
57
- // On shutdown
58
- await close(); // flushes then stops the background timer
59
+ `init(options)` takes a single `InitOptions` object. Every field except `dsn` is
60
+ optional.
61
+
62
+ | Option | Type | Default | Description |
63
+ | --- | --- | --- | --- |
64
+ | `dsn` | `string` | — (**required**) | `https://<public_key>@<host>/<environment_id>`. A non-string throws `Error`; a malformed value throws `DsnError`. |
65
+ | `release` | `string \| null` | `null` | Written to `header.release`. |
66
+ | `tags` | `Record<string, string>` | `{}` | Default tags seeded into the global scope at init. |
67
+ | `contexts` | `Record<string, unknown>` | `{}` | Default named dev context blocks seeded into the global scope. Distinct from the machine `context` (device/os/app/runtime). |
68
+ | `extra` | `Record<string, unknown>` | `{}` | Default free-form extra values seeded into the global scope. |
69
+ | `sampleRate` | `number` | `1` | Error sample rate, clamped to `[0, 1]`. Applies to `captureException` **only** (see below). |
70
+ | `flushInterval` | `number` (ms) | `5000` | Background flush cadence. `<= 0` disables the timer entirely — you must call `flush()`/`close()` yourself. |
71
+ | `maxBatch` | `number` | `30` | Queue depth that triggers an eager flush. |
72
+ | `maxBreadcrumbs` | `number` | `100` | Breadcrumb ring-buffer size on the global scope. Clamped to `>= 0`; `0` drops all breadcrumbs. |
73
+ | `gzipThresholdBytes` | `number` | `1024` | Gzip the body once it is strictly larger than this. A negative value disables compression. |
74
+ | `maxQueueBytes` | `number` | `1048576` (1 MiB) | Drop-oldest byte cap for the in-memory send buffer. |
75
+ | `offlineDir` | `string` | `null` (off) | Directory for FIFO disk persistence of pending items (at-least-once across restarts). Created recursively if missing. |
76
+ | `maxRetries` | `number` | `3` | Retries **after** the first attempt for transient failures. Clamped to `>= 0`. |
77
+ | `autoCaptureUnhandled` | `boolean` | `false` | Install `uncaughtException` / `unhandledRejection` handlers. |
78
+ | `autoShutdown` | `boolean` | `false` | Install `beforeExit` / `SIGTERM` / `SIGINT` handlers that `close()`. |
79
+ | `beforeSend` | `BeforeSend` | `undefined` | `(item, hint?) => item \| null`. Runs on every outgoing item just before enqueue; `null` drops it. |
80
+ | `beforeBreadcrumb` | `BeforeBreadcrumb` | `undefined` | `(crumb, hint?) => crumb \| null`. Runs on every breadcrumb before it is stored; `null` drops it. |
81
+ | `fetchImpl` | `FetchLike` | global `fetch` | Injected HTTP sender (tests). Construction throws if neither this nor a global `fetch` is available. |
82
+ | `debug` | `boolean` | `false` | Log transport decisions to `console.warn` with a `[sauron]` prefix. |
83
+
84
+ Notes that are easy to get wrong:
85
+
86
+ - `sampleRate` is checked **only** in `captureException`. `captureMessage`,
87
+ `track`, `identify` and `trackTransaction` are never sampled.
88
+ - `init` seeds `tags`/`contexts`/`extra` into the process-wide **global scope**;
89
+ it never clears it. Calling `init` twice accumulates rather than replaces.
90
+ - The per-envelope item cap is a fixed `1000` (matching the server limit) and is
91
+ not exposed through `InitOptions`; large backlogs are split across envelopes.
92
+
93
+ Fully-populated example:
94
+
95
+ ```ts
96
+ import { init, type EnvelopeItem, type Breadcrumb } from '@edraj/sauron-node';
97
+
98
+ const client = init({
99
+ dsn: 'https://pk_live_abc@ingest.example.com/42',
100
+ release: 'api@1.4.2',
101
+ tags: { service: 'checkout-api', region: 'eu-west-1' },
102
+ contexts: { deployment: { cluster: 'eu-1', pod: process.env.HOSTNAME } },
103
+ extra: { build_sha: process.env.GIT_SHA },
104
+ sampleRate: 0.5,
105
+ flushInterval: 2000,
106
+ maxBatch: 50,
107
+ maxBreadcrumbs: 50,
108
+ gzipThresholdBytes: 2048,
109
+ maxQueueBytes: 4 * 1024 * 1024,
110
+ offlineDir: '/var/lib/sauron/pending',
111
+ maxRetries: 5,
112
+ autoCaptureUnhandled: true,
113
+ autoShutdown: false,
114
+ beforeSend: (item: EnvelopeItem) => {
115
+ if (item.type === 'event' && 'email' in item.properties) {
116
+ item.properties.email = '[redacted]';
117
+ }
118
+ return item;
119
+ },
120
+ beforeBreadcrumb: (crumb: Breadcrumb) =>
121
+ crumb.category === 'secret' ? null : crumb,
122
+ debug: true,
123
+ });
59
124
  ```
60
125
 
61
- ### Per-request scope
126
+ ## API reference
127
+
128
+ Every module-level capture function delegates to the client created by the most
129
+ recent `init` and is a **no-op before `init`** (and after `close`). They never
130
+ throw and never return a value.
62
131
 
63
- Isolate user/tags/breadcrumbs per request with `withScope` — backed by
64
- `AsyncLocalStorage`, so concurrent requests never leak state into each other:
132
+ ### `init(options)`
65
133
 
66
134
  ```ts
67
- app.use((req, res, next) => {
68
- withScope(() => {
69
- setUser({ id: req.userId });
70
- setTag('route', req.route.path);
71
- addBreadcrumb({ category: 'http', message: `${req.method} ${req.url}` });
72
- next();
73
- });
135
+ function init(options: InitOptions): SauronClient
136
+ ```
137
+
138
+ | Parameter | Type | Default | Description |
139
+ | --- | --- | --- | --- |
140
+ | `options` | `InitOptions` | — (required) | See [Configuration](#configuration). |
141
+
142
+ Returns the `SauronClient` it created, and installs it as the active client.
143
+ Throws `Error` when `options.dsn` is not a string, and `DsnError` when the DSN
144
+ is malformed.
145
+
146
+ ```ts
147
+ const client = init({ dsn: 'https://pk@ingest.example.com/42' });
148
+ ```
149
+
150
+ ### `getClient()`
151
+
152
+ ```ts
153
+ function getClient(): SauronClient | null
154
+ ```
155
+
156
+ Returns the client created by the most recent `init`, or `null` before init /
157
+ after `close()`.
158
+
159
+ ```ts
160
+ if (getClient() === null) console.warn('sauron not initialized');
161
+ ```
162
+
163
+ ### `track(event, distinctId, properties?, options?)`
164
+
165
+ ```ts
166
+ function track(
167
+ event: string,
168
+ distinctId: string,
169
+ properties?: Record<string, unknown>,
170
+ options?: MetadataOptions,
171
+ ): void
172
+ ```
173
+
174
+ | Parameter | Type | Default | Description |
175
+ | --- | --- | --- | --- |
176
+ | `event` | `string` | — (required) | Event name. A non-string or empty string drops the call silently. |
177
+ | `distinctId` | `string` | — (required) | User/actor identity. A non-string or empty string drops the call silently. |
178
+ | `properties` | `Record<string, unknown>` | `{}` | Event properties. |
179
+ | `options` | `MetadataOptions` | `{}` | `{ tags?, contexts?, extra? }` merged over the active scope's metadata. |
180
+
181
+ Emits an `event` item. Returns `void`.
182
+
183
+ ```ts
184
+ track('order_completed', 'user-123', { total: 42.5 }, {
185
+ tags: { plan: 'pro' },
186
+ extra: { trace_id: req.id },
74
187
  });
75
188
  ```
76
189
 
77
- `captureException` automatically attaches the active scope's user, tags and
78
- breadcrumb trail. An optional `fingerprint` override is honored verbatim by the
79
- backend.
190
+ ### `captureException(error, options?)`
80
191
 
81
- ### Transactions
192
+ ```ts
193
+ function captureException(error: unknown, options?: CaptureExceptionOptions): void
194
+ ```
195
+
196
+ | Parameter | Type | Default | Description |
197
+ | --- | --- | --- | --- |
198
+ | `error` | `unknown` | — (required) | An `Error`, a string, or any object with `name`/`message`. Anything else is stringified. |
199
+ | `options.tags` | `Record<string, string>` | `{}` | Merged **over** the scope's tags. |
200
+ | `options.contexts` | `Record<string, unknown>` | `{}` | Merged by block name over the scope's contexts. |
201
+ | `options.extra` | `Record<string, unknown>` | `{}` | Merged by key over the scope's extra. |
202
+ | `options.user` | `Partial<ErrorUser> \| null` | `null` | `{ id?, email?, username? }`; missing fields become `null`. When omitted, the scope's user is used. |
203
+ | `options.level` | `Level` | `'error'` | `'debug' \| 'info' \| 'warning' \| 'error' \| 'fatal'`. |
204
+ | `options.handled` | `boolean` | `true` | Sets `exception.mechanism.handled`. |
205
+ | `options.fingerprint` | `string[] \| null` | `null` | Grouping override, honored verbatim by the backend. |
206
+
207
+ Emits an `error` item carrying a parsed stack trace (crash frame **last**, max 50
208
+ frames) plus the active scope's breadcrumb trail. Subject to `sampleRate`.
209
+ Returns `void`.
82
210
 
83
211
  ```ts
212
+ try {
213
+ await settleInvoice(id);
214
+ } catch (err) {
215
+ captureException(err, {
216
+ level: 'fatal',
217
+ handled: false,
218
+ user: { id: 'user-123', email: 'a@b.co' },
219
+ tags: { area: 'billing' },
220
+ fingerprint: ['invoice-settle-failure'],
221
+ });
222
+ }
223
+ ```
224
+
225
+ ### `captureMessage(message, level?, options?)`
226
+
227
+ ```ts
228
+ function captureMessage(message: string, level?: Level, options?: MetadataOptions): void
229
+ ```
230
+
231
+ | Parameter | Type | Default | Description |
232
+ | --- | --- | --- | --- |
233
+ | `message` | `string` | — (required) | Message body. |
234
+ | `level` | `Level` | `'info'` | Severity. |
235
+ | `options` | `MetadataOptions` | `{}` | `{ tags?, contexts?, extra? }` merged over the active scope. |
236
+
237
+ Emits an `error` item with `exception.type = 'Message'`, an empty stack trace and
238
+ `message` set. Not sampled. Returns `void`.
239
+
240
+ ```ts
241
+ captureMessage('cache warm-up finished', 'info', { tags: { job: 'warmup' } });
242
+ ```
243
+
244
+ ### `identify(distinctId, traits?)`
245
+
246
+ ```ts
247
+ function identify(distinctId: string, traits?: Record<string, unknown>): void
248
+ ```
249
+
250
+ | Parameter | Type | Default | Description |
251
+ | --- | --- | --- | --- |
252
+ | `distinctId` | `string` | — (required) | Identity to attach traits to. Empty/non-string drops the call. |
253
+ | `traits` | `Record<string, unknown>` | `{}` | Trait map. |
254
+
255
+ Emits an `identify` item with `anonymous_id: null`. Scope metadata is **not**
256
+ attached to identify items. Returns `void`.
257
+
258
+ ```ts
259
+ identify('user-123', { plan: 'pro', seats: 12 });
260
+ ```
261
+
262
+ ### `trackTransaction(input)`
263
+
264
+ ```ts
265
+ function trackTransaction(input: TransactionInput): void
266
+ ```
267
+
268
+ | Parameter | Type | Default | Description |
269
+ | --- | --- | --- | --- |
270
+ | `input.name` | `string` | — (required) | Transaction name. Empty/non-string drops the call. |
271
+ | `input.op` | `string` | `'custom'` | Operation class: `navigation`, `http`, `resource`, `screen_load`, `custom`. |
272
+ | `input.duration_ms` | `number` | — (required) | Wall-clock duration in ms (fractional allowed). |
273
+ | `input.status` | `string` | omitted | Free-form outcome, e.g. `'ok'`. |
274
+ | `input.http_method` | `string` | omitted | e.g. `'GET'`. |
275
+ | `input.http_status` | `number` | omitted | e.g. `200`. |
276
+ | `input.url` | `string` | omitted | Request URL/path. |
277
+ | `input.distinct_id` | `string` | scope user's `id`, else omitted | Explicit value wins over the scope. |
278
+
279
+ Emits a `transaction` item. Absent optional fields are omitted from the wire JSON
280
+ rather than serialized as `null`. Returns `void`.
281
+
282
+ ```ts
283
+ const started = Date.now();
284
+ await handler(req, res);
84
285
  trackTransaction({
85
286
  name: 'GET /api/users',
86
287
  op: 'http',
87
- duration_ms: 12.5,
288
+ duration_ms: Date.now() - started,
289
+ status: 'ok',
88
290
  http_method: 'GET',
89
291
  http_status: 200,
90
- }); // distinct_id falls back to the scoped user's id when omitted
292
+ url: '/api/users',
293
+ });
294
+ ```
295
+
296
+ ### `addBreadcrumb(crumb)`
297
+
298
+ ```ts
299
+ function addBreadcrumb(crumb: BreadcrumbInput): void
300
+ ```
301
+
302
+ | Parameter | Type | Default | Description |
303
+ | --- | --- | --- | --- |
304
+ | `crumb.type` | `string` | `'default'` | Crumb kind, e.g. `'http'`, `'navigation'`. |
305
+ | `crumb.category` | `string` | `null` | Free-form category. |
306
+ | `crumb.message` | `string` | `null` | Human-readable message. |
307
+ | `crumb.level` | `Level` | `null` | Severity. |
308
+ | `crumb.data` | `Record<string, unknown>` | `{}` | Structured payload. |
309
+
310
+ The crumb is stamped with an ISO-8601 `timestamp`, passed through
311
+ `beforeBreadcrumb` (a `null` return drops it), then pushed onto the **active**
312
+ scope's ring buffer. Once the buffer exceeds `maxBreadcrumbs`, the oldest crumbs
313
+ are evicted. Breadcrumbs are attached to error items only. Returns `void`.
314
+
315
+ ```ts
316
+ addBreadcrumb({
317
+ type: 'http',
318
+ category: 'outbound',
319
+ message: 'POST https://psp.example.com/charge',
320
+ level: 'info',
321
+ data: { status: 502, attempt: 2 },
322
+ });
323
+ ```
324
+
325
+ ### `startWorkflow(name, options?)`, `endWorkflow(name?)`, `cancelWorkflow(name?, options?)`, `getWorkflow()`
326
+
327
+ ```ts
328
+ function startWorkflow(name: string, options?: { force?: boolean }): WorkflowResult
329
+ function endWorkflow(name?: string): WorkflowResult
330
+ function cancelWorkflow(name?: string, options?: { reason?: string }): WorkflowResult
331
+ function getWorkflow(): ActiveWorkflow | null
332
+ ```
333
+
334
+ Workflows are an entirely **optional** way to bound a named span of activity
335
+ (e.g. `"checkout"`, `"password_reset"`) and have every event/error/transaction
336
+ captured while it is active stamped with it, so the dashboard can group them.
337
+ An app that never calls any of these four functions emits byte-identical
338
+ telemetry to one that predates this feature — no field is added to any item.
339
+
340
+ | Parameter | Type | Default | Description |
341
+ | --- | --- | --- | --- |
342
+ | `name` (start) | `string` | — (required) | Workflow name. Trimmed; rejected if empty after trimming or over 120 chars — **rejected, never truncated**. |
343
+ | `options.force` (start) | `boolean` | `false` | If `true` and a workflow is already active, it is superseded (see below) instead of blocking the new one. |
344
+ | `name` (end/cancel) | `string` | active workflow's name | If given, must match the active workflow's (trimmed) name or the call is a no-op. |
345
+ | `options.reason` (cancel) | `string` | `'user'` | Trimmed and capped at 120 chars. Never sent on `endWorkflow`. |
346
+
347
+ `WorkflowResult` is `{ status: WorkflowStatus; workflowId?: string }` —
348
+ `workflowId` is present only when `status` is `'ok'`. `WorkflowStatus` is
349
+ exactly six values, never a seventh:
350
+
351
+ | Status | Meaning |
352
+ | --- | --- |
353
+ | `ok` | The call took effect. |
354
+ | `already_active` | `startWorkflow` while one is already active and `force` was not set. The existing workflow is untouched. |
355
+ | `not_active` | `endWorkflow`/`cancelWorkflow` with none active. |
356
+ | `name_mismatch` | `endWorkflow`/`cancelWorkflow` with an explicit `name` that does not match the active workflow's — **including** a `name` that is itself malformed (empty after trim, or over 120 chars). A bad name on end/cancel is a mismatch against what's actually active, not an `invalid_name` (that status is only reachable from `startWorkflow`). |
357
+ | `invalid_name` | `startWorkflow` with an empty (after trim) or over-120-char name. |
358
+ | `disabled` | Nothing happened: before `init()`/after `close()`, after the transport has auto-disabled itself (401/403), **or an unexpected internal error**. Telemetry never throws into your code, so an internal failure is reported this way rather than propagating; if it happens after `startWorkflow` had already set the workflow locally, the workflow is still live and this case is instead reported as `ok` (see below). |
359
+
360
+ `startWorkflow` mints a fresh **client-generated UUID v4** (`workflowId`, via
361
+ `node:crypto`'s `randomUUID`) — never a session id, a hash of the name, or
362
+ anything else deterministic. The server's rollup key is
363
+ `(app_id, workflow_id)` **app-wide**, so a reused or derived id would merge
364
+ unrelated requests'/environments' counts into one row.
365
+
366
+ `startWorkflow(name, { force: true })` while a workflow is already active
367
+ first emits `$workflow_cancel` for it with `reason: 'superseded'`, then starts
368
+ the new one — both as a single call. Without `force`, an active workflow makes
369
+ the call a no-op returning `already_active`.
370
+
371
+ `endWorkflow`/`cancelWorkflow` emit `$workflow_end`/`$workflow_cancel`
372
+ (respectively) carrying `duration_ms`, then clear the workflow. A workflow is
373
+ also considered **abandoned** if the server sees no further stamped item for
374
+ it within 30 minutes — this is derived purely on read, server-side, from the
375
+ last item's timestamp; there is nothing to configure and no client-side timer
376
+ or action required. If an event stamped with that workflow arrives later
377
+ anyway (a slow retry, an offline-persisted item flushed after a restart, …),
378
+ it simply reads as active again — "abandoned" is not a terminal state you can
379
+ race.
380
+
381
+ `getWorkflow()` returns the workflow active on the **current scope** (see
382
+ below), or `null` if none — it takes no client and works the same regardless
383
+ of whether the SDK is initialized, exactly like `getCurrentScope()`.
384
+
385
+ **Attribution and `unique_users`.** The three lifecycle events are attributed
386
+ to the active scope's user id, and to an **empty** `distinct_id` when no user
387
+ has been identified (a background job, a pre-auth request). This is
388
+ deliberate, and it is why they bypass the empty-`distinctId` guard that drops
389
+ an ordinary `track()` call: the ingest pipeline stores an empty `distinct_id`
390
+ as SQL `NULL` on the workflow row, and the dashboard's per-workflow
391
+ `unique_users` figure is a `COUNT(DISTINCT distinct_id)`, which skips `NULL`s.
392
+ So anonymous runs contribute *nothing* to that count rather than collapsing
393
+ into one fake bucket — an anonymous-heavy workflow like `guest_checkout`
394
+ reports honest zeros instead of a misleading `1`. Call `setUser({ id })` (or
395
+ set a scoped user) before `startWorkflow` if you want those runs counted.
396
+
397
+ ```ts
398
+ app.post('/checkout', async (req, res) => {
399
+ const started = startWorkflow('checkout');
400
+ addBreadcrumb({ type: 'http', message: 'checkout started' });
401
+ track('checkout_started', req.userId ?? 'anon');
402
+ try {
403
+ await charge(req.body);
404
+ track('checkout_completed', req.userId ?? 'anon');
405
+ endWorkflow('checkout');
406
+ } catch (err) {
407
+ captureException(err, { tags: { area: 'checkout' } });
408
+ cancelWorkflow('checkout', { reason: 'payment_declined' });
409
+ throw err;
410
+ }
411
+ res.sendStatus(200);
412
+ });
413
+ ```
414
+
415
+ **Request-scoped via `AsyncLocalStorage`, not a module global.** Unlike the
416
+ browser SDK, this server SDK never holds the active workflow in a
417
+ module-level variable — that would leak one HTTP request's workflow into
418
+ every other concurrent request's telemetry. Instead it lives on the current
419
+ `Scope` (the same `AsyncLocalStorage`-backed mechanism `user`/`tags`/
420
+ `breadcrumbs` already use — see [Scope & metadata](#scope--metadata)):
421
+ `startWorkflow` inside a `withScope`/`runWithAsyncScope` block sets it on that
422
+ request's isolated child scope, and it is gone the moment the block returns,
423
+ never visible to any other concurrent request.
424
+
425
+ This means workflow state follows the **same async call chain** rules as the
426
+ rest of the scope: it propagates automatically across `await`s and work
427
+ scheduled from within the scoped callback (timers, promise continuations),
428
+ but code that runs **outside** that chain — a listener registered on a
429
+ long-lived `EventEmitter` before you ever called `withScope`, a job resumed
430
+ from a persisted queue/message broker, or anything invoked from a fresh async
431
+ root — will not see it. `getCurrentScope()` (and therefore `getWorkflow()`) in
432
+ that detached code falls back to the global scope, not the request's. If work
433
+ must cross into a detached async context, capture `getWorkflow()`'s
434
+ `workflowId`/`name` explicitly before you leave the scope and pass them along,
435
+ rather than relying on ambient state to still be there.
436
+
437
+ ### `setUser(user)`
438
+
439
+ ```ts
440
+ function setUser(user: User | null): void
441
+ ```
442
+
443
+ | Parameter | Type | Default | Description |
444
+ | --- | --- | --- | --- |
445
+ | `user` | `User \| null` | — (required) | `{ id?, email?, username? }` (all `string \| null`), or `null` to clear. |
446
+
447
+ Sets the user on the **active** scope — the `withScope` child if you are inside
448
+ one, otherwise the process-wide global scope. Used to fill an error item's `user`
449
+ when the capture call did not supply one, and as the `distinct_id` fallback for
450
+ `trackTransaction`. Returns `void`.
451
+
452
+ ```ts
453
+ setUser({ id: 'user-123', email: 'a@b.co' });
454
+ setUser(null); // clear
455
+ ```
456
+
457
+ ### `setTag(key, value)`
458
+
459
+ ```ts
460
+ function setTag(key: string, value: string): void
461
+ ```
462
+
463
+ | Parameter | Type | Default | Description |
464
+ | --- | --- | --- | --- |
465
+ | `key` | `string` | — (required) | Tag key. |
466
+ | `value` | `string` | — (required) | Tag value (tags are string→string). |
467
+
468
+ Sets one tag on the active scope, overwriting any existing value for `key`.
469
+
470
+ ```ts
471
+ setTag('route', '/api/orders/:id');
472
+ ```
473
+
474
+ ### `setTags(tags)`
475
+
476
+ ```ts
477
+ function setTags(tags: Record<string, string>): void
478
+ ```
479
+
480
+ | Parameter | Type | Default | Description |
481
+ | --- | --- | --- | --- |
482
+ | `tags` | `Record<string, string>` | — (required) | Tags to merge. |
483
+
484
+ Shallow-merges `tags` into the active scope's tags (`Object.assign`); existing
485
+ keys not present in `tags` survive.
486
+
487
+ ```ts
488
+ setTags({ route: '/api/orders', tenant: 'acme', canary: 'true' });
489
+ ```
490
+
491
+ ### `setContext(key, context)`
492
+
493
+ ```ts
494
+ function setContext(key: string, context: Record<string, unknown> | unknown): void
495
+ ```
496
+
497
+ | Parameter | Type | Default | Description |
498
+ | --- | --- | --- | --- |
499
+ | `key` | `string` | — (required) | Block name. |
500
+ | `context` | `Record<string, unknown> \| unknown` | — (required) | Block value; stored verbatim. |
501
+
502
+ Sets a **named block** on the active scope. Blocks are replaced wholesale by
503
+ name, never deep-merged. This is the developer-assignable `contexts` map — it is
504
+ separate from the machine `context` (device/os/app/runtime) the SDK builds once
505
+ at init.
506
+
507
+ ```ts
508
+ setContext('order', { id: 'ord_9', items: 3, currency: 'USD' });
509
+ ```
510
+
511
+ ### `setExtra(key, value)`
512
+
513
+ ```ts
514
+ function setExtra(key: string, value: unknown): void
515
+ ```
516
+
517
+ | Parameter | Type | Default | Description |
518
+ | --- | --- | --- | --- |
519
+ | `key` | `string` | — (required) | Extra key. |
520
+ | `value` | `unknown` | — (required) | Any JSON-serializable value. |
521
+
522
+ Sets one free-form value on the active scope's `extra` map.
523
+
524
+ ```ts
525
+ setExtra('trace_id', req.headers['x-trace-id']);
526
+ ```
527
+
528
+ ### `withScope(cb)`
529
+
530
+ ```ts
531
+ function withScope<T>(cb: (scope: Scope) => T): T
532
+ ```
533
+
534
+ | Parameter | Type | Default | Description |
535
+ | --- | --- | --- | --- |
536
+ | `cb` | `(scope: Scope) => T` | — (required) | Runs with an isolated child scope, which is also passed as the argument. |
537
+
538
+ Clones the current scope, runs `cb` inside an `AsyncLocalStorage` context bound to
539
+ that clone, and returns whatever `cb` returns (so an `async` callback yields a
540
+ promise you can await). For the duration of `cb` — **including across `await`s
541
+ and any async work started inside it** — `getCurrentScope()` returns the child.
542
+ Mutations to the child never propagate back to the parent/global scope.
543
+
544
+ ```ts
545
+ await withScope(async (scope) => {
546
+ scope.setUser({ id: 'user-123' });
547
+ scope.setTag('job', 'nightly-invoice');
548
+ await runJob(); // captures inside see the child scope
549
+ }); // child discarded here
550
+ ```
551
+
552
+ ### `runWithAsyncScope(cb)`
553
+
554
+ ```ts
555
+ function runWithAsyncScope<T>(cb: () => T): T
556
+ ```
557
+
558
+ | Parameter | Type | Default | Description |
559
+ | --- | --- | --- | --- |
560
+ | `cb` | `() => T` | — (required) | Same as `withScope`, but the callback takes no argument. |
561
+
562
+ Identical semantics to `withScope`; use it when you prefer the module-level
563
+ `setUser`/`setTag` helpers over the `Scope` handle.
564
+
565
+ ```ts
566
+ await runWithAsyncScope(async () => {
567
+ setUser({ id: 'user-123' });
568
+ setTag('job', 'nightly-invoice');
569
+ await runJob();
570
+ });
571
+ ```
572
+
573
+ ### `configureScope(cb)`
574
+
575
+ ```ts
576
+ function configureScope(cb: (scope: Scope) => void): void
577
+ ```
578
+
579
+ | Parameter | Type | Default | Description |
580
+ | --- | --- | --- | --- |
581
+ | `cb` | `(scope: Scope) => void` | — (required) | Receives the **currently active** scope. |
582
+
583
+ Mutates the active scope in place — it does **not** create a child and does not
584
+ isolate anything. Outside a `withScope`, that is the process-wide global scope
585
+ and the mutation is permanent for the process. Returns `void` (the callback's
586
+ return value is discarded).
587
+
588
+ ```ts
589
+ // withScope: temporary + isolated
590
+ withScope((scope) => {
591
+ scope.setTag('request_id', 'r-1'); // gone after the callback
592
+ });
593
+
594
+ // configureScope: mutates whatever is active right now
595
+ configureScope((scope) => {
596
+ scope.setTag('service', 'checkout'); // process-wide, permanent
597
+ });
598
+
599
+ // inside a withScope, configureScope targets the child
600
+ withScope(() => {
601
+ configureScope((scope) => scope.setTag('request_id', 'r-2')); // child only
602
+ });
603
+ ```
604
+
605
+ ### `getCurrentScope()` / `getGlobalScope()`
606
+
607
+ ```ts
608
+ function getCurrentScope(): Scope
609
+ function getGlobalScope(): Scope
610
+ ```
611
+
612
+ `getCurrentScope()` returns the async-local child inside a `withScope` /
613
+ `runWithAsyncScope` block, else the global scope. `getGlobalScope()` always
614
+ returns the process-wide scope, even from inside a child.
615
+
616
+ ```ts
617
+ withScope((child) => {
618
+ getCurrentScope() === child; // true
619
+ getGlobalScope().setTag('boot', 'ok'); // reaches past the child
620
+ });
621
+ ```
622
+
623
+ ### `flush()`
624
+
625
+ ```ts
626
+ function flush(): Promise<void>
627
+ ```
628
+
629
+ Zero-argument. Drains the queue and POSTs it now, in bounded chunks; resolves
630
+ once the in-flight sends have settled (there is **no** timeout parameter — a slow
631
+ ingest with retries can keep the promise pending for up to
632
+ `maxRetries` backoffs, each capped at 30 s). Resolves immediately when the SDK is
633
+ not initialized, and never rejects — transport failures are swallowed and the
634
+ batch is re-buffered.
635
+
636
+ ```ts
637
+ await flush();
638
+ ```
639
+
640
+ ### `close()`
641
+
642
+ ```ts
643
+ function close(): Promise<void>
644
+ ```
645
+
646
+ Zero-argument. Clears the active client, then flushes it, stops the background
647
+ timer and uninstalls any process hooks installed by `autoCaptureUnhandled` /
648
+ `autoShutdown`. Resolves immediately if the SDK is not initialized. After
649
+ `close()` every module-level capture function is a no-op again until the next
650
+ `init`.
651
+
652
+ ```ts
653
+ await close();
654
+ ```
655
+
656
+ ### `installAutoCapture(client, options?)`
657
+
658
+ ```ts
659
+ function installAutoCapture(
660
+ client: SauronClient,
661
+ options?: AutoCaptureOptions,
662
+ ): () => void
663
+ ```
664
+
665
+ | Parameter | Type | Default | Description |
666
+ | --- | --- | --- | --- |
667
+ | `client` | `SauronClient` | — (required) | Target client. |
668
+ | `options.process` | `ProcessLike` | Node's `process` | Injected process object (tests). |
669
+
670
+ Registers two listeners and returns an **uninstaller**. Idempotent per client —
671
+ installing twice returns the first uninstaller and registers nothing new.
672
+
673
+ - `uncaughtException` → `captureException(err, { level: 'fatal', handled: false })`,
674
+ then `flush()`, then — only if this SDK is the *sole* `uncaughtException`
675
+ listener — `process.exit(1)`, preserving Node's default crash behavior. If any
676
+ other listener is registered, that listener owns the process's fate.
677
+ - `unhandledRejection` → `captureException(reason, { level: 'error', handled: false })`
678
+ then `flush()`. Never exits on its own; Node's own `unhandledRejection` mode
679
+ still governs.
680
+
681
+ Re-entrancy is guarded, so a throw inside the capture path cannot loop. Prefer
682
+ `init({ autoCaptureUnhandled: true })`, which calls this for you and tears it
683
+ down on `close()`.
684
+
685
+ ```ts
686
+ const client = init({ dsn: DSN });
687
+ const uninstall = installAutoCapture(client);
688
+ // later
689
+ uninstall();
690
+ ```
691
+
692
+ ### `installShutdownHooks(client, options?)`
693
+
694
+ ```ts
695
+ function installShutdownHooks(
696
+ client: SauronClient,
697
+ options?: AutoCaptureOptions,
698
+ ): () => void
699
+ ```
700
+
701
+ | Parameter | Type | Default | Description |
702
+ | --- | --- | --- | --- |
703
+ | `client` | `SauronClient` | — (required) | Target client. |
704
+ | `options.process` | `ProcessLike` | Node's `process` | Injected process object (tests). |
705
+
706
+ Registers three listeners and returns an uninstaller. Idempotent per client. All
707
+ three are guarded by a `closing` latch, so only the first one to fire runs.
708
+
709
+ - `beforeExit` → `client.close()`. Does **not** force an exit.
710
+ - `SIGTERM` → `client.close()` then `process.exit(143)`.
711
+ - `SIGINT` → `client.close()` then `process.exit(130)`.
712
+
713
+ Prefer `init({ autoShutdown: true })`. Note the exit is unconditional once the
714
+ signal fires — if you need to drain HTTP connections first, leave `autoShutdown`
715
+ off and call `close()` yourself from your own handler.
716
+
717
+ ```ts
718
+ const client = init({ dsn: DSN });
719
+ const uninstall = installShutdownHooks(client);
720
+ ```
721
+
722
+ ### `class SauronClient`
723
+
724
+ Returned by `init`, or constructible directly for a second, independent client
725
+ (`new SauronClient(options)` with the same `InitOptions`). Its instance methods
726
+ mirror the module-level functions exactly, minus the "no-op before init"
727
+ behavior: `track`, `captureException`, `captureMessage`, `identify`,
728
+ `trackTransaction`, `addBreadcrumb`, `startWorkflow`, `endWorkflow`,
729
+ `cancelWorkflow`, `flush()`, `close()`, plus `isEnabled()` (`false` once the
730
+ transport has auto-disabled itself on a 401/403 — the same check `disabled`
731
+ statuses are gated on). `getWorkflow()` is **not** an instance method: it
732
+ reads the current scope directly and is client-agnostic, like
733
+ `getCurrentScope()`.
734
+
735
+ ```ts
736
+ import { SauronClient } from '@edraj/sauron-node';
737
+
738
+ const audit = new SauronClient({ dsn: AUDIT_DSN, flushInterval: 1000 });
739
+ audit.track('audit_written', 'system', { table: 'ledger' });
740
+ await audit.close();
741
+ ```
742
+
743
+ Note that all clients share the same process-wide global scope and the same
744
+ `AsyncLocalStorage`, so scope state is not per-client.
745
+
746
+ ### `class Scope`
747
+
748
+ The object handed to `withScope` / `configureScope`. All mutators return `this`
749
+ for chaining.
750
+
751
+ | Member | Signature | Description |
752
+ | --- | --- | --- |
753
+ | `data` | `ScopeData` | `{ user, tags, contexts, extra, breadcrumbs }` — readable directly. |
754
+ | `setUser` | `(user: User \| null) => this` | Set/clear the user. |
755
+ | `setTag` | `(key: string, value: string) => this` | Set one tag. |
756
+ | `setTags` | `(tags: Record<string, string>) => this` | Shallow-merge tags. |
757
+ | `setContext` | `(key: string, context: unknown) => this` | Set a named block. |
758
+ | `setExtra` | `(key: string, value: unknown) => this` | Set one extra value. |
759
+ | `addBreadcrumb` | `(crumb: BreadcrumbInput \| Breadcrumb) => this` | Push a crumb, evicting the oldest past the cap. Bypasses `beforeBreadcrumb` — use the module-level `addBreadcrumb` if you want that hook. |
760
+ | `setMaxBreadcrumbs` | `(max: number) => void` | Resize the ring buffer (clamped `>= 0`), trimming immediately. |
761
+ | `clone` | `() => Scope` | Snapshot with no shared mutable containers. |
762
+ | `applyToErrorItem` | `(item) => void` | Layer this scope onto an error item (scope *under* per-call). |
763
+ | `mergeMetadata` | `(overrides?) => { tags?, contexts?, extra? }` | Merge for non-error items, omitting empty maps. |
764
+
765
+ ```ts
766
+ withScope((scope) => {
767
+ scope.setUser({ id: 'u1' }).setTag('tier', 'gold').setExtra('shard', 3);
768
+ });
769
+ ```
770
+
771
+ ### `parseDsn(dsn)` and `DsnError`
772
+
773
+ ```ts
774
+ function parseDsn(dsn: string): Dsn
775
+ class DsnError extends Error
776
+ ```
777
+
778
+ | Parameter | Type | Default | Description |
779
+ | --- | --- | --- | --- |
780
+ | `dsn` | `string` | — (required) | `https://<public_key>@<host>/<environment_id>`. |
781
+
782
+ Returns a `Dsn`: `{ raw, publicKey, host, hostname, protocol, projectId,
783
+ envelopeUrl }` — `projectId` is the DSN's path segment (despite the name, this
784
+ is the **environment** id since the ingest key now lives on the environment,
785
+ not the app) — where `envelopeUrl` is
786
+ `{protocol}://{host}/api/{environment_id}/envelope`. Throws `DsnError` (`name`
787
+ is `'DsnError'`, message prefixed `[sauron] invalid DSN:`) for an
788
+ empty/unparseable string, a protocol other than `http`/`https`, a missing
789
+ public key, a **present password** (a DSN must never carry a secret), a
790
+ missing host, or a missing environment-id path segment.
791
+
792
+ ```ts
793
+ import { parseDsn, DsnError } from '@edraj/sauron-node';
794
+
795
+ try {
796
+ const dsn = parseDsn(process.env.SAURON_DSN!);
797
+ console.log(dsn.envelopeUrl); // https://ingest.example.com/api/42/envelope
798
+ } catch (err) {
799
+ if (err instanceof DsnError) process.exit(1);
800
+ }
801
+ ```
802
+
803
+ ### `parseError(err)`, `parseStackString(stack)`, `isInAppFrame(filename)`
804
+
805
+ ```ts
806
+ function parseError(err: unknown): Frame[]
807
+ function parseStackString(stack: string | undefined | null): Frame[]
808
+ function isInAppFrame(filename: string | null): boolean
809
+ ```
810
+
811
+ | Parameter | Type | Default | Description |
812
+ | --- | --- | --- | --- |
813
+ | `err` | `unknown` | — (required) | Any value; a string `.stack` property is parsed, anything else yields `[]`. |
814
+ | `stack` | `string \| undefined \| null` | — (required) | A raw V8 `Error.stack` string. |
815
+ | `filename` | `string \| null` | — (required) | A frame filename. |
816
+
817
+ `parseStackString` returns normalized `Frame`s with the **crashing frame last**
818
+ (raw V8 stacks are crash-first, so the list is reversed), capped at 50 frames
819
+ nearest the crash, with `file://` prefixes stripped and no symbolication.
820
+ `isInAppFrame` returns `false` for `null`, `<anonymous>`, `node:*`, `internal/*`,
821
+ `node internal*` and anything containing `node_modules`; `true` otherwise.
822
+
823
+ ```ts
824
+ const frames = parseError(new Error('boom'));
825
+ frames.at(-1); // the crash site
826
+ isInAppFrame('/srv/app/routes/orders.js'); // true
827
+ isInAppFrame('node:internal/process/task_queues'); // false
828
+ ```
829
+
830
+ ### `describeError(error)`
831
+
832
+ ```ts
833
+ function describeError(error: unknown): { type: string; value: string | null }
834
+ ```
835
+
836
+ | Parameter | Type | Default | Description |
837
+ | --- | --- | --- | --- |
838
+ | `error` | `unknown` | — (required) | Any thrown value. |
839
+
840
+ Derives the `exception.type` / `exception.value` pair the SDK puts on the wire:
841
+ an `Error` yields `{ type: err.name || 'Error', value: err.message || null }`; a
842
+ string yields `{ type: 'Error', value: theString }`; an object yields its
843
+ `name`/`message` when they are strings; `undefined` yields a `null` value and
844
+ anything else is `String()`-ified.
845
+
846
+ ```ts
847
+ describeError(new TypeError('x is not a function'));
848
+ // { type: 'TypeError', value: 'x is not a function' }
849
+ describeError('plain failure'); // { type: 'Error', value: 'plain failure' }
850
+ ```
851
+
852
+ ### `class Transport`
853
+
854
+ The buffered background sender, exported for advanced/embedding use. `init`
855
+ constructs one for you from `InitOptions`; you rarely need it directly. Its
856
+ `TransportConfig` accepts three knobs `InitOptions` does not expose:
857
+ `maxItemsPerEnvelope` (default `1000`), `retryBaseMs` (default `200`) and the
858
+ `sleep`/`random` test seams. Public methods: `enqueue(item)`, `flush()`,
859
+ `close()`.
860
+
861
+ ```ts
862
+ import { Transport, parseDsn } from '@edraj/sauron-node';
863
+
864
+ const transport = new Transport({
865
+ dsn: parseDsn(process.env.SAURON_DSN!),
866
+ release: null,
867
+ context: {
868
+ device: { device_id: 'worker-1' },
869
+ os: { name: 'linux', version: null },
870
+ app: {},
871
+ runtime: { name: 'node', version: process.versions.node },
872
+ user: null,
873
+ },
874
+ flushInterval: 5000,
875
+ maxBatch: 30,
876
+ maxItemsPerEnvelope: 500,
877
+ debug: false,
878
+ });
879
+ transport.enqueue({
880
+ type: 'event',
881
+ name: 'raw_enqueue',
882
+ distinct_id: 'system',
883
+ properties: {},
884
+ timestamp: new Date().toISOString(),
885
+ session_id: null,
886
+ screen: null,
887
+ });
888
+ await transport.close();
91
889
  ```
92
890
 
93
- ## API
891
+ ### Exported types
892
+
893
+ `export type * from './types.js'` re-exports the whole wire contract. The ones
894
+ you are likely to touch:
94
895
 
95
- | Function | Description |
896
+ | Type | Shape / values |
96
897
  | --- | --- |
97
- | `init(options)` | Create the global client. Throws `DsnError` on an invalid DSN. |
98
- | `track(event, distinctId, properties?)` | Capture an analytics event. |
99
- | `captureException(error, options?)` | Capture a native `Error` (attaches scope). |
100
- | `captureMessage(message, level?)` | Capture a bare message. |
101
- | `identify(distinctId, traits?)` | Associate traits with a user. |
102
- | `trackTransaction(input)` | Emit a performance transaction. |
103
- | `addBreadcrumb(crumb)` | Add a breadcrumb to the active scope (runs `beforeBreadcrumb`). |
104
- | `setUser / setTag / setTags / setContext / setExtra` | Mutate the active scope. |
105
- | `withScope(cb)` / `configureScope(cb)` | Run with an isolated child scope / mutate the current one. |
106
- | `installShutdownHooks(client)` | Wire `beforeExit`/`SIGTERM`/`SIGINT` to `close()`. |
107
- | `flush()` | Send buffered items immediately. |
108
- | `close()` | Flush, stop the timer, and remove any installed process hooks. |
109
-
110
- Every dispatch function is a no-op before `init` / when the SDK is disabled.
111
-
112
- ### `init` options
113
-
114
- `environment`, `release`, `sampleRate`, `flushInterval`, `maxBatch`,
115
- `maxBreadcrumbs` (default 100), `gzipThresholdBytes` (default 1024),
116
- `maxQueueBytes` (default 1 MiB), `offlineDir` (opt-in FIFO disk persistence),
117
- `maxRetries` (default 3), `autoCaptureUnhandled` (default off),
118
- `autoShutdown` (default off), `beforeSend(item)`, `beforeBreadcrumb(crumb)`.
119
-
120
- ## Transport
121
-
122
- Items buffer in a byte-bounded in-memory queue (drop-oldest past `maxQueueBytes`,
123
- optionally persisted to `offlineDir` for at-least-once delivery across restarts)
124
- and flush every `flushInterval` ms (default 5000) or once `maxBatch` items
125
- (default 30) accumulate. The flush timer is `unref`'d so it never keeps your
126
- process alive. Each flush POSTs one envelope to
127
- `{proto}://{host}/api/{project_id}/envelope` with an `X-Sauron-Key` header,
128
- gzipping the body once it crosses `gzipThresholdBytes` (`Content-Encoding: gzip`).
129
- Transient failures (408/413/429/5xx, network) retry with exponential backoff +
130
- jitter honoring `Retry-After`; 400/401/403/404 drop without retry.
898
+ | `Level` | `'debug' \| 'info' \| 'warning' \| 'error' \| 'fatal'` |
899
+ | `InitOptions` | The `init` options object (see Configuration). |
900
+ | `MetadataOptions` | `{ tags?, contexts?, extra? }` |
901
+ | `CaptureExceptionOptions` | `MetadataOptions` + `{ user?, level?, handled?, fingerprint? }` |
902
+ | `TransactionInput` | Input to `trackTransaction`. |
903
+ | `BreadcrumbInput` / `Breadcrumb` | Caller input / stored (stamped) crumb. |
904
+ | `WorkflowStatus` | `'ok' \| 'already_active' \| 'not_active' \| 'name_mismatch' \| 'invalid_name' \| 'disabled'` |
905
+ | `WorkflowResult` | `{ status: WorkflowStatus; workflowId?: string }` return value of `startWorkflow`/`endWorkflow`/`cancelWorkflow`. |
906
+ | `ActiveWorkflow` | `{ workflowId, name, startedAt }` return value of `getWorkflow()`. |
907
+ | `User` | `{ id?, email?, username? }` — input to `setUser`. |
908
+ | `ErrorUser` | `{ id, email, username }` — the wire shape (nulls, not absent). |
909
+ | `BeforeSend` / `BeforeBreadcrumb` | `(item, hint?) => item \| null` hooks. |
910
+ | `EnvelopeItem` | `ErrorItem \| EventItem \| IdentifyItem \| TransactionItem` |
911
+ | `Envelope`, `EnvelopeHeader`, `Context`, `Frame`, `ScopeData` | Wire structures. |
912
+ | `FetchLike`, `FetchResponse`, `ProcessLike` | Injection seams for tests. |
913
+ | `Dsn` | Result of `parseDsn`. |
914
+ | `AutoCaptureOptions` | `{ process?: ProcessLike }` |
915
+ | `ResolvedOptions` | `InitOptions` with all defaults applied. |
916
+
917
+ ## Scope & metadata
918
+
919
+ Three layers contribute `tags` / `contexts` / `extra` / `user` / `breadcrumbs`,
920
+ in increasing precedence:
921
+
922
+ 1. **init defaults** — `tags`/`contexts`/`extra` passed to `init` are seeded into
923
+ the global scope at construction.
924
+ 2. **scope** the global scope (`setTag`, `setUser`, outside any `withScope`),
925
+ then the async-local child inside a `withScope` / `runWithAsyncScope` block. A
926
+ child starts as a *snapshot* of its parent, so reads merge child-over-parent
927
+ automatically and writes never escape.
928
+ 3. **per-call options** — the `options` bag on `track`, `captureException`,
929
+ `captureMessage`.
930
+
931
+ Merge rules, per key:
932
+
933
+ - `tags` — shallow-merged by key; per-call wins.
934
+ - `extra` — shallow-merged by key; per-call wins.
935
+ - `contexts` — merged by **block name**; a per-call block *replaces* the
936
+ same-named scope block wholesale (no deep merge).
937
+ - `user` — per-call `user` wins outright; the scope's user is only used when the
938
+ call did not supply one.
939
+ - `breadcrumbs` — always taken from the active scope's ring buffer, and attached
940
+ to **error items only**.
941
+
942
+ Emit conventions on the wire:
943
+
944
+ - On error items, `tags` is always present (possibly `{}`), while `contexts` and
945
+ `extra` are omitted entirely when they resolve to empty.
946
+ - On event items, all three of `tags`/`contexts`/`extra` are omitted when empty.
947
+ - `identify` items carry no scope metadata; `transaction` items take only the
948
+ `distinct_id` fallback from the scope's user id.
949
+
950
+ ```ts
951
+ init({ dsn: DSN, tags: { service: 'checkout' } }); // layer 1
952
+
953
+ setTag('region', 'eu-west-1'); // layer 2 (global)
954
+
955
+ withScope((scope) => {
956
+ scope.setTag('request_id', 'r-1'); // layer 2 (child)
957
+ captureException(err, { tags: { severity: 'high' } }); // layer 3
958
+ // → tags: { service, region, request_id, severity }
959
+ });
960
+ // outside the block, request_id and severity are gone
961
+ ```
962
+
963
+ ## Framework integration
964
+
965
+ ### Express
966
+
967
+ ```ts
968
+ import express from 'express';
969
+ import {
970
+ init, withScope, addBreadcrumb, captureException, trackTransaction,
971
+ flush, close,
972
+ } from '@edraj/sauron-node';
973
+
974
+ init({
975
+ dsn: process.env.SAURON_DSN!,
976
+ release: process.env.GIT_SHA,
977
+ autoCaptureUnhandled: true,
978
+ });
979
+
980
+ const app = express();
981
+
982
+ // 1. Per-request scope — must be the FIRST middleware so everything downstream
983
+ // (including the error handler) runs inside the async-local context.
984
+ app.use((req, res, next) => {
985
+ withScope((scope) => {
986
+ const started = Date.now();
987
+ scope.setUser({ id: req.header('x-user-id') ?? null });
988
+ scope.setTag('method', req.method);
989
+ scope.setContext('request', {
990
+ url: req.originalUrl,
991
+ ip: req.ip,
992
+ user_agent: req.header('user-agent'),
993
+ });
994
+ addBreadcrumb({
995
+ type: 'http',
996
+ category: 'request',
997
+ message: `${req.method} ${req.originalUrl}`,
998
+ level: 'info',
999
+ });
1000
+
1001
+ res.on('finish', () => {
1002
+ trackTransaction({
1003
+ name: `${req.method} ${req.route?.path ?? req.path}`,
1004
+ op: 'http',
1005
+ duration_ms: Date.now() - started,
1006
+ status: res.statusCode < 500 ? 'ok' : 'internal_error',
1007
+ http_method: req.method,
1008
+ http_status: res.statusCode,
1009
+ url: req.originalUrl,
1010
+ });
1011
+ });
1012
+
1013
+ next();
1014
+ });
1015
+ });
1016
+
1017
+ app.get('/orders/:id', async (req, res) => {
1018
+ // withScope propagates across awaits, so captures in here carry the request
1019
+ // user/tags/breadcrumbs automatically.
1020
+ res.json(await loadOrder(req.params.id));
1021
+ });
1022
+
1023
+ // 2. Error handler — 4 args, registered LAST.
1024
+ app.use((err, req, res, _next) => {
1025
+ captureException(err, {
1026
+ tags: { route: req.route?.path ?? req.path },
1027
+ fingerprint: [`${req.method} ${req.route?.path ?? req.path}`],
1028
+ });
1029
+ res.status(500).json({ error: 'internal' });
1030
+ });
1031
+
1032
+ // 3. Graceful shutdown — drain HTTP first, then flush the SDK.
1033
+ const server = app.listen(3000);
1034
+ for (const signal of ['SIGTERM', 'SIGINT'] as const) {
1035
+ process.on(signal, () => {
1036
+ server.close(async () => {
1037
+ await close();
1038
+ process.exit(signal === 'SIGINT' ? 130 : 143);
1039
+ });
1040
+ });
1041
+ }
1042
+ ```
1043
+
1044
+ Because the handler above owns the exit, leave `autoShutdown` off — otherwise the
1045
+ SDK's own signal handler would `process.exit()` before your server finished
1046
+ draining. If you have nothing to drain, `init({ autoShutdown: true })` and drop
1047
+ step 3 entirely.
1048
+
1049
+ ### Fastify
1050
+
1051
+ ```ts
1052
+ import Fastify from 'fastify';
1053
+ import {
1054
+ init, withScope, addBreadcrumb, captureException, trackTransaction, close,
1055
+ } from '@edraj/sauron-node';
1056
+
1057
+ init({ dsn: process.env.SAURON_DSN!, autoCaptureUnhandled: true });
1058
+
1059
+ const fastify = Fastify();
1060
+ const startedAt = new WeakMap<object, number>();
1061
+
1062
+ // 1. Per-request scope. Calling done() inside withScope keeps the rest of the
1063
+ // request lifecycle inside the async-local context.
1064
+ fastify.addHook('onRequest', (req, _reply, done) => {
1065
+ withScope((scope) => {
1066
+ startedAt.set(req, Date.now());
1067
+ scope.setUser({ id: (req.headers['x-user-id'] as string) ?? null });
1068
+ scope.setTag('method', req.method);
1069
+ scope.setContext('request', { url: req.url, ip: req.ip });
1070
+ addBreadcrumb({
1071
+ type: 'http',
1072
+ category: 'request',
1073
+ message: `${req.method} ${req.url}`,
1074
+ });
1075
+ done();
1076
+ });
1077
+ });
1078
+
1079
+ // 2. Errors.
1080
+ fastify.setErrorHandler((err, req, reply) => {
1081
+ captureException(err, { tags: { route: req.routeOptions?.url ?? req.url } });
1082
+ reply.status(500).send({ error: 'internal' });
1083
+ });
1084
+
1085
+ // 3. Timing.
1086
+ fastify.addHook('onResponse', (req, reply, done) => {
1087
+ trackTransaction({
1088
+ name: `${req.method} ${req.routeOptions?.url ?? req.url}`,
1089
+ op: 'http',
1090
+ duration_ms: Date.now() - (startedAt.get(req) ?? Date.now()),
1091
+ http_method: req.method,
1092
+ http_status: reply.statusCode,
1093
+ url: req.url,
1094
+ });
1095
+ done();
1096
+ });
1097
+
1098
+ // 4. Graceful shutdown.
1099
+ fastify.addHook('onClose', async () => {
1100
+ await close();
1101
+ });
1102
+ await fastify.listen({ port: 3000 });
1103
+ ```
1104
+
1105
+ ### Background jobs / workers
1106
+
1107
+ Anything that is not a request still deserves its own scope:
1108
+
1109
+ ```ts
1110
+ import { runWithAsyncScope, setTag, setUser, captureException } from '@edraj/sauron-node';
1111
+
1112
+ async function processJob(job: Job) {
1113
+ await runWithAsyncScope(async () => {
1114
+ setTag('job', job.name);
1115
+ setUser({ id: job.ownerId });
1116
+ try {
1117
+ await job.run();
1118
+ } catch (err) {
1119
+ captureException(err, { extra: { attempt: job.attempt } });
1120
+ throw err;
1121
+ }
1122
+ });
1123
+ }
1124
+ ```
1125
+
1126
+ ## Transport & delivery
1127
+
1128
+ **Batching.** Items land in a byte-bounded in-memory queue. A flush fires when
1129
+ the queue reaches `maxBatch` (default 30) or every `flushInterval` ms (default
1130
+ 5000), whichever comes first. The interval timer is `unref`'d so it never keeps
1131
+ your process alive; `flushInterval <= 0` disables it entirely. Overlapping
1132
+ flushes are serialized through a promise chain, so a batch is never drained
1133
+ twice.
1134
+
1135
+ **Queue caps.** The queue drops **oldest first** once it exceeds `maxQueueBytes`
1136
+ (default 1 MiB), measured on each item's serialized size — a stalled ingest can
1137
+ never grow memory without bound. Separately, no single envelope carries more than
1138
+ 1000 items (matching the server limit); a larger backlog is split across
1139
+ consecutive requests within one flush.
1140
+
1141
+ **Offline persistence.** With `offlineDir` set, every queued item is also written
1142
+ to a sequence-named FIFO file (`0000000000000007.env.json`) in that directory,
1143
+ and a fresh process reloads them on construction — at-least-once delivery across
1144
+ restarts. Files are unlinked only when their batch is committed (delivered or
1145
+ intentionally dropped); a corrupt/partial file is discarded rather than wedging
1146
+ the queue. Writes are best-effort and never block the send path. Off by default.
1147
+
1148
+ **Compression.** Bodies strictly larger than `gzipThresholdBytes` (default 1024)
1149
+ are gzipped with `node:zlib` and sent with `Content-Encoding: gzip`. A negative
1150
+ threshold disables compression.
1151
+
1152
+ **Retry policy.** Per envelope, with `maxRetries` (default 3) retries *after* the
1153
+ first attempt:
1154
+
1155
+ | Response | Behavior |
1156
+ | --- | --- |
1157
+ | 2xx | Committed; persisted files unlinked. |
1158
+ | 408, 429, any 5xx | Retried. On 429 a `Retry-After` header (delta-seconds or HTTP-date) sets the delay. |
1159
+ | Network error / thrown `fetch` | Retried. |
1160
+ | 413 | **Not** retried as-is — the working envelope size is halved and the batch re-buffered for the next flush. A *single* item that still 413s is dropped. |
1161
+ | 401, 403 | Batch committed and the SDK **disables itself permanently** for the process. |
1162
+ | 400, 404, any other non-2xx | Dropped without retry. |
1163
+
1164
+ Backoff is exponential with equal jitter: `retryBaseMs * 2^attempt` (base 200 ms),
1165
+ halved and re-jittered, and every individual sleep — including a `Retry-After`
1166
+ delay — is capped at 30 s. After the last retry the batch is **re-buffered**, not
1167
+ discarded, so it can go out on a later flush or after a restart. `flush()` never
1168
+ rejects.
1169
+
1170
+ ## Troubleshooting
1171
+
1172
+ | Symptom | Cause | Fix |
1173
+ | --- | --- | --- |
1174
+ | Nothing arrives, no errors logged | The ingest is not exposed at `/api/{environment_id}/envelope` on the host root. A DSN cannot express a path prefix, so a proxy that serves Sauron under e.g. `/sauron/` silently 404s and the SDK drops the batch. | Expose ingest at `/api/{environment_id}/envelope` on the DSN host root. |
1175
+ | Nothing arrives, nothing happens at all | Capture calls ran before `init` or after `close()` — they are silent no-ops. | Check `getClient() !== null`. |
1176
+ | Events stop after a while, one warning logged | The ingest returned 401/403; the SDK disabled itself for the process. | Fix the public key in the DSN and restart. Set `debug: true` to see `auth failed (401); disabling SDK`. |
1177
+ | Events lost when the process exits | Buffered items had not flushed; the flush timer is `unref`'d and does not hold the loop open. | `await close()` before exiting, or `init({ autoShutdown: true })`. |
1178
+ | Items lost on a hard crash / container kill | The in-memory queue is not persisted by default. | Set `offlineDir`. |
1179
+ | Errors missing but events arrive | `sampleRate < 1` — it applies to `captureException` only. | Raise `sampleRate` (default 1). |
1180
+ | No breadcrumbs on captured errors | `maxBreadcrumbs: 0`, `beforeBreadcrumb` returned `null`, or the crumbs were added in a different `withScope` than the capture. | Add crumbs and capture inside the same scope. |
1181
+ | Scope data leaking between requests | Metadata was set on the global scope instead of a per-request child. | Wrap each request in `withScope` / `runWithAsyncScope`. |
1182
+ | `Error: [sauron] global fetch is unavailable` | Node < 18. | Upgrade to Node >= 18 or pass `fetchImpl`. |
1183
+ | `DsnError: [sauron] invalid DSN: …` | Malformed DSN, wrong protocol, or a password component. | Use `https://<public_key>@<host>/<environment_id>` with no secret. |
1184
+ | Want to see what the transport is doing | — | `init({ debug: true })` — decisions are logged to `console.warn` with a `[sauron]` prefix. |
131
1185
 
132
1186
  ## Development
133
1187
 
134
1188
  ```bash
135
1189
  npm install
136
- npm run build
137
- npm test
1190
+ npm run build # tsc -p tsconfig.build.json
1191
+ npm test # vitest run
1192
+ npm run test:watch # vitest
1193
+ npm run typecheck # tsc --noEmit
138
1194
  ```
1195
+
1196
+ ## License
1197
+
1198
+ AGPL-3.0-only — GNU Affero General Public License v3.0.