@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/CHANGELOG.md +87 -0
- package/README.md +77 -14
- package/dist/index.cjs +231 -23
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +108 -11
- package/dist/index.d.ts +108 -11
- package/dist/index.js +231 -24
- package/dist/index.js.map +1 -1
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,93 @@
|
|
|
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
|
+
|
|
39
|
+
## 1.4.0
|
|
40
|
+
|
|
41
|
+
### Fixed
|
|
42
|
+
|
|
43
|
+
- **`captureMessage()` poisoned the entire envelope.** It sent an `exception`
|
|
44
|
+
block with `type: null`, but the gateway's `ExceptionInfo.ty` is a
|
|
45
|
+
non-optional string with no default, so the envelope failed to deserialize and
|
|
46
|
+
was rejected whole (`400 invalid_envelope`) — taking every unrelated error,
|
|
47
|
+
event and transaction batched alongside it. Every SDK treats a 400 as
|
|
48
|
+
non-retryable, so the batch was dropped without a retry and without a trace.
|
|
49
|
+
A message item now carries no `exception` block at all and puts its text in
|
|
50
|
+
`message`, which is the shape the Python SDK already sent.
|
|
51
|
+
|
|
52
|
+
Worth knowing before you upgrade: this also changes server-side grouping.
|
|
53
|
+
Messages now fingerprint on their normalized text instead of piling into one
|
|
54
|
+
bucket keyed by a synthetic exception type, so existing message issues will
|
|
55
|
+
re-split into separate issues.
|
|
56
|
+
- **A failed offline-queue drain silently deleted the rest of the backlog.**
|
|
57
|
+
`drain()` empties `localStorage` in one shot, so from that point the parked
|
|
58
|
+
envelopes exist only in a local array — and a send failure re-parked just the
|
|
59
|
+
payload that had failed before returning, discarding every payload behind it.
|
|
60
|
+
The whole untried remainder is now re-parked, at the head, preserving the
|
|
61
|
+
oldest-first order the byte-cap eviction policy depends on. This is the plain
|
|
62
|
+
reconnect-then-one-500 case the queue exists for. A 401/403 mid-drain now
|
|
63
|
+
keeps the backlog too — the credentials may be fixed and the client
|
|
64
|
+
re-inited — and logs a warning instead of disabling in silence.
|
|
65
|
+
|
|
66
|
+
### Changed
|
|
67
|
+
|
|
68
|
+
- The anonymous id is now persisted in `localStorage` under `sauron.anon_id`
|
|
69
|
+
instead of being re-minted in memory on every page load. **Every web app's
|
|
70
|
+
reported active-user count drops sharply and permanently on the day this is
|
|
71
|
+
adopted** — the old behaviour counted page loads, not people (a 5-10x
|
|
72
|
+
inflation, all of it in the "guest" half of the Active Users report). The
|
|
73
|
+
drop is a data artifact, not a regression.
|
|
74
|
+
- The anonymous id is a durable first-party identifier stored on the user's
|
|
75
|
+
terminal. That is a retention and consent consequence, not just an
|
|
76
|
+
implementation detail.
|
|
77
|
+
- `ExceptionValue.type` is `string`, no longer `string | null`, and `ErrorItem`
|
|
78
|
+
gained an optional `message`. A TypeScript caller that builds these by hand
|
|
79
|
+
will now see a type error where it previously compiled — passing `null` there
|
|
80
|
+
is precisely what produced the envelope rejection above.
|
|
81
|
+
|
|
82
|
+
### Added
|
|
83
|
+
|
|
84
|
+
- `reset()` — clears the scope user and mints a fresh anonymous id.
|
|
85
|
+
**Call it on logout.** `setUser(null)` now calls it for you. Without it, the
|
|
86
|
+
next anonymous visitor on a shared browser reuses the persisted id and a
|
|
87
|
+
later `identify()` aliases their activity to the previous account,
|
|
88
|
+
server-side, permanently.
|
|
89
|
+
- `anonymous_id` is sent on the identify item only when the anonymous id was
|
|
90
|
+
actually used as a `distinct_id` in this browser session.
|
|
91
|
+
|
|
5
92
|
## 1.3.0
|
|
6
93
|
|
|
7
94
|
- **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
|
-
|
|
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;
|
|
@@ -115,6 +117,42 @@ Sauron.init({
|
|
|
115
117
|
});
|
|
116
118
|
```
|
|
117
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
|
+
|
|
118
156
|
## API reference
|
|
119
157
|
|
|
120
158
|
Everything is exported both as a named function and as a member of the `Sauron`
|
|
@@ -221,13 +259,24 @@ function captureMessage(message: string, level?: Level, hint?: Hint): void
|
|
|
221
259
|
|
|
222
260
|
| Parameter | Type | Default | Description |
|
|
223
261
|
| --- | --- | --- | --- |
|
|
224
|
-
| `message` | `string` | — (required) | Becomes
|
|
262
|
+
| `message` | `string` | — (required) | Becomes the item's `message`. |
|
|
225
263
|
| `level` | `Level` | `'info'` | Severity. |
|
|
226
|
-
| `hint` | `Hint` | `undefined` | Only `fingerprint`, `event_id`, `
|
|
227
|
-
|
|
228
|
-
Emits an error item
|
|
229
|
-
|
|
230
|
-
|
|
264
|
+
| `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. |
|
|
265
|
+
|
|
266
|
+
Emits an error item that carries **no `exception` block at all** — a message is
|
|
267
|
+
not an exception — plus the current breadcrumb trail and the current screen.
|
|
268
|
+
Server-side it groups on the message-fallback fingerprint
|
|
269
|
+
(`message` + the message text, normalized), so distinct messages become distinct
|
|
270
|
+
issues instead of piling into one bucket keyed by a synthetic exception type.
|
|
271
|
+
Counts against `sampleRate` like any other error item. Returns `void`.
|
|
272
|
+
|
|
273
|
+
> Through 1.3.0 this shipped `exception: { type: null, value: message }`. The
|
|
274
|
+
> gateway's exception type is a non-nullable string, so that item failed to
|
|
275
|
+
> deserialize and the whole envelope came back `400 invalid_envelope` — and since
|
|
276
|
+
> a 400 is a non-retryable drop, **every other item batched with it (up to
|
|
277
|
+
> `maxBatch`, default 30) was silently lost too**. If you have a `beforeSend`
|
|
278
|
+
> hook or any code reading `item.exception` on message items, note that the field
|
|
279
|
+
> is now absent and `item.message` carries the text.
|
|
231
280
|
|
|
232
281
|
```ts
|
|
233
282
|
Sauron.captureMessage('payment provider returned a soft decline', 'warning', {
|
|
@@ -809,7 +858,7 @@ const appFrames = frames.filter((f) => isInAppFrame(f.filename));
|
|
|
809
858
|
|
|
810
859
|
```ts
|
|
811
860
|
const SDK_NAME: string // 'sauron.javascript'
|
|
812
|
-
const SDK_VERSION: string // '1.
|
|
861
|
+
const SDK_VERSION: string // '1.4.0'
|
|
813
862
|
```
|
|
814
863
|
|
|
815
864
|
The SDK identity embedded in `header.sdk` of every envelope.
|
|
@@ -909,8 +958,15 @@ Other scope data:
|
|
|
909
958
|
`captureException`.
|
|
910
959
|
- **identity** — `device_id` persists in `localStorage` under
|
|
911
960
|
`sauron.device_id`; `session_id` persists in `sessionStorage` under
|
|
912
|
-
`sauron.session_id
|
|
913
|
-
|
|
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.
|
|
914
970
|
|
|
915
971
|
```ts
|
|
916
972
|
Sauron.init({ dsn, tags: { tier: 'free' }, extra: { build: 'ci-42' } });
|
|
@@ -933,7 +989,7 @@ Sauron.track('upgraded', {}, { tags: { tier: 'trial' } });
|
|
|
933
989
|
|
|
934
990
|
```html
|
|
935
991
|
<script type="module">
|
|
936
|
-
import { Sauron } from 'https://esm.sh/@edraj/sauron-browser@1.
|
|
992
|
+
import { Sauron } from 'https://esm.sh/@edraj/sauron-browser@1.4.1';
|
|
937
993
|
Sauron.init({ dsn: 'https://pk_test@ingest.example.com/42' });
|
|
938
994
|
</script>
|
|
939
995
|
```
|
|
@@ -994,9 +1050,16 @@ is parked in the offline queue.
|
|
|
994
1050
|
byte-capped at `maxQueueBytes` (default 1 MiB); the oldest entries are evicted
|
|
995
1051
|
first and at least one entry is always kept. It is drained at `init()`, at the
|
|
996
1052
|
start of every `flush()`, and on the window `online` event. If a drained
|
|
997
|
-
envelope still fails, it
|
|
998
|
-
|
|
999
|
-
|
|
1053
|
+
envelope still fails, **it and every envelope behind it are re-parked at the head
|
|
1054
|
+
of the queue** (order preserved) and draining stops to avoid a tight loop — a
|
|
1055
|
+
single 500 on reconnect costs you nothing. Same on a 401/403: the client
|
|
1056
|
+
disables itself but the backlog is kept, so fixing the key and re-`init()`ing
|
|
1057
|
+
still delivers it. When `localStorage` is unavailable the queue is disabled and
|
|
1058
|
+
failed envelopes are dropped.
|
|
1059
|
+
|
|
1060
|
+
> Through 1.3.0 the drain deleted the whole `localStorage` backlog up front and
|
|
1061
|
+
> re-parked only the one envelope that failed, so everything queued behind it was
|
|
1062
|
+
> lost — the exact reconnect-then-one-500 scenario the queue exists for.
|
|
1000
1063
|
|
|
1001
1064
|
**Page unload.** On `visibilitychange` → `hidden` and on `pagehide`, the pending
|
|
1002
1065
|
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.
|
|
11
|
+
var SDK_VERSION = "1.4.1";
|
|
12
12
|
function getGlobal() {
|
|
13
13
|
return globalThis;
|
|
14
14
|
}
|
|
@@ -91,6 +91,30 @@ 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";
|
|
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
|
+
}
|
|
94
118
|
function webStorage(name) {
|
|
95
119
|
try {
|
|
96
120
|
const s = globalThis[name];
|
|
@@ -123,6 +147,8 @@ function persistentId(cached, storage, key) {
|
|
|
123
147
|
}
|
|
124
148
|
var deviceId = null;
|
|
125
149
|
var sessionId = null;
|
|
150
|
+
var anonymousId = null;
|
|
151
|
+
var lastIdentified = null;
|
|
126
152
|
function getDeviceId() {
|
|
127
153
|
deviceId = persistentId(deviceId, webStorage("localStorage"), DEVICE_ID_KEY);
|
|
128
154
|
return deviceId;
|
|
@@ -131,6 +157,82 @@ function getSessionId() {
|
|
|
131
157
|
sessionId = persistentId(sessionId, webStorage("sessionStorage"), SESSION_ID_KEY);
|
|
132
158
|
return sessionId;
|
|
133
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
|
+
}
|
|
171
|
+
function getAnonymousId() {
|
|
172
|
+
if (anonymousId) return anonymousId;
|
|
173
|
+
const storage = webStorage("localStorage");
|
|
174
|
+
if (storage) {
|
|
175
|
+
try {
|
|
176
|
+
const existing = storage.getItem(ANON_ID_KEY);
|
|
177
|
+
if (existing) {
|
|
178
|
+
anonymousId = existing;
|
|
179
|
+
return anonymousId;
|
|
180
|
+
}
|
|
181
|
+
} catch {
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
const fresh = `anon_${uuidv4()}`;
|
|
185
|
+
if (storage) {
|
|
186
|
+
try {
|
|
187
|
+
storage.setItem(ANON_ID_KEY, fresh);
|
|
188
|
+
} catch {
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
anonymousId = fresh;
|
|
192
|
+
return anonymousId;
|
|
193
|
+
}
|
|
194
|
+
function resetAnonymousId() {
|
|
195
|
+
anonymousId = null;
|
|
196
|
+
const storage = webStorage("localStorage");
|
|
197
|
+
if (storage) {
|
|
198
|
+
try {
|
|
199
|
+
storage.removeItem(ANON_ID_KEY);
|
|
200
|
+
} catch {
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
return getAnonymousId();
|
|
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
|
+
}
|
|
134
236
|
|
|
135
237
|
// src/context.ts
|
|
136
238
|
function getNavigator() {
|
|
@@ -628,12 +730,7 @@ function captureMessage(message, level = "info", hint) {
|
|
|
628
730
|
type: "error",
|
|
629
731
|
timestamp: nowIso(),
|
|
630
732
|
level,
|
|
631
|
-
|
|
632
|
-
type: null,
|
|
633
|
-
value: message,
|
|
634
|
-
mechanism: { type: "message", handled: true },
|
|
635
|
-
stacktrace: []
|
|
636
|
-
},
|
|
733
|
+
message,
|
|
637
734
|
breadcrumbs,
|
|
638
735
|
fingerprint: hint?.fingerprint ?? null,
|
|
639
736
|
session_id: getSessionId(),
|
|
@@ -782,13 +879,31 @@ var Scope = class {
|
|
|
782
879
|
this.maxBreadcrumbs = Math.max(0, max);
|
|
783
880
|
this.trim();
|
|
784
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
|
+
*/
|
|
785
900
|
setUser(user) {
|
|
786
901
|
if (user === null) {
|
|
787
902
|
this.user = null;
|
|
788
903
|
return;
|
|
789
904
|
}
|
|
790
905
|
this.user = {
|
|
791
|
-
id: user.id
|
|
906
|
+
id: user.id === null || user.id === void 0 ? null : String(user.id),
|
|
792
907
|
email: user.email ?? null,
|
|
793
908
|
traits: user.traits ?? {}
|
|
794
909
|
};
|
|
@@ -889,12 +1004,13 @@ function setScreen(name) {
|
|
|
889
1004
|
function identify(id, traits = {}) {
|
|
890
1005
|
const client = getClient();
|
|
891
1006
|
if (!client) return;
|
|
892
|
-
const
|
|
893
|
-
client.
|
|
1007
|
+
const distinctId = String(id);
|
|
1008
|
+
const anonymousId2 = client.prepareIdentify(distinctId);
|
|
1009
|
+
client.getScope().setUser({ id: distinctId, traits });
|
|
894
1010
|
const item = {
|
|
895
1011
|
type: "identify",
|
|
896
|
-
distinct_id:
|
|
897
|
-
anonymous_id:
|
|
1012
|
+
distinct_id: distinctId,
|
|
1013
|
+
anonymous_id: anonymousId2,
|
|
898
1014
|
traits: traits ?? {}
|
|
899
1015
|
};
|
|
900
1016
|
client.captureItem(item);
|
|
@@ -1399,6 +1515,21 @@ var OfflineQueue = class {
|
|
|
1399
1515
|
if (entries.length) this.write([]);
|
|
1400
1516
|
return entries;
|
|
1401
1517
|
}
|
|
1518
|
+
/**
|
|
1519
|
+
* Put drained payloads BACK at the head, keeping their relative order.
|
|
1520
|
+
*
|
|
1521
|
+
* The counterpart to {@link drain}: a drain empties the store immediately, so
|
|
1522
|
+
* whatever the caller could not deliver only exists in its local array and is
|
|
1523
|
+
* lost the moment the caller returns. Re-parking at the head (rather than via
|
|
1524
|
+
* {@link enqueue}) keeps the queue oldest-first, which is what the byte-cap
|
|
1525
|
+
* eviction policy assumes — the oldest entry must stay the first one evicted.
|
|
1526
|
+
*/
|
|
1527
|
+
requeueFront(payloads) {
|
|
1528
|
+
if (!this.storage || payloads.length === 0) return;
|
|
1529
|
+
const entries = [...payloads, ...this.read()];
|
|
1530
|
+
this.evict(entries);
|
|
1531
|
+
this.write(entries);
|
|
1532
|
+
}
|
|
1402
1533
|
/** Non-destructive read of the current entries, oldest first. */
|
|
1403
1534
|
peek() {
|
|
1404
1535
|
return this.read();
|
|
@@ -1584,11 +1715,21 @@ var Transport = class {
|
|
|
1584
1715
|
}
|
|
1585
1716
|
}
|
|
1586
1717
|
}
|
|
1587
|
-
/**
|
|
1718
|
+
/**
|
|
1719
|
+
* Re-attempt any envelopes that were parked while offline.
|
|
1720
|
+
*
|
|
1721
|
+
* `drain()` empties `localStorage` in one shot, so from here on the ONLY copy
|
|
1722
|
+
* of the backlog is the local `payloads` array. Every early return therefore
|
|
1723
|
+
* has to re-park the whole untried remainder, not just the payload that
|
|
1724
|
+
* failed: this used to re-park the failing one and return, which silently
|
|
1725
|
+
* deleted every payload behind it — the exact reconnect-then-one-500 case the
|
|
1726
|
+
* queue exists for.
|
|
1727
|
+
*/
|
|
1588
1728
|
async drainOfflineQueue() {
|
|
1589
1729
|
if (this.disabled || !this.offline.available) return;
|
|
1590
1730
|
const payloads = this.offline.drain();
|
|
1591
|
-
for (
|
|
1731
|
+
for (let i = 0; i < payloads.length; i++) {
|
|
1732
|
+
const json = payloads[i];
|
|
1592
1733
|
let outcome;
|
|
1593
1734
|
try {
|
|
1594
1735
|
outcome = await this.post(json);
|
|
@@ -1596,13 +1737,14 @@ var Transport = class {
|
|
|
1596
1737
|
outcome = { action: "retry_backoff" };
|
|
1597
1738
|
}
|
|
1598
1739
|
if (outcome.action === "disable") {
|
|
1740
|
+
this.logger.warn("server rejected credentials while draining; disabling client");
|
|
1599
1741
|
this.disable();
|
|
1600
1742
|
this.onDisable();
|
|
1601
|
-
this.offline.
|
|
1743
|
+
this.offline.requeueFront(payloads.slice(i));
|
|
1602
1744
|
return;
|
|
1603
1745
|
}
|
|
1604
1746
|
if (outcome.action === "retry_after" || outcome.action === "retry_backoff") {
|
|
1605
|
-
this.offline.
|
|
1747
|
+
this.offline.requeueFront(payloads.slice(i));
|
|
1606
1748
|
return;
|
|
1607
1749
|
}
|
|
1608
1750
|
}
|
|
@@ -1712,8 +1854,17 @@ var SauronClient = class {
|
|
|
1712
1854
|
__publicField(this, "nativeFetch");
|
|
1713
1855
|
__publicField(this, "enabled", true);
|
|
1714
1856
|
__publicField(this, "installed", false);
|
|
1715
|
-
__publicField(this, "anonymousId", null);
|
|
1716
1857
|
__publicField(this, "beaconCleanup", null);
|
|
1858
|
+
/**
|
|
1859
|
+
* Whether the anonymous id has actually been USED as a `distinct_id` in this
|
|
1860
|
+
* browser session.
|
|
1861
|
+
*
|
|
1862
|
+
* A persisted id that has never been observed anonymously must not create a
|
|
1863
|
+
* permanent `identities` alias row on the server: aliasing is a durable
|
|
1864
|
+
* server-side binding of this browser profile to a named user, and an
|
|
1865
|
+
* identify() on a first-ever page load has no anonymous history to link.
|
|
1866
|
+
*/
|
|
1867
|
+
__publicField(this, "anonUsed", false);
|
|
1717
1868
|
this.options = options;
|
|
1718
1869
|
this.dsn = parseDsn(options.dsn);
|
|
1719
1870
|
this.logger = makeLogger(options.debug);
|
|
@@ -1780,15 +1931,63 @@ var SauronClient = class {
|
|
|
1780
1931
|
getDistinctId() {
|
|
1781
1932
|
const user = this.scope.getUser();
|
|
1782
1933
|
if (user.id) return user.id;
|
|
1783
|
-
|
|
1934
|
+
this.anonUsed = true;
|
|
1935
|
+
return getAnonymousId();
|
|
1784
1936
|
}
|
|
1785
|
-
/** The anonymous id, or null
|
|
1937
|
+
/** The anonymous id, or null when it was never actually used as an identity. */
|
|
1786
1938
|
getAnonymousId() {
|
|
1787
|
-
return this.
|
|
1939
|
+
return this.anonUsed ? getAnonymousId() : null;
|
|
1788
1940
|
}
|
|
1789
|
-
|
|
1790
|
-
|
|
1791
|
-
|
|
1941
|
+
/**
|
|
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.
|
|
1944
|
+
*
|
|
1945
|
+
* MUST BE CALLED ON LOGOUT. Without it, the next anonymous visitor on this
|
|
1946
|
+
* browser reuses the persisted anon id, and a later identify() aliases their
|
|
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.
|
|
1952
|
+
*/
|
|
1953
|
+
reset() {
|
|
1954
|
+
this.scope.setUser(null);
|
|
1955
|
+
resetAnonymousId();
|
|
1956
|
+
clearLastIdentified();
|
|
1957
|
+
rotateSessionId();
|
|
1958
|
+
this.anonUsed = false;
|
|
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();
|
|
1792
1991
|
}
|
|
1793
1992
|
/** Stamp a fresh envelope (new `sent_at`, current context) around `items`. */
|
|
1794
1993
|
makeEnvelope(items) {
|
|
@@ -2047,8 +2246,15 @@ function addBreadcrumb2(breadcrumb, hint) {
|
|
|
2047
2246
|
addBreadcrumb(breadcrumb, hint);
|
|
2048
2247
|
}
|
|
2049
2248
|
function setUser(user) {
|
|
2249
|
+
if (user === null) {
|
|
2250
|
+
getClient()?.reset();
|
|
2251
|
+
return;
|
|
2252
|
+
}
|
|
2050
2253
|
getClient()?.getScope().setUser(user);
|
|
2051
2254
|
}
|
|
2255
|
+
function reset() {
|
|
2256
|
+
getClient()?.reset();
|
|
2257
|
+
}
|
|
2052
2258
|
function setTag(key, value) {
|
|
2053
2259
|
getClient()?.getScope().setTag(key, value);
|
|
2054
2260
|
}
|
|
@@ -2078,6 +2284,7 @@ var Sauron = {
|
|
|
2078
2284
|
identify: identify2,
|
|
2079
2285
|
addBreadcrumb: addBreadcrumb2,
|
|
2080
2286
|
setUser,
|
|
2287
|
+
reset,
|
|
2081
2288
|
setTag,
|
|
2082
2289
|
setTags,
|
|
2083
2290
|
setContext,
|
|
@@ -2117,6 +2324,7 @@ exports.isInAppFrame = isInAppFrame;
|
|
|
2117
2324
|
exports.parseDsn = parseDsn;
|
|
2118
2325
|
exports.parseError = parseError;
|
|
2119
2326
|
exports.parseStackString = parseStackString;
|
|
2327
|
+
exports.reset = reset;
|
|
2120
2328
|
exports.setContext = setContext;
|
|
2121
2329
|
exports.setExtra = setExtra;
|
|
2122
2330
|
exports.setScreen = setScreen2;
|