@edraj/sauron-browser 1.4.1 → 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/CHANGELOG.md CHANGED
@@ -2,6 +2,70 @@
2
2
 
3
3
  All notable changes to `@edraj/sauron-browser` are documented here.
4
4
 
5
+ ## 1.6.0
6
+
7
+ ### Added
8
+
9
+ - **Navigation direction on history breadcrumbs.** SPA navigation breadcrumbs
10
+ now carry `operation` alongside `from` and `to`: `push` for
11
+ `history.pushState`, `replace` for `history.replaceState`, and `pop` for
12
+ `popstate`. A breadcrumb trail no longer reads the same whether the user
13
+ advanced through a flow or backed out of it.
14
+
15
+ The vocabulary is shared with the Flutter SDK's `SauronNavigatorObserver`
16
+ (`push` / `pop` / `replace` / `remove`), so a trail reads the same whichever
17
+ SDK sent it. `remove` has no web equivalent and is never emitted here.
18
+
19
+ **A forward navigation is recorded as `pop`.** `history.forward()` fires the
20
+ same `popstate` event as `history.back()` and carries nothing to separate
21
+ them, so `pop` means "moved through history" rather than specifically "went
22
+ back". Telling them apart would mean writing a counter into `history.state`,
23
+ which the host app's router also owns — not a trade this SDK makes to
24
+ improve a breadcrumb.
25
+
26
+ No API change: `operation` is added to `data` on breadcrumbs the SDK already
27
+ emitted, and the same-path guard still suppresses a `replaceState` to the
28
+ current URL before any direction is recorded.
29
+
30
+ ## 1.5.0
31
+
32
+ ### Added
33
+
34
+ - **`tags` and `extra` on transactions.** `trackTransaction` now accepts two
35
+ developer-supplied maps: `tags` (flat string→string, indexed) and `extra`
36
+ (freeform JSON). `extra` is where a request body, a response body, an order
37
+ id or a retry count goes — the span that times an HTTP call can now carry
38
+ what the call actually sent and received.
39
+
40
+ Both are visible on the new **Transactions** page and in the session
41
+ timeline, and both are searchable: `@tag.tier:premium`,
42
+ `extra.order_id:9001`, or `extra.response:~9001` to match a substring
43
+ *inside* a stored response body.
44
+
45
+ **They are per-call only.** Unlike `captureException` and `track`, a
46
+ transaction does not inherit the scope — `setTag()` / `setExtra()` defaults
47
+ are not merged in. Transactions are the highest-volume signal an app emits,
48
+ one per navigation and per request, so inheriting a global blob would write
49
+ it onto every row. This asymmetry is deliberate and is documented on the
50
+ method.
51
+
52
+ `extra` is serialized and capped at **16 KB**. Past that the whole map is
53
+ replaced with a `{"_truncated": true, "_bytes": N}` marker, and the
54
+ dashboard says so on the row rather than showing a short object that looks
55
+ complete. The cap is not cosmetic: envelopes are batched, and one oversized
56
+ body would push the whole envelope past the ingest limit and take every
57
+ unrelated span sent with it — a silent loss of data nobody asked about.
58
+ Size is measured in UTF-8 bytes, so non-ASCII payloads are counted at what
59
+ they actually cost on the wire.
60
+
61
+ Nothing in `extra` is scrubbed. `beforeSend` remains the redaction seam;
62
+ think twice before attaching a body that can carry tokens or personal data.
63
+
64
+ An app that sets neither field serializes byte-identically to before: both
65
+ keys are omitted when empty, never sent as `null`.
66
+
67
+ 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.
68
+
5
69
  ## 1.4.1
6
70
 
7
71
  ### 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.6.0'
