@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/CHANGELOG.md +57 -0
- package/README.md +1148 -88
- package/dist/client.d.ts +113 -4
- package/dist/client.js +261 -9
- package/dist/index.d.ts +23 -1
- package/dist/index.js +24 -0
- package/dist/scope.js +5 -0
- package/dist/transport.d.ts +2 -1
- package/dist/transport.js +5 -2
- package/dist/types.d.ts +52 -4
- package/dist/workflow.d.ts +26 -0
- package/dist/workflow.js +51 -0
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -1,10 +1,23 @@
|
|
|
1
1
|
# @edraj/sauron-node
|
|
2
2
|
|
|
3
|
-
Server-side Node/TypeScript SDK for
|
|
4
|
-
product-analytics events
|
|
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
|
|
7
|
-
|
|
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 (
|
|
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
|
-
##
|
|
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>/<
|
|
37
|
-
|
|
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
|
-
|
|
44
|
+
await chargeCard();
|
|
50
45
|
} catch (err) {
|
|
51
|
-
captureException(err, {
|
|
46
|
+
captureException(err, { tags: { area: 'checkout' } });
|
|
52
47
|
}
|
|
53
48
|
|
|
54
|
-
|
|
55
|
-
|
|
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
|
-
|
|
58
|
-
|
|
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
|
-
|
|
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
|
-
|
|
64
|
-
`AsyncLocalStorage`, so concurrent requests never leak state into each other:
|
|
132
|
+
### `init(options)`
|
|
65
133
|
|
|
66
134
|
```ts
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
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
|
|
78
|
-
breadcrumb trail. An optional `fingerprint` override is honored verbatim by the
|
|
79
|
-
backend.
|
|
190
|
+
### `captureException(error, options?)`
|
|
80
191
|
|
|
81
|
-
|
|
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:
|
|
288
|
+
duration_ms: Date.now() - started,
|
|
289
|
+
status: 'ok',
|
|
88
290
|
http_method: 'GET',
|
|
89
291
|
http_status: 200,
|
|
90
|
-
|
|
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
|
-
|
|
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
|
-
|
|
|
896
|
+
| Type | Shape / values |
|
|
96
897
|
| --- | --- |
|
|
97
|
-
| `
|
|
98
|
-
| `
|
|
99
|
-
| `
|
|
100
|
-
| `
|
|
101
|
-
| `
|
|
102
|
-
| `
|
|
103
|
-
| `
|
|
104
|
-
| `
|
|
105
|
-
| `
|
|
106
|
-
| `
|
|
107
|
-
| `
|
|
108
|
-
| `
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
`
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
`
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
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.
|