@heystack/otel 0.1.0 → 0.2.1

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.2.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,49 @@ 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
+ `instrument()` must be the **outermost** wrapper if other middleware also wraps the handler, so the request span covers everything inside:
74
+
75
+ ```ts
76
+ export default instrument(withOtherMiddleware(worker), { service: "my-worker" });
77
+ ```
78
+
79
+ Set the key as a secret: `wrangler secret put HEYSTACK_API_KEY`.
80
+
81
+ ## Flushing
82
+
83
+ 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).
84
+
85
+ - **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.
86
+ - **Next on workerd (`registerHeystack`)** — there's no per-request `ExecutionContext`, so spans are **not** auto-flushed. For guaranteed delivery, `import { flushHeystack } from "@heystack/otel/workers"` and call it from a response hook (or `ctx.waitUntil(flushHeystack())` if you have a ctx) after handling a request. `flushHeystack()` awaits the export fetch.
87
+ - **Node (`initHeystack`)** — flushes on `SIGTERM`/`SIGINT` automatically.
49
88
 
50
- ## Workers / Edge
89
+ ## Migration / versioning
51
90
 
52
- Coming in `@heystack/otel/workers` — the Node SDK can't run on Workers/Edge, so a dedicated, fetch-based entry handles those runtimes.
91
+ - **Pin `@heystack/otel` `>=0.2.0`** — the workerd-aware `/next` path and `initHeystackWorkers` / `flushHeystack` exports were added in 0.2.0.
92
+ - 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
93
 
54
94
  ## Verify it's working
55
95
 
package/dist/next.d.ts CHANGED
@@ -2,8 +2,19 @@ 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` on this path, so
14
+ * spans are NOT auto-flushed and the export fetch may be cut off when the
15
+ * isolate is torn down. For guaranteed delivery, import and call
16
+ * `flushHeystack()` from `@heystack/otel/workers` in a response hook (or via
17
+ * `ctx.waitUntil(flushHeystack())` if you have a ctx) — it awaits the export.
7
18
  */
8
19
  export declare function registerHeystack(o: Partial<HeystackOptions> & {
9
20
  service: string;
package/dist/next.js CHANGED
@@ -1,23 +1,45 @@
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` on this path, so
13
+ * spans are NOT auto-flushed and the export fetch may be cut off when the
14
+ * isolate is torn down. For guaranteed delivery, import and call
15
+ * `flushHeystack()` from `@heystack/otel/workers` in a response hook (or via
16
+ * `ctx.waitUntil(flushHeystack())` if you have a ctx) — it awaits the export.
6
17
  */
7
18
  export async function registerHeystack(o) {
8
- // Next sets NEXT_RUNTIME to "nodejs" | "edge"
9
- if (process.env.NEXT_RUNTIME !== "nodejs")
19
+ // Edge runtime can't run either SDK path — skip.
20
+ if (process.env.NEXT_RUNTIME === "edge")
10
21
  return;
11
22
  const apiKey = o.apiKey ?? process.env.HEYSTACK_API_KEY;
12
23
  if (!apiKey) {
13
24
  console.warn("[heystack] HEYSTACK_API_KEY not set — tracing disabled");
14
25
  return;
15
26
  }
16
- const { initHeystack } = await import("./node.js");
17
- initHeystack({
18
- apiKey,
19
- service: o.service,
20
- endpoint: o.endpoint,
21
- debug: o.debug,
22
- });
27
+ // Detect Cloudflare workerd (OpenNext / Workers). NEXT_RUNTIME is "nodejs" there,
28
+ // but the Node SDK's node:http exporter can't send — use the fetch exporter instead.
29
+ const g = globalThis;
30
+ const onWorkerd = g.navigator?.userAgent === "Cloudflare-Workers" ||
31
+ (typeof g.caches?.default !== "undefined" && !g.process?.versions?.node);
32
+ if (onWorkerd) {
33
+ const { initHeystackWorkers } = await import("./workers.js");
34
+ initHeystackWorkers({ apiKey, service: o.service, endpoint: o.endpoint });
35
+ }
36
+ else {
37
+ const { initHeystack } = await import("./node.js");
38
+ initHeystack({
39
+ apiKey,
40
+ service: o.service,
41
+ endpoint: o.endpoint,
42
+ debug: o.debug,
43
+ });
44
+ }
23
45
  }
