@heystack/otel 0.4.3 → 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 CHANGED
@@ -85,32 +85,102 @@ export default instrument(
85
85
  return new Response("ok");
86
86
  },
87
87
  },
88
- { service: "my-worker" }, // apiKey defaults to env.HEYSTACK_API_KEY
88
+ {
89
+ service: "my-worker", // apiKey defaults to env.HEYSTACK_API_KEY
90
+ getUser: (req) => ({
91
+ id: req.headers.get("x-user-id") ?? undefined,
92
+ }),
93
+ instrumentBindings: true, // auto-trace D1/KV/R2/Vectorize
94
+ },
89
95
  );
90
96
  ```
91
97
 
92
- As of **0.3.0** `instrument()` registers the **global** tracer provider and creates the per-request SERVER span via the global tracer, so nested spans created through the global `trace.getTracer()` API (framework/library/manual) also export — you get a trace tree, not a lone SERVER span.
98
+ `instrument()` must be the **outermost** wrapper if other middleware also wraps the handler, so the request span covers everything inside:
99
+
100
+ ```ts
101
+ export default instrument(withOtherMiddleware(worker), { service: "my-worker" });
102
+ ```
103
+
104
+ Set the key as a secret: `wrangler secret put HEYSTACK_API_KEY`.
93
105
 
94
- > **Requires `nodejs_compat` on workerd.** As of **0.3.2** the SDK registers an OpenTelemetry **ContextManager** at init (see below), backed by `AsyncLocalStorage` from `node:async_hooks`. On Cloudflare Workers that means your `wrangler.toml` must enable the Node.js compatibility flag:
106
+ > **Requires `nodejs_compat` on workerd.** The SDK uses `AsyncLocalStorage` for per-request context isolation. Add to `wrangler.toml`:
95
107
  > ```toml
96
108
  > compatibility_flags = ["nodejs_compat"]
97
109
  > ```
98
- > If `node:async_hooks` is unavailable, the SDK transparently falls back to a synchronous stack-based ContextManager (no extra dependency) — suppression still works, but cross-`await` parent linking and per-request context isolation degrade to best-effort.
110
+ > Without it the SDK falls back to a synchronous stack-based context manager — suppression still works, but cross-`await` span parenting and per-request isolation degrade to best-effort.
99
111
 
100
- ### Why a ContextManager (0.3.2)
112
+ ### `WorkersConfig` options
101
113
 
102
- `context.with(...)` in OpenTelemetry is a **no-op unless a ContextManager is registered** with the global API. Before 0.3.2 the Workers path registered only a tracer provider, so `suppressTracing()` — the primary defence against the self-trace feedback loop — silently did nothing in production (the exporter's own `POST /v1/traces` could be re-traced by host fetch auto-instrumentation, looping). As of **0.3.2** the SDK registers a ContextManager exactly once at init. With `AsyncLocalStorageContextManager` (the default, on Node and on workerd under `nodejs_compat`) you also get **cross-`await` parent→child span linking** and **per-request context isolation** — concurrent requests no longer share or clobber the active span.
114
+ | Option | Type | Notes |
115
+ | --- | --- | --- |
116
+ | `service` | `string` | **Required.** Service name that appears in the Heystack console. |
117
+ | `apiKey` | `string?` | Defaults to `env.HEYSTACK_API_KEY`. |
118
+ | `getUser` | `(req: Request) => { id?, session?, requestId? } \| undefined` | Called per request. `id` → `enduser.id`, `session` → `session.id`, `requestId` → `http.request.id` (falls back to the `cf-ray` header). |
119
+ | `instrumentBindings` | `boolean \| string[]` | `true` = auto child spans for all detected D1/KV/R2/Vectorize bindings; `string[]` = only the named bindings. Default `false`. |
120
+ | `waitUntil` | `(p: Promise<unknown>) => void` | Override the isolate keep-alive hook; defaults to the auto-detected `ctx.waitUntil`. |
121
+ | `endpoint` | `string?` | Override the ingest endpoint (advanced). |
103
122
 
104
- `instrument()` must be the **outermost** wrapper if other middleware also wraps the handler, so the request span covers everything inside:
123
+ ### Automatic tracing
124
+
125
+ `instrument()` traces the following automatically, with no additional config:
126
+
127
+ - **Incoming requests (`fetch`)** — a SERVER span per request, carrying `http.request.method`, `url.full`, `url.path`, `server.address`, `http.response.status_code`, and `enduser.id`/`client.address`/geo attributes (see [Client enrichment](#client-enrichment) below). An inbound W3C `traceparent` header is continued so the client and server share one trace; a `traceparent` response header (+ `Access-Control-Expose-Headers: traceparent`) is set so downstream clients can read it.
128
+ - **Outbound `fetch`** — each outbound subrequest while a request span is active gets a CLIENT child span (`http.request.method`, `url.full`, `server.address`, `http.response.status_code`). A W3C `traceparent` header is injected into the subrequest so a downstream Heystack-instrumented service continues the same trace (distributed tracing across services). The exporter's own ingest POST is never traced.
129
+ - **Queue consumers (`queue`)** — a CONSUMER span per batch, with `messaging.destination.name` (queue name) and `messaging.batch.message_count`.
130
+ - **Scheduled handlers (`scheduled`)** — an INTERNAL span per invocation, with `controller.cron`.
131
+ - **Binding calls** (when `instrumentBindings` is set) — a child span for every D1 query (`db.statement`), KV read/write, R2 operation, and Vectorize query.
132
+
133
+ ### Client enrichment
134
+
135
+ These attributes are set automatically on every SERVER span from request metadata:
136
+
137
+ | Attribute | Source |
138
+ | --- | --- |
139
+ | `enduser.id` | `getUser(req).id` |
140
+ | `session.id` | `getUser(req).session` |
141
+ | `http.request.id` | `getUser(req).requestId` or `cf-ray` header |
142
+ | `client.address` | `CF-Connecting-IP` header |
143
+ | `geo.country`, `geo.region`, `geo.city`, `geo.asn` | Cloudflare `req.cf` object |
144
+
145
+ ### Manual spans: `withSpan` / `addEvent`
146
+
147
+ Inside a traced handler, add finer-grained spans without touching the OpenTelemetry API directly:
105
148
 
106
149
  ```ts
