@heystack/otel 0.12.0 → 0.13.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,14 +13,15 @@ Always read your key from the environment — never paste it into source:
13
13
  HEYSTACK_API_KEY=sk_live_…
14
14
  ```
15
15
 
16
- > **Requires `@heystack/otel` `>=0.3.0` (prefer `>=0.3.2`).** 0.3.2 fixes the no-op `suppressTracing` (feedback-loop guard) on the Workers/Next-on-OpenNext path and adds the Workers `nodejs_compat` requirement. See [Migration](#migration--versioning) below.
16
+ > **Use `@heystack/otel` `>=0.13.0`.** 0.13 makes Cloudflare's native trace tree authoritative, adds async-safe native custom spans, queue/cron coverage, and the Agents SDK adapter. See [Migration](#migration--versioning) below.
17
17
 
18
18
  ## Runtime matrix
19
19
 
20
20
  | Runtime | Import | Notes |
21
21
  | --- | --- | --- |
22
22
  | Next.js — **any** deploy target (Vercel/Node **and** Cloudflare/OpenNext) | `@heystack/otel/next` | `registerHeystack` in `instrumentation.ts`. Auto-detects Node vs Cloudflare workerd and picks the right exporter. No-op on Edge. |
23
- | Standalone Cloudflare Workers (hand-written `export default { fetch }`) | `@heystack/otel/workers` | `instrument()` wraps your handler. Fetch-based exporter, flushes via `ctx.waitUntil`. |
23
+ | Standalone Cloudflare Workers (hand-written `export default { fetch }`) | `@heystack/otel/workers` | Native-first `instrument()` wrapper; Cloudflare owns OTLP export and automatic platform spans. |
24
+ | Cloudflare Agents SDK / Agents Tail Worker | `@heystack/otel/workers/agents` | Structured Agents lifecycle events, trace correlation, redaction, and Tail Worker adapter. |
24
25
  | Node / Express / Fastify / NestJS (long-running server) | `@heystack/otel/node` | `initHeystack`: auto-instrumentations + graceful shutdown. |
25
26
  | Browser (SPA / any web frontend) | `@heystack/otel/web` | `instrumentWeb`: session replay + opt-in browser distributed tracing (`tracing: true`) that emits CLIENT spans + propagates W3C `traceparent` (browser→API shows as one trace). No-op on the server (SSR-safe). |
26
27
  | Anywhere (pure helpers) | `@heystack/otel` | `buildExporterConfig`, types. No Node SDK loaded. |
@@ -108,6 +109,64 @@ export async function register() {
108
109
 
109
110
  ## Standalone Cloudflare Workers
110
111
 
112
+ ### Preferred: Cloudflare-native OTel export
113
+
114
+ Create separate `heystack-traces` and `heystack-logs` OTLP destinations in Cloudflare's Workers Observability dashboard. Use `https://ingest.heystack.dev/v1/traces` and `/v1/logs`, each with `Authorization: Bearer <ingest-key>`, then configure:
115
+
116
+ ```toml
117
+ [observability.traces]
118
+ enabled = true
119
+ destinations = ["heystack-traces"]
120
+ head_sampling_rate = 1
121
+ persist = false
122
+
123
+ [observability.logs]
124
+ enabled = true
125
+ destinations = ["heystack-logs"]
126
+ head_sampling_rate = 1
127
+ persist = false
128
+ ```
129
+
130
+ Cloudflare creates the invocation and supported platform/binding spans and exports trace-correlated logs. `instrument()` detects `ctx.tracing` and adds Heystack business/identity attributes to that same tree. In this mode it creates no OTel provider, makes no Heystack export request, does not patch `fetch`, does not proxy bindings, and does not require `HEYSTACK_API_KEY` in the Worker.
131
+
132
+ ```ts
133
+ import { instrument, withCloudflareSpan } from "@heystack/otel/workers";
134
+
135
+ const worker = {
136
+ async fetch(request: Request, env: Env, ctx: ExecutionContext) {
137
+ return withCloudflareSpan(ctx, "order.load", { "order.id": "o1" }, async () => {
138
+ return Response.json(await env.DB.prepare("SELECT 1").first());
139
+ });
140
+ },
141
+ };
142
+
143
+ export default instrument(worker, {
144
+ service: "my-worker",
145
+ nativeTracing: "auto", // default
146
+ getUser: (request) => ({ id: request.headers.get("x-user-id") ?? undefined }),
147
+ });
148
+ ```
149
+
150
+ The wrapper also enters native spans for default-export queue and scheduled handlers. `withCloudflareSpan(ctx, ...)` remains native across arbitrary `await` boundaries and does not require `nodejs_compat`. `withSpan(...)` routes to the native tracer whenever its active context is available.
151
+
152
+ ### Cloudflare Agents SDK
153
+
154
+ ```ts
155
+ import { Agent } from "agents";
156
+ import { createCloudflareAgentsObservability } from "@heystack/otel/workers/agents";
157
+
158
+ const observability = createCloudflareAgentsObservability();
159
+ export class SupportAgent extends Agent<Env> {
160
+ override observability = observability;
161
+ }
162
+ ```
163
+
164
+ Every Agents SDK RPC, state, message/tool, chat/recovery, fiber, scheduling, lifecycle, workflow, MCP, and email event becomes a structured, trace-correlated native log. Common secret and content fields are redacted. For production diagnostics channels, attach a Tail Worker using `createCloudflareAgentsTailHandler()` and enable the same native log destination on it.
165
+
166
+ ### Direct OTLP fallback (non-native runtimes only)
167
+
168
+ Set `nativeTracing: "direct"` only when `ctx.tracing` and Cloudflare native export are unavailable. The remainder of this section describes that compatibility path. It requires `wrangler secret put HEYSTACK_API_KEY`; do not run it alongside a native trace destination because that creates duplicate trace trees.
169
+
111
170
  For a hand-written Worker (`export default { fetch }`), wrap the handler with `instrument()`:
