1/**
2 * Effect Tracer that mirrors `Effect.withSpan` / `Effect.fn` spans into
3 * Cloudflare Workers Observability via `tracing.startActiveSpan`.
4 *
5 * Built once per event (see {@link Telemetry} + `buildEventTelemetry`).
6 * Captures the invocation's async context; do not cache the built Tracer
7 * across requests. Not exported from the Cloudflare barrel.
8 */
9import { AsyncLocalStorage } from "node:async_hooks";
10import * as Cause from "effect/Cause";
11import * as Effect from "effect/Effect";
12import * as Exit from "effect/Exit";
13import * as Layer from "effect/Layer";
14import * as Option from "effect/Option";
15import * as Predicate from "effect/Predicate";
16import * as Tracer from "effect/Tracer";
17import cloudflare_workers from "./cloudflare_workers.ts";
18
19type SpanOptions = Parameters<Tracer.Tracer["span"]>[0];
20type RunInContext = ReturnType<typeof AsyncLocalStorage.snapshot>;
21type CloudflareSpan = ReturnType<
22 (typeof import("cloudflare:workers"))["tracing"]["startSpan"]
23>;
24
25class Span extends Tracer.NativeSpan {
26 constructor(
27 options: SpanOptions,
28 readonly runInContext: RunInContext,
29 readonly cloudflareSpan?: CloudflareSpan,
30 ) {
31 // Cascade Cloudflare's decision: an untraced invocation (`isTraced`
32 // false — e.g. traces disabled; head sampling is applied later, at
33 // ingestion) or a runtime without the API marks the Effect span
34 // unsampled, so descendants skip `startActiveSpan` entirely.
35 super({
36 ...options,
37 sampled: options.sampled && (cloudflareSpan?.isTraced ?? false),
38 });
39 }
40
41 override attribute(key: string, value: unknown): void {
42 super.attribute(key, value);
43 if (
44 Predicate.isString(value) ||
45 Predicate.isNumber(value) ||
46 Predicate.isBoolean(value)
47 ) {
48 this.cloudflareSpan?.setAttribute(key, value);
49 }
50 }
51
52 override end(endTime: bigint, exit: Exit.Exit<unknown, unknown>): void {
53 super.end(endTime, exit);
54 this.cloudflareSpan?.setAttribute(
55 "effect.exit",
56 Exit.isSuccess(exit)
57 ? "success"
58 : Cause.hasInterruptsOnly(exit.cause)
59 ? "interrupted"
60 : "failure",
61 );
62 this.cloudflareSpan?.end();
63 }
64}
65
66/**
67 * Per-event Tracer Layer. Cloudflare owns sampling and export; scalar
68 * attributes are forwarded; events, links, and non-scalars stay
69 * Effect-local. Completion is recorded as `effect.exit`. Effect trace/span
70 * IDs are independent of Cloudflare's opaque IDs.
71 */
72export const layer: Layer.Layer<never> = Layer.effect(
73 Tracer.Tracer,
74 Effect.gen(function* () {
75 // `tracing` only exists on compatibility dates >= 2026-07-28. Deploy
76 // already rejects older dates (`assertCloudflareTelemetryCompatibility`),
77 // so a missing API here (an old local workerd) just keeps spans
78 // Effect-local.
79 const { tracing } = yield* cloudflare_workers;
80 const invocationContext = AsyncLocalStorage.snapshot();
81 const contextFor = (span: Tracer.AnySpan | undefined): RunInContext => {
82 while (span?._tag === "Span") {
83 if (span instanceof Span) return span.runInContext;
84 span = Option.getOrUndefined(span.parent);
85 }
86 return invocationContext;
87 };
88
89 return Tracer.make({
90 span(options) {
91 const parentContext = options.root
92 ? invocationContext
93 : contextFor(Option.getOrUndefined(options.parent));
94
95 if (!options.sampled || tracing?.startActiveSpan === undefined) {
96 return new Span(options, parentContext);
97 }
98
99 return parentContext(() =>
100 tracing.startActiveSpan(
101 options.name,
102 (span) => new Span(options, AsyncLocalStorage.snapshot(), span),
103 ),
104 );
105 },
106 context(primitive, fiber) {
107 return contextFor(fiber.cache.span)(() =>
108 primitive["~effect/Effect/evaluate"](fiber),
109 );
110 },
111 });
112 }),
113);