@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/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
- * Parse the hostname (no port) out of a URL, lower-cased; empty string if it
152
- * can't be parsed. We compare on hostname rather than `host` so that an ingest
153
- * URL like `ingest.heystack.dev` matches a captured span attribute of
154
- * `ingest.heystack.dev:443` (and vice-versa).
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 safeHostname(url) {
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 new URL(url).hostname.toLowerCase();
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
- * Strip a trailing `:port` from a bare host attribute and lower-case it, so a
166
- * host-only attr like `ingest.heystack.dev:443` compares equal to the ingest
167
- * hostname. IPv6 literals (`[::1]:443`) keep their bracketed form. Returns ""
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 isSelfSpanAttrs(attrs, ingestHost) {
207
- if (!ingestHost)
208
- return false;
209
- for (const key of HTTP_URL_ATTRS) {
210
- const v = attrs[key];
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
- return false;
262
+ const headers = new Headers(init?.headers);
263
+ headers.set("traceparent", traceparent);
264
+ return [input, { ...(init ?? {}), headers }];
219
265
  }
220
- function isSelfSpan(span, ingestHost) {
221
- return isSelfSpanAttrs(span.attributes, ingestHost);
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
- * Test-only helper: run the self-span attribute check directly against a plain
225
- * attribute bag + ingest hostname, without constructing a ReadableSpan. The
226
- * `ingestHost` should be a bare hostname (lower-case, no port), matching what
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 isSelfSpanForTest(attrs, ingestHost) {
230
- return isSelfSpanAttrs(attrs, ingestHost);
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()), () => fetch(this.url, { method: "POST", headers: this.headers, body }))
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
- // Context manager registration (makes suppressTracing() actually work)
426
- //
427
- // `context.with(...)` is a NO-OP unless a ContextManager is registered with the
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
- * A minimal SYNCHRONOUS, stack-based ContextManager the registered manager for
444
- * the /workers entry (no `node:async_hooks`, so it works on any WinterCG runtime).
445
- * It makes `context.with()` propagate synchronously, which is enough for the
446
- * exporter's `suppressTracing` to take effect and for the belt-and-suspenders
447
- * self-span filter but it does NOT carry context across `await` boundaries (so
448
- * cross-`await` parent linking and per-request isolation are best-effort).
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
- * We register a synchronous, dependency-free stack manager. This keeps the
482
- * /workers entry WinterCG-safe (no `node:async_hooks` import → works on bare
483
- * workerd WITHOUT nodejs_compat, Deno, Bun, etc.). It fully covers the critical
484
- * path (the export fetch runs synchronously inside the suppressed `context.with`,
485
- * and per-request root spans). Trade-off: it does not propagate context across
486
- * `await` boundaries, so deep nested-span parenting is limited on the edge — an
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
- context.setGlobalContextManager(new SyncStackContextManager().enable());
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(context.active(), span), () => originalFetch(req, env, ctx));
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
- span.setStatus({
644
- code: response.status >= 500 ? SpanStatusCode.ERROR : SpanStatusCode.UNSET,
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
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@heystack/otel",
3
- "version": "0.4.2",
3
+ "version": "0.5.0",
4
4
  "description": "Runtime-aware OpenTelemetry tracing that exports to Heystack (Node, Next.js, Workers).",
5
5
  "license": "MIT",
6
6
  "type": "module",