862
989
  ```
863
990
 
864
991
  The SDK identity embedded in `header.sdk` of every envelope.
@@ -900,7 +1027,7 @@ On by default:
900
1027
  | `window.onunhandledrejection` | Error item from `event.reason`, mechanism `{ type: 'onunhandledrejection', handled: false }`, level `error`. |
901
1028
  | `console.log/info/warn/error/debug` | Breadcrumb, category `console`, level mapped (`warn`→`warning`, `error`→`error`, `debug`→`debug`, else `info`), message = arguments joined and truncated to 512 chars, `data: { arguments: n }`. Output is untouched. |
902
1029
  | `document` click listener (capture, passive) | Breadcrumb, category `ui.click`, message = a `tag#id.class` selector (up to 3 classes). Element text and attribute values are never serialized. |
903
- | `history.pushState` / `replaceState` / `popstate` | Breadcrumb, type `navigation`, category `history`, `data: { from, to }` as paths. Same-path transitions are skipped. |
1030
+ | `history.pushState` / `replaceState` / `popstate` | Breadcrumb, type `navigation`, category `history`, `data: { from, to, operation }` paths plus the direction: `push`, `replace` or `pop`. Same-path transitions are skipped. |
904
1031
  | `fetch` | Breadcrumb, category `fetch`, message `METHOD url`, `data: { method, url, status_code }`, level `warning` for status >= 400. |
905
1032
  | `XMLHttpRequest.prototype.open` / `send` | Breadcrumb, category `xhr`, same shape as `fetch`. |
906
1033
  | `document` `visibilitychange` + window `pagehide` | Beacon flush of the pending batch on unload. |
@@ -913,6 +1040,16 @@ Opt-in:
913
1040
  | `performance: true` | A `navigation` transaction for the initial page load (Navigation Timing, captured on `load`), an `http` transaction per instrumented `fetch` (`name` = `METHOD /path`, `status` `ok`/`error`), and a `navigation` transaction per SPA route change measured over one animation frame. No-op when `document` is undefined. |
914
1041
  | `screenTracking: true` | Sets the screen to the new path on each SPA History navigation, which emits a `$screen` event on change. |
915
1042
 
1043
+ `operation` uses the same vocabulary as the Flutter SDK's
1044
+ `SauronNavigatorObserver` (`push` / `pop` / `replace` / `remove`), so a trail
1045
+ reads the same whichever SDK sent it. `remove` has no web equivalent and is
1046
+ never emitted here. One limit worth knowing: **a forward navigation is recorded
1047
+ as `pop`.** `history.forward()` fires the same `popstate` event as
1048
+ `history.back()` and carries nothing to separate them, so `pop` means "moved
1049
+ through history", not specifically "went back". Distinguishing them would mean
1050
+ writing to `history.state`, which the host app's router also owns — not a
1051
+ trade the SDK makes.
1052
+
916
1053
  Two guards keep the SDK from observing itself: a reentrancy flag held while SDK
917
1054
  code runs, and a denylist on the DSN host. Requests the transport makes are
918
1055
  therefore never turned into breadcrumbs or transactions. Wrappers are tagged, so
@@ -989,7 +1126,7 @@ Sauron.track('upgraded', {}, { tags: { tier: 'trial' } });
989
1126
 
