@edraj/sauron-browser 1.4.1 → 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-browser` 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.1
6
45
 
7
46
  ### Added
package/README.md CHANGED
@@ -358,10 +358,28 @@ function trackTransaction(input: TransactionInput): void
358
358
  | `httpMethod` | `string \| null` | `null` | For `http` ops. |
359
359
  | `httpStatus` | `number \| null` | `null` | For `http` ops. |
360
360
  | `url` | `string \| null` | `null` | For `http` ops. |
361
+ | `tags` | `Record<string, string>` | omitted | Indexed string→string labels. Filter with `@tag.key:value` on the Transactions page. |
362
+ | `extra` | `Record<string, unknown>` | omitted | Freeform JSON — request body, response body, SQL text, row counts. Searchable with `extra.key:value`. |
361
363
 
362
364
  The item is stamped with the current distinct id, session id and timestamp.
363
365
  Never sampled. Returns `void`.
364
366
 
367
+ **`tags` and `extra` are per-call only.** Unlike `track()` and
368
+ `captureException()`, a transaction does **not** inherit the scope:
369
+ `setTag()` / `setExtra()` defaults are not merged in. Transactions are the
370
+ highest-volume signal a page emits — one per navigation and per fetch — so
371
+ inheriting a global blob would write it onto every row.
372
+
373
+ `extra` is serialized and capped at **16 KB** (`MAX_TRANSACTION_EXTRA_BYTES`).
374
+ Past that the whole map is replaced with `{ _truncated: true, _bytes: N }` and
375
+ the dashboard says so on the row. The cap is not cosmetic: envelopes are
376
+ batched, and one oversized body would push the whole envelope past the ingest
377
+ limit and drop every unrelated span sent with it. Size is measured in **UTF-8
378
+ bytes**, so a body of non-ASCII text counts what it will actually cost.
379
+
380
+ Nothing in `extra` is scrubbed. Use `beforeSend` for redaction, and think twice
381
+ before attaching a body that can carry tokens or personal data.
382
+
365
383
  ```ts
366
384
  const started = performance.now();
367
385
  const res = await fetch('/api/orders');
@@ -376,6 +394,115 @@ Sauron.trackTransaction({
376
394
  });
377
395
  ```
378
396
 
397
+ #### Example: a `fetch` wrapper that records both bodies
398
+
399
+ Drop-in replacement for `fetch` on the calls you care about. Note the
400
+ `res.clone()` — reading the body consumes the stream, so the caller would get an
401
+ empty response otherwise.
402
+
403
+ ```ts
404
+ import * as Sauron from '@edraj/sauron-browser';
405
+
406
+ export async function tracedFetch(
407
+ input: string,
408
+ init: RequestInit = {},
409
+ ): Promise<Response> {
410
+ const method = (init.method ?? 'GET').toUpperCase();
411
+ const path = new URL(input, location.origin).pathname;
412
+ const started = performance.now();
413
+
414
+ try {
415
+ const res = await fetch(input, init);
416
+ // Clone BEFORE reading: a Response body is a one-shot stream, and
417
+ // consuming it here would hand the caller an empty one.
418
+ const responseBody = await res.clone().text();
419
+
420
+ Sauron.trackTransaction({
421
+ name: `${method} ${path}`, // grouping key — keep it low cardinality
422
+ op: 'http',
423
+ durationMs: performance.now() - started,
424
+ httpMethod: method,
425
+ httpStatus: res.status,
426
+ url: input,
427
+ status: res.ok ? 'ok' : 'error',
428
+ tags: { api: path.split('/')[2] ?? 'root' },
429
+ extra: {
430
+ request: typeof init.body === 'string' ? init.body : undefined,
431
+ response: responseBody,
432
+ response_bytes: responseBody.length,
433
+ },
434
+ });
435
+ return res;
436
+ } catch (err) {
437
+ Sauron.trackTransaction({
438
+ name: `${method} ${path}`,
439
+ op: 'http',
440
+ durationMs: performance.now() - started,
441
+ httpMethod: method,
442
+ url: input,
443
+ status: 'error',
444
+ extra: { request: init.body, error: String(err) },
445
+ });
446
+ throw err;
447
+ }
448
+ }
449
+ ```
450
+
451
+ On the dashboard: **Transactions → the row → expand**. Both bodies render as a
452
+ JSON tree, and every one of these finds it:
453
+
454
+ ```text
455
+ extra.response:~9001 # substring, inside the stored response body
456
+ @tag.api:orders # indexed tag
457
+ op:http http.status:>=500 # the failures
458
+ duration:>2s # the slow ones
459
+ ```
460
+
461
+ #### Example: a client-side SQL query (`sql.js` / `wa-sqlite`)
462
+
463
+ If your app runs SQLite in the browser, spans work the same way. Put the
464
+ **statement** in `extra` and keep `name` a stable label — a query with literals
465
+ baked in would mint a new dashboard row per execution.
466
+
467
+ ```ts
468
+ function tracedQuery(db: Database, sql: string, params: unknown[] = []) {
469
+ const started = performance.now();
470
+ try {
471
+ const rows = db.exec(sql, params);
472
+ Sauron.trackTransaction({
473
+ // The LABEL, not the statement. `op` accepts only
474
+ // navigation|http|resource|screen_load|custom — anything else, `'db'`
475
+ // included, is coerced to 'custom', so pass 'custom' and say it with a tag.
476
+ name: 'SELECT orders',
477
+ op: 'custom',
478
+ durationMs: performance.now() - started,
479
+ status: 'ok',
480
+ tags: { db: 'sqlite', table: 'orders' },
481
+ extra: {
482
+ statement: sql,
483
+ row_count: rows[0]?.values.length ?? 0,
484
+ // Bind PARAMETERS are user data. Log them only if you have decided
485
+ // that is acceptable, or log their shape instead.
486
+ params,
487
+ },
488
+ });
489
+ return rows;
490
+ } catch (err) {
491
+ Sauron.trackTransaction({
492
+ name: 'SELECT orders',
493
+ op: 'custom',
494
+ durationMs: performance.now() - started,
495
+ status: 'error',
496
+ tags: { db: 'sqlite', table: 'orders' },
497
+ extra: { statement: sql, error: String(err) },
498
+ });
499
+ throw err;
500
+ }
501
+ }
502
+ ```
503
+
504
+ Then `@tag.table:orders duration:>500ms` is your slow-query list.
505
+
379
506
  ### `setScreen(name)`
380
507
 
381
508
  ```ts
