@heystack/otel 0.2.1 → 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,7 +13,7 @@ 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.
16
+ > **Requires `@heystack/otel` `>=0.3.0`.** See [Migration](#migration--versioning) below.
17
17
 
18
18
  ## Runtime matrix
19
19
 
@@ -70,6 +70,8 @@ export default instrument(
70
70
  );
71
71
  ```
72
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
+
73
75
  `instrument()` must be the **outermost** wrapper if other middleware also wraps the handler, so the request span covers everything inside:
74
76
 
75
77
  ```ts
@@ -83,12 +85,12 @@ Set the key as a secret: `wrangler secret put HEYSTACK_API_KEY`.
83
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).
84
86
 
85
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.
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.
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.
87
89
  - **Node (`initHeystack`)** — flushes on `SIGTERM`/`SIGINT` automatically.
88
90
 
89
91
  ## Migration / versioning
90
92
 
91
- - **Pin `@heystack/otel` `>=0.2.0`** — the workerd-aware `/next` path and `initHeystackWorkers` / `flushHeystack` exports were added in 0.2.0.
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.
92
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).
93
95
 
94
96
  ## Verify it's working
package/dist/next.d.ts CHANGED
@@ -10,9 +10,12 @@ import type { HeystackOptions } from "./core.js";
10
10
  * and use the fetch-based exporter (@heystack/otel/workers) instead. On the Edge
11
11
  * runtime neither SDK can run, so it's a no-op.
12
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
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
16
19
  * `flushHeystack()` from `@heystack/otel/workers` in a response hook (or via
17
20
  * `ctx.waitUntil(flushHeystack())` if you have a ctx) — it awaits the export.
18
21
  */
package/dist/next.js CHANGED
@@ -9,9 +9,12 @@
9
9
  * and use the fetch-based exporter (@heystack/otel/workers) instead. On the Edge
10
10
  * runtime neither SDK can run, so it's a no-op.
11
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
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
15
18
  * `flushHeystack()` from `@heystack/otel/workers` in a response hook (or via
16
19
  * `ctx.waitUntil(flushHeystack())` if you have a ctx) — it awaits the export.
17
20
  */
@@ -26,9 +29,16 @@ export async function registerHeystack(o) {
26
29
  }
27
30
  // Detect Cloudflare workerd (OpenNext / Workers). NEXT_RUNTIME is "nodejs" there,
28
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).
29
39
  const g = globalThis;
30
40
  const onWorkerd = g.navigator?.userAgent === "Cloudflare-Workers" ||
31
- (typeof g.caches?.default !== "undefined" && !g.process?.versions?.node);
41
+ typeof g.WebSocketPair !== "undefined";
32
42
  if (onWorkerd) {
33
43
  const { initHeystackWorkers } = await import("./workers.js");
34
44
  initHeystackWorkers({ apiKey, service: o.service, endpoint: o.endpoint });
package/dist/workers.d.ts CHANGED
@@ -69,6 +69,16 @@ export declare class HeystackSpanExporter implements SpanExporter {
69
69
  * dropped on fast-responding Workers/edge handlers.
70
70
  */
71
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;
72
82
  constructor(options: HeystackOptions);
73
83
  export(spans: ReadableSpan[], resultCallback: (result: ExportResult) => void): void;
74
84
  shutdown(): Promise<void>;
@@ -85,6 +95,14 @@ export interface WorkersConfig {
85
95
  /** Defaults to env.HEYSTACK_API_KEY at request time if omitted. */
86
96
  apiKey?: string;
87
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;
88
106
  }
89
107
  /**
90
108
  * A `BasicTracerProvider` with the underlying `HeystackSpanExporter` attached so
@@ -110,10 +128,13 @@ export declare function createTracerProvider(config: HeystackOptions): HeystackT
110
128
  * (workerd). Spans from the host framework (e.g. Next.js) export over fetch.
111
129
  * Use this instead of @heystack/otel/node when running on Cloudflare/edge.
112
130
  *
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.
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.
117
138
  */
118
139
  export declare function initHeystackWorkers(config: WorkersConfig & {
119
140
  apiKey: string;
@@ -150,6 +171,12 @@ interface FetchHandler<E> {
150
171
  * { service: "my-worker" },
151
172
  * );
152
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
+ *
153
180
  * If no API key is available (neither `config.apiKey` nor
154
181
  * `env.HEYSTACK_API_KEY`), the handler runs untraced.
155
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.
@@ -123,6 +139,16 @@ export class HeystackSpanExporter {
123
139
  * dropped on fast-responding Workers/edge handlers.
124
140
  */
125
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;
126
152
  constructor(options) {
127
153
  const cfg = buildExporterConfig(options);
128
154
  this.url = cfg.url;
@@ -167,6 +193,25 @@ export class HeystackSpanExporter {
167
193
  });
168
194
  this.pending.add(p);
169
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
+ }
170
215
  }
171
216
  shutdown() {
172
217
  return this.forceFlush().then(() => {
@@ -207,26 +252,45 @@ export function createTracerProvider(config) {
207
252
  // ---------------------------------------------------------------------------
208
253
  let _provider = null;
209
254
  /**
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.
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`.
218
261
  */
219
- export function initHeystackWorkers(config) {
262
+ function ensureGlobalProvider(config) {
220
263
  if (_provider)
221
264
  return _provider;
222
265
  _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.
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.
227
275
  trace.setGlobalTracerProvider(_provider);
228
276
  return _provider;
229
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
+ }
230
294
  /**
231
295
  * Force-flush any pending spans AND wait for the export network write to
232
296
  * complete. This awaits both the OTel provider's `forceFlush()` (drains the
@@ -276,6 +340,12 @@ export function __resetWarnings() {
276
340
  * { service: "my-worker" },
277
341
  * );
278
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
+ *
279
349
  * If no API key is available (neither `config.apiKey` nor
280
350
  * `env.HEYSTACK_API_KEY`), the handler runs untraced.
281
351
  */
@@ -288,12 +358,18 @@ export function instrument(handler, config) {
288
358
  warnOnceNoKey();
289
359
  return handler.fetch(req, env, ctx);
290
360
  }
291
- 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({
292
365
  apiKey,
293
366
  service: config.service,
294
367
  endpoint: config.endpoint,
368
+ waitUntil: config.waitUntil,
295
369
  });
296
- 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");
297
373
  const url = new URL(req.url);
298
374
  const span = tracer.startSpan(`${req.method} ${url.pathname}`, {
299
375
  kind: SpanKind.SERVER,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@heystack/otel",
3
- "version": "0.2.1",
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",