@edraj/sauron-browser 1.3.0 → 1.4.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,59 @@
2
2
 
3
3
  All notable changes to `@edraj/sauron-browser` are documented here.
4
4
 
5
+ ## 1.4.0
6
+
7
+ ### Fixed
8
+
9
+ - **`captureMessage()` poisoned the entire envelope.** It sent an `exception`
10
+ block with `type: null`, but the gateway's `ExceptionInfo.ty` is a
11
+ non-optional string with no default, so the envelope failed to deserialize and
12
+ was rejected whole (`400 invalid_envelope`) — taking every unrelated error,
13
+ event and transaction batched alongside it. Every SDK treats a 400 as
14
+ non-retryable, so the batch was dropped without a retry and without a trace.
15
+ A message item now carries no `exception` block at all and puts its text in
16
+ `message`, which is the shape the Python SDK already sent.
17
+
18
+ Worth knowing before you upgrade: this also changes server-side grouping.
19
+ Messages now fingerprint on their normalized text instead of piling into one
20
+ bucket keyed by a synthetic exception type, so existing message issues will
21
+ re-split into separate issues.
22
+ - **A failed offline-queue drain silently deleted the rest of the backlog.**
23
+ `drain()` empties `localStorage` in one shot, so from that point the parked
24
+ envelopes exist only in a local array — and a send failure re-parked just the
25
+ payload that had failed before returning, discarding every payload behind it.
26
+ The whole untried remainder is now re-parked, at the head, preserving the
27
+ oldest-first order the byte-cap eviction policy depends on. This is the plain
28
+ reconnect-then-one-500 case the queue exists for. A 401/403 mid-drain now
29
+ keeps the backlog too — the credentials may be fixed and the client
30
+ re-inited — and logs a warning instead of disabling in silence.
31
+
32
+ ### Changed
33
+
34
+ - The anonymous id is now persisted in `localStorage` under `sauron.anon_id`
35
+ instead of being re-minted in memory on every page load. **Every web app's
36
+ reported active-user count drops sharply and permanently on the day this is
37
+ adopted** — the old behaviour counted page loads, not people (a 5-10x
38
+ inflation, all of it in the "guest" half of the Active Users report). The
39
+ drop is a data artifact, not a regression.
40
+ - The anonymous id is a durable first-party identifier stored on the user's
41
+ terminal. That is a retention and consent consequence, not just an
42
+ implementation detail.
43
+ - `ExceptionValue.type` is `string`, no longer `string | null`, and `ErrorItem`
44
+ gained an optional `message`. A TypeScript caller that builds these by hand
45
+ will now see a type error where it previously compiled — passing `null` there
46
+ is precisely what produced the envelope rejection above.
47
+
48
+ ### Added
49
+
50
+ - `reset()` — clears the scope user and mints a fresh anonymous id.
51
+ **Call it on logout.** `setUser(null)` now calls it for you. Without it, the
52
+ next anonymous visitor on a shared browser reuses the persisted id and a
53
+ later `identify()` aliases their activity to the previous account,
54
+ server-side, permanently.
55
+ - `anonymous_id` is sent on the identify item only when the anonymous id was
56
+ actually used as a `distinct_id` in this browser session.
57
+
5
58
  ## 1.3.0
6
59
 
7
60
  - **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;
@@ -221,13 +223,24 @@ function captureMessage(message: string, level?: Level, hint?: Hint): void
221
223
 
222
224
  | Parameter | Type | Default | Description |
223
225
  | --- | --- | --- | --- |
224
- | `message` | `string` | — (required) | Becomes `exception.value`; `exception.type` is `null`. |
226
+ | `message` | `string` | — (required) | Becomes the item's `message`. |
225
227
  | `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`.
228
+ | `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. |
229
+
230
+ Emits an error item that carries **no `exception` block at all** a message is
231
+ not an exception — plus the current breadcrumb trail and the current screen.
232
+ Server-side it groups on the message-fallback fingerprint
233
+ (`message` + the message text, normalized), so distinct messages become distinct
234
+ issues instead of piling into one bucket keyed by a synthetic exception type.
235
+ Counts against `sampleRate` like any other error item. Returns `void`.
236
+
237
+ > Through 1.3.0 this shipped `exception: { type: null, value: message }`. The
238
+ > gateway's exception type is a non-nullable string, so that item failed to
239
+ > deserialize and the whole envelope came back `400 invalid_envelope` — and since
240
+ > a 400 is a non-retryable drop, **every other item batched with it (up to
241
+ > `maxBatch`, default 30) was silently lost too**. If you have a `beforeSend`
242
+ > hook or any code reading `item.exception` on message items, note that the field
243
+ > is now absent and `item.message` carries the text.
231
244
 
232
245
  ```ts
