1import { CloudflareEnvironment } from "@/Cloudflare/CloudflareEnvironment";
2import * as Cloudflare from "@/Cloudflare/index.ts";
3import * as Test from "@/Test/Alchemy";
4import * as workers from "@distilled.cloud/cloudflare/workers";
5import { describe } from "alchemy-test";
6import * as ConfigProvider from "effect/ConfigProvider";
7import * as Effect from "effect/Effect";
8import * as pathe from "pathe";
9import { expectUrlContains } from "../Utils/Http.ts";
10import type { OtelSink } from "./fixtures/otel-collector-worker.ts";
11import OtelCustomWorker from "./fixtures/otel-custom-worker.ts";
12import OtelEventFlushWorker from "./fixtures/otel-event-flush-worker.ts";
13import OtelTracedWorker from "./fixtures/otel-traced-worker.ts";
14
15const { test } = Test.make({ providers: Cloudflare.providers() });
16
17const collectorMain = pathe.resolve(
18 import.meta.dirname,
19 "fixtures/otel-collector-worker.ts",
20);
21
22// The collector must be reachable *from another Worker*: same-account
23// worker-to-worker fetches over workers.dev are blocked (error 1042), so
24// the collector gets a deterministic custom hostname on the standing test
25// zone instead.
26const zoneName =
27 process.env.CLOUDFLARE_TEST_DNS_ZONE_NAME ?? "alchemy-test-2.us";
28const collectorHost = `otel-collector-${process.env.PULL_REQUEST ?? process.env.USER}.${zoneName}`;
29
30// The OTLP sink both traced fixtures export to. Declared identically in
31// both deploy steps so the second deploy keeps it.
32const collectorWorker = () =>
33 Cloudflare.Worker("OtelCollector", {
34 main: collectorMain,
35 env: {
36 SINK: Cloudflare.DurableObject<OtelSink>("OtelSink"),
37 },
38 domain: collectorHost,
39 compatibility: { date: "2024-09-23" },
40 });
41
42describe(
43 "Cloudflare Worker Telemetry",
44 { tags: ["provider:cloudflare", "provider:cloudflare:worker", "live"] },
45 () => {
46 test.provider(
47 "OTLP telemetry exports per request (otlp binding + custom Layer)",
48 (stack) =>
49 Effect.gen(function* () {
50 yield* stack.destroy();
51
52 // Deploy the collector first: the traced fixtures resolve
53 // `COLLECTOR_URL` from the deployer environment at
54 // deploy time, so its URL must exist before they deploy.
55 const collector = yield* stack.deploy(
56 Effect.gen(function* () {
57 return yield* collectorWorker();
58 }),
59 );
60 // Exporters (running inside the traced Workers) push to the custom
61 // domain; the test reads back over workers.dev, which is reachable
62 // from outside but blocked worker-to-worker (error 1042). With a
63 // custom domain attached, `collector.url` is the domain, so derive
64 // the workers.dev URL from the account subdomain.
65 const { accountId } = yield* yield* CloudflareEnvironment;
66 const { subdomain } = yield* workers.getSubdomain({ accountId });
67 const collectorUrl = `https://${collectorHost}`;
68 const collected = `https://${collector.workerName}.${subdomain}.workers.dev/collected`;
69
70 yield* expectUrlContains(collected, "otel-collector-ok", {
71 timeout: "180 seconds",
72 });
73
74 // The traced fixtures read the collector URL via `Config` at deploy
75 // time. The test harness snapshots its ConfigProvider before the
76 // test body runs, so layer the just-learned collector URL on top of
77 // the current provider for this deploy only.
78 const currentConfig = yield* ConfigProvider.ConfigProvider;
79 const { traced, custom, flush } = yield* stack
80 .deploy(
81 Effect.gen(function* () {
82 yield* collectorWorker();
83 const traced = yield* OtelTracedWorker;
84 const custom = yield* OtelCustomWorker;
85 const flush = yield* OtelEventFlushWorker;
86 return { traced, custom, flush };
87 }),
88 )
89 .pipe(
90 Effect.provideService(
91 ConfigProvider.ConfigProvider,
92 ConfigProvider.orElse(
93 ConfigProvider.fromUnknown({
94 COLLECTOR_URL: collectorUrl,
95 OTLP_EVENT_FLUSH_URL: `${collectorUrl}/v1/traces`,
96 }),
97 currentConfig,
98 ),
99 ),
100 );
101 const tracedUrl = traced.url as string;
102 const customUrl = custom.url as string;
103
104 // Wait for the traced workers to serve (fresh workers.dev
105 // subdomains can take minutes to start routing), then for the
106 // collector's custom domain to be reachable from *inside* a Worker
107 // (DNS + edge certificate issuance).
108 yield* expectUrlContains(`${tracedUrl}/probe`, '"status":200', {
109 timeout: "240 seconds",
110 label: "collector reachable from a Worker",
111 });
112
113 // Drive one traced request on each worker now that exports can
114 // reach the collector.
115 yield* expectUrlContains(`${tracedUrl}/work`, "did-work", {
116 timeout: "180 seconds",
117 });
118 yield* expectUrlContains(`${customUrl}/work`, "custom-did-work", {
119 timeout: "180 seconds",
120 });
121
122 // The otlp binding-layer path: the request scope finalizer flushes
123 // the OTLP buffers via ctx.waitUntil, so everything the request
124 // produced arrives at the collector shortly after the response.
125 yield* expectUrlContains(collected, "otel-traced-test", {
126 timeout: "120 seconds",
127 label: "default-path service.name",
128 });
129 // http.server root span from the HttpMiddleware tracer.
130 yield* expectUrlContains(collected, "http.server GET");
131 // Child span from Effect.fn instrumentation.
132 yield* expectUrlContains(collected, "test.child-span");
133 // Log record shipped by the OTLP logger.
134 yield* expectUrlContains(collected, "did-work-log");
135 // Default resource attributes stamped by the exporter.
136 yield* expectUrlContains(collected, "alchemy.stack");
137 // Exporter composition: the fixture merges a second otlp layer
138 // whose traces url lands under /v1/second-traces — same spans,
139 // second destination.
140 yield* expectUrlContains(collected, "second-traces");
141
142 // Custom exporter path: Effect.provide(Telemetry.layer(...)) on
143 // the Worker init Effect replaces the built-in OTLP exporter.
144 yield* expectUrlContains(collected, "otel-custom-test", {
145 timeout: "120 seconds",
146 label: "custom-path service.name",
147 });
148 yield* expectUrlContains(collected, "custom.child-span");
149 yield* expectUrlContains(collected, "custom-work-log");
150
151 // Durable Object event paths on deployed workerd: the Worker calls
152 // its DO over HTTP and over RPC; each DO event is its own telemetry
153 // scope. `state.waitUntil` is a no-op in DOs, so DO batches only
154 // arrive if the DurableObjectBridge's flush actually completes
155 // before/despite the event's I/O context winding down.
156 const flushUrl = flush.url as string;
157 yield* expectUrlContains(
158 `${flushUrl}/`,
159 "worker-saw:durable-object-ok",
160 {
161 timeout: "240 seconds",
162 label: "event-flush worker via DO fetch",
163 },
164 );
165 yield* expectUrlContains(
166 `${flushUrl}/rpc`,
167 "worker-saw:durable-object-rpc-ok",
168 {
169 timeout: "120 seconds",
170 label: "event-flush worker via DO rpc",
171 },
172 );
173 // The DO fetch event's child span…
174 yield* expectUrlContains(collected, "otel-event-flush.child", {
175 timeout: "120 seconds",
176 label: "deployed DO fetch event batch",
177 });
178 // …and the DO RPC method event's child span both reach the
179 // collector.
180 yield* expectUrlContains(collected, "otel-event-flush.rpc", {
181 timeout: "120 seconds",
182 label: "deployed DO rpc event batch",
183 });
184 }),
185 { timeout: 600_000 },
186 );
187 },
188);