@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 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.
@@ -200,8 +270,12 @@ As of **0.3.1** the exporter **suppresses tracing for its own ingest POST**, and
200
270
 
201
271
  As belt-and-suspenders the exporter also drops any span whose HTTP target points at the configured ingest origin. As of **0.3.2** that match is **hostname-accurate**: full-URL attributes (`url.full`, `http.url`) are parsed and compared on `.hostname` (case-insensitive, port-stripped) so a sibling domain like `myingest.heystack.dev` is no longer a false positive and an explicit port like `ingest.heystack.dev:443` is correctly matched; host-only attributes (`server.address`, `net.peer.name`, `net.peer.hostname`, `http.host`, `peer.address`) are port-stripped and compared by hostname.
202
272
 
273
+ **The `/node` (and Next-on-Node) path got the same guard in `0.4.3`.** Before then, only the Workers/Edge exporter dropped self-spans; on a plain Node runtime the OTLP-over-`http` exporter's own `POST /v1/traces` was still captured by the HTTP/undici auto-instrumentation and re-exported, so the **same feedback loop ran on Node** (production measurement: ~77% of all ingested spans were `fetch POST .../v1/traces`). `0.4.3` (a) configures the HTTP + undici auto-instrumentations to ignore outbound requests to the ingest host, and (b) wraps the exporter so any span targeting the ingest origin is filtered before export — the latter holds even if you pass your own `instrumentations` array. Same hostname-accurate matcher as the Workers path (shared module). No API change; upgrade and redeploy.
274
+
203
275
  ## Migration / versioning
204
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.
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.
205
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.
206
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.
207
281
  - **`0.3.3`** — `/next` uses bare, exports-mapped dynamic imports so the OpenNext (Cloudflare Pages) build resolves the workers entry correctly (fixes a build break). Workers/Node paths unchanged.
package/dist/node.d.ts CHANGED
@@ -1,5 +1,7 @@
1
1
  import { NodeSDK } from "@opentelemetry/sdk-node";
2
2
  import { getNodeAutoInstrumentations } from "@opentelemetry/auto-instrumentations-node";
3
+ import { type ExportResult } from "@opentelemetry/core";
4
+ import type { ReadableSpan, SpanExporter } from "@opentelemetry/sdk-trace-base";
3
5
  import { type HeystackOptions } from "./core.js";
4
6
  /**
5
7
  * The element type the NodeSDK `instrumentations` config accepts (an OTel
@@ -24,6 +26,32 @@ export interface NodeOptions extends HeystackOptions {
24
26
  */
25
27
  instrumentations?: InstrumentationConfigItem[];
26
28
  }
29
+ /**
30
+ * Wraps a span exporter and drops any span that targets the Heystack ingest
31
+ * origin before it is exported. On Node/Next the runtime auto-instruments
32
+ * outbound HTTP, so the exporter's own POST to `/v1/traces` becomes a CLIENT
33
+ * span → exported → re-captured → a sustained self-feeding loop. We measured
34
+ * this loop accounting for ~77% of ingested spans in production.
35
+ *
36
+ * The `/workers` entry stops the loop at the exporter via a suppressed context;
37
+ * Node's OTLP-over-`http` exporter cannot rely on that being honoured by every
38
+ * instrumentation version, so we filter at the exporter boundary instead. This
39
+ * is the universal guard — it works regardless of which instrumentations are
40
+ * loaded (including a caller-supplied `instrumentations` array). The
41
+ * `ignore*RequestHook` config below is the cheaper primary defence that stops
42
+ * the self-span from being created in the first place.
43
+ */
44
+ export declare class SelfSpanFilteringExporter implements SpanExporter {
45
+ private readonly inner;
46
+ /** Bare ingest hostname (lower-case, no port), e.g. `ingest.heystack.dev`. */
47
+ private readonly ingestHost;
48
+ constructor(inner: SpanExporter,
49
+ /** Bare ingest hostname (lower-case, no port), e.g. `ingest.heystack.dev`. */
50
+ ingestHost: string);
51
+ export(spans: ReadableSpan[], cb: (result: ExportResult) => void): void;
52
+ shutdown(): Promise<void>;
53
+ forceFlush(): Promise<void>;
54
+ }
27
55
  /** Initialise Heystack tracing on a Node runtime. Call once, as early as possible. Returns the started SDK. */
