| 1 | import { CloudflareEnvironment } from "@/Cloudflare/CloudflareEnvironment"; |
| 2 | import * as Cloudflare from "@/Cloudflare/index.ts"; |
| 3 | import * as Test from "@/Test/Alchemy"; |
| 4 | import * as workers from "@distilled.cloud/cloudflare/workers"; |
| 5 | import { describe } from "alchemy-test"; |
| 6 | import * as ConfigProvider from "effect/ConfigProvider"; |
| 7 | import * as Effect from "effect/Effect"; |
| 8 | import * as pathe from "pathe"; |
| 9 | import { expectUrlContains } from "../Utils/Http.ts"; |
| 10 | import type { OtelSink } from "./fixtures/otel-collector-worker.ts"; |
| 11 | import OtelCustomWorker from "./fixtures/otel-custom-worker.ts"; |
| 12 | import OtelEventFlushWorker from "./fixtures/otel-event-flush-worker.ts"; |
| 13 | import OtelTracedWorker from "./fixtures/otel-traced-worker.ts"; |
| 14 | |
| 15 | const { test } = Test.make({ providers: Cloudflare.providers() }); |
| 16 | |
| 17 | const 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. |
| 26 | const zoneName = |
| 27 | process.env.CLOUDFLARE_TEST_DNS_ZONE_NAME ?? "alchemy-test-2.us"; |
| 28 | const 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. |
| 32 | const 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 | |
| 42 | describe( |
| 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 | ); |