@heystack/otel 0.1.0 → 0.3.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
@@ -13,13 +13,15 @@ Always read your key from the environment — never paste it into source:
13
13
  HEYSTACK_API_KEY=sk_live_…
14
14
  ```
15
15
 
16
+ > **Requires `@heystack/otel` `>=0.3.0`.** See [Migration](#migration--versioning) below.
17
+
16
18
  ## Runtime matrix
17
19
 
18
20
  | Runtime | Import | Notes |
19
21
  | --- | --- | --- |
20
- | Node / Express / Fastify / workers (long-running) | `@heystack/otel/node` | Auto-instrumentations + graceful shutdown. |
21
- | Next.js (`instrumentation.ts`) | `@heystack/otel/next` | Runtime-guarded; no-op on Edge. |
22
- | Cloudflare Workers / Edge | `@heystack/otel/workers` | Coming soon the Node SDK can't run on Workers/Edge. |
22
+ | Next.js **any** deploy target (Vercel/Node **and** Cloudflare/OpenNext) | `@heystack/otel/next` | `registerHeystack` in `instrumentation.ts`. Auto-detects Node vs Cloudflare workerd and picks the right exporter. No-op on Edge. |
23
+ | Standalone Cloudflare Workers (hand-written `export default { fetch }`) | `@heystack/otel/workers` | `instrument()` wraps your handler. Fetch-based exporter, flushes via `ctx.waitUntil`. |
24
+ | Node / Express / Fastify / NestJS (long-running server) | `@heystack/otel/node` | `initHeystack`: auto-instrumentations + graceful shutdown. |
23
25
  | Anywhere (pure helpers) | `@heystack/otel` | `buildExporterConfig`, types. No Node SDK loaded. |
24
26
 
25
27
  ## Node / Express / etc.
@@ -34,7 +36,7 @@ initHeystack({ apiKey: process.env.HEYSTACK_API_KEY, service: "my-app" });
34
36
 
35
37
  This enables auto-instrumentations (HTTP, Express, etc.) so you get spans without manual wiring.
36
38
 
37
- ## Next.js
39
+ ## Next.js (any deploy target, including Cloudflare/OpenNext)
38
40
 
39
41
  In `instrumentation.ts` at the project root:
40
42
 
@@ -45,11 +47,51 @@ export async function register() {
45
47
  }
46
48
  ```
47
49
 
48
- `apiKey` defaults to `process.env.HEYSTACK_API_KEY`. `registerHeystack` only initialises on the Node.js runtime — on the Edge runtime it's a no-op, so it's safe to call unconditionally.
50
+ `apiKey` defaults to `process.env.HEYSTACK_API_KEY`. `registerHeystack` is **runtime-aware** and safe to call unconditionally:
51
+
52
+ - **Vercel / Node deploys** → uses the Node OTel SDK (auto-instrumentations).
53
+ - **Cloudflare / OpenNext (workerd)** → Next still reports `NEXT_RUNTIME === "nodejs"`, but the code actually runs on workerd where the Node SDK's `node:http` OTLP exporter can't send (it initializes and silently exports nothing). `registerHeystack` detects workerd and registers the **fetch-based** exporter instead, so framework spans actually ship. No separate setup or import needed.
54
+ - **Edge runtime** → no-op (neither SDK can run there).
55
+
56
+ ## Standalone Cloudflare Workers
57
+
58
+ For a hand-written Worker (`export default { fetch }`), wrap the handler with `instrument()`:
59
+
60
+ ```ts
61
+ import { instrument } from "@heystack/otel/workers";
62
+
63
+ export default instrument(
64
+ {
65
+ async fetch(req, env, ctx) {
66
+ return new Response("ok");
67
+ },
68
+ },
69
+ { service: "my-worker" }, // apiKey defaults to env.HEYSTACK_API_KEY
70
+ );
71
+ ```
72
+
73
+ 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. Note: on workerd there is no async context manager, so cross-`await` parent→child context propagation is limited; spans still export, but automatic parent linking across `await` boundaries is not guaranteed.
74
+
75
+ `instrument()` must be the **outermost** wrapper if other middleware also wraps the handler, so the request span covers everything inside:
76
+
77
+ ```ts
78
+ export default instrument(withOtherMiddleware(worker), { service: "my-worker" });
79
+ ```
80
+
81
+ Set the key as a secret: `wrangler secret put HEYSTACK_API_KEY`.
82
+
83
+ ## Flushing
84
+
85
+ On Workers/edge the export is a `fetch()` POST, and the isolate can be torn down the instant your handler returns. **You must let that POST complete or the trace is silently dropped** — this is the #1 cause of flaky Workers tracing. `flushHeystack()` and `instrument()`'s built-in flush both await the in-flight fetch (not just the OTel span processor, which does *not* wait for it).
86
+
87
+ - **Standalone Workers (`instrument()`)** — flushes automatically. After the response it `ctx.waitUntil`s a promise that drains both the span processor and the exporter's in-flight fetch, so the POST finishes before the isolate is killed. No action needed.
88
+ - **Next on Cloudflare/OpenNext (`registerHeystack`)** — as of **0.3.0** this flushes automatically when `@opennextjs/cloudflare` is present: the export runs inside the Cloudflare request context, so the exporter borrows that request's `ctx.waitUntil` (via OpenNext's `getCloudflareContext`) to keep the isolate alive until the POST completes. **No manual hook needed.** For other workerd setups *without* `@opennextjs/cloudflare`, `import { flushHeystack } from "@heystack/otel/workers"` and call it from a response hook (or `ctx.waitUntil(flushHeystack())` if you have a ctx) — or pass an explicit `waitUntil` to `initHeystackWorkers` (highest priority). `flushHeystack()` awaits the export fetch.
89
+ - **Node (`initHeystack`)** — flushes on `SIGTERM`/`SIGINT` automatically.
49
90
 
