@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 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, // sample 25% of requests — tune for cost
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
- 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 decisionno 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.
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
- export declare function patchFetchForCorrelation(collector: TraceIdCollector): () => void;
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
- if (opts.tracing) {
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
- if (opts.errors !== false) {
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
- export function patchFetchForCorrelation(collector) {
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);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@heystack/otel",
3
- "version": "0.11.0",
3
+ "version": "0.12.0",
4
4
  "description": "Runtime-aware OpenTelemetry tracing that exports to Heystack (Node, Next.js, Workers).",
5
5
  "license": "MIT",
6
6
  "type": "module",