@heystack/otel 0.4.2 → 0.5.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 +83 -9
- package/dist/node.d.ts +28 -0
- package/dist/node.js +82 -2
- package/dist/self-span.d.ts +30 -0
- package/dist/self-span.js +83 -0
- package/dist/workers-bindings.d.ts +33 -0
- package/dist/workers-bindings.js +205 -0
- package/dist/workers.d.ts +81 -7
- package/dist/workers.js +355 -109
- package/package.json +1 -1
package/dist/workers.js
CHANGED
|
@@ -8,12 +8,14 @@
|
|
|
8
8
|
// ships its own OTLP/JSON-over-fetch span exporter so it runs on Workers/Edge
|
|
9
9
|
// where the Node SDK cannot.
|
|
10
10
|
import { context, trace, SpanKind, SpanStatusCode, } from "@opentelemetry/api";
|
|
11
|
-
import { suppressTracing } from "@opentelemetry/core";
|
|
11
|
+
import { suppressTracing, isTracingSuppressed } from "@opentelemetry/core";
|
|
12
12
|
import { ROOT_CONTEXT } from "@opentelemetry/api";
|
|
13
13
|
import { Resource } from "@opentelemetry/resources";
|
|
14
14
|
import { BasicTracerProvider, SimpleSpanProcessor, } from "@opentelemetry/sdk-trace-base";
|
|
15
15
|
import { ATTR_SERVICE_NAME } from "@opentelemetry/semantic-conventions";
|
|
16
16
|
import { buildExporterConfig } from "./core.js";
|
|
17
|
+
import { isSelfSpanAttrs, safeHostname } from "./self-span.js";
|
|
18
|
+
import { instrumentEnv } from "./workers-bindings.js";
|
|
17
19
|
// `ExportResult` / `ExportResultCode` mirror `@opentelemetry/core`. We define
|
|
18
20
|
// them inline (structurally identical) rather than import them: core is only a
|
|
19
21
|
// transitive dep of sdk-trace-base and isn't reliably resolvable, and keeping it
|
|
@@ -115,6 +117,11 @@ function readableSpanToOtlp(span) {
|
|
|
115
117
|
startTimeUnixNano: hrTimeToUnixNano(span.startTime),
|
|
116
118
|
endTimeUnixNano: hrTimeToUnixNano(span.endTime),
|
|
117
119
|
attributes: toKeyValues(span.attributes),
|
|
120
|
+
events: span.events.map((ev) => ({
|
|
121
|
+
timeUnixNano: hrTimeToUnixNano(ev.time),
|
|
122
|
+
name: ev.name,
|
|
123
|
+
attributes: toKeyValues((ev.attributes ?? {})),
|
|
124
|
+
})),
|
|
118
125
|
status: toStatus(span.status),
|
|
119
126
|
};
|
|
120
127
|
}
|
|
@@ -138,6 +145,36 @@ export function serializeSpans(spans) {
|
|
|
138
145
|
};
|
|
139
146
|
}
|
|
140
147
|
// ---------------------------------------------------------------------------
|
|
148
|
+
// W3C traceparent + CORS expose-header helpers (FR5 tap→server correlation)
|
|
149
|
+
// ---------------------------------------------------------------------------
|
|
150
|
+
const TRACEPARENT_RE = /^00-([0-9a-f]{32})-([0-9a-f]{16})-([0-9a-f]{2})$/;
|
|
151
|
+
/** Parse a W3C `traceparent`. Returns null for malformed or all-zero ids. */
|
|
152
|
+
export function parseTraceparent(header) {
|
|
153
|
+
if (!header)
|
|
154
|
+
return null;
|
|
155
|
+
const m = TRACEPARENT_RE.exec(header.trim());
|
|
156
|
+
if (!m)
|
|
157
|
+
return null;
|
|
158
|
+
const traceId = m[1];
|
|
159
|
+
const spanId = m[2];
|
|
160
|
+
const flags = m[3];
|
|
161
|
+
if (traceId === "0".repeat(32) || spanId === "0".repeat(16))
|
|
162
|
+
return null;
|
|
163
|
+
return { traceId, spanId, traceFlags: parseInt(flags, 16) };
|
|
164
|
+
}
|
|
165
|
+
/** Add `value` to Access-Control-Expose-Headers without duplicating it. */
|
|
166
|
+
export function appendExposeHeader(headers, value) {
|
|
167
|
+
const existing = headers.get("access-control-expose-headers");
|
|
168
|
+
if (!existing) {
|
|
169
|
+
headers.set("access-control-expose-headers", value);
|
|
170
|
+
return;
|
|
171
|
+
}
|
|
172
|
+
const present = existing.split(",").map((s) => s.trim().toLowerCase());
|
|
173
|
+
if (!present.includes(value.toLowerCase())) {
|
|
174
|
+
headers.set("access-control-expose-headers", `${existing}, ${value}`);
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
// ---------------------------------------------------------------------------
|
|
141
178
|
// Self-span filtering (feedback-loop guard)
|
|
142
179
|
//
|
|
143
180
|
// On Next/OpenNext the host auto-instruments outbound `fetch`, so the exporter's
|
|
@@ -147,87 +184,157 @@ export function serializeSpans(spans) {
|
|
|
147
184
|
// targets the ingest origin, so an upstream instrumentation that ignores
|
|
148
185
|
// suppression still can't feed the loop.
|
|
149
186
|
// ---------------------------------------------------------------------------
|
|
187
|
+
// `safeHostname` / `isSelfSpanAttrs` live in the shared, runtime-agnostic
|
|
188
|
+
// `./self-span.js` module (also used by `/node`). They are pure string logic
|
|
189
|
+
// with no runtime imports, so importing them keeps this entry WinterCG-safe.
|
|
190
|
+
function isSelfSpan(span, ingestHost) {
|
|
191
|
+
return isSelfSpanAttrs(span.attributes, ingestHost);
|
|
192
|
+
}
|
|
150
193
|
/**
|
|
151
|
-
*
|
|
152
|
-
*
|
|
153
|
-
*
|
|
154
|
-
* `
|
|
194
|
+
* Test-only helper: run the self-span attribute check directly against a plain
|
|
195
|
+
* attribute bag + ingest hostname, without constructing a ReadableSpan. The
|
|
196
|
+
* `ingestHost` should be a bare hostname (lower-case, no port), matching what
|
|
197
|
+
* the exporter derives via `safeHostname(cfg.url)`.
|
|
155
198
|
*/
|
|
156
|
-
function
|
|
199
|
+
export function isSelfSpanForTest(attrs, ingestHost) {
|
|
200
|
+
return isSelfSpanAttrs(attrs, ingestHost);
|
|
201
|
+
}
|
|
202
|
+
// ---------------------------------------------------------------------------
|
|
203
|
+
// Outbound fetch auto-instrumentation (CLIENT child spans + traceparent)
|
|
204
|
+
//
|
|
205
|
+
// Patches globalThis.fetch ONCE (guarded), capturing the platform fetch the
|
|
206
|
+
// instant before replacing it. Each outbound call made inside a traced request
|
|
207
|
+
// (an active span is present, tracing is not suppressed, and the target is not
|
|
208
|
+
// the ingest host) gets a CLIENT child span AND a W3C `traceparent` injected
|
|
209
|
+
// into a CLONE of the request — so a downstream Heystack-instrumented service
|
|
210
|
+
// continues the SAME trace (distributed tracing).
|
|
211
|
+
//
|
|
212
|
+
// Why patch-once + ALS rather than a per-request global swap: under workerd a
|
|
213
|
+
// single isolate serves concurrent requests, so swapping globalThis.fetch per
|
|
214
|
+
// request (install/restore in a finally) would race across overlapping requests.
|
|
215
|
+
// Task 2's AsyncLocalStorage context manager makes `context.active()` reflect the
|
|
216
|
+
// CURRENT request's active span across awaits, so ONE global wrapper can decide
|
|
217
|
+
// per-call which request (if any) the subrequest belongs to.
|
|
218
|
+
//
|
|
219
|
+
// `_originalFetch` is the captured platform fetch. The exporter's own POST uses
|
|
220
|
+
// it directly (see `export()`), so an export is never re-entered by this wrapper
|
|
221
|
+
// (belt; the wrapper also bails on suppressed contexts + ingest-host targets —
|
|
222
|
+
// suspenders). Reading `safeHostname` / the self-span host concept keeps the
|
|
223
|
+
// self-span guardrail satisfied and the path WinterCG-safe (pure string logic).
|
|
224
|
+
// ---------------------------------------------------------------------------
|
|
225
|
+
let _fetchInstrumented = false;
|
|
226
|
+
let _originalFetch;
|
|
227
|
+
let _fetchWrapper;
|
|
228
|
+
/** Resolve the absolute URL of an outbound fetch arg (string | URL | Request). */
|
|
229
|
+
function outboundUrl(input) {
|
|
230
|
+
if (typeof input === "string")
|
|
231
|
+
return input;
|
|
232
|
+
if (input instanceof URL)
|
|
233
|
+
return input.href;
|
|
234
|
+
if (input instanceof Request)
|
|
235
|
+
return input.url;
|
|
157
236
|
try {
|
|
158
|
-
return
|
|
237
|
+
return String(input.url ?? input);
|
|
159
238
|
}
|
|
160
239
|
catch {
|
|
161
240
|
return "";
|
|
162
241
|
}
|
|
163
242
|
}
|
|
243
|
+
/** Resolve the HTTP method of an outbound fetch arg. */
|
|
244
|
+
function outboundMethod(input, init) {
|
|
245
|
+
if (init?.method)
|
|
246
|
+
return init.method;
|
|
247
|
+
if (input instanceof Request)
|
|
248
|
+
return input.method;
|
|
249
|
+
return "GET";
|
|
250
|
+
}
|
|
164
251
|
/**
|
|
165
|
-
*
|
|
166
|
-
*
|
|
167
|
-
*
|
|
168
|
-
* for anything that isn't a non-empty string.
|
|
169
|
-
*/
|
|
170
|
-
function hostnameOf(hostAttr) {
|
|
171
|
-
if (typeof hostAttr !== "string" || hostAttr === "")
|
|
172
|
-
return "";
|
|
173
|
-
const v = hostAttr.trim();
|
|
174
|
-
// Bracketed IPv6, optionally with a port: `[::1]` or `[::1]:443`.
|
|
175
|
-
if (v.startsWith("[")) {
|
|
176
|
-
const close = v.indexOf("]");
|
|
177
|
-
if (close !== -1)
|
|
178
|
-
return v.slice(0, close + 1).toLowerCase();
|
|
179
|
-
return v.toLowerCase();
|
|
180
|
-
}
|
|
181
|
-
// Strip a single trailing :port (host:port). A bare hostname has no colon.
|
|
182
|
-
const colon = v.lastIndexOf(":");
|
|
183
|
-
if (colon !== -1 && v.indexOf(":") === colon) {
|
|
184
|
-
return v.slice(0, colon).toLowerCase();
|
|
185
|
-
}
|
|
186
|
-
return v.toLowerCase();
|
|
187
|
-
}
|
|
188
|
-
/** Attributes that carry a full HTTP URL on a CLIENT/SERVER span. */
|
|
189
|
-
const HTTP_URL_ATTRS = ["url.full", "http.url"];
|
|
190
|
-
/** Host-only attributes (host[:port], no scheme/path). */
|
|
191
|
-
const HTTP_HOST_ATTRS = [
|
|
192
|
-
"server.address",
|
|
193
|
-
"net.peer.name",
|
|
194
|
-
"net.peer.hostname",
|
|
195
|
-
"http.host",
|
|
196
|
-
"peer.address",
|
|
197
|
-
];
|
|
198
|
-
/**
|
|
199
|
-
* True if `span` looks like a request to the configured ingest origin — i.e. it
|
|
200
|
-
* is (or could be) the exporter's own self-trace. For full-URL attributes we
|
|
201
|
-
* parse the URL and compare its `.hostname` (case-insensitive, port stripped) so
|
|
202
|
-
* a sibling domain like `myingest.heystack.dev` is NOT a false positive and an
|
|
203
|
-
* explicit port like `ingest.heystack.dev:443` IS matched. For host-only attrs
|
|
204
|
-
* we strip any `:port` and compare hostname equality.
|
|
252
|
+
* Return an [input, init] pair with `traceparent` injected, WITHOUT mutating the
|
|
253
|
+
* caller's Request/Headers. For a Request input we build a fresh Request copying
|
|
254
|
+
* it; for string/URL we clone the init headers.
|
|
205
255
|
*/
|
|
206
|
-
function
|
|
207
|
-
if (
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
if (typeof v === "string" && safeHostname(v) === ingestHost)
|
|
212
|
-
return true;
|
|
213
|
-
}
|
|
214
|
-
for (const key of HTTP_HOST_ATTRS) {
|
|
215
|
-
if (hostnameOf(attrs[key]) === ingestHost)
|
|
216
|
-
return true;
|
|
256
|
+
function injectTraceparent(input, init, traceparent) {
|
|
257
|
+
if (input instanceof Request) {
|
|
258
|
+
const headers = new Headers(init?.headers ?? input.headers);
|
|
259
|
+
headers.set("traceparent", traceparent);
|
|
260
|
+
return [new Request(input, { ...(init ?? {}), headers }), undefined];
|
|
217
261
|
}
|
|
218
|
-
|
|
262
|
+
const headers = new Headers(init?.headers);
|
|
263
|
+
headers.set("traceparent", traceparent);
|
|
264
|
+
return [input, { ...(init ?? {}), headers }];
|
|
219
265
|
}
|
|
220
|
-
|
|
221
|
-
|
|
266
|
+
/**
|
|
267
|
+
* Patch `globalThis.fetch` exactly once to emit a CLIENT child span + inject
|
|
268
|
+
* `traceparent` for outbound subrequests. `ingestHost` is the bare ingest
|
|
269
|
+
* hostname (lower-case, no port) so the exporter's own uploads are never traced.
|
|
270
|
+
*/
|
|
271
|
+
function ensureFetchInstrumentation(ingestHost) {
|
|
272
|
+
if (_fetchInstrumented)
|
|
273
|
+
return;
|
|
274
|
+
_fetchInstrumented = true;
|
|
275
|
+
// Capture the platform fetch the instant before replacing it (cost guardrail:
|
|
276
|
+
// "capture original before patching"). The exporter reuses this captured
|
|
277
|
+
// reference, so its POST is never routed back through the wrapper.
|
|
278
|
+
const originalFetch = globalThis.fetch.bind(globalThis);
|
|
279
|
+
_originalFetch = originalFetch;
|
|
280
|
+
const wrapper = async (input, init) => {
|
|
281
|
+
const active = context.active();
|
|
282
|
+
// Passthrough UNCHANGED when there is nothing to parent to (no active span,
|
|
283
|
+
// e.g. a fetch outside a traced request) or tracing is suppressed (the
|
|
284
|
+
// exporter's own POST, or anything under suppressTracing).
|
|
285
|
+
if (isTracingSuppressed(active) || !trace.getSpan(active)) {
|
|
286
|
+
return originalFetch(input, init);
|
|
287
|
+
}
|
|
288
|
+
const target = outboundUrl(input);
|
|
289
|
+
const host = safeHostname(target);
|
|
290
|
+
// No parseable host, or the ingest host itself (self-span) → don't trace.
|
|
291
|
+
if (!host || host === ingestHost) {
|
|
292
|
+
return originalFetch(input, init);
|
|
293
|
+
}
|
|
294
|
+
const method = outboundMethod(input, init);
|
|
295
|
+
const span = trace.getTracer("heystack").startSpan(`${method} ${host}`, {
|
|
296
|
+
kind: SpanKind.CLIENT,
|
|
297
|
+
attributes: {
|
|
298
|
+
"http.request.method": method,
|
|
299
|
+
"url.full": target,
|
|
300
|
+
"server.address": host,
|
|
301
|
+
},
|
|
302
|
+
}, active);
|
|
303
|
+
const sc = span.spanContext();
|
|
304
|
+
const traceparent = `00-${sc.traceId}-${sc.spanId}-01`;
|
|
305
|
+
const [reqInput, reqInit] = injectTraceparent(input, init, traceparent);
|
|
306
|
+
try {
|
|
307
|
+
const response = await originalFetch(reqInput, reqInit);
|
|
308
|
+
span.setAttribute("http.response.status_code", response.status);
|
|
309
|
+
return response;
|
|
310
|
+
}
|
|
311
|
+
catch (error) {
|
|
312
|
+
span.recordException(error instanceof Error ? error : new Error(String(error)));
|
|
313
|
+
span.setStatus({
|
|
314
|
+
code: SpanStatusCode.ERROR,
|
|
315
|
+
message: error instanceof Error ? error.message : String(error),
|
|
316
|
+
});
|
|
317
|
+
throw error;
|
|
318
|
+
}
|
|
319
|
+
finally {
|
|
320
|
+
span.end();
|
|
321
|
+
}
|
|
322
|
+
};
|
|
323
|
+
_fetchWrapper = wrapper;
|
|
324
|
+
globalThis.fetch = _fetchWrapper;
|
|
222
325
|
}
|
|
223
326
|
/**
|
|
224
|
-
*
|
|
225
|
-
*
|
|
226
|
-
*
|
|
227
|
-
* the exporter derives via `safeHostname(cfg.url)`.
|
|
327
|
+
* Reset the outbound-fetch instrumentation: restore the captured platform fetch
|
|
328
|
+
* (only when our wrapper is still the installed global) and clear the guard.
|
|
329
|
+
* Internal/testing helper.
|
|
228
330
|
*/
|
|
229
|
-
export function
|
|
230
|
-
|
|
331
|
+
export function __resetFetchInstrumentation() {
|
|
332
|
+
if (_fetchWrapper && globalThis.fetch === _fetchWrapper && _originalFetch) {
|
|
333
|
+
globalThis.fetch = _originalFetch;
|
|
334
|
+
}
|
|
335
|
+
_fetchInstrumented = false;
|
|
336
|
+
_originalFetch = undefined;
|
|
337
|
+
_fetchWrapper = undefined;
|
|
231
338
|
}
|
|
232
339
|
// ---------------------------------------------------------------------------
|
|
233
340
|
// Exporter
|
|
@@ -297,9 +404,12 @@ export class HeystackSpanExporter {
|
|
|
297
404
|
// The POST runs inside a tracing-suppressed context so that host fetch
|
|
298
405
|
// auto-instrumentation (e.g. Next/OpenNext) does NOT create a CLIENT span
|
|
299
406
|
// for it — which would otherwise be exported and re-captured, a sustained
|
|
300
|
-
// feedback loop.
|
|
407
|
+
// feedback loop. As a belt-and-suspenders second layer it also uses the
|
|
408
|
+
// CAPTURED platform fetch (when our outbound-fetch wrapper is installed), so
|
|
409
|
+
// the export can never be re-entered by that wrapper regardless of context.
|
|
410
|
+
const doFetch = _originalFetch ?? fetch;
|
|
301
411
|
const p = context
|
|
302
|
-
.with(suppressTracing(context.active()), () =>
|
|
412
|
+
.with(suppressTracing(context.active()), () => doFetch(this.url, { method: "POST", headers: this.headers, body }))
|
|
303
413
|
.then((res) => {
|
|
304
414
|
if (res.ok) {
|
|
305
415
|
resultCallback({ code: ExportResultCode.SUCCESS });
|
|
@@ -421,31 +531,51 @@ export function createTracerProvider(config) {
|
|
|
421
531
|
// Global tracer provider registration (for host frameworks, e.g. Next.js)
|
|
422
532
|
// ---------------------------------------------------------------------------
|
|
423
533
|
let _provider = null;
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
// global OTel API. Without one, `suppressTracing(context.active())` produces a
|
|
429
|
-
// context that is never made active, so the exporter's POST is NOT suppressed
|
|
430
|
-
// in production and host fetch auto-instrumentation can re-trace it (feedback
|
|
431
|
-
// loop). We therefore register a manager exactly ONCE in `ensureGlobalProvider`.
|
|
432
|
-
//
|
|
433
|
-
// We register a dependency-free SYNCHRONOUS stack manager (below). Deliberately
|
|
434
|
-
// NOT AsyncLocalStorageContextManager: that statically imports `node:async_hooks`,
|
|
435
|
-
// which would break `import "@heystack/otel/workers"` on a bare workerd without
|
|
436
|
-
// `nodejs_compat` (and on other WinterCG runtimes) — defeating the whole point of
|
|
437
|
-
// this entry being node-builtin-free. The sync manager covers the critical path:
|
|
438
|
-
// the exporter's POST runs synchronously inside the suppressed `context.with`, so
|
|
439
|
-
// `suppressTracing` takes effect. Trade-off: no cross-`await` context propagation,
|
|
440
|
-
// so deep nested-span parenting is limited on the edge (documented).
|
|
441
|
-
// ---------------------------------------------------------------------------
|
|
534
|
+
/** Read the ALS global lazily — at call time, not module load time. */
|
|
535
|
+
function getALS() {
|
|
536
|
+
return globalThis.AsyncLocalStorage;
|
|
537
|
+
}
|
|
442
538
|
/**
|
|
443
|
-
*
|
|
444
|
-
*
|
|
445
|
-
*
|
|
446
|
-
*
|
|
447
|
-
*
|
|
448
|
-
*
|
|
539
|
+
* An AsyncLocalStorage-backed ContextManager for the /workers entry. When the
|
|
540
|
+
* runtime exposes `globalThis.AsyncLocalStorage` (workerd and Node do), this
|
|
541
|
+
* manager is preferred over `SyncStackContextManager` because it propagates
|
|
542
|
+
* context across `await` boundaries — child spans created after an `await`
|
|
543
|
+
* inside a handler are correctly parented to the request's root span.
|
|
544
|
+
*
|
|
545
|
+
* WinterCG-safe: it does NOT import `node:async_hooks`; instead it uses the
|
|
546
|
+
* ALS global that the runtime exposes. Falls back to `SyncStackContextManager`
|
|
547
|
+
* when the global is absent (Deno, Bun without globals, etc.).
|
|
548
|
+
*/
|
|
549
|
+
export class AlsContextManager {
|
|
550
|
+
_als;
|
|
551
|
+
constructor() {
|
|
552
|
+
const ALS = getALS();
|
|
553
|
+
if (!ALS)
|
|
554
|
+
throw new Error("AlsContextManager: globalThis.AsyncLocalStorage is not available in this runtime");
|
|
555
|
+
this._als = new ALS();
|
|
556
|
+
}
|
|
557
|
+
active() {
|
|
558
|
+
return this._als.getStore() ?? ROOT_CONTEXT;
|
|
559
|
+
}
|
|
560
|
+
with(ctx, fn, thisArg, ...args) {
|
|
561
|
+
return this._als.run(ctx, () => fn.call(thisArg, ...args));
|
|
562
|
+
}
|
|
563
|
+
bind(_ctx, target) {
|
|
564
|
+
return target;
|
|
565
|
+
}
|
|
566
|
+
enable() {
|
|
567
|
+
return this;
|
|
568
|
+
}
|
|
569
|
+
disable() {
|
|
570
|
+
return this;
|
|
571
|
+
}
|
|
572
|
+
}
|
|
573
|
+
/**
|
|
574
|
+
* A minimal SYNCHRONOUS, stack-based ContextManager — the fallback for the
|
|
575
|
+
* /workers entry when `globalThis.AsyncLocalStorage` is absent (any WinterCG
|
|
576
|
+
* runtime without the global). It makes `context.with()` propagate
|
|
577
|
+
* synchronously, which is enough for the exporter's `suppressTracing` to take
|
|
578
|
+
* effect — but it does NOT carry context across `await` boundaries.
|
|
449
579
|
*/
|
|
450
580
|
export class SyncStackContextManager {
|
|
451
581
|
_stack = [];
|
|
@@ -478,20 +608,19 @@ let _contextManagerRegistered = false;
|
|
|
478
608
|
* `context.with(suppressTracing(...))` in the exporter is actually honoured —
|
|
479
609
|
* otherwise suppression is a no-op and the exporter's POST can be re-traced.
|
|
480
610
|
*
|
|
481
|
-
*
|
|
482
|
-
*
|
|
483
|
-
*
|
|
484
|
-
*
|
|
485
|
-
*
|
|
486
|
-
* `await` boundaries
|
|
487
|
-
* acceptable, documented limitation (workerd has no async context manager by
|
|
488
|
-
* default regardless).
|
|
611
|
+
* When `globalThis.AsyncLocalStorage` is available (workerd, Node), we register
|
|
612
|
+
* an `AlsContextManager` that propagates context across `await` boundaries —
|
|
613
|
+
* child spans created after an `await` are correctly parented to the root span.
|
|
614
|
+
* When absent we fall back to `SyncStackContextManager`, which covers the
|
|
615
|
+
* critical suppression path synchronously but does not carry context across
|
|
616
|
+
* `await` boundaries.
|
|
489
617
|
*/
|
|
490
618
|
function ensureContextManager() {
|
|
491
619
|
if (_contextManagerRegistered)
|
|
492
620
|
return;
|
|
493
621
|
_contextManagerRegistered = true;
|
|
494
|
-
|
|
622
|
+
const mgr = getALS() ? new AlsContextManager() : new SyncStackContextManager();
|
|
623
|
+
context.setGlobalContextManager(mgr.enable());
|
|
495
624
|
}
|
|
496
625
|
/** Reset the context-manager registration guard. Internal/testing helper. */
|
|
497
626
|
export function __resetContextManager() {
|
|
@@ -522,6 +651,10 @@ function ensureGlobalProvider(config) {
|
|
|
522
651
|
// the exporter actually takes effect — without one, `context.with` is a no-op
|
|
523
652
|
// and suppression silently does nothing in production.
|
|
524
653
|
ensureContextManager();
|
|
654
|
+
// Patch globalThis.fetch (once) so outbound subrequests get CLIENT child spans
|
|
655
|
+
// + `traceparent` injection (distributed tracing). The exporter's own POST uses
|
|
656
|
+
// the captured original fetch, so it is never re-entered by this wrapper.
|
|
657
|
+
ensureFetchInstrumentation(_provider.heystackExporter.ingestHost);
|
|
525
658
|
return _provider;
|
|
526
659
|
}
|
|
527
660
|
/**
|
|
@@ -628,6 +761,17 @@ export function instrument(handler, config) {
|
|
|
628
761
|
return originalFetch(req, env, ctx);
|
|
629
762
|
const { provider, tracer } = s;
|
|
630
763
|
const url = new URL(req.url);
|
|
764
|
+
// FR5: continue an inbound W3C traceparent so tap→server is one trace.
|
|
765
|
+
const parent = parseTraceparent(req.headers.get("traceparent"));
|
|
766
|
+
let startCtx = context.active();
|
|
767
|
+
if (parent) {
|
|
768
|
+
startCtx = trace.setSpanContext(startCtx, {
|
|
769
|
+
traceId: parent.traceId,
|
|
770
|
+
spanId: parent.spanId,
|
|
771
|
+
traceFlags: parent.traceFlags,
|
|
772
|
+
isRemote: true,
|
|
773
|
+
});
|
|
774
|
+
}
|
|
631
775
|
const span = tracer.startSpan(`${req.method} ${url.pathname}`, {
|
|
632
776
|
kind: SpanKind.SERVER,
|
|
633
777
|
attributes: {
|
|
@@ -636,16 +780,92 @@ export function instrument(handler, config) {
|
|
|
636
780
|
"url.path": url.pathname,
|
|
637
781
|
"server.address": url.host,
|
|
638
782
|
},
|
|
639
|
-
});
|
|
783
|
+
}, startCtx);
|
|
784
|
+
// A1 enrichment: identity, request id, client ip, geo.
|
|
785
|
+
// All attributes are set only when non-empty to keep spans lean.
|
|
786
|
+
const userInfo = config.getUser?.(req);
|
|
787
|
+
if (userInfo?.id)
|
|
788
|
+
span.setAttribute("enduser.id", userInfo.id);
|
|
789
|
+
if (userInfo?.session)
|
|
790
|
+
span.setAttribute("session.id", userInfo.session);
|
|
791
|
+
const reqId = userInfo?.requestId ?? req.headers.get("cf-ray") ?? "";
|
|
792
|
+
if (reqId)
|
|
793
|
+
span.setAttribute("http.request.id", reqId);
|
|
794
|
+
const clientIp = req.headers.get("CF-Connecting-IP") ?? "";
|
|
795
|
+
if (clientIp)
|
|
796
|
+
span.setAttribute("client.address", clientIp);
|
|
797
|
+
// Cloudflare geo — only present on the real CF runtime; read defensively.
|
|
798
|
+
const cf = req.cf;
|
|
799
|
+
if (cf) {
|
|
800
|
+
if (typeof cf.country === "string" && cf.country)
|
|
801
|
+
span.setAttribute("geo.country", cf.country);
|
|
802
|
+
if (typeof cf.region === "string" && cf.region)
|
|
803
|
+
span.setAttribute("geo.region", cf.region);
|
|
804
|
+
if (typeof cf.city === "string" && cf.city)
|
|
805
|
+
span.setAttribute("geo.city", cf.city);
|
|
806
|
+
if (cf.asn != null)
|
|
807
|
+
span.setAttribute("geo.asn", String(cf.asn));
|
|
808
|
+
}
|
|
809
|
+
// FR5: response header carrying THIS span's trace + span id.
|
|
810
|
+
const sc = span.spanContext();
|
|
811
|
+
const traceparent = `00-${sc.traceId}-${sc.spanId}-01`;
|
|
812
|
+
// Instrument bindings when requested — wrap env BEFORE handing to the
|
|
813
|
+
// handler so binding calls made inside `originalFetch` (which runs inside
|
|
814
|
+
// `context.with` below) correctly parent to the root span via ALS.
|
|
815
|
+
let handlerEnv = env;
|
|
816
|
+
if (config.instrumentBindings) {
|
|
817
|
+
const binTracer = trace.getTracer("heystack");
|
|
818
|
+
handlerEnv = instrumentEnv(env, {
|
|
819
|
+
startSpan: (name, attrs) => binTracer.startSpan(name, { attributes: attrs }, context.active()),
|
|
820
|
+
select: config.instrumentBindings,
|
|
821
|
+
});
|
|
822
|
+
}
|
|
640
823
|
try {
|
|
641
|
-
const response = await context.with(trace.setSpan(
|
|
824
|
+
const response = await context.with(trace.setSpan(startCtx, span), () => originalFetch(req, handlerEnv, ctx));
|
|
825
|
+
const headers = new Headers(response.headers);
|
|
826
|
+
headers.set("traceparent", traceparent);
|
|
827
|
+
appendExposeHeader(headers, "traceparent");
|
|
642
828
|
span.setAttribute("http.response.status_code", response.status);
|
|
643
|
-
|
|
644
|
-
|
|
829
|
+
const finalize = () => {
|
|
830
|
+
span.setStatus({
|
|
831
|
+
code: response.status >= 500 ? SpanStatusCode.ERROR : SpanStatusCode.UNSET,
|
|
832
|
+
});
|
|
833
|
+
span.end();
|
|
834
|
+
drain(provider, ctx);
|
|
835
|
+
};
|
|
836
|
+
// No body to stream → finalize now (redirects, 204/304, etc.).
|
|
837
|
+
if (!response.body) {
|
|
838
|
+
finalize();
|
|
839
|
+
return new Response(null, {
|
|
840
|
+
status: response.status,
|
|
841
|
+
statusText: response.statusText,
|
|
842
|
+
headers,
|
|
843
|
+
});
|
|
844
|
+
}
|
|
845
|
+
// FR1: keep the span open until the streamed body drains; the first
|
|
846
|
+
// chunk records time-to-first-byte. `finished` guards double-finalize.
|
|
847
|
+
let firstByte = false;
|
|
848
|
+
let finished = false;
|
|
849
|
+
const monitor = new TransformStream({
|
|
850
|
+
transform(chunk, controller) {
|
|
851
|
+
if (!firstByte) {
|
|
852
|
+
firstByte = true;
|
|
853
|
+
span.addEvent("first_byte");
|
|
854
|
+
}
|
|
855
|
+
controller.enqueue(chunk);
|
|
856
|
+
},
|
|
857
|
+
flush() {
|
|
858
|
+
if (finished)
|
|
859
|
+
return;
|
|
860
|
+
finished = true;
|
|
861
|
+
finalize();
|
|
862
|
+
},
|
|
863
|
+
});
|
|
864
|
+
return new Response(response.body.pipeThrough(monitor), {
|
|
865
|
+
status: response.status,
|
|
866
|
+
statusText: response.statusText,
|
|
867
|
+
headers,
|
|
645
868
|
});
|
|
646
|
-
span.end();
|
|
647
|
-
drain(provider, ctx);
|
|
648
|
-
return response;
|
|
649
869
|
}
|
|
650
870
|
catch (error) {
|
|
651
871
|
span.recordException(error instanceof Error ? error : new Error(String(error)));
|
|
@@ -724,3 +944,29 @@ export function instrument(handler, config) {
|
|
|
724
944
|
}
|
|
725
945
|
return wrapped;
|
|
726
946
|
}
|
|
947
|
+
export async function withSpan(name, attrsOrFn, maybeFn) {
|
|
948
|
+
const attrs = typeof attrsOrFn === "function" ? undefined : attrsOrFn;
|
|
949
|
+
const fn = (typeof attrsOrFn === "function" ? attrsOrFn : maybeFn);
|
|
950
|
+
const tracer = trace.getTracer("heystack");
|
|
951
|
+
const span = tracer.startSpan(name, attrs ? { attributes: attrs } : undefined);
|
|
952
|
+
const ctx = trace.setSpan(context.active(), span);
|
|
953
|
+
try {
|
|
954
|
+
return await context.with(ctx, () => fn(span));
|
|
955
|
+
}
|
|
956
|
+
catch (err) {
|
|
957
|
+
span.recordException(err);
|
|
958
|
+
span.setStatus({ code: SpanStatusCode.ERROR, message: err.message });
|
|
959
|
+
throw err;
|
|
960
|
+
}
|
|
961
|
+
finally {
|
|
962
|
+
span.end();
|
|
963
|
+
}
|
|
964
|
+
}
|
|
965
|
+
/**
|
|
966
|
+
* Add a named event (with optional attributes) to the currently-active span.
|
|
967
|
+
* No-op when no span is active.
|
|
968
|
+
*/
|
|
969
|
+
export function addEvent(name, attrs) {
|
|
970
|
+
const span = trace.getSpan(context.active());
|
|
971
|
+
span?.addEvent(name, attrs);
|
|
972
|
+
}
|