@edraj/sauron-browser 1.4.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.d.cts CHANGED
@@ -374,6 +374,24 @@ declare class Scope {
374
374
  readonly extra: Record<string, unknown>;
375
375
  constructor(maxBreadcrumbs?: number);
376
376
  setMaxBreadcrumbs(max: number): void;
377
+ /**
378
+ * Replace the scope user.
379
+ *
380
+ * `id` is coerced with `String()` for the same reason `SauronClient.
381
+ * prepareIdentify` coerces its own — a plain-JS caller can (and does) pass
382
+ * `setUser({ id: user.id })` where `user.id` is a number, and TypeScript
383
+ * cannot stop them. This path is the one that BYPASSES `identify()`'s
384
+ * coercion entirely, and the consequence is not cosmetic: the scope user
385
+ * lands in the envelope context, where the server's `distinct_id` is a
386
+ * non-`Option` Rust `String`. A JSON number there fails deserialization of
387
+ * the ENVELOPE, not of the one field — so the whole batch 400s, and a 400
388
+ * is non-retryable, so every event in it is dropped for good.
389
+ *
390
+ * Rebuilding the whole object (rather than merging into the existing one)
391
+ * is deliberate and is the behaviour the Flutter SDK was fixed to match:
392
+ * `email` and `traits` come from the input alone, so setting a new user
393
+ * never inherits the previous person's contact details.
394
+ */
377
395
  setUser(user: UserInput): void;
378
396
  /** The user context for an envelope. Never null — defaults to an empty user. */
379
397
  getUser(): UserContext;
@@ -438,14 +456,40 @@ declare class SauronClient {
438
456
  /** The anonymous id, or null when it was never actually used as an identity. */
439
457
  getAnonymousId(): string | null;
440
458
  /**
441
- * Forget the current person: clear the scope user and mint a fresh anonymous
442
- * id.
459
+ * Forget the current person: clear the scope user, mint a fresh anonymous
460
+ * id, forget the last identified user, and rotate the session id.
443
461
  *
444
462
  * MUST BE CALLED ON LOGOUT. Without it, the next anonymous visitor on this
445
463
  * browser reuses the persisted anon id, and a later identify() aliases their
446
- * activity to the previous account server-side, permanently.
464
+ * activity to the previous account server-side, permanently. Rotating the
465
+ * session id matters too: the server's `bump_session` is last-write-wins on
466
+ * `distinct_id`, so without rotation one `sessions` row could otherwise
467
+ * serially represent two different people and record only whichever wrote
468
+ * last.
447
469
  */
448
470
  reset(): void;
471
+ /**
472
+ * Prepare for an `identify()`; returns the `anonymous_id` to send.
473
+ *
474
+ * When a DIFFERENT user identifies than last time, the current anon id
475
+ * belongs to the previous person and is already burned server-side, so it is
476
+ * replaced before anything else happens and `null` is sent instead of a
477
+ * cross-user alias. This cannot repair events already sent under the burned
478
+ * alias — nothing can — but it bounds a forgotten `reset()` to one guest
479
+ * window instead of every future one.
480
+ *
481
+ * `id` is coerced with `String()` before comparing/persisting: a plain-JS
482
+ * caller can pass a number (`Sauron.identify(user.id)`), and `Storage`
483
+ * itself applies `ToString` on write — so comparing an un-coerced `id`
484
+ * against a value that already round-tripped through storage would treat
485
+ * the SAME numeric user as a switch on every single call. The comparison
486
+ * against `last` is an explicit `!== null` (not a truthiness check) so an
487
+ * app that (unusually) identifies with `''` still has a later, different id
488
+ * correctly detected as a real switch — a falsy string is not "no identity
489
+ * yet". `last`/the persisted value are digests, not the raw id — see
490
+ * `hashIdentity`.
491
+ */
492
+ prepareIdentify(id: string): string | null;
449
493
  /** Stamp a fresh envelope (new `sent_at`, current context) around `items`. */
450
494
  makeEnvelope(items: EnvelopeItem[]): Envelope;
451
495
  /** Add a breadcrumb, running it through `beforeBreadcrumb` first. */
@@ -517,7 +561,7 @@ declare function parseError(err: unknown): Frame[];
517
561
  /** Small dependency-free helpers shared across the SDK. */
518
562
  /** SDK identity, embedded in every envelope header. */
519
563
  declare const SDK_NAME = "sauron.javascript";
520
- declare const SDK_VERSION = "1.4.0";
564
+ declare const SDK_VERSION = "1.4.1";
521
565
 
522
566
  /**
523
567
  * `@edraj/sauron-browser` — public API surface.
@@ -540,7 +584,16 @@ declare function captureException(err: unknown, hint?: Hint): void;
540
584
  declare function captureMessage(message: string, level?: Level, hint?: Hint): void;
541
585
  /** Record a product-analytics event, optionally with per-call tags/contexts/extra. */
542
586
  declare function track(name: string, properties?: Record<string, unknown>, options?: TrackOptions): void;
543
- /** Associate the session with a known user. */
587
+ /**
588
+ * Associate the session with a known user.
589
+ *
590
+ * The `anonymous_id` sent with the identify item is the current anon id — but
591
+ * only when it was actually used as a `distinct_id` this session, and never
592
+ * when it belongs to a different person than the last one who identified on
593
+ * this device. In that case a fresh anon id is minted first and `null` is
594
+ * sent instead, since the old one is already permanently bound to the
595
+ * previous person server-side (see `reset()`).
596
+ */
544
597
  declare function identify(id: string, traits?: Record<string, unknown>): void;
545
598
  /** Record a performance transaction (navigation, http, screen load, ...). */
546
599
  declare function trackTransaction(input: TransactionInput): void;
@@ -570,14 +623,15 @@ declare function addBreadcrumb(breadcrumb: BreadcrumbInput, hint?: Hint): void;
570
623
  /**
571
624
  * Set (or clear, with `null`) the current user.
572
625
  *
573
- * `setUser(null)` is a logout, so it also rotates the anonymous id — otherwise
626
+ * `setUser(null)` is a logout, so it also calls `reset()` for you — otherwise
574
627
  * the next anonymous visitor on this browser inherits the previous person's
575
628
  * durable id and a later identify() aliases them together server-side.
576
629
  */
577
630
  declare function setUser(user: UserInput): void;
578
631
  /**
579
- * Forget the current person: clears the scope user and mints a fresh anonymous
580
- * id. Call this on logout.
632
+ * Forget the current person: clears the scope user, mints a fresh anonymous
633
+ * id, forgets the last identified user, and rotates the session id so a
634
+ * single session can never span two different people. Call this on logout.
581
635
  */
582
636
  declare function reset(): void;
583
637
  /** Set a single scope tag (lifted onto later errors/events). */
package/dist/index.d.ts CHANGED
@@ -374,6 +374,24 @@ declare class Scope {
374
374
  readonly extra: Record<string, unknown>;
375
375
  constructor(maxBreadcrumbs?: number);
376
376
  setMaxBreadcrumbs(max: number): void;
377
+ /**
378
+ * Replace the scope user.
379
+ *
380
+ * `id` is coerced with `String()` for the same reason `SauronClient.
381
+ * prepareIdentify` coerces its own — a plain-JS caller can (and does) pass
382
+ * `setUser({ id: user.id })` where `user.id` is a number, and TypeScript
383
+ * cannot stop them. This path is the one that BYPASSES `identify()`'s
384
+ * coercion entirely, and the consequence is not cosmetic: the scope user
385
+ * lands in the envelope context, where the server's `distinct_id` is a
386
+ * non-`Option` Rust `String`. A JSON number there fails deserialization of
387
+ * the ENVELOPE, not of the one field — so the whole batch 400s, and a 400
388
+ * is non-retryable, so every event in it is dropped for good.
389
+ *
390
+ * Rebuilding the whole object (rather than merging into the existing one)
391
+ * is deliberate and is the behaviour the Flutter SDK was fixed to match:
392
+ * `email` and `traits` come from the input alone, so setting a new user
393
+ * never inherits the previous person's contact details.
394
+ */
377
395
  setUser(user: UserInput): void;
378
396
  /** The user context for an envelope. Never null — defaults to an empty user. */
379
397
  getUser(): UserContext;
@@ -438,14 +456,40 @@ declare class SauronClient {
438
456
  /** The anonymous id, or null when it was never actually used as an identity. */
439
457
  getAnonymousId(): string | null;
440
458
  /**
441
- * Forget the current person: clear the scope user and mint a fresh anonymous
442
- * id.
459
+ * Forget the current person: clear the scope user, mint a fresh anonymous
460
+ * id, forget the last identified user, and rotate the session id.
443
461
  *
444
462
  * MUST BE CALLED ON LOGOUT. Without it, the next anonymous visitor on this
445
463
  * browser reuses the persisted anon id, and a later identify() aliases their
446
- * activity to the previous account server-side, permanently.
464
+ * activity to the previous account server-side, permanently. Rotating the
465
+ * session id matters too: the server's `bump_session` is last-write-wins on
466
+ * `distinct_id`, so without rotation one `sessions` row could otherwise
467
+ * serially represent two different people and record only whichever wrote
468
+ * last.
447
469
  */
448
470
  reset(): void;
471
+ /**
472
+ * Prepare for an `identify()`; returns the `anonymous_id` to send.
473
+ *
474
+ * When a DIFFERENT user identifies than last time, the current anon id
475
+ * belongs to the previous person and is already burned server-side, so it is
476
+ * replaced before anything else happens and `null` is sent instead of a
477
+ * cross-user alias. This cannot repair events already sent under the burned
478
+ * alias — nothing can — but it bounds a forgotten `reset()` to one guest
479
+ * window instead of every future one.
480
+ *
481
+ * `id` is coerced with `String()` before comparing/persisting: a plain-JS
482
+ * caller can pass a number (`Sauron.identify(user.id)`), and `Storage`
483
+ * itself applies `ToString` on write — so comparing an un-coerced `id`
484
+ * against a value that already round-tripped through storage would treat
485
+ * the SAME numeric user as a switch on every single call. The comparison
486
+ * against `last` is an explicit `!== null` (not a truthiness check) so an
487
+ * app that (unusually) identifies with `''` still has a later, different id
488
+ * correctly detected as a real switch — a falsy string is not "no identity
489
+ * yet". `last`/the persisted value are digests, not the raw id — see
490
+ * `hashIdentity`.
491
+ */
492
+ prepareIdentify(id: string): string | null;
449
493
  /** Stamp a fresh envelope (new `sent_at`, current context) around `items`. */
450
494
  makeEnvelope(items: EnvelopeItem[]): Envelope;
451
495
  /** Add a breadcrumb, running it through `beforeBreadcrumb` first. */
@@ -517,7 +561,7 @@ declare function parseError(err: unknown): Frame[];
517
561
  /** Small dependency-free helpers shared across the SDK. */
518
562
  /** SDK identity, embedded in every envelope header. */
519
563
  declare const SDK_NAME = "sauron.javascript";
520
- declare const SDK_VERSION = "1.4.0";
564
+ declare const SDK_VERSION = "1.4.1";
521
565
 
522
566
  /**
523
567
  * `@edraj/sauron-browser` — public API surface.
@@ -540,7 +584,16 @@ declare function captureException(err: unknown, hint?: Hint): void;
540
584
  declare function captureMessage(message: string, level?: Level, hint?: Hint): void;
541
585
  /** Record a product-analytics event, optionally with per-call tags/contexts/extra. */
542
586
  declare function track(name: string, properties?: Record<string, unknown>, options?: TrackOptions): void;
543
- /** Associate the session with a known user. */
587
+ /**
588
+ * Associate the session with a known user.
589
+ *
590
+ * The `anonymous_id` sent with the identify item is the current anon id — but
591
+ * only when it was actually used as a `distinct_id` this session, and never
592
+ * when it belongs to a different person than the last one who identified on
593
+ * this device. In that case a fresh anon id is minted first and `null` is
594
+ * sent instead, since the old one is already permanently bound to the
595
+ * previous person server-side (see `reset()`).
596
+ */
544
597
  declare function identify(id: string, traits?: Record<string, unknown>): void;
545
598
  /** Record a performance transaction (navigation, http, screen load, ...). */
546
599
  declare function trackTransaction(input: TransactionInput): void;
@@ -570,14 +623,15 @@ declare function addBreadcrumb(breadcrumb: BreadcrumbInput, hint?: Hint): void;
570
623
  /**
571
624
  * Set (or clear, with `null`) the current user.
572
625
  *
573
- * `setUser(null)` is a logout, so it also rotates the anonymous id — otherwise
626
+ * `setUser(null)` is a logout, so it also calls `reset()` for you — otherwise
574
627
  * the next anonymous visitor on this browser inherits the previous person's
575
628
  * durable id and a later identify() aliases them together server-side.
576
629
  */
577
630
  declare function setUser(user: UserInput): void;
578
631
  /**
579
- * Forget the current person: clears the scope user and mints a fresh anonymous
580
- * id. Call this on logout.
632
+ * Forget the current person: clears the scope user, mints a fresh anonymous
633
+ * id, forgets the last identified user, and rotates the session id so a
634
+ * single session can never span two different people. Call this on logout.
581
635
  */
582
636
  declare function reset(): void;
583
637
  /** Set a single scope tag (lifted onto later errors/events). */
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.4.0";
7
+ var SDK_VERSION = "1.4.1";
8
8
  function getGlobal() {
9
9
  return globalThis;
10
10
  }
@@ -88,6 +88,29 @@ function makeLogger(debug) {
88
88
  var DEVICE_ID_KEY = "sauron.device_id";
89
89
  var SESSION_ID_KEY = "sauron.session_id";
90
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
+ }
91
114
  function webStorage(name) {
92
115
  try {
93
116
  const s = globalThis[name];
@@ -121,6 +144,7 @@ function persistentId(cached, storage, key) {
121
144
  var deviceId = null;
122
145
  var sessionId = null;
123
146
  var anonymousId = null;
147
+ var lastIdentified = null;
124
148
  function getDeviceId() {
125
149
  deviceId = persistentId(deviceId, webStorage("localStorage"), DEVICE_ID_KEY);
126
150
  return deviceId;
@@ -129,6 +153,17 @@ function getSessionId() {
129
153
  sessionId = persistentId(sessionId, webStorage("sessionStorage"), SESSION_ID_KEY);
130
154
  return sessionId;
131
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
+ }
132
167
  function getAnonymousId() {
133
168
  if (anonymousId) return anonymousId;
134
169
  const storage = webStorage("localStorage");
@@ -163,6 +198,37 @@ function resetAnonymousId() {
163
198
  }
164
199
  return getAnonymousId();
165
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
+ }
166
232
 
167
233
  // src/context.ts
168
234
  function getNavigator() {
@@ -809,13 +875,31 @@ var Scope = class {
809
875
  this.maxBreadcrumbs = Math.max(0, max);
810
876
  this.trim();
811
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
+ */
812
896
  setUser(user) {
813
897
  if (user === null) {
814
898
  this.user = null;
815
899
  return;
816
900
  }
817
901
  this.user = {
818
- id: user.id ?? null,
902
+ id: user.id === null || user.id === void 0 ? null : String(user.id),
819
903
  email: user.email ?? null,
820
904
  traits: user.traits ?? {}
821
905
  };
@@ -916,11 +1000,12 @@ function setScreen(name) {
916
1000
  function identify(id, traits = {}) {
917
1001
  const client = getClient();
918
1002
  if (!client) return;
919
- const anonymousId2 = client.getAnonymousId();
920
- client.getScope().setUser({ id, traits });
1003
+ const distinctId = String(id);
1004
+ const anonymousId2 = client.prepareIdentify(distinctId);
1005
+ client.getScope().setUser({ id: distinctId, traits });
921
1006
  const item = {
922
1007
  type: "identify",
923
- distinct_id: id,
1008
+ distinct_id: distinctId,
924
1009
  anonymous_id: anonymousId2,
925
1010
  traits: traits ?? {}
926
1011
  };
@@ -1850,18 +1935,56 @@ var SauronClient = class {
1850
1935
  return this.anonUsed ? getAnonymousId() : null;
1851
1936
  }
1852
1937
  /**
1853
- * Forget the current person: clear the scope user and mint a fresh anonymous
1854
- * id.
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.
1855
1940
  *
1856
1941
  * MUST BE CALLED ON LOGOUT. Without it, the next anonymous visitor on this
1857
1942
  * browser reuses the persisted anon id, and a later identify() aliases their
1858
- * activity to the previous account server-side, permanently.
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.
1859
1948
  */
1860
1949
  reset() {
1861
1950
  this.scope.setUser(null);
1862
1951
  resetAnonymousId();
1952
+ clearLastIdentified();
1953
+ rotateSessionId();
1863
1954
  this.anonUsed = false;
1864
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();
1987
+ }
1865
1988
  /** Stamp a fresh envelope (new `sent_at`, current context) around `items`. */
1866
1989
  makeEnvelope(items) {
1867
1990
  const header = {