@heystack/otel 0.18.0 → 0.19.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/README.md +149 -26
- package/dist/core.d.ts +1 -0
- package/dist/core.js +4 -0
- package/dist/hash.d.ts +19 -0
- package/dist/hash.js +47 -0
- package/dist/next.d.ts +2 -1
- package/dist/next.js +5 -0
- package/dist/node.d.ts +14 -2
- package/dist/node.js +49 -4
- package/dist/redact.d.ts +57 -0
- package/dist/redact.js +190 -0
- package/dist/web.d.ts +42 -0
- package/dist/web.js +91 -24
- package/dist/workers-bindings.d.ts +39 -2
- package/dist/workers-bindings.js +351 -76
- package/dist/workers-coverage.d.ts +11 -0
- package/dist/workers-coverage.js +22 -0
- package/dist/workers-native.d.ts +66 -0
- package/dist/workers-native.js +147 -0
- package/dist/workers-propagation.d.ts +24 -0
- package/dist/workers-propagation.js +72 -0
- package/dist/workers-routes.d.ts +11 -0
- package/dist/workers-routes.js +45 -0
- package/dist/workers-sampler.d.ts +10 -1
- package/dist/workers-sampler.js +7 -3
- package/dist/workers.d.ts +108 -19
- package/dist/workers.js +583 -185
- package/package.json +9 -4
package/dist/redact.d.ts
ADDED
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
/** Replacement written in place of a redacted query/fragment parameter value. */
|
|
2
|
+
export declare const REDACTED = "REDACTED";
|
|
3
|
+
/**
|
|
4
|
+
* Query/fragment parameter names whose VALUES are replaced with `REDACTED`
|
|
5
|
+
* before a URL is recorded. Compared case-insensitively against the decoded key.
|
|
6
|
+
* `state` is intentionally NOT listed (OAuth `state` is an anti-CSRF nonce, not a
|
|
7
|
+
* credential, and it is useful when debugging a broken login redirect).
|
|
8
|
+
*/
|
|
9
|
+
export declare const DEFAULT_REDACTED_QUERY_PARAMS: readonly string[];
|
|
10
|
+
/** Options controlling how URLs are redacted before they are recorded. */
|
|
11
|
+
export interface UrlRedactionOptions {
|
|
12
|
+
/**
|
|
13
|
+
* Extra query/fragment parameter names to redact, ADDED to
|
|
14
|
+
* {@link DEFAULT_REDACTED_QUERY_PARAMS} (case-insensitive). Pass `false` to
|
|
15
|
+
* turn query/fragment parameter redaction off entirely. Credentials embedded
|
|
16
|
+
* as URL userinfo (`https://user:pass@host`) are always stripped.
|
|
17
|
+
*/
|
|
18
|
+
redactQueryParams?: readonly string[] | false;
|
|
19
|
+
}
|
|
20
|
+
/** Privacy options shared by the server entries (`/node`, `/next`, `/workers`). */
|
|
21
|
+
export interface SpanPrivacyOptions extends UrlRedactionOptions {
|
|
22
|
+
/**
|
|
23
|
+
* Record the end-user's IP address (`client.address`, plus `http.client_ip` /
|
|
24
|
+
* peer-address attributes on SERVER spans under `/node`). **Off by default** —
|
|
25
|
+
* an IP address is personal data in most jurisdictions. Set `true` only if you
|
|
26
|
+
* have a lawful basis and have told your users.
|
|
27
|
+
*/
|
|
28
|
+
captureClientIp?: boolean;
|
|
29
|
+
}
|
|
30
|
+
export type UrlRedactor = (url: string) => string;
|
|
31
|
+
/** Build a reusable redactor (pre-computes the key set). */
|
|
32
|
+
export declare function makeUrlRedactor(opts?: UrlRedactionOptions): UrlRedactor;
|
|
33
|
+
/**
|
|
34
|
+
* Redact secrets from a URL before it is recorded: strips userinfo
|
|
35
|
+
* (`https://user:pass@host` → `https://host`) and replaces the VALUE of every
|
|
36
|
+
* sensitive query or fragment parameter (see
|
|
37
|
+
* {@link DEFAULT_REDACTED_QUERY_PARAMS}) with `REDACTED`. Everything else —
|
|
38
|
+
* scheme, host, path, non-sensitive parameters, plain `#anchors` — is kept
|
|
39
|
+
* byte-for-byte. Works on absolute and relative URLs (`/cb?code=…`).
|
|
40
|
+
*/
|
|
41
|
+
export declare function redactUrl(url: string, opts?: UrlRedactionOptions): string;
|
|
42
|
+
/** Span/log attributes that hold a full or relative URL. */
|
|
43
|
+
export declare const URL_ATTRS: readonly ["url.full", "http.url", "http.target", "url.original"];
|
|
44
|
+
/** End-user IP attributes, dropped on every span unless `captureClientIp`. */
|
|
45
|
+
export declare const CLIENT_IP_ATTRS: readonly ["client.address", "http.client_ip"];
|
|
46
|
+
/** Peer-address attributes that equal the end-user (or edge proxy) IP on a SERVER span. */
|
|
47
|
+
export declare const SERVER_PEER_IP_ATTRS: readonly ["net.peer.ip", "network.peer.address", "net.sock.peer.addr"];
|
|
48
|
+
/**
|
|
49
|
+
* Return `attrs` with every URL-bearing attribute redacted and, unless
|
|
50
|
+
* `captureClientIp`, end-user IP attributes removed. Returns the SAME object
|
|
51
|
+
* when nothing changed (cheap common path), otherwise a shallow copy.
|
|
52
|
+
*/
|
|
53
|
+
export declare function applySpanPrivacy(attrs: Record<string, unknown>, o: {
|
|
54
|
+
redact: UrlRedactor;
|
|
55
|
+
captureClientIp?: boolean;
|
|
56
|
+
isServer?: boolean;
|
|
57
|
+
}): Record<string, unknown>;
|
package/dist/redact.js
ADDED
|
@@ -0,0 +1,190 @@
|
|
|
1
|
+
// ---------------------------------------------------------------------------
|
|
2
|
+
// URL + client-IP privacy helpers (shared by /node, /workers and /web)
|
|
3
|
+
//
|
|
4
|
+
// Pure string logic with NO imports — safe in every entry, including the
|
|
5
|
+
// WinterCG-only `/workers` build and the browser `/web` bundle. Deliberately
|
|
6
|
+
// avoids `new URL(...).href` re-serialisation so a redacted URL keeps the exact
|
|
7
|
+
// spelling of everything that was not redacted (and so relative targets such as
|
|
8
|
+
// `http.target` = `/cb?code=…` work without a base).
|
|
9
|
+
// ---------------------------------------------------------------------------
|
|
10
|
+
/** Replacement written in place of a redacted query/fragment parameter value. */
|
|
11
|
+
export const REDACTED = "REDACTED";
|
|
12
|
+
/**
|
|
13
|
+
* Query/fragment parameter names whose VALUES are replaced with `REDACTED`
|
|
14
|
+
* before a URL is recorded. Compared case-insensitively against the decoded key.
|
|
15
|
+
* `state` is intentionally NOT listed (OAuth `state` is an anti-CSRF nonce, not a
|
|
16
|
+
* credential, and it is useful when debugging a broken login redirect).
|
|
17
|
+
*/
|
|
18
|
+
export const DEFAULT_REDACTED_QUERY_PARAMS = [
|
|
19
|
+
"key",
|
|
20
|
+
"api_key",
|
|
21
|
+
"apikey",
|
|
22
|
+
"api-key",
|
|
23
|
+
"token",
|
|
24
|
+
"access_token",
|
|
25
|
+
"refresh_token",
|
|
26
|
+
"id_token",
|
|
27
|
+
"auth_token",
|
|
28
|
+
"api_token",
|
|
29
|
+
"oauth_token",
|
|
30
|
+
"oauth_signature",
|
|
31
|
+
"code",
|
|
32
|
+
"sig",
|
|
33
|
+
"signature",
|
|
34
|
+
"password",
|
|
35
|
+
"passwd",
|
|
36
|
+
"pass",
|
|
37
|
+
"secret",
|
|
38
|
+
"client_secret",
|
|
39
|
+
"auth",
|
|
40
|
+
"authorization",
|
|
41
|
+
"session",
|
|
42
|
+
"sessionid",
|
|
43
|
+
"session_id",
|
|
44
|
+
"jwt",
|
|
45
|
+
"x-amz-signature",
|
|
46
|
+
"x-amz-credential",
|
|
47
|
+
"x-amz-security-token",
|
|
48
|
+
"x-goog-signature",
|
|
49
|
+
"x-goog-credential",
|
|
50
|
+
];
|
|
51
|
+
function safeDecode(s) {
|
|
52
|
+
try {
|
|
53
|
+
return decodeURIComponent(s.replace(/\+/g, " "));
|
|
54
|
+
}
|
|
55
|
+
catch {
|
|
56
|
+
return s;
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
/** Redact sensitive parameter values in a bare `a=1&b=2` string. */
|
|
60
|
+
function redactParams(qs, keys) {
|
|
61
|
+
if (!qs || keys.size === 0)
|
|
62
|
+
return qs;
|
|
63
|
+
let changed = false;
|
|
64
|
+
const parts = qs.split("&").map((part) => {
|
|
65
|
+
const eq = part.indexOf("=");
|
|
66
|
+
if (eq < 0)
|
|
67
|
+
return part; // bare flag, no value to leak
|
|
68
|
+
const key = part.slice(0, eq);
|
|
69
|
+
if (!keys.has(safeDecode(key).trim().toLowerCase()))
|
|
70
|
+
return part;
|
|
71
|
+
if (part.slice(eq + 1) === REDACTED)
|
|
72
|
+
return part;
|
|
73
|
+
changed = true;
|
|
74
|
+
return `${key}=${REDACTED}`;
|
|
75
|
+
});
|
|
76
|
+
return changed ? parts.join("&") : qs;
|
|
77
|
+
}
|
|
78
|
+
/** Strip `user[:password]@` from an absolute (or protocol-relative) URL. */
|
|
79
|
+
const USERINFO_RE = /^((?:[a-zA-Z][a-zA-Z0-9+.-]*:)?\/\/)[^/?#]*@/;
|
|
80
|
+
function redactWith(url, keys) {
|
|
81
|
+
let out = url.replace(USERINFO_RE, "$1");
|
|
82
|
+
const hash = out.indexOf("#");
|
|
83
|
+
let fragment;
|
|
84
|
+
if (hash >= 0) {
|
|
85
|
+
fragment = out.slice(hash + 1);
|
|
86
|
+
out = out.slice(0, hash);
|
|
87
|
+
}
|
|
88
|
+
const q = out.indexOf("?");
|
|
89
|
+
if (q >= 0)
|
|
90
|
+
out = `${out.slice(0, q)}?${redactParams(out.slice(q + 1), keys)}`;
|
|
91
|
+
if (fragment !== undefined) {
|
|
92
|
+
// OAuth implicit flow puts tokens in the fragment (`#access_token=…`); hash
|
|
93
|
+
// routers put a query after a route (`#/cb?code=…`). Plain anchors pass.
|
|
94
|
+
const fq = fragment.indexOf("?");
|
|
95
|
+
if (fq >= 0) {
|
|
96
|
+
fragment = `${fragment.slice(0, fq)}?${redactParams(fragment.slice(fq + 1), keys)}`;
|
|
97
|
+
}
|
|
98
|
+
else if (fragment.includes("=")) {
|
|
99
|
+
fragment = redactParams(fragment, keys);
|
|
100
|
+
}
|
|
101
|
+
out = `${out}#${fragment}`;
|
|
102
|
+
}
|
|
103
|
+
return out;
|
|
104
|
+
}
|
|
105
|
+
function keySet(opts) {
|
|
106
|
+
if (opts?.redactQueryParams === false)
|
|
107
|
+
return new Set();
|
|
108
|
+
const extra = Array.isArray(opts?.redactQueryParams) ? opts.redactQueryParams : [];
|
|
109
|
+
return new Set([...DEFAULT_REDACTED_QUERY_PARAMS, ...extra].map((k) => String(k).trim().toLowerCase()));
|
|
110
|
+
}
|
|
111
|
+
/** Build a reusable redactor (pre-computes the key set). */
|
|
112
|
+
export function makeUrlRedactor(opts) {
|
|
113
|
+
const keys = keySet(opts);
|
|
114
|
+
return (url) => (typeof url === "string" && url ? redactWith(url, keys) : url);
|
|
115
|
+
}
|
|
116
|
+
const defaultRedactor = makeUrlRedactor();
|
|
117
|
+
/**
|
|
118
|
+
* Redact secrets from a URL before it is recorded: strips userinfo
|
|
119
|
+
* (`https://user:pass@host` → `https://host`) and replaces the VALUE of every
|
|
120
|
+
* sensitive query or fragment parameter (see
|
|
121
|
+
* {@link DEFAULT_REDACTED_QUERY_PARAMS}) with `REDACTED`. Everything else —
|
|
122
|
+
* scheme, host, path, non-sensitive parameters, plain `#anchors` — is kept
|
|
123
|
+
* byte-for-byte. Works on absolute and relative URLs (`/cb?code=…`).
|
|
124
|
+
*/
|
|
125
|
+
export function redactUrl(url, opts) {
|
|
126
|
+
return opts ? makeUrlRedactor(opts)(url) : defaultRedactor(url);
|
|
127
|
+
}
|
|
128
|
+
/** Span/log attributes that hold a full or relative URL. */
|
|
129
|
+
export const URL_ATTRS = ["url.full", "http.url", "http.target", "url.original"];
|
|
130
|
+
/** Attributes that hold a bare query string (no leading `?`). */
|
|
131
|
+
const QUERY_ATTRS = ["url.query"];
|
|
132
|
+
/** Attributes that hold a bare fragment (no leading `#`). */
|
|
133
|
+
const FRAGMENT_ATTRS = ["url.fragment"];
|
|
134
|
+
/** End-user IP attributes, dropped on every span unless `captureClientIp`. */
|
|
135
|
+
export const CLIENT_IP_ATTRS = ["client.address", "http.client_ip"];
|
|
136
|
+
/** Peer-address attributes that equal the end-user (or edge proxy) IP on a SERVER span. */
|
|
137
|
+
export const SERVER_PEER_IP_ATTRS = [
|
|
138
|
+
"net.peer.ip",
|
|
139
|
+
"network.peer.address",
|
|
140
|
+
"net.sock.peer.addr",
|
|
141
|
+
];
|
|
142
|
+
/**
|
|
143
|
+
* Return `attrs` with every URL-bearing attribute redacted and, unless
|
|
144
|
+
* `captureClientIp`, end-user IP attributes removed. Returns the SAME object
|
|
145
|
+
* when nothing changed (cheap common path), otherwise a shallow copy.
|
|
146
|
+
*/
|
|
147
|
+
export function applySpanPrivacy(attrs, o) {
|
|
148
|
+
let out;
|
|
149
|
+
const set = (k, v) => {
|
|
150
|
+
out ??= { ...attrs };
|
|
151
|
+
if (v === undefined)
|
|
152
|
+
delete out[k];
|
|
153
|
+
else
|
|
154
|
+
out[k] = v;
|
|
155
|
+
};
|
|
156
|
+
for (const k of URL_ATTRS) {
|
|
157
|
+
const v = attrs[k];
|
|
158
|
+
if (typeof v === "string") {
|
|
159
|
+
const r = o.redact(v);
|
|
160
|
+
if (r !== v)
|
|
161
|
+
set(k, r);
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
for (const k of QUERY_ATTRS) {
|
|
165
|
+
const v = attrs[k];
|
|
166
|
+
if (typeof v === "string" && v) {
|
|
167
|
+
const r = o.redact(`?${v.replace(/^\?/, "")}`).slice(1);
|
|
168
|
+
if (r !== v)
|
|
169
|
+
set(k, r);
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
for (const k of FRAGMENT_ATTRS) {
|
|
173
|
+
const v = attrs[k];
|
|
174
|
+
if (typeof v === "string" && v) {
|
|
175
|
+
const r = o.redact(`#${v.replace(/^#/, "")}`).slice(1);
|
|
176
|
+
if (r !== v)
|
|
177
|
+
set(k, r);
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
if (!o.captureClientIp) {
|
|
181
|
+
for (const k of CLIENT_IP_ATTRS)
|
|
182
|
+
if (k in attrs)
|
|
183
|
+
set(k, undefined);
|
|
184
|
+
if (o.isServer)
|
|
185
|
+
for (const k of SERVER_PEER_IP_ATTRS)
|
|
186
|
+
if (k in attrs)
|
|
187
|
+
set(k, undefined);
|
|
188
|
+
}
|
|
189
|
+
return out ?? attrs;
|
|
190
|
+
}
|
package/dist/web.d.ts
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { type UrlRedactor } from "./redact.js";
|
|
1
2
|
/** The replay chunk metadata sent alongside the events array. Defined locally so
|
|
2
3
|
* the published SDK does not depend on the private @heystack/schema package. */
|
|
3
4
|
export interface ReplayChunkMeta {
|
|
@@ -130,6 +131,14 @@ export interface InstrumentWebOptions {
|
|
|
130
131
|
* Rate-capped and recursion-guarded so it can never feed a self-export loop.
|
|
131
132
|
*/
|
|
132
133
|
captureConsole?: "error" | "warn" | false;
|
|
134
|
+
/**
|
|
135
|
+
* Extra query/fragment parameter names whose values are replaced with
|
|
136
|
+
* `REDACTED` in every URL this SDK records (fetch spans, page URL on errors,
|
|
137
|
+
* console logs, bug reports and replay). Added to the built-in list (`token`,
|
|
138
|
+
* `code`, `key`, `apikey`, `access_token`, `signature`, …); userinfo is always
|
|
139
|
+
* stripped. Pass `false` to turn parameter redaction off.
|
|
140
|
+
*/
|
|
141
|
+
redactQueryParams?: readonly string[] | false;
|
|
133
142
|
}
|
|
134
143
|
/**
|
|
135
144
|
* Pure: is browser distributed tracing active for this run? Requires BOTH the app
|
|
@@ -146,6 +155,33 @@ export declare function tracingActive(opts: Pick<InstrumentWebOptions, "tracing"
|
|
|
146
155
|
* (`cfg.errors_enabled`). **Fail-open**: an absent/`true` flag counts as enabled.
|
|
147
156
|
*/
|
|
148
157
|
export declare function errorCaptureActive(opts: Pick<InstrumentWebOptions, "errors">, cfg: Pick<RecorderConfig, "errors_enabled">): boolean;
|
|
158
|
+
/** rrweb `record()` privacy options for a masking mode. */
|
|
159
|
+
export interface ReplayPrivacyOptions {
|
|
160
|
+
maskAllInputs: boolean;
|
|
161
|
+
maskTextSelector: string;
|
|
162
|
+
blockSelector: string;
|
|
163
|
+
maskInputFn: (text: string, element: HTMLElement) => string;
|
|
164
|
+
maskTextFn: (text: string, element: HTMLElement | null) => string;
|
|
165
|
+
}
|
|
166
|
+
/**
|
|
167
|
+
* Pure: the rrweb privacy options for a masking mode. Anything other than an
|
|
168
|
+
* explicit `"relaxed"` is treated as `"strict"` (fail-closed).
|
|
169
|
+
*
|
|
170
|
+
* - **strict** (default): ALL text and ALL input values are masked
|
|
171
|
+
* (`maskTextSelector: "*"` + `maskAllInputs`). Mark an element with
|
|
172
|
+
* `data-hs-unmask` to record it (and its descendants) in cleartext; a nested
|
|
173
|
+
* `data-hs-mask` re-masks. Password inputs are ALWAYS masked, even inside a
|
|
174
|
+
* `data-hs-unmask` region.
|
|
175
|
+
* - **relaxed**: text is recorded except inside `data-hs-mask`; input values are
|
|
176
|
+
* recorded except passwords (rrweb's default).
|
|
177
|
+
*
|
|
178
|
+
* Both modes drop `data-hs-block` elements entirely (recorded as a placeholder
|
|
179
|
+
* box). Masking applies to text nodes and input values only — element ATTRIBUTES
|
|
180
|
+
* (`title`, `alt`, `placeholder`, `aria-label`, link `href`s) are not masked by
|
|
181
|
+
* rrweb, so use `data-hs-block` for regions whose attributes are sensitive.
|
|
182
|
+
*/
|
|
183
|
+
export declare function replayPrivacyOptions(mode: RecorderConfig["masking_mode"] | undefined): ReplayPrivacyOptions;
|
|
184
|
+
export declare function redactReplayEvent(event: unknown, redact?: UrlRedactor): unknown;
|
|
149
185
|
/** A caller-owned session UUID allows product activity to correlate with replay. */
|
|
150
186
|
export declare function resolveWebSessionId(candidate?: string): string;
|
|
151
187
|
/** Entry point: fetch config, decide sampling, start rrweb, stream chunks.
|
|
@@ -236,6 +272,8 @@ export interface TracingPatchOpts {
|
|
|
236
272
|
/** Cross-origin backends allowed to receive trace headers (same-origin always is).
|
|
237
273
|
* Third-party / non-listed cross-origin calls are passed through untouched. */
|
|
238
274
|
propagateTargets?: ReadonlyArray<string | RegExp>;
|
|
275
|
+
/** URL redactor for the recorded `url.full` (default: built-in redaction). */
|
|
276
|
+
redactUrl?: UrlRedactor;
|
|
239
277
|
rng?: () => number;
|
|
240
278
|
}
|
|
241
279
|
/** Patch window.fetch to emit a real CLIENT span per outbound call, inject that
|
|
@@ -322,6 +360,8 @@ export interface ErrorCaptureOpts {
|
|
|
322
360
|
rateCap?: number;
|
|
323
361
|
/** Rate window in ms (default 60_000). */
|
|
324
362
|
rateWindowMs?: number;
|
|
363
|
+
/** URL redactor for the recorded page URL (default: built-in redaction). */
|
|
364
|
+
redactUrl?: UrlRedactor;
|
|
325
365
|
}
|
|
326
366
|
/** Wire up `window.onerror` / `unhandledrejection` (+ optional console) capture.
|
|
327
367
|
* Returns an unpatch function. Recursion-guarded (anything logged on our own
|
|
@@ -356,6 +396,8 @@ export interface BugReportSession {
|
|
|
356
396
|
logsExporter: BrowserLogsExporter;
|
|
357
397
|
version?: string;
|
|
358
398
|
build?: string;
|
|
399
|
+
/** URL redactor for the attached page URL (default: built-in redaction). */
|
|
400
|
+
redactUrl?: UrlRedactor;
|
|
359
401
|
}
|
|
360
402
|
/** Internal: instrumentWeb() registers the active session here (and clears it with
|
|
361
403
|
* `null` on stop) so the module-level reportBug() works after instrumentWeb() is
|
package/dist/web.js
CHANGED
|
@@ -3,6 +3,8 @@ import { DEFAULT_ENDPOINT, releaseResourceAttributes } from "./core.js";
|
|
|
3
3
|
// bundle. Used to skip tracing our OWN telemetry POSTs (the self-export loop that
|
|
4
4
|
// caused the June 2026 cost incident); see CLAUDE.md → "Cost guardrails".
|
|
5
5
|
import { safeHostname } from "./self-span.js";
|
|
6
|
+
// Pure, import-free URL redaction shared with the server entries.
|
|
7
|
+
import { makeUrlRedactor, redactUrl } from "./redact.js";
|
|
6
8
|
/** Pure: decide once per session whether to record. rng defaults to Math.random. */
|
|
7
9
|
export function shouldRecord(cfg, rng = Math.random) {
|
|
8
10
|
if (!cfg.enabled)
|
|
@@ -100,6 +102,72 @@ export function tracingActive(opts, cfg) {
|
|
|
100
102
|
export function errorCaptureActive(opts, cfg) {
|
|
101
103
|
return opts.errors !== false && cfg.errors_enabled !== false;
|
|
102
104
|
}
|
|
105
|
+
const MASK_ATTR = "data-hs-mask";
|
|
106
|
+
const UNMASK_ATTR = "data-hs-unmask";
|
|
107
|
+
/** True if the NEAREST `data-hs-mask` / `data-hs-unmask` marker on `element` or
|
|
108
|
+
* an ancestor is `data-hs-unmask` (so a `data-hs-mask` nested inside an unmasked
|
|
109
|
+
* region re-masks). */
|
|
110
|
+
function isRevealed(element) {
|
|
111
|
+
const marked = element?.closest?.(`[${MASK_ATTR}],[${UNMASK_ATTR}]`);
|
|
112
|
+
return !!marked && !!marked.hasAttribute?.(UNMASK_ATTR);
|
|
113
|
+
}
|
|
114
|
+
function isPasswordInput(element) {
|
|
115
|
+
const type = (element?.type ?? element?.getAttribute?.("type") ?? "").toString().toLowerCase();
|
|
116
|
+
return type === "password";
|
|
117
|
+
}
|
|
118
|
+
const stars = (text) => "*".repeat(text.length);
|
|
119
|
+
/**
|
|
120
|
+
* Pure: the rrweb privacy options for a masking mode. Anything other than an
|
|
121
|
+
* explicit `"relaxed"` is treated as `"strict"` (fail-closed).
|
|
122
|
+
*
|
|
123
|
+
* - **strict** (default): ALL text and ALL input values are masked
|
|
124
|
+
* (`maskTextSelector: "*"` + `maskAllInputs`). Mark an element with
|
|
125
|
+
* `data-hs-unmask` to record it (and its descendants) in cleartext; a nested
|
|
126
|
+
* `data-hs-mask` re-masks. Password inputs are ALWAYS masked, even inside a
|
|
127
|
+
* `data-hs-unmask` region.
|
|
128
|
+
* - **relaxed**: text is recorded except inside `data-hs-mask`; input values are
|
|
129
|
+
* recorded except passwords (rrweb's default).
|
|
130
|
+
*
|
|
131
|
+
* Both modes drop `data-hs-block` elements entirely (recorded as a placeholder
|
|
132
|
+
* box). Masking applies to text nodes and input values only — element ATTRIBUTES
|
|
133
|
+
* (`title`, `alt`, `placeholder`, `aria-label`, link `href`s) are not masked by
|
|
134
|
+
* rrweb, so use `data-hs-block` for regions whose attributes are sensitive.
|
|
135
|
+
*/
|
|
136
|
+
export function replayPrivacyOptions(mode) {
|
|
137
|
+
const strict = mode !== "relaxed";
|
|
138
|
+
if (!strict) {
|
|
139
|
+
return {
|
|
140
|
+
maskAllInputs: false,
|
|
141
|
+
maskTextSelector: `[${MASK_ATTR}]`,
|
|
142
|
+
blockSelector: "[data-hs-block]",
|
|
143
|
+
// rrweb only calls these for nodes it already decided to mask
|
|
144
|
+
// (`data-hs-mask` text / password inputs) — always mask them.
|
|
145
|
+
maskInputFn: (text) => stars(text),
|
|
146
|
+
maskTextFn: (text) => stars(text),
|
|
147
|
+
};
|
|
148
|
+
}
|
|
149
|
+
return {
|
|
150
|
+
maskAllInputs: true,
|
|
151
|
+
// "*" matches every element, so rrweb routes ALL text through maskTextFn
|
|
152
|
+
// (rrweb 2.0.1 checks `el.closest(maskTextSelector)` for mutations and
|
|
153
|
+
// `el.matches(...)` / ancestor checks for snapshots).
|
|
154
|
+
maskTextSelector: "*",
|
|
155
|
+
blockSelector: "[data-hs-block]",
|
|
156
|
+
maskInputFn: (text, element) => !isPasswordInput(element) && isRevealed(element) ? text : stars(text),
|
|
157
|
+
maskTextFn: (text, element) => (isRevealed(element) ? text : stars(text)),
|
|
158
|
+
};
|
|
159
|
+
}
|
|
160
|
+
/** rrweb Meta events (`type: 4`) carry the page `href`: return a copy with it redacted. */
|
|
161
|
+
const META = 4;
|
|
162
|
+
export function redactReplayEvent(event, redact = redactUrl) {
|
|
163
|
+
const e = event;
|
|
164
|
+
if (e && e.type === META && e.data && typeof e.data.href === "string") {
|
|
165
|
+
const href = redact(e.data.href);
|
|
166
|
+
if (href !== e.data.href)
|
|
167
|
+
return { ...e, data: { ...e.data, href } };
|
|
168
|
+
}
|
|
169
|
+
return event;
|
|
170
|
+
}
|
|
103
171
|
/** A caller-owned session UUID allows product activity to correlate with replay. */
|
|
104
172
|
export function resolveWebSessionId(candidate) {
|
|
105
173
|
if (candidate === undefined)
|
|
@@ -136,6 +204,8 @@ export async function instrumentWeb(opts) {
|
|
|
136
204
|
const sessionId = resolveWebSessionId(opts.sessionId);
|
|
137
205
|
// service.version / service.build for release attribution on exported browser logs.
|
|
138
206
|
const releaseAttrs = releaseResourceAttributes({ version: opts.version, build: opts.build });
|
|
207
|
+
// One URL redactor for everything this run records (spans, logs, replay).
|
|
208
|
+
const redact = makeUrlRedactor(opts);
|
|
139
209
|
// 2. Browser distributed tracing (opt-in) — INDEPENDENT of replay sampling. Emits
|
|
140
210
|
// a real CLIENT span per outbound fetch + propagates W3C context to the backend.
|
|
141
211
|
let stopTracing = () => { };
|
|
@@ -154,6 +224,7 @@ export async function instrumentWeb(opts) {
|
|
|
154
224
|
collector: traces, // real trace ids also tag the replay session (better correlation)
|
|
155
225
|
activeTrace, // so a captured error can reference the in-flight span
|
|
156
226
|
propagateTargets: opts.tracePropagationTargets, // same-origin + these only (never third parties)
|
|
227
|
+
redactUrl: redact,
|
|
157
228
|
});
|
|
158
229
|
fetchPatched = true;
|
|
159
230
|
const traceFlush = setInterval(() => void exporter.flush(), opts.flushIntervalMs ?? DEFAULT_FLUSH_MS);
|
|
@@ -181,7 +252,7 @@ export async function instrumentWeb(opts) {
|
|
|
181
252
|
registerBugSession({
|
|
182
253
|
endpoint, apiKey: opts.apiKey, service: opts.service, sessionId,
|
|
183
254
|
fetchImpl: originalFetch, activeTrace, recentErrors, logsExporter,
|
|
184
|
-
version: opts.version, build: opts.build,
|
|
255
|
+
version: opts.version, build: opts.build, redactUrl: redact,
|
|
185
256
|
});
|
|
186
257
|
// 2b. Browser error / console log collection — ON by default, INDEPENDENT of both
|
|
187
258
|
// tracing and replay sampling. Exports OTLP logs to /v1/logs via the original fetch.
|
|
@@ -195,6 +266,7 @@ export async function instrumentWeb(opts) {
|
|
|
195
266
|
captureConsole: opts.captureConsole ?? "error",
|
|
196
267
|
activeTrace,
|
|
197
268
|
recent: recentErrors,
|
|
269
|
+
redactUrl: redact,
|
|
198
270
|
});
|
|
199
271
|
const logFlush = setInterval(() => void logsExporter.flush(), opts.flushIntervalMs ?? DEFAULT_FLUSH_MS);
|
|
200
272
|
const onHideLogs = () => { if (document.visibilityState === "hidden")
|
|
@@ -214,16 +286,11 @@ export async function instrumentWeb(opts) {
|
|
|
214
286
|
if (!shouldRecord(cfg))
|
|
215
287
|
return () => { stopTracing(); stopErrors(); registerBugSession(null); };
|
|
216
288
|
const { record } = await import("rrweb");
|
|
217
|
-
//
|
|
218
|
-
//
|
|
219
|
-
// `
|
|
220
|
-
//
|
|
221
|
-
|
|
222
|
-
// MaskTextFn = (text: string, element: HTMLElement | null) => string
|
|
223
|
-
// `maskTextFn`'s element can be null, so the `?.closest?.` chain is guarded.
|
|
224
|
-
const reveal = (text, element) => !!element?.closest?.("[data-hs-unmask]");
|
|
225
|
-
const maskInputFn = (text, element) => reveal(text, element) ? text : "*".repeat(text.length);
|
|
226
|
-
const maskTextFn = (text, element) => reveal(text, element) ? text : "*".repeat(text.length);
|
|
289
|
+
// Strict (the default, and the fallback for any unknown/missing mode) masks ALL
|
|
290
|
+
// text and input values; `data-hs-unmask` opts an element back in. rrweb 2.0.1
|
|
291
|
+
// has no `unmaskTextSelector`, so the opt-out is implemented in
|
|
292
|
+
// `maskTextFn`/`maskInputFn` — see replayPrivacyOptions.
|
|
293
|
+
const privacy = replayPrivacyOptions(cfg.masking_mode);
|
|
227
294
|
const transport = new ReplayTransport({ endpoint, apiKey: opts.apiKey, fetchImpl: originalFetch, sessionId });
|
|
228
295
|
// Only patch fetch for replay correlation if tracing didn't already patch it
|
|
229
296
|
// (tracing's patch injects real context AND feeds `traces`). Avoids double-wrapping
|
|
@@ -244,7 +311,7 @@ export async function instrumentWeb(opts) {
|
|
|
244
311
|
const errs = errorCount;
|
|
245
312
|
errorCount = 0;
|
|
246
313
|
void transport.send(events, {
|
|
247
|
-
url: location.href, errorCount: errs, userId: opts.userId, browser, device,
|
|
314
|
+
url: redact(location.href), errorCount: errs, userId: opts.userId, browser, device,
|
|
248
315
|
traceIds: traces.drain(),
|
|
249
316
|
}, keepalive);
|
|
250
317
|
};
|
|
@@ -256,17 +323,13 @@ export async function instrumentWeb(opts) {
|
|
|
256
323
|
// nearest keyframe ≤ a target time instead of loading the whole session.
|
|
257
324
|
if (isCheckout)
|
|
258
325
|
flush();
|
|
259
|
-
buffer.push(event);
|
|
326
|
+
buffer.push(redactReplayEvent(event, redact));
|
|
260
327
|
if (buffer.length >= (opts.flushEveryEvents ?? DEFAULT_FLUSH_EVENTS))
|
|
261
328
|
flush();
|
|
262
329
|
},
|
|
263
330
|
// A ~60s keyframe cadence bounds how many chunks the player must fetch to seek.
|
|
264
331
|
checkoutEveryNms: 60_000,
|
|
265
|
-
|
|
266
|
-
maskTextSelector: "[data-hs-mask]",
|
|
267
|
-
blockSelector: "[data-hs-block]",
|
|
268
|
-
maskInputFn,
|
|
269
|
-
maskTextFn,
|
|
332
|
+
...privacy,
|
|
270
333
|
});
|
|
271
334
|
const interval = setInterval(() => flush(), opts.flushIntervalMs ?? DEFAULT_FLUSH_MS);
|
|
272
335
|
const onHide = () => { if (document.visibilityState === "hidden")
|
|
@@ -460,11 +523,13 @@ function fetchMethod(input, init) {
|
|
|
460
523
|
return m.toUpperCase();
|
|
461
524
|
}
|
|
462
525
|
function pathOf(url) {
|
|
526
|
+
// Relative URLs (no base) fall back to the raw string minus query/fragment, so a
|
|
527
|
+
// span NAME never carries a secret the url.full redaction removed.
|
|
463
528
|
try {
|
|
464
529
|
return new URL(url).pathname;
|
|
465
530
|
}
|
|
466
531
|
catch {
|
|
467
|
-
return url;
|
|
532
|
+
return url.split(/[?#]/)[0] ?? url;
|
|
468
533
|
}
|
|
469
534
|
}
|
|
470
535
|
/** Patch window.fetch to emit a real CLIENT span per outbound call, inject that
|
|
@@ -475,6 +540,7 @@ export function patchFetchForTracing(o) {
|
|
|
475
540
|
return () => { };
|
|
476
541
|
const orig = window.fetch.bind(window);
|
|
477
542
|
const rng = o.rng ?? Math.random;
|
|
543
|
+
const redact = o.redactUrl ?? redactUrl;
|
|
478
544
|
window.fetch = ((input, init) => {
|
|
479
545
|
const url = fetchUrl(input);
|
|
480
546
|
// Never trace our own telemetry POSTs (trace export, replay upload, config) —
|
|
@@ -502,7 +568,7 @@ export function patchFetchForTracing(o) {
|
|
|
502
568
|
const emit = (statusCode, error) => {
|
|
503
569
|
if (!sampled)
|
|
504
570
|
return;
|
|
505
|
-
o.onSpan({ traceId, spanId, name: `${method} ${pathOf(url)}`, startMs, endMs: Date.now(), method, url, statusCode, error });
|
|
571
|
+
o.onSpan({ traceId, spanId, name: `${method} ${pathOf(url)}`, startMs, endMs: Date.now(), method, url: redact(url), statusCode, error });
|
|
506
572
|
};
|
|
507
573
|
return orig(input, { ...init, headers }).then((res) => { emit(res.status, res.status >= 400); return res; }, (err) => { emit(0, true); throw err; });
|
|
508
574
|
});
|
|
@@ -647,6 +713,7 @@ export function startErrorCapture(o) {
|
|
|
647
713
|
if (typeof window === "undefined")
|
|
648
714
|
return () => { };
|
|
649
715
|
const now = o.now ?? Date.now;
|
|
716
|
+
const redact = o.redactUrl ?? redactUrl;
|
|
650
717
|
const gate = new RateGate(o.rateCap ?? 60, o.rateWindowMs ?? 60_000, now);
|
|
651
718
|
// Re-entrancy flag: if capturing a record ends up invoking a patched console
|
|
652
719
|
// (or any path that re-enters here), we drop the nested call — this is what
|
|
@@ -677,7 +744,7 @@ export function startErrorCapture(o) {
|
|
|
677
744
|
o.recent?.push({ message: e.message, type: e.type, timestamp: now() });
|
|
678
745
|
const attrs = {
|
|
679
746
|
"event.name": "browser.error",
|
|
680
|
-
"url.full": location.href,
|
|
747
|
+
"url.full": redact(location.href),
|
|
681
748
|
session_id: o.sessionId,
|
|
682
749
|
"exception.message": e.message,
|
|
683
750
|
};
|
|
@@ -740,7 +807,7 @@ export function startErrorCapture(o) {
|
|
|
740
807
|
attributes: {
|
|
741
808
|
"event.name": "browser.console",
|
|
742
809
|
"console.level": level,
|
|
743
|
-
"url.full": location.href,
|
|
810
|
+
"url.full": redact(location.href),
|
|
744
811
|
session_id: o.sessionId,
|
|
745
812
|
},
|
|
746
813
|
traceId: active?.traceId,
|
|
@@ -778,7 +845,7 @@ export function buildBugReportPayload(s, report) {
|
|
|
778
845
|
message: report.message,
|
|
779
846
|
email: report.email,
|
|
780
847
|
context: report.context,
|
|
781
|
-
url: typeof location !== "undefined" ? location.href : undefined,
|
|
848
|
+
url: typeof location !== "undefined" ? (s.redactUrl ?? redactUrl)(location.href) : undefined,
|
|
782
849
|
user_agent: typeof navigator !== "undefined" ? navigator.userAgent : undefined,
|
|
783
850
|
session_id: s.sessionId,
|
|
784
851
|
trace_id: active?.traceId,
|
|
@@ -821,7 +888,7 @@ export async function reportBug(report) {
|
|
|
821
888
|
body: message,
|
|
822
889
|
attributes: {
|
|
823
890
|
"event.name": "user.bug_report",
|
|
824
|
-
"url.full": typeof location !== "undefined" ? location.href : "",
|
|
891
|
+
"url.full": typeof location !== "undefined" ? (s.redactUrl ?? redactUrl)(location.href) : "",
|
|
825
892
|
session_id: s.sessionId,
|
|
826
893
|
...(report.email ? { "user.email": report.email } : {}),
|
|
827
894
|
},
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { SpanKind, type Span } from "@opentelemetry/api";
|
|
2
|
+
import type { BindingKind } from "./workers-coverage.js";
|
|
2
3
|
export interface InstrumentBindingsOpts {
|
|
3
4
|
/**
|
|
4
5
|
* Factory that creates and starts a new child span. Called at binding method
|
|
@@ -8,7 +9,17 @@ export interface InstrumentBindingsOpts {
|
|
|
8
9
|
* For integration: pass `(name, attrs, kind) => tracer.startSpan(name, { attributes: attrs, kind }, context.active())`.
|
|
9
10
|
* For unit tests: inject a fake so no global provider is required.
|
|
10
11
|
*/
|
|
11
|
-
startSpan
|
|
12
|
+
startSpan?: (name: string, attrs: Record<string, unknown>, kind?: SpanKind) => Span;
|
|
13
|
+
/**
|
|
14
|
+
* Callback-scoped span runner (native mode). When present, every wrapper runs
|
|
15
|
+
* its operation inside it and `startSpan` is never called. Must throw/reject
|
|
16
|
+
* only what `fn` throws.
|
|
17
|
+
*/
|
|
18
|
+
withSpan?: <T>(name: string, attrs: Record<string, unknown>, kind: SpanKind | undefined, fn: (span: Span) => T | Promise<T>) => Promise<T>;
|
|
19
|
+
/** Skip bindings whose {@link BindingKind} this returns false for. */
|
|
20
|
+
kindFilter?: (kind: BindingKind) => boolean;
|
|
21
|
+
/** Use Cloudflare-native span names (`ai_run`, `vectorize_<op>`, `workflow_<op>`, ...). */
|
|
22
|
+
nativeNames?: boolean;
|
|
12
23
|
/**
|
|
13
24
|
* `true` → auto-detect and wrap every supported Cloudflare binding.
|
|
14
25
|
* `string[]` → only wrap bindings whose env key is listed.
|
|
@@ -21,6 +32,19 @@ export interface InstrumentBindingsOpts {
|
|
|
21
32
|
* wrappers in workers.ts.
|
|
22
33
|
*/
|
|
23
34
|
export declare function runWithSpan<T>(span: Span, fn: () => Promise<T>): Promise<T>;
|
|
35
|
+
/** Plain string keys from a string / string[] argument (anything else yields none). */
|
|
36
|
+
export declare function keysOf(value: unknown): string[];
|
|
37
|
+
/** Resolves once every in-flight key hash has attached its attribute and ended its span. */
|
|
38
|
+
export declare function settlePendingKeyHashes(): Promise<void>;
|
|
39
|
+
/**
|
|
40
|
+
* Like {@link runWithSpan} for operations that carry user-controlled keys. The
|
|
41
|
+
* hash is computed concurrently with the call and attached under `attr` before
|
|
42
|
+
* the span ends; it can neither delay the call's result nor change its error.
|
|
43
|
+
* If hashing is impossible the attribute is omitted (never the raw key).
|
|
44
|
+
*/
|
|
45
|
+
export declare function runWithSpanKeyed<T>(span: Span, attr: string, keys: readonly string[], fn: () => Promise<T>, countAttr?: string): Promise<T>;
|
|
46
|
+
/** Trace a call without replacing synchronous, Promise, or custom thenable results. */
|
|
47
|
+
export declare function runWithSpanPreservingReturn<T>(span: Span, fn: () => T): T;
|
|
24
48
|
/** Internal proxy builder shared with the class-level wrappers in workers.ts. */
|
|
25
49
|
export declare function makeProxy(target: any, handlers: Record<string, Function>): any;
|
|
26
50
|
/**
|
|
@@ -61,4 +85,17 @@ export declare function injectTraceparentArgs(args: any[], traceparent: string):
|
|
|
61
85
|
* @param opts - `startSpan` factory (now accepts optional `SpanKind`) + `select` filter.
|
|
62
86
|
* @returns A shallow copy of `env` with selected bindings replaced by proxies.
|
|
63
87
|
*/
|
|
64
|
-
export declare function instrumentEnv<E extends Record<string, unknown>>(env: E,
|
|
88
|
+
export declare function instrumentEnv<E extends Record<string, unknown>>(env: E, rawOpts: InstrumentBindingsOpts): E;
|
|
89
|
+
/**
|
|
90
|
+
* Wrap a Postgres/MySQL client (postgres.js, pg, mysql2, ...) created from a
|
|
91
|
+
* Hyperdrive binding so each `query` / `unsafe` / `execute` call, and each
|
|
92
|
+
* tagged-template call when the client itself is callable (postgres.js
|
|
93
|
+
* `sql\`select 1\``), emits a `hyperdrive_query` span. `db.query.text` is
|
|
94
|
+
* recorded raw (template strings are joined with `?`; ingest sanitises). The
|
|
95
|
+
* ORIGINAL return value is passed through untouched; the span ends when it
|
|
96
|
+
* settles. Uses the request's native tracer when present, else the global one.
|
|
97
|
+
*/
|
|
98
|
+
export declare function wrapSqlClient<C extends object>(client: C, opts?: {
|
|
99
|
+
name?: string;
|
|
100
|
+
dbSystem?: "postgresql" | "mysql";
|
|
101
|
+
}): C;
|