@edraj/sauron-browser 1.3.0 → 1.4.0
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 +53 -0
- package/README.md +32 -12
- package/dist/index.cjs +105 -20
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +53 -10
- package/dist/index.d.ts +53 -10
- package/dist/index.js +105 -21
- package/dist/index.js.map +1 -1
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,59 @@
|
|
|
2
2
|
|
|
3
3
|
All notable changes to `@edraj/sauron-browser` are documented here.
|
|
4
4
|
|
|
5
|
+
## 1.4.0
|
|
6
|
+
|
|
7
|
+
### Fixed
|
|
8
|
+
|
|
9
|
+
- **`captureMessage()` poisoned the entire envelope.** It sent an `exception`
|
|
10
|
+
block with `type: null`, but the gateway's `ExceptionInfo.ty` is a
|
|
11
|
+
non-optional string with no default, so the envelope failed to deserialize and
|
|
12
|
+
was rejected whole (`400 invalid_envelope`) — taking every unrelated error,
|
|
13
|
+
event and transaction batched alongside it. Every SDK treats a 400 as
|
|
14
|
+
non-retryable, so the batch was dropped without a retry and without a trace.
|
|
15
|
+
A message item now carries no `exception` block at all and puts its text in
|
|
16
|
+
`message`, which is the shape the Python SDK already sent.
|
|
17
|
+
|
|
18
|
+
Worth knowing before you upgrade: this also changes server-side grouping.
|
|
19
|
+
Messages now fingerprint on their normalized text instead of piling into one
|
|
20
|
+
bucket keyed by a synthetic exception type, so existing message issues will
|
|
21
|
+
re-split into separate issues.
|
|
22
|
+
- **A failed offline-queue drain silently deleted the rest of the backlog.**
|
|
23
|
+
`drain()` empties `localStorage` in one shot, so from that point the parked
|
|
24
|
+
envelopes exist only in a local array — and a send failure re-parked just the
|
|
25
|
+
payload that had failed before returning, discarding every payload behind it.
|
|
26
|
+
The whole untried remainder is now re-parked, at the head, preserving the
|
|
27
|
+
oldest-first order the byte-cap eviction policy depends on. This is the plain
|
|
28
|
+
reconnect-then-one-500 case the queue exists for. A 401/403 mid-drain now
|
|
29
|
+
keeps the backlog too — the credentials may be fixed and the client
|
|
30
|
+
re-inited — and logs a warning instead of disabling in silence.
|
|
31
|
+
|
|
32
|
+
### Changed
|
|
33
|
+
|
|
34
|
+
- The anonymous id is now persisted in `localStorage` under `sauron.anon_id`
|
|
35
|
+
instead of being re-minted in memory on every page load. **Every web app's
|
|
36
|
+
reported active-user count drops sharply and permanently on the day this is
|
|
37
|
+
adopted** — the old behaviour counted page loads, not people (a 5-10x
|
|
38
|
+
inflation, all of it in the "guest" half of the Active Users report). The
|
|
39
|
+
drop is a data artifact, not a regression.
|
|
40
|
+
- The anonymous id is a durable first-party identifier stored on the user's
|
|
41
|
+
terminal. That is a retention and consent consequence, not just an
|
|
42
|
+
implementation detail.
|
|
43
|
+
- `ExceptionValue.type` is `string`, no longer `string | null`, and `ErrorItem`
|
|
44
|
+
gained an optional `message`. A TypeScript caller that builds these by hand
|
|
45
|
+
will now see a type error where it previously compiled — passing `null` there
|
|
46
|
+
is precisely what produced the envelope rejection above.
|
|
47
|
+
|
|
48
|
+
### Added
|
|
49
|
+
|
|
50
|
+
- `reset()` — clears the scope user and mints a fresh anonymous id.
|
|
51
|
+
**Call it on logout.** `setUser(null)` now calls it for you. Without it, the
|
|
52
|
+
next anonymous visitor on a shared browser reuses the persisted id and a
|
|
53
|
+
later `identify()` aliases their activity to the previous account,
|
|
54
|
+
server-side, permanently.
|
|
55
|
+
- `anonymous_id` is sent on the identify item only when the anonymous id was
|
|
56
|
+
actually used as a `distinct_id` in this browser session.
|
|
57
|
+
|
|
5
58
|
## 1.3.0
|
|
6
59
|
|
|
7
60
|
- **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;
|
|
@@ -221,13 +223,24 @@ function captureMessage(message: string, level?: Level, hint?: Hint): void
|
|
|
221
223
|
|
|
222
224
|
| Parameter | Type | Default | Description |
|
|
223
225
|
| --- | --- | --- | --- |
|
|
224
|
-
| `message` | `string` | — (required) | Becomes
|
|
226
|
+
| `message` | `string` | — (required) | Becomes the item's `message`. |
|
|
225
227
|
| `level` | `Level` | `'info'` | Severity. |
|
|
226
|
-
| `hint` | `Hint` | `undefined` | Only `fingerprint`, `event_id`, `
|
|
227
|
-
|
|
228
|
-
Emits an error item
|
|
229
|
-
|
|
230
|
-
|
|
228
|
+
| `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. |
|
|
229
|
+
|
|
230
|
+
Emits an error item that carries **no `exception` block at all** — a message is
|
|
231
|
+
not an exception — plus the current breadcrumb trail and the current screen.
|
|
232
|
+
Server-side it groups on the message-fallback fingerprint
|
|
233
|
+
(`message` + the message text, normalized), so distinct messages become distinct
|
|
234
|
+
issues instead of piling into one bucket keyed by a synthetic exception type.
|
|
235
|
+
Counts against `sampleRate` like any other error item. Returns `void`.
|
|
236
|
+
|
|
237
|
+
> Through 1.3.0 this shipped `exception: { type: null, value: message }`. The
|
|
238
|
+
> gateway's exception type is a non-nullable string, so that item failed to
|
|
239
|
+
> deserialize and the whole envelope came back `400 invalid_envelope` — and since
|
|
240
|
+
> a 400 is a non-retryable drop, **every other item batched with it (up to
|
|
241
|
+
> `maxBatch`, default 30) was silently lost too**. If you have a `beforeSend`
|
|
242
|
+
> hook or any code reading `item.exception` on message items, note that the field
|
|
243
|
+
> is now absent and `item.message` carries the text.
|
|
231
244
|
|
|
232
245
|
```ts
|
|
233
246
|
Sauron.captureMessage('payment provider returned a soft decline', 'warning', {
|
|
@@ -809,7 +822,7 @@ const appFrames = frames.filter((f) => isInAppFrame(f.filename));
|
|
|
809
822
|
|
|
810
823
|
```ts
|
|
811
824
|
const SDK_NAME: string // 'sauron.javascript'
|
|
812
|
-
const SDK_VERSION: string // '1.
|
|
825
|
+
const SDK_VERSION: string // '1.4.0'
|
|
813
826
|
```
|
|
814
827
|
|
|
815
828
|
The SDK identity embedded in `header.sdk` of every envelope.
|
|
@@ -933,7 +946,7 @@ Sauron.track('upgraded', {}, { tags: { tier: 'trial' } });
|
|
|
933
946
|
|
|
934
947
|
```html
|
|
935
948
|
<script type="module">
|
|
936
|
-
import { Sauron } from 'https://esm.sh/@edraj/sauron-browser@1.
|
|
949
|
+
import { Sauron } from 'https://esm.sh/@edraj/sauron-browser@1.4.0';
|
|
937
950
|
Sauron.init({ dsn: 'https://pk_test@ingest.example.com/42' });
|
|
938
951
|
</script>
|
|
939
952
|
```
|
|
@@ -994,9 +1007,16 @@ is parked in the offline queue.
|
|
|
994
1007
|
byte-capped at `maxQueueBytes` (default 1 MiB); the oldest entries are evicted
|
|
995
1008
|
first and at least one entry is always kept. It is drained at `init()`, at the
|
|
996
1009
|
start of every `flush()`, and on the window `online` event. If a drained
|
|
997
|
-
envelope still fails, it
|
|
998
|
-
|
|
999
|
-
|
|
1010
|
+
envelope still fails, **it and every envelope behind it are re-parked at the head
|
|
1011
|
+
of the queue** (order preserved) and draining stops to avoid a tight loop — a
|
|
1012
|
+
single 500 on reconnect costs you nothing. Same on a 401/403: the client
|
|
1013
|
+
disables itself but the backlog is kept, so fixing the key and re-`init()`ing
|
|
1014
|
+
still delivers it. When `localStorage` is unavailable the queue is disabled and
|
|
1015
|
+
failed envelopes are dropped.
|
|
1016
|
+
|
|
1017
|
+
> Through 1.3.0 the drain deleted the whole `localStorage` backlog up front and
|
|
1018
|
+
> re-parked only the one envelope that failed, so everything queued behind it was
|
|
1019
|
+
> lost — the exact reconnect-then-one-500 scenario the queue exists for.
|
|
1000
1020
|
|
|
1001
1021
|
**Page unload.** On `visibilitychange` → `hidden` and on `pagehide`, the pending
|
|
1002
1022
|
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.0";
|
|
12
12
|
function getGlobal() {
|
|
13
13
|
return globalThis;
|
|
14
14
|
}
|
|
@@ -91,6 +91,7 @@ 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";
|
|
94
95
|
function webStorage(name) {
|
|
95
96
|
try {
|
|
96
97
|
const s = globalThis[name];
|
|
@@ -123,6 +124,7 @@ function persistentId(cached, storage, key) {
|
|
|
123
124
|
}
|
|
124
125
|
var deviceId = null;
|
|
125
126
|
var sessionId = null;
|
|
127
|
+
var anonymousId = null;
|
|
126
128
|
function getDeviceId() {
|
|
127
129
|
deviceId = persistentId(deviceId, webStorage("localStorage"), DEVICE_ID_KEY);
|
|
128
130
|
return deviceId;
|
|
@@ -131,6 +133,40 @@ function getSessionId() {
|
|
|
131
133
|
sessionId = persistentId(sessionId, webStorage("sessionStorage"), SESSION_ID_KEY);
|
|
132
134
|
return sessionId;
|
|
133
135
|
}
|
|
136
|
+
function getAnonymousId() {
|
|
137
|
+
if (anonymousId) return anonymousId;
|
|
138
|
+
const storage = webStorage("localStorage");
|
|
139
|
+
if (storage) {
|
|
140
|
+
try {
|
|
141
|
+
const existing = storage.getItem(ANON_ID_KEY);
|
|
142
|
+
if (existing) {
|
|
143
|
+
anonymousId = existing;
|
|
144
|
+
return anonymousId;
|
|
145
|
+
}
|
|
146
|
+
} catch {
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
const fresh = `anon_${uuidv4()}`;
|
|
150
|
+
if (storage) {
|
|
151
|
+
try {
|
|
152
|
+
storage.setItem(ANON_ID_KEY, fresh);
|
|
153
|
+
} catch {
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
anonymousId = fresh;
|
|
157
|
+
return anonymousId;
|
|
158
|
+
}
|
|
159
|
+
function resetAnonymousId() {
|
|
160
|
+
anonymousId = null;
|
|
161
|
+
const storage = webStorage("localStorage");
|
|
162
|
+
if (storage) {
|
|
163
|
+
try {
|
|
164
|
+
storage.removeItem(ANON_ID_KEY);
|
|
165
|
+
} catch {
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
return getAnonymousId();
|
|
169
|
+
}
|
|
134
170
|
|
|
135
171
|
// src/context.ts
|
|
136
172
|
function getNavigator() {
|
|
@@ -628,12 +664,7 @@ function captureMessage(message, level = "info", hint) {
|
|
|
628
664
|
type: "error",
|
|
629
665
|
timestamp: nowIso(),
|
|
630
666
|
level,
|
|
631
|
-
|
|
632
|
-
type: null,
|
|
633
|
-
value: message,
|
|
634
|
-
mechanism: { type: "message", handled: true },
|
|
635
|
-
stacktrace: []
|
|
636
|
-
},
|
|
667
|
+
message,
|
|
637
668
|
breadcrumbs,
|
|
638
669
|
fingerprint: hint?.fingerprint ?? null,
|
|
639
670
|
session_id: getSessionId(),
|
|
@@ -889,12 +920,12 @@ function setScreen(name) {
|
|
|
889
920
|
function identify(id, traits = {}) {
|
|
890
921
|
const client = getClient();
|
|
891
922
|
if (!client) return;
|
|
892
|
-
const
|
|
923
|
+
const anonymousId2 = client.getAnonymousId();
|
|
893
924
|
client.getScope().setUser({ id, traits });
|
|
894
925
|
const item = {
|
|
895
926
|
type: "identify",
|
|
896
927
|
distinct_id: id,
|
|
897
|
-
anonymous_id:
|
|
928
|
+
anonymous_id: anonymousId2,
|
|
898
929
|
traits: traits ?? {}
|
|
899
930
|
};
|
|
900
931
|
client.captureItem(item);
|
|
@@ -1399,6 +1430,21 @@ var OfflineQueue = class {
|
|
|
1399
1430
|
if (entries.length) this.write([]);
|
|
1400
1431
|
return entries;
|
|
1401
1432
|
}
|
|
1433
|
+
/**
|
|
1434
|
+
* Put drained payloads BACK at the head, keeping their relative order.
|
|
1435
|
+
*
|
|
1436
|
+
* The counterpart to {@link drain}: a drain empties the store immediately, so
|
|
1437
|
+
* whatever the caller could not deliver only exists in its local array and is
|
|
1438
|
+
* lost the moment the caller returns. Re-parking at the head (rather than via
|
|
1439
|
+
* {@link enqueue}) keeps the queue oldest-first, which is what the byte-cap
|
|
1440
|
+
* eviction policy assumes — the oldest entry must stay the first one evicted.
|
|
1441
|
+
*/
|
|
1442
|
+
requeueFront(payloads) {
|
|
1443
|
+
if (!this.storage || payloads.length === 0) return;
|
|
1444
|
+
const entries = [...payloads, ...this.read()];
|
|
1445
|
+
this.evict(entries);
|
|
1446
|
+
this.write(entries);
|
|
1447
|
+
}
|
|
1402
1448
|
/** Non-destructive read of the current entries, oldest first. */
|
|
1403
1449
|
peek() {
|
|
1404
1450
|
return this.read();
|
|
@@ -1584,11 +1630,21 @@ var Transport = class {
|
|
|
1584
1630
|
}
|
|
1585
1631
|
}
|
|
1586
1632
|
}
|
|
1587
|
-
/**
|
|
1633
|
+
/**
|
|
1634
|
+
* Re-attempt any envelopes that were parked while offline.
|
|
1635
|
+
*
|
|
1636
|
+
* `drain()` empties `localStorage` in one shot, so from here on the ONLY copy
|
|
1637
|
+
* of the backlog is the local `payloads` array. Every early return therefore
|
|
1638
|
+
* has to re-park the whole untried remainder, not just the payload that
|
|
1639
|
+
* failed: this used to re-park the failing one and return, which silently
|
|
1640
|
+
* deleted every payload behind it — the exact reconnect-then-one-500 case the
|
|
1641
|
+
* queue exists for.
|
|
1642
|
+
*/
|
|
1588
1643
|
async drainOfflineQueue() {
|
|
1589
1644
|
if (this.disabled || !this.offline.available) return;
|
|
1590
1645
|
const payloads = this.offline.drain();
|
|
1591
|
-
for (
|
|
1646
|
+
for (let i = 0; i < payloads.length; i++) {
|
|
1647
|
+
const json = payloads[i];
|
|
1592
1648
|
let outcome;
|
|
1593
1649
|
try {
|
|
1594
1650
|
outcome = await this.post(json);
|
|
@@ -1596,13 +1652,14 @@ var Transport = class {
|
|
|
1596
1652
|
outcome = { action: "retry_backoff" };
|
|
1597
1653
|
}
|
|
1598
1654
|
if (outcome.action === "disable") {
|
|
1655
|
+
this.logger.warn("server rejected credentials while draining; disabling client");
|
|
1599
1656
|
this.disable();
|
|
1600
1657
|
this.onDisable();
|
|
1601
|
-
this.offline.
|
|
1658
|
+
this.offline.requeueFront(payloads.slice(i));
|
|
1602
1659
|
return;
|
|
1603
1660
|
}
|
|
1604
1661
|
if (outcome.action === "retry_after" || outcome.action === "retry_backoff") {
|
|
1605
|
-
this.offline.
|
|
1662
|
+
this.offline.requeueFront(payloads.slice(i));
|
|
1606
1663
|
return;
|
|
1607
1664
|
}
|
|
1608
1665
|
}
|
|
@@ -1712,8 +1769,17 @@ var SauronClient = class {
|
|
|
1712
1769
|
__publicField(this, "nativeFetch");
|
|
1713
1770
|
__publicField(this, "enabled", true);
|
|
1714
1771
|
__publicField(this, "installed", false);
|
|
1715
|
-
__publicField(this, "anonymousId", null);
|
|
1716
1772
|
__publicField(this, "beaconCleanup", null);
|
|
1773
|
+
/**
|
|
1774
|
+
* Whether the anonymous id has actually been USED as a `distinct_id` in this
|
|
1775
|
+
* browser session.
|
|
1776
|
+
*
|
|
1777
|
+
* A persisted id that has never been observed anonymously must not create a
|
|
1778
|
+
* permanent `identities` alias row on the server: aliasing is a durable
|
|
1779
|
+
* server-side binding of this browser profile to a named user, and an
|
|
1780
|
+
* identify() on a first-ever page load has no anonymous history to link.
|
|
1781
|
+
*/
|
|
1782
|
+
__publicField(this, "anonUsed", false);
|
|
1717
1783
|
this.options = options;
|
|
1718
1784
|
this.dsn = parseDsn(options.dsn);
|
|
1719
1785
|
this.logger = makeLogger(options.debug);
|
|
@@ -1780,15 +1846,25 @@ var SauronClient = class {
|
|
|
1780
1846
|
getDistinctId() {
|
|
1781
1847
|
const user = this.scope.getUser();
|
|
1782
1848
|
if (user.id) return user.id;
|
|
1783
|
-
|
|
1849
|
+
this.anonUsed = true;
|
|
1850
|
+
return getAnonymousId();
|
|
1784
1851
|
}
|
|
1785
|
-
/** The anonymous id, or null
|
|
1852
|
+
/** The anonymous id, or null when it was never actually used as an identity. */
|
|
1786
1853
|
getAnonymousId() {
|
|
1787
|
-
return this.
|
|
1854
|
+
return this.anonUsed ? getAnonymousId() : null;
|
|
1788
1855
|
}
|
|
1789
|
-
|
|
1790
|
-
|
|
1791
|
-
|
|
1856
|
+
/**
|
|
1857
|
+
* Forget the current person: clear the scope user and mint a fresh anonymous
|
|
1858
|
+
* id.
|
|
1859
|
+
*
|
|
1860
|
+
* MUST BE CALLED ON LOGOUT. Without it, the next anonymous visitor on this
|
|
1861
|
+
* browser reuses the persisted anon id, and a later identify() aliases their
|
|
1862
|
+
* activity to the previous account server-side, permanently.
|
|
1863
|
+
*/
|
|
1864
|
+
reset() {
|
|
1865
|
+
this.scope.setUser(null);
|
|
1866
|
+
resetAnonymousId();
|
|
1867
|
+
this.anonUsed = false;
|
|
1792
1868
|
}
|
|
1793
1869
|
/** Stamp a fresh envelope (new `sent_at`, current context) around `items`. */
|
|
1794
1870
|
makeEnvelope(items) {
|
|
@@ -2047,8 +2123,15 @@ function addBreadcrumb2(breadcrumb, hint) {
|
|
|
2047
2123
|
addBreadcrumb(breadcrumb, hint);
|
|
2048
2124
|
}
|
|
2049
2125
|
function setUser(user) {
|
|
2126
|
+
if (user === null) {
|
|
2127
|
+
getClient()?.reset();
|
|
2128
|
+
return;
|
|
2129
|
+
}
|
|
2050
2130
|
getClient()?.getScope().setUser(user);
|
|
2051
2131
|
}
|
|
2132
|
+
function reset() {
|
|
2133
|
+
getClient()?.reset();
|
|
2134
|
+
}
|
|
2052
2135
|
function setTag(key, value) {
|
|
2053
2136
|
getClient()?.getScope().setTag(key, value);
|
|
2054
2137
|
}
|
|
@@ -2078,6 +2161,7 @@ var Sauron = {
|
|
|
2078
2161
|
identify: identify2,
|
|
2079
2162
|
addBreadcrumb: addBreadcrumb2,
|
|
2080
2163
|
setUser,
|
|
2164
|
+
reset,
|
|
2081
2165
|
setTag,
|
|
2082
2166
|
setTags,
|
|
2083
2167
|
setContext,
|
|
@@ -2117,6 +2201,7 @@ exports.isInAppFrame = isInAppFrame;
|
|
|
2117
2201
|
exports.parseDsn = parseDsn;
|
|
2118
2202
|
exports.parseError = parseError;
|
|
2119
2203
|
exports.parseStackString = parseStackString;
|
|
2204
|
+
exports.reset = reset;
|
|
2120
2205
|
exports.setContext = setContext;
|
|
2121
2206
|
exports.setExtra = setExtra;
|
|
2122
2207
|
exports.setScreen = setScreen2;
|