@edraj/sauron-browser 1.3.0 → 1.4.1

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,93 @@
2
2
 
3
3
  All notable changes to `@edraj/sauron-browser` are documented here.
4
4
 
5
+ ## 1.4.1
6
+
7
+ ### Added
8
+
9
+ - **Auto-reset on identity switch.** `identify()` now detects a login by a
10
+ DIFFERENT user than last time on the same device — the common case of a
11
+ forgotten `reset()` on logout — and mints a fresh anonymous id (and rotates
12
+ the session id) before sending, so `anonymous_id` is `null` instead of an
13
+ alias to the previous person. This can't undo an alias already sent under
14
+ the old id — still call `reset()` on logout — but it bounds a missed
15
+ `reset()` to one corrupted guest window instead of every one after it. To
16
+ detect the switch, `identify()` persists a short one-way digest (never the
17
+ id itself; see `hashIdentity`) of the last identified user in `localStorage`
18
+ under `sauron.last_identified`. Like the anonymous id, this is a durable
19
+ first-party value stored on the user's terminal — a retention and consent
20
+ consequence, not just an implementation detail.
21
+
22
+ The stored value carries a format tag: `v1:<digest>`, byte-identical to what
23
+ the Flutter SDK writes under the same key. A value with no tag or an
24
+ unrecognised one reads as "nobody has identified on this device yet" and is
25
+ rewritten in the current format on the next `identify()`. That matters
26
+ because the digest's shape is not frozen — if it ever widens again, an
27
+ untagged store could not tell "a digest I no longer produce" from "a
28
+ different person", so every returning user's next `identify()` would be read
29
+ as a switch and would rotate their anonymous id and session, once, silently.
30
+ The tag turns that into one missed switch per device instead.
31
+
32
+ ### Changed
33
+
34
+ - `reset()` now also rotates the session id (`sauron.session_id`). The
35
+ server's `bump_session` is last-write-wins on `distinct_id`, so without
36
+ this a single `sessions` row could otherwise end up serially representing
37
+ two different people and recording only whichever wrote last.
38
+
39
+ ## 1.4.0
40
+
41
+ ### Fixed
42
+
43
+ - **`captureMessage()` poisoned the entire envelope.** It sent an `exception`
44
+ block with `type: null`, but the gateway's `ExceptionInfo.ty` is a
45
+ non-optional string with no default, so the envelope failed to deserialize and
46
+ was rejected whole (`400 invalid_envelope`) — taking every unrelated error,
47
+ event and transaction batched alongside it. Every SDK treats a 400 as
48
+ non-retryable, so the batch was dropped without a retry and without a trace.
49
+ A message item now carries no `exception` block at all and puts its text in
50
+ `message`, which is the shape the Python SDK already sent.
51
+
52
+ Worth knowing before you upgrade: this also changes server-side grouping.
53
+ Messages now fingerprint on their normalized text instead of piling into one
54
+ bucket keyed by a synthetic exception type, so existing message issues will
55
+ re-split into separate issues.
56
+ - **A failed offline-queue drain silently deleted the rest of the backlog.**
57
+ `drain()` empties `localStorage` in one shot, so from that point the parked
58
+ envelopes exist only in a local array — and a send failure re-parked just the
59
+ payload that had failed before returning, discarding every payload behind it.
60
+ The whole untried remainder is now re-parked, at the head, preserving the
61
+ oldest-first order the byte-cap eviction policy depends on. This is the plain
62
+ reconnect-then-one-500 case the queue exists for. A 401/403 mid-drain now
63
+ keeps the backlog too — the credentials may be fixed and the client
64
+ re-inited — and logs a warning instead of disabling in silence.
65
+
66
+ ### Changed
67
+
68
+ - The anonymous id is now persisted in `localStorage` under `sauron.anon_id`
69
+ instead of being re-minted in memory on every page load. **Every web app's
70
+ reported active-user count drops sharply and permanently on the day this is
71
+ adopted** — the old behaviour counted page loads, not people (a 5-10x
72
+ inflation, all of it in the "guest" half of the Active Users report). The
73
+ drop is a data artifact, not a regression.
74
+ - The anonymous id is a durable first-party identifier stored on the user's
75
+ terminal. That is a retention and consent consequence, not just an
76
+ implementation detail.
77
+ - `ExceptionValue.type` is `string`, no longer `string | null`, and `ErrorItem`
78
+ gained an optional `message`. A TypeScript caller that builds these by hand
79
+ will now see a type error where it previously compiled — passing `null` there
80
+ is precisely what produced the envelope rejection above.
81
+
82
+ ### Added
83
+
84
+ - `reset()` — clears the scope user and mints a fresh anonymous id.
85
+ **Call it on logout.** `setUser(null)` now calls it for you. Without it, the
86
+ next anonymous visitor on a shared browser reuses the persisted id and a
87
+ later `identify()` aliases their activity to the previous account,
88
+ server-side, permanently.
89
+ - `anonymous_id` is sent on the identify item only when the anonymous id was
90
+ actually used as a `distinct_id` in this browser session.
91
+
5
92
  ## 1.3.0
