@edraj/sauron-browser 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,79 @@
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
+
44
+ ## 1.4.1
45
+
46
+ ### Added
47
+
48
+ - **Auto-reset on identity switch.** `identify()` now detects a login by a
49
+ DIFFERENT user than last time on the same device — the common case of a
50
+ forgotten `reset()` on logout — and mints a fresh anonymous id (and rotates
51
+ the session id) before sending, so `anonymous_id` is `null` instead of an
52
+ alias to the previous person. This can't undo an alias already sent under
53
+ the old id — still call `reset()` on logout — but it bounds a missed
54
+ `reset()` to one corrupted guest window instead of every one after it. To
55
+ detect the switch, `identify()` persists a short one-way digest (never the
56
+ id itself; see `hashIdentity`) of the last identified user in `localStorage`
57
+ under `sauron.last_identified`. Like the anonymous id, this is a durable
58
+ first-party value stored on the user's terminal — a retention and consent
59
+ consequence, not just an implementation detail.
60
+
61
+ The stored value carries a format tag: `v1:<digest>`, byte-identical to what
62
+ the Flutter SDK writes under the same key. A value with no tag or an
63
+ unrecognised one reads as "nobody has identified on this device yet" and is
64
+ rewritten in the current format on the next `identify()`. That matters
65
+ because the digest's shape is not frozen — if it ever widens again, an
66
+ untagged store could not tell "a digest I no longer produce" from "a
67
+ different person", so every returning user's next `identify()` would be read
68
+ as a switch and would rotate their anonymous id and session, once, silently.
69
+ The tag turns that into one missed switch per device instead.
70
+
71
+ ### Changed
72
+
73
+ - `reset()` now also rotates the session id (`sauron.session_id`). The
74
+ server's `bump_session` is last-write-wins on `distinct_id`, so without
75
+ this a single `sessions` row could otherwise end up serially representing
76
+ two different people and recording only whichever wrote last.
77
+
5
78
  ## 1.4.0
6
79
 
7
80
  ### Fixed
package/README.md CHANGED
@@ -117,6 +117,42 @@ Sauron.init({
117
117
  });
118
118
  ```
119
119
 
120
+ ## Funnels
121
+
122
+ Funnels track the conversion rate of users progressing through a defined sequence of steps. By tracking a unique event at each step, the Sauron dashboard can visualize where users drop off.
123
+
124
+ ```ts
125
+ // 1. User arrives at the pricing page
126
+ Sauron.track('pricing_viewed');
127
+
128
+ // 2. User clicks on a plan
129
+ Sauron.track('plan_selected', { plan: 'pro' });
130
+
131
+ // 3. User successfully checks out
132
+ Sauron.track('checkout_completed', { plan: 'pro', value: 42.5 });
133
+ ```
134
+
135
+ ## User Journeys
136
+
137
+ User journeys track the broader path a user takes through your application. Combine `setScreen` (to track navigation) and `startWorkflow` (to group a multi-step process) to see exactly how a user reached an outcome or encountered an error.
138
+
139
+ ```ts
140
+ // Update the screen when the user navigates
141
+ Sauron.setScreen('/onboarding/step1');
142
+
143
+ // Start a workflow to group all subsequent events and errors
144
+ Sauron.startWorkflow('user_onboarding');
145
+
146
+ // Track specific actions within the journey
147
+ Sauron.track('profile_photo_uploaded');
148
+
149
+ Sauron.setScreen('/onboarding/step2');
150
+ Sauron.track('preferences_saved');
151
+
152
+ // End the workflow when the journey concludes
153
+ Sauron.endWorkflow();
154
+ ```
155
+
120
156
  ## API reference
121
157
 
122
158
  Everything is exported both as a named function and as a member of the `Sauron`
@@ -322,10 +358,28 @@ function trackTransaction(input: TransactionInput): void
322
358
  | `httpMethod` | `string \| null` | `null` | For `http` ops. |
323
359
  | `httpStatus` | `number \| null` | `null` | For `http` ops. |
324
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`. |
325
363
 
326
364
  The item is stamped with the current distinct id, session id and timestamp.
327
365
  Never sampled. Returns `void`.
328
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
+
329
383
  ```ts
330
384
  const started = performance.now();
331
385
  const res = await fetch('/api/orders');
@@ -340,6 +394,115 @@ Sauron.trackTransaction({
340
394
  });
341
395
  ```
342
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
+
343
506
  ### `setScreen(name)`
344
507
 