28
56
  export declare function initHeystack(o: NodeOptions): NodeSDK;
29
57
  /** Flush + shutdown the SDK on SIGTERM/SIGINT so short-lived processes don't lose the last batch. Registers handlers at most once. */
package/dist/node.js CHANGED
@@ -2,7 +2,78 @@ import { NodeSDK } from "@opentelemetry/sdk-node";
2
2
  import { OTLPTraceExporter } from "@opentelemetry/exporter-trace-otlp-http";
3
3
  import { getNodeAutoInstrumentations } from "@opentelemetry/auto-instrumentations-node";
4
4
  import { diag, DiagConsoleLogger, DiagLogLevel } from "@opentelemetry/api";
5
+ import { ExportResultCode } from "@opentelemetry/core";
5
6
  import { buildExporterConfig } from "./core.js";
7
+ import { isSelfSpanAttrs, safeHostname } from "./self-span.js";
8
+ /**
9
+ * Wraps a span exporter and drops any span that targets the Heystack ingest
10
+ * origin before it is exported. On Node/Next the runtime auto-instruments
11
+ * outbound HTTP, so the exporter's own POST to `/v1/traces` becomes a CLIENT
12
+ * span → exported → re-captured → a sustained self-feeding loop. We measured
13
+ * this loop accounting for ~77% of ingested spans in production.
14
+ *
15
+ * The `/workers` entry stops the loop at the exporter via a suppressed context;
16
+ * Node's OTLP-over-`http` exporter cannot rely on that being honoured by every
17
+ * instrumentation version, so we filter at the exporter boundary instead. This
18
+ * is the universal guard — it works regardless of which instrumentations are
19
+ * loaded (including a caller-supplied `instrumentations` array). The
20
+ * `ignore*RequestHook` config below is the cheaper primary defence that stops
21
+ * the self-span from being created in the first place.
22
+ */
23
+ export class SelfSpanFilteringExporter {
24
+ inner;
25
+ ingestHost;
26
+ constructor(inner,
27
+ /** Bare ingest hostname (lower-case, no port), e.g. `ingest.heystack.dev`. */
28
+ ingestHost) {
29
+ this.inner = inner;
30
+ this.ingestHost = ingestHost;
31
+ }
32
+ export(spans, cb) {
33
+ const kept = this.ingestHost
34
+ ? spans.filter((s) => !isSelfSpanAttrs(s.attributes, this.ingestHost))
35
+ : spans;
36
+ // Nothing left to send: report success without a no-op POST (which would
37
+ // itself be a self-span and re-prime the loop).
38
+ if (kept.length === 0) {
39
+ cb({ code: ExportResultCode.SUCCESS });
40
+ return;
41
+ }
42
+ this.inner.export(kept, cb);
43
+ }
44
+ shutdown() {
45
+ return this.inner.shutdown();
46
+ }
47
+ forceFlush() {
48
+ return this.inner.forceFlush?.() ?? Promise.resolve();
49
+ }
50
+ }
51
+ /**
52
+ * Per-instrumentation config that tells the HTTP + undici auto-instrumentations
53
+ * to skip outbound requests to the ingest host, so the exporter's own POST is
54
+ * never turned into a span. Keyed by instrumentation package name; unknown keys
55
+ * are ignored by `getNodeAutoInstrumentations`, so this stays version-tolerant.
56
+ */
57
+ function ingestIgnoreConfig(ingestHost) {
58
+ if (!ingestHost)
59
+ return {};
60
+ return {
61
+ "@opentelemetry/instrumentation-http": {
62
+ // RequestOptions carries host/hostname (and sometimes a full URL).
63
+ ignoreOutgoingRequestHook: (options) => {
64
+ const h = (options.hostname ?? options.host ?? "")
65
+ .toString()
66
+ .toLowerCase();
67
+ // host may include :port — compare on the bare hostname.
68
+ return h.split(":")[0] === ingestHost;
69
+ },
70
+ },
71
+ "@opentelemetry/instrumentation-undici": {
72
+ // Undici/fetch request: `.origin` is a full URL string, `.path` the path.
73
+ ignoreRequestHook: (req) => safeHostname(req.origin ?? "") === ingestHost,
74
+ },
75
+ };
76
+ }
6
77
  /**
7
78
  * Process-level guard so SIGTERM/SIGINT handlers are registered AT MOST ONCE,
8
79
  * even if `initHeystack` is called repeatedly (e.g. Next dev server reloads).
@@ -26,12 +97,21 @@ export function initHeystack(o) {
26
97
  if (o.debug)
27
98
  diag.setLogger(new DiagConsoleLogger(), DiagLogLevel.DEBUG);
28
99
  const cfg = buildExporterConfig(o);
100
+ const ingestHost = safeHostname(cfg.url);
101
+ // Primary defence: don't even create a span for the exporter's own POST.
102
+ // Only applies to our default auto-instrumentation set — a caller-supplied
103
+ // `instrumentations` array is left untouched (the exporter filter still
104
+ // protects it).
29
105
  const instrumentations = o.autoInstrument === false
30
106
  ? []
31
- : (o.instrumentations ?? [getNodeAutoInstrumentations()]);
107
+ : (o.instrumentations ??
108
+ [getNodeAutoInstrumentations(ingestIgnoreConfig(ingestHost))]);
109
+ // Universal defence: filter any self-span at the exporter boundary, so the
110
+ // loop is killed regardless of which instrumentations are active.
111
+ const traceExporter = new SelfSpanFilteringExporter(new OTLPTraceExporter({ url: cfg.url, headers: cfg.headers }), ingestHost);
32
112
  const sdk = new NodeSDK({
33
113
  serviceName: o.service,
34
- traceExporter: new OTLPTraceExporter({ url: cfg.url, headers: cfg.headers }),
114
+ traceExporter,
35
115
  instrumentations,
36
116
  });
37
117
  sdk.start();
@@ -0,0 +1,30 @@
1
+ /**
2
+ * Parse the hostname (no port) out of a URL, lower-cased; empty string if it
3
+ * can't be parsed. We compare on hostname rather than `host` so that an ingest
4
+ * URL like `ingest.heystack.dev` matches a captured span attribute of
5
+ * `ingest.heystack.dev:443` (and vice-versa).
6
+ */
7
+ export declare function safeHostname(url: string): string;
8
+ /**
9
+ * Strip a trailing `:port` from a bare host attribute and lower-case it, so a
10
+ * host-only attr like `ingest.heystack.dev:443` compares equal to the ingest
11
+ * hostname. IPv6 literals (`[::1]:443`) keep their bracketed form. Returns ""
12
+ * for anything that isn't a non-empty string.
13
+ */
14
+ export declare function hostnameOf(hostAttr: unknown): string;
15
+ /** Attributes that carry a full HTTP URL on a CLIENT/SERVER span. */
16
+ export declare const HTTP_URL_ATTRS: readonly ["url.full", "http.url"];
17
+ /** Host-only attributes (host[:port], no scheme/path). */
18
+ export declare const HTTP_HOST_ATTRS: readonly ["server.address", "net.peer.name", "net.peer.hostname", "http.host", "peer.address"];
19
+ /**
20
+ * True if `attrs` looks like a request to the configured ingest origin — i.e. it
21
+ * is (or could be) the exporter's own self-trace. For full-URL attributes we
22
+ * parse the URL and compare its `.hostname` (case-insensitive, port stripped) so
23
+ * a sibling domain like `myingest.heystack.dev` is NOT a false positive and an
24
+ * explicit port like `ingest.heystack.dev:443` IS matched. For host-only attrs
25
+ * we strip any `:port` and compare hostname equality.
26
+ *
27
+ * `ingestHost` must be a bare hostname (lower-case, no port), as produced by
28
+ * `safeHostname(cfg.url)`.
29
+ */
30
+ export declare function isSelfSpanAttrs(attrs: Record<string, unknown>, ingestHost: string): boolean;
@@ -0,0 +1,83 @@
1
+ // ---------------------------------------------------------------------------
2
+ // Self-span detection (shared by /workers and /node)
3
+ //
4
+ // When a runtime auto-instruments outbound HTTP (undici/fetch on Next/OpenNext,
5
+ // the http module on Node), the exporter's own POST to `/v1/traces` becomes a
6
+ // CLIENT span → exported → re-captured → a sustained loop. These helpers detect
7
+ // a span that targets the configured ingest origin so we can drop it before it
8
+ // feeds the loop. Pure string logic — no runtime imports — safe in any entry
9
+ // (including the WinterCG-only `/workers` build).
10
+ // ---------------------------------------------------------------------------
11
+ /**
12
+ * Parse the hostname (no port) out of a URL, lower-cased; empty string if it
13
+ * can't be parsed. We compare on hostname rather than `host` so that an ingest
14
+ * URL like `ingest.heystack.dev` matches a captured span attribute of
15
+ * `ingest.heystack.dev:443` (and vice-versa).
16
+ */
17
+ export function safeHostname(url) {
18
+ try {
19
+ return new URL(url).hostname.toLowerCase();
20
+ }
21
+ catch {
22
+ return "";
23
+ }
24
+ }
25
+ /**
26
+ * Strip a trailing `:port` from a bare host attribute and lower-case it, so a
27
+ * host-only attr like `ingest.heystack.dev:443` compares equal to the ingest
28
+ * hostname. IPv6 literals (`[::1]:443`) keep their bracketed form. Returns ""
29
+ * for anything that isn't a non-empty string.
30
+ */
31
+ export function hostnameOf(hostAttr) {
32
+ if (typeof hostAttr !== "string" || hostAttr === "")
33
+ return "";
34
+ const v = hostAttr.trim();
35
+ // Bracketed IPv6, optionally with a port: `[::1]` or `[::1]:443`.
36
+ if (v.startsWith("[")) {
37
+ const close = v.indexOf("]");
38
+ if (close !== -1)
39
+ return v.slice(0, close + 1).toLowerCase();
40
+ return v.toLowerCase();
41
+ }
42
+ // Strip a single trailing :port (host:port). A bare hostname has no colon.
43
+ const colon = v.lastIndexOf(":");
44
+ if (colon !== -1 && v.indexOf(":") === colon) {
45
+ return v.slice(0, colon).toLowerCase();
46
+ }
47
+ return v.toLowerCase();
48
+ }
49
+ /** Attributes that carry a full HTTP URL on a CLIENT/SERVER span. */
50
+ export const HTTP_URL_ATTRS = ["url.full", "http.url"];
51
+ /** Host-only attributes (host[:port], no scheme/path). */
52
+ export const HTTP_HOST_ATTRS = [
53
+ "server.address",
54
+ "net.peer.name",
55
+ "net.peer.hostname",
56
+ "http.host",
57
+ "peer.address",
58
+ ];
59
+ /**
60
+ * True if `attrs` looks like a request to the configured ingest origin — i.e. it
61
+ * is (or could be) the exporter's own self-trace. For full-URL attributes we
62
+ * parse the URL and compare its `.hostname` (case-insensitive, port stripped) so
63
+ * a sibling domain like `myingest.heystack.dev` is NOT a false positive and an
64
+ * explicit port like `ingest.heystack.dev:443` IS matched. For host-only attrs
65
+ * we strip any `:port` and compare hostname equality.
66
+ *
67
+ * `ingestHost` must be a bare hostname (lower-case, no port), as produced by
68
+ * `safeHostname(cfg.url)`.
69
+ */
70
+ export function isSelfSpanAttrs(attrs, ingestHost) {
71
+ if (!ingestHost)
72
+ return false;
73
+ for (const key of HTTP_URL_ATTRS) {
74
+ const v = attrs[key];
75
+ if (typeof v === "string" && safeHostname(v) === ingestHost)
76
+ return true;
77
+ }
78
+ for (const key of HTTP_HOST_ATTRS) {
79
+ if (hostnameOf(attrs[key]) === ingestHost)
80
+ return true;
81
+ }
82
+ return false;
83
+ }
@@ -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
+ }