@edraj/sauron-node 1.4.0 → 1.5.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 CHANGED
@@ -2,6 +2,45 @@
2
2
 
3
3
  All notable changes to `@edraj/sauron-node` are documented here.
4
4
 
5
+ ## 1.5.0
6
+
7
+ ### Added
8
+
9
+ - **`tags` and `extra` on transactions.** `trackTransaction` now accepts two
10
+ developer-supplied maps: `tags` (flat string→string, indexed) and `extra`
11
+ (freeform JSON). `extra` is where a request body, a response body, an order
12
+ id or a retry count goes — the span that times an HTTP call can now carry
13
+ what the call actually sent and received.
14
+
15
+ Both are visible on the new **Transactions** page and in the session
16
+ timeline, and both are searchable: `@tag.tier:premium`,
17
+ `extra.order_id:9001`, or `extra.response:~9001` to match a substring
18
+ *inside* a stored response body.
19
+
20
+ **They are per-call only.** Unlike `captureException` and `track`, a
21
+ transaction does not inherit the scope — `setTag()` / `setExtra()` defaults
22
+ are not merged in. Transactions are the highest-volume signal an app emits,
23
+ one per navigation and per request, so inheriting a global blob would write
24
+ it onto every row. This asymmetry is deliberate and is documented on the
25
+ method.
26
+
27
+ `extra` is serialized and capped at **16 KB**. Past that the whole map is
28
+ replaced with a `{"_truncated": true, "_bytes": N}` marker, and the
29
+ dashboard says so on the row rather than showing a short object that looks
30
+ complete. The cap is not cosmetic: envelopes are batched, and one oversized
31
+ body would push the whole envelope past the ingest limit and take every
32
+ unrelated span sent with it — a silent loss of data nobody asked about.
33
+ Size is measured in UTF-8 bytes, so non-ASCII payloads are counted at what
34
+ they actually cost on the wire.
35
+
36
+ Nothing in `extra` is scrubbed. `beforeSend` remains the redaction seam;
37
+ think twice before attaching a body that can carry tokens or personal data.
38
+
39
+ An app that sets neither field serializes byte-identically to before: both
40
+ keys are omitted when empty, never sent as `null`.
41
+
42
+ Signature: `trackTransaction({ …, tags?: Record<string, string>, extra?: Record<string, unknown> })`. `MAX_TRANSACTION_EXTRA_BYTES` and `capTransactionExtra` are now exported from the package entrypoint, so a caller can size a payload before attaching it.
43
+
5
44
  ## 1.4.0
6
45
 
7
46
  ### Fixed
package/README.md CHANGED
@@ -275,10 +275,28 @@ function trackTransaction(input: TransactionInput): void
275
275
  | `input.http_status` | `number` | omitted | e.g. `200`. |
276
276
  | `input.url` | `string` | omitted | Request URL/path. |
277
277
  | `input.distinct_id` | `string` | scope user's `id`, else omitted | Explicit value wins over the scope. |
278
+ | `input.tags` | `Record<string, string>` | omitted | Indexed string→string labels. Filter with `@tag.key:value` on the Transactions page. |
279
+ | `input.extra` | `Record<string, unknown>` | omitted | Freeform JSON — request body, response body, SQL text, row counts. Searchable with `extra.key:value`. |
278
280
 
279
281
  Emits a `transaction` item. Absent optional fields are omitted from the wire JSON
280
282
  rather than serialized as `null`. Returns `void`.
281
283
 
284
+ **`tags` and `extra` are per-call only.** Unlike `track()` and
285
+ `captureException()`, a transaction does **not** inherit the scope:
286
+ `setTag()` / `setExtra()` defaults are not merged in. Transactions are the
287
+ highest-volume signal a service emits — one per request and per query — so
288
+ inheriting a global blob would write it onto every row.
289
+
290
+ `extra` is serialized and capped at **16 KB**
291
+ (`MAX_TRANSACTION_EXTRA_BYTES`, exported from `transaction-extra`). Past that
292
+ the whole map is replaced with `{ _truncated: true, _bytes: N }` and the
293
+ dashboard says so on the row. The cap is not cosmetic: envelopes are batched,
294
+ and one oversized body would push the whole envelope past the ingest limit and
295
+ drop every unrelated span sent with it.
296
+
297
+ Nothing in `extra` is scrubbed. Use `beforeSend` for redaction, and think twice
298
+ before attaching a body that can carry tokens, passwords or personal data.
299
+
282
300
  ```ts
283
301
  const started = Date.now();
284
302
  await handler(req, res);
@@ -293,6 +311,128 @@ trackTransaction({
293
311
  });
294
312
  ```
295
313
 
