Skip to content
27 changes: 27 additions & 0 deletions docs/2.deploy/20.providers/cloudflare.md
Original file line number Diff line number Diff line change
Expand Up @@ -103,6 +103,33 @@ export default defineConfig({

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

### Tracing

**πŸ§ͺ Experimental!**

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.

```ts [nitro.config.ts]
import { defineConfig } from "nitro";

export default defineConfig({
preset: "cloudflare_module",
tracingChannel: true,
});
```

Tracing must be enabled on the Worker for spans to be recorded:

```jsonc [wrangler.jsonc]
{
"observability": {
"traces": {
"enabled": true
}
}
}
```

## Cloudflare Pages

**Preset:** `cloudflare_pages`
Expand Down
26 changes: 25 additions & 1 deletion src/presets/cloudflare/preset.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,8 @@ import { defineNitroPreset } from "../_utils/preset.ts";
import { writeFile } from "../_utils/fs.ts";
import type { Nitro } from "nitro/types";
import type { Plugin } from "rollup";
import { resolve } from "pathe";
import { join, resolve } from "pathe";
import { presetsDir } from "nitro/meta";
import { unenvCfExternals } from "./unenv/preset.ts";
import {
enableNodeCompat,
Expand Down Expand Up @@ -141,6 +142,14 @@ export const cloudflareDev = defineNitroPreset(
devServer: {
runner: "miniflare",
},
hooks: {
"build:before": (nitro) => {
// The bridge imports `cloudflare:workers`, only available in workerd
if (nitro.options.devServer.runner === "miniflare") {
setupTracingBridge(nitro);
}
},
},
},
{
name: "cloudflare-dev" as const,
Expand Down Expand Up @@ -180,6 +189,7 @@ const cloudflareModule = defineNitroPreset(
nitro.options.unenv.push(unenvCfExternals);
await enableNodeCompat(nitro);
await setupEntryExports(nitro);
setupTracingBridge(nitro);
},
async compiled(nitro: Nitro) {
await writeWranglerConfig(nitro, "module");
Expand Down Expand Up @@ -219,3 +229,17 @@ export default [
cloudflareDurable,
cloudflareDev,
];

/**
* Export tracing-channel spans as Cloudflare custom spans (`tracing.enterSpan`)
* Registered first (unshift) so the bridge subscribes to the traced channels at
* startup, before any request is handled.
*/
function setupTracingBridge(nitro: Nitro) {
if (!nitro.options.tracingChannel) {
return;
}
nitro.options.plugins ??= [];

nitro.options.plugins.unshift(join(presetsDir, "cloudflare/runtime/telemetry/plugin"));
}
85 changes: 85 additions & 0 deletions src/presets/cloudflare/runtime/telemetry/plugin.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,85 @@
import { definePlugin } from "nitro";
// Handle older compatibility dates without the custom-spans API
import * as cloudflare from "cloudflare:workers";
import type { Span, Tracing } from "@cloudflare/workers-types";
import type { IAnyValue } from "#nitro/runtime/telemetry/types";
import { subscribeTracedChannels } from "#nitro/runtime/telemetry/subscribe";

// https://developers.cloudflare.com/workers/observability/traces/custom-spans/
const tracing = (cloudflare as { tracing?: Tracing }).tracing;

interface PendingSpan {
span: Span;
close: () => void;
}

/**
* Exports Nitro tracing-channel events as Cloudflare Workers custom spans,
* alongside Cloudflare's automatic instrumentation (fetch, KV, D1, …)
*/
export default definePlugin(() => {
if (typeof tracing?.enterSpan !== "function") return;

subscribeTracedChannels<PendingSpan>(
(info, _startTimeUnixNano, error, entry) => {
if (!entry) return;
try {
// Skip attribute work for unsampled requests (`head_sampling_rate`).
if (entry.span.isTraced) {
// `info` is undefined when the describer failed on the completed
// payload β€” the error is still recorded on the (already named) span.
if (info) {
for (const { key, value } of info.attributes) {
entry.span.setAttribute(key, attributeValue(value));
}
}
Comment thread
pi0 marked this conversation as resolved.
if (error !== undefined) {
recordException(entry.span, error);
}
}
} finally {
entry.close();
}
},
{
onStart(info) {
let close!: () => void;
const done = new Promise<void>((resolve) => {
close = resolve;
});
let entry: PendingSpan | undefined;
tracing.enterSpan(info.name, (span) => {
entry = { span, close };
return done;
});
return entry;
},
}
);
});

/** OTLP `IAnyValue` (from the shared describers) β†’ Cloudflare attribute value. */
function attributeValue(value: IAnyValue): string | number | boolean | undefined {
if (value.stringValue != null) return value.stringValue;
if (value.intValue != null) return value.intValue;
if (value.doubleValue != null) return value.doubleValue;
if (value.boolValue != null) return value.boolValue;
}

/**
* OTEL exception semconv, flattened onto span attributes β€” the Cloudflare API
* has no span events, and `setOutcome` is not available yet.
*/
function recordException(span: Span, error: unknown): void {
const err = error as Partial<Error> | undefined;
if (typeof err?.name === "string") {
span.setAttribute("exception.type", err.name);
}
span.setAttribute(
"exception.message",
typeof err?.message === "string" ? err.message : String(error)
);
if (typeof err?.stack === "string") {
span.setAttribute("exception.stacktrace", err.stack);
}
}
3 changes: 3 additions & 0 deletions src/presets/vercel/runtime/telemetry/plugin.ts
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,9 @@ const pendingSpans = new Map<string, Span[]>();
*/
export default definePlugin(() => {
subscribeTracedChannels((info, startTimeUnixNano, error) => {
// Describer failed on the completed payload β€” nothing to report.
if (!info) return;

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Looks like a bug. Why are we even calling callback in this case?


const context = (globalThis as Record<symbol, RequestContextReader | undefined>)[
REQUEST_CONTEXT_SYMBOL
]?.get?.();
Expand Down
10 changes: 6 additions & 4 deletions src/runtime/internal/telemetry/logger-plugin.ts
Original file line number Diff line number Diff line change
Expand Up @@ -61,9 +61,10 @@ export default definePlugin((nitroApp: NitroApp) => {

subscribeTracedChannels((info, start, error) => {
const trace = als.getStore();
// No active request (e.g. storage during plugin setup, or the platform's
// outer request span that closes after the response) β€” nothing to attach to.
if (!trace) return;
// No span info (describer failed on the payload), or no active request
// (e.g. storage during plugin setup, or the platform's outer request span
// that closes after the response) β€” nothing to attach to.
if (!info || !trace) return;
trace.spans.push({
name: info.name,
attributes: info.attributes,
Expand Down Expand Up @@ -321,7 +322,8 @@ function ms(nanos: bigint): string {
return `${(Number(nanos) / 1e6).toFixed(2)}ms`;
}

function logFlat(info: SpanInfo, startTimeUnixNano: string, error: unknown): void {
function logFlat(info: SpanInfo | undefined, startTimeUnixNano: string, error: unknown): void {
if (!info) return;
const durationMs = Number(BigInt(Span.nowUnixNano()) - BigInt(startTimeUnixNano)) / 1e6;
const attrs = info.attributes.length ? ` ${formatAttributes(info.attributes)}` : "";
const line = `[trace] ${info.name} (${durationMs.toFixed(2)}ms)${attrs}`;
Expand Down
93 changes: 71 additions & 22 deletions src/runtime/internal/telemetry/subscribe.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,16 +3,34 @@ import { TRACED_CHANNELS } from "./channels.ts";
import { Span } from "./span.ts";

/**
* Called once per completed traced operation with the derived span info, the
* operation start time (unix nanoseconds, as an OTLP `*UnixNano` string) and the
* operation's error (`undefined` when it succeeded). A sink turns this into
* whatever its platform consumes β€” an OTLP export, a log line, …
* Called once per completed traced operation with the span info derived from
* the completed payload (`undefined` when the describer failed on it), the
* operation start time (unix nanoseconds, as an OTLP `*UnixNano` string), the
* operation's error (`undefined` when it succeeded) and the state returned by
* `onStart` (`undefined` without one). A sink turns this into whatever its
* platform consumes β€” an OTLP export, a log line, a platform span, …
*/
export type SpanSink = (info: SpanInfo, startTimeUnixNano: string, error: unknown) => void;
export type SpanSink<S = unknown> = (
info: SpanInfo | undefined,
startTimeUnixNano: string,
error: unknown,
state: S | undefined
) => void;

export interface SubscribeTracedChannelsOptions<S> {
/**
* Called synchronously when a traced operation starts, inside its execution
* context β€” where platform span APIs like Cloudflare's `enterSpan` must be
* called. Returned state is handed back to `onSpan` at completion.
*/
onStart?: (info: SpanInfo) => S | undefined;
}

/**
* Subscribes to the tracing channels declared in `TRACED_CHANNELS` (produced by
* h3, srvx, unstorage, …) and invokes `onSpan` for each completed operation.
* h3, srvx, unstorage, …) and invokes `onSpan` once per traced operation:
* normally at `asyncEnd`, or at `end` when the traced function threw
* synchronously (`tracePromise` never publishes `asyncEnd` in that case).
*
* A `tracingChannel(<name>)` publishes to plain named channels
* (`tracing:<name>:start`, `tracing:<name>:asyncEnd`, …). Subscribing to those
Expand All @@ -23,34 +41,65 @@ export type SpanSink = (info: SpanInfo, startTimeUnixNano: string, error: unknow
*
* A no-op when `node:diagnostics_channel` is unavailable (non-Node runtimes).
*/
export function subscribeTracedChannels(onSpan: SpanSink): void {
export function subscribeTracedChannels<S = unknown>(
onSpan: SpanSink<S>,
options?: SubscribeTracedChannelsOptions<S>
): void {
const diagnostics = globalThis.process?.getBuiltinModule?.("node:diagnostics_channel");
if (!diagnostics?.subscribe) return;

// Carry the start time from `start` to `asyncEnd` without mutating the producer's context object.
const starts = new WeakMap<object, string>();
const onStart = options?.onStart;

// Carry the start time (and any sink state) from `start` to completion
// without mutating the producer's context object.
const pending = new WeakMap<object, { start: string; state: S | undefined }>();

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

diagnostics.subscribe(`tracing:${name}:start`, (message) => {
starts.set(message as object, Span.nowUnixNano());
const entry = { start: Span.nowUnixNano(), state: undefined as S | undefined };
if (onStart) {
try {
entry.state = onStart(describe(name, message));
} catch {
// Malformed payload, or a sink failure (e.g. the platform refused to
// open a span) β€” no state; the completion callback still fires.
}
}
pending.set(message as object, entry);
});

diagnostics.subscribe(`tracing:${name}:asyncEnd`, (message) => {
const complete = (message: unknown) => {
const entry = pending.get(message as object);
if (entry === undefined) return;
pending.delete(message as object);

// Derive span name, kind and semantic attributes from the completed
// operation. A describer only throws on a payload shape it doesn't
// recognise (a producer that changed shape); still deliver the
// completion β€” without info β€” so stateful sinks can release their span.
let info: SpanInfo | undefined;
try {
const start = starts.get(message as object);
if (start === undefined) return;
starts.delete(message as object);

// Derive span name, kind and semantic attributes from the operation. A
// describer only throws on a payload shape it doesn't recognise (a
// producer that changed shape); drop that span via the catch below
// rather than emit a contentless one.
const info = describe(name, message);
onSpan(info, start, (message as { error?: unknown }).error);
info = describe(name, message);
} catch {}

try {
onSpan(info, entry.start, (message as { error?: unknown }).error, entry.state);
} catch {
// Malformed payload, or a sink failure β€” never break the traced operation.
// A sink failure must never break the traced operation.
}
};

diagnostics.subscribe(`tracing:${name}:asyncEnd`, complete);

// `tracePromise` never publishes `asyncEnd` when the traced function
// throws synchronously β€” only `end`, with `error` already set. In the
// normal async path `end` fires before the promise settles, while `error`
// is still unset, so the guard makes this a no-op there.
diagnostics.subscribe(`tracing:${name}:end`, (message) => {
if ((message as { error?: unknown }).error !== undefined) {
complete(message);
}
});
}
Expand Down
Loading
Loading