112
171
 
113
172
  ```ts
@@ -121,6 +180,7 @@ export default instrument(
121
180
  },
122
181
  {
123
182
  service: "my-worker", // apiKey defaults to env.HEYSTACK_API_KEY
183
+ nativeTracing: "direct", // compatibility fallback only
124
184
  getUser: (req) => ({
125
185
  id: req.headers.get("x-user-id") ?? undefined,
126
186
  }),
@@ -135,9 +195,9 @@ export default instrument(
135
195
  export default instrument(withOtherMiddleware(worker), { service: "my-worker" });
136
196
  ```
137
197
 
138
- Set the key as a secret: `wrangler secret put HEYSTACK_API_KEY`.
198
+ Set the key as a secret: `wrangler secret put HEYSTACK_API_KEY`, and set `nativeTracing: "direct"` in the config.
139
199
 
140
- > **Requires `nodejs_compat` on workerd.** The SDK uses `AsyncLocalStorage` for per-request context isolation. Add to `wrangler.toml`:
200
+ > **Direct mode requires `nodejs_compat` on workerd.** The direct SDK uses `AsyncLocalStorage` for per-request context isolation. Native mode does not require it. Add to `wrangler.toml`:
141
201
  > ```toml
142
202
  > compatibility_flags = ["nodejs_compat"]
143
203
  > ```
@@ -148,6 +208,7 @@ Set the key as a secret: `wrangler secret put HEYSTACK_API_KEY`.
148
208
  | Option | Type | Notes |
149
209
  | --- | --- | --- |
150
210
  | `service` | `string` | **Required.** Service name that appears in the Heystack console. |
211
+ | `nativeTracing` | `"auto" \| "native" \| "direct"` | `auto` (default) uses Cloudflare's native tree when `ctx.tracing` exists. `direct` forces the compatibility exporter. |
151
212
  | `apiKey` | `string?` | Defaults to `env.HEYSTACK_API_KEY`. |
152
213
  | `getUser` | `(req: Request) => { id?, session?, requestId? } \| undefined` | Called per request. `id` → `enduser.id`, `session` → `session.id`, `requestId` → `http.request.id` (falls back to the `cf-ray` header). |
153
214
  | `instrumentBindings` | `boolean \| string[]` | `true` = auto child spans for all detected D1 / KV / R2 / Vectorize / Workers AI / Queue producer / Service binding bindings; `string[]` = only the named bindings. Default `false`. |
@@ -307,9 +368,9 @@ inbound W3C `traceparent` (so a client request and the server handler share one
307
368
  trace) and returns a `traceparent` response header — plus
308
369
  `Access-Control-Expose-Headers: traceparent` so browser clients can read it.
309
370
 
310
- ### Durable Objects are NOT covered by `instrument()`
371
+ ### Durable Objects in direct fallback mode
311
372
 
312
- `instrument()` wraps the keys of the **default-export handler object** (`fetch`/`queue`/`scheduled`/… ). **Durable Objects are separate named class exports**, so spreading the handler object does not touch them — a DO's `fetch`/`alarm` methods run **untraced** even when your Worker's default export is wrapped.
373
+ Cloudflare native tracing covers Durable Objects and their platform operations. The limitation below applies only to the direct exporter fallback: `instrument()` wraps the keys of the **default-export handler object** (`fetch`/`queue`/`scheduled`/… ), while Durable Objects are separate named class exports.
313
374
 