50
- ## Workers / Edge
91
+ ## Migration / versioning
51
92
 
52
- Coming in `@heystack/otel/workers` — the Node SDK can't run on Workers/Edge, so a dedicated, fetch-based entry handles those runtimes.
93
+ - **Pin `@heystack/otel` `>=0.3.0`** 0.3.0 makes Next-on-OpenNext auto-flush via the Cloudflare request context, hardens workerd detection (uses the `WebSocketPair` global so it survives `nodejs_compat`), and has `instrument()` set the global provider so nested spans export. The workerd-aware `/next` path and `initHeystackWorkers` / `flushHeystack` exports were added in 0.2.0.
94
+ - The pre-0.1.0 top-level default `initHeystack({ apiKey })` is **gone**. Use the subpath entries: `@heystack/otel/node`, `@heystack/otel/next`, `@heystack/otel/workers`. The root `@heystack/otel` entry now exposes only pure helpers (`buildExporterConfig`, types).
53
95
 
54
96
  ## Verify it's working
55
97
 
package/dist/next.d.ts CHANGED
@@ -2,8 +2,22 @@ import type { HeystackOptions } from "./core.js";
2
2
  /**
3
3
  * Call from Next.js instrumentation.ts:
4
4
  * export function register() { registerHeystack({ service: "my-app" }); }
5
- * apiKey defaults to process.env.HEYSTACK_API_KEY. Only initialises on the Node.js
6
- * runtime — on Edge it's a no-op (the Node SDK can't run there).
5
+ * apiKey defaults to process.env.HEYSTACK_API_KEY.
6
+ *
7
+ * Runtime-aware: this runs on the Node.js runtime (NEXT_RUNTIME === "nodejs"),
8
+ * but that runtime can actually be Cloudflare workerd under OpenNext — where the
9
+ * Node SDK's node:http OTLP exporter silently sends nothing. We detect workerd
10
+ * and use the fetch-based exporter (@heystack/otel/workers) instead. On the Edge
11
+ * runtime neither SDK can run, so it's a no-op.
12
+ *
13
+ * FLUSH (workerd): there is no per-request `ExecutionContext` handed to this
14
+ * function, but on Next-on-OpenNext the export runs inside the Cloudflare
15
+ * request context, so the exporter automatically borrows that request's
16
+ * `ctx.waitUntil` (via `@opennextjs/cloudflare`'s `getCloudflareContext`) to
17
+ * keep the isolate alive until the export POST completes — no app hook needed.
18
+ * For other workerd setups without `@opennextjs/cloudflare`, import and call
19
+ * `flushHeystack()` from `@heystack/otel/workers` in a response hook (or via
20
+ * `ctx.waitUntil(flushHeystack())` if you have a ctx) — it awaits the export.
7
21
  */
