@heystack/otel 0.15.0 → 0.16.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
- > **Use `@heystack/otel` `>=0.15.0`.** 0.15 extends direct Workers instrumentation to D1 sessions and batches. Cloudflare native OTel remains the preferred path for Workers; direct SDK export is a compatibility fallback when native tracing is unavailable. See [Migration](#migration--versioning) below.
16
+ > **Use `@heystack/otel` `>=0.16.0`.** 0.16 extends direct Workers instrumentation to Durable Objects, Workflows, and the remaining Worker lifecycle entry points while preserving host-object return semantics. Cloudflare native OTel remains the preferred path for Workers; direct SDK export is a compatibility fallback when native tracing is unavailable. See [Migration](#migration--versioning) below.
17
17
 
18
18
  ## Runtime matrix
19
19
 
@@ -615,6 +615,7 @@ As belt-and-suspenders the exporter also drops any span whose HTTP target points
615
615
 
616
616
  ## Migration / versioning
617
617
 
618
+ - **`0.16.0`** — **complete direct Workers lifecycle and binding coverage.** Durable Object namespace stubs now trace `fetch` and RPC calls with W3C trace propagation; Workflow bindings trace creation, batch creation, lookup, instance lifecycle methods, and deletion operations. Direct instrumentation also covers scheduled, queue, email, Tail Worker, and related Worker entry points, while preserving synchronous values, native promises, custom thenables, upgrade responses, host-object identity, and callback-scoped native span ownership. Service-binding propagation now respects caller-supplied request headers. No breaking changes.
618
619
  - **`0.15.0`** — **direct Workers D1 session and batch coverage.** With `nativeTracing: "direct"` and `instrumentBindings` enabled, prepared D1 statements, database/session `batch`, database `exec`, and statements created through `withSession` now receive child spans. Native Cloudflare OTel remains the recommended Workers path because it preserves Cloudflare's platform trace/log tree; use direct export only where `ctx.tracing` is unavailable. No breaking changes.
619
620
  - **`0.14.0`** — **gen_ai enrichment under Cloudflare native tracing, Mistral + AI-gateway detection, streaming usage; OpenTelemetry JS SDK 2.x.** (1) `/workers`: under `nativeTracing: "auto" | "native"` `instrument()` never installed the fetch wrapper, so native-mode Workers emitted **no** `gen_ai.*` telemetry. It now installs a lean LLM-only wrapper that opens a native `<operation> <provider>` span (e.g. `chat mistral`) with request/response `gen_ai.*` for detected providers and passes every other subrequest through untouched. (2) New provider **Mistral** (`api.mistral.ai`) and a new `ai.gateways` option (`[{ hostSuffix, pathPattern }]`, e.g. `{ hostSuffix: "wafersecurity.ai", pathPattern: "/p/:tenant/:provider" }`) that resolves the provider from an AI-gateway URL and stamps `heystack.ai.gateway`; `gemini` → `google`, `workers-ai` → `cloudflare`. New `gen_ai.operation.name`. (3) **Streaming (`text/event-stream`) responses now yield usage**: the body is `tee()`d and scanned line-by-line for `usage`/`usageMetadata`, model, id and finish reason only (never content); the span stays open until the stream closes. Google (`usageMetadata`) and Mistral response shapes added to the JSON path too. (4) New `/workers` exports `genAiRequestAttrs` / `genAiResponseAttrs` for manual `withSpan` spans around `env.AI.run` or any non-`fetch` LLM call. Native `error.type` now keeps the exception class instead of being overwritten with `error`. (5) All `@opentelemetry/*` dependencies move to the 2.x SDK line (`core`/`sdk-trace-base`/`resources` ^2.11, `sdk-node`/`exporter-trace-otlp-http` ^0.222, `auto-instrumentations-node` ^0.80, `semantic-conventions` ^1.43), clearing the downstream Dependabot alerts the 1.x line surfaced through this package (GHSA-8988-4f7v-96qf in `@opentelemetry/core`, GHSA-q7rr-3cgh-j5r3 in `sdk-node` / `auto-instrumentations-node` / `exporter-prometheus`, GHSA-45rx-2jwx-cxfr in `propagator-jaeger`). **Node ≥ 18.19 (or ≥ 20.6) is now required on `/node` and `/next`** — the 2.x SDK dropped Node 14/16. If you pass your own `instrumentations` array to `initHeystack`, upgrade those `@opentelemetry/instrumentation-*` packages to a release that peers on `@opentelemetry/core@^2` (the auto-instrumentations 0.80 set already does). `/workers` is still WinterCG-safe (no `node:*` imports). No other API changes; all new options are optional.
620
621
  - **`0.13.0`** — **Cloudflare-native tracing and complete Workers platform coverage.** On runtimes exposing `ctx.tracing`, `/workers` now joins Cloudflare's authoritative invocation trace instead of creating and exporting a duplicate tree. `nativeTracing: "auto"` is the default; `withCloudflareSpan(ctx, ...)` adds async-safe business spans, while fetch, queue, and scheduled handlers are enriched in the native tree. The new `/workers/agents` entry maps Cloudflare Agents observability callbacks and Tail Worker diagnostics to structured, redacted telemetry. Direct OTLP remains available with `nativeTracing: "direct"` for runtimes without the native API. Heystack ingest now preserves full OTLP trace/log fidelity, accepts OTLP metrics, and can import Workers Analytics metrics through the app-scoped Cloudflare connector. Native mode needs no Worker API-key secret and must not be combined with direct export.
@@ -0,0 +1,8 @@
1
+ interface TraceparentSpanContext {
2
+ traceId: string;
3
+ spanId: string;
4
+ traceFlags: number;
5
+ }
6
+ /** Encode a span context as W3C traceparent, preserving the low flags byte. */
7
+ export declare function makeTraceparent(context: TraceparentSpanContext): string;
8
+ export {};
@@ -0,0 +1,5 @@
1
+ /** Encode a span context as W3C traceparent, preserving the low flags byte. */
2
+ export function makeTraceparent(context) {
3
+ const flags = (context.traceFlags & 0xff).toString(16).padStart(2, "0");
4
+ return `00-${context.traceId}-${context.spanId}-${flags}`;
5
+ }
@@ -1,5 +1,5 @@
1
1
  import { type Span } from "@opentelemetry/api";
2
- import { type CloudflareNativeTracing } from "./workers";
2
+ import { type CloudflareNativeTracing } from "./workers.js";
3
3
  /** Structural equivalent of the Agents SDK event; no `agents` peer required. */
4
4
  export interface CloudflareAgentEvent {
5
5
  type: string;
@@ -1,5 +1,5 @@
1
1
  import { trace } from "@opentelemetry/api";
2
- import { withCloudflareSpan, } from "./workers";
2
+ import { withCloudflareSpan, } from "./workers.js";
3
3
  const SENSITIVE_KEY = /(?:authorization|cookie|password|secret|token|api[_-]?key|prompt|completion|content|message)/i;
4
4
  const ERROR_EVENT = /(?::error|:failed|:stalled|:interrupted|:exhausted)$/;
5
5
  function sanitize(value, extraKeys, depth = 0) {
@@ -10,11 +10,19 @@ export interface InstrumentBindingsOpts {
10
10
  */
11
11
  startSpan: (name: string, attrs: Record<string, unknown>, kind?: SpanKind) => Span;
12
12
  /**
13
- * `true` → auto-detect and wrap all supported Cloudflare bindings.
13
+ * `true` → auto-detect and wrap every supported Cloudflare binding.
14
14
  * `string[]` → only wrap bindings whose env key is listed.
15
15
  */
16
16
  select: boolean | string[];
17
17
  }
18
+ /**
19
+ * Run `fn`, ending `span` in `finally` and recording exceptions + ERROR status
20
+ * on failure (re-throwing). Internal helper shared with the class-level
21
+ * wrappers in workers.ts.
22
+ */
23
+ export declare function runWithSpan<T>(span: Span, fn: () => Promise<T>): Promise<T>;
24
+ /** Internal proxy builder shared with the class-level wrappers in workers.ts. */
25
+ export declare function makeProxy(target: any, handlers: Record<string, Function>): any;
18
26
  /**
19
27
  * Build a new args array for `fetch(input, init?)` with a `traceparent` header
20
28
  * injected WITHOUT mutating the caller's original Request or init object.
@@ -37,6 +45,8 @@ export declare function injectTraceparentArgs(args: any[], traceparent: string):
37
45
  * - Workflow: `get`+`create`+`createBatch`
38
46
  * - Rate limit: `limit`
39
47
  * - Analytics Engine: `writeDataPoint`
48
+ * - Durable Object namespace: `idFromName`+`get`+`newUniqueId` (stub fetch/RPC spans)
49
+ * - Workflow binding: `create`+`createBatch`+`get` and not a DO namespace
40
50
  * - Service binding / Fetcher: `fetch` (last branch; most generic)
41
51
  *
42
52
  * Unrecognised bindings are passed through unchanged.
@@ -1,14 +1,15 @@
1
1
  // ---------------------------------------------------------------------------
2
2
  // Cloudflare binding instrumentation for @heystack/otel/workers.
3
3
  //
4
- // Wraps stable Cloudflare binding APIs with OTel child spans so that binding
5
- // operations are visible as children of the active request span.
4
+ // Wraps supported Cloudflare data, AI, messaging, Durable Object, Workflow,
5
+ // and Service binding operations with OTel child spans.
6
6
  //
7
- // WinterCG-safe: no `node:*` imports. Span factory is injected so the logic
7
+ // WinterCG-safe: no Node-builtin imports. Span factory is injected so the logic
8
8
  // is pure and unit-testable without a global provider.
9
9
  // ---------------------------------------------------------------------------
10
10
  import { context, SpanKind, SpanStatusCode } from "@opentelemetry/api";
11
11
  import { isTracingSuppressed } from "@opentelemetry/core";
12
+ import { makeTraceparent } from "./traceparent.js";
12
13
  // ---------------------------------------------------------------------------
13
14
  // Duck-type detectors — conservative; require the distinctive method set
14
15
  // exactly as documented in the task brief.
@@ -63,7 +64,12 @@ function isServiceLike(b) {
63
64
  // ---------------------------------------------------------------------------
64
65
  // Span lifecycle helper
65
66
  // ---------------------------------------------------------------------------
66
- async function runWithSpan(span, fn) {
67
+ /**
68
+ * Run `fn`, ending `span` in `finally` and recording exceptions + ERROR status
69
+ * on failure (re-throwing). Internal helper shared with the class-level
70
+ * wrappers in workers.ts.
71
+ */
72
+ export async function runWithSpan(span, fn) {
67
73
  try {
68
74
  return await fn();
69
75
  }
@@ -95,11 +101,80 @@ function runSyncWithSpan(span, fn) {
95
101
  span.end();
96
102
  }
97
103
  }
104
+ function isPromiseLike(value) {
105
+ return (((typeof value === "object" && value !== null) || typeof value === "function") &&
106
+ typeof value.then === "function");
107
+ }
108
+ function recordSpanError(span, error) {
109
+ span.recordException(error instanceof Error ? error : new Error(String(error)));
110
+ span.setStatus({
111
+ code: SpanStatusCode.ERROR,
112
+ message: error instanceof Error ? error.message : String(error),
113
+ });
114
+ }
115
+ /** Trace a call without replacing synchronous, Promise, or custom thenable results. */
116
+ function runWithSpanPreservingReturn(span, fn) {
117
+ try {
118
+ const result = fn();
119
+ if (isPromiseLike(result)) {
120
+ let settled = false;
121
+ const finish = (error, failed = false) => {
122
+ if (settled)
123
+ return;
124
+ settled = true;
125
+ if (failed) {
126
+ try {
127
+ recordSpanError(span, error);
128
+ }
129
+ catch {
130
+ // Instrumentation must not alter the observed thenable's behaviour.
131
+ }
132
+ }
133
+ try {
134
+ span.end();
135
+ }
136
+ catch {
137
+ // Instrumentation must not alter the observed thenable's behaviour.
138
+ }
139
+ };
140
+ try {
141
+ const observer = result.then(() => finish(), (error) => finish(error, true));
142
+ // A non-compliant custom thenable may reject the observer despite the
143
+ // rejection callback above. Consume that rejection without replacing
144
+ // or awaiting the original RPC/Promise object returned to the caller.
145
+ if (isPromiseLike(observer)) {
146
+ try {
147
+ observer.then(undefined, () => undefined);
148
+ }
149
+ catch {
150
+ // Calling then() is best-effort for exotic host thenables.
151
+ }
152
+ }
153
+ }
154
+ catch (error) {
155
+ finish(error, true);
156
+ }
157
+ return result;
158
+ }
159
+ span.end();
160
+ return result;
161
+ }
162
+ catch (error) {
163
+ recordSpanError(span, error);
164
+ span.end();
165
+ throw error;
166
+ }
167
+ }
168
+ /** Transform either a synchronous result or a promise without changing its category. */
169
+ function mapReturn(result, map) {
170
+ return isPromiseLike(result) ? Promise.resolve(result).then(map) : map(result);
171
+ }
98
172
  // ---------------------------------------------------------------------------
99
173
  // Proxy helper — intercepts listed handlers, falls through to prototype for rest
100
174
  // ---------------------------------------------------------------------------
101
175
  const rawTarget = Symbol("heystack.rawBindingTarget");
102
- function makeProxy(target, handlers) {
176
+ /** Internal proxy builder shared with the class-level wrappers in workers.ts. */
177
+ export function makeProxy(target, handlers) {
103
178
  return new Proxy(target, {
104
179
  get(t, prop, receiver) {
105
180
  if (prop === rawTarget)
@@ -455,6 +530,14 @@ function wrapAnalyticsEngine(binding, opts, name) {
455
530
  export function injectTraceparentArgs(args, traceparent) {
456
531
  const [input, ...rest] = args;
457
532
  if (input instanceof Request) {
533
+ const originalInit = rest[0];
534
+ if (originalInit?.headers !== undefined) {
535
+ const init = { ...originalInit };
536
+ const headers = new Headers(originalInit.headers);
537
+ headers.set("traceparent", traceparent);
538
+ init.headers = headers;
539
+ return [input, init, ...rest.slice(1)];
540
+ }
458
541
  const headers = new Headers(input.headers);
459
542
  headers.set("traceparent", traceparent);
460
543
  return [new Request(input, { headers }), ...rest];
@@ -474,13 +557,202 @@ function wrapService(binding, opts, name) {
474
557
  }
475
558
  const span = opts.startSpan(`Service ${name} fetch`, { "peer.service": name }, SpanKind.CLIENT);
476
559
  const sc = span.spanContext();
477
- const traceparent = `00-${sc.traceId}-${sc.spanId}-01`;
560
+ const traceparent = makeTraceparent(sc);
478
561
  const injectedArgs = injectTraceparentArgs(args, traceparent);
479
562
  return runWithSpan(span, () => binding.fetch(...injectedArgs));
480
563
  },
481
564
  });
482
565
  }
483
566
  // ---------------------------------------------------------------------------
567
+ // Durable Object namespace binding
568
+ // ---------------------------------------------------------------------------
569
+ /** DO namespace detector using the real synchronous Cloudflare API surface. */
570
+ function isDurableObjectNamespaceLike(b) {
571
+ return (typeof b?.idFromName === "function" &&
572
+ typeof b?.get === "function" &&
573
+ typeof b?.newUniqueId === "function");
574
+ }
575
+ /** Fetcher/DO members that are platform operations or must retain host semantics. */
576
+ const DO_STUB_RPC_EXCLUDED = new Set([
577
+ "fetch",
578
+ "connect",
579
+ "alarm",
580
+ "webSocketMessage",
581
+ "webSocketClose",
582
+ "webSocketError",
583
+ "id",
584
+ "name",
585
+ "dup",
586
+ "constructor",
587
+ "then",
588
+ ]);
589
+ /** Wrap a DO stub: CLIENT spans on fetch (+ traceparent injection) and RPC calls. */
590
+ function wrapDurableObjectStub(stub, opts, name) {
591
+ const fetch = (...args) => {
592
+ if (isTracingSuppressed(context.active()))
593
+ return stub.fetch(...args);
594
+ const span = opts.startSpan(`DO ${name} fetch`, { "peer.service": name }, SpanKind.CLIENT);
595
+ const sc = span.spanContext();
596
+ // Propagate into the DO so its own spans join this trace.
597
+ const injectedArgs = injectTraceparentArgs(args, makeTraceparent(sc));
598
+ return runWithSpanPreservingReturn(span, () => stub.fetch(...injectedArgs));
599
+ };
600
+ // workerd exposes RPC methods through a wildcard property hook, so method
601
+ // names are not enumerable. Resolve and wrap them lazily on first access.
602
+ const rpcMethods = new Map();
603
+ return new Proxy(stub, {
604
+ get(target, property) {
605
+ if (property === "fetch")
606
+ return fetch;
607
+ const value = Reflect.get(target, property, target);
608
+ if (typeof property !== "string" ||
609
+ typeof value !== "function" ||
610
+ DO_STUB_RPC_EXCLUDED.has(property) ||
611
+ property in Object.prototype) {
612
+ return typeof value === "function" ? value.bind(target) : value;
613
+ }
614
+ const cached = rpcMethods.get(property);
615
+ if (cached)
616
+ return cached;
617
+ // Capture the host callable returned for this property. Calling it with
618
+ // the original stub as receiver preserves both ordinary and workerd RPC
619
+ // method semantics; the wrapper itself remains stable across reads.
620
+ const method = value;
621
+ const wrapped = (...args) => {
622
+ if (isTracingSuppressed(context.active())) {
623
+ return Reflect.apply(method, target, args);
624
+ }
625
+ const span = opts.startSpan(`DO ${name} ${property}`, { "peer.service": name, "rpc.method": property }, SpanKind.CLIENT);
626
+ return runWithSpanPreservingReturn(span, () => Reflect.apply(method, target, args));
627
+ };
628
+ rpcMethods.set(property, wrapped);
629
+ return wrapped;
630
+ },
631
+ });
632
+ }
633
+ function wrapDurableObjectNamespace(ns, opts, name) {
634
+ const handlers = {
635
+ // Namespace lookup is local and synchronous. Trace the returned stub's
636
+ // actual fetch/RPC calls, not get() itself.
637
+ get: (...args) => wrapDurableObjectStub(ns.get(...args), opts, name),
638
+ };
639
+ if (typeof ns.getByName === "function") {
640
+ handlers.getByName = (...args) => wrapDurableObjectStub(ns.getByName(...args), opts, name);
641
+ }
642
+ if (typeof ns.jurisdiction === "function") {
643
+ handlers.jurisdiction = (...args) => wrapDurableObjectNamespace(ns.jurisdiction(...args), opts, name);
644
+ }
645
+ // idFromName/idFromString/newUniqueId are synchronous local operations and
646
+ // fall through untouched.
647
+ return makeProxy(ns, handlers);
648
+ }
649
+ // ---------------------------------------------------------------------------
650
+ // Workflows binding wrapper
651
+ // ---------------------------------------------------------------------------
652
+ /** Workflow binding: exposes create/createBatch/get and is not a DO namespace. */
653
+ function isWorkflowBindingLike(b) {
654
+ return (typeof b?.create === "function" &&
655
+ typeof b?.createBatch === "function" &&
656
+ typeof b?.get === "function" &&
657
+ !isDurableObjectNamespaceLike(b));
658
+ }
659
+ const WORKFLOW_INSTANCE_METHODS = [
660
+ "pause",
661
+ "resume",
662
+ "terminate",
663
+ "restart",
664
+ "status",
665
+ "sendEvent",
666
+ ];
667
+ function workflowId(value) {
668
+ if (typeof value === "string" && value.length > 0)
669
+ return value;
670
+ if (value && typeof value === "object") {
671
+ const id = value.id;
672
+ if (typeof id === "string" && id.length > 0)
673
+ return id;
674
+ }
675
+ return undefined;
676
+ }
677
+ function workflowBatchIds(value) {
678
+ if (!Array.isArray(value))
679
+ return [];
680
+ return value.map(workflowId).filter((id) => id !== undefined);
681
+ }
682
+ function wrapWorkflowInstance(instance, opts, name) {
683
+ if (!instance || typeof instance !== "object")
684
+ return instance;
685
+ const target = instance;
686
+ const handlers = {};
687
+ const instanceId = workflowId(instance);
688
+ for (const method of WORKFLOW_INSTANCE_METHODS) {
689
+ if (typeof target[method] !== "function")
690
+ continue;
691
+ handlers[method] = (...args) => {
692
+ const underlying = target[method];
693
+ if (isTracingSuppressed(context.active())) {
694
+ return underlying.apply(instance, args);
695
+ }
696
+ const attrs = {
697
+ "cloudflare.workflow.namespace": name,
698
+ };
699
+ if (instanceId !== undefined) {
700
+ attrs["cloudflare.workflow.instance_id"] = instanceId;
701
+ }
702
+ const span = opts.startSpan(`Workflow ${method}`, attrs, SpanKind.CLIENT);
703
+ return runWithSpanPreservingReturn(span, () => underlying.apply(instance, args));
704
+ };
705
+ }
706
+ return makeProxy(instance, handlers);
707
+ }
708
+ function wrapWorkflowBindingResult(value, op, opts, name, span) {
709
+ if (op === "deleteBatch")
710
+ return value;
711
+ if (op === "createBatch") {
712
+ if (!Array.isArray(value))
713
+ return value;
714
+ const returnedIds = workflowBatchIds(value);
715
+ if (span && returnedIds.length > 0) {
716
+ span.setAttribute("cloudflare.workflow.instance_ids", returnedIds);
717
+ }
718
+ return value.map((item) => wrapWorkflowInstance(item, opts, name));
719
+ }
720
+ const returnedId = workflowId(value);
721
+ if (span && returnedId !== undefined) {
722
+ span.setAttribute("cloudflare.workflow.instance_id", returnedId);
723
+ }
724
+ return wrapWorkflowInstance(value, opts, name);
725
+ }
726
+ function wrapWorkflowBinding(binding, opts, name) {
727
+ const baseAttrs = {
728
+ "cloudflare.workflow.namespace": name,
729
+ "cloudflare.binding.type": "workflow",
730
+ "cloudflare.binding.name": name,
731
+ };
732
+ const ops = ["create", "createBatch", "get", "deleteBatch"];
733
+ const handlers = {};
734
+ for (const op of ops) {
735
+ handlers[op] = (...args) => {
736
+ if (isTracingSuppressed(context.active())) {
737
+ return mapReturn(binding[op](...args), (value) => wrapWorkflowBindingResult(value, op, opts, name));
738
+ }
739
+ const attrs = { ...baseAttrs };
740
+ const instanceId = workflowId(args[0]);
741
+ const batchIds = op === "createBatch" || op === "deleteBatch" ? workflowBatchIds(args[0]) : [];
742
+ if (instanceId !== undefined) {
743
+ attrs["cloudflare.workflow.instance_id"] = instanceId;
744
+ }
745
+ if (batchIds.length > 0) {
746
+ attrs["cloudflare.workflow.instance_ids"] = batchIds;
747
+ attrs["cloudflare.workflow.batch.size"] = batchIds.length;
748
+ }
749
+ const span = opts.startSpan(`Workflow ${op}`, attrs, SpanKind.CLIENT);
750
+ return runWithSpanPreservingReturn(span, () => mapReturn(binding[op](...args), (value) => wrapWorkflowBindingResult(value, op, opts, name, span)));
751
+ };
752
+ }
753
+ return makeProxy(binding, handlers);
754
+ }
755
+ // ---------------------------------------------------------------------------
484
756
  // Main export
485
757
  // ---------------------------------------------------------------------------
486
758
  /**
@@ -497,6 +769,8 @@ function wrapService(binding, opts, name) {
497
769
  * - Workflow: `get`+`create`+`createBatch`
498
770
  * - Rate limit: `limit`
499
771
  * - Analytics Engine: `writeDataPoint`
772
+ * - Durable Object namespace: `idFromName`+`get`+`newUniqueId` (stub fetch/RPC spans)
773
+ * - Workflow binding: `create`+`createBatch`+`get` and not a DO namespace
500
774
  * - Service binding / Fetcher: `fetch` (last branch; most generic)
501
775
  *
502
776
  * Unrecognised bindings are passed through unchanged.
@@ -538,6 +812,12 @@ export function instrumentEnv(env, opts) {
538
812
  else if (isQueueLike(binding)) {
539
813
  result[key] = wrapQueue(binding, opts, key);
540
814
  }
815
+ else if (isDurableObjectNamespaceLike(binding)) {
816
+ result[key] = wrapDurableObjectNamespace(binding, opts, key);
817
+ }
818
+ else if (isWorkflowBindingLike(binding)) {
819
+ result[key] = wrapWorkflowBinding(binding, opts, key);
820
+ }
541
821
  else if (isWorkflowLike(binding)) {
542
822
  result[key] = wrapWorkflow(binding, opts, key);
543
823
  }
package/dist/workers.d.ts CHANGED
@@ -1,4 +1,4 @@
1
- import { type Span } from "@opentelemetry/api";
1
+ import { SpanKind, type Span } from "@opentelemetry/api";
2
2
  import { type Context, type ContextManager } from "@opentelemetry/api";
3
3
  import { BasicTracerProvider, type ReadableSpan, type SpanExporter } from "@opentelemetry/sdk-trace-base";
4
4
  import { type HeystackOptions } from "./core.js";
@@ -92,6 +92,8 @@ export declare class HeystackSpanExporter implements SpanExporter {
92
92
  readonly ingestHost: string;
93
93
  private readonly headers;
94
94
  private shutdownState;
95
+ /** Exports rejected before network work because the in-flight cap was hit. */
96
+ droppedPending: number;
95
97
  /**
96
98
  * In-flight export fetches. Each `export()` adds its settled-when-the-POST-
97
99
  * completes promise here and removes it on `finally`. `forceFlush()` awaits
@@ -112,10 +114,21 @@ export declare class HeystackSpanExporter implements SpanExporter {
112
114
  waitUntil?: (p: Promise<unknown>) => void;
113
115
  constructor(options: HeystackOptions);
114
116
  export(spans: ReadableSpan[], resultCallback: (result: ExportResult) => void): void;
117
+ /**
118
+ * POST the batch with ONE bounded automatic retry for network errors / 429 /
119
+ * 5xx. Honours `Retry-After` (seconds form) on 429 within the total backoff
120
+ * cap; after retries are exhausted it preserves plain FAILED semantics via
121
+ * `resultCallback`. The returned promise never rejects.
122
+ */
123
+ private postWithRetry;
124
+ /** Reserve a tracked slot synchronously before starting any network work. */
125
+ private reservePending;
115
126
  /**
116
127
  * Hand `p` to the OpenNext Cloudflare request context's `ctx.waitUntil`.
117
128
  * Resolves the accessor first (awaiting its in-flight load if needed) so an
118
129
  * export that fired before the dynamic import settled is still covered.
130
+ * Sets `outcome.borrowed` when (and only when) a real `waitUntil` received
131
+ * `p` — the signal the no-handoff advisory keys off.
119
132
  */
120
133
  private handOffToCloudflareContext;
121
134
  private warnedWaitUntilFailure;
@@ -235,13 +248,11 @@ export interface WorkersConfig {
235
248
  /** Minimal public surface exposed by Cloudflare's Workers custom-spans API. */
236
249
  export interface CloudflareNativeSpan {
237
250
  readonly isTraced: boolean;
238
- setAttribute(key: string, value: string | number | boolean | undefined): void;
239
- end(): void;
251
+ setAttribute(key: string, value?: string | number | boolean): void;
240
252
  }
241
253
  /** `ExecutionContext.tracing` without importing the `cloudflare:workers` module. */
242
254
  export interface CloudflareNativeTracing {
243
- enterSpan<T>(name: string, callback: (span: CloudflareNativeSpan) => T): T;
244
- startActiveSpan<T>(name: string, callback: (span: CloudflareNativeSpan) => T): T;
255
+ enterSpan<T, A extends unknown[]>(name: string, callback: (span: CloudflareNativeSpan, ...args: A) => T, ...args: A): T;
245
256
  }
246
257
  /**
247
258
  * A `BasicTracerProvider` with the underlying `HeystackSpanExporter` attached so
@@ -273,9 +284,9 @@ export declare function createTracerProvider(config: HeystackOptions & {
273
284
  * context across `await` boundaries — child spans created after an `await`
274
285
  * inside a handler are correctly parented to the request's root span.
275
286
  *
276
- * WinterCG-safe: it does NOT import `node:async_hooks`; instead it uses the
277
- * ALS global that the runtime exposes. Falls back to `SyncStackContextManager`
278
- * when the global is absent (Deno, Bun without globals, etc.).
287
+ * WinterCG-safe: it does NOT import the Node-only async_hooks builtin; instead
288
+ * it uses the ALS global that the runtime exposes. Falls back to
289
+ * `SyncStackContextManager` when the global is absent (Deno, Bun without globals, etc.).
279
290
  */
280
291
  export declare class AlsContextManager implements ContextManager {
281
292
  private _als;
@@ -311,10 +322,12 @@ export declare function __resetContextManager(): void;
311
322
  * FLUSH: on Next-on-OpenNext (where `@opennextjs/cloudflare` is present) the
312
323
  * exporter automatically borrows the Cloudflare request context's
313
324
  * `ctx.waitUntil` so the export POST completes before the isolate is torn down
314
- * — no app hook needed. For other workerd setups without it, call
315
- * `flushHeystack()` (which awaits the export fetch) from a response hook, hand
316
- * it to `ctx.waitUntil(flushHeystack())`, or pass an explicit `waitUntil` in
317
- * the config (highest priority). Returns the provider.
325
+ * — no app hook needed. For other workerd setups without it, wrap your
326
+ * handler's ExecutionContext with {@link wrapExecutionContext} (every
327
+ * `ctx.waitUntil` then also schedules the export drain), call `flushHeystack()`
328
+ * (which awaits the export fetch) from a response hook, hand it to
329
+ * `ctx.waitUntil(flushHeystack())`, or pass an explicit `waitUntil` in the
330
+ * config (highest priority). Returns the provider.
318
331
  */
319
332
  export declare function initHeystackWorkers(config: WorkersConfig & {
320
333
  apiKey: string;
@@ -328,21 +341,25 @@ export declare function initHeystackWorkers(config: WorkersConfig & {
328
341
  * isolate is torn down. Best-effort no-op if no provider is registered.
329
342
  */
330
343
  export declare function flushHeystack(): Promise<void>;
344
+ export declare function wrapExecutionContext<Props>(ctx: ExecutionContext<Props>): ExecutionContext<Props>;
331
345
  /** Reset the singleton global provider. Internal/testing helper. */
332
346
  export declare function __resetProvider(): void;
333
- /** Reset the once-only no-key warning. Internal/testing helper. */
347
+ /** Reset the once-only warnings. Internal/testing helper. */
334
348
  export declare function __resetWarnings(): void;
335
349
  /**
336
350
  * The shape of a Worker's default export. `fetch` is the common entrypoint, but
337
- * a Worker may also export `queue` / `scheduled` / `tail` (and arbitrary other
338
- * siblings). `instrument()` forwards ALL of these untouched except `fetch`
339
- * (always traced) and `queue` / `scheduled` (traced when present) — so wrapping
340
- * a Worker never drops a handler Cloudflare requires for deploy.
351
+ * a Worker may also export `queue` / `scheduled` / `tail` / `email` (and
352
+ * arbitrary other siblings). `instrument()` traces `fetch`, `queue`,
353
+ * `scheduled`, `email` and `tail` when present — every other sibling is
354
+ * forwarded untouched, so wrapping never drops a handler Cloudflare requires
355
+ * for deploy.
341
356
  */
342
357
  interface WorkerHandler<E> {
343
358
  fetch?(req: Request, env: E, ctx: ExecutionContext): Promise<Response> | Response;
344
359
  queue?(batch: MessageBatch<any>, env: E, ctx: ExecutionContext): Promise<void> | void;
345
360
  scheduled?(controller: ScheduledController, env: E, ctx: ExecutionContext): Promise<void> | void;
361
+ email?(message: ForwardableEmailMessage, env: E, ctx: ExecutionContext): Promise<void> | void;
362
+ tail?(events: TraceItem[], env: E, ctx: ExecutionContext): Promise<void> | void;
346
363
  [key: string]: unknown;
347
364
  }
348
365
  /**
@@ -370,8 +387,8 @@ interface WorkerHandler<E> {
370
387
  * ALL HANDLERS FORWARDED: the returned object spreads `handler`, so sibling
371
388
  * handlers a Worker exports (`tail`, `email`, etc.) are preserved — Cloudflare
372
389
  * won't reject the deploy for a missing handler. `fetch` is always replaced with
373
- * a traced wrapper; `queue` and `scheduled` are wrapped (and traced) when
374
- * present; everything else is forwarded untouched.
390
+ * a traced wrapper; `queue`, `scheduled`, `email` and `tail` are wrapped (and
391
+ * traced) when present; everything else is forwarded untouched.
375
392
  *
376
393
  * If no API key is available (neither `config.apiKey` nor
377
394
  * `env.HEYSTACK_API_KEY`), the handler runs untraced.
@@ -383,7 +400,7 @@ interface WorkerHandler<E> {
383
400
  * traced key is present (and non-optional) exactly when it exists on the input
384
401
  * handler, so a `{ fetch }` Worker yields a non-optional `fetch`.
385
402
  */
386
- type Instrumented<E, H> = Omit<H, "fetch" | "queue" | "scheduled"> & (H extends {
403
+ type Instrumented<E, H> = Omit<H, "fetch" | "queue" | "scheduled" | "email" | "tail"> & (H extends {
387
404
  fetch: unknown;
388
405
  } ? {
389
406
  fetch: (req: Request, env: E, ctx: ExecutionContext) => Promise<Response>;
@@ -395,6 +412,14 @@ type Instrumented<E, H> = Omit<H, "fetch" | "queue" | "scheduled"> & (H extends
395
412
  scheduled: unknown;
396
413
  } ? {
397
414
  scheduled: (controller: ScheduledController, env: E, ctx: ExecutionContext) => Promise<void>;
415
+ } : unknown) & (H extends {
416
+ email: unknown;
417
+ } ? {
418
+ email: (message: ForwardableEmailMessage, env: E, ctx: ExecutionContext) => Promise<void>;
419
+ } : unknown) & (H extends {
420
+ tail: unknown;
421
+ } ? {
422
+ tail: (events: TraceItem[], env: E, ctx: ExecutionContext) => Promise<void>;
398
423
  } : unknown);
399
424
  /**
400
425
  * Recover a handler's concrete env type from whichever traced entrypoint it
@@ -412,6 +437,52 @@ type EnvOf<H> = H extends {
412
437
  scheduled: (controller: ScheduledController, env: infer E, ctx: ExecutionContext) => unknown;
413
438
  } ? E : unknown;
414
439
  export declare function instrument<H extends WorkerHandler<any>>(handler: H, config: WorkersConfig): Instrumented<EnvOf<H>, H>;
440
+ export interface InstrumentClassesOpts {
441
+ /**
442
+ * Factory that creates and starts a new span. Called at method-invocation
443
+ * time. For integration: omit it — the global `heystack` tracer is used with
444
+ * the active context (or the explicit `parentCtx`). For unit tests inject a
445
+ * fake so no global provider is required.
446
+ */
447
+ startSpan?: (name: string, attrs: Record<string, unknown>, kind?: SpanKind, parentCtx?: Context) => Span;
448
+ }
449
+ /**
450
+ * Decorate a Durable Object class:
451
+ * - constructor: wraps the incoming `state` so `state.storage` operations emit
452
+ * INTERNAL child spans (`DO storage get|put|delete|list`) with key/count attrs.
453
+ * - `fetch()`: SERVER span named `<METHOD> <path>` (like instrument()'s fetch
454
+ * wrapper), continuing an inbound W3C traceparent, recording status/duration.
455
+ * - `alarm()`: INTERNAL span.
456
+ * - RPC prototype methods: SERVER span `DO rpc <method>` with rpc.method attrs.
457
+ *
458
+ * Every root span schedules flushHeystack() via `state.waitUntil` so exports
459
+ * complete before isolate teardown. Instance arrow-function methods assigned in
460
+ * the constructor are not enumerable on the prototype and stay unwrapped.
461
+ *
462
+ * export class Counter extends DurableObject {
463
+ * // ...
464
+ * }
465
+ * export const Counter = wrapDurableObjectClass(BaseCounter); // or decorate in place
466
+ */
467
+ export declare function wrapDurableObjectClass<C extends {
468
+ readonly prototype: object;
469
+ }>(cls: C, opts?: InstrumentClassesOpts): C;
470
+ /**
471
+ * Decorate a Cloudflare Workflows entrypoint class so its `run(event, step)`
472
+ * emits a `workflow run` INTERNAL span with workflow identity/schedule metadata
473
+ * (never payload content), and the handed-out
474
+ * step object's methods become traced versions (see {@link wrapWorkflowStep}):
475
+ * `step.do(name, fn)` → INTERNAL span named `step <name>` with success/error
476
+ * status (step retries manifest as whole-run replays by the engine and are not
477
+ * observable at this layer, so no retry attribute is emitted), and
478
+ * sleep/sleepUntil/waitForEvent/waitForName → short marker spans.
479
+ *
480
+ * Every completed run schedules flushHeystack() via the entrypoint state's
481
+ * `waitUntil` so exports survive isolate teardown.
482
+ */
483
+ export declare function wrapWorkflowEntrypointClass<C extends {
484
+ readonly prototype: object;
485
+ }>(cls: C, opts?: InstrumentClassesOpts): C;
415
486
  /**
416
487
  * Run `fn` inside a new child span named `name`. The span is automatically
417
488
  * parented to the currently-active span (via `context.active()`), started