Skip to content

Commit c1ff6ba

Browse files
authored
presets(cloudflare): bridge tracing channel events to observability custom spans (#4413)
1 parent 997a3bf commit c1ff6ba

6 files changed

Lines changed: 722 additions & 23 deletions

File tree

docs/2.deploy/20.providers/cloudflare.md

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -103,6 +103,33 @@ export default defineConfig({
103103

104104
No manual Wrangler configuration is needed - Nitro handles it for you.
105105

106+
### Tracing
107+
108+
**🧪 Experimental!**
109+
110+
When the experimental [`tracingChannel`](/config#tracingchannel) option is enabled, the Cloudflare presets report Nitro's tracing-channel events (h3 routes and middleware, srvx, unstorage operations, …) as [custom spans](https://developers.cloudflare.com/workers/observability/traces/custom-spans/), alongside Cloudflare's automatic instrumentation (fetch calls, KV reads, D1 queries, …) — no OpenTelemetry SDK required.
111+
112+
```ts [nitro.config.ts]
113+
import { defineConfig } from "nitro";
114+
115+
export default defineConfig({
116+
preset: "cloudflare_module",
117+
tracingChannel: true,
118+
});
119+
```
120+
121+
Tracing must be enabled on the Worker for spans to be recorded:
122+
123+
```jsonc [wrangler.jsonc]
124+
{
125+
"observability": {
126+
"traces": {
127+
"enabled": true
128+
}
129+
}
130+
}
131+
```
132+
106133
## Cloudflare Pages
107134

108135
**Preset:** `cloudflare_pages`

src/presets/cloudflare/preset.ts

Lines changed: 25 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,8 @@ import { defineNitroPreset } from "../_utils/preset.ts";
22
import { writeFile } from "../_utils/fs.ts";
33
import type { Nitro } from "nitro/types";
44
import type { Plugin } from "rollup";
5-
import { resolve } from "pathe";
5+
import { join, resolve } from "pathe";
6+
import { presetsDir } from "nitro/meta";
67
import { unenvCfExternals } from "./unenv/preset.ts";
78
import {
89
enableNodeCompat,
@@ -141,6 +142,14 @@ export const cloudflareDev = defineNitroPreset(
141142
devServer: {
142143
runner: "miniflare",
143144
},
145+
hooks: {
146+
"build:before": (nitro) => {
147+
// The bridge imports `cloudflare:workers`, only available in workerd
148+
if (nitro.options.devServer.runner === "miniflare") {
149+
setupTracingBridge(nitro);
150+
}
151+
},
152+
},
144153
},
145154
{
146155
name: "cloudflare-dev" as const,
@@ -180,6 +189,7 @@ const cloudflareModule = defineNitroPreset(
180189
nitro.options.unenv.push(unenvCfExternals);
181190
await enableNodeCompat(nitro);
182191
await setupEntryExports(nitro);
192+
setupTracingBridge(nitro);
183193
},
184194
async compiled(nitro: Nitro) {
185195
await writeWranglerConfig(nitro, "module");
@@ -219,3 +229,17 @@ export default [
219229
cloudflareDurable,
220230
cloudflareDev,
221231
];
232+
233+
/**
234+
* Export tracing-channel spans as Cloudflare custom spans (`tracing.enterSpan`)
235+
* Registered first (unshift) so the bridge subscribes to the traced channels at
236+
* startup, before any request is handled.
237+
*/
238+
function setupTracingBridge(nitro: Nitro) {
239+
if (!nitro.options.tracingChannel) {
240+
return;
241+
}
242+
nitro.options.plugins ??= [];
243+
244+
nitro.options.plugins.unshift(join(presetsDir, "cloudflare/runtime/telemetry/plugin"));
245+
}
Lines changed: 84 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,84 @@
1+
import { definePlugin } from "nitro";
2+
// Handle older compatibility dates without the custom-spans API
3+
import * as cloudflare from "cloudflare:workers";
4+
import type { Span, Tracing } from "@cloudflare/workers-types";
5+
import type { IAnyValue } from "#nitro/runtime/telemetry/types";
6+
import { subscribeTracedChannels } from "#nitro/runtime/telemetry/subscribe";
7+
8+
// https://developers.cloudflare.com/workers/observability/traces/custom-spans/
9+
const tracing = (cloudflare as { tracing?: Tracing }).tracing;
10+
11+
interface PendingSpan {
12+
span: Span;
13+
close: () => void;
14+
}
15+
16+
/**
17+
* Exports Nitro tracing-channel events as Cloudflare Workers custom spans,
18+
* alongside Cloudflare's automatic instrumentation (fetch, KV, D1, …)
19+
*/
20+
export default definePlugin(() => {
21+
if (typeof tracing?.enterSpan !== "function") return;
22+
23+
subscribeTracedChannels<PendingSpan>(
24+
(info, _startTimeUnixNano, error, entry) => {
25+
if (!entry) return;
26+
try {
27+
// Skip attribute work for unsampled requests (`head_sampling_rate`).
28+
if (entry.span.isTraced) {
29+
for (const { key, value } of info.attributes) {
30+
const attribute = attributeValue(value);
31+
if (attribute !== undefined) {
32+
entry.span.setAttribute(key, attribute);
33+
}
34+
}
35+
if (error !== undefined) {
36+
recordException(entry.span, error);
37+
}
38+
}
39+
} finally {
40+
entry.close();
41+
}
42+
},
43+
{
44+
onStart(info) {
45+
let close!: () => void;
46+
const done = new Promise<void>((resolve) => {
47+
close = resolve;
48+
});
49+
let entry: PendingSpan | undefined;
50+
tracing.enterSpan(info.name, (span) => {
51+
entry = { span, close };
52+
return done;
53+
});
54+
return entry;
55+
},
56+
}
57+
);
58+
});
59+
60+
/** OTLP `IAnyValue` (from the shared describers) → Cloudflare attribute value. */
61+
function attributeValue(value: IAnyValue): string | number | boolean | undefined {
62+
if (value.stringValue != null) return value.stringValue;
63+
if (value.intValue != null) return value.intValue;
64+
if (value.doubleValue != null) return value.doubleValue;
65+
if (value.boolValue != null) return value.boolValue;
66+
}
67+
68+
/**
69+
* OTEL exception semconv, flattened onto span attributes — the Cloudflare API
70+
* has no span events, and `setOutcome` is not available yet.
71+
*/
72+
function recordException(span: Span, error: unknown): void {
73+
const err = error as Partial<Error> | undefined;
74+
if (typeof err?.name === "string") {
75+
span.setAttribute("exception.type", err.name);
76+
}
77+
span.setAttribute(
78+
"exception.message",
79+
typeof err?.message === "string" ? err.message : String(error)
80+
);
81+
if (typeof err?.stack === "string") {
82+
span.setAttribute("exception.stacktrace", err.stack);
83+
}
84+
}

src/runtime/internal/telemetry/subscribe.ts

Lines changed: 81 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -3,16 +3,35 @@ import { TRACED_CHANNELS } from "./channels.ts";
33
import { Span } from "./span.ts";
44

55
/**
6-
* Called once per completed traced operation with the derived span info, the
7-
* operation start time (unix nanoseconds, as an OTLP `*UnixNano` string) and the
8-
* operation's error (`undefined` when it succeeded). A sink turns this into
9-
* whatever its platform consumes — an OTLP export, a log line, …
6+
* Called once per completed traced operation with the span info (derived from
7+
* the completed payload, falling back to the start-time payload for `onStart`
8+
* subscriptions), the operation start time (unix nanoseconds, as an OTLP
9+
* `*UnixNano` string), the operation's error (`undefined` when it succeeded)
10+
* and the state returned by `onStart` (`undefined` without one). A sink turns
11+
* this into whatever its platform consumes — an OTLP export, a log line, a
12+
* platform span, …
1013
*/
11-
export type SpanSink = (info: SpanInfo, startTimeUnixNano: string, error: unknown) => void;
14+
export type SpanSink<S = unknown> = (
15+
info: SpanInfo,
16+
startTimeUnixNano: string,
17+
error: unknown,
18+
state: S | undefined
19+
) => void;
20+
21+
export interface SubscribeTracedChannelsOptions<S> {
22+
/**
23+
* Called synchronously when a traced operation starts, inside its execution
24+
* context — where platform span APIs like Cloudflare's `enterSpan` must be
25+
* called. Returned state is handed back to `onSpan` at completion.
26+
*/
27+
onStart?: (info: SpanInfo) => S | undefined;
28+
}
1229

1330
/**
1431
* Subscribes to the tracing channels declared in `TRACED_CHANNELS` (produced by
15-
* h3, srvx, unstorage, …) and invokes `onSpan` for each completed operation.
32+
* h3, srvx, unstorage, …) and invokes `onSpan` once per traced operation:
33+
* normally at `asyncEnd`, or at `end` when the traced function threw
34+
* synchronously (`tracePromise` never publishes `asyncEnd` in that case).
1635
*
1736
* A `tracingChannel(<name>)` publishes to plain named channels
1837
* (`tracing:<name>:start`, `tracing:<name>:asyncEnd`, …). Subscribing to those
@@ -23,34 +42,74 @@ export type SpanSink = (info: SpanInfo, startTimeUnixNano: string, error: unknow
2342
*
2443
* A no-op when `node:diagnostics_channel` is unavailable (non-Node runtimes).
2544
*/
26-
export function subscribeTracedChannels(onSpan: SpanSink): void {
45+
export function subscribeTracedChannels<S = unknown>(
46+
onSpan: SpanSink<S>,
47+
options?: SubscribeTracedChannelsOptions<S>
48+
): void {
2749
const diagnostics = globalThis.process?.getBuiltinModule?.("node:diagnostics_channel");
2850
if (!diagnostics?.subscribe) return;
2951

30-
// Carry the start time from `start` to `asyncEnd` without mutating the producer's context object.
31-
const starts = new WeakMap<object, string>();
52+
const onStart = options?.onStart;
53+
54+
// Carry the start time (and any start-time info / sink state) from `start`
55+
// to completion without mutating the producer's context object.
56+
interface Pending {
57+
start: string;
58+
info: SpanInfo | undefined;
59+
state: S | undefined;
60+
}
61+
const pending = new WeakMap<object, Pending>();
3262

3363
for (const name of Object.keys(TRACED_CHANNELS)) {
3464
const describe = TRACED_CHANNELS[name];
3565

3666
diagnostics.subscribe(`tracing:${name}:start`, (message) => {
37-
starts.set(message as object, Span.nowUnixNano());
67+
const entry: Pending = { start: Span.nowUnixNano(), info: undefined, state: undefined };
68+
if (onStart) {
69+
try {
70+
entry.info = describe(name, message);
71+
entry.state = onStart(entry.info);
72+
} catch {
73+
// Malformed payload, or a sink failure (e.g. the platform refused to
74+
// open a span) — no state; the completion callback still fires.
75+
}
76+
}
77+
pending.set(message as object, entry);
3878
});
3979

40-
diagnostics.subscribe(`tracing:${name}:asyncEnd`, (message) => {
80+
const complete = (message: unknown) => {
81+
const entry = pending.get(message as object);
82+
if (entry === undefined) return;
83+
pending.delete(message as object);
84+
85+
// Derive span name, kind and semantic attributes from the completed
86+
// operation. A describer only throws on a payload shape it doesn't
87+
// recognise (a producer that changed shape); fall back to the start-time
88+
// info (held for `onStart` subscriptions) so stateful sinks still get
89+
// the completion and can release their span.
90+
let info: SpanInfo | undefined;
4191
try {
42-
const start = starts.get(message as object);
43-
if (start === undefined) return;
44-
starts.delete(message as object);
45-
46-
// Derive span name, kind and semantic attributes from the operation. A
47-
// describer only throws on a payload shape it doesn't recognise (a
48-
// producer that changed shape); drop that span via the catch below
49-
// rather than emit a contentless one.
50-
const info = describe(name, message);
51-
onSpan(info, start, (message as { error?: unknown }).error);
92+
info = describe(name, message);
93+
} catch {}
94+
info ??= entry.info;
95+
if (info === undefined) return;
96+
97+
try {
98+
onSpan(info, entry.start, (message as { error?: unknown }).error, entry.state);
5299
} catch {
53-
// Malformed payload, or a sink failure — never break the traced operation.
100+
// A sink failure must never break the traced operation.
101+
}
102+
};
103+
104+
diagnostics.subscribe(`tracing:${name}:asyncEnd`, complete);
105+
106+
// `tracePromise` never publishes `asyncEnd` when the traced function
107+
// throws synchronously — only `end`, with `error` already set. In the
108+
// normal async path `end` fires before the promise settles, while `error`
109+
// is still unset, so the guard makes this a no-op there.
110+
diagnostics.subscribe(`tracing:${name}:end`, (message) => {
111+
if ((message as { error?: unknown }).error !== undefined) {
112+
complete(message);
54113
}
55114
});
56115
}

0 commit comments

Comments
 (0)