@edraj/sauron-browser 1.4.0 → 1.5.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 +73 -0
- package/README.md +174 -4
- package/dist/index.cjs +165 -9
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +126 -9
- package/dist/index.d.ts +126 -9
- package/dist/index.js +164 -10
- package/dist/index.js.map +1 -1
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,79 @@
|
|
|
2
2
|
|
|
3
3
|
All notable changes to `@edraj/sauron-browser` are documented here.
|
|
4
4
|
|
|
5
|
+
## 1.5.0
|
|
6
|
+
|
|
7
|
+
### Added
|
|
8
|
+
|
|
9
|
+
- **`tags` and `extra` on transactions.** `trackTransaction` now accepts two
|
|
10
|
+
developer-supplied maps: `tags` (flat string→string, indexed) and `extra`
|
|
11
|
+
(freeform JSON). `extra` is where a request body, a response body, an order
|
|
12
|
+
id or a retry count goes — the span that times an HTTP call can now carry
|
|
13
|
+
what the call actually sent and received.
|
|
14
|
+
|
|
15
|
+
Both are visible on the new **Transactions** page and in the session
|
|
16
|
+
timeline, and both are searchable: `@tag.tier:premium`,
|
|
17
|
+
`extra.order_id:9001`, or `extra.response:~9001` to match a substring
|
|
18
|
+
*inside* a stored response body.
|
|
19
|
+
|
|
20
|
+
**They are per-call only.** Unlike `captureException` and `track`, a
|
|
21
|
+
transaction does not inherit the scope — `setTag()` / `setExtra()` defaults
|
|
22
|
+
are not merged in. Transactions are the highest-volume signal an app emits,
|
|
23
|
+
one per navigation and per request, so inheriting a global blob would write
|
|
24
|
+
it onto every row. This asymmetry is deliberate and is documented on the
|
|
25
|
+
method.
|
|
26
|
+
|
|
27
|
+
`extra` is serialized and capped at **16 KB**. Past that the whole map is
|
|
28
|
+
replaced with a `{"_truncated": true, "_bytes": N}` marker, and the
|
|
29
|
+
dashboard says so on the row rather than showing a short object that looks
|
|
30
|
+
complete. The cap is not cosmetic: envelopes are batched, and one oversized
|
|
31
|
+
body would push the whole envelope past the ingest limit and take every
|
|
32
|
+
unrelated span sent with it — a silent loss of data nobody asked about.
|
|
33
|
+
Size is measured in UTF-8 bytes, so non-ASCII payloads are counted at what
|
|
34
|
+
they actually cost on the wire.
|
|
35
|
+
|
|
36
|
+
Nothing in `extra` is scrubbed. `beforeSend` remains the redaction seam;
|
|
37
|
+
think twice before attaching a body that can carry tokens or personal data.
|
|
38
|
+
|
|
39
|
+
An app that sets neither field serializes byte-identically to before: both
|
|
40
|
+
keys are omitted when empty, never sent as `null`.
|
|
41
|
+
|
|
42
|
+
Signature: `trackTransaction({ …, tags?: Record<string, string>, extra?: Record<string, unknown> })`. `MAX_TRANSACTION_EXTRA_BYTES` and `capTransactionExtra` are now exported from the package entrypoint, so a caller can size a payload before attaching it.
|
|
43
|
+
|
|
44
|
+
## 1.4.1
|
|
45
|
+
|
|
46
|
+
### Added
|
|
47
|
+
|
|
48
|
+
- **Auto-reset on identity switch.** `identify()` now detects a login by a
|
|
49
|
+
DIFFERENT user than last time on the same device — the common case of a
|
|
50
|
+
forgotten `reset()` on logout — and mints a fresh anonymous id (and rotates
|
|
51
|
+
the session id) before sending, so `anonymous_id` is `null` instead of an
|
|
52
|
+
alias to the previous person. This can't undo an alias already sent under
|
|
53
|
+
the old id — still call `reset()` on logout — but it bounds a missed
|
|
54
|
+
`reset()` to one corrupted guest window instead of every one after it. To
|
|
55
|
+
detect the switch, `identify()` persists a short one-way digest (never the
|
|
56
|
+
id itself; see `hashIdentity`) of the last identified user in `localStorage`
|
|
57
|
+
under `sauron.last_identified`. Like the anonymous id, this is a durable
|
|
58
|
+
first-party value stored on the user's terminal — a retention and consent
|
|
59
|
+
consequence, not just an implementation detail.
|
|
60
|
+
|
|
61
|
+
The stored value carries a format tag: `v1:<digest>`, byte-identical to what
|
|
62
|
+
the Flutter SDK writes under the same key. A value with no tag or an
|
|
63
|
+
unrecognised one reads as "nobody has identified on this device yet" and is
|
|
64
|
+
rewritten in the current format on the next `identify()`. That matters
|
|
65
|
+
because the digest's shape is not frozen — if it ever widens again, an
|
|
66
|
+
untagged store could not tell "a digest I no longer produce" from "a
|
|
67
|
+
different person", so every returning user's next `identify()` would be read
|
|
68
|
+
as a switch and would rotate their anonymous id and session, once, silently.
|
|
69
|
+
The tag turns that into one missed switch per device instead.
|
|
70
|
+
|
|
71
|
+
### Changed
|
|
72
|
+
|
|
73
|
+
- `reset()` now also rotates the session id (`sauron.session_id`). The
|
|
74
|
+
server's `bump_session` is last-write-wins on `distinct_id`, so without
|
|
75
|
+
this a single `sessions` row could otherwise end up serially representing
|
|
76
|
+
two different people and recording only whichever wrote last.
|
|
77
|
+
|
|
5
78
|
## 1.4.0
|
|
6
79
|
|
|
7
80
|
### 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`
|
|
@@ -322,10 +358,28 @@ function trackTransaction(input: TransactionInput): void
|
|
|
322
358
|
| `httpMethod` | `string \| null` | `null` | For `http` ops. |
|
|
323
359
|
| `httpStatus` | `number \| null` | `null` | For `http` ops. |
|
|
324
360
|
| `url` | `string \| null` | `null` | For `http` ops. |
|
|
361
|
+
| `tags` | `Record<string, string>` | omitted | Indexed string→string labels. Filter with `@tag.key:value` on the Transactions page. |
|
|
362
|
+
| `extra` | `Record<string, unknown>` | omitted | Freeform JSON — request body, response body, SQL text, row counts. Searchable with `extra.key:value`. |
|
|
325
363
|
|
|
326
364
|
The item is stamped with the current distinct id, session id and timestamp.
|
|
327
365
|
Never sampled. Returns `void`.
|
|
328
366
|
|
|
367
|
+
**`tags` and `extra` are per-call only.** Unlike `track()` and
|
|
368
|
+
`captureException()`, a transaction does **not** inherit the scope:
|
|
369
|
+
`setTag()` / `setExtra()` defaults are not merged in. Transactions are the
|
|
370
|
+
highest-volume signal a page emits — one per navigation and per fetch — so
|
|
371
|
+
inheriting a global blob would write it onto every row.
|
|
372
|
+
|
|
373
|
+
`extra` is serialized and capped at **16 KB** (`MAX_TRANSACTION_EXTRA_BYTES`).
|
|
374
|
+
Past that the whole map is replaced with `{ _truncated: true, _bytes: N }` and
|
|
375
|
+
the dashboard says so on the row. The cap is not cosmetic: envelopes are
|
|
376
|
+
batched, and one oversized body would push the whole envelope past the ingest
|
|
377
|
+
limit and drop every unrelated span sent with it. Size is measured in **UTF-8
|
|
378
|
+
bytes**, so a body of non-ASCII text counts what it will actually cost.
|
|
379
|
+
|
|
380
|
+
Nothing in `extra` is scrubbed. Use `beforeSend` for redaction, and think twice
|
|
381
|
+
before attaching a body that can carry tokens or personal data.
|
|
382
|
+
|
|
329
383
|
```ts
|
|
330
384
|
const started = performance.now();
|
|
331
385
|
const res = await fetch('/api/orders');
|
|
@@ -340,6 +394,115 @@ Sauron.trackTransaction({
|
|
|
340
394
|
});
|
|
341
395
|
```
|
|
342
396
|
|
|
397
|
+
#### Example: a `fetch` wrapper that records both bodies
|
|
398
|
+
|
|
399
|
+
Drop-in replacement for `fetch` on the calls you care about. Note the
|
|
400
|
+
`res.clone()` — reading the body consumes the stream, so the caller would get an
|
|
401
|
+
empty response otherwise.
|
|
402
|
+
|
|
403
|
+
```ts
|
|
404
|
+
import * as Sauron from '@edraj/sauron-browser';
|
|
405
|
+
|
|
406
|
+
export async function tracedFetch(
|
|
407
|
+
input: string,
|
|
408
|
+
init: RequestInit = {},
|
|
409
|
+
): Promise<Response> {
|
|
410
|
+
const method = (init.method ?? 'GET').toUpperCase();
|
|
411
|
+
const path = new URL(input, location.origin).pathname;
|
|
412
|
+
const started = performance.now();
|
|
413
|
+
|
|
414
|
+
try {
|
|
415
|
+
const res = await fetch(input, init);
|
|
416
|
+
// Clone BEFORE reading: a Response body is a one-shot stream, and
|
|
417
|
+
// consuming it here would hand the caller an empty one.
|
|
418
|
+
const responseBody = await res.clone().text();
|
|
419
|
+
|
|
420
|
+
Sauron.trackTransaction({
|
|
421
|
+
name: `${method} ${path}`, // grouping key — keep it low cardinality
|
|
422
|
+
op: 'http',
|
|
423
|
+
durationMs: performance.now() - started,
|
|
424
|
+
httpMethod: method,
|
|
425
|
+
httpStatus: res.status,
|
|
426
|
+
url: input,
|
|
427
|
+
status: res.ok ? 'ok' : 'error',
|
|
428
|
+
tags: { api: path.split('/')[2] ?? 'root' },
|
|
429
|
+
extra: {
|
|
430
|
+
request: typeof init.body === 'string' ? init.body : undefined,
|
|
431
|
+
response: responseBody,
|
|
432
|
+
response_bytes: responseBody.length,
|
|
433
|
+
},
|
|
434
|
+
});
|
|
435
|
+
return res;
|
|
436
|
+
} catch (err) {
|
|
437
|
+
Sauron.trackTransaction({
|
|
438
|
+
name: `${method} ${path}`,
|
|
439
|
+
op: 'http',
|
|
440
|
+
durationMs: performance.now() - started,
|
|
441
|
+
httpMethod: method,
|
|
442
|
+
url: input,
|
|
443
|
+
status: 'error',
|
|
444
|
+
extra: { request: init.body, error: String(err) },
|
|
445
|
+
});
|
|
446
|
+
throw err;
|
|
447
|
+
}
|
|
448
|
+
}
|
|
449
|
+
```
|
|
450
|
+
|
|
451
|
+
On the dashboard: **Transactions → the row → expand**. Both bodies render as a
|
|
452
|
+
JSON tree, and every one of these finds it:
|
|
453
|
+
|
|
454
|
+
```text
|
|
455
|
+
extra.response:~9001 # substring, inside the stored response body
|
|
456
|
+
@tag.api:orders # indexed tag
|
|
457
|
+
op:http http.status:>=500 # the failures
|
|
458
|
+
duration:>2s # the slow ones
|
|
459
|
+
```
|
|
460
|
+
|
|
461
|
+
#### Example: a client-side SQL query (`sql.js` / `wa-sqlite`)
|
|
462
|
+
|
|
463
|
+
If your app runs SQLite in the browser, spans work the same way. Put the
|
|
464
|
+
**statement** in `extra` and keep `name` a stable label — a query with literals
|
|
465
|
+
baked in would mint a new dashboard row per execution.
|
|
466
|
+
|
|
467
|
+
```ts
|
|
468
|
+
function tracedQuery(db: Database, sql: string, params: unknown[] = []) {
|
|
469
|
+
const started = performance.now();
|
|
470
|
+
try {
|
|
471
|
+
const rows = db.exec(sql, params);
|
|
472
|
+
Sauron.trackTransaction({
|
|
473
|
+
// The LABEL, not the statement. `op` accepts only
|
|
474
|
+
// navigation|http|resource|screen_load|custom — anything else, `'db'`
|
|
475
|
+
// included, is coerced to 'custom', so pass 'custom' and say it with a tag.
|
|
476
|
+
name: 'SELECT orders',
|
|
477
|
+
op: 'custom',
|
|
478
|
+
durationMs: performance.now() - started,
|
|
479
|
+
status: 'ok',
|
|
480
|
+
tags: { db: 'sqlite', table: 'orders' },
|
|
481
|
+
extra: {
|
|
482
|
+
statement: sql,
|
|
483
|
+
row_count: rows[0]?.values.length ?? 0,
|
|
484
|
+
// Bind PARAMETERS are user data. Log them only if you have decided
|
|
485
|
+
// that is acceptable, or log their shape instead.
|
|
486
|
+
params,
|
|
487
|
+
},
|
|
488
|
+
});
|
|
489
|
+
return rows;
|
|
490
|
+
} catch (err) {
|
|
491
|
+
Sauron.trackTransaction({
|
|
492
|
+
name: 'SELECT orders',
|
|
493
|
+
op: 'custom',
|
|
494
|
+
durationMs: performance.now() - started,
|
|
495
|
+
status: 'error',
|
|
496
|
+
tags: { db: 'sqlite', table: 'orders' },
|
|
497
|
+
extra: { statement: sql, error: String(err) },
|
|
498
|
+
});
|
|
499
|
+
throw err;
|
|
500
|
+
}
|
|
501
|
+
}
|
|
502
|
+
```
|
|
503
|
+
|
|
504
|
+
Then `@tag.table:orders duration:>500ms` is your slow-query list.
|
|
505
|
+
|
|
343
506
|
### `setScreen(name)`
|
|
344
507
|
|
|
345
508
|
```ts
|
|
@@ -822,7 +985,7 @@ const appFrames = frames.filter((f) => isInAppFrame(f.filename));
|
|
|
822
985
|
|
|
823
986
|
```ts
|
|
824
987
|
const SDK_NAME: string // 'sauron.javascript'
|
|
825
|
-
const SDK_VERSION: string // '1.
|
|
988
|
+
const SDK_VERSION: string // '1.5.0'
|
|
826
989
|
```
|
|
827
990
|
|
|
828
991
|
The SDK identity embedded in `header.sdk` of every envelope.
|
|
@@ -922,8 +1085,15 @@ Other scope data:
|
|
|
922
1085
|
`captureException`.
|
|
923
1086
|
- **identity** — `device_id` persists in `localStorage` under
|
|
924
1087
|
`sauron.device_id`; `session_id` persists in `sessionStorage` under
|
|
925
|
-
`sauron.session_id
|
|
926
|
-
|
|
1088
|
+
`sauron.session_id`; `identify()` additionally persists a short one-way
|
|
1089
|
+
digest (never the id itself) of the last identified user in `localStorage`
|
|
1090
|
+
under `sauron.last_identified`, used to detect a login by a different
|
|
1091
|
+
person on a device where `reset()` was never wired — see "Reset on logout"
|
|
1092
|
+
in the wiki. This is not a security boundary (an unkeyed hash over a
|
|
1093
|
+
possibly low-entropy id, e.g. an email, is a confirmation oracle, not a
|
|
1094
|
+
secret) — it exists only so the key isn't a second plaintext copy of the
|
|
1095
|
+
app's user id. All fall back to a per-process in-memory id when Web Storage
|
|
1096
|
+
is unavailable.
|
|
927
1097
|
|
|
928
1098
|
```ts
|
|
929
1099
|
Sauron.init({ dsn, tags: { tier: 'free' }, extra: { build: 'ci-42' } });
|
|
@@ -946,7 +1116,7 @@ Sauron.track('upgraded', {}, { tags: { tier: 'trial' } });
|
|
|
946
1116
|
|
|
947
1117
|
```html
|
|
948
1118
|
<script type="module">
|
|
949
|
-
import { Sauron } from 'https://esm.sh/@edraj/sauron-browser@1.
|
|
1119
|
+
import { Sauron } from 'https://esm.sh/@edraj/sauron-browser@1.5.0';
|
|
950
1120
|
Sauron.init({ dsn: 'https://pk_test@ingest.example.com/42' });
|
|
951
1121
|
</script>
|
|
952
1122
|
```
|
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.5.0";
|
|
12
12
|
function getGlobal() {
|
|
13
13
|
return globalThis;
|
|
14
14
|
}
|
|
@@ -87,11 +87,60 @@ function makeLogger(debug) {
|
|
|
87
87
|
warn: (...args) => console.warn("[sauron]", ...args)
|
|
88
88
|
};
|
|
89
89
|
}
|
|
90
|
+
var MAX_TRANSACTION_EXTRA_BYTES = 16 * 1024;
|
|
91
|
+
function capTransactionExtra(extra, maxBytes = MAX_TRANSACTION_EXTRA_BYTES) {
|
|
92
|
+
let bytes;
|
|
93
|
+
try {
|
|
94
|
+
const json = JSON.stringify(extra);
|
|
95
|
+
if (json === void 0) return { _truncated: true, _bytes: -1 };
|
|
96
|
+
bytes = utf8Length(json);
|
|
97
|
+
} catch {
|
|
98
|
+
return { _truncated: true, _bytes: -1 };
|
|
99
|
+
}
|
|
100
|
+
if (bytes <= maxBytes) return extra;
|
|
101
|
+
return { _truncated: true, _bytes: bytes };
|
|
102
|
+
}
|
|
103
|
+
function utf8Length(s) {
|
|
104
|
+
let n = 0;
|
|
105
|
+
for (let i = 0; i < s.length; i++) {
|
|
106
|
+
const c = s.charCodeAt(i);
|
|
107
|
+
if (c < 128) n += 1;
|
|
108
|
+
else if (c < 2048) n += 2;
|
|
109
|
+
else if (c >= 55296 && c <= 56319) {
|
|
110
|
+
n += 4;
|
|
111
|
+
i++;
|
|
112
|
+
} else n += 3;
|
|
113
|
+
}
|
|
114
|
+
return n;
|
|
115
|
+
}
|
|
90
116
|
|
|
91
117
|
// src/identity.ts
|
|
92
118
|
var DEVICE_ID_KEY = "sauron.device_id";
|
|
93
119
|
var SESSION_ID_KEY = "sauron.session_id";
|
|
94
120
|
var ANON_ID_KEY = "sauron.anon_id";
|
|
121
|
+
var LAST_IDENTIFIED_KEY = "sauron.last_identified";
|
|
122
|
+
var LAST_IDENTIFIED_FORMAT = "v1";
|
|
123
|
+
function encodeLastIdentified(digest) {
|
|
124
|
+
return `${LAST_IDENTIFIED_FORMAT}:${digest}`;
|
|
125
|
+
}
|
|
126
|
+
function decodeLastIdentified(raw) {
|
|
127
|
+
if (raw === null) return null;
|
|
128
|
+
const sep = raw.indexOf(":");
|
|
129
|
+
if (sep < 0 || raw.slice(0, sep) !== LAST_IDENTIFIED_FORMAT) return null;
|
|
130
|
+
const digest = raw.slice(sep + 1);
|
|
131
|
+
return digest === "" ? null : digest;
|
|
132
|
+
}
|
|
133
|
+
function fnv1a32(s) {
|
|
134
|
+
let h = 2166136261;
|
|
135
|
+
for (let i = 0; i < s.length; i++) {
|
|
136
|
+
h ^= s.charCodeAt(i);
|
|
137
|
+
h = Math.imul(h, 16777619);
|
|
138
|
+
}
|
|
139
|
+
return (h >>> 0).toString(16).padStart(8, "0");
|
|
140
|
+
}
|
|
141
|
+
function hashIdentity(id) {
|
|
142
|
+
return fnv1a32(id) + fnv1a32("" + id);
|
|
143
|
+
}
|
|
95
144
|
function webStorage(name) {
|
|
96
145
|
try {
|
|
97
146
|
const s = globalThis[name];
|
|
@@ -125,6 +174,7 @@ function persistentId(cached, storage, key) {
|
|
|
125
174
|
var deviceId = null;
|
|
126
175
|
var sessionId = null;
|
|
127
176
|
var anonymousId = null;
|
|
177
|
+
var lastIdentified = null;
|
|
128
178
|
function getDeviceId() {
|
|
129
179
|
deviceId = persistentId(deviceId, webStorage("localStorage"), DEVICE_ID_KEY);
|
|
130
180
|
return deviceId;
|
|
@@ -133,6 +183,17 @@ function getSessionId() {
|
|
|
133
183
|
sessionId = persistentId(sessionId, webStorage("sessionStorage"), SESSION_ID_KEY);
|
|
134
184
|
return sessionId;
|
|
135
185
|
}
|
|
186
|
+
function rotateSessionId() {
|
|
187
|
+
sessionId = null;
|
|
188
|
+
const storage = webStorage("sessionStorage");
|
|
189
|
+
if (storage) {
|
|
190
|
+
try {
|
|
191
|
+
storage.removeItem(SESSION_ID_KEY);
|
|
192
|
+
} catch {
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
return getSessionId();
|
|
196
|
+
}
|
|
136
197
|
function getAnonymousId() {
|
|
137
198
|
if (anonymousId) return anonymousId;
|
|
138
199
|
const storage = webStorage("localStorage");
|
|
@@ -167,6 +228,37 @@ function resetAnonymousId() {
|
|
|
167
228
|
}
|
|
168
229
|
return getAnonymousId();
|
|
169
230
|
}
|
|
231
|
+
function getLastIdentified() {
|
|
232
|
+
const storage = webStorage("localStorage");
|
|
233
|
+
if (!storage) return decodeLastIdentified(lastIdentified);
|
|
234
|
+
try {
|
|
235
|
+
const stored = storage.getItem(LAST_IDENTIFIED_KEY);
|
|
236
|
+
return decodeLastIdentified(stored ?? lastIdentified);
|
|
237
|
+
} catch {
|
|
238
|
+
return decodeLastIdentified(lastIdentified);
|
|
239
|
+
}
|
|
240
|
+
}
|
|
241
|
+
function setLastIdentified(id) {
|
|
242
|
+
const encoded = encodeLastIdentified(id);
|
|
243
|
+
lastIdentified = encoded;
|
|
244
|
+
const storage = webStorage("localStorage");
|
|
245
|
+
if (storage) {
|
|
246
|
+
try {
|
|
247
|
+
storage.setItem(LAST_IDENTIFIED_KEY, encoded);
|
|
248
|
+
} catch {
|
|
249
|
+
}
|
|
250
|
+
}
|
|
251
|
+
}
|
|
252
|
+
function clearLastIdentified() {
|
|
253
|
+
lastIdentified = null;
|
|
254
|
+
const storage = webStorage("localStorage");
|
|
255
|
+
if (storage) {
|
|
256
|
+
try {
|
|
257
|
+
storage.removeItem(LAST_IDENTIFIED_KEY);
|
|
258
|
+
} catch {
|
|
259
|
+
}
|
|
260
|
+
}
|
|
261
|
+
}
|
|
170
262
|
|
|
171
263
|
// src/context.ts
|
|
172
264
|
function getNavigator() {
|
|
@@ -813,13 +905,31 @@ var Scope = class {
|
|
|
813
905
|
this.maxBreadcrumbs = Math.max(0, max);
|
|
814
906
|
this.trim();
|
|
815
907
|
}
|
|
908
|
+
/**
|
|
909
|
+
* Replace the scope user.
|
|
910
|
+
*
|
|
911
|
+
* `id` is coerced with `String()` for the same reason `SauronClient.
|
|
912
|
+
* prepareIdentify` coerces its own — a plain-JS caller can (and does) pass
|
|
913
|
+
* `setUser({ id: user.id })` where `user.id` is a number, and TypeScript
|
|
914
|
+
* cannot stop them. This path is the one that BYPASSES `identify()`'s
|
|
915
|
+
* coercion entirely, and the consequence is not cosmetic: the scope user
|
|
916
|
+
* lands in the envelope context, where the server's `distinct_id` is a
|
|
917
|
+
* non-`Option` Rust `String`. A JSON number there fails deserialization of
|
|
918
|
+
* the ENVELOPE, not of the one field — so the whole batch 400s, and a 400
|
|
919
|
+
* is non-retryable, so every event in it is dropped for good.
|
|
920
|
+
*
|
|
921
|
+
* Rebuilding the whole object (rather than merging into the existing one)
|
|
922
|
+
* is deliberate and is the behaviour the Flutter SDK was fixed to match:
|
|
923
|
+
* `email` and `traits` come from the input alone, so setting a new user
|
|
924
|
+
* never inherits the previous person's contact details.
|
|
925
|
+
*/
|
|
816
926
|
setUser(user) {
|
|
817
927
|
if (user === null) {
|
|
818
928
|
this.user = null;
|
|
819
929
|
return;
|
|
820
930
|
}
|
|
821
931
|
this.user = {
|
|
822
|
-
id: user.id
|
|
932
|
+
id: user.id === null || user.id === void 0 ? null : String(user.id),
|
|
823
933
|
email: user.email ?? null,
|
|
824
934
|
traits: user.traits ?? {}
|
|
825
935
|
};
|
|
@@ -920,11 +1030,12 @@ function setScreen(name) {
|
|
|
920
1030
|
function identify(id, traits = {}) {
|
|
921
1031
|
const client = getClient();
|
|
922
1032
|
if (!client) return;
|
|
923
|
-
const
|
|
924
|
-
client.
|
|
1033
|
+
const distinctId = String(id);
|
|
1034
|
+
const anonymousId2 = client.prepareIdentify(distinctId);
|
|
1035
|
+
client.getScope().setUser({ id: distinctId, traits });
|
|
925
1036
|
const item = {
|
|
926
1037
|
type: "identify",
|
|
927
|
-
distinct_id:
|
|
1038
|
+
distinct_id: distinctId,
|
|
928
1039
|
anonymous_id: anonymousId2,
|
|
929
1040
|
traits: traits ?? {}
|
|
930
1041
|
};
|
|
@@ -941,7 +1052,7 @@ function normalizeOp(op) {
|
|
|
941
1052
|
return op && TRANSACTION_OPS.includes(op) ? op : "custom";
|
|
942
1053
|
}
|
|
943
1054
|
function buildTransactionItem(input, distinctId, sessionId2) {
|
|
944
|
-
|
|
1055
|
+
const item = {
|
|
945
1056
|
type: "transaction",
|
|
946
1057
|
name: input.name,
|
|
947
1058
|
op: normalizeOp(input.op),
|
|
@@ -954,6 +1065,11 @@ function buildTransactionItem(input, distinctId, sessionId2) {
|
|
|
954
1065
|
session_id: sessionId2,
|
|
955
1066
|
timestamp: nowIso()
|
|
956
1067
|
};
|
|
1068
|
+
if (input.tags && Object.keys(input.tags).length > 0) item.tags = { ...input.tags };
|
|
1069
|
+
if (input.extra && Object.keys(input.extra).length > 0) {
|
|
1070
|
+
item.extra = capTransactionExtra({ ...input.extra });
|
|
1071
|
+
}
|
|
1072
|
+
return item;
|
|
957
1073
|
}
|
|
958
1074
|
function trackTransaction(input) {
|
|
959
1075
|
const client = getClient();
|
|
@@ -1854,18 +1970,56 @@ var SauronClient = class {
|
|
|
1854
1970
|
return this.anonUsed ? getAnonymousId() : null;
|
|
1855
1971
|
}
|
|
1856
1972
|
/**
|
|
1857
|
-
* Forget the current person: clear the scope user
|
|
1858
|
-
* id.
|
|
1973
|
+
* Forget the current person: clear the scope user, mint a fresh anonymous
|
|
1974
|
+
* id, forget the last identified user, and rotate the session id.
|
|
1859
1975
|
*
|
|
1860
1976
|
* MUST BE CALLED ON LOGOUT. Without it, the next anonymous visitor on this
|
|
1861
1977
|
* browser reuses the persisted anon id, and a later identify() aliases their
|
|
1862
|
-
* activity to the previous account server-side, permanently.
|
|
1978
|
+
* activity to the previous account server-side, permanently. Rotating the
|
|
1979
|
+
* session id matters too: the server's `bump_session` is last-write-wins on
|
|
1980
|
+
* `distinct_id`, so without rotation one `sessions` row could otherwise
|
|
1981
|
+
* serially represent two different people and record only whichever wrote
|
|
1982
|
+
* last.
|
|
1863
1983
|
*/
|
|
1864
1984
|
reset() {
|
|
1865
1985
|
this.scope.setUser(null);
|
|
1866
1986
|
resetAnonymousId();
|
|
1987
|
+
clearLastIdentified();
|
|
1988
|
+
rotateSessionId();
|
|
1867
1989
|
this.anonUsed = false;
|
|
1868
1990
|
}
|
|
1991
|
+
/**
|
|
1992
|
+
* Prepare for an `identify()`; returns the `anonymous_id` to send.
|
|
1993
|
+
*
|
|
1994
|
+
* When a DIFFERENT user identifies than last time, the current anon id
|
|
1995
|
+
* belongs to the previous person and is already burned server-side, so it is
|
|
1996
|
+
* replaced before anything else happens and `null` is sent instead of a
|
|
1997
|
+
* cross-user alias. This cannot repair events already sent under the burned
|
|
1998
|
+
* alias — nothing can — but it bounds a forgotten `reset()` to one guest
|
|
1999
|
+
* window instead of every future one.
|
|
2000
|
+
*
|
|
2001
|
+
* `id` is coerced with `String()` before comparing/persisting: a plain-JS
|
|
2002
|
+
* caller can pass a number (`Sauron.identify(user.id)`), and `Storage`
|
|
2003
|
+
* itself applies `ToString` on write — so comparing an un-coerced `id`
|
|
2004
|
+
* against a value that already round-tripped through storage would treat
|
|
2005
|
+
* the SAME numeric user as a switch on every single call. The comparison
|
|
2006
|
+
* against `last` is an explicit `!== null` (not a truthiness check) so an
|
|
2007
|
+
* app that (unusually) identifies with `''` still has a later, different id
|
|
2008
|
+
* correctly detected as a real switch — a falsy string is not "no identity
|
|
2009
|
+
* yet". `last`/the persisted value are digests, not the raw id — see
|
|
2010
|
+
* `hashIdentity`.
|
|
2011
|
+
*/
|
|
2012
|
+
prepareIdentify(id) {
|
|
2013
|
+
const digest = hashIdentity(String(id));
|
|
2014
|
+
const last = getLastIdentified();
|
|
2015
|
+
if (last !== null && last !== digest) {
|
|
2016
|
+
resetAnonymousId();
|
|
2017
|
+
rotateSessionId();
|
|
2018
|
+
this.anonUsed = false;
|
|
2019
|
+
}
|
|
2020
|
+
setLastIdentified(digest);
|
|
2021
|
+
return this.getAnonymousId();
|
|
2022
|
+
}
|
|
1869
2023
|
/** Stamp a fresh envelope (new `sent_at`, current context) around `items`. */
|
|
1870
2024
|
makeEnvelope(items) {
|
|
1871
2025
|
const header = {
|
|
@@ -2179,6 +2333,7 @@ var Sauron = {
|
|
|
2179
2333
|
var index_default = Sauron;
|
|
2180
2334
|
|
|
2181
2335
|
exports.DsnError = DsnError;
|
|
2336
|
+
exports.MAX_TRANSACTION_EXTRA_BYTES = MAX_TRANSACTION_EXTRA_BYTES;
|
|
2182
2337
|
exports.SDK_NAME = SDK_NAME;
|
|
2183
2338
|
exports.SDK_VERSION = SDK_VERSION;
|
|
2184
2339
|
exports.Sauron = Sauron;
|
|
@@ -2186,6 +2341,7 @@ exports.SauronClient = SauronClient;
|
|
|
2186
2341
|
exports.addBreadcrumb = addBreadcrumb2;
|
|
2187
2342
|
exports.buildEnvelope = buildEnvelope;
|
|
2188
2343
|
exports.cancelWorkflow = cancelWorkflow2;
|
|
2344
|
+
exports.capTransactionExtra = capTransactionExtra;
|
|
2189
2345
|
exports.captureException = captureException2;
|
|
2190
2346
|
exports.captureMessage = captureMessage2;
|
|
2191
2347
|
exports.close = close;
|