@heystack/otel 0.11.0 → 0.11.1

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,14 @@ 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.
398
402
 
399
403
  ### Browser error & console collection
400
404
 
package/dist/web.d.ts CHANGED
@@ -89,6 +89,18 @@ export interface InstrumentWebOptions {
89
89
  /** Head sample rate for browser tracing (0–1, default 1 when `tracing` is on).
90
90
  * Lower it to control span volume/cost on high-traffic apps. */
91
91
  traceSampleRate?: number;
92
+ /**
93
+ * Cross-origin backend URLs that may receive the `traceparent` header, in
94
+ * addition to same-origin (which always does). Trace context is NEVER sent to an
95
+ * origin outside this list, so third-party calls (Clerk, Stripe, analytics, CDNs)
96
+ * are not broken by an unexpected CORS preflight. Match by substring or RegExp
97
+ * against the request URL, e.g. `["api.myapp.com"]`.
98
+ *
99
+ * IMPORTANT: a listed backend MUST allow `traceparent` (and `tracestate`) in its
100
+ * CORS `Access-Control-Allow-Headers`, or its own requests will be preflight-
101
+ * rejected too. Same-origin calls need no CORS and are unaffected.
102
+ */
103
+ tracePropagationTargets?: (string | RegExp)[];
92
104
  /**
93
105
  * Capture uncaught browser errors (`window.onerror` + `unhandledrejection`) and
94
106
  * export them as OTLP logs (`event.name=browser.error`, ERROR severity, with
@@ -128,9 +140,24 @@ export declare class ActiveTraceRef {
128
140
  spanId: string;
129
141
  } | undefined;
130
142
  }
143
+ /**
144
+ * Decide whether to attach trace headers (`traceparent`) to an outbound request.
145
+ * Same-origin is always propagated; a CROSS-origin request is propagated ONLY if
146
+ * its URL matches an explicit target.
147
+ *
148
+ * This is critical for correctness: adding the non-safelisted `traceparent` header
149
+ * to a cross-origin request forces a CORS preflight, and any third party that
150
+ * doesn't list the header in Access-Control-Allow-Headers (Clerk, Stripe, most
151
+ * APIs) rejects it — breaking the request. So we never send trace context to an
152
+ * origin the app didn't opt in. Mirrors OTel-web's `propagateTraceHeaderCorsUrls`
153
+ * / Sentry's `tracePropagationTargets`. Targets match against the full request URL
154
+ * (string substring or RegExp).
155
+ */
156
+ export declare function shouldPropagateTrace(url: string, targets?: ReadonlyArray<string | RegExp>): boolean;
131
157
  /** 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;
158
+ * trace id for correlation. Returns an unpatch function. `propagateTargets` are
159
+ * cross-origin backends allowed to receive trace headers (same-origin always is). */
160
+ export declare function patchFetchForCorrelation(collector: TraceIdCollector, propagateTargets?: ReadonlyArray<string | RegExp>): () => void;
134
161
  /** One recorded browser CLIENT span (an outbound fetch). */
135
162
  export interface BrowserClientSpan {
136
163
  traceId: string;
@@ -174,6 +201,9 @@ export interface TracingPatchOpts {
174
201
  collector?: TraceIdCollector;
175
202
  /** Optional: publish the current span context so captured errors can reference it. */
176
203
  activeTrace?: ActiveTraceRef;
204
+ /** Cross-origin backends allowed to receive trace headers (same-origin always is).
205
+ * Third-party / non-listed cross-origin calls are passed through untouched. */
206
+ propagateTargets?: ReadonlyArray<string | RegExp>;
177
207
  rng?: () => number;
178
208
  }
179
209
  /** Patch window.fetch to emit a real CLIENT span per outbound call, inject that
package/dist/web.js CHANGED
@@ -124,6 +124,7 @@ export async function instrumentWeb(opts) {
124
124
  ingestHost,
125
125
  collector: traces, // real trace ids also tag the replay session (better correlation)
126
126
  activeTrace, // so a captured error can reference the in-flight span
127
+ propagateTargets: opts.tracePropagationTargets, // same-origin + these only (never third parties)
127
128
  });
128
129
  fetchPatched = true;
129
130
  const traceFlush = setInterval(() => void exporter.flush(), opts.flushIntervalMs ?? DEFAULT_FLUSH_MS);
@@ -196,7 +197,7 @@ export async function instrumentWeb(opts) {
196
197
  // Only patch fetch for replay correlation if tracing didn't already patch it
197
198
  // (tracing's patch injects real context AND feeds `traces`). Avoids double-wrapping
198
199
  // window.fetch. Uses the original fetch for uploads (self-span suppression).
199
- const unpatch = fetchPatched ? () => { } : patchFetchForCorrelation(traces);
200
+ const unpatch = fetchPatched ? () => { } : patchFetchForCorrelation(traces, opts.tracePropagationTargets);
200
201
  let buffer = [];
201
202
  let errorCount = 0;
202
203
  const browser = navigator.userAgent;
@@ -297,13 +298,50 @@ export class ActiveTraceRef {
297
298
  set(traceId, spanId) { this.cur = { traceId, spanId }; }
298
299
  get() { return this.cur; }
299
300
  }
301
+ /**
302
+ * Decide whether to attach trace headers (`traceparent`) to an outbound request.
303
+ * Same-origin is always propagated; a CROSS-origin request is propagated ONLY if
304
+ * its URL matches an explicit target.
305
+ *
306
+ * This is critical for correctness: adding the non-safelisted `traceparent` header
307
+ * to a cross-origin request forces a CORS preflight, and any third party that
308
+ * doesn't list the header in Access-Control-Allow-Headers (Clerk, Stripe, most
309
+ * APIs) rejects it — breaking the request. So we never send trace context to an
310
+ * origin the app didn't opt in. Mirrors OTel-web's `propagateTraceHeaderCorsUrls`
311
+ * / Sentry's `tracePropagationTargets`. Targets match against the full request URL
312
+ * (string substring or RegExp).
313
+ */
314
+ export function shouldPropagateTrace(url, targets = []) {
315
+ const base = typeof location !== "undefined" ? location.href : undefined;
316
+ let href;
317
+ try {
318
+ const u = new URL(url, base);
319
+ if (typeof location !== "undefined" && u.origin === location.origin)
320
+ return true;
321
+ href = u.href;
322
+ }
323
+ catch {
324
+ return true; // relative/opaque with no base ⇒ same-origin (safe to propagate)
325
+ }
326
+ for (const t of targets) {
327
+ if (typeof t === "string" ? href.includes(t) : t.test(href))
328
+ return true;
329
+ }
330
+ return false;
331
+ }
300
332
  /** 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) {
333
+ * trace id for correlation. Returns an unpatch function. `propagateTargets` are
334
+ * cross-origin backends allowed to receive trace headers (same-origin always is). */
335
+ export function patchFetchForCorrelation(collector, propagateTargets = []) {
303
336
  if (typeof window === "undefined" || !window.fetch)
304
337
  return () => { };
305
338
  const orig = window.fetch.bind(window);
306
339
  window.fetch = ((input, init) => {
340
+ // Only attach trace context to same-origin / allow-listed backends — never a
341
+ // third party (that would break its CORS preflight). See shouldPropagateTrace.
342
+ if (!shouldPropagateTrace(fetchUrl(input), propagateTargets)) {
343
+ return orig(input, init);
344
+ }
307
345
  const traceId = randHex(16);
308
346
  const spanId = randHex(8);
309
347
  collector.add(traceId);
@@ -413,6 +451,11 @@ export function patchFetchForTracing(o) {
413
451
  // the original fetch; this is the belt-and-suspenders host-match guard.
414
452
  if (o.ingestHost && safeHostname(url) === o.ingestHost)
415
453
  return orig(input, init);
454
+ // Third-party / non-allow-listed cross-origin: don't trace AND don't inject a
455
+ // header — a `traceparent` on a cross-origin request forces a CORS preflight the
456
+ // third party rejects (the Clerk/Stripe outage class). Pass through untouched.
457
+ if (!shouldPropagateTrace(url, o.propagateTargets))
458
+ return orig(input, init);
416
459
  const sampled = rng() < o.sampleRate;
417
460
  const traceId = randHex(16);
418
461
  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.11.1",
4
4
  "description": "Runtime-aware OpenTelemetry tracing that exports to Heystack (Node, Next.js, Workers).",
5
5
  "license": "MIT",
6
6
  "type": "module",