233
246
  Sauron.captureMessage('payment provider returned a soft decline', 'warning', {
@@ -809,7 +822,7 @@ const appFrames = frames.filter((f) => isInAppFrame(f.filename));
809
822
 
810
823
  ```ts
811
824
  const SDK_NAME: string // 'sauron.javascript'
812
- const SDK_VERSION: string // '1.2.0'
825
+ const SDK_VERSION: string // '1.4.0'
813
826
  ```
814
827
 
815
828
  The SDK identity embedded in `header.sdk` of every envelope.
@@ -933,7 +946,7 @@ Sauron.track('upgraded', {}, { tags: { tier: 'trial' } });
933
946
 
934
947
  ```html
935
948
  <script type="module">
936
- import { Sauron } from 'https://esm.sh/@edraj/sauron-browser@1.2.0';
949
+ import { Sauron } from 'https://esm.sh/@edraj/sauron-browser@1.4.0';
937
950
  Sauron.init({ dsn: 'https://pk_test@ingest.example.com/42' });
938
951
  </script>
939
952
  ```
@@ -994,9 +1007,16 @@ is parked in the offline queue.
994
1007
  byte-capped at `maxQueueBytes` (default 1 MiB); the oldest entries are evicted
995
1008
  first and at least one entry is always kept. It is drained at `init()`, at the
996
1009
  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.
1010
+ envelope still fails, **it and every envelope behind it are re-parked at the head
1011
+ of the queue** (order preserved) and draining stops to avoid a tight loop — a
1012
+ single 500 on reconnect costs you nothing. Same on a 401/403: the client
1013
+ disables itself but the backlog is kept, so fixing the key and re-`init()`ing
1014
+ still delivers it. When `localStorage` is unavailable the queue is disabled and
1015
+ failed envelopes are dropped.
1016
+
1017
+ > Through 1.3.0 the drain deleted the whole `localStorage` backlog up front and
1018
+ > re-parked only the one envelope that failed, so everything queued behind it was
1019
+ > lost — the exact reconnect-then-one-500 scenario the queue exists for.
1000
1020
 
1001
1021
  **Page unload.** On `visibilitychange` → `hidden` and on `pagehide`, the pending
1002
1022
  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.0";
12
12
  function getGlobal() {
13
13
  return globalThis;
14
14
  }
@@ -91,6 +91,7 @@ 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";
94
95
  function webStorage(name) {
95
96
  try {
96
97
  const s = globalThis[name];
@@ -123,6 +124,7 @@ function persistentId(cached, storage, key) {
123
124
  }
124
125
  var deviceId = null;
125
126
  var sessionId = null;
127
+ var anonymousId = null;
126
128
  function getDeviceId() {
127
129
  deviceId = persistentId(deviceId, webStorage("localStorage"), DEVICE_ID_KEY);
128
130
  return deviceId;
@@ -131,6 +133,40 @@ function getSessionId() {
131
133
  sessionId = persistentId(sessionId, webStorage("sessionStorage"), SESSION_ID_KEY);
132
134
  return sessionId;
133
135
  }
136
+ function getAnonymousId() {
137
+ if (anonymousId) return anonymousId;
138
+ const storage = webStorage("localStorage");
139
+ if (storage) {
140
+ try {
141
+ const existing = storage.getItem(ANON_ID_KEY);
142
+ if (existing) {
143
+ anonymousId = existing;
144
+ return anonymousId;
145
+ }
146
+ } catch {
147
+ }
148
+ }
149
+ const fresh = `anon_${uuidv4()}`;
150
+ if (storage) {
151
+ try {
152
+ storage.setItem(ANON_ID_KEY, fresh);
153
+ } catch {
154
+ }
155
+ }
156
+ anonymousId = fresh;
157
+ return anonymousId;
158
+ }
159
+ function resetAnonymousId() {
160
+ anonymousId = null;
161
+ const storage = webStorage("localStorage");
162
+ if (storage) {
163
+ try {
164
+ storage.removeItem(ANON_ID_KEY);
165
+ } catch {
166
+ }
167
+ }
168
+ return getAnonymousId();
169
+ }
134
170
 
135
171
  // src/context.ts
136
172
  function getNavigator() {
@@ -628,12 +664,7 @@ function captureMessage(message, level = "info", hint) {
628
664
  type: "error",
629
665
  timestamp: nowIso(),
630
666
  level,
631
- exception: {
632
- type: null,
633
- value: message,
634
- mechanism: { type: "message", handled: true },
635
- stacktrace: []
636
- },
667
+ message,
637
668
  breadcrumbs,
638
669
  fingerprint: hint?.fingerprint ?? null,
639
670
  session_id: getSessionId(),
@@ -889,12 +920,12 @@ function setScreen(name) {
889
920
  function identify(id, traits = {}) {
890
921
  const client = getClient();
891
922
  if (!client) return;
892
- const anonymousId = client.getAnonymousId();
923
+ const anonymousId2 = client.getAnonymousId();
893
924
  client.getScope().setUser({ id, traits });
894
925
  const item = {
895
926
  type: "identify",
896
927
  distinct_id: id,
897
- anonymous_id: anonymousId,
928
+ anonymous_id: anonymousId2,
898
929
  traits: traits ?? {}
899
930
  };
900
931
  client.captureItem(item);
@@ -1399,6 +1430,21 @@ var OfflineQueue = class {
1399
1430
  if (entries.length) this.write([]);
1400
1431
  return entries;
1401
1432
  }
1433
+ /**
1434
+ * Put drained payloads BACK at the head, keeping their relative order.
1435
+ *
1436
+ * The counterpart to {@link drain}: a drain empties the store immediately, so
1437
+ * whatever the caller could not deliver only exists in its local array and is
1438
+ * lost the moment the caller returns. Re-parking at the head (rather than via
1439
+ * {@link enqueue}) keeps the queue oldest-first, which is what the byte-cap
1440
+ * eviction policy assumes — the oldest entry must stay the first one evicted.
1441
+ */
1442
+ requeueFront(payloads) {
1443
+ if (!this.storage || payloads.length === 0) return;
1444
+ const entries = [...payloads, ...this.read()];
1445
+ this.evict(entries);
1446
+ this.write(entries);
1447
+ }
1402
1448
  /** Non-destructive read of the current entries, oldest first. */
1403
1449
  peek() {
1404
1450
  return this.read();
@@ -1584,11 +1630,21 @@ var Transport = class {
1584
1630
  }
1585
1631
  }
1586
1632
  }
1587
- /** Re-attempt any envelopes that were parked while offline. */
1633
+ /**
1634
+ * Re-attempt any envelopes that were parked while offline.
1635
+ *
1636
+ * `drain()` empties `localStorage` in one shot, so from here on the ONLY copy
1637
+ * of the backlog is the local `payloads` array. Every early return therefore
1638
+ * has to re-park the whole untried remainder, not just the payload that
1639
+ * failed: this used to re-park the failing one and return, which silently
1640
+ * deleted every payload behind it — the exact reconnect-then-one-500 case the
1641
+ * queue exists for.
1642
+ */
1588
1643
  async drainOfflineQueue() {
1589
1644
  if (this.disabled || !this.offline.available) return;
1590
1645
  const payloads = this.offline.drain();
1591
- for (const json of payloads) {
1646
+ for (let i = 0; i < payloads.length; i++) {
1647
+ const json = payloads[i];
1592
1648
  let outcome;
1593
1649
  try {
1594
1650
  outcome = await this.post(json);
@@ -1596,13 +1652,14 @@ var Transport = class {
1596
1652
  outcome = { action: "retry_backoff" };
1597
1653
  }
1598
1654
  if (outcome.action === "disable") {
1655
+ this.logger.warn("server rejected credentials while draining; disabling client");
1599
1656
  this.disable();
1600
1657
  this.onDisable();
1601
- this.offline.enqueue(json);
1658
+ this.offline.requeueFront(payloads.slice(i));
1602
1659
  return;
1603
1660
  }
1604
1661
  if (outcome.action === "retry_after" || outcome.action === "retry_backoff") {
1605
- this.offline.enqueue(json);
1662
+ this.offline.requeueFront(payloads.slice(i));
1606
1663
  return;
1607
1664
  }
1608
1665
  }
@@ -1712,8 +1769,17 @@ var SauronClient = class {
1712
1769
  __publicField(this, "nativeFetch");
1713
1770
  __publicField(this, "enabled", true);
1714
1771
  __publicField(this, "installed", false);
1715
- __publicField(this, "anonymousId", null);
1716
1772
  __publicField(this, "beaconCleanup", null);
1773
+ /**
1774
+ * Whether the anonymous id has actually been USED as a `distinct_id` in this
1775
+ * browser session.
1776
+ *
1777
+ * A persisted id that has never been observed anonymously must not create a
1778
+ * permanent `identities` alias row on the server: aliasing is a durable
1779
+ * server-side binding of this browser profile to a named user, and an
1780
+ * identify() on a first-ever page load has no anonymous history to link.
1781
+ */
1782
+ __publicField(this, "anonUsed", false);
1717
1783
  this.options = options;
1718
1784
  this.dsn = parseDsn(options.dsn);
1719
1785
  this.logger = makeLogger(options.debug);
@@ -1780,15 +1846,25 @@ var SauronClient = class {
1780
1846
  getDistinctId() {
1781
1847
  const user = this.scope.getUser();
1782
1848
  if (user.id) return user.id;
1783
- return this.ensureAnonymousId();
1849
+ this.anonUsed = true;
1850
+ return getAnonymousId();
1784
1851
  }
1785
- /** The anonymous id, or null if one was never needed. */
1852
+ /** The anonymous id, or null when it was never actually used as an identity. */
1786
1853
  getAnonymousId() {
1787
- return this.anonymousId;
1854
+ return this.anonUsed ? getAnonymousId() : null;
1788
1855
  }
1789
- ensureAnonymousId() {
1790
- if (!this.anonymousId) this.anonymousId = `anon_${uuidv4()}`;
1791
- return this.anonymousId;
1856
+ /**
1857
+ * Forget the current person: clear the scope user and mint a fresh anonymous
1858
+ * id.
1859
+ *
1860
+ * MUST BE CALLED ON LOGOUT. Without it, the next anonymous visitor on this
1861
+ * browser reuses the persisted anon id, and a later identify() aliases their
1862
+ * activity to the previous account server-side, permanently.
1863
+ */
1864
+ reset() {
1865
+ this.scope.setUser(null);
1866
+ resetAnonymousId();
1867
+ this.anonUsed = false;
1792
1868
  }
1793
1869
  /** Stamp a fresh envelope (new `sent_at`, current context) around `items`. */
1794
1870
  makeEnvelope(items) {
@@ -2047,8 +2123,15 @@ function addBreadcrumb2(breadcrumb, hint) {
2047
2123
  addBreadcrumb(breadcrumb, hint);
2048
2124
  }
2049
2125
  function setUser(user) {
2126
+ if (user === null) {
2127
+ getClient()?.reset();
2128
+ return;
2129
+ }
2050
2130
  getClient()?.getScope().setUser(user);
2051
2131
  }
2132
+ function reset() {
2133
+ getClient()?.reset();
2134
+ }
2052
2135
  function setTag(key, value) {
2053
2136
  getClient()?.getScope().setTag(key, value);
2054
2137
  }
@@ -2078,6 +2161,7 @@ var Sauron = {
2078
2161
  identify: identify2,
2079
2162
  addBreadcrumb: addBreadcrumb2,
2080
2163
  setUser,
2164
+ reset,
2081
2165
  setTag,
2082
2166
  setTags,
2083
2167
  setContext,
@@ -2117,6 +2201,7 @@ exports.isInAppFrame = isInAppFrame;
2117
2201
  exports.parseDsn = parseDsn;
2118
2202
  exports.parseError = parseError;
2119
2203
  exports.parseStackString = parseStackString;
2204
+ exports.reset = reset;
2120
2205
  exports.setContext = setContext;
2121
2206
  exports.setExtra = setExtra;
2122
2207
  exports.setScreen = setScreen2;