@edraj/sauron-node 1.4.0 → 1.6.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
@@ -56,13 +56,13 @@ adding `Content-Encoding: gzip` once the body crosses the gzip threshold.
56
56
 
57
57
  ## Configuration
58
58
 
59
- `init(options)` takes a single `InitOptions` object. Every field except `dsn` is
60
- optional.
59
+ `init(options)` takes a single `InitOptions` object. Every field except `dsn`
60
+ and `release` is optional.
61
61
 
62
62
  | Option | Type | Default | Description |
63
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`. |
64
+ | `dsn` | `string` | — (**required**) | `https://<public_key>@<host>/<environment_id>`. A non-string or empty value throws `Error`; a malformed value throws `DsnError`. |
65
+ | `release` | `string` | (**required**) | The app version this build reports as, written to `header.release`. Missing or whitespace-only throws `Error`. Trimmed before it is sent. |
66
66
  | `tags` | `Record<string, string>` | `{}` | Default tags seeded into the global scope at init. |
67
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
68
  | `extra` | `Record<string, unknown>` | `{}` | Default free-form extra values seeded into the global scope. |
@@ -140,11 +140,12 @@ function init(options: InitOptions): SauronClient
140
140
  | `options` | `InitOptions` | — (required) | See [Configuration](#configuration). |
141
141
 
142
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.
143
+ Throws `Error` when `options.dsn` is missing/empty/not a string, when
144
+ `options.release` is missing or blank (**required as of v1.6.0**), and
145
+ `DsnError` when the DSN is malformed.
145
146
 
146
147
  ```ts
147
- const client = init({ dsn: 'https://pk@ingest.example.com/42' });
148
+ const client = init({ dsn: 'https://pk@ingest.example.com/42', release: 'api@1.4.2' });
148
149
  ```
149
150
 
150
151
  ### `getClient()`
@@ -275,10 +276,28 @@ function trackTransaction(input: TransactionInput): void
275
276
  | `input.http_status` | `number` | omitted | e.g. `200`. |
276
277
  | `input.url` | `string` | omitted | Request URL/path. |
277
278
  | `input.distinct_id` | `string` | scope user's `id`, else omitted | Explicit value wins over the scope. |
279
+ | `input.tags` | `Record<string, string>` | omitted | Indexed string→string labels. Filter with `@tag.key:value` on the Transactions page. |
280
+ | `input.extra` | `Record<string, unknown>` | omitted | Freeform JSON — request body, response body, SQL text, row counts. Searchable with `extra.key:value`. |
278
281
 
279
282
  Emits a `transaction` item. Absent optional fields are omitted from the wire JSON
280
283
  rather than serialized as `null`. Returns `void`.
281
284
 
285
+ **`tags` and `extra` are per-call only.** Unlike `track()` and
286
+ `captureException()`, a transaction does **not** inherit the scope:
287
+ `setTag()` / `setExtra()` defaults are not merged in. Transactions are the
288
+ highest-volume signal a service emits — one per request and per query — so
289
+ inheriting a global blob would write it onto every row.
290
+
291
+ `extra` is serialized and capped at **16 KB**
292
+ (`MAX_TRANSACTION_EXTRA_BYTES`, exported from `transaction-extra`). Past that
293
+ the whole map is replaced with `{ _truncated: true, _bytes: N }` and the
294
+ dashboard says so on the row. The cap is not cosmetic: envelopes are batched,
295
+ and one oversized body would push the whole envelope past the ingest limit and
296
+ drop every unrelated span sent with it.
297
+
298
+ Nothing in `extra` is scrubbed. Use `beforeSend` for redaction, and think twice
299
+ before attaching a body that can carry tokens, passwords or personal data.
300
+
282
301
  ```ts
283
302
  const started = Date.now();
284
303
  await handler(req, res);
@@ -293,6 +312,128 @@ trackTransaction({
293
312
  });
294
313
  ```
295
314
 
315
+ #### Example: an Express route, with request and response bodies
316
+
317
+ `res.json` is wrapped rather than read afterwards, because by the time the
318
+ `finish` event fires the payload is already on the wire and gone.
319
+
320
+ ```ts
321
+ import express from 'express';
322
+ import { trackTransaction } from '@edraj/sauron-node';
323
+
324
+ export function tracedRoute(routeLabel: string, handler: express.RequestHandler) {
325
+ return async (req: express.Request, res: express.Response, next: express.NextFunction) => {
326
+ const started = Date.now();
327
+ let responseBody: unknown;
328
+
329
+ // Capture on the way out. Reading it in `finish` is too late — the body
330
+ // has already been serialized and released by then.
331
+ const json = res.json.bind(res);
332
+ res.json = (body: unknown) => {
333
+ responseBody = body;
334
+ return json(body);
335
+ };
336
+
337
+ res.on('finish', () => {
338
+ trackTransaction({
339
+ name: routeLabel, // 'POST /orders', NOT '/orders/8412'
340
+ op: 'http',
341
+ duration_ms: Date.now() - started,
342
+ http_method: req.method,
343
+ http_status: res.statusCode,
344
+ url: req.originalUrl,
345
+ status: res.statusCode < 400 ? 'ok' : 'error',
346
+ distinct_id: (req as { userId?: string }).userId,
347
+ tags: { route: routeLabel, tier: (req as { plan?: string }).plan ?? 'free' },
348
+ extra: {
349
+ request: req.body,
350
+ response: responseBody,
351
+ query: req.query,
352
+ // Header VALUES are omitted on purpose — `authorization` and
353
+ // `cookie` live there.
354
+ request_headers: Object.keys(req.headers),
355
+ },
356
+ });
357
+ });
358
+
359
+ try {
360
+ await handler(req, res, next);
361
+ } catch (err) {
362
+ next(err);
363
+ }
364
+ };
365
+ }
366
+
367
+ const app = express();
368
+ app.post('/orders', tracedRoute('POST /orders', createOrder));
369
+ ```
370
+
371
+ On the dashboard: **Transactions → the row → expand**. Both bodies render as a
372
+ JSON tree, and every one of these finds it:
373
+
374
+ ```text
375
+ extra.response:~9001 # substring, inside the stored response body
376
+ @tag.route:"POST /orders" # indexed tag
377
+ op:http http.status:>=500 # the failures
378
+ duration:>2s # the slow ones
379
+ ```
380
+
381
+ #### Example: a SQL query (`pg`)
382
+
383
+ Put the **statement** in `extra` and keep `name` a stable label — a query with
384
+ literals baked in would mint a new dashboard row per execution.
385
+
386
+ ```ts
387
+ import { Pool } from 'pg';
388
+ import { trackTransaction } from '@edraj/sauron-node';
389
+
390
+ const pool = new Pool();
391
+
392
+ export async function tracedQuery<T>(label: string, sql: string, params: unknown[] = []) {
393
+ const started = Date.now();
394
+ try {
395
+ const result = await pool.query<T>(sql, params);
396
+ trackTransaction({
397
+ // The LABEL, not the statement. `op` is free-form on this SDK, so a
398
+ // 'db' op is stored as-is — but note the browser SDK coerces anything
399
+ // outside navigation|http|resource|screen_load|custom to 'custom', so
400
+ // use a tag if you need the two to agree.
401
+ name: label,
402
+ op: 'db',
403
+ duration_ms: Date.now() - started,
404
+ status: 'ok',
405
+ tags: { db: 'postgres', table: 'orders' },
406
+ extra: {
407
+ statement: sql,
408
+ row_count: result.rowCount,
409
+ // Bind PARAMETERS are user data. Log them only if you have decided
410
+ // that is acceptable, or log their shape instead.
411
+ params,
412
+ },
413
+ });
414
+ return result;
415
+ } catch (err) {
416
+ trackTransaction({
417
+ name: label,
418
+ op: 'db',
419
+ duration_ms: Date.now() - started,
420
+ status: 'error',
421
+ tags: { db: 'postgres', table: 'orders' },
422
+ extra: { statement: sql, error: String(err) },
423
+ });
424
+ throw err;
425
+ }
426
+ }
427
+
428
+ await tracedQuery(
429
+ 'SELECT orders',
430
+ 'SELECT id, total FROM orders WHERE user_id = $1 ORDER BY created_at DESC LIMIT 20',
431
+ [userId],
432
+ );
433
+ ```
434
+
435
+ Then `@tag.table:orders duration:>500ms` is your slow-query list.
436
+
296
437
  ### `addBreadcrumb(crumb)`
297
438
 
298
439
  ```ts
@@ -683,7 +824,7 @@ Re-entrancy is guarded, so a throw inside the capture path cannot loop. Prefer
683
824
  down on `close()`.
684
825
 
685
826
  ```ts
686
- const client = init({ dsn: DSN });
827
+ const client = init({ dsn: DSN, release: 'api@1.4.2' });
687
828
  const uninstall = installAutoCapture(client);
688
829
  // later
689
830
  uninstall();
@@ -715,7 +856,7 @@ signal fires — if you need to drain HTTP connections first, leave `autoShutdow
715
856
  off and call `close()` yourself from your own handler.
716
857
 
717
858
  ```ts
718
- const client = init({ dsn: DSN });
859
+ const client = init({ dsn: DSN, release: 'api@1.4.2' });
719
860
  const uninstall = installShutdownHooks(client);
720
861
  ```
721
862
 
@@ -948,7 +1089,7 @@ Emit conventions on the wire:
948
1089
  `distinct_id` fallback from the scope's user id.
949
1090
 
950
1091
  ```ts
951
- init({ dsn: DSN, tags: { service: 'checkout' } }); // layer 1
1092
+ init({ dsn: DSN, release: 'api@1.4.2', tags: { service: 'checkout' } }); // layer 1
952
1093
 
953
1094
  setTag('region', 'eu-west-1'); // layer 2 (global)
954
1095
 
@@ -1054,7 +1195,7 @@ import {
1054
1195
  init, withScope, addBreadcrumb, captureException, trackTransaction, close,
1055
1196
  } from '@edraj/sauron-node';
1056
1197
 
1057
- init({ dsn: process.env.SAURON_DSN!, autoCaptureUnhandled: true });
1198
+ init({ dsn: process.env.SAURON_DSN!, release: process.env.GIT_SHA, autoCaptureUnhandled: true });
1058
1199
 
1059
1200
  const fastify = Fastify();
1060
1201
  const startedAt = new WeakMap<object, number>();
@@ -1195,4 +1336,5 @@ npm run typecheck # tsc --noEmit
1195
1336
 
1196
1337
  ## License
1197
1338
 
1198
- AGPL-3.0-only — GNU Affero General Public License v3.0.
1339
+ LGPL-3.0-only — GNU Lesser General Public License v3.0. LGPLv3 applies on top of
1340
+ the GNU GPL v3, whose text ships alongside it in `COPYING`.
package/dist/client.js CHANGED
@@ -6,8 +6,8 @@ import { parseError } from './stacktrace.js';
6
6
  import { installAutoCapture, installShutdownHooks } from './autocapture.js';
7
7
  import { getCurrentScope, getGlobalScope, normalizeBreadcrumb, } from './scope.js';
8
8
  import { normalizeReason, normalizeWorkflowName } from './workflow.js';
9
+ import { capTransactionExtra } from './transaction-extra.js';
9
10
  const DEFAULTS = {
10
- release: null,
11
11
  sampleRate: 1,
12
12
  flushInterval: 5000,
13
13
  maxBatch: 30,
@@ -18,13 +18,16 @@ const DEFAULTS = {
18
18
  debug: false,
19
19
  };
20
20
  function resolveOptions(options) {
21
- if (!options || typeof options.dsn !== 'string') {
21
+ if (!options || typeof options.dsn !== 'string' || options.dsn.length === 0) {
22
22
  throw new Error('[sauron] init requires a { dsn } option');
23
23
  }
24
+ if (typeof options.release !== 'string' || options.release.trim().length === 0) {
25
+ throw new Error('[sauron] init requires a { release } option (the app version this build reports as)');
26
+ }
24
27
  const sampleRate = typeof options.sampleRate === 'number' ? options.sampleRate : DEFAULTS.sampleRate;
25
28
  return {
26
29
  dsn: options.dsn,
27
- release: options.release ?? DEFAULTS.release,
30
+ release: options.release.trim(),
28
31
  tags: options.tags ?? {},
29
32
  contexts: options.contexts ?? {},
30
33
  extra: options.extra ?? {},
@@ -210,6 +213,15 @@ export class SauronClient {
210
213
  item.url = input.url;
211
214
  if (distinctId != null)
212
215
  item.distinct_id = distinctId;
216
+ // Per-call only — deliberately no `globalScope.tags`/`.extra` merge here,
217
+ // unlike `track()` and `captureException` below. See TransactionInput.
218
+ // Omitted when empty so an app that never sets them is byte-identical on
219
+ // the wire to before these fields existed.
220
+ if (input.tags && Object.keys(input.tags).length > 0)
221
+ item.tags = { ...input.tags };
222
+ if (input.extra && Object.keys(input.extra).length > 0) {
223
+ item.extra = capTransactionExtra({ ...input.extra });
224
+ }
213
225
  this.dispatch(item);
214
226
  }
215
227
  /** Capture a product-analytics event. `distinctId` is required. */
package/dist/index.d.ts CHANGED
@@ -77,3 +77,4 @@ export declare function setExtra(key: string, value: unknown): void;
77
77
  export declare function flush(): Promise<void>;
78
78
  /** Flush and stop the background timer, then clear the active client. */
79
79
  export declare function close(): Promise<void>;
80
+ export { MAX_TRANSACTION_EXTRA_BYTES, capTransactionExtra, } from './transaction-extra.js';
package/dist/index.js CHANGED
@@ -112,3 +112,6 @@ export async function close() {
112
112
  activeClient = null;
113
113
  await client.close();
114
114
  }
115
+ // Exported so a caller can size a payload BEFORE attaching it — the cap is
116
+ // otherwise invisible until the dashboard shows a truncation marker.
117
+ export { MAX_TRANSACTION_EXTRA_BYTES, capTransactionExtra, } from './transaction-extra.js';
@@ -0,0 +1,36 @@
1
+ /**
2
+ * The size cap on a transaction's developer-supplied `extra`.
3
+ *
4
+ * Its own module rather than a private helper inside `client.ts` so the limit
5
+ * and its behaviour are directly testable — the failure this guards against
6
+ * (an oversized payload taking a whole batched envelope down with it) is not
7
+ * visible from the outside once it happens.
8
+ */
9
+ /**
10
+ * Largest serialized `extra` a single transaction may carry, in bytes.
11
+ *
12
+ * Transactions are the highest-volume signal and they ship in BATCHED
13
+ * envelopes, so one oversized payload does not fail alone — ingest rejects the
14
+ * whole envelope past `INGEST_MAX_BODY_BYTES` (1 MiB by default) and every
15
+ * unrelated span batched with it is lost. Since the motivating use of
16
+ * transaction `extra` is request and response bodies, that is not a remote
17
+ * hazard.
18
+ *
19
+ * Kept identical across all five SDKs. If it moves, it moves everywhere.
20
+ */
21
+ export declare const MAX_TRANSACTION_EXTRA_BYTES: number;
22
+ /**
23
+ * Cap a transaction's `extra`, substituting a marker when it is too large.
24
+ *
25
+ * Replaces the WHOLE map rather than trimming keys: a half-written JSON value
26
+ * is worse than an honest marker, and per-key trimming would make the result
27
+ * depend on key iteration order, which differs across the five SDKs. The marker
28
+ * is deliberately readable on the dashboard — `_truncated` says data was
29
+ * dropped rather than silently serving a short object that looks complete.
30
+ *
31
+ * A value that cannot be serialized at all (a cycle, a BigInt) becomes the same
32
+ * marker with `_bytes: -1`, because the alternative is throwing from inside
33
+ * `trackTransaction` — and an SDK that crashes the app it is measuring is worse
34
+ * than one that drops a payload.
35
+ */
36
+ export declare function capTransactionExtra(extra: Record<string, unknown>, maxBytes?: number): Record<string, unknown>;
@@ -0,0 +1,53 @@
1
+ /**
2
+ * The size cap on a transaction's developer-supplied `extra`.
3
+ *
4
+ * Its own module rather than a private helper inside `client.ts` so the limit
5
+ * and its behaviour are directly testable — the failure this guards against
6
+ * (an oversized payload taking a whole batched envelope down with it) is not
7
+ * visible from the outside once it happens.
8
+ */
9
+ /**
10
+ * Largest serialized `extra` a single transaction may carry, in bytes.
11
+ *
12
+ * Transactions are the highest-volume signal and they ship in BATCHED
13
+ * envelopes, so one oversized payload does not fail alone — ingest rejects the
14
+ * whole envelope past `INGEST_MAX_BODY_BYTES` (1 MiB by default) and every
15
+ * unrelated span batched with it is lost. Since the motivating use of
16
+ * transaction `extra` is request and response bodies, that is not a remote
17
+ * hazard.
18
+ *
19
+ * Kept identical across all five SDKs. If it moves, it moves everywhere.
20
+ */
21
+ export const MAX_TRANSACTION_EXTRA_BYTES = 16 * 1024;
22
+ /**
23
+ * Cap a transaction's `extra`, substituting a marker when it is too large.
24
+ *
25
+ * Replaces the WHOLE map rather than trimming keys: a half-written JSON value
26
+ * is worse than an honest marker, and per-key trimming would make the result
27
+ * depend on key iteration order, which differs across the five SDKs. The marker
28
+ * is deliberately readable on the dashboard — `_truncated` says data was
29
+ * dropped rather than silently serving a short object that looks complete.
30
+ *
31
+ * A value that cannot be serialized at all (a cycle, a BigInt) becomes the same
32
+ * marker with `_bytes: -1`, because the alternative is throwing from inside
33
+ * `trackTransaction` — and an SDK that crashes the app it is measuring is worse
34
+ * than one that drops a payload.
35
+ */
36
+ export function capTransactionExtra(extra, maxBytes = MAX_TRANSACTION_EXTRA_BYTES) {
37
+ let bytes;
38
+ try {
39
+ const json = JSON.stringify(extra);
40
+ if (json === undefined)
41
+ return { _truncated: true, _bytes: -1 };
42
+ // UTF-8 byte length, not `json.length`: the latter undercounts every
43
+ // non-ASCII byte, which is exactly what a response body full of user text
44
+ // is made of.
45
+ bytes = Buffer.byteLength(json, 'utf8');
46
+ }
47
+ catch {
48
+ return { _truncated: true, _bytes: -1 };
49
+ }
50
+ if (bytes <= maxBytes)
51
+ return extra;
52
+ return { _truncated: true, _bytes: bytes };
53
+ }
package/dist/transport.js CHANGED
@@ -1,7 +1,7 @@
1
1
  import { maybeGzip } from './gzip.js';
2
2
  import { BoundedQueue } from './queue.js';
3
3
  const SDK_NAME = 'sauron-node';
4
- const SDK_VERSION = '1.4.0';
4
+ const SDK_VERSION = '1.6.0';
5
5
  /** Default exponential-backoff base (ms) for the first retry. */
6
6
  const DEFAULT_RETRY_BASE_MS = 200;
7
7
  /** Hard cap on any single backoff delay (ms). */
package/dist/types.d.ts CHANGED
@@ -161,6 +161,17 @@ export interface TransactionItem {
161
161
  workflow_id?: string;
162
162
  workflow_name?: string;
163
163
  timestamp: string;
164
+ /**
165
+ * Developer-supplied flat string tags for THIS transaction. NOT merged with
166
+ * the scope — see {@link TransactionInput.tags}. Omitted when empty.
167
+ */
168
+ tags?: Record<string, string>;
169
+ /**
170
+ * Developer-supplied freeform JSON for THIS transaction. Capped and replaced
171
+ * with a truncation marker past `MAX_TRANSACTION_EXTRA_BYTES`. Omitted when
172
+ * empty.
173
+ */
174
+ extra?: Record<string, unknown>;
164
175
  }
165
176
  /** Caller input for {@link TransactionItem} via `trackTransaction`. */
166
177
  export interface TransactionInput {
@@ -187,6 +198,29 @@ export interface TransactionInput {
187
198
  url?: string;
188
199
  /** Falls back to the scoped user's id when omitted. */
189
200
  distinct_id?: string;
201
+ /**
202
+ * Flat string tags for this transaction.
203
+ *
204
+ * **Per-call only — the scope is NOT merged in**, which is the one place
205
+ * transactions differ from `track()` and `captureException()`. Those two
206
+ * merge `setTag`/`setExtra` defaults; a transaction carries only what its own
207
+ * call site attached. Transactions are the highest-volume signal (one per
208
+ * navigation and per HTTP call), so inheriting a global blob would write it
209
+ * onto every row.
210
+ */
211
+ tags?: Record<string, string>;
212
+ /**
213
+ * Freeform JSON for this transaction — the request body, the response body,
214
+ * an order id, a retry count.
215
+ *
216
+ * Per-call only, for the reason on {@link TransactionInput.tags}. Serialized
217
+ * and capped at `MAX_TRANSACTION_EXTRA_BYTES`; past that the whole map is
218
+ * replaced with `{ _truncated: true, _bytes: N }` so one large body cannot
219
+ * take a batched envelope over the ingest limit and drop every span in it.
220
+ *
221
+ * Nothing here is scrubbed. `beforeSend` is the redaction seam.
222
+ */
223
+ extra?: Record<string, unknown>;
190
224
  }
191
225
  /** Any item that can appear in an envelope's `items` array. */
192
226
  export type EnvelopeItem = ErrorItem | EventItem | IdentifyItem | TransactionItem;
@@ -273,7 +307,8 @@ export interface TransportOptions {
273
307
  export interface InitOptions {
274
308
  /** `https://<public_key>@<host>/<project_id>` */
275
309
  dsn: string;
276
- release?: string | null;
310
+ /** Required. The app version every event is attributed to. */
311
+ release: string;
277
312
  /** Default tags seeded into the global scope at init. */
278
313
  tags?: Record<string, string>;
279
314
  /** Default named dev context blocks seeded into the global scope at init. Distinct from the machine `context`. */
@@ -318,7 +353,7 @@ export interface InitOptions {
318
353
  /** Fully-resolved options with all defaults applied. */
319
354
  export interface ResolvedOptions {
320
355
  dsn: string;
321
- release: string | null;
356
+ release: string;
322
357
  tags: Record<string, string>;
323
358
  contexts: Record<string, unknown>;
324
359
  extra: Record<string, unknown>;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@edraj/sauron-node",
3
- "version": "1.4.0",
3
+ "version": "1.6.0",
4
4
  "description": "Sauron server-side Node/TypeScript SDK: product-analytics events + exception capture for Node backends.",
5
5
  "homepage": "https://github.com/edraj/sauron/tree/main/sdks/node#readme",
6
6
  "repository": {
@@ -25,7 +25,8 @@
25
25
  "dist",
26
26
  "README.md",
27
27
  "CHANGELOG.md",
28
- "LICENSE"
28
+ "LICENSE",
29
+ "COPYING"
29
30
  ],
30
31
  "scripts": {
31
32
  "build": "tsc -p tsconfig.build.json",
@@ -52,7 +53,7 @@
52
53
  "node",
53
54
  "server"
54
55
  ],
55
- "license": "AGPL-3.0-only",
56
+ "license": "LGPL-3.0-only",
56
57
  "publishConfig": {
57
58
  "access": "public"
58
59
  }