1/**
2 * Cloudflare Workers Observability tracer as a binding layer — the
3 * platform-native counterpart of `Axiom.Telemetry`.
4 */
5import * as Data from "effect/Data";
6import * as Effect from "effect/Effect";
7import * as Layer from "effect/Layer";
8import * as Namespace from "../../Namespace.ts";
9import type { ResourceBinding } from "../../Resource.ts";
10import * as AlchemyTelemetry from "../../Telemetry.ts";
11import { getCompatibility } from "./Compatibility.ts";
12import { layer as cloudflareTracerLayer } from "./CloudflareTracer.ts";
13import { Worker, type WorkerProps } from "./Worker.ts";
14
15/**
16 * Earliest compatibility date that exposes `tracing.startActiveSpan`
17 * (`cloudflare:workers`). ISO dates compare lexically.
18 */
19export const MIN_CLOUDFLARE_TRACING_DATE = "2026-07-28";
20
21/**
22 * Thrown when {@link Telemetry} is provided on a Worker whose resolved
23 * compatibility date is older than {@link MIN_CLOUDFLARE_TRACING_DATE}.
24 */
25export class CloudflareTelemetryCompatibilityError extends Data.TaggedError(
26 "CloudflareTelemetryCompatibilityError",
27)<{
28 date: string;
29 minimumDate: string;
30}> {
31 override get message() {
32 return (
33 `Cloudflare.Telemetry() requires compatibility date >= ${this.minimumDate} ` +
34 `(tracing.startActiveSpan). This Worker is pinned to ${this.date}. ` +
35 `Set compatibility: { date: "${this.minimumDate}" } (or later) on the Worker.`
36 );
37 }
38}
39
40export interface CloudflareTelemetryProps {
41 /**
42 * Enable Workers Traces on the host Worker.
43 * @default true
44 */
45 enabled?: boolean;
46 /**
47 * Head-based sampling rate for Workers Traces (`0`–`1`).
48 * Omitted unless set — Cloudflare's default applies.
49 */
50 headSamplingRate?: number;
51 /**
52 * Persist traces in the Cloudflare dashboard. Omitted unless set.
53 * Pass `false` to export-only (no dashboard persist).
54 */
55 persist?: boolean;
56}
57
58const tracesFromProps = (props: CloudflareTelemetryProps) => ({
59 enabled: props.enabled ?? true,
60 ...(props.headSamplingRate !== undefined
61 ? { headSamplingRate: props.headSamplingRate }
62 : {}),
63 ...(props.persist !== undefined ? { persist: props.persist } : {}),
64});
65
66/**
67 * Fail deploy when `Cloudflare.Telemetry()` is bound and the Worker's
68 * resolved compatibility date is too old for `tracing.startActiveSpan`.
69 *
70 * @internal
71 */
72export const assertCloudflareTelemetryCompatibility = (
73 news: WorkerProps,
74 bindings: ReadonlyArray<ResourceBinding<Worker["Binding"]>>,
75): Effect.Effect<void, CloudflareTelemetryCompatibilityError> => {
76 const hasBind = bindings.some((b) => b.data.observability?.traces != null);
77 if (!hasBind) return Effect.void;
78 const { date } = getCompatibility(news);
79 if (date >= MIN_CLOUDFLARE_TRACING_DATE) return Effect.void;
80 return Effect.fail(
81 new CloudflareTelemetryCompatibilityError({
82 date,
83 minimumDate: MIN_CLOUDFLARE_TRACING_DATE,
84 }),
85 );
86};
87
88/**
89 * Mirror Effect spans into Cloudflare Workers Observability.
90 *
91 * A binding layer: at deploy time it turns on `observability.traces` on the
92 * host Worker (the same path as `Cloudflare.cache()`) and registers a
93 * per-event Effect Tracer. Cloudflare auto-instruments fetch/KV/R2/D1;
94 * `Effect.withSpan` / `Effect.fn` frames nest in that waterfall. Cloudflare
95 * owns sampling and export — no OTLP URL, flush, or Wrangler block.
96 *
97 * Until the global default compatibility date is raised past 2026-07-28,
98 * pin `compatibility: { date: "2026-08-25" }` (or later) or deploy fails.
99 * `alchemy dev` does not gate the date: what a local Worker records is the
100 * local runtime's concern.
101 *
102 * Compose it into the Function/Worker's single `Effect.provide`:
103 *
104 * ### Enabling native tracing
105 * **Example:** Effect spans in the Workers Observability waterfall
106 * ```typescript
107 * export default Cloudflare.Worker(
108 * "Worker",
109 * {
110 * main: import.meta.url,
111 * compatibility: { date: "2026-08-25" },
112 * },
113 * Effect.gen(function* () {
114 * return {
115 * fetch: Effect.gen(function* () {
116 * yield* doWork().pipe(Effect.withSpan("operation"));
117 * return HttpServerResponse.text("ok");
118 * }),
119 * };
120 * }).pipe(
121 * Effect.provide(
122 * Layer.mergeAll(
123 * Cloudflare.R2.ReadWriteBucketBinding,
124 * Cloudflare.Telemetry(),
125 * ),
126 * ),
127 * ),
128 * );
129 * ```
130 *
131 * ### Combining with other exporters
132 * **Example:** Native traces plus another exporter's logs and metrics
133 * ```typescript
134 * Effect.provide(
135 * Layer.mergeAll(
136 * Cloudflare.Telemetry(),
137 * // omit `traces` — Cloudflare.Telemetry provides the Tracer
138 * Axiom.Telemetry({ token: Ingest, logs: Logs, metrics: Metrics }),
139 * ),
140 * )
141 * ```
142 *
143 * To ship the same waterfall (Effect + platform spans) elsewhere, export
144 * it from Cloudflare with `Cloudflare.Workers.ObservabilityDestination`
145 * rather than sending a second Worker-side OTLP trace stream.
146 *
147 * @layer
148 * @provides Tracer.Tracer
149 * @product Workers
150 * @category Workers & Compute
151 */
152export const Telemetry = (
153 props: CloudflareTelemetryProps = {},
154): Layer.Layer<never, never, Worker> =>
155 Layer.unwrap(
156 Effect.gen(function* () {
157 const host = yield* Worker;
158 if (!globalThis.__ALCHEMY_RUNTIME__) {
159 yield* Namespace.push(
160 host.LogicalId,
161 host.bind("Cloudflare.Telemetry", {
162 observability: { traces: tracesFromProps(props) },
163 }),
164 );
165 }
166 // The runtime bridges build the registered Layer per event with a
167 // forked MemoMap, so the Tracer is naturally fresh per invocation.
168 return AlchemyTelemetry.layer(cloudflareTracerLayer);
169 }),
170 );