@heystack/otel 0.15.0 → 0.17.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.
@@ -658,3 +659,37 @@ claude mcp add --transport http heystack https://mcp.heystack.dev/mcp
658
659
  ```
659
660
 
660
661
  One-click links for Cursor and VS Code, commands for Codex/Gemini CLI, personal access tokens for CI, and the full tool reference are in the docs: **https://heystack.dev/docs/mcp**.
662
+
663
+
664
+ ## Product activity (0.17.0)
665
+
666
+ Use `createProductAnalytics` from `@heystack/otel/product` alongside the runtime
667
+ instrumentation. `track({ name, feature?, surface?, action?, outcome?, userId?,
668
+ sessionId?, traceId?, spanId?, properties? })` queues an explicit event.
669
+ `withContext(context)` returns an immutable request/session tracker;
670
+ `feature(name, options?)` and `screen(name, options?)` are convenience methods.
671
+ There is no mutable global user identity. Read the [full guide](https://heystack.dev/docs/product-activity).
672
+
673
+ ```ts
674
+ import { createProductAnalytics } from "@heystack/otel/product";
675
+ const activity = createProductAnalytics({ apiKey, service: "api", version: "2.4.0", allowedProperties: ["plan"] });
676
+ activity.withContext({ userId, sessionId, feature: "signup" }).track({
677
+ name: "signup.completed", outcome: "succeeded", properties: { plan: "starter" },
678
+ });
679
+ await activity.flush(); // Workers: ctx.waitUntil(activity.flush())
680
+ ```
681
+
682
+ Only allowlisted metadata is exported. Queue: 200 events; batches: 50;
683
+ properties: 20 keys, 256-character strings. `track` reports queued, `flush`
684
+ reports HTTP acceptance, not durable query visibility. Failed batches remain
685
+ bounded for retry; counts may include duplicates. No automatic click/form/text
686
+ capture. The browser-only `flushIntervalMs` timer is opt-in (minimum 5s);
687
+ servers have no background timer. Call `shutdown()` to stop and flush.
688
+
689
+ `instrumentWeb` now accepts `sessionId`, a UUID shared with product activity.
690
+ Capture product collection's original fetch before calling `instrumentWeb`,
691
+ or supply an original `fetch`. Rotate session UUID and discard bound user
692
+ trackers on logout; restart replay to avoid linking different people. Replay
693
+ sampling remains independent. Use a dedicated browser ingest configuration,
694
+ never expose the backend secret. Native clients may emit the documented event
695
+ attributes through existing log APIs.
@@ -0,0 +1,41 @@
1
+ import { type HeystackOptions } from "./core.js";
2
+ export interface ActivityContext {
3
+ /** Stable pseudonymous identifiers; never email addresses or credentials. */
4
+ userId?: string;
5
+ sessionId?: string;
6
+ feature?: string;
7
+ /** Stable screen/route name, not a URL containing customer data. */
8
+ surface?: string;
9
+ traceId?: string;
10
+ spanId?: string;
11
+ }
12
+ export interface ProductEvent extends ActivityContext {
13
+ name: string;
14
+ action?: string;
15
+ outcome?: "started" | "succeeded" | "failed" | "cancelled";
16
+ properties?: Record<string, string | number | boolean>;
17
+ }
18
+ export interface ProductAnalyticsOptions extends HeystackOptions {
19
+ environment?: string;
20
+ /** Supply an original, uninstrumented fetch if browser fetch is already patched. */
21
+ fetch?: typeof fetch;
22
+ /** Explicitly allow metadata keys. Unlisted properties are discarded. */
23
+ allowedProperties?: readonly string[];
24
+ /** Opt-in browser flush timer. Default: no background work; await flush on servers. */
25
+ flushIntervalMs?: number;
26
+ }
27
+ export interface ActivityTracker {
28
+ track(event: ProductEvent): boolean;
29
+ feature(name: string, event?: Omit<ProductEvent, "name" | "feature">): boolean;
30
+ screen(name: string, event?: Omit<ProductEvent, "name" | "surface">): boolean;
31
+ /** Immutable binding. Create one per request/session; never store request identity globally. */
32
+ withContext(context: ActivityContext): ActivityTracker;
33
+ }
34
+ export interface ProductAnalytics extends ActivityTracker {
35
+ flush(): Promise<boolean>;
36
+ shutdown(): Promise<boolean>;
37
+ }
38
+ /** Explicit product activity over the existing OTLP logs ingest path.
39
+ * Bounded, best-effort collection; never auto-captures UI text or form contents.
40
+ * The return value means queued, not delivered or verified business success. */
41
+ export declare function createProductAnalytics(options: ProductAnalyticsOptions): ProductAnalytics;
@@ -0,0 +1,97 @@
1
+ import { suppressTracing } from "@opentelemetry/core";
2
+ import { context, trace, isSpanContextValid } from "@opentelemetry/api";
3
+ import { DEFAULT_ENDPOINT, releaseResourceAttributes } from "./core.js";
4
+ const MAX_QUEUE = 200;
5
+ const MAX_BATCH = 50;
6
+ const NAME = /^[a-zA-Z][a-zA-Z0-9_.-]{0,127}$/;
7
+ const SENSITIVE = /password|secret|token|authorization|cookie|email|phone|credit|card|body|prompt|content/i;
8
+ const text = (v, max = 256) => typeof v === "string" ? v.trim().slice(0, max) : "";
9
+ const id = (v, length) => typeof v === "string" && new RegExp(`^[a-f0-9]{${length}}$`, "i").test(v) && !/^0+$/.test(v) ? v.toLowerCase() : "";
10
+ /** Explicit product activity over the existing OTLP logs ingest path.
11
+ * Bounded, best-effort collection; never auto-captures UI text or form contents.
12
+ * The return value means queued, not delivered or verified business success. */
13
+ export function createProductAnalytics(options) {
14
+ const send = options.fetch ?? globalThis.fetch.bind(globalThis);
15
+ const endpoint = `${(options.endpoint ?? DEFAULT_ENDPOINT).replace(/\/+$/, "")}/v1/logs`;
16
+ const resource = { "service.name": options.service, ...releaseResourceAttributes(options),
17
+ ...(options.environment ? { "deployment.environment": text(options.environment) } : {}) };
18
+ const allowed = new Set((options.allowedProperties ?? []).filter(k => NAME.test(k) && !SENSITIVE.test(k)).slice(0, 20));
19
+ const kv = (key, value) => ({ key, value: typeof value === "boolean" ? { boolValue: value } : typeof value === "number" ? { doubleValue: value } : { stringValue: value } });
20
+ let queue = [];
21
+ let sequence = 0;
22
+ let stopped = false;
23
+ let inFlight;
24
+ let timer;
25
+ const tracker = (bound) => ({
26
+ track(event) {
27
+ if (stopped || queue.length >= MAX_QUEUE || !NAME.test(event.name))
28
+ return false;
29
+ const active = trace.getSpan(context.active())?.spanContext();
30
+ const ctx = { ...bound, ...event };
31
+ const attrs = {
32
+ "event.name": event.name, "event.domain": "product", "event.id": crypto.randomUUID(),
33
+ };
34
+ for (const [key, v] of Object.entries({ "enduser.id": ctx.userId, "session.id": ctx.sessionId,
35
+ "heystack.feature": ctx.feature, "heystack.surface": ctx.surface, "heystack.action": event.action, "heystack.outcome": event.outcome })) {
36
+ if (text(v))
37
+ attrs[key] = text(v);
38
+ }
39
+ for (const key of allowed) {
40
+ const v = event.properties?.[key];
41
+ if (typeof v === "string")
42
+ attrs[`heystack.properties.${key}`] = v.slice(0, 256);
43
+ else if (typeof v === "boolean" || (typeof v === "number" && Number.isFinite(v)))
44
+ attrs[`heystack.properties.${key}`] = v;
45
+ }
46
+ const traceId = ctx.traceId !== undefined ? id(ctx.traceId, 32) : (active && isSpanContextValid(active) ? active.traceId : "");
47
+ const spanId = ctx.traceId ? id(ctx.spanId, 16) : active && isSpanContextValid(active) ? active.spanId : "";
48
+ queue.push({ sequence: ++sequence, record: { timeUnixNano: (BigInt(Date.now()) * 1000000n).toString(),
49
+ severityText: event.outcome === "failed" ? "ERROR" : "INFO", severityNumber: event.outcome === "failed" ? 17 : 9,
50
+ body: { stringValue: event.name }, attributes: Object.entries(attrs).map(([k, v]) => kv(k, v)),
51
+ ...(traceId ? { traceId, ...(spanId ? { spanId } : {}) } : {}) } });
52
+ return true;
53
+ },
54
+ feature(name, event = {}) { return this.track({ ...event, name: "feature.used", feature: name }); },
55
+ screen(name, event = {}) { return this.track({ ...event, name: "screen.viewed", surface: name }); },
56
+ withContext(next) { return tracker({ ...bound, ...next }); },
57
+ });
58
+ const flushThrough = (target) => {
59
+ // Every caller waits for its own sequence watermark, including events queued
60
+ // during an earlier export. New producers cannot extend this caller forever.
61
+ if (inFlight)
62
+ return inFlight.then(ok => ok ? flushThrough(target) : false);
63
+ const run = async () => {
64
+ while (queue.length && queue[0].sequence <= target) {
65
+ const size = Math.min(MAX_BATCH, queue.filter(item => item.sequence <= target).length);
66
+ const batch = queue.splice(0, size);
67
+ const records = batch.map(item => item.record);
68
+ const controller = new AbortController();
69
+ const timeout = setTimeout(() => controller.abort(), 5000);
70
+ try {
71
+ const response = await context.with(suppressTracing(context.active()), () => send(endpoint, { method: "POST", signal: controller.signal,
72
+ headers: { "content-type": "application/json", authorization: `Bearer ${options.apiKey}` },
73
+ body: JSON.stringify({ resourceLogs: [{ resource: { attributes: Object.entries(resource).map(([k, v]) => kv(k, v)) },
74
+ scopeLogs: [{ scope: { name: "@heystack/otel/product", version: "1" }, logRecords: records }] }] }) }));
75
+ if (!response.ok)
76
+ throw new Error("export failed");
77
+ }
78
+ catch {
79
+ queue = [...batch, ...queue].slice(0, MAX_QUEUE);
80
+ return false;
81
+ }
82
+ finally {
83
+ clearTimeout(timeout);
84
+ }
85
+ }
86
+ return true;
87
+ };
88
+ inFlight = run().finally(() => { inFlight = undefined; });
89
+ return inFlight;
90
+ };
91
+ const flush = () => flushThrough(sequence);
92
+ if (typeof window !== "undefined" && options.flushIntervalMs !== undefined) {
93
+ timer = setInterval(() => { void flush(); }, Math.max(5000, Number.isFinite(options.flushIntervalMs) ? options.flushIntervalMs : 5000));
94
+ }
95
+ return { ...tracker({}), flush, async shutdown() { stopped = true; if (timer)
96
+ clearInterval(timer); return flush(); } };
97
+ }
@@ -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
+ }
package/dist/web.d.ts CHANGED
@@ -85,6 +85,8 @@ export interface InstrumentWebOptions {
85
85
  * resource of exported browser logs for commit-level attribution.
86
86
  */
87
87
  build?: string;
88
+ /** Optional shared UUID for product events, browser errors and replay. Rotate on logout. */
89
+ sessionId?: string;
88
90
  /** Optional app-supplied user identifier stamped on the session. */
89
91
  userId?: string;
90
92
  /** Local overrides; by default sampling + masking come from server config. */
@@ -144,6 +146,8 @@ export declare function tracingActive(opts: Pick<InstrumentWebOptions, "tracing"
144
146
  * (`cfg.errors_enabled`). **Fail-open**: an absent/`true` flag counts as enabled.
145
147
  */
146
148
  export declare function errorCaptureActive(opts: Pick<InstrumentWebOptions, "errors">, cfg: Pick<RecorderConfig, "errors_enabled">): boolean;
149
+ /** A caller-owned session UUID allows product activity to correlate with replay. */
150
+ export declare function resolveWebSessionId(candidate?: string): string;
147
151
  /** Entry point: fetch config, decide sampling, start rrweb, stream chunks.
148
152
  * Returns a stop() function. Safe to call in any browser; no-ops on the server. */
149
153
  export declare function instrumentWeb(opts: InstrumentWebOptions): Promise<() => void>;
package/dist/web.js CHANGED
@@ -100,6 +100,14 @@ export function tracingActive(opts, cfg) {
100
100
  export function errorCaptureActive(opts, cfg) {
101
101
  return opts.errors !== false && cfg.errors_enabled !== false;
102
102
  }
103
+ /** A caller-owned session UUID allows product activity to correlate with replay. */
104
+ export function resolveWebSessionId(candidate) {
105
+ if (candidate === undefined)
106
+ return crypto.randomUUID();
107
+ if (!/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(candidate))
108
+ throw new Error("sessionId must be a UUID");
109
+ return candidate;
110
+ }
103
111
  /** Entry point: fetch config, decide sampling, start rrweb, stream chunks.
104
112
  * Returns a stop() function. Safe to call in any browser; no-ops on the server. */
105
113
  export async function instrumentWeb(opts) {
@@ -125,7 +133,7 @@ export async function instrumentWeb(opts) {
125
133
  // One session id for the whole run — shared by errors, console logs AND replay
126
134
  // (created here, before the replay sampling gate, so errors correlate even when
127
135
  // replay isn't sampled; when it is, they share this id → error ↔ replay linking).
128
- const sessionId = crypto.randomUUID();
136
+ const sessionId = resolveWebSessionId(opts.sessionId);
129
137
  // service.version / service.build for release attribution on exported browser logs.
130
138
  const releaseAttrs = releaseResourceAttributes({ version: opts.version, build: opts.build });
131
139
  // 2. Browser distributed tracing (opt-in) — INDEPENDENT of replay sampling. Emits
@@ -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
  }