314
375
  To trace a Durable Object, instrument it manually with the global tracer (which `instrument()` / `initHeystackWorkers()` already registered) and flush per invocation:
315
376
 
@@ -484,6 +545,7 @@ As belt-and-suspenders the exporter also drops any span whose HTTP target points
484
545
 
485
546
  ## Migration / versioning
486
547
 
548
+ - **`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.
487
549
  - **`0.11.0`** — **`/web`: browser error + console collection, in-app bug reports, and seekable replay keyframes.** `instrumentWeb` now captures uncaught errors (`window.onerror` + `unhandledrejection`) and `console.error` as OTLP logs by default (`event.name=browser.error` / `browser.console`, with `exception.*` semconv, `url.full`, `session_id`, and the active trace/span ids when `tracing` is on). New options: `errors?: boolean` (default `true`), `captureConsole?: 'error' | 'warn' | false` (default `'error'`). New **`reportBug({ message, email?, context? })`** API — a headless in-app bug reporter that POSTs to `/v1/bug-reports` and auto-attaches the URL, user agent, `session_id`, active `trace_id`, `release`/`build`, and the last ≤20 captured browser errors (also emitted as a `user.bug_report` log); reports triage in the console **Bugs** tab. The `version` / `build` options are now stamped as `service.version` / `service.build` on exported browser logs (release attribution). Console capture is recursion-guarded + rate-capped (no self-export loop, no flood). The recorder also checkpoints a keyframe (~60s) so the console player seeks from the nearest snapshot instead of loading the whole session. No breaking changes; all new options are optional.
488
550
  - **`0.10.0`** — **release / commit attribution (`version` + `build`) on every runtime entry.** New optional options: `version` → the `service.version` resource attribute (a release identifier such as `1.4.2` or a git tag), `build` → the `service.build` resource attribute (the commit SHA). They power **release health**, **suspect release**, and **suspect commit** in the console — attributing a regression to the version/commit that introduced it (suspect-commit deep-links to the commit when a repo URL is configured for the app). Wired on `/node`, `/next` (threaded to whichever runtime runs), and `/workers`; accepted on `/web` for API symmetry. `/node` also reads `HEYSTACK_SERVICE_VERSION` / `OTEL_SERVICE_VERSION` and `HEYSTACK_SERVICE_BUILD` as env fallbacks. Both options are optional and only emitted when non-empty. No breaking changes. See [Release & commit attribution](#release--commit-attribution-version--build).
489
551
  - **`0.9.2`** — **`/workers`: `instrument()` no longer breaks WebSocket / Durable Object upgrades.** The fetch wrapper rebuilt every response with `new Response(...)`, which **drops the `webSocket` property** on a `101 Switching Protocols` upgrade — so every WebSocket connection on an instrumented Worker failed *whenever tracing was active* (an API key set), while passing in dev/staging where `instrument()` is a passthrough. `instrument()` now detects an upgrade (`response.webSocket` present, or `status === 101`) and returns the **original response untouched** (span closed immediately; no `traceparent` header injected — headers on a 101 aren't delivered anyway). Fixes failed `wss://` connections behind `routeAgentRequest` / any `new WebSocketPair()` handler. No API change; upgrade and redeploy.