@@ -858,7 +985,7 @@ const appFrames = frames.filter((f) => isInAppFrame(f.filename));
858
985
 
859
986
  ```ts
860
987
  const SDK_NAME: string // 'sauron.javascript'
861
- const SDK_VERSION: string // '1.4.0'
988
+ const SDK_VERSION: string // '1.5.0'
862
989
  ```
863
990
 
864
991
  The SDK identity embedded in `header.sdk` of every envelope.
@@ -989,7 +1116,7 @@ Sauron.track('upgraded', {}, { tags: { tier: 'trial' } });
989
1116
 
990
1117
  ```html
991
1118
  <script type="module">
992
- import { Sauron } from 'https://esm.sh/@edraj/sauron-browser@1.4.1';
1119
+ import { Sauron } from 'https://esm.sh/@edraj/sauron-browser@1.5.0';
993
1120
  Sauron.init({ dsn: 'https://pk_test@ingest.example.com/42' });
994
1121
  </script>
995
1122
  ```
package/dist/index.cjs CHANGED
@@ -8,7 +8,7 @@ var __publicField = (obj, key, value) => __defNormalProp(obj, typeof key !== "sy
8
8
 
9
9
  // src/utils.ts
10
10
  var SDK_NAME = "sauron.javascript";
11
- var SDK_VERSION = "1.4.1";
11
+ var SDK_VERSION = "1.5.0";
12
12
  function getGlobal() {
13
13
  return globalThis;
14
14
  }
@@ -87,6 +87,32 @@ function makeLogger(debug) {
87
87
  warn: (...args) => console.warn("[sauron]", ...args)
88
88
  };
89
89
  }
90
+ var MAX_TRANSACTION_EXTRA_BYTES = 16 * 1024;
91
+ function capTransactionExtra(extra, maxBytes = MAX_TRANSACTION_EXTRA_BYTES) {
92
+ let bytes;
93
+ try {
94
+ const json = JSON.stringify(extra);
95
+ if (json === void 0) return { _truncated: true, _bytes: -1 };
96
+ bytes = utf8Length(json);
97
+ } catch {
98
+ return { _truncated: true, _bytes: -1 };
99
+ }
100
+ if (bytes <= maxBytes) return extra;
101
+ return { _truncated: true, _bytes: bytes };
102
+ }
103
+ function utf8Length(s) {
104
+ let n = 0;
105
+ for (let i = 0; i < s.length; i++) {
106
+ const c = s.charCodeAt(i);
107
+ if (c < 128) n += 1;
108
+ else if (c < 2048) n += 2;
109
+ else if (c >= 55296 && c <= 56319) {
110
+ n += 4;
111
+ i++;
112
+ } else n += 3;
113
+ }
114
+ return n;
115
+ }
90
116
 
91
117
  // src/identity.ts
92
118
  var DEVICE_ID_KEY = "sauron.device_id";
@@ -1026,7 +1052,7 @@ function normalizeOp(op) {
1026
1052
  return op && TRANSACTION_OPS.includes(op) ? op : "custom";
1027
1053
  }
1028
1054
  function buildTransactionItem(input, distinctId, sessionId2) {
1029
- return {
1055
+ const item = {
1030
1056
  type: "transaction",
1031
1057
  name: input.name,
1032
1058
  op: normalizeOp(input.op),
@@ -1039,6 +1065,11 @@ function buildTransactionItem(input, distinctId, sessionId2) {
1039
1065
  session_id: sessionId2,
1040
1066
  timestamp: nowIso()
1041
1067
  };
1068
+ if (input.tags && Object.keys(input.tags).length > 0) item.tags = { ...input.tags };
1069
+ if (input.extra && Object.keys(input.extra).length > 0) {
1070
+ item.extra = capTransactionExtra({ ...input.extra });
1071
+ }
1072
+ return item;
1042
1073
  }
1043
1074
  function trackTransaction(input) {
1044
1075
  const client = getClient();
@@ -2302,6 +2333,7 @@ var Sauron = {
2302
2333
  var index_default = Sauron;
2303
2334
 
2304
2335
  exports.DsnError = DsnError;
2336
+ exports.MAX_TRANSACTION_EXTRA_BYTES = MAX_TRANSACTION_EXTRA_BYTES;
2305
2337
  exports.SDK_NAME = SDK_NAME;
2306
2338
  exports.SDK_VERSION = SDK_VERSION;
2307
2339
  exports.Sauron = Sauron;
@@ -2309,6 +2341,7 @@ exports.SauronClient = SauronClient;
2309
2341
  exports.addBreadcrumb = addBreadcrumb2;
2310
2342
  exports.buildEnvelope = buildEnvelope;
2311
2343
  exports.cancelWorkflow = cancelWorkflow2;
2344
+ exports.capTransactionExtra = capTransactionExtra;
2312
2345
  exports.captureException = captureException2;
2313
2346
  exports.captureMessage = captureMessage2;
2314
2347
  exports.close = close;