@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 +6 -2
- package/dist/web.d.ts +32 -2
- package/dist/web.js +46 -3
- 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,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,
|
|
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.
|
|
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
|
-
|
|
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
|
-
|
|
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);
|