package/dist/workers.d.ts CHANGED
@@ -61,9 +61,23 @@ 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;
64
72
  constructor(options: HeystackOptions);
65
73
  export(spans: ReadableSpan[], resultCallback: (result: ExportResult) => void): void;
66
74
  shutdown(): Promise<void>;
75
+ /**
76
+ * Resolve only once every in-flight export fetch has settled. This is the
77
+ * guaranteed drain path: the OTel `SimpleSpanProcessor.forceFlush()` does not
78
+ * await our fire-and-forget fetch, so callers must await this directly (see
79
+ * `flushHeystack` and `instrument`).
80
+ */
67
81
  forceFlush(): Promise<void>;
68
82
  }
69
83
  export interface WorkersConfig {
@@ -72,15 +86,49 @@ export interface WorkersConfig {
72
86
  apiKey?: string;
73
87
  endpoint?: string;
74
88
  }
89
+ /**
90
+ * A `BasicTracerProvider` with the underlying `HeystackSpanExporter` attached so
91
+ * flush paths can drain its in-flight fetches directly (the OTel processor's
92
+ * `forceFlush()` does not await our fire-and-forget fetch).
93
+ */
94
+ export type HeystackTracerProvider = BasicTracerProvider & {
95
+ readonly heystackExporter: HeystackSpanExporter;
96
+ };
75
97
  /**
76
98
  * Build a `BasicTracerProvider` wired to a `HeystackSpanExporter` via a
77
99
  * `SimpleSpanProcessor`. Exposed for advanced users who want to add manual
78
100
  * spans; for typical use prefer {@link instrument}.
79
101
  *
80
102
  * On Workers each request should get its own short-lived provider so its spans
81
- * can be flushed via `ctx.waitUntil(provider.forceFlush())`.
103
+ * can be flushed. IMPORTANT: to actually wait for the export network write you
104
+ * must drain the exporter — `await provider.heystackExporter.forceFlush()` (or
105
+ * `flushHeystack()`), not just `provider.forceFlush()`.
82
106
  */
83
- export declare function createTracerProvider(config: HeystackOptions): BasicTracerProvider;
107
+ export declare function createTracerProvider(config: HeystackOptions): HeystackTracerProvider;
108
+ /**
109
+ * Register Heystack as the global tracer provider on a Workers/edge runtime
110
+ * (workerd). Spans from the host framework (e.g. Next.js) export over fetch.
111
+ * Use this instead of @heystack/otel/node when running on Cloudflare/edge.
112
+ *
113
+ * FLUSH: there is no per-request `ExecutionContext` on this path, so spans are
114
+ * NOT auto-flushed. To guarantee delivery on workerd, call `flushHeystack()`
115
+ * (which awaits the export fetch) from a response hook — or hand it to
116
+ * `ctx.waitUntil(flushHeystack())` if you do have a ctx. Returns the provider.
117
+ */
118
+ export declare function initHeystackWorkers(config: WorkersConfig & {
119
+ apiKey: string;
120
+ }): HeystackTracerProvider;
121
+ /**
122
+ * Force-flush any pending spans AND wait for the export network write to
123
+ * complete. This awaits both the OTel provider's `forceFlush()` (drains the
124
+ * span processor) and the exporter's own pending-fetch set (the part the OTel
125
+ * processor does not await) — so on workerd you can hand this to
126
+ * `ctx.waitUntil(flushHeystack())` and the POST genuinely finishes before the
127
+ * isolate is torn down. Best-effort no-op if no provider is registered.
128
+ */
129
+ export declare function flushHeystack(): Promise<void>;
130
+ /** Reset the singleton global provider. Internal/testing helper. */
131
+ export declare function __resetProvider(): void;
84
132
  /** Reset the once-only no-key warning. Internal/testing helper. */
85
133
  export declare function __resetWarnings(): void;
86
134
  interface FetchHandler<E> {
@@ -88,8 +136,13 @@ interface FetchHandler<E> {
88
136
  }
89
137
  /**
90
138
  * 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.
139
+ * span.
140
+ *
141
+ * FLUSH (CRITICAL on Workers/edge): the export is a `fetch()` POST. After
142
+ * `span.end()` we `ctx.waitUntil` a promise that awaits BOTH the provider's
143
+ * span processor AND the exporter's in-flight fetch, so the network write
144
+ * completes before the isolate is torn down. Without this, fast-responding
145
+ * handlers return before the POST finishes and the trace is silently dropped.
93
146
  *
94
147
  * import { instrument } from "@heystack/otel/workers";
95
148
  * export default instrument(
package/dist/workers.js CHANGED
@@ -115,6 +115,14 @@ export class HeystackSpanExporter {
115
115
  url;
116
116
  headers;
117
117
  shutdownState = false;
118
+ /**
119
+ * In-flight export fetches. Each `export()` adds its settled-when-the-POST-
120
+ * completes promise here and removes it on `finally`. `forceFlush()` awaits
121
+ * this set so callers (and `ctx.waitUntil`) genuinely wait for the network
122
+ * write before the isolate is torn down — otherwise traces are silently
123
+ * dropped on fast-responding Workers/edge handlers.
124
+ */
125
+ pending = new Set();
118
126
  constructor(options) {
119
127
  const cfg = buildExporterConfig(options);
120
128
  this.url = cfg.url;
@@ -136,7 +144,10 @@ export class HeystackSpanExporter {
136
144
  return;
137
145
  }
138
146
  const body = JSON.stringify(serializeSpans(spans));
139
- fetch(this.url, { method: "POST", headers: this.headers, body })
147
+ // Build the fetch chain as a promise we retain, so forceFlush() can await
148
+ // the actual network write. It resolves (never rejects) once the POST has
149
+ // completed (success or fail) and resultCallback has been invoked.
150
+ const p = fetch(this.url, { method: "POST", headers: this.headers, body })
140
151
  .then((res) => {
141
152
  if (res.ok) {
142
153
  resultCallback({ code: ExportResultCode.SUCCESS });
@@ -154,13 +165,22 @@ export class HeystackSpanExporter {
154
165
  error: error instanceof Error ? error : new Error(String(error)),
155
166
  });
156
167
  });
168
+ this.pending.add(p);
169
+ p.finally(() => this.pending.delete(p));
157
170
  }
158
171
  shutdown() {
159
- this.shutdownState = true;
160
- return Promise.resolve();
172
+ return this.forceFlush().then(() => {
173
+ this.shutdownState = true;
174
+ });
161
175
  }
162
- forceFlush() {
163
- return Promise.resolve();
176
+ /**
177
+ * Resolve only once every in-flight export fetch has settled. This is the
178
+ * guaranteed drain path: the OTel `SimpleSpanProcessor.forceFlush()` does not
179
+ * await our fire-and-forget fetch, so callers must await this directly (see
180
+ * `flushHeystack` and `instrument`).
181
+ */
182
+ async forceFlush() {
183
+ await Promise.allSettled([...this.pending]);
164
184
  }
165
185
  }
166
186
  /**
@@ -169,14 +189,65 @@ export class HeystackSpanExporter {
169
189
  * spans; for typical use prefer {@link instrument}.
170
190
  *
171
191
  * On Workers each request should get its own short-lived provider so its spans
172
- * can be flushed via `ctx.waitUntil(provider.forceFlush())`.
192
+ * can be flushed. IMPORTANT: to actually wait for the export network write you
193
+ * must drain the exporter — `await provider.heystackExporter.forceFlush()` (or
194
+ * `flushHeystack()`), not just `provider.forceFlush()`.
173
195
  */
174
196
  export function createTracerProvider(config) {
175
197
  const exporter = new HeystackSpanExporter(config);
176
- return new BasicTracerProvider({
198
+ const provider = new BasicTracerProvider({
177
199
  resource: new Resource({ [ATTR_SERVICE_NAME]: config.service }),
178
200
  spanProcessors: [new SimpleSpanProcessor(exporter)],
179
201
  });
202
+ // Attach the exporter so flush paths can await its in-flight fetches.
203
+ return Object.assign(provider, { heystackExporter: exporter });
204
+ }
205
+ // ---------------------------------------------------------------------------
206
+ // Global tracer provider registration (for host frameworks, e.g. Next.js)
207
+ // ---------------------------------------------------------------------------
208
+ let _provider = null;
209
+ /**
210
+ * Register Heystack as the global tracer provider on a Workers/edge runtime
211
+ * (workerd). Spans from the host framework (e.g. Next.js) export over fetch.
212
+ * Use this instead of @heystack/otel/node when running on Cloudflare/edge.
213
+ *
214
+ * FLUSH: there is no per-request `ExecutionContext` on this path, so spans are
215
+ * NOT auto-flushed. To guarantee delivery on workerd, call `flushHeystack()`
216
+ * (which awaits the export fetch) from a response hook — or hand it to
217
+ * `ctx.waitUntil(flushHeystack())` if you do have a ctx. Returns the provider.
218
+ */
219
+ export function initHeystackWorkers(config) {
220
+ if (_provider)
221
+ return _provider;
222
+ _provider = createTracerProvider(config);
223
+ // register as the global provider so framework spans flow to our exporter.
224
+ // BasicTracerProvider.register() also wires context/propagation, but on
225
+ // workerd we only need the global tracer provider — set it directly so it's
226
+ // deterministic and doesn't pull in a context manager that may not run here.
227
+ trace.setGlobalTracerProvider(_provider);
228
+ return _provider;
229
+ }
230
+ /**
231
+ * Force-flush any pending spans AND wait for the export network write to
232
+ * complete. This awaits both the OTel provider's `forceFlush()` (drains the
233
+ * span processor) and the exporter's own pending-fetch set (the part the OTel
234
+ * processor does not await) — so on workerd you can hand this to
235
+ * `ctx.waitUntil(flushHeystack())` and the POST genuinely finishes before the
236
+ * isolate is torn down. Best-effort no-op if no provider is registered.
237
+ */
238
+ export async function flushHeystack() {
239
+ if (!_provider)
240
+ return;
241
+ if (typeof _provider.forceFlush === "function") {
242
+ await _provider.forceFlush();
243
+ }
244
+ // The processor's forceFlush does not await our fire-and-forget fetch, so
245
+ // drain the exporter's in-flight POSTs explicitly.
246
+ await _provider.heystackExporter.forceFlush();
247
+ }
248
+ /** Reset the singleton global provider. Internal/testing helper. */
249
+ export function __resetProvider() {
250
+ _provider = null;
180
251
  }
181
252
  let warnedNoKey = false;
182
253
  function warnOnceNoKey() {
@@ -191,8 +262,13 @@ export function __resetWarnings() {
191
262
  }
192
263
  /**
193
264
  * 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.
265
+ * span.
266
+ *
267
+ * FLUSH (CRITICAL on Workers/edge): the export is a `fetch()` POST. After
268
+ * `span.end()` we `ctx.waitUntil` a promise that awaits BOTH the provider's
269
+ * span processor AND the exporter's in-flight fetch, so the network write
270
+ * completes before the isolate is torn down. Without this, fast-responding
271
+ * handlers return before the POST finishes and the trace is silently dropped.
196
272
  *
197
273
  * import { instrument } from "@heystack/otel/workers";
198
274
  * export default instrument(
@@ -228,7 +304,14 @@ export function instrument(handler, config) {
228
304
  "server.address": url.host,
229
305
  },
230
306
  });
231
- const flush = () => ctx.waitUntil(provider.forceFlush());
307
+ // waitUntil a promise that drains BOTH the provider's span processor and
308
+ // the exporter's in-flight fetch. Awaiting only provider.forceFlush()
309
+ // would return before the export POST completes, letting the isolate be
310
+ // torn down and silently dropping the trace.
311
+ const flush = () => ctx.waitUntil((async () => {
312
+ await provider.forceFlush().catch(() => { });
313
+ await provider.heystackExporter.forceFlush().catch(() => { });
314
+ })());
232
315
  try {
233
316
  const response = await context.with(trace.setSpan(context.active(), span), () => handler.fetch(req, env, ctx));
234
317
  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.2.1",
4
4
  "description": "Runtime-aware OpenTelemetry tracing that exports to Heystack (Node, Next.js, Workers).",
5
5
  "license": "MIT",
6
6
  "type": "module",