@edraj/sauron-browser 1.4.1 → 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 +39 -0
- package/README.md +129 -2
- package/dist/index.cjs +35 -2
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +65 -2
- package/dist/index.d.ts +65 -2
- package/dist/index.js +34 -3
- package/dist/index.js.map +1 -1
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,45 @@
|
|
|
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
|
+
|
|
5
44
|
## 1.4.1
|
|
6
45
|
|
|
7
46
|
### Added
|
package/README.md
CHANGED
|
@@ -358,10 +358,28 @@ function trackTransaction(input: TransactionInput): void
|
|
|
358
358
|
| `httpMethod` | `string \| null` | `null` | For `http` ops. |
|
|
359
359
|
| `httpStatus` | `number \| null` | `null` | For `http` ops. |
|
|
360
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`. |
|
|
361
363
|
|
|
362
364
|
The item is stamped with the current distinct id, session id and timestamp.
|
|
363
365
|
Never sampled. Returns `void`.
|
|
364
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
|
+
|
|
365
383
|
```ts
|
|
366
384
|
const started = performance.now();
|
|
367
385
|
const res = await fetch('/api/orders');
|
|
@@ -376,6 +394,115 @@ Sauron.trackTransaction({
|
|
|
376
394
|
});
|
|
377
395
|
```
|
|
378
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
|
+
|
|
379
506
|
### `setScreen(name)`
|
|
380
507
|
|
|
381
508
|
```ts
|
|
@@ -858,7 +985,7 @@ const appFrames = frames.filter((f) => isInAppFrame(f.filename));
|
|
|
858
985
|
|
|
859
986
|
```ts
|
|
860
987
|
const SDK_NAME: string // 'sauron.javascript'
|
|
861
|
-
const SDK_VERSION: string // '1.
|
|
988
|
+
const SDK_VERSION: string // '1.5.0'
|
|
862
989
|
```
|
|
863
990
|
|
|
864
991
|
The SDK identity embedded in `header.sdk` of every envelope.
|
|
@@ -989,7 +1116,7 @@ Sauron.track('upgraded', {}, { tags: { tier: 'trial' } });
|
|
|
989
1116
|
|
|
990
1117
|
```html
|
|
991
1118
|
<script type="module">
|
|
992
|
-
import { Sauron } from 'https://esm.sh/@edraj/sauron-browser@1.
|
|
1119
|
+
import { Sauron } from 'https://esm.sh/@edraj/sauron-browser@1.5.0';
|
|
993
1120
|
Sauron.init({ dsn: 'https://pk_test@ingest.example.com/42' });
|
|
994
1121
|
</script>
|
|
995
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,6 +87,32 @@ 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";
|
|
@@ -1026,7 +1052,7 @@ function normalizeOp(op) {
|
|
|
1026
1052
|
return op && TRANSACTION_OPS.includes(op) ? op : "custom";
|
|
1027
1053
|
}
|
|
1028
1054
|
function buildTransactionItem(input, distinctId, sessionId2) {
|
|
1029
|
-
|
|
1055
|
+
const item = {
|
|
1030
1056
|
type: "transaction",
|
|
1031
1057
|
name: input.name,
|
|
1032
1058
|
op: normalizeOp(input.op),
|
|
@@ -1039,6 +1065,11 @@ function buildTransactionItem(input, distinctId, sessionId2) {
|
|
|
1039
1065
|
session_id: sessionId2,
|
|
1040
1066
|
timestamp: nowIso()
|
|
1041
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;
|
|
1042
1073
|
}
|
|
1043
1074
|
function trackTransaction(input) {
|
|
1044
1075
|
const client = getClient();
|
|
@@ -2302,6 +2333,7 @@ var Sauron = {
|
|
|
2302
2333
|
var index_default = Sauron;
|
|
2303
2334
|
|
|
2304
2335
|
exports.DsnError = DsnError;
|
|
2336
|
+
exports.MAX_TRANSACTION_EXTRA_BYTES = MAX_TRANSACTION_EXTRA_BYTES;
|
|
2305
2337
|
exports.SDK_NAME = SDK_NAME;
|
|
2306
2338
|
exports.SDK_VERSION = SDK_VERSION;
|
|
2307
2339
|
exports.Sauron = Sauron;
|
|
@@ -2309,6 +2341,7 @@ exports.SauronClient = SauronClient;
|
|
|
2309
2341
|
exports.addBreadcrumb = addBreadcrumb2;
|
|
2310
2342
|
exports.buildEnvelope = buildEnvelope;
|
|
2311
2343
|
exports.cancelWorkflow = cancelWorkflow2;
|
|
2344
|
+
exports.capTransactionExtra = capTransactionExtra;
|
|
2312
2345
|
exports.captureException = captureException2;
|
|
2313
2346
|
exports.captureMessage = captureMessage2;
|
|
2314
2347
|
exports.close = close;
|