8
22
  export declare function registerHeystack(o: Partial<HeystackOptions> & {
9
23
  service: string;
package/dist/next.js CHANGED
@@ -1,23 +1,55 @@
1
1
  /**
2
2
  * Call from Next.js instrumentation.ts:
3
3
  * export function register() { registerHeystack({ service: "my-app" }); }
4
- * apiKey defaults to process.env.HEYSTACK_API_KEY. Only initialises on the Node.js
5
- * runtime — on Edge it's a no-op (the Node SDK can't run there).
4
+ * apiKey defaults to process.env.HEYSTACK_API_KEY.
5
+ *
6
+ * Runtime-aware: this runs on the Node.js runtime (NEXT_RUNTIME === "nodejs"),
7
+ * but that runtime can actually be Cloudflare workerd under OpenNext — where the
8
+ * Node SDK's node:http OTLP exporter silently sends nothing. We detect workerd
9
+ * and use the fetch-based exporter (@heystack/otel/workers) instead. On the Edge
10
+ * runtime neither SDK can run, so it's a no-op.
11
+ *
12
+ * FLUSH (workerd): there is no per-request `ExecutionContext` handed to this
13
+ * function, but on Next-on-OpenNext the export runs inside the Cloudflare
14
+ * request context, so the exporter automatically borrows that request's
15
+ * `ctx.waitUntil` (via `@opennextjs/cloudflare`'s `getCloudflareContext`) to
16
+ * keep the isolate alive until the export POST completes — no app hook needed.
17
+ * For other workerd setups without `@opennextjs/cloudflare`, import and call
18
+ * `flushHeystack()` from `@heystack/otel/workers` in a response hook (or via
19
+ * `ctx.waitUntil(flushHeystack())` if you have a ctx) — it awaits the export.
6
20
  */
7
21
  export async function registerHeystack(o) {
8
- // Next sets NEXT_RUNTIME to "nodejs" | "edge"
9
- if (process.env.NEXT_RUNTIME !== "nodejs")
22
+ // Edge runtime can't run either SDK path — skip.
23
+ if (process.env.NEXT_RUNTIME === "edge")
10
24
  return;
11
25
  const apiKey = o.apiKey ?? process.env.HEYSTACK_API_KEY;
12
26
  if (!apiKey) {
13
27
  console.warn("[heystack] HEYSTACK_API_KEY not set — tracing disabled");
14
28
  return;
15
29
  }
16
- const { initHeystack } = await import("./node.js");
17
- initHeystack({
18
- apiKey,
19
- service: o.service,
20
- endpoint: o.endpoint,
21
- debug: o.debug,
22
- });
30
+ // Detect Cloudflare workerd (OpenNext / Workers). NEXT_RUNTIME is "nodejs" there,
31
+ // but the Node SDK's node:http exporter can't send — use the fetch exporter instead.
32
+ //
33
+ // The old heuristic combined `caches.default` with `!process.versions.node`,
34
+ // which is WRONG under `nodejs_compat`: that compat layer polyfills
35
+ // `process.versions.node`, so the `!` is false and we'd pick the Node path
36
+ // (which silently exports nothing). Use Cloudflare-only signals that survive
37
+ // nodejs_compat instead: the `Cloudflare-Workers` UA and the `WebSocketPair`
38
+ // global (a Workers-only global present even under nodejs_compat).
39
+ const g = globalThis;
40
+ const onWorkerd = g.navigator?.userAgent === "Cloudflare-Workers" ||
41
+ typeof g.WebSocketPair !== "undefined";
42
+ if (onWorkerd) {
43
+ const { initHeystackWorkers } = await import("./workers.js");
44
+ initHeystackWorkers({ apiKey, service: o.service, endpoint: o.endpoint });
45
+ }
46
+ else {
47
+ const { initHeystack } = await import("./node.js");
48
+ initHeystack({
49
+ apiKey,
50
+ service: o.service,
51
+ endpoint: o.endpoint,
52
+ debug: o.debug,
53
+ });
54
+ }
23
55
  }
package/dist/workers.d.ts CHANGED
@@ -61,9 +61,33 @@ export declare class HeystackSpanExporter implements SpanExporter {
61
61
  private readonly url;
62
62
  private readonly headers;
63
63
  private shutdownState;
64
+ /**
65
+ * In-flight export fetches. Each `export()` adds its settled-when-the-POST-
66
+ * completes promise here and removes it on `finally`. `forceFlush()` awaits
67
+ * this set so callers (and `ctx.waitUntil`) genuinely wait for the network
68
+ * write before the isolate is torn down — otherwise traces are silently
69
+ * dropped on fast-responding Workers/edge handlers.
70
+ */
71
+ private readonly pending;
72
+ /**
73
+ * Optional `waitUntil` hook. When set, each in-flight export `fetch` is also
74
+ * handed to it so the runtime keeps the isolate alive until the POST
75
+ * completes — without any per-request hook in app code. This is the reliable
76
+ * delivery path on Next-on-OpenNext, where there is no `ExecutionContext`
77
+ * passed to `registerHeystack` but the export DOES run inside a Cloudflare
78
+ * request context whose `ctx.waitUntil` we can borrow. Falls back silently to
79
+ * the `pending` set + manual `flushHeystack()` when absent/unavailable.
80
+ */
81
+ waitUntil?: (p: Promise<unknown>) => void;
64
82
  constructor(options: HeystackOptions);
65
83
  export(spans: ReadableSpan[], resultCallback: (result: ExportResult) => void): void;
66
84
  shutdown(): Promise<void>;
85
+ /**
86
+ * Resolve only once every in-flight export fetch has settled. This is the
87
+ * guaranteed drain path: the OTel `SimpleSpanProcessor.forceFlush()` does not
88
+ * await our fire-and-forget fetch, so callers must await this directly (see
89
+ * `flushHeystack` and `instrument`).
90
+ */
67
91
  forceFlush(): Promise<void>;
68
92
  }
69
93
  export interface WorkersConfig {
@@ -71,16 +95,61 @@ export interface WorkersConfig {
71
95
  /** Defaults to env.HEYSTACK_API_KEY at request time if omitted. */
72
96
  apiKey?: string;
73
97
  endpoint?: string;
98
+ /**
99
+ * Optional override to keep the isolate alive until each export `fetch`
100
+ * completes. When provided this takes priority over the auto-detected
101
+ * OpenNext Cloudflare request context. Typically `ctx.waitUntil` from your
102
+ * Worker's `ExecutionContext`. If omitted on Next-on-OpenNext, the exporter
103
+ * automatically borrows the request context's `ctx.waitUntil`.
104
+ */
105
+ waitUntil?: (p: Promise<unknown>) => void;
74
106
  }
107
+ /**
108
+ * A `BasicTracerProvider` with the underlying `HeystackSpanExporter` attached so
109
+ * flush paths can drain its in-flight fetches directly (the OTel processor's
110
+ * `forceFlush()` does not await our fire-and-forget fetch).
111
+ */
112
+ export type HeystackTracerProvider = BasicTracerProvider & {
113
+ readonly heystackExporter: HeystackSpanExporter;
114
+ };
75
115
  /**
76
116
  * Build a `BasicTracerProvider` wired to a `HeystackSpanExporter` via a
77
117
  * `SimpleSpanProcessor`. Exposed for advanced users who want to add manual
78
118
  * spans; for typical use prefer {@link instrument}.
79
119
  *
80
120
  * On Workers each request should get its own short-lived provider so its spans
81
- * can be flushed via `ctx.waitUntil(provider.forceFlush())`.
121
+ * can be flushed. IMPORTANT: to actually wait for the export network write you
122
+ * must drain the exporter — `await provider.heystackExporter.forceFlush()` (or
123
+ * `flushHeystack()`), not just `provider.forceFlush()`.
124
+ */
125
+ export declare function createTracerProvider(config: HeystackOptions): HeystackTracerProvider;
126
+ /**
127
+ * Register Heystack as the global tracer provider on a Workers/edge runtime
128
+ * (workerd). Spans from the host framework (e.g. Next.js) export over fetch.
129
+ * Use this instead of @heystack/otel/node when running on Cloudflare/edge.
130
+ *
131
+ * FLUSH: on Next-on-OpenNext (where `@opennextjs/cloudflare` is present) the
132
+ * exporter automatically borrows the Cloudflare request context's
133
+ * `ctx.waitUntil` so the export POST completes before the isolate is torn down
134
+ * — no app hook needed. For other workerd setups without it, call
135
+ * `flushHeystack()` (which awaits the export fetch) from a response hook, hand
136
+ * it to `ctx.waitUntil(flushHeystack())`, or pass an explicit `waitUntil` in
137
+ * the config (highest priority). Returns the provider.
138
+ */
139
+ export declare function initHeystackWorkers(config: WorkersConfig & {
140
+ apiKey: string;
141
+ }): HeystackTracerProvider;
142
+ /**
143
+ * Force-flush any pending spans AND wait for the export network write to
144
+ * complete. This awaits both the OTel provider's `forceFlush()` (drains the
145
+ * span processor) and the exporter's own pending-fetch set (the part the OTel
146
+ * processor does not await) — so on workerd you can hand this to
147
+ * `ctx.waitUntil(flushHeystack())` and the POST genuinely finishes before the
148
+ * isolate is torn down. Best-effort no-op if no provider is registered.
82
149
  */
83
- export declare function createTracerProvider(config: HeystackOptions): BasicTracerProvider;
150
+ export declare function flushHeystack(): Promise<void>;
151
+ /** Reset the singleton global provider. Internal/testing helper. */
152
+ export declare function __resetProvider(): void;
84
153
  /** Reset the once-only no-key warning. Internal/testing helper. */
85
154
  export declare function __resetWarnings(): void;
86
155
  interface FetchHandler<E> {
@@ -88,8 +157,13 @@ interface FetchHandler<E> {
88
157
  }
89
158
  /**
90
159
  * Wrap a Worker's default export so every request is auto-traced with a SERVER
91
- * span. The export is flushed via `ctx.waitUntil` so it completes after the
92
- * response is returned.
160
+ * span.
161
+ *
162
+ * FLUSH (CRITICAL on Workers/edge): the export is a `fetch()` POST. After
163
+ * `span.end()` we `ctx.waitUntil` a promise that awaits BOTH the provider's
164
+ * span processor AND the exporter's in-flight fetch, so the network write
165
+ * completes before the isolate is torn down. Without this, fast-responding
166
+ * handlers return before the POST finishes and the trace is silently dropped.
93
167
  *
94
168
  * import { instrument } from "@heystack/otel/workers";
95
169
  * export default instrument(
@@ -97,6 +171,12 @@ interface FetchHandler<E> {
97
171
  * { service: "my-worker" },
98
172
  * );
99
173
  *
174
+ * TRACE TREE: `instrument()` sets up the singleton GLOBAL tracer provider and
175
+ * creates the per-request SERVER span via the global tracer
176
+ * (`trace.getTracer("heystack")`). This means nested spans created through the
177
+ * global `trace.getTracer()` API (framework / library / your own manual spans)
178
+ * also flow to the exporter — you get a trace tree, not a lone SERVER span.
179
+ *
100
180
  * If no API key is available (neither `config.apiKey` nor
101
181
  * `env.HEYSTACK_API_KEY`), the handler runs untraced.
102
182
  */
package/dist/workers.js CHANGED
@@ -17,6 +17,22 @@ import { buildExporterConfig } from "./core.js";
17
17
  // transitive dep of sdk-trace-base and isn't reliably resolvable, and keeping it
18
18
  // out guarantees no extra (potentially node-platform) code in the bundle.
19
19
  const ExportResultCode = { SUCCESS: 0, FAILED: 1 };
20
+ let _getCloudflareContext;
21
+ let _cfAccessorLoaded = false;
22
+ /** Best-effort, once-only resolve of OpenNext's `getCloudflareContext`. */
23
+ async function loadCloudflareContextAccessor() {
24
+ if (_cfAccessorLoaded)
25
+ return;
26
+ _cfAccessorLoaded = true;
27
+ try {
28
+ const spec = "@opennextjs/cloudflare";
29
+ const m = (await import(/* @vite-ignore */ spec));
30
+ _getCloudflareContext = m.getCloudflareContext;
31
+ }
32
+ catch {
33
+ // Not on OpenNext (or the package isn't installed) — fall back silently.
34
+ }
35
+ }
20
36
  /** Convert an OTel HrTime `[seconds, nanos]` tuple to a nanosecond string. */
21
37
  function hrTimeToUnixNano(time) {
22
38
  // BigInt math keeps full nanosecond precision without float rounding.
@@ -115,6 +131,24 @@ export class HeystackSpanExporter {
115
131
  url;
116
132
  headers;
117
133
  shutdownState = false;
134
+ /**
135
+ * In-flight export fetches. Each `export()` adds its settled-when-the-POST-
136
+ * completes promise here and removes it on `finally`. `forceFlush()` awaits
137
+ * this set so callers (and `ctx.waitUntil`) genuinely wait for the network
138
+ * write before the isolate is torn down — otherwise traces are silently
139
+ * dropped on fast-responding Workers/edge handlers.
140
+ */
141
+ pending = new Set();
142
+ /**
143
+ * Optional `waitUntil` hook. When set, each in-flight export `fetch` is also
144
+ * handed to it so the runtime keeps the isolate alive until the POST
145
+ * completes — without any per-request hook in app code. This is the reliable
146
+ * delivery path on Next-on-OpenNext, where there is no `ExecutionContext`
147
+ * passed to `registerHeystack` but the export DOES run inside a Cloudflare
148
+ * request context whose `ctx.waitUntil` we can borrow. Falls back silently to
149
+ * the `pending` set + manual `flushHeystack()` when absent/unavailable.
150
+ */
151
+ waitUntil;
118
152
  constructor(options) {
119
153
  const cfg = buildExporterConfig(options);
120
154
  this.url = cfg.url;
@@ -136,7 +170,10 @@ export class HeystackSpanExporter {
136
170
  return;
137
171
  }
138
172
  const body = JSON.stringify(serializeSpans(spans));
139
- fetch(this.url, { method: "POST", headers: this.headers, body })
173
+ // Build the fetch chain as a promise we retain, so forceFlush() can await
174
+ // the actual network write. It resolves (never rejects) once the POST has
175
+ // completed (success or fail) and resultCallback has been invoked.
176
+ const p = fetch(this.url, { method: "POST", headers: this.headers, body })
140
177
  .then((res) => {
141
178
  if (res.ok) {
142
179
  resultCallback({ code: ExportResultCode.SUCCESS });
@@ -154,13 +191,41 @@ export class HeystackSpanExporter {
154
191
  error: error instanceof Error ? error : new Error(String(error)),
155
192
  });
156
193
  });
194
+ this.pending.add(p);
195
+ p.finally(() => this.pending.delete(p));
196
+ // Keep the isolate alive until the POST completes. Priority:
197
+ // 1. an explicit `waitUntil` override (set via initHeystackWorkers).
198
+ // 2. the OpenNext Cloudflare request context's `ctx.waitUntil` — the
199
+ // export runs during request handling, so this is available there.
200
+ // Either path makes delivery reliable on workerd/OpenNext with no app hook.
201
+ // The `pending` set + `flushHeystack()` remain as the explicit fallback.
202
+ try {
203
+ if (this.waitUntil) {
204
+ this.waitUntil(p);
205
+ }
206
+ else if (_getCloudflareContext) {
207
+ const cf = _getCloudflareContext();
208
+ cf?.ctx?.waitUntil?.(p);
209
+ }
210
+ }
211
+ catch {
212
+ // Not inside a request context (or unavailable) — rely on `pending` +
213
+ // manual flush. Never let this break the export.
214
+ }
157
215
  }
158
216
  shutdown() {
159
- this.shutdownState = true;
160
- return Promise.resolve();
217
+ return this.forceFlush().then(() => {
218
+ this.shutdownState = true;
219
+ });
161
220
  }
162
- forceFlush() {
163
- return Promise.resolve();
221
+ /**
222
+ * Resolve only once every in-flight export fetch has settled. This is the
223
+ * guaranteed drain path: the OTel `SimpleSpanProcessor.forceFlush()` does not
224
+ * await our fire-and-forget fetch, so callers must await this directly (see
225
+ * `flushHeystack` and `instrument`).
226
+ */
227
+ async forceFlush() {
228
+ await Promise.allSettled([...this.pending]);
164
229
  }
165
230
  }
166
231
  /**
@@ -169,14 +234,84 @@ export class HeystackSpanExporter {
169
234
  * spans; for typical use prefer {@link instrument}.
170
235
  *
171
236
  * On Workers each request should get its own short-lived provider so its spans
172
- * can be flushed via `ctx.waitUntil(provider.forceFlush())`.
237
+ * can be flushed. IMPORTANT: to actually wait for the export network write you
238
+ * must drain the exporter — `await provider.heystackExporter.forceFlush()` (or
239
+ * `flushHeystack()`), not just `provider.forceFlush()`.
173
240
  */
174
241
  export function createTracerProvider(config) {
175
242
  const exporter = new HeystackSpanExporter(config);
176
- return new BasicTracerProvider({
243
+ const provider = new BasicTracerProvider({
177
244
  resource: new Resource({ [ATTR_SERVICE_NAME]: config.service }),
178
245
  spanProcessors: [new SimpleSpanProcessor(exporter)],
179
246
  });
247
+ // Attach the exporter so flush paths can await its in-flight fetches.
248
+ return Object.assign(provider, { heystackExporter: exporter });
249
+ }
250
+ // ---------------------------------------------------------------------------
251
+ // Global tracer provider registration (for host frameworks, e.g. Next.js)
252
+ // ---------------------------------------------------------------------------
253
+ let _provider = null;
254
+ /**
255
+ * Build (once) and register the singleton global tracer provider. Wires the
256
+ * exporter's `waitUntil` (explicit override > nothing here; the auto-detected
257
+ * OpenNext context is resolved lazily inside the exporter) and kicks off the
258
+ * best-effort load of OpenNext's `getCloudflareContext` accessor so the
259
+ * exporter can borrow `ctx.waitUntil` during request handling. Shared by
260
+ * `initHeystackWorkers` and `instrument`.
261
+ */
262
+ function ensureGlobalProvider(config) {
263
+ if (_provider)
264
+ return _provider;
265
+ _provider = createTracerProvider(config);
266
+ if (config.waitUntil)
267
+ _provider.heystackExporter.waitUntil = config.waitUntil;
268
+ // Best-effort: resolve OpenNext's context accessor so the exporter can borrow
269
+ // `ctx.waitUntil` during request handling. Guarded; fails closed when absent.
270
+ void loadCloudflareContextAccessor();
271
+ // Register as the global provider so framework / global-API spans flow to our
272
+ // exporter. We set it directly (rather than provider.register()) so it's
273
+ // deterministic and doesn't pull in a context manager that may not run on
274
+ // workerd.
275
+ trace.setGlobalTracerProvider(_provider);
276
+ return _provider;
277
+ }
278
+ /**
279
+ * Register Heystack as the global tracer provider on a Workers/edge runtime
280
+ * (workerd). Spans from the host framework (e.g. Next.js) export over fetch.
281
+ * Use this instead of @heystack/otel/node when running on Cloudflare/edge.
282
+ *
283
+ * FLUSH: on Next-on-OpenNext (where `@opennextjs/cloudflare` is present) the
284
+ * exporter automatically borrows the Cloudflare request context's
285
+ * `ctx.waitUntil` so the export POST completes before the isolate is torn down
286
+ * — no app hook needed. For other workerd setups without it, call
287
+ * `flushHeystack()` (which awaits the export fetch) from a response hook, hand
288
+ * it to `ctx.waitUntil(flushHeystack())`, or pass an explicit `waitUntil` in
289
+ * the config (highest priority). Returns the provider.
290
+ */
291
+ export function initHeystackWorkers(config) {
292
+ return ensureGlobalProvider(config);
293
+ }
294
+ /**
295
+ * Force-flush any pending spans AND wait for the export network write to
296
+ * complete. This awaits both the OTel provider's `forceFlush()` (drains the
297
+ * span processor) and the exporter's own pending-fetch set (the part the OTel
298
+ * processor does not await) — so on workerd you can hand this to
299
+ * `ctx.waitUntil(flushHeystack())` and the POST genuinely finishes before the
300
+ * isolate is torn down. Best-effort no-op if no provider is registered.
301
+ */
302
+ export async function flushHeystack() {
303
+ if (!_provider)
304
+ return;
305
+ if (typeof _provider.forceFlush === "function") {
306
+ await _provider.forceFlush();
307
+ }
308
+ // The processor's forceFlush does not await our fire-and-forget fetch, so
309
+ // drain the exporter's in-flight POSTs explicitly.
310
+ await _provider.heystackExporter.forceFlush();
311
+ }
312
+ /** Reset the singleton global provider. Internal/testing helper. */
313
+ export function __resetProvider() {
314
+ _provider = null;
180
315
  }
181
316
  let warnedNoKey = false;
182
317
  function warnOnceNoKey() {
@@ -191,8 +326,13 @@ export function __resetWarnings() {
191
326
  }
192
327
  /**
193
328
  * Wrap a Worker's default export so every request is auto-traced with a SERVER
194
- * span. The export is flushed via `ctx.waitUntil` so it completes after the
195
- * response is returned.
329
+ * span.
330
+ *
331
+ * FLUSH (CRITICAL on Workers/edge): the export is a `fetch()` POST. After
332
+ * `span.end()` we `ctx.waitUntil` a promise that awaits BOTH the provider's
333
+ * span processor AND the exporter's in-flight fetch, so the network write
334
+ * completes before the isolate is torn down. Without this, fast-responding
335
+ * handlers return before the POST finishes and the trace is silently dropped.
196
336
  *
197
337
  * import { instrument } from "@heystack/otel/workers";
198
338
  * export default instrument(
@@ -200,6 +340,12 @@ export function __resetWarnings() {
200
340
  * { service: "my-worker" },
201
341
  * );
202
342
  *
343
+ * TRACE TREE: `instrument()` sets up the singleton GLOBAL tracer provider and
344
+ * creates the per-request SERVER span via the global tracer
345
+ * (`trace.getTracer("heystack")`). This means nested spans created through the
346
+ * global `trace.getTracer()` API (framework / library / your own manual spans)
347
+ * also flow to the exporter — you get a trace tree, not a lone SERVER span.
348
+ *
203
349
  * If no API key is available (neither `config.apiKey` nor
204
350
  * `env.HEYSTACK_API_KEY`), the handler runs untraced.
205
351
  */
@@ -212,12 +358,18 @@ export function instrument(handler, config) {
212
358
  warnOnceNoKey();
213
359
  return handler.fetch(req, env, ctx);
214
360
  }
215
- const provider = createTracerProvider({
361
+ // Set up (once) the global provider so spans created via the global
362
+ // `trace.getTracer()` API — nested framework/library/manual spans —
363
+ // export too, yielding a trace tree rather than a lone SERVER span.
364
+ const provider = ensureGlobalProvider({
216
365
  apiKey,
217
366
  service: config.service,
218
367
  endpoint: config.endpoint,
368
+ waitUntil: config.waitUntil,
219
369
  });
220
- const tracer = provider.getTracer("@heystack/otel/workers");
370
+ // Create the SERVER span via the GLOBAL tracer so it shares the provider
371
+ // (and context) used by nested global spans.
372
+ const tracer = trace.getTracer("heystack");
221
373
  const url = new URL(req.url);
222
374
  const span = tracer.startSpan(`${req.method} ${url.pathname}`, {
223
375
  kind: SpanKind.SERVER,
@@ -228,7 +380,14 @@ export function instrument(handler, config) {
228
380
  "server.address": url.host,
229
381
  },
230
382
  });
231
- const flush = () => ctx.waitUntil(provider.forceFlush());
383
+ // waitUntil a promise that drains BOTH the provider's span processor and
384
+ // the exporter's in-flight fetch. Awaiting only provider.forceFlush()
385
+ // would return before the export POST completes, letting the isolate be
386
+ // torn down and silently dropping the trace.
387
+ const flush = () => ctx.waitUntil((async () => {
388
+ await provider.forceFlush().catch(() => { });
389
+ await provider.heystackExporter.forceFlush().catch(() => { });
390
+ })());
232
391
  try {
233
392
  const response = await context.with(trace.setSpan(context.active(), span), () => handler.fetch(req, env, ctx));
234
393
  span.setAttribute("http.response.status_code", response.status);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@heystack/otel",
3
- "version": "0.1.0",
3
+ "version": "0.3.0",
4
4
  "description": "Runtime-aware OpenTelemetry tracing that exports to Heystack (Node, Next.js, Workers).",
5
5
  "license": "MIT",
6
6
  "type": "module",