@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.
@@ -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
- // An element marked `data-hs-unmask` (or any descendant of one) is recorded in
218
- // cleartext; everything else is masked. rrweb 2.0.1's real opt-out hooks are
219
- // `maskInputFn`/`maskTextFn` — there is no `unmaskTextSelector` option.
220
- // Signatures (from rrweb-snapshot 2.0.1):
221
- // MaskInputFn = (text: string, element: HTMLElement) => string
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
- maskAllInputs: cfg.masking_mode === "strict",
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: (name: string, attrs: Record<string, unknown>, kind?: SpanKind) => Span;
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, opts: InstrumentBindingsOpts): 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;