@heystack/otel 0.17.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/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;