@@ -0,0 +1,49 @@
1
+ import { type Span } from "@opentelemetry/api";
2
+ import { type CloudflareNativeTracing } from "./workers";
3
+ /** Structural equivalent of the Agents SDK event; no `agents` peer required. */
4
+ export interface CloudflareAgentEvent {
5
+ type: string;
6
+ agent?: string;
7
+ name?: string;
8
+ payload?: Record<string, unknown>;
9
+ timestamp?: number;
10
+ [key: string]: unknown;
11
+ }
12
+ export interface CloudflareAgentsObservability {
13
+ emit(event: CloudflareAgentEvent): void;
14
+ }
15
+ export interface CloudflareAgentsObservabilityOptions {
16
+ /** Static fields added to every structured event and active-span event. */
17
+ attributes?: Record<string, string | number | boolean>;
18
+ /** Allow-list event types. By default every Agents SDK event is retained. */
19
+ include?: readonly string[];
20
+ /** Additional payload keys to redact. Common secret keys are always redacted. */
21
+ redactKeys?: readonly string[];
22
+ /** Override the structured-log sink (useful for tests/custom routing). */
23
+ logger?: (level: "info" | "error", value: Record<string, unknown>) => void;
24
+ }
25
+ /**
26
+ * Agents SDK `Observability` implementation. It emits one structured log for
27
+ * every lifecycle event and mirrors safe scalar metadata onto the active span.
28
+ * Native Workers log export then preserves trace correlation without an extra
29
+ * network request in the Agent/Durable Object request path.
30
+ */
31
+ export declare function createCloudflareAgentsObservability(options?: CloudflareAgentsObservabilityOptions): CloudflareAgentsObservability;
32
+ export interface CloudflareDiagnosticsMessage {
33
+ timestamp?: number;
34
+ channel?: string;
35
+ message?: CloudflareAgentEvent | unknown;
36
+ }
37
+ export interface CloudflareTailEvent {
38
+ diagnosticsChannelEvents?: CloudflareDiagnosticsMessage[];
39
+ }
40
+ /**
41
+ * Tail Worker handler for production Agents diagnostics channels. Attach the
42
+ * returned handler as a Tail Consumer; its structured logs are exported by the
43
+ * Tail Worker's own native OTLP logs destination.
44
+ */
45
+ export declare function createCloudflareAgentsTailHandler(options?: CloudflareAgentsObservabilityOptions): {
46
+ tail(events: CloudflareTailEvent[]): Promise<void>;
47
+ };
48
+ /** Explicit async-safe native custom span for Agent model/tool/RPC work. */
49
+ export declare function withCloudflareAgentSpan<T>(tracing: ExecutionContext | CloudflareNativeTracing, operation: string, attrs: Record<string, string | number | boolean>, fn: (span: Span) => T | Promise<T>): Promise<T>;
@@ -0,0 +1,110 @@
1
+ import { trace } from "@opentelemetry/api";
2
+ import { withCloudflareSpan, } from "./workers";
3
+ const SENSITIVE_KEY = /(?:authorization|cookie|password|secret|token|api[_-]?key|prompt|completion|content|message)/i;
4
+ const ERROR_EVENT = /(?::error|:failed|:stalled|:interrupted|:exhausted)$/;
5
+ function sanitize(value, extraKeys, depth = 0) {
6
+ if (depth > 5)
7
+ return "[truncated]";
8
+ if (value === null || typeof value === "boolean" || typeof value === "number")
9
+ return value;
10
+ if (typeof value === "string")
11
+ return value.length > 2_000 ? `${value.slice(0, 2_000)}…` : value;
12
+ if (Array.isArray(value))
13
+ return value.slice(0, 50).map((item) => sanitize(item, extraKeys, depth + 1));
14
+ if (typeof value === "object") {
15
+ const out = {};
16
+ for (const [key, item] of Object.entries(value).slice(0, 100)) {
17
+ out[key] = SENSITIVE_KEY.test(key) || extraKeys.has(key) ? "[redacted]" : sanitize(item, extraKeys, depth + 1);
18
+ }
19
+ return out;
20
+ }
21
+ return String(value);
22
+ }
23
+ function eventAttributes(event) {
24
+ const attrs = { "cloudflare.agent.event.type": event.type };
25
+ if (event.agent)
26
+ attrs["cloudflare.agent.class"] = event.agent;
27
+ if (event.name)
28
+ attrs["cloudflare.agent.name"] = event.name;
29
+ if (event.timestamp)
30
+ attrs["cloudflare.agent.event.timestamp_ms"] = event.timestamp;
31
+ for (const [key, value] of Object.entries(event.payload ?? {})) {
32
+ if (typeof value === "string" || typeof value === "number" || typeof value === "boolean") {
33
+ if (!SENSITIVE_KEY.test(key))
34
+ attrs[`cloudflare.agent.${key}`] = value;
35
+ }
36
+ }
37
+ return attrs;
38
+ }
39
+ /**
40
+ * Agents SDK `Observability` implementation. It emits one structured log for
41
+ * every lifecycle event and mirrors safe scalar metadata onto the active span.
42
+ * Native Workers log export then preserves trace correlation without an extra
43
+ * network request in the Agent/Durable Object request path.
44
+ */
45
+ export function createCloudflareAgentsObservability(options = {}) {
46
+ const include = options.include ? new Set(options.include) : undefined;
47
+ const redactKeys = new Set(options.redactKeys ?? []);
48
+ const logger = options.logger ?? ((level, value) => {
49
+ if (level === "error")
50
+ console.error(value);
51
+ else
52
+ console.log(value);
53
+ });
54
+ return {
55
+ emit(event) {
56
+ if (!event?.type || (include && !include.has(event.type)))
57
+ return;
58
+ const level = ERROR_EVENT.test(event.type) ? "error" : "info";
59
+ const safeEvent = sanitize(event, redactKeys);
60
+ const value = {
61
+ event: "cloudflare.agent",
62
+ ...options.attributes,
63
+ ...safeEvent,
64
+ severity: level,
65
+ };
66
+ logger(level, value);
67
+ const active = trace.getActiveSpan();
68
+ if (active?.isRecording())
69
+ active.addEvent(`cloudflare.agent.${event.type}`, {
70
+ ...options.attributes,
71
+ ...eventAttributes(event),
72
+ });
73
+ },
74
+ };
75
+ }
76
+ /**
77
+ * Tail Worker handler for production Agents diagnostics channels. Attach the
78
+ * returned handler as a Tail Consumer; its structured logs are exported by the
79
+ * Tail Worker's own native OTLP logs destination.
80
+ */
81
+ export function createCloudflareAgentsTailHandler(options = {}) {
82
+ const observability = createCloudflareAgentsObservability(options);
83
+ return {
84
+ async tail(events) {
85
+ for (const event of events) {
86
+ for (const diagnostic of event.diagnosticsChannelEvents ?? []) {
87
+ if (!diagnostic.channel?.startsWith("agents:"))
88
+ continue;
89
+ const message = diagnostic.message;
90
+ if (!message || typeof message !== "object")
91
+ continue;
92
+ const agentEvent = message;
93
+ observability.emit({
94
+ ...agentEvent,
95
+ type: agentEvent.type || diagnostic.channel.slice("agents:".length),
96
+ timestamp: agentEvent.timestamp ?? diagnostic.timestamp,
97
+ diagnostics_channel: diagnostic.channel,
98
+ });
99
+ }
100
+ }
101
+ },
102
+ };
103
+ }
104
+ /** Explicit async-safe native custom span for Agent model/tool/RPC work. */
105
+ export function withCloudflareAgentSpan(tracing, operation, attrs, fn) {
106
+ return withCloudflareSpan(tracing, `agent.${operation}`, {
107
+ "cloudflare.agent.operation": operation,
108
+ ...attrs,
109
+ }, fn);
110
+ }
package/dist/workers.d.ts CHANGED
@@ -171,6 +171,17 @@ export interface WorkersConfig {
171
171
  * Defaults to `false` (no binding tracing). Consumed by a later task.
172
172
  */
173
173
  instrumentBindings?: boolean | string[];
174
+ /**
175
+ * Select the Workers tracing plane. `auto` (default) uses Cloudflare's native
176
+ * tracer when `ctx.tracing` is available and otherwise falls back to direct
177
+ * OTLP export. `native` prefers the platform tracer but still fails open when
178
+ * unavailable. `direct` always uses Heystack's fetch exporter.
179
+ *
180
+ * Native mode deliberately disables Heystack root/fetch/binding
181
+ * instrumentation: Cloudflare already emits those spans, and running both
182
+ * creates duplicate, disconnected trace trees.
183
+ */
184
+ nativeTracing?: "auto" | "native" | "direct";
174
185
  /**
175
186
  * Head-sampling configuration. When omitted (or `rate` >= 1), all traces are
176
187
  * kept. When `rate` < 1, a deterministic FNV-1a hash of the trace ID decides
@@ -206,6 +217,17 @@ export interface WorkersConfig {
206
217
  maxContentChars?: number;
207
218
  };
208
219
  }
220
+ /** Minimal public surface exposed by Cloudflare's Workers custom-spans API. */
221
+ export interface CloudflareNativeSpan {
222
+ readonly isTraced: boolean;
223
+ setAttribute(key: string, value: string | number | boolean | undefined): void;
224
+ end(): void;
225
+ }
226
+ /** `ExecutionContext.tracing` without importing the `cloudflare:workers` module. */
227
+ export interface CloudflareNativeTracing {
228
+ enterSpan<T>(name: string, callback: (span: CloudflareNativeSpan) => T): T;
229
+ startActiveSpan<T>(name: string, callback: (span: CloudflareNativeSpan) => T): T;
230
+ }
209
231
  /**
210
232
  * A `BasicTracerProvider` with the underlying `HeystackSpanExporter` attached so
211
233
  * flush paths can drain its in-flight fetches directly (the OTel processor's
@@ -388,6 +410,14 @@ export declare function instrument<H extends WorkerHandler<any>>(handler: H, con
388
410
  */
389
411
  export declare function withSpan<T>(name: string, fn: (span: Span) => T | Promise<T>): Promise<T>;
390
412
  export declare function withSpan<T>(name: string, attrs: Record<string, string | number | boolean>, fn: (span: Span) => T | Promise<T>): Promise<T>;
413
+ /**
414
+ * Create a Cloudflare-native custom span using an explicit ExecutionContext.
415
+ * Prefer this form in code that calls `withSpan` after an `await` on a Worker
416
+ * without `nodejs_compat`: the explicit context cannot be lost across async
417
+ * boundaries. It falls back to the regular OTel tracer outside Cloudflare.
418
+ */
419
+ export declare function withCloudflareSpan<T>(ctx: ExecutionContext | CloudflareNativeTracing, name: string, fn: (span: Span) => T | Promise<T>): Promise<T>;
420
+ export declare function withCloudflareSpan<T>(ctx: ExecutionContext | CloudflareNativeTracing, name: string, attrs: Record<string, string | number | boolean>, fn: (span: Span) => T | Promise<T>): Promise<T>;
391
421
  /**
392
422
  * Add a named event (with optional attributes) to the currently-active span.
393
423
  * No-op when no span is active.
package/dist/workers.js CHANGED
@@ -7,7 +7,7 @@
7
7
  // `@opentelemetry/resources` and the platform `fetch` — all WinterCG-safe — and
8
8
  // ships its own OTLP/JSON-over-fetch span exporter so it runs on Workers/Edge
9
9
  // where the Node SDK cannot.
10
- import { context, trace, SpanKind, SpanStatusCode, } from "@opentelemetry/api";
10
+ import { context, createContextKey, INVALID_SPAN_CONTEXT, trace, SpanKind, SpanStatusCode, } from "@opentelemetry/api";
11
11
  import { suppressTracing, isTracingSuppressed } from "@opentelemetry/core";
12
12
  import { ROOT_CONTEXT } from "@opentelemetry/api";
13
13
  import { Resource } from "@opentelemetry/resources";
@@ -563,6 +563,115 @@ export class HeystackSpanExporter {
563
563
  await Promise.allSettled([...this.pending]);
564
564
  }
565
565
  }
566
+ const NATIVE_TRACING_CONTEXT_KEY = createContextKey("heystack.cloudflare.native-tracing");
567
+ function nativeTracingFromContext() {
568
+ return context.active().getValue(NATIVE_TRACING_CONTEXT_KEY);
569
+ }
570
+ /**
571
+ * Adapts Cloudflare's deliberately-small Span API to the OTel API shape used by
572
+ * `withSpan`. Unsupported OTel operations degrade to attributes instead of
573
+ * creating a second exporter/provider.
574
+ */
575
+ class NativeSpanAdapter {
576
+ nativeSpan;
577
+ autoEnd;
578
+ constructor(nativeSpan, autoEnd) {
579
+ this.nativeSpan = nativeSpan;
580
+ this.autoEnd = autoEnd;
581
+ }
582
+ spanContext() { return INVALID_SPAN_CONTEXT; }
583
+ setAttribute(key, value) {
584
+ if (typeof value === "string" || typeof value === "number" || typeof value === "boolean") {
585
+ this.nativeSpan.setAttribute(key, value);
586
+ }
587
+ else if (Array.isArray(value)) {
588
+ this.nativeSpan.setAttribute(key, JSON.stringify(value));
589
+ }
590
+ return this;
591
+ }
592
+ setAttributes(attributes) {
593
+ for (const [key, value] of Object.entries(attributes)) {
594
+ if (value !== undefined)
595
+ this.setAttribute(key, value);
596
+ }
597
+ return this;
598
+ }
599
+ addEvent(name, attributesOrStartTime) {
600
+ const prefix = `event.${name}`;
601
+ this.nativeSpan.setAttribute(prefix, true);
602
+ if (attributesOrStartTime && !Array.isArray(attributesOrStartTime) && typeof attributesOrStartTime === "object") {
603
+ for (const [key, value] of Object.entries(attributesOrStartTime)) {
604
+ if (value !== undefined)
605
+ this.setAttribute(`${prefix}.${key}`, value);
606
+ }
607
+ }
608
+ return this;
609
+ }
610
+ addLink(_link) { return this; }
611
+ addLinks(_links) { return this; }
612
+ setStatus(status) {
613
+ if (status.code === SpanStatusCode.ERROR) {
614
+ this.nativeSpan.setAttribute("error.type", "error");
615
+ if (status.message)
616
+ this.nativeSpan.setAttribute("error.message", status.message);
617
+ }
618
+ return this;
619
+ }
620
+ updateName(_name) { return this; }
621
+ end(_endTime) {
622
+ if (!this.autoEnd)
623
+ this.nativeSpan.end();
624
+ }
625
+ isRecording() { return this.nativeSpan.isTraced; }
626
+ recordException(exception, _time) {
627
+ const error = exception instanceof Error ? exception : new Error(String(exception));
628
+ this.nativeSpan.setAttribute("error.type", error.name || "Error");
629
+ this.nativeSpan.setAttribute("error.message", error.message);
630
+ if (error.stack)
631
+ this.nativeSpan.setAttribute("exception.stacktrace", error.stack);
632
+ }
633
+ }
634
+ function getNativeTracing(ctx) {
635
+ const candidate = ctx.tracing;
636
+ return candidate && typeof candidate.enterSpan === "function" ? candidate : undefined;
637
+ }
638
+ function setNativeRequestAttributes(span, config, req) {
639
+ const url = new URL(req.url);
640
+ span.setAttribute("service.name", config.service);
641
+ span.setAttribute("http.route", url.pathname);
642
+ if (config.version)
643
+ span.setAttribute("service.version", config.version);
644
+ if (config.build)
645
+ span.setAttribute("service.build", config.build);
646
+ const userInfo = config.getUser?.(req);
647
+ if (userInfo?.id)
648
+ span.setAttribute("enduser.id", userInfo.id);
649
+ if (userInfo?.session)
650
+ span.setAttribute("session.id", userInfo.session);
651
+ const requestId = userInfo?.requestId ?? req.headers.get("cf-ray") ?? undefined;
652
+ if (requestId)
653
+ span.setAttribute("http.request.id", requestId);
654
+ }
655
+ async function runNativeSpan(nativeTracing, name, attrs, fn) {
656
+ ensureContextManager();
657
+ return nativeTracing.enterSpan(name, async (nativeSpan) => {
658
+ const adapter = new NativeSpanAdapter(nativeSpan, true);
659
+ for (const [key, value] of Object.entries(attrs)) {
660
+ if (value !== undefined)
661
+ adapter.setAttribute(key, value);
662
+ }
663
+ const nativeContext = trace.setSpan(context.active().setValue(NATIVE_TRACING_CONTEXT_KEY, nativeTracing), adapter);
664
+ try {
665
+ return await context.with(nativeContext, () => fn(adapter));
666
+ }
667
+ catch (error) {
668
+ const err = error instanceof Error ? error : new Error(String(error));
669
+ adapter.recordException(err);
670
+ adapter.setStatus({ code: SpanStatusCode.ERROR, message: err.message });
671
+ throw error;
672
+ }
673
+ });
674
+ }
566
675
  /**
567
676
  * Build a `BasicTracerProvider` wired to a `HeystackSpanExporter` via a
568
677
  * `SimpleSpanProcessor`. Exposed for advanced users who want to add manual
@@ -824,6 +933,20 @@ export function instrument(handler, config) {
824
933
  const originalFetch = handler.fetch?.bind(handler);
825
934
  if (originalFetch) {
826
935
  wrapped.fetch = async (req, env, ctx) => {
936
+ const nativeTracing = config.nativeTracing === "direct" ? undefined : getNativeTracing(ctx);
937
+ if (nativeTracing) {
938
+ // `withSpan()` discovers the request's native tracer through OTel
939
+ // context. Register our WinterCG-safe manager even though native mode
940
+ // intentionally does not create an OTel provider/exporter.
941
+ return runNativeSpan(nativeTracing, "heystack.request", {}, async (adapter) => {
942
+ setNativeRequestAttributes(adapter, config, req);
943
+ const response = await originalFetch(req, env, ctx);
944
+ adapter.setAttribute("http.response.status_code", response.status);
945
+ if (response.status >= 500)
946
+ adapter.setAttribute("error.type", "http_error");
947
+ return response;
948
+ });
949
+ }
827
950
  const s = setup(env);
828
951
  if (!s)
829
952
  return originalFetch(req, env, ctx);
@@ -998,6 +1121,17 @@ export function instrument(handler, config) {
998
1121
  const originalQueue = handler.queue?.bind(handler);
999
1122
  if (originalQueue) {
1000
1123
  wrapped.queue = async (batch, env, ctx) => {
1124
+ const nativeTracing = config.nativeTracing === "direct" ? undefined : getNativeTracing(ctx);
1125
+ if (nativeTracing) {
1126
+ return runNativeSpan(nativeTracing, `heystack.queue ${batch.queue}`, {
1127
+ "service.name": config.service,
1128
+ "messaging.batch.message_count": batch.messages.length,
1129
+ "messaging.destination.name": batch.queue,
1130
+ "messaging.operation.type": "process",
1131
+ "service.version": config.version,
1132
+ "service.build": config.build,
1133
+ }, () => originalQueue(batch, env, ctx));
1134
+ }
1001
1135
  const s = setup(env);
1002
1136
  if (!s)
1003
1137
  return originalQueue(batch, env, ctx);
@@ -1030,6 +1164,16 @@ export function instrument(handler, config) {
1030
1164
  const originalScheduled = handler.scheduled?.bind(handler);
1031
1165
  if (originalScheduled) {
1032
1166
  wrapped.scheduled = async (controller, env, ctx) => {
1167
+ const nativeTracing = config.nativeTracing === "direct" ? undefined : getNativeTracing(ctx);
1168
+ if (nativeTracing) {
1169
+ return runNativeSpan(nativeTracing, `heystack.scheduled ${controller.cron}`, {
1170
+ "service.name": config.service,
1171
+ "faas.trigger": "timer",
1172
+ "cloudflare.scheduled.cron": controller.cron,
1173
+ "service.version": config.version,
1174
+ "service.build": config.build,
1175
+ }, () => originalScheduled(controller, env, ctx));
1176
+ }
1033
1177
  const s = setup(env);
1034
1178
  if (!s)
1035
1179
  return originalScheduled(controller, env, ctx);
@@ -1063,6 +1207,23 @@ export function instrument(handler, config) {
1063
1207
  export async function withSpan(name, attrsOrFn, maybeFn) {
1064
1208
  const attrs = typeof attrsOrFn === "function" ? undefined : attrsOrFn;
1065
1209
  const fn = (typeof attrsOrFn === "function" ? attrsOrFn : maybeFn);
1210
+ const nativeTracing = nativeTracingFromContext();
1211
+ if (nativeTracing) {
1212
+ return nativeTracing.enterSpan(name, async (nativeSpan) => {
1213
+ const adapter = new NativeSpanAdapter(nativeSpan, true);
1214
+ if (attrs)
1215
+ adapter.setAttributes(attrs);
1216
+ const nativeContext = trace.setSpan(context.active().setValue(NATIVE_TRACING_CONTEXT_KEY, nativeTracing), adapter);
1217
+ try {
1218
+ return await context.with(nativeContext, () => fn(adapter));
1219
+ }
1220
+ catch (err) {
1221
+ adapter.recordException(err instanceof Error ? err : new Error(String(err)));
1222
+ adapter.setStatus({ code: SpanStatusCode.ERROR, message: err instanceof Error ? err.message : String(err) });
1223
+ throw err;
1224
+ }
1225
+ });
1226
+ }
1066
1227
  const tracer = trace.getTracer("heystack");
1067
1228
  const span = tracer.startSpan(name, attrs ? { attributes: attrs } : undefined);
1068
1229
  const ctx = trace.setSpan(context.active(), span);
@@ -1078,6 +1239,14 @@ export async function withSpan(name, attrsOrFn, maybeFn) {
1078
1239
  span.end();
1079
1240
  }
1080
1241
  }
1242
+ export async function withCloudflareSpan(ctx, name, attrsOrFn, maybeFn) {
1243
+ const attrs = typeof attrsOrFn === "function" ? {} : attrsOrFn;
1244
+ const fn = (typeof attrsOrFn === "function" ? attrsOrFn : maybeFn);
1245
+ const tracing = "enterSpan" in ctx ? ctx : getNativeTracing(ctx);
1246
+ if (tracing)
1247
+ return runNativeSpan(tracing, name, attrs, fn);
1248
+ return withSpan(name, attrs, fn);
1249
+ }
1081
1250
  /**
1082
1251
  * Add a named event (with optional attributes) to the currently-active span.
1083
1252
  * No-op when no span is active.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@heystack/otel",
3
- "version": "0.12.0",
3
+ "version": "0.13.0",
4
4
  "description": "Runtime-aware OpenTelemetry tracing that exports to Heystack (Node, Next.js, Workers).",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -23,6 +23,10 @@
23
23
  "types": "./dist/workers.d.ts",
24
24
  "import": "./dist/workers.js"
25
25
  },
26
+ "./workers/agents": {
27
+ "types": "./dist/workers-agents.d.ts",
28
+ "import": "./dist/workers-agents.js"
29
+ },
26
30
  "./web": {
27
31
  "types": "./dist/web.d.ts",
28
32
  "import": "./dist/web.js"