@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/CHANGELOG.md CHANGED
@@ -2,6 +2,40 @@
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
+
5
39
  ## 1.4.0
6
40
 
7
41
  ### 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`
@@ -922,8 +958,15 @@ Other scope data:
922
958
  `captureException`.
923
959
  - **identity** — `device_id` persists in `localStorage` under
924
960
  `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.
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.
927
970
 
928
971
  ```ts
929
972
  Sauron.init({ dsn, tags: { tier: 'free' }, extra: { build: 'ci-42' } });
@@ -946,7 +989,7 @@ Sauron.track('upgraded', {}, { tags: { tier: 'trial' } });
946
989
 
947
990
  ```html
948
991
  <script type="module">
949
- import { Sauron } from 'https://esm.sh/@edraj/sauron-browser@1.4.0';
992
+ import { Sauron } from 'https://esm.sh/@edraj/sauron-browser@1.4.1';
950
993
  Sauron.init({ dsn: 'https://pk_test@ingest.example.com/42' });
951
994
  </script>
952
995
  ```
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.4.1";
12
12
  function getGlobal() {
13
13
  return globalThis;
14
14
  }
@@ -92,6 +92,29 @@ function makeLogger(debug) {
92
92
  var DEVICE_ID_KEY = "sauron.device_id";
93
93
  var SESSION_ID_KEY = "sauron.session_id";
94
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
+ }
95
118
  function webStorage(name) {
96
119
  try {
97
120
  const s = globalThis[name];
@@ -125,6 +148,7 @@ function persistentId(cached, storage, key) {
125
148
  var deviceId = null;
126
149
  var sessionId = null;
127
150
  var anonymousId = null;
151
+ var lastIdentified = null;
128
152
  function getDeviceId() {
129
153
  deviceId = persistentId(deviceId, webStorage("localStorage"), DEVICE_ID_KEY);
130
154
  return deviceId;
@@ -133,6 +157,17 @@ function getSessionId() {
133
157
  sessionId = persistentId(sessionId, webStorage("sessionStorage"), SESSION_ID_KEY);
134
158
  return sessionId;
135
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
+ }
136
171
  function getAnonymousId() {
137
172
  if (anonymousId) return anonymousId;
138
173
  const storage = webStorage("localStorage");
@@ -167,6 +202,37 @@ function resetAnonymousId() {
167
202
  }
168
203
  return getAnonymousId();
169
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
+ }
170
236
 
171
237
  // src/context.ts
172
238
  function getNavigator() {
@@ -813,13 +879,31 @@ var Scope = class {
813
879
  this.maxBreadcrumbs = Math.max(0, max);
814
880
  this.trim();
815
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
+ */
816
900
  setUser(user) {
817
901
  if (user === null) {
818
902
  this.user = null;
819
903
  return;
820
904
  }
821
905
  this.user = {
822
- id: user.id ?? null,
906
+ id: user.id === null || user.id === void 0 ? null : String(user.id),
823
907
  email: user.email ?? null,
824
908
  traits: user.traits ?? {}
825
909
  };
@@ -920,11 +1004,12 @@ function setScreen(name) {
920
1004
  function identify(id, traits = {}) {
921
1005
  const client = getClient();
922
1006
  if (!client) return;
923
- const anonymousId2 = client.getAnonymousId();
924
- client.getScope().setUser({ id, traits });
1007
+ const distinctId = String(id);
1008
+ const anonymousId2 = client.prepareIdentify(distinctId);
1009
+ client.getScope().setUser({ id: distinctId, traits });
925
1010
  const item = {
926
1011
  type: "identify",
927
- distinct_id: id,
1012
+ distinct_id: distinctId,
928
1013
  anonymous_id: anonymousId2,
929
1014
  traits: traits ?? {}
930
1015
  };
@@ -1854,18 +1939,56 @@ var SauronClient = class {
1854
1939
  return this.anonUsed ? getAnonymousId() : null;
1855
1940
  }
1856
1941
  /**
1857
- * Forget the current person: clear the scope user and mint a fresh anonymous
1858
- * id.
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.
1859
1944
  *
1860
1945
  * MUST BE CALLED ON LOGOUT. Without it, the next anonymous visitor on this
1861
1946
  * browser reuses the persisted anon id, and a later identify() aliases their
1862
- * activity to the previous account server-side, permanently.
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.
1863
1952
  */
1864
1953
  reset() {
1865
1954
  this.scope.setUser(null);
1866
1955
  resetAnonymousId();
1956
+ clearLastIdentified();
1957
+ rotateSessionId();
1867
1958
  this.anonUsed = false;
1868
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();
1991
+ }
1869
1992
  /** Stamp a fresh envelope (new `sent_at`, current context) around `items`. */
1870
1993
  makeEnvelope(items) {
1871
1994
  const header = {