107
- export default instrument(withOtherMiddleware(worker), { service: "my-worker" });
150
+ import { instrument, withSpan, addEvent } from "@heystack/otel/workers";
151
+
152
+ // Inside a fetch handler:
153
+ const result = await withSpan("parse-payload", { "source": "body" }, async (span) => {
154
+ addEvent("parsing-started");
155
+ span.setAttribute("content-type", req.headers.get("content-type") ?? "");
156
+ return JSON.parse(await req.text());
157
+ });
158
+
159
+ // Without attrs:
160
+ const data = await withSpan("call-llm", async () => {
161
+ return callMyAI();
162
+ });
108
163
  ```
109
164
 
110
- Set the key as a secret: `wrangler secret put HEYSTACK_API_KEY`.
165
+ `withSpan(name, attrs?, fn)` runs `fn` inside a new child span parented to the currently-active span. The span is started before `fn` and ended in `finally`; exceptions are recorded on the span and re-thrown. The `fn` receives the live `Span` as its argument.
166
+
167
+ `addEvent(name, attrs?)` — adds a named event to the currently-active span. No-op when no span is active.
168
+
169
+ ### Why a ContextManager
170
+
171
+ `context.with(...)` in OpenTelemetry is a **no-op unless a ContextManager is registered** with the global API. Without one, `suppressTracing()` — the primary defence against the self-trace feedback loop — silently does nothing in production. As of **0.3.2** the SDK registers a ContextManager exactly once at init. With `AsyncLocalStorage` (workerd under `nodejs_compat`, Node) you also get **cross-`await` parent→child span linking** and **per-request context isolation** — concurrent requests no longer share or clobber the active span.
111
172
 
112
173
  As of **0.3.1** `instrument()` **forwards every other handler your Worker exports** — `queue`, `scheduled`, `tail`, etc. — untouched, so wrapping never drops a handler Cloudflare requires for deploy (it previously returned only `{ fetch }`, which broke Queue/Cron Workers). On top of forwarding, `queue` and `scheduled` are themselves traced when present: each gets a root span via the global tracer (`queue <queueName>` as a CONSUMER span with batch attributes; `scheduled <cron>` as an INTERNAL span with the cron attribute), flushed via `ctx.waitUntil` just like `fetch`.
113
174
 
175
+ ### Streaming responses & trace correlation (`/workers`)
176
+
177
+ `instrument()` keeps the SERVER span open until the response body finishes
178
+ streaming, so a streamed response's duration includes time-to-last-byte and the
179
+ span carries a `first_byte` event (time-to-first-byte). It also continues an
180
+ inbound W3C `traceparent` (so a client request and the server handler share one
181
+ trace) and returns a `traceparent` response header — plus
182
+ `Access-Control-Expose-Headers: traceparent` so browser clients can read it.
183
+
114
184
  ### Durable Objects are NOT covered by `instrument()`
115
185
 
116
186
  `instrument()` wraps the keys of the **default-export handler object** (`fetch`/`queue`/`scheduled`/… ). **Durable Objects are separate named class exports**, so spreading the handler object does not touch them — a DO's `fetch`/`alarm` methods run **untraced** even when your Worker's default export is wrapped.
@@ -204,6 +274,7 @@ As belt-and-suspenders the exporter also drops any span whose HTTP target points
204
274
 
205
275
  ## Migration / versioning
206
276
 
277
+ - **`0.5.0`** — **`/workers`: identity enrichment, binding tracing, outbound-fetch tracing, manual span helpers.** New `WorkersConfig` options: `getUser` (attach `enduser.id`/`session.id`/`http.request.id` per request from a synchronous callback), `instrumentBindings` (auto child spans for D1/KV/R2/Vectorize — `true` = all detected, or a `string[]` to select). Outbound `fetch` calls made inside a traced handler automatically get CLIENT child spans with `traceparent` injection (distributed tracing across services). New ergonomic exports from `/workers`: `withSpan(name, attrs?, fn)` runs a function inside a named child span (auto-parented, exceptions recorded, `span.end()` in `finally`); `addEvent(name, attrs?)` adds an event to the active span. No breaking changes; all new options are optional.
207
278
  - **`0.4.3`** — **feedback-loop guard extended to the Node path (cost fix).** The self-instrumentation loop the Workers path fixed in 0.3.1/0.3.2 was still live on plain Node / Next-on-Node: the OTLP-over-`http` exporter's `POST /v1/traces` was auto-instrumented and re-exported, so ~77% of ingested spans in production were the exporter tracing itself — needless ingest + ClickHouse compute. 0.4.3 ignores ingest-host requests in the HTTP/undici auto-instrumentations **and** filters self-spans at the exporter boundary (covers caller-supplied `instrumentations` too). The hostname matcher is now a shared module used by both `/node` and `/workers`. No API change. **Action: upgrade and redeploy any Node/Next-on-Node app** — it cuts ingested span volume sharply.
208
279
  - **`0.3.5`** — **type-constraint fix (Workers).** A Worker whose `queue` consumer is typed with a concrete message body — `queue(batch: MessageBatch<MyJob>, …)`, the normal case — failed to compile against `instrument()` in 0.3.4 (`TS2345`: `MessageBatch<unknown>` not assignable to `MessageBatch<MyJob>`). The `WorkerHandler` constraint declared its entrypoints as arrow properties, whose parameters are checked **contravariantly** under `strictFunctionTypes`, so a narrowed handler wasn't assignable. 0.3.5 declares them with **method syntax** (bivariant parameters) and widens the batch to `MessageBatch<any>`, mirroring Cloudflare's own `ExportedHandler` — a typed-queue Worker now type-checks with a bare `instrument(handler, cfg)`. Runtime behaviour unchanged. Also adds a **consumer type-check gate** (`pnpm consumer-typecheck`, run in `check` and `prepublishOnly`) that compiles a fully-typed Worker against the built `dist` through the public `exports` map and asserts `satisfies ExportedHandler<Env>` — the regression that escaped in 0.3.3/0.3.4 now fails the build before publish.
209
280
  - **`0.3.4`** — **type-inference fix (Workers).** Restores `instrument()`'s ability to infer the handler's concrete `Env` type. In 0.3.3 the signature was `instrument<E = unknown, H extends WorkerHandler<E>>`, so `E` defaulted to `unknown` and was never recovered from the handler — under `strictFunctionTypes` a Worker typed `fetch(req, env: Env, ctx)` then failed to compile (`TS2345: 'Env' is not assignable to 'unknown'`) unless the caller passed `instrument<Env>(...)` explicitly. 0.3.4 infers `E` from the handler argument (`instrument<H extends WorkerHandler<any>>(...): Instrumented<EnvOf<H>, H>`), so a bare `instrument(handler, cfg)` type-checks again. Runtime behaviour is unchanged; no `0.3.3` consumer needs the explicit type arg after upgrading.
@@ -0,0 +1,33 @@
1
+ import { type Span } from "@opentelemetry/api";
2
+ export interface InstrumentBindingsOpts {
3
+ /**
4
+ * Factory that creates and starts a new child span. Called at binding method
5
+ * invocation time (inside the traced handler scope), so `context.active()`
6
+ * at that moment correctly parents to the root span.
7
+ *
8
+ * For integration: pass `(name, attrs) => tracer.startSpan(name, { attributes: attrs }, context.active())`.
9
+ * For unit tests: inject a fake so no global provider is required.
10
+ */
11
+ startSpan: (name: string, attrs: Record<string, unknown>) => Span;
12
+ /**
13
+ * `true` → auto-detect and wrap all D1/KV/R2/Vectorize bindings.
14
+ * `string[]` → only wrap bindings whose env key is listed.
15
+ */
16
+ select: boolean | string[];
17
+ }
18
+ /**
19
+ * Wrap an env object's Cloudflare bindings so that each binding operation
20
+ * emits a child span under the currently-active OTel context.
21
+ *
22
+ * Detects binding type by duck-typing (D1: `prepare`; KV: `get`+`put`+`list`;
23
+ * R2: `get`+`put`+`head`; Vectorize: `query`+`upsert`). Unrecognised bindings
24
+ * are passed through unchanged.
25
+ *
26
+ * Each wrapped binding is a `Proxy` over the original — non-wrapped prototype
27
+ * methods fall through to the real binding so no functionality is lost.
28
+ *
29
+ * @param env - The Worker env / binding bag.
30
+ * @param opts - `startSpan` factory + `select` filter.
31
+ * @returns A shallow copy of `env` with selected bindings replaced by proxies.
32
+ */
33
+ export declare function instrumentEnv<E extends Record<string, unknown>>(env: E, opts: InstrumentBindingsOpts): E;
@@ -0,0 +1,205 @@
1
+ // ---------------------------------------------------------------------------
2
+ // Cloudflare binding instrumentation for @heystack/otel/workers.
3
+ //
4
+ // Wraps D1, KV, R2, and Vectorize bindings with OTel child spans so that
5
+ // every binding operation is visible as a child of the active request span.
6
+ //
7
+ // WinterCG-safe: no `node:*` imports. Span factory is injected so the logic
8
+ // is pure and unit-testable without a global provider.
9
+ // ---------------------------------------------------------------------------
10
+ import { context, SpanStatusCode } from "@opentelemetry/api";
11
+ import { isTracingSuppressed } from "@opentelemetry/core";
12
+ // ---------------------------------------------------------------------------
13
+ // Duck-type detectors — conservative; require the distinctive method set
14
+ // exactly as documented in the task brief.
15
+ // ---------------------------------------------------------------------------
16
+ function isD1Like(b) {
17
+ return typeof b?.prepare === "function";
18
+ }
19
+ /** R2 is checked BEFORE KV because R2 also exposes `list`. */
20
+ function isR2Like(b) {
21
+ return (typeof b?.get === "function" &&
22
+ typeof b?.put === "function" &&
23
+ typeof b?.head === "function");
24
+ }
25
+ function isKVLike(b) {
26
+ return (typeof b?.get === "function" &&
27
+ typeof b?.put === "function" &&
28
+ typeof b?.list === "function");
29
+ }
30
+ function isVectorizeLike(b) {
31
+ return (typeof b?.query === "function" &&
32
+ typeof b?.upsert === "function");
33
+ }
34
+ // ---------------------------------------------------------------------------
35
+ // Span lifecycle helper
36
+ // ---------------------------------------------------------------------------
37
+ async function runWithSpan(span, fn) {
38
+ try {
39
+ return await fn();
40
+ }
41
+ catch (err) {
42
+ span.recordException(err instanceof Error ? err : new Error(String(err)));
43
+ span.setStatus({
44
+ code: SpanStatusCode.ERROR,
45
+ message: err instanceof Error ? err.message : String(err),
46
+ });
47
+ throw err;
48
+ }
49
+ finally {
50
+ span.end();
51
+ }
52
+ }
53
+ // ---------------------------------------------------------------------------
54
+ // Proxy helper — intercepts listed handlers, falls through to prototype for rest
55
+ // ---------------------------------------------------------------------------
56
+ function makeProxy(target, handlers) {
57
+ return new Proxy(target, {
58
+ get(t, prop, receiver) {
59
+ if (typeof prop === "string" && prop in handlers)
60
+ return handlers[prop];
61
+ const val = Reflect.get(t, prop, receiver);
62
+ return typeof val === "function" ? val.bind(t) : val;
63
+ },
64
+ });
65
+ }
66
+ // ---------------------------------------------------------------------------
67
+ // D1 wrappers
68
+ // ---------------------------------------------------------------------------
69
+ function wrapD1Statement(stmt, sql, opts) {
70
+ const wrapOp = (op) => async (...args) => {
71
+ if (isTracingSuppressed(context.active())) {
72
+ return stmt[op](...args);
73
+ }
74
+ const span = opts.startSpan(`D1 ${op}`, {
75
+ "db.system": "d1",
76
+ "db.statement": sql,
77
+ });
78
+ return runWithSpan(span, () => stmt[op](...args));
79
+ };
80
+ return makeProxy(stmt, {
81
+ all: wrapOp("all"),
82
+ first: wrapOp("first"),
83
+ run: wrapOp("run"),
84
+ raw: wrapOp("raw"),
85
+ bind(...bindArgs) {
86
+ // Return a wrapped statement so the sql propagates through bind chains.
87
+ return wrapD1Statement(stmt.bind(...bindArgs), sql, opts);
88
+ },
89
+ });
90
+ }
91
+ function wrapD1(binding, opts) {
92
+ return makeProxy(binding, {
93
+ prepare(sql) {
94
+ return wrapD1Statement(binding.prepare(sql), sql, opts);
95
+ },
96
+ });
97
+ }
98
+ // ---------------------------------------------------------------------------
99
+ // KV wrappers
100
+ // ---------------------------------------------------------------------------
101
+ function wrapKV(binding, opts, namespace) {
102
+ const wrapOp = (op, getKey) => async (...args) => {
103
+ if (isTracingSuppressed(context.active())) {
104
+ return binding[op](...args);
105
+ }
106
+ const attrs = { "kv.namespace": namespace };
107
+ const key = getKey?.(...args);
108
+ if (key !== undefined)
109
+ attrs["kv.key"] = key;
110
+ const span = opts.startSpan(`KV ${op}`, attrs);
111
+ return runWithSpan(span, () => binding[op](...args));
112
+ };
113
+ return makeProxy(binding, {
114
+ get: wrapOp("get", (key) => key),
115
+ put: wrapOp("put", (key) => key),
116
+ list: wrapOp("list"),
117
+ delete: wrapOp("delete", (key) => key),
118
+ });
119
+ }
120
+ // ---------------------------------------------------------------------------
121
+ // R2 wrappers
122
+ // ---------------------------------------------------------------------------
123
+ function wrapR2(binding, opts, bucket) {
124
+ const wrapOp = (op, getKey) => async (...args) => {
125
+ if (isTracingSuppressed(context.active())) {
126
+ return binding[op](...args);
127
+ }
128
+ const attrs = { "r2.bucket": bucket };
129
+ const key = getKey?.(...args);
130
+ if (key !== undefined)
131
+ attrs["r2.key"] = key;
132
+ const span = opts.startSpan(`R2 ${op}`, attrs);
133
+ return runWithSpan(span, () => binding[op](...args));
134
+ };
135
+ return makeProxy(binding, {
136
+ get: wrapOp("get", (key) => key),
137
+ put: wrapOp("put", (key) => key),
138
+ head: wrapOp("head", (key) => key),
139
+ delete: wrapOp("delete", (key) => key),
140
+ });
141
+ }
142
+ // ---------------------------------------------------------------------------
143
+ // Vectorize wrappers
144
+ // ---------------------------------------------------------------------------
145
+ function wrapVectorize(binding, opts, indexName) {
146
+ const wrapOp = (op) => async (...args) => {
147
+ if (isTracingSuppressed(context.active())) {
148
+ return binding[op](...args);
149
+ }
150
+ const span = opts.startSpan(`Vectorize ${op}`, {
151
+ "vectorize.index": indexName,
152
+ });
153
+ return runWithSpan(span, () => binding[op](...args));
154
+ };
155
+ return makeProxy(binding, {
156
+ query: wrapOp("query"),
157
+ upsert: wrapOp("upsert"),
158
+ insert: wrapOp("insert"),
159
+ deleteByIds: wrapOp("deleteByIds"),
160
+ getByIds: wrapOp("getByIds"),
161
+ });
162
+ }
163
+ // ---------------------------------------------------------------------------
164
+ // Main export
165
+ // ---------------------------------------------------------------------------
166
+ /**
167
+ * Wrap an env object's Cloudflare bindings so that each binding operation
168
+ * emits a child span under the currently-active OTel context.
169
+ *
170
+ * Detects binding type by duck-typing (D1: `prepare`; KV: `get`+`put`+`list`;
171
+ * R2: `get`+`put`+`head`; Vectorize: `query`+`upsert`). Unrecognised bindings
172
+ * are passed through unchanged.
173
+ *
174
+ * Each wrapped binding is a `Proxy` over the original — non-wrapped prototype
175
+ * methods fall through to the real binding so no functionality is lost.
176
+ *
177
+ * @param env - The Worker env / binding bag.
178
+ * @param opts - `startSpan` factory + `select` filter.
179
+ * @returns A shallow copy of `env` with selected bindings replaced by proxies.
180
+ */
181
+ export function instrumentEnv(env, opts) {
182
+ const result = { ...env };
183
+ const { select } = opts;
184
+ for (const key of Object.keys(env)) {
185
+ // Filter: if select is an array, only wrap keys in the list.
186
+ if (select !== true && !select.includes(key))
187
+ continue;
188
+ const binding = env[key];
189
+ if (isD1Like(binding)) {
190
+ result[key] = wrapD1(binding, opts);
191
+ }
192
+ else if (isR2Like(binding)) {
193
+ // R2 before KV — R2 also has `list`, so checking `head` first avoids mis-classifying.
194
+ result[key] = wrapR2(binding, opts, key);
195
+ }
196
+ else if (isKVLike(binding)) {
197
+ result[key] = wrapKV(binding, opts, key);
198
+ }
199
+ else if (isVectorizeLike(binding)) {
200
+ result[key] = wrapVectorize(binding, opts, key);
201
+ }
202
+ // Unrecognised bindings are left as-is.
203
+ }
204
+ return result;
205
+ }
package/dist/workers.d.ts CHANGED
@@ -23,6 +23,11 @@ interface OtlpKeyValue {
23
23
  key: string;
24
24
  value: OtlpAnyValue;
25
25
  }
26
+ interface OtlpSpanEvent {
27
+ timeUnixNano: string;
28
+ name: string;
29
+ attributes: OtlpKeyValue[];
30
+ }
26
31
  interface OtlpSpan {
27
32
  traceId: string;
28
33
  spanId: string;
@@ -32,6 +37,7 @@ interface OtlpSpan {
32
37
  startTimeUnixNano: string;
33
38
  endTimeUnixNano: string;
34
39
  attributes: OtlpKeyValue[];
40
+ events: OtlpSpanEvent[];
35
41
  status: {
36
42
  code: number;
37
43
  message?: string;
@@ -54,6 +60,14 @@ interface OtlpTracesPayload {
54
60
  * hence the same resource, so we emit a single resourceSpans entry.
55
61
  */
56
62
  export declare function serializeSpans(spans: ReadableSpan[]): OtlpTracesPayload;
63
+ /** Parse a W3C `traceparent`. Returns null for malformed or all-zero ids. */
64
+ export declare function parseTraceparent(header: string | null): {
65
+ traceId: string;
66
+ spanId: string;
67
+ traceFlags: number;
68
+ } | null;
69
+ /** Add `value` to Access-Control-Expose-Headers without duplicating it. */
70
+ export declare function appendExposeHeader(headers: Headers, value: string): void;
57
71
  /**
58
72
  * Test-only helper: run the self-span attribute check directly against a plain
59
73
  * attribute bag + ingest hostname, without constructing a ReadableSpan. The
@@ -61,6 +75,12 @@ export declare function serializeSpans(spans: ReadableSpan[]): OtlpTracesPayload
61
75
  * the exporter derives via `safeHostname(cfg.url)`.
62
76
  */
63
77
  export declare function isSelfSpanForTest(attrs: Record<string, unknown>, ingestHost: string): boolean;
78
+ /**
79
+ * Reset the outbound-fetch instrumentation: restore the captured platform fetch
80
+ * (only when our wrapper is still the installed global) and clear the guard.
81
+ * Internal/testing helper.
82
+ */
83
+ export declare function __resetFetchInstrumentation(): void;
64
84
  /**
65
85
  * A WinterCG-compatible OTLP/JSON span exporter. POSTs ended spans to the
66
86
  * Heystack ingest using the platform `fetch` — no Node built-ins.
@@ -68,7 +88,7 @@ export declare function isSelfSpanForTest(attrs: Record<string, unknown>, ingest
68
88
  export declare class HeystackSpanExporter implements SpanExporter {
69
89
  private readonly url;
70
90
  /** Hostname (no port) of the ingest endpoint, used to drop self-trace spans. */
71
- private readonly ingestHost;
91
+ readonly ingestHost: string;
72
92
  private readonly headers;
73
93
  private shutdownState;
74
94
  /**
@@ -122,6 +142,23 @@ export interface WorkersConfig {
122
142
  * automatically borrows the request context's `ctx.waitUntil`.
123
143
  */
124
144
  waitUntil?: (p: Promise<unknown>) => void;
145
+ /**
146
+ * Optional hook called on each incoming request to supply identity context.
147
+ * Return `{ id }` to tag the SERVER span with `enduser.id`, `{ session }` for
148
+ * `session.id`, or `{ requestId }` to override `http.request.id` (otherwise
149
+ * the `cf-ray` request header is used as a fallback). Any field may be omitted.
150
+ */
151
+ getUser?: (req: Request) => {
152
+ id?: string;
153
+ session?: string;
154
+ requestId?: string;
155
+ } | undefined;
156
+ /**
157
+ * Declare which bindings to instrument with tracing (Task 5). Pass `true` to
158
+ * trace all bindings, or an array of binding names to trace selectively.
159
+ * Defaults to `false` (no binding tracing). Consumed by a later task.
160
+ */
161
+ instrumentBindings?: boolean | string[];
125
162
  }
126
163
  /**
127
164
  * A `BasicTracerProvider` with the underlying `HeystackSpanExporter` attached so
@@ -143,12 +180,31 @@ export type HeystackTracerProvider = BasicTracerProvider & {
143
180
  */
144
181
  export declare function createTracerProvider(config: HeystackOptions): HeystackTracerProvider;
145
182
  /**
146
- * A minimal SYNCHRONOUS, stack-based ContextManager the registered manager for
147
- * the /workers entry (no `node:async_hooks`, so it works on any WinterCG runtime).
148
- * It makes `context.with()` propagate synchronously, which is enough for the
149
- * exporter's `suppressTracing` to take effect and for the belt-and-suspenders
150
- * self-span filter but it does NOT carry context across `await` boundaries (so
151
- * cross-`await` parent linking and per-request isolation are best-effort).
183
+ * An AsyncLocalStorage-backed ContextManager for the /workers entry. When the
184
+ * runtime exposes `globalThis.AsyncLocalStorage` (workerd and Node do), this
185
+ * manager is preferred over `SyncStackContextManager` because it propagates
186
+ * context across `await` boundaries child spans created after an `await`
187
+ * inside a handler are correctly parented to the request's root span.
188
+ *
189
+ * WinterCG-safe: it does NOT import `node:async_hooks`; instead it uses the
190
+ * ALS global that the runtime exposes. Falls back to `SyncStackContextManager`
191
+ * when the global is absent (Deno, Bun without globals, etc.).
192
+ */
193
+ export declare class AlsContextManager implements ContextManager {
194
+ private _als;
195
+ constructor();
196
+ active(): Context;
197
+ with<A extends unknown[], F extends (...args: A) => ReturnType<F>>(ctx: Context, fn: F, thisArg?: ThisParameterType<F>, ...args: A): ReturnType<F>;
198
+ bind<T>(_ctx: Context, target: T): T;
199
+ enable(): this;
200
+ disable(): this;
201
+ }
202
+ /**
203
+ * A minimal SYNCHRONOUS, stack-based ContextManager — the fallback for the
204
+ * /workers entry when `globalThis.AsyncLocalStorage` is absent (any WinterCG
205
+ * runtime without the global). It makes `context.with()` propagate
206
+ * synchronously, which is enough for the exporter's `suppressTracing` to take
207
+ * effect — but it does NOT carry context across `await` boundaries.
152
208
  */
153
209
  export declare class SyncStackContextManager implements ContextManager {
154
210
  private _stack;
@@ -269,4 +325,22 @@ type EnvOf<H> = H extends {
269
325
  scheduled: (controller: ScheduledController, env: infer E, ctx: ExecutionContext) => unknown;
270
326
  } ? E : unknown;
271
327
  export declare function instrument<H extends WorkerHandler<any>>(handler: H, config: WorkersConfig): Instrumented<EnvOf<H>, H>;
328
+ /**
329
+ * Run `fn` inside a new child span named `name`. The span is automatically
330
+ * parented to the currently-active span (via `context.active()`), started
331
+ * before `fn` and ended in `finally` — so the caller never needs to call
332
+ * `span.end()`. If `fn` throws, the exception is recorded on the span and
333
+ * the status is set to ERROR before re-throwing.
334
+ *
335
+ * Two call signatures are supported:
336
+ * withSpan("name", async (span) => { ... })
337
+ * withSpan("name", { attr: "value" }, async (span) => { ... })
338
+ */
339
+ export declare function withSpan<T>(name: string, fn: (span: Span) => T | Promise<T>): Promise<T>;
340
+ export declare function withSpan<T>(name: string, attrs: Record<string, string | number | boolean>, fn: (span: Span) => T | Promise<T>): Promise<T>;
341
+ /**
342
+ * Add a named event (with optional attributes) to the currently-active span.
343
+ * No-op when no span is active.
344
+ */
345
+ export declare function addEvent(name: string, attrs?: Record<string, string | number | boolean>): void;
272
346
  export type { Span };
package/dist/workers.js CHANGED
@@ -8,13 +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
17
  import { isSelfSpanAttrs, safeHostname } from "./self-span.js";
18
+ import { instrumentEnv } from "./workers-bindings.js";
18
19
  // `ExportResult` / `ExportResultCode` mirror `@opentelemetry/core`. We define
19
20
  // them inline (structurally identical) rather than import them: core is only a
20
21
  // transitive dep of sdk-trace-base and isn't reliably resolvable, and keeping it
@@ -116,6 +117,11 @@ function readableSpanToOtlp(span) {
116
117
  startTimeUnixNano: hrTimeToUnixNano(span.startTime),
117
118
  endTimeUnixNano: hrTimeToUnixNano(span.endTime),
118
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
+ })),
119
125
  status: toStatus(span.status),
120
126
  };
121
127
  }
@@ -139,6 +145,36 @@ export function serializeSpans(spans) {
139
145
  };
140
146
  }
141
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
+ // ---------------------------------------------------------------------------
142
178
  // Self-span filtering (feedback-loop guard)
143
179
  //
144
180
  // On Next/OpenNext the host auto-instruments outbound `fetch`, so the exporter's
@@ -164,6 +200,143 @@ export function isSelfSpanForTest(attrs, ingestHost) {
164
200
  return isSelfSpanAttrs(attrs, ingestHost);
165
201
  }
166
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;
236
+ try {
237
+ return String(input.url ?? input);
238
+ }
239
+ catch {
240
+ return "";
241
+ }
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
+ }
251
+ /**
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.
255
+ */
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];
261
+ }
262
+ const headers = new Headers(init?.headers);
263
+ headers.set("traceparent", traceparent);
264
+ return [input, { ...(init ?? {}), headers }];
265
+ }
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;
325
+ }
326
+ /**
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.
330
+ */
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;
338
+ }
339
+ // ---------------------------------------------------------------------------
167
340
  // Exporter
168
341
  // ---------------------------------------------------------------------------
169
342
  /**
@@ -231,9 +404,12 @@ export class HeystackSpanExporter {
231
404
  // The POST runs inside a tracing-suppressed context so that host fetch
232
405
  // auto-instrumentation (e.g. Next/OpenNext) does NOT create a CLIENT span
233
406
  // for it — which would otherwise be exported and re-captured, a sustained
234
- // 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;
235
411
  const p = context
236
- .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 }))
237
413
  .then((res) => {
238
414
  if (res.ok) {
239
415
  resultCallback({ code: ExportResultCode.SUCCESS });
@@ -355,31 +531,51 @@ export function createTracerProvider(config) {
355
531
  // Global tracer provider registration (for host frameworks, e.g. Next.js)
356
532
  // ---------------------------------------------------------------------------
357
533
  let _provider = null;
358
- // ---------------------------------------------------------------------------
359
- // Context manager registration (makes suppressTracing() actually work)
360
- //
361
- // `context.with(...)` is a NO-OP unless a ContextManager is registered with the
362
- // global OTel API. Without one, `suppressTracing(context.active())` produces a
363
- // context that is never made active, so the exporter's POST is NOT suppressed
364
- // in production and host fetch auto-instrumentation can re-trace it (feedback
365
- // loop). We therefore register a manager exactly ONCE in `ensureGlobalProvider`.
366
- //
367
- // We register a dependency-free SYNCHRONOUS stack manager (below). Deliberately
368
- // NOT AsyncLocalStorageContextManager: that statically imports `node:async_hooks`,
369
- // which would break `import "@heystack/otel/workers"` on a bare workerd without
370
- // `nodejs_compat` (and on other WinterCG runtimes) — defeating the whole point of
371
- // this entry being node-builtin-free. The sync manager covers the critical path:
372
- // the exporter's POST runs synchronously inside the suppressed `context.with`, so
373
- // `suppressTracing` takes effect. Trade-off: no cross-`await` context propagation,
374
- // so deep nested-span parenting is limited on the edge (documented).
375
- // ---------------------------------------------------------------------------
534
+ /** Read the ALS global lazily — at call time, not module load time. */
535
+ function getALS() {
536
+ return globalThis.AsyncLocalStorage;
537
+ }
376
538
  /**
377
- * A minimal SYNCHRONOUS, stack-based ContextManager the registered manager for
378
- * the /workers entry (no `node:async_hooks`, so it works on any WinterCG runtime).
379
- * It makes `context.with()` propagate synchronously, which is enough for the
380
- * exporter's `suppressTracing` to take effect and for the belt-and-suspenders
381
- * self-span filter but it does NOT carry context across `await` boundaries (so
382
- * 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.
383
579
  */
384
580
  export class SyncStackContextManager {
385
581
  _stack = [];
@@ -412,20 +608,19 @@ let _contextManagerRegistered = false;
412
608
  * `context.with(suppressTracing(...))` in the exporter is actually honoured —
413
609
  * otherwise suppression is a no-op and the exporter's POST can be re-traced.
414
610
  *
415
- * We register a synchronous, dependency-free stack manager. This keeps the
416
- * /workers entry WinterCG-safe (no `node:async_hooks` import → works on bare
417
- * workerd WITHOUT nodejs_compat, Deno, Bun, etc.). It fully covers the critical
418
- * path (the export fetch runs synchronously inside the suppressed `context.with`,
419
- * and per-request root spans). Trade-off: it does not propagate context across
420
- * `await` boundaries, so deep nested-span parenting is limited on the edge — an
421
- * acceptable, documented limitation (workerd has no async context manager by
422
- * 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.
423
617
  */
424
618
  function ensureContextManager() {
425
619
  if (_contextManagerRegistered)
426
620
  return;
427
621
  _contextManagerRegistered = true;
428
- context.setGlobalContextManager(new SyncStackContextManager().enable());
622
+ const mgr = getALS() ? new AlsContextManager() : new SyncStackContextManager();
623
+ context.setGlobalContextManager(mgr.enable());
429
624
  }
430
625
  /** Reset the context-manager registration guard. Internal/testing helper. */
431
626
  export function __resetContextManager() {
@@ -456,6 +651,10 @@ function ensureGlobalProvider(config) {
456
651
  // the exporter actually takes effect — without one, `context.with` is a no-op
457
652
  // and suppression silently does nothing in production.
458
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);
459
658
  return _provider;
460
659
  }
461
660
  /**
@@ -562,6 +761,17 @@ export function instrument(handler, config) {
562
761
  return originalFetch(req, env, ctx);
563
762
  const { provider, tracer } = s;
564
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
+ }
565
775
  const span = tracer.startSpan(`${req.method} ${url.pathname}`, {
566
776
  kind: SpanKind.SERVER,
567
777
  attributes: {
@@ -570,16 +780,92 @@ export function instrument(handler, config) {
570
780
  "url.path": url.pathname,
571
781
  "server.address": url.host,
572
782
  },
573
- });
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
+ }
574
823
  try {
575
- 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");
576
828
  span.setAttribute("http.response.status_code", response.status);
577
- span.setStatus({
578
- 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,
579
868
  });
580
- span.end();
581
- drain(provider, ctx);
582
- return response;
583
869
  }
584
870
  catch (error) {
585
871
  span.recordException(error instanceof Error ? error : new Error(String(error)));
@@ -658,3 +944,29 @@ export function instrument(handler, config) {
658
944
  }
659
945
  return wrapped;
660
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.3",
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",