345
508
  ```ts
@@ -822,7 +985,7 @@ const appFrames = frames.filter((f) => isInAppFrame(f.filename));
822
985
 
823
986
  ```ts
824
987
  const SDK_NAME: string // 'sauron.javascript'
825
- const SDK_VERSION: string // '1.4.0'
988
+ const SDK_VERSION: string // '1.5.0'
826
989
  ```
827
990
 
828
991
  The SDK identity embedded in `header.sdk` of every envelope.
@@ -922,8 +1085,15 @@ Other scope data:
922
1085
  `captureException`.
923
1086
  - **identity** — `device_id` persists in `localStorage` under
924
1087
  `sauron.device_id`; `session_id` persists in `sessionStorage` under
925
- `sauron.session_id`. Both fall back to a per-process in-memory id when Web
926
- Storage is unavailable.
1088
+ `sauron.session_id`; `identify()` additionally persists a short one-way
1089
+ digest (never the id itself) of the last identified user in `localStorage`
1090
+ under `sauron.last_identified`, used to detect a login by a different
1091
+ person on a device where `reset()` was never wired — see "Reset on logout"
1092
+ in the wiki. This is not a security boundary (an unkeyed hash over a
1093
+ possibly low-entropy id, e.g. an email, is a confirmation oracle, not a
1094
+ secret) — it exists only so the key isn't a second plaintext copy of the
1095
+ app's user id. All fall back to a per-process in-memory id when Web Storage
1096
+ is unavailable.
927
1097
 
928
1098
  ```ts
929
1099
  Sauron.init({ dsn, tags: { tier: 'free' }, extra: { build: 'ci-42' } });
@@ -946,7 +1116,7 @@ Sauron.track('upgraded', {}, { tags: { tier: 'trial' } });
946
1116
 
