@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/dist/index.js CHANGED
@@ -4,7 +4,7 @@ var __publicField = (obj, key, value) => __defNormalProp(obj, typeof key !== "sy
4
4
 
5
5
  // src/utils.ts
6
6
  var SDK_NAME = "sauron.javascript";
7
- var SDK_VERSION = "1.3.0";
7
+ var SDK_VERSION = "1.4.1";
8
8
  function getGlobal() {
9
9
  return globalThis;
10
10
  }
@@ -87,6 +87,30 @@ function makeLogger(debug) {
87
87
  // src/identity.ts
88
88
  var DEVICE_ID_KEY = "sauron.device_id";
89
89
  var SESSION_ID_KEY = "sauron.session_id";
90
+ var ANON_ID_KEY = "sauron.anon_id";
91
+ var LAST_IDENTIFIED_KEY = "sauron.last_identified";
92
+ var LAST_IDENTIFIED_FORMAT = "v1";
93
+ function encodeLastIdentified(digest) {
94
+ return `${LAST_IDENTIFIED_FORMAT}:${digest}`;
95
+ }
96
+ function decodeLastIdentified(raw) {
97
+ if (raw === null) return null;
98
+ const sep = raw.indexOf(":");
99
+ if (sep < 0 || raw.slice(0, sep) !== LAST_IDENTIFIED_FORMAT) return null;
100
+ const digest = raw.slice(sep + 1);
101
+ return digest === "" ? null : digest;
102
+ }
103
+ function fnv1a32(s) {
104
+ let h = 2166136261;
105
+ for (let i = 0; i < s.length; i++) {
106
+ h ^= s.charCodeAt(i);
107
+ h = Math.imul(h, 16777619);
108
+ }
109
+ return (h >>> 0).toString(16).padStart(8, "0");
110
+ }
111
+ function hashIdentity(id) {
112
+ return fnv1a32(id) + fnv1a32("" + id);
113
+ }
90
114
  function webStorage(name) {
91
115
  try {
92
116
  const s = globalThis[name];
@@ -119,6 +143,8 @@ function persistentId(cached, storage, key) {
119
143
  }
120
144
  var deviceId = null;
121
145
  var sessionId = null;
146
+ var anonymousId = null;
147
+ var lastIdentified = null;
122
148
  function getDeviceId() {
123
149
  deviceId = persistentId(deviceId, webStorage("localStorage"), DEVICE_ID_KEY);
124
150
  return deviceId;
@@ -127,6 +153,82 @@ function getSessionId() {
127
153
  sessionId = persistentId(sessionId, webStorage("sessionStorage"), SESSION_ID_KEY);
128
154
  return sessionId;
129
155
  }
156
+ function rotateSessionId() {
157
+ sessionId = null;
158
+ const storage = webStorage("sessionStorage");
159
+ if (storage) {
160
+ try {
161
+ storage.removeItem(SESSION_ID_KEY);
162
+ } catch {
163
+ }
164
+ }
165
+ return getSessionId();
166
+ }
167
+ function getAnonymousId() {
168
+ if (anonymousId) return anonymousId;
169
+ const storage = webStorage("localStorage");
170
+ if (storage) {
171
+ try {
172
+ const existing = storage.getItem(ANON_ID_KEY);
173
+ if (existing) {
174
+ anonymousId = existing;
175
+ return anonymousId;
176
+ }
177
+ } catch {
178
+ }
179
+ }
180
+ const fresh = `anon_${uuidv4()}`;
181
+ if (storage) {
182
+ try {
183
+ storage.setItem(ANON_ID_KEY, fresh);
184
+ } catch {
185
+ }
186
+ }
187
+ anonymousId = fresh;
188
+ return anonymousId;
189
+ }
190
+ function resetAnonymousId() {
191
+ anonymousId = null;
192
+ const storage = webStorage("localStorage");
193
+ if (storage) {
194
+ try {
195
+ storage.removeItem(ANON_ID_KEY);
196
+ } catch {
197
+ }
198
+ }
199
+ return getAnonymousId();
200
+ }
201
+ function getLastIdentified() {
202
+ const storage = webStorage("localStorage");
203
+ if (!storage) return decodeLastIdentified(lastIdentified);
204
+ try {
205
+ const stored = storage.getItem(LAST_IDENTIFIED_KEY);
206
+ return decodeLastIdentified(stored ?? lastIdentified);
207
+ } catch {
208
+ return decodeLastIdentified(lastIdentified);
209
+ }
210
+ }
211
+ function setLastIdentified(id) {
212
+ const encoded = encodeLastIdentified(id);
213
+ lastIdentified = encoded;
214
+ const storage = webStorage("localStorage");
215
+ if (storage) {
216
+ try {
217
+ storage.setItem(LAST_IDENTIFIED_KEY, encoded);
218
+ } catch {
219
+ }
220
+ }
221
+ }
222
+ function clearLastIdentified() {
223
+ lastIdentified = null;
224
+ const storage = webStorage("localStorage");
225
+ if (storage) {
226
+ try {
227
+ storage.removeItem(LAST_IDENTIFIED_KEY);
228
+ } catch {
229
+ }
230
+ }
231
+ }
130
232
 
131
233
  // src/context.ts
132
234
  function getNavigator() {
@@ -624,12 +726,7 @@ function captureMessage(message, level = "info", hint) {
624
726
  type: "error",
625
727
  timestamp: nowIso(),
626
728
  level,
627
- exception: {
628
- type: null,
629
- value: message,
630
- mechanism: { type: "message", handled: true },
631
- stacktrace: []
632
- },
729
+ message,
633
730
  breadcrumbs,
634
731
  fingerprint: hint?.fingerprint ?? null,
635
732
  session_id: getSessionId(),
@@ -778,13 +875,31 @@ var Scope = class {
778
875
  this.maxBreadcrumbs = Math.max(0, max);
779
876
  this.trim();
780
877
  }
878
+ /**
879
+ * Replace the scope user.
880
+ *
881
+ * `id` is coerced with `String()` for the same reason `SauronClient.
882
+ * prepareIdentify` coerces its own — a plain-JS caller can (and does) pass
883
+ * `setUser({ id: user.id })` where `user.id` is a number, and TypeScript
884
+ * cannot stop them. This path is the one that BYPASSES `identify()`'s
885
+ * coercion entirely, and the consequence is not cosmetic: the scope user
886
+ * lands in the envelope context, where the server's `distinct_id` is a
887
+ * non-`Option` Rust `String`. A JSON number there fails deserialization of
888
+ * the ENVELOPE, not of the one field — so the whole batch 400s, and a 400
889
+ * is non-retryable, so every event in it is dropped for good.
890
+ *
891
+ * Rebuilding the whole object (rather than merging into the existing one)
892
+ * is deliberate and is the behaviour the Flutter SDK was fixed to match:
893
+ * `email` and `traits` come from the input alone, so setting a new user
894
+ * never inherits the previous person's contact details.
895
+ */
781
896
  setUser(user) {
782
897
  if (user === null) {
783
898
  this.user = null;
784
899
  return;
785
900
  }
786
901
  this.user = {
787
- id: user.id ?? null,
902
+ id: user.id === null || user.id === void 0 ? null : String(user.id),
788
903
  email: user.email ?? null,
789
904
  traits: user.traits ?? {}
790
905
  };
@@ -885,12 +1000,13 @@ function setScreen(name) {
885
1000
  function identify(id, traits = {}) {
886
1001
  const client = getClient();
887
1002
  if (!client) return;
888
- const anonymousId = client.getAnonymousId();
889
- client.getScope().setUser({ id, traits });
1003
+ const distinctId = String(id);
1004
+ const anonymousId2 = client.prepareIdentify(distinctId);
1005
+ client.getScope().setUser({ id: distinctId, traits });
890
1006
  const item = {
891
1007
  type: "identify",
892
- distinct_id: id,
893
- anonymous_id: anonymousId,
1008
+ distinct_id: distinctId,
1009
+ anonymous_id: anonymousId2,
894
1010
  traits: traits ?? {}
895
1011
  };
896
1012
  client.captureItem(item);
@@ -1395,6 +1511,21 @@ var OfflineQueue = class {
1395
1511
  if (entries.length) this.write([]);
1396
1512
  return entries;
1397
1513
  }
1514
+ /**
1515
+ * Put drained payloads BACK at the head, keeping their relative order.
1516
+ *
1517
+ * The counterpart to {@link drain}: a drain empties the store immediately, so
1518
+ * whatever the caller could not deliver only exists in its local array and is
1519
+ * lost the moment the caller returns. Re-parking at the head (rather than via
1520
+ * {@link enqueue}) keeps the queue oldest-first, which is what the byte-cap
1521
+ * eviction policy assumes — the oldest entry must stay the first one evicted.
1522
+ */
1523
+ requeueFront(payloads) {
1524
+ if (!this.storage || payloads.length === 0) return;
1525
+ const entries = [...payloads, ...this.read()];
1526
+ this.evict(entries);
1527
+ this.write(entries);
1528
+ }
1398
1529
  /** Non-destructive read of the current entries, oldest first. */
1399
1530
  peek() {
1400
1531
  return this.read();
@@ -1580,11 +1711,21 @@ var Transport = class {
1580
1711
  }
1581
1712
  }
1582
1713
  }
1583
- /** Re-attempt any envelopes that were parked while offline. */
1714
+ /**
1715
+ * Re-attempt any envelopes that were parked while offline.
1716
+ *
1717
+ * `drain()` empties `localStorage` in one shot, so from here on the ONLY copy
1718
+ * of the backlog is the local `payloads` array. Every early return therefore
1719
+ * has to re-park the whole untried remainder, not just the payload that
1720
+ * failed: this used to re-park the failing one and return, which silently
1721
+ * deleted every payload behind it — the exact reconnect-then-one-500 case the
1722
+ * queue exists for.
1723
+ */
1584
1724
  async drainOfflineQueue() {
1585
1725
  if (this.disabled || !this.offline.available) return;
1586
1726
  const payloads = this.offline.drain();
1587
- for (const json of payloads) {
1727
+ for (let i = 0; i < payloads.length; i++) {
1728
+ const json = payloads[i];
1588
1729
  let outcome;
1589
1730
  try {
1590
1731
  outcome = await this.post(json);
@@ -1592,13 +1733,14 @@ var Transport = class {
1592
1733
  outcome = { action: "retry_backoff" };
1593
1734
  }
1594
1735
  if (outcome.action === "disable") {
1736
+ this.logger.warn("server rejected credentials while draining; disabling client");
1595
1737
  this.disable();
1596
1738
  this.onDisable();
1597
- this.offline.enqueue(json);
1739
+ this.offline.requeueFront(payloads.slice(i));
1598
1740
  return;
1599
1741
  }
1600
1742
  if (outcome.action === "retry_after" || outcome.action === "retry_backoff") {
1601
- this.offline.enqueue(json);
1743
+ this.offline.requeueFront(payloads.slice(i));
1602
1744
  return;
1603
1745
  }
1604
1746
  }
@@ -1708,8 +1850,17 @@ var SauronClient = class {
1708
1850
  __publicField(this, "nativeFetch");
1709
1851
  __publicField(this, "enabled", true);
1710
1852
  __publicField(this, "installed", false);
1711
- __publicField(this, "anonymousId", null);
1712
1853
  __publicField(this, "beaconCleanup", null);
1854
+ /**
1855
+ * Whether the anonymous id has actually been USED as a `distinct_id` in this
1856
+ * browser session.
1857
+ *
1858
+ * A persisted id that has never been observed anonymously must not create a
1859
+ * permanent `identities` alias row on the server: aliasing is a durable
1860
+ * server-side binding of this browser profile to a named user, and an
1861
+ * identify() on a first-ever page load has no anonymous history to link.
1862
+ */
1863
+ __publicField(this, "anonUsed", false);
1713
1864
  this.options = options;
1714
1865
  this.dsn = parseDsn(options.dsn);
1715
1866
  this.logger = makeLogger(options.debug);
@@ -1776,15 +1927,63 @@ var SauronClient = class {
1776
1927
  getDistinctId() {
1777
1928
  const user = this.scope.getUser();
1778
1929
  if (user.id) return user.id;
1779
- return this.ensureAnonymousId();
1930
+ this.anonUsed = true;
1931
+ return getAnonymousId();
1780
1932
  }
1781
- /** The anonymous id, or null if one was never needed. */
1933
+ /** The anonymous id, or null when it was never actually used as an identity. */
1782
1934
  getAnonymousId() {
1783
- return this.anonymousId;
1935
+ return this.anonUsed ? getAnonymousId() : null;
1784
1936
  }
1785
- ensureAnonymousId() {
1786
- if (!this.anonymousId) this.anonymousId = `anon_${uuidv4()}`;
1787
- return this.anonymousId;
1937
+ /**
1938
+ * Forget the current person: clear the scope user, mint a fresh anonymous
1939
+ * id, forget the last identified user, and rotate the session id.
1940
+ *
1941
+ * MUST BE CALLED ON LOGOUT. Without it, the next anonymous visitor on this
1942
+ * browser reuses the persisted anon id, and a later identify() aliases their
1943
+ * activity to the previous account server-side, permanently. Rotating the
1944
+ * session id matters too: the server's `bump_session` is last-write-wins on
1945
+ * `distinct_id`, so without rotation one `sessions` row could otherwise
1946
+ * serially represent two different people and record only whichever wrote
1947
+ * last.
1948
+ */
1949
+ reset() {
1950
+ this.scope.setUser(null);
1951
+ resetAnonymousId();
1952
+ clearLastIdentified();
1953
+ rotateSessionId();
1954
+ this.anonUsed = false;
1955
+ }
1956
+ /**
1957
+ * Prepare for an `identify()`; returns the `anonymous_id` to send.
1958
+ *
1959
+ * When a DIFFERENT user identifies than last time, the current anon id
1960
+ * belongs to the previous person and is already burned server-side, so it is
1961
+ * replaced before anything else happens and `null` is sent instead of a
1962
+ * cross-user alias. This cannot repair events already sent under the burned
1963
+ * alias — nothing can — but it bounds a forgotten `reset()` to one guest
1964
+ * window instead of every future one.
1965
+ *
1966
+ * `id` is coerced with `String()` before comparing/persisting: a plain-JS
1967
+ * caller can pass a number (`Sauron.identify(user.id)`), and `Storage`
1968
+ * itself applies `ToString` on write — so comparing an un-coerced `id`
1969
+ * against a value that already round-tripped through storage would treat
1970
+ * the SAME numeric user as a switch on every single call. The comparison
1971
+ * against `last` is an explicit `!== null` (not a truthiness check) so an
1972
+ * app that (unusually) identifies with `''` still has a later, different id
1973
+ * correctly detected as a real switch — a falsy string is not "no identity
1974
+ * yet". `last`/the persisted value are digests, not the raw id — see
1975
+ * `hashIdentity`.
1976
+ */
1977
+ prepareIdentify(id) {
1978
+ const digest = hashIdentity(String(id));
1979
+ const last = getLastIdentified();
1980
+ if (last !== null && last !== digest) {
1981
+ resetAnonymousId();
1982
+ rotateSessionId();
1983
+ this.anonUsed = false;
1984
+ }
1985
+ setLastIdentified(digest);
1986
+ return this.getAnonymousId();
1788
1987
  }
1789
1988
  /** Stamp a fresh envelope (new `sent_at`, current context) around `items`. */
1790
1989
  makeEnvelope(items) {
@@ -2043,8 +2242,15 @@ function addBreadcrumb2(breadcrumb, hint) {
2043
2242
  addBreadcrumb(breadcrumb, hint);
2044
2243
  }
2045
2244
  function setUser(user) {
2245
+ if (user === null) {
2246
+ getClient()?.reset();
2247
+ return;
2248
+ }
2046
2249
  getClient()?.getScope().setUser(user);
2047
2250
  }
2251
+ function reset() {
2252
+ getClient()?.reset();
2253
+ }
2048
2254
  function setTag(key, value) {
2049
2255
  getClient()?.getScope().setTag(key, value);
2050
2256
  }
@@ -2074,6 +2280,7 @@ var Sauron = {
2074
2280
  identify: identify2,
2075
2281
  addBreadcrumb: addBreadcrumb2,
2076
2282
  setUser,
2283
+ reset,
2077
2284
  setTag,
2078
2285
  setTags,
2079
2286
  setContext,
@@ -2090,6 +2297,6 @@ var Sauron = {
2090
2297
  };
2091
2298
  var index_default = Sauron;
2092
2299
 
2093
- export { DsnError, SDK_NAME, SDK_VERSION, Sauron, SauronClient, addBreadcrumb2 as addBreadcrumb, buildEnvelope, cancelWorkflow2 as cancelWorkflow, captureException2 as captureException, captureMessage2 as captureMessage, close, index_default as default, endWorkflow2 as endWorkflow, flush, getClient, getScreen2 as getScreen, getWorkflow2 as getWorkflow, identify2 as identify, init2 as init, isInAppFrame, parseDsn, parseError, parseStackString, setContext, setExtra, setScreen2 as setScreen, setTag, setTags, setUser, startWorkflow2 as startWorkflow, track2 as track, trackTransaction2 as trackTransaction };
2300
+ export { DsnError, SDK_NAME, SDK_VERSION, Sauron, SauronClient, addBreadcrumb2 as addBreadcrumb, buildEnvelope, cancelWorkflow2 as cancelWorkflow, captureException2 as captureException, captureMessage2 as captureMessage, close, index_default as default, endWorkflow2 as endWorkflow, flush, getClient, getScreen2 as getScreen, getWorkflow2 as getWorkflow, identify2 as identify, init2 as init, isInAppFrame, parseDsn, parseError, parseStackString, reset, setContext, setExtra, setScreen2 as setScreen, setTag, setTags, setUser, startWorkflow2 as startWorkflow, track2 as track, trackTransaction2 as trackTransaction };
2094
2301
  //# sourceMappingURL=index.js.map
2095
2302
  //# sourceMappingURL=index.js.map