6
93
 
7
94
  - **Workflows** — bound a named span of activity with start / end / cancel, and
package/README.md CHANGED
@@ -92,7 +92,9 @@ Sauron.init({
92
92
  sampleRate: 0.5,
93
93
  maxBreadcrumbs: 100,
94
94
  beforeSend(item, hint) {
95
- if (item.type === 'error' && item.exception.value?.includes('token=')) {
95
+ // `exception` is optional a `captureMessage` item has none, and carries
96
+ // its text in `item.message` instead.
97
+ if (item.type === 'error' && item.exception?.value?.includes('token=')) {
96
98
  return null; // PII escape hatch
97
99
  }
98
100
  return item;
@@ -115,6 +117,42 @@ Sauron.init({
115
117
  });
116
118
  ```
117
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
+
118
156
  ## API reference
119
157
 
120
158
  Everything is exported both as a named function and as a member of the `Sauron`
@@ -221,13 +259,24 @@ function captureMessage(message: string, level?: Level, hint?: Hint): void
221
259
 
222
260
  | Parameter | Type | Default | Description |
223
261
  | --- | --- | --- | --- |
224
- | `message` | `string` | — (required) | Becomes `exception.value`; `exception.type` is `null`. |
262
+ | `message` | `string` | — (required) | Becomes the item's `message`. |
225
263
  | `level` | `Level` | `'info'` | Severity. |
226
- | `hint` | `Hint` | `undefined` | Only `fingerprint`, `event_id`, `message`, `tags`, `contexts` and `extra` are read here — unlike `captureException`, `hint.level`, `hint.mechanism` and `hint.screen` are ignored. |
227
-
228
- Emits an error item with mechanism `{ type: 'message', handled: true }`, an
229
- empty stack trace, the current breadcrumb trail and the current screen. Counts
230
- against `sampleRate` like any other error item. Returns `void`.
264
+ | `hint` | `Hint` | `undefined` | Only `fingerprint`, `event_id`, `tags`, `contexts` and `extra` are read here — unlike `captureException`, `hint.level`, `hint.mechanism` and `hint.screen` are ignored, and `hint.message` no longer applies because the `message` argument already occupies that field. |
265
+
266
+ Emits an error item that carries **no `exception` block at all** a message is
267
+ not an exception — plus the current breadcrumb trail and the current screen.
268
+ Server-side it groups on the message-fallback fingerprint
269
+ (`message` + the message text, normalized), so distinct messages become distinct
270
+ issues instead of piling into one bucket keyed by a synthetic exception type.
271
+ Counts against `sampleRate` like any other error item. Returns `void`.
272
+
273
+ > Through 1.3.0 this shipped `exception: { type: null, value: message }`. The
274
+ > gateway's exception type is a non-nullable string, so that item failed to
275
+ > deserialize and the whole envelope came back `400 invalid_envelope` — and since
276
+ > a 400 is a non-retryable drop, **every other item batched with it (up to
277
+ > `maxBatch`, default 30) was silently lost too**. If you have a `beforeSend`
278
+ > hook or any code reading `item.exception` on message items, note that the field
279
+ > is now absent and `item.message` carries the text.
231
280
 
232
281
  ```ts
233
282
  Sauron.captureMessage('payment provider returned a soft decline', 'warning', {
@@ -809,7 +858,7 @@ const appFrames = frames.filter((f) => isInAppFrame(f.filename));
809
858
 
810
859
  ```ts
811
860
  const SDK_NAME: string // 'sauron.javascript'
812
- const SDK_VERSION: string // '1.2.0'
861
+ const SDK_VERSION: string // '1.4.0'
813
862
  ```
814
863
 
815
864
  The SDK identity embedded in `header.sdk` of every envelope.
@@ -909,8 +958,15 @@ Other scope data:
909
958
  `captureException`.
910
959
  - **identity** — `device_id` persists in `localStorage` under
911
960
  `sauron.device_id`; `session_id` persists in `sessionStorage` under
912
- `sauron.session_id`. Both fall back to a per-process in-memory id when Web
913
- Storage is unavailable.
961
+ `sauron.session_id`; `identify()` additionally persists a short one-way
962
+ digest (never the id itself) of the last identified user in `localStorage`
963
+ under `sauron.last_identified`, used to detect a login by a different
964
+ person on a device where `reset()` was never wired — see "Reset on logout"
965
+ in the wiki. This is not a security boundary (an unkeyed hash over a
966
+ possibly low-entropy id, e.g. an email, is a confirmation oracle, not a
967
+ secret) — it exists only so the key isn't a second plaintext copy of the
968
+ app's user id. All fall back to a per-process in-memory id when Web Storage
969
+ is unavailable.
914
970
 
915
971
  ```ts
916
972
  Sauron.init({ dsn, tags: { tier: 'free' }, extra: { build: 'ci-42' } });
@@ -933,7 +989,7 @@ Sauron.track('upgraded', {}, { tags: { tier: 'trial' } });
933
989
 
934
990
  ```html
935
991
  <script type="module">
936
- import { Sauron } from 'https://esm.sh/@edraj/sauron-browser@1.2.0';
992
+ import { Sauron } from 'https://esm.sh/@edraj/sauron-browser@1.4.1';
937
993
  Sauron.init({ dsn: 'https://pk_test@ingest.example.com/42' });
938
994
  </script>
939
995
  ```
@@ -994,9 +1050,16 @@ is parked in the offline queue.
994
1050
  byte-capped at `maxQueueBytes` (default 1 MiB); the oldest entries are evicted
995
1051
  first and at least one entry is always kept. It is drained at `init()`, at the
996
1052
  start of every `flush()`, and on the window `online` event. If a drained
997
- envelope still fails, it is re-parked and draining stops to avoid a tight loop.
998
- When `localStorage` is unavailable the queue is disabled and failed envelopes
999
- are dropped.
1053
+ envelope still fails, **it and every envelope behind it are re-parked at the head
1054
+ of the queue** (order preserved) and draining stops to avoid a tight loop — a
1055
+ single 500 on reconnect costs you nothing. Same on a 401/403: the client
1056
+ disables itself but the backlog is kept, so fixing the key and re-`init()`ing
1057
+ still delivers it. When `localStorage` is unavailable the queue is disabled and
1058
+ failed envelopes are dropped.
1059
+
1060
+ > Through 1.3.0 the drain deleted the whole `localStorage` backlog up front and
1061
+ > re-parked only the one envelope that failed, so everything queued behind it was
1062
+ > lost — the exact reconnect-then-one-500 scenario the queue exists for.
1000
1063
 
1001
1064
  **Page unload.** On `visibilitychange` → `hidden` and on `pagehide`, the pending
1002
1065
  batch is chunked to 1000 items and handed to `navigator.sendBeacon` as an
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.3.0";
11
+ var SDK_VERSION = "1.4.1";
12
12
  function getGlobal() {
13
13
  return globalThis;
14
14
  }
@@ -91,6 +91,30 @@ function makeLogger(debug) {
91
91
  // src/identity.ts
92
92
  var DEVICE_ID_KEY = "sauron.device_id";
93
93
  var SESSION_ID_KEY = "sauron.session_id";
94
+ var ANON_ID_KEY = "sauron.anon_id";
95
+ var LAST_IDENTIFIED_KEY = "sauron.last_identified";
96
+ var LAST_IDENTIFIED_FORMAT = "v1";
97
+ function encodeLastIdentified(digest) {
98
+ return `${LAST_IDENTIFIED_FORMAT}:${digest}`;
99
+ }
100
+ function decodeLastIdentified(raw) {
101
+ if (raw === null) return null;
102
+ const sep = raw.indexOf(":");
103
+ if (sep < 0 || raw.slice(0, sep) !== LAST_IDENTIFIED_FORMAT) return null;
104
+ const digest = raw.slice(sep + 1);
105
+ return digest === "" ? null : digest;
106
+ }
107
+ function fnv1a32(s) {
108
+ let h = 2166136261;
109
+ for (let i = 0; i < s.length; i++) {
110
+ h ^= s.charCodeAt(i);
111
+ h = Math.imul(h, 16777619);
112
+ }
113
+ return (h >>> 0).toString(16).padStart(8, "0");
114
+ }
115
+ function hashIdentity(id) {
116
+ return fnv1a32(id) + fnv1a32("" + id);
117
+ }
94
118
  function webStorage(name) {
95
119
  try {
96
120
  const s = globalThis[name];
@@ -123,6 +147,8 @@ function persistentId(cached, storage, key) {
123
147
  }
124
148
  var deviceId = null;
125
149
  var sessionId = null;
150
+ var anonymousId = null;
151
+ var lastIdentified = null;
126
152
  function getDeviceId() {
127
153
  deviceId = persistentId(deviceId, webStorage("localStorage"), DEVICE_ID_KEY);
128
154
  return deviceId;
@@ -131,6 +157,82 @@ function getSessionId() {
131
157
  sessionId = persistentId(sessionId, webStorage("sessionStorage"), SESSION_ID_KEY);
132
158
  return sessionId;
133
159
  }
160
+ function rotateSessionId() {
161
+ sessionId = null;
162
+ const storage = webStorage("sessionStorage");
163
+ if (storage) {
164
+ try {
165
+ storage.removeItem(SESSION_ID_KEY);
166
+ } catch {
167
+ }
168
+ }
169
+ return getSessionId();
170
+ }
171
+ function getAnonymousId() {
172
+ if (anonymousId) return anonymousId;
173
+ const storage = webStorage("localStorage");
174
+ if (storage) {
175
+ try {
176
+ const existing = storage.getItem(ANON_ID_KEY);
177
+ if (existing) {
178
+ anonymousId = existing;
179
+ return anonymousId;
180
+ }
181
+ } catch {
182
+ }
183
+ }
184
+ const fresh = `anon_${uuidv4()}`;
185
+ if (storage) {
186
+ try {
187
+ storage.setItem(ANON_ID_KEY, fresh);
188
+ } catch {
189
+ }
190
+ }
191
+ anonymousId = fresh;
192
+ return anonymousId;
193
+ }
194
+ function resetAnonymousId() {
195
+ anonymousId = null;
196
+ const storage = webStorage("localStorage");
197
+ if (storage) {
198
+ try {
199
+ storage.removeItem(ANON_ID_KEY);
200
+ } catch {
201
+ }
202
+ }
203
+ return getAnonymousId();
204
+ }
205
+ function getLastIdentified() {
206
+ const storage = webStorage("localStorage");
207
+ if (!storage) return decodeLastIdentified(lastIdentified);
208
+ try {
209
+ const stored = storage.getItem(LAST_IDENTIFIED_KEY);
210
+ return decodeLastIdentified(stored ?? lastIdentified);
211
+ } catch {
212
+ return decodeLastIdentified(lastIdentified);
213
+ }
214
+ }
215
+ function setLastIdentified(id) {
216
+ const encoded = encodeLastIdentified(id);
217
+ lastIdentified = encoded;
218
+ const storage = webStorage("localStorage");
219
+ if (storage) {
220
+ try {
221
+ storage.setItem(LAST_IDENTIFIED_KEY, encoded);
222
+ } catch {
223
+ }
224
+ }
225
+ }
226
+ function clearLastIdentified() {
227
+ lastIdentified = null;
228
+ const storage = webStorage("localStorage");
229
+ if (storage) {
230
+ try {
231
+ storage.removeItem(LAST_IDENTIFIED_KEY);
232
+ } catch {
233
+ }
234
+ }
235
+ }
134
236
 
135
237
  // src/context.ts
136
238
  function getNavigator() {
@@ -628,12 +730,7 @@ function captureMessage(message, level = "info", hint) {
628
730
  type: "error",
629
731
  timestamp: nowIso(),
630
732
  level,
631
- exception: {
632
- type: null,
633
- value: message,
634
- mechanism: { type: "message", handled: true },
635
- stacktrace: []
636
- },
733
+ message,
637
734
  breadcrumbs,
638
735
  fingerprint: hint?.fingerprint ?? null,
639
736
  session_id: getSessionId(),
@@ -782,13 +879,31 @@ var Scope = class {
782
879
  this.maxBreadcrumbs = Math.max(0, max);
783
880
  this.trim();
784
881
  }
882
+ /**
883
+ * Replace the scope user.
884
+ *
885
+ * `id` is coerced with `String()` for the same reason `SauronClient.
886
+ * prepareIdentify` coerces its own — a plain-JS caller can (and does) pass
887
+ * `setUser({ id: user.id })` where `user.id` is a number, and TypeScript
888
+ * cannot stop them. This path is the one that BYPASSES `identify()`'s
889
+ * coercion entirely, and the consequence is not cosmetic: the scope user
890
+ * lands in the envelope context, where the server's `distinct_id` is a
891
+ * non-`Option` Rust `String`. A JSON number there fails deserialization of
892
+ * the ENVELOPE, not of the one field — so the whole batch 400s, and a 400
893
+ * is non-retryable, so every event in it is dropped for good.
894
+ *
895
+ * Rebuilding the whole object (rather than merging into the existing one)
896
+ * is deliberate and is the behaviour the Flutter SDK was fixed to match:
897
+ * `email` and `traits` come from the input alone, so setting a new user
898
+ * never inherits the previous person's contact details.
899
+ */
785
900
  setUser(user) {
786
901
  if (user === null) {
787
902
  this.user = null;
788
903
  return;
789
904
  }
790
905
  this.user = {
791
- id: user.id ?? null,
906
+ id: user.id === null || user.id === void 0 ? null : String(user.id),
792
907
  email: user.email ?? null,
793
908
  traits: user.traits ?? {}
794
909
  };
@@ -889,12 +1004,13 @@ function setScreen(name) {
889
1004
  function identify(id, traits = {}) {
890
1005
  const client = getClient();
891
1006
  if (!client) return;
892
- const anonymousId = client.getAnonymousId();
893
- client.getScope().setUser({ id, traits });
1007
+ const distinctId = String(id);
1008
+ const anonymousId2 = client.prepareIdentify(distinctId);
1009
+ client.getScope().setUser({ id: distinctId, traits });
894
1010
  const item = {
895
1011
  type: "identify",
896
- distinct_id: id,
897
- anonymous_id: anonymousId,
1012
+ distinct_id: distinctId,
1013
+ anonymous_id: anonymousId2,
898
1014
  traits: traits ?? {}
899
1015
  };
900
1016
  client.captureItem(item);
@@ -1399,6 +1515,21 @@ var OfflineQueue = class {
1399
1515
  if (entries.length) this.write([]);
1400
1516
  return entries;
1401
1517
  }
1518
+ /**
1519
+ * Put drained payloads BACK at the head, keeping their relative order.
1520
+ *
1521
+ * The counterpart to {@link drain}: a drain empties the store immediately, so
1522
+ * whatever the caller could not deliver only exists in its local array and is
1523
+ * lost the moment the caller returns. Re-parking at the head (rather than via
1524
+ * {@link enqueue}) keeps the queue oldest-first, which is what the byte-cap
1525
+ * eviction policy assumes — the oldest entry must stay the first one evicted.
1526
+ */
1527
+ requeueFront(payloads) {
1528
+ if (!this.storage || payloads.length === 0) return;
1529
+ const entries = [...payloads, ...this.read()];
1530
+ this.evict(entries);
1531
+ this.write(entries);
1532
+ }
1402
1533
  /** Non-destructive read of the current entries, oldest first. */
1403
1534
  peek() {
1404
1535
  return this.read();
@@ -1584,11 +1715,21 @@ var Transport = class {
1584
1715
  }
1585
1716
  }
1586
1717
  }
1587
- /** Re-attempt any envelopes that were parked while offline. */
1718
+ /**
1719
+ * Re-attempt any envelopes that were parked while offline.
1720
+ *
1721
+ * `drain()` empties `localStorage` in one shot, so from here on the ONLY copy
1722
+ * of the backlog is the local `payloads` array. Every early return therefore
1723
+ * has to re-park the whole untried remainder, not just the payload that
1724
+ * failed: this used to re-park the failing one and return, which silently
1725
+ * deleted every payload behind it — the exact reconnect-then-one-500 case the
1726
+ * queue exists for.
1727
+ */
1588
1728
  async drainOfflineQueue() {
1589
1729
  if (this.disabled || !this.offline.available) return;
1590
1730
  const payloads = this.offline.drain();
1591
- for (const json of payloads) {
1731
+ for (let i = 0; i < payloads.length; i++) {
1732
+ const json = payloads[i];
1592
1733
  let outcome;
1593
1734
  try {
1594
1735
  outcome = await this.post(json);
@@ -1596,13 +1737,14 @@ var Transport = class {
1596
1737
  outcome = { action: "retry_backoff" };
1597
1738
  }
1598
1739
  if (outcome.action === "disable") {
1740
+ this.logger.warn("server rejected credentials while draining; disabling client");
1599
1741
  this.disable();
1600
1742
  this.onDisable();
1601
- this.offline.enqueue(json);
1743
+ this.offline.requeueFront(payloads.slice(i));
1602
1744
  return;
1603
1745
  }
1604
1746
  if (outcome.action === "retry_after" || outcome.action === "retry_backoff") {
1605
- this.offline.enqueue(json);
1747
+ this.offline.requeueFront(payloads.slice(i));
1606
1748
  return;
1607
1749
  }
1608
1750
  }
@@ -1712,8 +1854,17 @@ var SauronClient = class {
1712
1854
  __publicField(this, "nativeFetch");
1713
1855
  __publicField(this, "enabled", true);
1714
1856
  __publicField(this, "installed", false);
1715
- __publicField(this, "anonymousId", null);
1716
1857
  __publicField(this, "beaconCleanup", null);
1858
+ /**
1859
+ * Whether the anonymous id has actually been USED as a `distinct_id` in this
1860
+ * browser session.
1861
+ *
1862
+ * A persisted id that has never been observed anonymously must not create a
1863
+ * permanent `identities` alias row on the server: aliasing is a durable
1864
+ * server-side binding of this browser profile to a named user, and an
1865
+ * identify() on a first-ever page load has no anonymous history to link.
1866
+ */
1867
+ __publicField(this, "anonUsed", false);
1717
1868
  this.options = options;
1718
1869
  this.dsn = parseDsn(options.dsn);
1719
1870
  this.logger = makeLogger(options.debug);
@@ -1780,15 +1931,63 @@ var SauronClient = class {
1780
1931
  getDistinctId() {
1781
1932
  const user = this.scope.getUser();
1782
1933
  if (user.id) return user.id;
1783
- return this.ensureAnonymousId();
1934
+ this.anonUsed = true;
1935
+ return getAnonymousId();
1784
1936
  }
1785
- /** The anonymous id, or null if one was never needed. */
1937
+ /** The anonymous id, or null when it was never actually used as an identity. */
1786
1938
  getAnonymousId() {
1787
- return this.anonymousId;
1939
+ return this.anonUsed ? getAnonymousId() : null;
1788
1940
  }
1789
- ensureAnonymousId() {
1790
- if (!this.anonymousId) this.anonymousId = `anon_${uuidv4()}`;
1791
- return this.anonymousId;
1941
+ /**
1942
+ * Forget the current person: clear the scope user, mint a fresh anonymous
1943
+ * id, forget the last identified user, and rotate the session id.
1944
+ *
1945
+ * MUST BE CALLED ON LOGOUT. Without it, the next anonymous visitor on this
1946
+ * browser reuses the persisted anon id, and a later identify() aliases their
1947
+ * activity to the previous account server-side, permanently. Rotating the
1948
+ * session id matters too: the server's `bump_session` is last-write-wins on
1949
+ * `distinct_id`, so without rotation one `sessions` row could otherwise
1950
+ * serially represent two different people and record only whichever wrote
1951
+ * last.
1952
+ */
1953
+ reset() {
1954
+ this.scope.setUser(null);
1955
+ resetAnonymousId();
1956
+ clearLastIdentified();
1957
+ rotateSessionId();
1958
+ this.anonUsed = false;
1959
+ }
1960
+ /**
1961
+ * Prepare for an `identify()`; returns the `anonymous_id` to send.
1962
+ *
1963
+ * When a DIFFERENT user identifies than last time, the current anon id
1964
+ * belongs to the previous person and is already burned server-side, so it is
1965
+ * replaced before anything else happens and `null` is sent instead of a
1966
+ * cross-user alias. This cannot repair events already sent under the burned
1967
+ * alias — nothing can — but it bounds a forgotten `reset()` to one guest
1968
+ * window instead of every future one.
1969
+ *
1970
+ * `id` is coerced with `String()` before comparing/persisting: a plain-JS
1971
+ * caller can pass a number (`Sauron.identify(user.id)`), and `Storage`
1972
+ * itself applies `ToString` on write — so comparing an un-coerced `id`
1973
+ * against a value that already round-tripped through storage would treat
1974
+ * the SAME numeric user as a switch on every single call. The comparison
1975
+ * against `last` is an explicit `!== null` (not a truthiness check) so an
1976
+ * app that (unusually) identifies with `''` still has a later, different id
1977
+ * correctly detected as a real switch — a falsy string is not "no identity
1978
+ * yet". `last`/the persisted value are digests, not the raw id — see
1979
+ * `hashIdentity`.
1980
+ */
1981
+ prepareIdentify(id) {
1982
+ const digest = hashIdentity(String(id));
1983
+ const last = getLastIdentified();
1984
+ if (last !== null && last !== digest) {
1985
+ resetAnonymousId();
1986
+ rotateSessionId();
1987
+ this.anonUsed = false;
1988
+ }
1989
+ setLastIdentified(digest);
1990
+ return this.getAnonymousId();
1792
1991
  }
1793
1992
  /** Stamp a fresh envelope (new `sent_at`, current context) around `items`. */
1794
1993
  makeEnvelope(items) {
@@ -2047,8 +2246,15 @@ function addBreadcrumb2(breadcrumb, hint) {
2047
2246
  addBreadcrumb(breadcrumb, hint);
2048
2247
  }
2049
2248
  function setUser(user) {
2249
+ if (user === null) {
2250
+ getClient()?.reset();
2251
+ return;
2252
+ }
2050
2253
  getClient()?.getScope().setUser(user);
2051
2254
  }
2255
+ function reset() {
2256
+ getClient()?.reset();
2257
+ }
2052
2258
  function setTag(key, value) {
2053
2259
  getClient()?.getScope().setTag(key, value);
2054
2260
  }
@@ -2078,6 +2284,7 @@ var Sauron = {
2078
2284
  identify: identify2,
2079
2285
  addBreadcrumb: addBreadcrumb2,
2080
2286
  setUser,
2287
+ reset,
2081
2288
  setTag,
2082
2289
  setTags,
2083
2290
  setContext,
@@ -2117,6 +2324,7 @@ exports.isInAppFrame = isInAppFrame;
2117
2324
  exports.parseDsn = parseDsn;
2118
2325
  exports.parseError = parseError;
2119
2326
  exports.parseStackString = parseStackString;
2327
+ exports.reset = reset;
2120
2328
  exports.setContext = setContext;
2121
2329
  exports.setExtra = setExtra;
2122
2330
  exports.setScreen = setScreen2;