990
1127
  ```html
991
1128
  <script type="module">
992
- import { Sauron } from 'https://esm.sh/@edraj/sauron-browser@1.4.1';
1129
+ import { Sauron } from 'https://esm.sh/@edraj/sauron-browser@1.6.0';
993
1130
  Sauron.init({ dsn: 'https://pk_test@ingest.example.com/42' });
994
1131
  </script>
995
1132
  ```
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.6.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";
@@ -817,12 +843,12 @@ function installHistory() {
817
843
  const loc = g2.location;
818
844
  if (!hist) return;
819
845
  let lastPath = toPath(loc?.href ?? null, loc?.href);
820
- const emit = (toHref) => {
846
+ const emit = (toHref, operation) => {
821
847
  const to = toPath(toHref, loc?.href);
822
848
  const from = lastPath;
823
849
  lastPath = to;
824
850
  if (from === to) return;
825
- withInternal(() => addNavigationBreadcrumb(from, to));
851
+ withInternal(() => addNavigationBreadcrumb(from, to, operation));
826
852
  if (to && navHandler) {
827
853
  try {
828
854
  navHandler(to);
@@ -833,11 +859,12 @@ function installHistory() {
833
859
  const wrap = (name) => {
834
860
  const original = hist[name];
835
861
  if (typeof original !== "function" || isWrapped(original)) return;
862
+ const operation = name === "pushState" ? "push" : "replace";
836
863
  hist[name] = markWrapped(function sauronHistory(...args) {
837
864
  const result = original.apply(this, args);
838
865
  if (!isInternal()) {
839
866
  const urlArg = args[2];
840
- emit(urlArg != null ? String(urlArg) : loc?.href ?? null);
867
+ emit(urlArg != null ? String(urlArg) : loc?.href ?? null, operation);
841
868
  }
842
869
  return result;
843
870
  });
@@ -849,7 +876,7 @@ function installHistory() {
849
876
  wrap("replaceState");
850
877
  if (typeof g2.addEventListener === "function") {
851
878
  const onPopState = () => {
852
- if (!isInternal()) emit(loc?.href ?? null);
879
+ if (!isInternal()) emit(loc?.href ?? null, "pop");
853
880
  };
854
881
  g2.addEventListener("popstate", onPopState);
855
882
  registerPatch("popstate", () => {
@@ -1026,7 +1053,7 @@ function normalizeOp(op) {
1026
1053
  return op && TRANSACTION_OPS.includes(op) ? op : "custom";
1027
1054
  }
1028
1055
  function buildTransactionItem(input, distinctId, sessionId2) {
1029
- return {
1056
+ const item = {
1030
1057
  type: "transaction",
1031
1058
  name: input.name,
1032
1059
  op: normalizeOp(input.op),
@@ -1039,6 +1066,11 @@ function buildTransactionItem(input, distinctId, sessionId2) {
1039
1066
  session_id: sessionId2,
1040
1067
  timestamp: nowIso()
1041
1068
  };
1069
+ if (input.tags && Object.keys(input.tags).length > 0) item.tags = { ...input.tags };
1070
+ if (input.extra && Object.keys(input.extra).length > 0) {
1071
+ item.extra = capTransactionExtra({ ...input.extra });
1072
+ }
1073
+ return item;
1042
1074
  }
1043
1075
  function trackTransaction(input) {
1044
1076
  const client = getClient();
@@ -2195,13 +2227,13 @@ function addBreadcrumb(input, hint) {
2195
2227
  if (!client) return;
2196
2228
  client.addBreadcrumb(normalizeBreadcrumb(input), hint);
2197
2229
  }
2198
- function addNavigationBreadcrumb(from, to) {
2230
+ function addNavigationBreadcrumb(from, to, operation = "push") {
2199
2231
  addBreadcrumb({
2200
2232
  type: "navigation",
2201
2233
  category: "history",
2202
2234
  level: "info",
2203
2235
  message: null,
2204
- data: { from, to }
2236
+ data: { from, to, operation }
2205
2237
  });
2206
2238
  }
2207
2239
 
@@ -2302,6 +2334,7 @@ var Sauron = {
2302
2334
  var index_default = Sauron;
2303
2335
 
2304
2336
  exports.DsnError = DsnError;
2337
+ exports.MAX_TRANSACTION_EXTRA_BYTES = MAX_TRANSACTION_EXTRA_BYTES;
2305
2338
  exports.SDK_NAME = SDK_NAME;
2306
2339
  exports.SDK_VERSION = SDK_VERSION;
2307
2340
  exports.Sauron = Sauron;
@@ -2309,6 +2342,7 @@ exports.SauronClient = SauronClient;
2309
2342
  exports.addBreadcrumb = addBreadcrumb2;
2310
2343
  exports.buildEnvelope = buildEnvelope;
2311
2344
  exports.cancelWorkflow = cancelWorkflow2;
2345
+ exports.capTransactionExtra = capTransactionExtra;
2312
2346
  exports.captureException = captureException2;
2313
2347
  exports.captureMessage = captureMessage2;
2314
2348
  exports.close = close;