314
+ #### Example: an Express route, with request and response bodies
315
+
316
+ `res.json` is wrapped rather than read afterwards, because by the time the
317
+ `finish` event fires the payload is already on the wire and gone.
318
+
319
+ ```ts
320
+ import express from 'express';
321
+ import { trackTransaction } from '@edraj/sauron-node';
322
+
323
+ export function tracedRoute(routeLabel: string, handler: express.RequestHandler) {
324
+ return async (req: express.Request, res: express.Response, next: express.NextFunction) => {
325
+ const started = Date.now();
326
+ let responseBody: unknown;
327
+
328
+ // Capture on the way out. Reading it in `finish` is too late — the body
329
+ // has already been serialized and released by then.
330
+ const json = res.json.bind(res);
331
+ res.json = (body: unknown) => {
332
+ responseBody = body;
333
+ return json(body);
334
+ };
335
+
336
+ res.on('finish', () => {
337
+ trackTransaction({
338
+ name: routeLabel, // 'POST /orders', NOT '/orders/8412'
339
+ op: 'http',
340
+ duration_ms: Date.now() - started,
341
+ http_method: req.method,
342
+ http_status: res.statusCode,
343
+ url: req.originalUrl,
344
+ status: res.statusCode < 400 ? 'ok' : 'error',
345
+ distinct_id: (req as { userId?: string }).userId,
346
+ tags: { route: routeLabel, tier: (req as { plan?: string }).plan ?? 'free' },
347
+ extra: {
348
+ request: req.body,
349
+ response: responseBody,
350
+ query: req.query,
351
+ // Header VALUES are omitted on purpose — `authorization` and
352
+ // `cookie` live there.
353
+ request_headers: Object.keys(req.headers),
354
+ },
355
+ });
356
+ });
357
+
358
+ try {
359
+ await handler(req, res, next);
360
+ } catch (err) {
361
+ next(err);
362
+ }
363
+ };
364
+ }
365
+
366
+ const app = express();
367
+ app.post('/orders', tracedRoute('POST /orders', createOrder));
368
+ ```
369
+
370
+ On the dashboard: **Transactions → the row → expand**. Both bodies render as a
371
+ JSON tree, and every one of these finds it:
372
+
373
+ ```text
374
+ extra.response:~9001 # substring, inside the stored response body
375
+ @tag.route:"POST /orders" # indexed tag
376
+ op:http http.status:>=500 # the failures
377
+ duration:>2s # the slow ones
378
+ ```
379
+
380
+ #### Example: a SQL query (`pg`)
381
+
382
+ Put the **statement** in `extra` and keep `name` a stable label — a query with
383
+ literals baked in would mint a new dashboard row per execution.
384
+
385
+ ```ts
386
+ import { Pool } from 'pg';
387
+ import { trackTransaction } from '@edraj/sauron-node';
388
+
389
+ const pool = new Pool();
390
+
391
+ export async function tracedQuery<T>(label: string, sql: string, params: unknown[] = []) {
392
+ const started = Date.now();
393
+ try {
394
+ const result = await pool.query<T>(sql, params);
395
+ trackTransaction({
396
+ // The LABEL, not the statement. `op` is free-form on this SDK, so a
397
+ // 'db' op is stored as-is — but note the browser SDK coerces anything
398
+ // outside navigation|http|resource|screen_load|custom to 'custom', so
399
+ // use a tag if you need the two to agree.
400
+ name: label,
401
+ op: 'db',
402
+ duration_ms: Date.now() - started,
403
+ status: 'ok',
404
+ tags: { db: 'postgres', table: 'orders' },
405
+ extra: {
406
+ statement: sql,
407
+ row_count: result.rowCount,
408
+ // Bind PARAMETERS are user data. Log them only if you have decided
409
+ // that is acceptable, or log their shape instead.
410
+ params,
411
+ },
412
+ });
413
+ return result;
414
+ } catch (err) {
415
+ trackTransaction({
416
+ name: label,
417
+ op: 'db',
418
+ duration_ms: Date.now() - started,
419
+ status: 'error',
420
+ tags: { db: 'postgres', table: 'orders' },
421
+ extra: { statement: sql, error: String(err) },
422
+ });
423
+ throw err;
424
+ }
425
+ }
426
+
427
+ await tracedQuery(
428
+ 'SELECT orders',
429
+ 'SELECT id, total FROM orders WHERE user_id = $1 ORDER BY created_at DESC LIMIT 20',
430
+ [userId],
431
+ );
432
+ ```
433
+
434
+ Then `@tag.table:orders duration:>500ms` is your slow-query list.
435
+
296
436
  ### `addBreadcrumb(crumb)`
297
437
 
298
438
  ```ts
package/dist/client.js CHANGED
@@ -6,6 +6,7 @@ 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
11
  release: null,
11
12
  sampleRate: 1,
@@ -210,6 +211,15 @@ export class SauronClient {
210
211
  item.url = input.url;
211
212
  if (distinctId != null)
212
213
  item.distinct_id = distinctId;
214
+ // Per-call only — deliberately no `globalScope.tags`/`.extra` merge here,
215
+ // unlike `track()` and `captureException` below. See TransactionInput.
216
+ // Omitted when empty so an app that never sets them is byte-identical on
217
+ // the wire to before these fields existed.
218
+ if (input.tags && Object.keys(input.tags).length > 0)
219
+ item.tags = { ...input.tags };
220
+ if (input.extra && Object.keys(input.extra).length > 0) {
221
+ item.extra = capTransactionExtra({ ...input.extra });
222
+ }
213
223
  this.dispatch(item);
214
224
  }
215
225
  /** 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.5.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;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@edraj/sauron-node",
3
- "version": "1.4.0",
3
+ "version": "1.5.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": {