@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 +34 -0
- package/README.md +46 -3
- package/dist/index.cjs +131 -8
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +62 -8
- package/dist/index.d.ts +62 -8
- package/dist/index.js +131 -8
- package/dist/index.js.map +1 -1
- package/package.json +1 -1
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
|
|
926
|
-
|
|
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.
|
|
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.
|
|
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
|
|
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
|
|
924
|
-
client.
|
|
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:
|
|
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
|
|
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 = {
|