947
1117
  ```html
948
1118
  <script type="module">
949
- import { Sauron } from 'https://esm.sh/@edraj/sauron-browser@1.4.0';
1119
+ import { Sauron } from 'https://esm.sh/@edraj/sauron-browser@1.5.0';
950
1120
  Sauron.init({ dsn: 'https://pk_test@ingest.example.com/42' });
951
1121
  </script>
952
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.0";
11
+ var SDK_VERSION = "1.5.0";
12
12
  function getGlobal() {
13
13
  return globalThis;
14
14
  }
@@ -87,11 +87,60 @@ 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";
93
119
  var SESSION_ID_KEY = "sauron.session_id";
94
120
  var ANON_ID_KEY = "sauron.anon_id";
121
+ var LAST_IDENTIFIED_KEY = "sauron.last_identified";
122
+ var LAST_IDENTIFIED_FORMAT = "v1";
123
+ function encodeLastIdentified(digest) {
124
+ return `${LAST_IDENTIFIED_FORMAT}:${digest}`;
125
+ }
126
+ function decodeLastIdentified(raw) {
127
+ if (raw === null) return null;
128
+ const sep = raw.indexOf(":");
129
+ if (sep < 0 || raw.slice(0, sep) !== LAST_IDENTIFIED_FORMAT) return null;
130
+ const digest = raw.slice(sep + 1);
131
+ return digest === "" ? null : digest;
132
+ }
133
+ function fnv1a32(s) {
134
+ let h = 2166136261;
135
+ for (let i = 0; i < s.length; i++) {
136
+ h ^= s.charCodeAt(i);
137
+ h = Math.imul(h, 16777619);
138
+ }
139
+ return (h >>> 0).toString(16).padStart(8, "0");
140
+ }
141
+ function hashIdentity(id) {
142
+ return fnv1a32(id) + fnv1a32("" + id);
143
+ }
95
144
  function webStorage(name) {
96
145
  try {
97
146
  const s = globalThis[name];
@@ -125,6 +174,7 @@ function persistentId(cached, storage, key) {
125
174
  var deviceId = null;
126
175
  var sessionId = null;
127
176
  var anonymousId = null;
177
+ var lastIdentified = null;
128
178
  function getDeviceId() {
129
179
  deviceId = persistentId(deviceId, webStorage("localStorage"), DEVICE_ID_KEY);
130
180
  return deviceId;
@@ -133,6 +183,17 @@ function getSessionId() {
133
183
  sessionId = persistentId(sessionId, webStorage("sessionStorage"), SESSION_ID_KEY);
134
184
  return sessionId;
135
185
  }
186
+ function rotateSessionId() {
187
+ sessionId = null;
188
+ const storage = webStorage("sessionStorage");
189
+ if (storage) {
190
+ try {
191
+ storage.removeItem(SESSION_ID_KEY);
192
+ } catch {
193
+ }
194
+ }
195
+ return getSessionId();
196
+ }
136
197
  function getAnonymousId() {
137
198
  if (anonymousId) return anonymousId;
138
199
  const storage = webStorage("localStorage");
@@ -167,6 +228,37 @@ function resetAnonymousId() {
167
228
  }
168
229
  return getAnonymousId();
169
230
  }
231
+ function getLastIdentified() {
232
+ const storage = webStorage("localStorage");
233
+ if (!storage) return decodeLastIdentified(lastIdentified);
234
+ try {
235
+ const stored = storage.getItem(LAST_IDENTIFIED_KEY);
236
+ return decodeLastIdentified(stored ?? lastIdentified);
237
+ } catch {
238
+ return decodeLastIdentified(lastIdentified);
239
+ }
240
+ }
241
+ function setLastIdentified(id) {
242
+ const encoded = encodeLastIdentified(id);
243
+ lastIdentified = encoded;
244
+ const storage = webStorage("localStorage");
245
+ if (storage) {
246
+ try {
247
+ storage.setItem(LAST_IDENTIFIED_KEY, encoded);
248
+ } catch {
249
+ }
250
+ }
251
+ }
252
+ function clearLastIdentified() {
253
+ lastIdentified = null;
254
+ const storage = webStorage("localStorage");
255
+ if (storage) {
256
+ try {
257
+ storage.removeItem(LAST_IDENTIFIED_KEY);
258
+ } catch {
259
+ }
260
+ }
261
+ }
170
262
 
171
263
  // src/context.ts
172
264
  function getNavigator() {
@@ -813,13 +905,31 @@ var Scope = class {
813
905
  this.maxBreadcrumbs = Math.max(0, max);
814
906
  this.trim();
815
907
  }
908
+ /**
909
+ * Replace the scope user.
910
+ *
911
+ * `id` is coerced with `String()` for the same reason `SauronClient.
912
+ * prepareIdentify` coerces its own — a plain-JS caller can (and does) pass
913
+ * `setUser({ id: user.id })` where `user.id` is a number, and TypeScript
914
+ * cannot stop them. This path is the one that BYPASSES `identify()`'s
915
+ * coercion entirely, and the consequence is not cosmetic: the scope user
916
+ * lands in the envelope context, where the server's `distinct_id` is a
917
+ * non-`Option` Rust `String`. A JSON number there fails deserialization of
918
+ * the ENVELOPE, not of the one field — so the whole batch 400s, and a 400
919
+ * is non-retryable, so every event in it is dropped for good.
920
+ *
921
+ * Rebuilding the whole object (rather than merging into the existing one)
922
+ * is deliberate and is the behaviour the Flutter SDK was fixed to match:
923
+ * `email` and `traits` come from the input alone, so setting a new user
924
+ * never inherits the previous person's contact details.
925
+ */
816
926
  setUser(user) {
817
927
  if (user === null) {
818
928
  this.user = null;
819
929
  return;
820
930
  }
821
931
  this.user = {
822
- id: user.id ?? null,
932
+ id: user.id === null || user.id === void 0 ? null : String(user.id),
823
933
  email: user.email ?? null,
824
934
  traits: user.traits ?? {}
825
935
  };
@@ -920,11 +1030,12 @@ function setScreen(name) {
920
1030
  function identify(id, traits = {}) {
921
1031
  const client = getClient();
922
1032
  if (!client) return;
923
- const anonymousId2 = client.getAnonymousId();
924
- client.getScope().setUser({ id, traits });
1033
+ const distinctId = String(id);
1034
+ const anonymousId2 = client.prepareIdentify(distinctId);
1035
+ client.getScope().setUser({ id: distinctId, traits });
925
1036
  const item = {
926
1037
  type: "identify",
927
- distinct_id: id,
1038
+ distinct_id: distinctId,
928
1039
  anonymous_id: anonymousId2,
929
1040
  traits: traits ?? {}
930
1041
  };
@@ -941,7 +1052,7 @@ function normalizeOp(op) {
941
1052
  return op && TRANSACTION_OPS.includes(op) ? op : "custom";
942
1053
  }
943
1054
  function buildTransactionItem(input, distinctId, sessionId2) {
944
- return {
1055
+ const item = {
945
1056
  type: "transaction",
946
1057
  name: input.name,
947
1058
  op: normalizeOp(input.op),
@@ -954,6 +1065,11 @@ function buildTransactionItem(input, distinctId, sessionId2) {
954
1065
  session_id: sessionId2,
955
1066
  timestamp: nowIso()
956
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;
957
1073
  }
958
1074
  function trackTransaction(input) {
959
1075
  const client = getClient();
@@ -1854,18 +1970,56 @@ var SauronClient = class {
1854
1970
  return this.anonUsed ? getAnonymousId() : null;
1855
1971
  }
1856
1972
  /**
1857
- * Forget the current person: clear the scope user and mint a fresh anonymous
1858
- * id.
1973
+ * Forget the current person: clear the scope user, mint a fresh anonymous
1974
+ * id, forget the last identified user, and rotate the session id.
1859
1975
  *
1860
1976
  * MUST BE CALLED ON LOGOUT. Without it, the next anonymous visitor on this
1861
1977
  * browser reuses the persisted anon id, and a later identify() aliases their
1862
- * activity to the previous account server-side, permanently.
1978
+ * activity to the previous account server-side, permanently. Rotating the
1979
+ * session id matters too: the server's `bump_session` is last-write-wins on
1980
+ * `distinct_id`, so without rotation one `sessions` row could otherwise
1981
+ * serially represent two different people and record only whichever wrote
1982
+ * last.
1863
1983
  */
1864
1984
  reset() {
1865
1985
  this.scope.setUser(null);
1866
1986
  resetAnonymousId();
1987
+ clearLastIdentified();
1988
+ rotateSessionId();
1867
1989
  this.anonUsed = false;
1868
1990
  }
1991
+ /**
1992
+ * Prepare for an `identify()`; returns the `anonymous_id` to send.
1993
+ *
1994
+ * When a DIFFERENT user identifies than last time, the current anon id
1995
+ * belongs to the previous person and is already burned server-side, so it is
1996
+ * replaced before anything else happens and `null` is sent instead of a
1997
+ * cross-user alias. This cannot repair events already sent under the burned
1998
+ * alias — nothing can — but it bounds a forgotten `reset()` to one guest
1999
+ * window instead of every future one.
2000
+ *
2001
+ * `id` is coerced with `String()` before comparing/persisting: a plain-JS
2002
+ * caller can pass a number (`Sauron.identify(user.id)`), and `Storage`
2003
+ * itself applies `ToString` on write — so comparing an un-coerced `id`
2004
+ * against a value that already round-tripped through storage would treat
2005
+ * the SAME numeric user as a switch on every single call. The comparison
2006
+ * against `last` is an explicit `!== null` (not a truthiness check) so an
2007
+ * app that (unusually) identifies with `''` still has a later, different id
2008
+ * correctly detected as a real switch — a falsy string is not "no identity
2009
+ * yet". `last`/the persisted value are digests, not the raw id — see
2010
+ * `hashIdentity`.
2011
+ */
2012
+ prepareIdentify(id) {
2013
+ const digest = hashIdentity(String(id));
2014
+ const last = getLastIdentified();
2015
+ if (last !== null && last !== digest) {
2016
+ resetAnonymousId();
2017
+ rotateSessionId();
2018
+ this.anonUsed = false;
2019
+ }
2020
+ setLastIdentified(digest);
2021
+ return this.getAnonymousId();
2022
+ }
1869
2023
  /** Stamp a fresh envelope (new `sent_at`, current context) around `items`. */
1870
2024
  makeEnvelope(items) {
1871
2025
  const header = {
@@ -2179,6 +2333,7 @@ var Sauron = {
2179
2333
  var index_default = Sauron;
2180
2334
 
2181
2335
  exports.DsnError = DsnError;
2336
+ exports.MAX_TRANSACTION_EXTRA_BYTES = MAX_TRANSACTION_EXTRA_BYTES;
2182
2337
  exports.SDK_NAME = SDK_NAME;
2183
2338
  exports.SDK_VERSION = SDK_VERSION;
2184
2339
  exports.Sauron = Sauron;
@@ -2186,6 +2341,7 @@ exports.SauronClient = SauronClient;
2186
2341
  exports.addBreadcrumb = addBreadcrumb2;
2187
2342
  exports.buildEnvelope = buildEnvelope;
2188
2343
  exports.cancelWorkflow = cancelWorkflow2;
2344
+ exports.capTransactionExtra = capTransactionExtra;
2189
2345
  exports.captureException = captureException2;
2190
2346
  exports.captureMessage = captureMessage2;
2191
2347
  exports.close = close;