@heystack/otel 0.11.0 → 0.12.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 +10 -2
- package/dist/web.d.ts +60 -2
- package/dist/web.js +71 -5
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -376,6 +376,7 @@ stop();
|
|
|
376
376
|
| `flushEveryEvents` | `number?` | Max buffered events before an early flush (default 200). |
|
|
377
377
|
| `tracing` | `boolean?` | Opt in to **browser distributed tracing** (default off). Emits a real CLIENT span per outbound `fetch` and propagates W3C trace context, so browser→backend calls show as one connected trace + a service-map edge. Independent of replay. |
|
|
378
378
|
| `traceSampleRate` | `number?` | Head sample rate for browser tracing (0–1, default 1 when `tracing` is on). Lower it to cap span volume/cost on busy apps. |
|
|
379
|
+
| `tracePropagationTargets` | `(string \| RegExp)[]?` | Cross-origin backends allowed to receive the `traceparent` header (same-origin always is). Trace context is **never** sent to any other origin, so third-party calls (Clerk, Stripe, analytics) aren't broken by an unexpected CORS preflight. Match by substring/RegExp against the URL, e.g. `["api.myapp.com"]`. **A listed backend must allow `traceparent`/`tracestate` in its CORS `Access-Control-Allow-Headers`.** |
|
|
379
380
|
| `errors` | `boolean?` | Capture uncaught browser errors (`window.onerror` + `unhandledrejection`) as logs. **On by default** — set `false` to disable. Independent of replay/tracing. |
|
|
380
381
|
| `captureConsole` | `'error' \| 'warn' \| false` | Capture `console` output as logs. `'error'` (default) captures `console.error`; `'warn'` captures `console.warn` **and** `console.error`; `false` disables it. Rate-capped + recursion-guarded. |
|
|
381
382
|
| `version` | `string?` | Release identifier → the `service.version` resource attribute on exported browser logs (release attribution / suspect release). |
|
|
@@ -390,11 +391,16 @@ await instrumentWeb({
|
|
|
390
391
|
apiKey: import.meta.env.VITE_HEYSTACK_API_KEY,
|
|
391
392
|
service: "my-web-app",
|
|
392
393
|
tracing: true,
|
|
393
|
-
traceSampleRate: 0.25,
|
|
394
|
+
traceSampleRate: 0.25, // sample 25% of requests — tune for cost
|
|
395
|
+
tracePropagationTargets: ["api.myapp.com"], // cross-origin backend(s) to trace through
|
|
394
396
|
});
|
|
395
397
|
```
|
|
396
398
|
|
|
397
|
-
|
|
399
|
+
**Trace headers go to same-origin + your allow-list only — never third parties.** The `traceparent` header is added only to same-origin requests and to URLs matching `tracePropagationTargets`. This is a safety guarantee: injecting `traceparent` on a *cross-origin* request forces a CORS preflight, and a third party that doesn't list the header (Clerk, Stripe, most APIs) **rejects it — breaking that request**. So calls to your own **cross-origin** backend need it added to `tracePropagationTargets`, **and** that backend must allow `traceparent`/`tracestate` in its CORS `Access-Control-Allow-Headers`. Same-origin backends need neither (no CORS involved).
|
|
400
|
+
|
|
401
|
+
Otherwise it's **cost-aware and safe by design**: off unless you opt in; head-sampled (an unsampled request still propagates `traceparent` with the sampled flag cleared, so the backend makes the same keep/drop decision — no orphaned server spans); and the exporter posts through the *original* `fetch`, never tracing its own upload (no self-export loop). Spans post to `/v1/traces` cross-origin with no CORS setup on your side.
|
|
402
|
+
|
|
403
|
+
**Remote kill-switch (safety valve).** Browser tracing can also be disabled **from the server**, without an app redeploy — a way to stop it fleet-wide if it ever misbehaves in the wild. It is **fail-open**: if the server config is unreachable or omits the flag, tracing stays on (absent flag = enabled), so the switch can only ever *disable*, never accidentally turn a working app off.
|
|
398
404
|
|
|
399
405
|
### Browser error & console collection
|
|
400
406
|
|
|
@@ -413,6 +419,8 @@ await instrumentWeb({
|
|
|
413
419
|
|
|
414
420
|
It's the **same cost-safe design** as tracing/replay: logs POST to `/v1/logs` through the *original* `fetch` (captured before any patching) so the exporter never traces its own upload — no self-export loop. Console capture is **recursion-guarded** (anything logged on the export path is never re-captured) and **rate-capped** (max ~60 records/minute; the overflow is dropped and counted, with one summary log emitted when the cap lifts) so a runaway `console.error` in a render loop can't flood ingest. Errors show up in the console **Logs** tab and correlate to their session replay and trace.
|
|
415
421
|
|
|
422
|
+
Like tracing, error / console capture has a **remote kill-switch**: it can be disabled **from the server** without an app redeploy, and is **fail-open** (if the server config is unreachable or omits the flag, capture stays on — absent flag = enabled).
|
|
423
|
+
|
|
416
424
|
### In-app bug reports (`reportBug`)
|
|
417
425
|
|
|
418
426
|
Let users report a bug from inside your app. `reportBug` is a headless API — **no widget, you own the UX** — that files a structured report and auto-attaches the context a triager needs: the current URL, user agent, replay `session_id`, active `trace_id`, `release` / `build`, and the **last few captured browser errors** (so you see what was going wrong on the page right before the report). The report also appears on the Logs timeline (`event.name=user.bug_report`).
|
package/dist/web.d.ts
CHANGED
|
@@ -17,6 +17,19 @@ export interface RecorderConfig {
|
|
|
17
17
|
enabled: boolean;
|
|
18
18
|
sample_rate: number;
|
|
19
19
|
masking_mode: "strict" | "relaxed";
|
|
20
|
+
/**
|
|
21
|
+
* Server-side kill-switch for browser distributed tracing. Absent/`true` = enabled
|
|
22
|
+
* (**fail-open**); set `false` server-side to remotely disable browser tracing
|
|
23
|
+
* WITHOUT an app redeploy — the safety valve for the 2026-07-03 cross-origin
|
|
24
|
+
* `traceparent` outage. See `tracingActive`.
|
|
25
|
+
*/
|
|
26
|
+
tracing_enabled?: boolean;
|
|
27
|
+
/**
|
|
28
|
+
* Server-side kill-switch for browser error / console capture. Absent/`true` =
|
|
29
|
+
* enabled (**fail-open**); set `false` server-side to remotely disable it without
|
|
30
|
+
* an app redeploy. See `errorCaptureActive`.
|
|
31
|
+
*/
|
|
32
|
+
errors_enabled?: boolean;
|
|
20
33
|
}
|
|
21
34
|
/** Pure: decide once per session whether to record. rng defaults to Math.random. */
|
|
22
35
|
export declare function shouldRecord(cfg: RecorderConfig, rng?: () => number): boolean;
|
|
@@ -89,6 +102,18 @@ export interface InstrumentWebOptions {
|
|
|
89
102
|
/** Head sample rate for browser tracing (0–1, default 1 when `tracing` is on).
|
|
90
103
|
* Lower it to control span volume/cost on high-traffic apps. */
|
|
91
104
|
traceSampleRate?: number;
|
|
105
|
+
/**
|
|
106
|
+
* Cross-origin backend URLs that may receive the `traceparent` header, in
|
|
107
|
+
* addition to same-origin (which always does). Trace context is NEVER sent to an
|
|
108
|
+
* origin outside this list, so third-party calls (Clerk, Stripe, analytics, CDNs)
|
|
109
|
+
* are not broken by an unexpected CORS preflight. Match by substring or RegExp
|
|
110
|
+
* against the request URL, e.g. `["api.myapp.com"]`.
|
|
111
|
+
*
|
|
112
|
+
* IMPORTANT: a listed backend MUST allow `traceparent` (and `tracestate`) in its
|
|
113
|
+
* CORS `Access-Control-Allow-Headers`, or its own requests will be preflight-
|
|
114
|
+
* rejected too. Same-origin calls need no CORS and are unaffected.
|
|
115
|
+
*/
|
|
116
|
+
tracePropagationTargets?: (string | RegExp)[];
|
|
92
117
|
/**
|
|
93
118
|
* Capture uncaught browser errors (`window.onerror` + `unhandledrejection`) and
|
|
94
119
|
* export them as OTLP logs (`event.name=browser.error`, ERROR severity, with
|
|
@@ -104,6 +129,21 @@ export interface InstrumentWebOptions {
|
|
|
104
129
|
*/
|
|
105
130
|
captureConsole?: "error" | "warn" | false;
|
|
106
131
|
}
|
|
132
|
+
/**
|
|
133
|
+
* Pure: is browser distributed tracing active for this run? Requires BOTH the app
|
|
134
|
+
* to have opted in (`opts.tracing`) AND the server-side kill-switch to not be off
|
|
135
|
+
* (`cfg.tracing_enabled`). **Fail-open**: an absent/`true` flag counts as enabled, so
|
|
136
|
+
* an old or unreachable `/v1/replay/config` never silently disables tracing. Split out
|
|
137
|
+
* as a tiny exported predicate so the gating decision is unit-testable without booting
|
|
138
|
+
* rrweb (which `instrumentWeb` imports). See `RecorderConfig.tracing_enabled`.
|
|
139
|
+
*/
|
|
140
|
+
export declare function tracingActive(opts: Pick<InstrumentWebOptions, "tracing">, cfg: Pick<RecorderConfig, "tracing_enabled">): boolean;
|
|
141
|
+
/**
|
|
142
|
+
* Pure: is browser error / console capture active for this run? On by default
|
|
143
|
+
* (`opts.errors !== false`) AND gated by the server-side kill-switch
|
|
144
|
+
* (`cfg.errors_enabled`). **Fail-open**: an absent/`true` flag counts as enabled.
|
|
145
|
+
*/
|
|
146
|
+
export declare function errorCaptureActive(opts: Pick<InstrumentWebOptions, "errors">, cfg: Pick<RecorderConfig, "errors_enabled">): boolean;
|
|
107
147
|
/** Entry point: fetch config, decide sampling, start rrweb, stream chunks.
|
|
108
148
|
* Returns a stop() function. Safe to call in any browser; no-ops on the server. */
|
|
109
149
|
export declare function instrumentWeb(opts: InstrumentWebOptions): Promise<() => void>;
|
|
@@ -128,9 +168,24 @@ export declare class ActiveTraceRef {
|
|
|
128
168
|
spanId: string;
|
|
129
169
|
} | undefined;
|
|
130
170
|
}
|
|
171
|
+
/**
|
|
172
|
+
* Decide whether to attach trace headers (`traceparent`) to an outbound request.
|
|
173
|
+
* Same-origin is always propagated; a CROSS-origin request is propagated ONLY if
|
|
174
|
+
* its URL matches an explicit target.
|
|
175
|
+
*
|
|
176
|
+
* This is critical for correctness: adding the non-safelisted `traceparent` header
|
|
177
|
+
* to a cross-origin request forces a CORS preflight, and any third party that
|
|
178
|
+
* doesn't list the header in Access-Control-Allow-Headers (Clerk, Stripe, most
|
|
179
|
+
* APIs) rejects it — breaking the request. So we never send trace context to an
|
|
180
|
+
* origin the app didn't opt in. Mirrors OTel-web's `propagateTraceHeaderCorsUrls`
|
|
181
|
+
* / Sentry's `tracePropagationTargets`. Targets match against the full request URL
|
|
182
|
+
* (string substring or RegExp).
|
|
183
|
+
*/
|
|
184
|
+
export declare function shouldPropagateTrace(url: string, targets?: ReadonlyArray<string | RegExp>): boolean;
|
|
131
185
|
/** Patch window.fetch to inject traceparent on outgoing calls and record the
|
|
132
|
-
* trace id for correlation. Returns an unpatch function.
|
|
133
|
-
|
|
186
|
+
* trace id for correlation. Returns an unpatch function. `propagateTargets` are
|
|
187
|
+
* cross-origin backends allowed to receive trace headers (same-origin always is). */
|
|
188
|
+
export declare function patchFetchForCorrelation(collector: TraceIdCollector, propagateTargets?: ReadonlyArray<string | RegExp>): () => void;
|
|
134
189
|
/** One recorded browser CLIENT span (an outbound fetch). */
|
|
135
190
|
export interface BrowserClientSpan {
|
|
136
191
|
traceId: string;
|
|
@@ -174,6 +229,9 @@ export interface TracingPatchOpts {
|
|
|
174
229
|
collector?: TraceIdCollector;
|
|
175
230
|
/** Optional: publish the current span context so captured errors can reference it. */
|
|
176
231
|
activeTrace?: ActiveTraceRef;
|
|
232
|
+
/** Cross-origin backends allowed to receive trace headers (same-origin always is).
|
|
233
|
+
* Third-party / non-listed cross-origin calls are passed through untouched. */
|
|
234
|
+
propagateTargets?: ReadonlyArray<string | RegExp>;
|
|
177
235
|
rng?: () => number;
|
|
178
236
|
}
|
|
179
237
|
/** Patch window.fetch to emit a real CLIENT span per outbound call, inject that
|
package/dist/web.js
CHANGED
|
@@ -81,6 +81,25 @@ export class ReplayTransport {
|
|
|
81
81
|
}
|
|
82
82
|
const DEFAULT_FLUSH_MS = 5000;
|
|
83
83
|
const DEFAULT_FLUSH_EVENTS = 200;
|
|
84
|
+
/**
|
|
85
|
+
* Pure: is browser distributed tracing active for this run? Requires BOTH the app
|
|
86
|
+
* to have opted in (`opts.tracing`) AND the server-side kill-switch to not be off
|
|
87
|
+
* (`cfg.tracing_enabled`). **Fail-open**: an absent/`true` flag counts as enabled, so
|
|
88
|
+
* an old or unreachable `/v1/replay/config` never silently disables tracing. Split out
|
|
89
|
+
* as a tiny exported predicate so the gating decision is unit-testable without booting
|
|
90
|
+
* rrweb (which `instrumentWeb` imports). See `RecorderConfig.tracing_enabled`.
|
|
91
|
+
*/
|
|
92
|
+
export function tracingActive(opts, cfg) {
|
|
93
|
+
return !!opts.tracing && cfg.tracing_enabled !== false;
|
|
94
|
+
}
|
|
95
|
+
/**
|
|
96
|
+
* Pure: is browser error / console capture active for this run? On by default
|
|
97
|
+
* (`opts.errors !== false`) AND gated by the server-side kill-switch
|
|
98
|
+
* (`cfg.errors_enabled`). **Fail-open**: an absent/`true` flag counts as enabled.
|
|
99
|
+
*/
|
|
100
|
+
export function errorCaptureActive(opts, cfg) {
|
|
101
|
+
return opts.errors !== false && cfg.errors_enabled !== false;
|
|
102
|
+
}
|
|
84
103
|
/** Entry point: fetch config, decide sampling, start rrweb, stream chunks.
|
|
85
104
|
* Returns a stop() function. Safe to call in any browser; no-ops on the server. */
|
|
86
105
|
export async function instrumentWeb(opts) {
|
|
@@ -113,7 +132,9 @@ export async function instrumentWeb(opts) {
|
|
|
113
132
|
// a real CLIENT span per outbound fetch + propagates W3C context to the backend.
|
|
114
133
|
let stopTracing = () => { };
|
|
115
134
|
let fetchPatched = false;
|
|
116
|
-
|
|
135
|
+
// Gated by the server kill-switch (cfg.tracing_enabled) as well as the app opt-in —
|
|
136
|
+
// fail-open, so an unreachable/old config can't disable tracing. See tracingActive.
|
|
137
|
+
if (tracingActive(opts, cfg)) {
|
|
117
138
|
const exporter = new BrowserTraceExporter({
|
|
118
139
|
endpoint, apiKey: opts.apiKey, service: opts.service, fetchImpl: originalFetch,
|
|
119
140
|
resourceAttributes: releaseAttrs,
|
|
@@ -124,6 +145,7 @@ export async function instrumentWeb(opts) {
|
|
|
124
145
|
ingestHost,
|
|
125
146
|
collector: traces, // real trace ids also tag the replay session (better correlation)
|
|
126
147
|
activeTrace, // so a captured error can reference the in-flight span
|
|
148
|
+
propagateTargets: opts.tracePropagationTargets, // same-origin + these only (never third parties)
|
|
127
149
|
});
|
|
128
150
|
fetchPatched = true;
|
|
129
151
|
const traceFlush = setInterval(() => void exporter.flush(), opts.flushIntervalMs ?? DEFAULT_FLUSH_MS);
|
|
@@ -156,7 +178,9 @@ export async function instrumentWeb(opts) {
|
|
|
156
178
|
// 2b. Browser error / console log collection — ON by default, INDEPENDENT of both
|
|
157
179
|
// tracing and replay sampling. Exports OTLP logs to /v1/logs via the original fetch.
|
|
158
180
|
let stopErrors = () => { };
|
|
159
|
-
|
|
181
|
+
// Gated by the server kill-switch (cfg.errors_enabled) as well as the opts.errors
|
|
182
|
+
// default — fail-open. See errorCaptureActive.
|
|
183
|
+
if (errorCaptureActive(opts, cfg)) {
|
|
160
184
|
const unpatchErrors = startErrorCapture({
|
|
161
185
|
exporter: logsExporter,
|
|
162
186
|
sessionId,
|
|
@@ -196,7 +220,7 @@ export async function instrumentWeb(opts) {
|
|
|
196
220
|
// Only patch fetch for replay correlation if tracing didn't already patch it
|
|
197
221
|
// (tracing's patch injects real context AND feeds `traces`). Avoids double-wrapping
|
|
198
222
|
// window.fetch. Uses the original fetch for uploads (self-span suppression).
|
|
199
|
-
const unpatch = fetchPatched ? () => { } : patchFetchForCorrelation(traces);
|
|
223
|
+
const unpatch = fetchPatched ? () => { } : patchFetchForCorrelation(traces, opts.tracePropagationTargets);
|
|
200
224
|
let buffer = [];
|
|
201
225
|
let errorCount = 0;
|
|
202
226
|
const browser = navigator.userAgent;
|
|
@@ -297,13 +321,50 @@ export class ActiveTraceRef {
|
|
|
297
321
|
set(traceId, spanId) { this.cur = { traceId, spanId }; }
|
|
298
322
|
get() { return this.cur; }
|
|
299
323
|
}
|
|
324
|
+
/**
|
|
325
|
+
* Decide whether to attach trace headers (`traceparent`) to an outbound request.
|
|
326
|
+
* Same-origin is always propagated; a CROSS-origin request is propagated ONLY if
|
|
327
|
+
* its URL matches an explicit target.
|
|
328
|
+
*
|
|
329
|
+
* This is critical for correctness: adding the non-safelisted `traceparent` header
|
|
330
|
+
* to a cross-origin request forces a CORS preflight, and any third party that
|
|
331
|
+
* doesn't list the header in Access-Control-Allow-Headers (Clerk, Stripe, most
|
|
332
|
+
* APIs) rejects it — breaking the request. So we never send trace context to an
|
|
333
|
+
* origin the app didn't opt in. Mirrors OTel-web's `propagateTraceHeaderCorsUrls`
|
|
334
|
+
* / Sentry's `tracePropagationTargets`. Targets match against the full request URL
|
|
335
|
+
* (string substring or RegExp).
|
|
336
|
+
*/
|
|
337
|
+
export function shouldPropagateTrace(url, targets = []) {
|
|
338
|
+
const base = typeof location !== "undefined" ? location.href : undefined;
|
|
339
|
+
let href;
|
|
340
|
+
try {
|
|
341
|
+
const u = new URL(url, base);
|
|
342
|
+
if (typeof location !== "undefined" && u.origin === location.origin)
|
|
343
|
+
return true;
|
|
344
|
+
href = u.href;
|
|
345
|
+
}
|
|
346
|
+
catch {
|
|
347
|
+
return true; // relative/opaque with no base ⇒ same-origin (safe to propagate)
|
|
348
|
+
}
|
|
349
|
+
for (const t of targets) {
|
|
350
|
+
if (typeof t === "string" ? href.includes(t) : t.test(href))
|
|
351
|
+
return true;
|
|
352
|
+
}
|
|
353
|
+
return false;
|
|
354
|
+
}
|
|
300
355
|
/** Patch window.fetch to inject traceparent on outgoing calls and record the
|
|
301
|
-
* trace id for correlation. Returns an unpatch function.
|
|
302
|
-
|
|
356
|
+
* trace id for correlation. Returns an unpatch function. `propagateTargets` are
|
|
357
|
+
* cross-origin backends allowed to receive trace headers (same-origin always is). */
|
|
358
|
+
export function patchFetchForCorrelation(collector, propagateTargets = []) {
|
|
303
359
|
if (typeof window === "undefined" || !window.fetch)
|
|
304
360
|
return () => { };
|
|
305
361
|
const orig = window.fetch.bind(window);
|
|
306
362
|
window.fetch = ((input, init) => {
|
|
363
|
+
// Only attach trace context to same-origin / allow-listed backends — never a
|
|
364
|
+
// third party (that would break its CORS preflight). See shouldPropagateTrace.
|
|
365
|
+
if (!shouldPropagateTrace(fetchUrl(input), propagateTargets)) {
|
|
366
|
+
return orig(input, init);
|
|
367
|
+
}
|
|
307
368
|
const traceId = randHex(16);
|
|
308
369
|
const spanId = randHex(8);
|
|
309
370
|
collector.add(traceId);
|
|
@@ -413,6 +474,11 @@ export function patchFetchForTracing(o) {
|
|
|
413
474
|
// the original fetch; this is the belt-and-suspenders host-match guard.
|
|
414
475
|
if (o.ingestHost && safeHostname(url) === o.ingestHost)
|
|
415
476
|
return orig(input, init);
|
|
477
|
+
// Third-party / non-allow-listed cross-origin: don't trace AND don't inject a
|
|
478
|
+
// header — a `traceparent` on a cross-origin request forces a CORS preflight the
|
|
479
|
+
// third party rejects (the Clerk/Stripe outage class). Pass through untouched.
|
|
480
|
+
if (!shouldPropagateTrace(url, o.propagateTargets))
|
|
481
|
+
return orig(input, init);
|
|
416
482
|
const sampled = rng() < o.sampleRate;
|
|
417
483
|
const traceId = randHex(16);
|
|
418
484
|
const spanId = randHex(8);
|