@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/README.md +169 -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/product.d.ts +36 -0
- package/dist/product.js +41 -3
- 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/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;
|