| 1 | /** |
| 2 | * Tests for tail workers (`RuntimeWorker.tails`, the deployed `tail_consumers` |
| 3 | * setting — see the "tail consumer called" case in |
| 4 | * `workers-sdk/packages/miniflare/test/index.spec.ts`). |
| 5 | * |
| 6 | * Topology delta from Miniflare: Miniflare hosts producer and consumer in one |
| 7 | * workerd instance and points `tails` at the consumer's service directly. Here |
| 8 | * every worker runs in its own workerd instance, so `tails` designators point |
| 9 | * at the registry proxy's `ExternalService` entrypoint, which forwards |
| 10 | * `tail()` events to the consumer's process via the workerd debug port — the |
| 11 | * same path cross-process service bindings take. Consequently a consumer that |
| 12 | * is not running yet is not a config error: events are dropped with a warning |
| 13 | * until the consumer registers itself (mirroring `wrangler dev` registry |
| 14 | * semantics), which the second test pins. |
| 15 | */ |
| 16 | import * as NodeServices from "@effect/platform-node/NodeServices"; |
| 17 | import { describe, expect, it, layer } from "@effect/vitest"; |
| 18 | import * as Effect from "effect/Effect"; |
| 19 | import * as Layer from "effect/Layer"; |
| 20 | import * as Schedule from "effect/Schedule"; |
| 21 | import * as FetchHttpClient from "effect/http/FetchHttpClient"; |
| 22 | import * as Docker from "../Docker.ts"; |
| 23 | import * as Globals from "../globals/Globals.ts"; |
| 24 | import * as Internet from "../globals/Internet.ts"; |
| 25 | import * as Storage from "../globals/Storage.ts"; |
| 26 | import * as Paths from "../internal/Paths.ts"; |
| 27 | import * as Runtime from "../Runtime.ts"; |
| 28 | import * as Workerd from "../workerd/Workerd.ts"; |
| 29 | import { |
| 30 | configProvider, |
| 31 | localRuntimeLayer, |
| 32 | makeTempDirectory, |
| 33 | poll, |
| 34 | PredicateFailed, |
| 35 | startTestWorker, |
| 36 | waitForRegistryEntry, |
| 37 | } from "./helpers/runtime.ts"; |
| 38 | |
| 39 | /** The shape of a JSON-serialized `TraceItem` as recorded by the consumer. */ |
| 40 | interface RecordedTraceItem { |
| 41 | outcome?: string; |
| 42 | scriptName?: string | null; |
| 43 | logs?: Array<{ level?: string; message?: Array<unknown> }>; |
| 44 | event?: { request?: { url?: string; method?: string } } | null; |
| 45 | } |
| 46 | |
| 47 | /** Records every `tail()` batch and serves it back via `GET /events`. */ |
| 48 | const CONSUMER_SCRIPT = ` |
| 49 | const events = []; |
| 50 | export default { |
| 51 | fetch(request) { |
| 52 | const url = new URL(request.url); |
| 53 | if (url.pathname === "/events") return Response.json(events); |
| 54 | return new Response("consumer"); |
| 55 | }, |
| 56 | tail(items) { |
| 57 | events.push(...items); |
| 58 | }, |
| 59 | }; |
| 60 | `; |
| 61 | |
| 62 | const producerScript = (marker: string) => ` |
| 63 | export default { |
| 64 | fetch() { |
| 65 | console.log(${JSON.stringify(marker)}); |
| 66 | return new Response("hello from producer"); |
| 67 | }, |
| 68 | }; |
| 69 | `; |
| 70 | |
| 71 | const baseWorker = { |
| 72 | compatibilityDate: "2026-03-10", |
| 73 | compatibilityFlags: [], |
| 74 | bindings: [], |
| 75 | } as const; |
| 76 | |
| 77 | const consumerWorker = (name: string) => ({ |
| 78 | ...baseWorker, |
| 79 | name, |
| 80 | modules: [ |
| 81 | { name: "main.js", type: "ESModule", content: CONSUMER_SCRIPT } as const, |
| 82 | ], |
| 83 | }); |
| 84 | |
| 85 | const producerWorker = (name: string, consumer: string, marker: string) => ({ |
| 86 | ...baseWorker, |
| 87 | name, |
| 88 | tails: [consumer], |
| 89 | modules: [ |
| 90 | { |
| 91 | name: "main.js", |
| 92 | type: "ESModule", |
| 93 | content: producerScript(marker), |
| 94 | } as const, |
| 95 | ], |
| 96 | }); |
| 97 | |
| 98 | layer(localRuntimeLayer, { excludeTestServices: true })( |
| 99 | "Tail workers", |
| 100 | (it) => { |
| 101 | it.effect( |
| 102 | "delivers trace events from a producer to its tail consumer", |
| 103 | () => |
| 104 | Effect.gen(function* () { |
| 105 | const consumer = yield* startTestWorker( |
| 106 | consumerWorker("tails-consumer"), |
| 107 | ); |
| 108 | yield* waitForRegistryEntry({ |
| 109 | kind: "worker", |
| 110 | scriptName: "tails-consumer", |
| 111 | }); |
| 112 | |
| 113 | const producer = yield* startTestWorker( |
| 114 | producerWorker( |
| 115 | "tails-producer", |
| 116 | "tails-consumer", |
| 117 | "tails-basic-marker", |
| 118 | ), |
| 119 | ); |
| 120 | expect(yield* producer.fetchText("/hello")).toBe( |
| 121 | "hello from producer", |
| 122 | ); |
| 123 | |
| 124 | // Tail events are delivered after the response completes; poll until |
| 125 | // the consumer has recorded the batch. |
| 126 | const events = yield* poll<Array<RecordedTraceItem>>( |
| 127 | consumer, |
| 128 | "/events", |
| 129 | (items) => items.length > 0, |
| 130 | 20_000, |
| 131 | ); |
| 132 | const item = events[0]; |
| 133 | expect(item.outcome).toBe("ok"); |
| 134 | expect(item.logs?.map((log) => log.message)).toContainEqual([ |
| 135 | "tails-basic-marker", |
| 136 | ]); |
| 137 | expect(item.event?.request?.url).toContain("/hello"); |
| 138 | }), |
| 139 | { timeout: 60_000 }, |
| 140 | ); |
| 141 | |
| 142 | it.effect( |
| 143 | "drops events while the consumer is down and delivers once it starts", |
| 144 | () => |
| 145 | Effect.gen(function* () { |
| 146 | // The consumer is not running yet: starting the producer and serving |
| 147 | // requests must both succeed — events are dropped with a warning. |
| 148 | const producer = yield* startTestWorker( |
| 149 | producerWorker( |
| 150 | "tails-late-producer", |
| 151 | "tails-late-consumer", |
| 152 | "tails-late-marker", |
| 153 | ), |
| 154 | ); |
| 155 | expect(yield* producer.fetchText("/hello")).toBe( |
| 156 | "hello from producer", |
| 157 | ); |
| 158 | |
| 159 | const consumer = yield* startTestWorker( |
| 160 | consumerWorker("tails-late-consumer"), |
| 161 | ); |
| 162 | yield* waitForRegistryEntry({ |
| 163 | kind: "worker", |
| 164 | scriptName: "tails-late-consumer", |
| 165 | }); |
| 166 | |
| 167 | // The producer's registry proxy picks the consumer up asynchronously; |
| 168 | // keep producing until an event lands. |
| 169 | const events = yield* producer.fetch("/hello").pipe( |
| 170 | Effect.andThen( |
| 171 | consumer.fetchJson<Array<RecordedTraceItem>>("/events"), |
| 172 | ), |
| 173 | Effect.flatMap((items) => |
| 174 | items.length > 0 |
| 175 | ? Effect.succeed(items) |
| 176 | : Effect.fail(new PredicateFailed({ value: items })), |
| 177 | ), |
| 178 | Effect.retry({ |
| 179 | while: (error) => error._tag === "PredicateFailed", |
| 180 | schedule: Schedule.spaced("250 millis"), |
| 181 | times: 120, |
| 182 | }), |
| 183 | ); |
| 184 | expect(events[0].logs?.map((log) => log.message)).toContainEqual([ |
| 185 | "tails-late-marker", |
| 186 | ]); |
| 187 | }), |
| 188 | { timeout: 90_000 }, |
| 189 | ); |
| 190 | }, |
| 191 | ); |
| 192 | |
| 193 | // A runtime composed without the registry proxy plugin (`tails` has nothing |
| 194 | // to resolve through) must fail fast with a typed `ConfigError` instead of |
| 195 | // silently starting a worker whose tail events go nowhere. This lives outside |
| 196 | // the `layer(localRuntimeLayer)` suite: `PluginContext.make` also picks |
| 197 | // plugins up from the ambient effect context, so the suite's registry proxy |
| 198 | // would leak into the deliberately registry-free runtime below. |
| 199 | describe("Tail workers without a registry proxy", () => { |
| 200 | const noRegistryRuntimeLayer = Runtime.RuntimeLive.pipe( |
| 201 | Layer.provide(Globals.GlobalsLive), |
| 202 | Layer.provide( |
| 203 | Layer.effect( |
| 204 | Storage.Storage, |
| 205 | Effect.suspend(() => makeTempDirectory()).pipe( |
| 206 | Effect.map(Storage.make), |
| 207 | ), |
| 208 | ), |
| 209 | ), |
| 210 | Layer.provide(Internet.InternetLive), |
| 211 | Layer.provideMerge(Paths.PathsLive), |
| 212 | Layer.provideMerge(Docker.DockerLive), |
| 213 | Layer.provide(Workerd.WorkerdLive), |
| 214 | Layer.provide(configProvider()), |
| 215 | Layer.provideMerge( |
| 216 | Layer.mergeAll(NodeServices.layer, FetchHttpClient.layer), |
| 217 | ), |
| 218 | ); |
| 219 | |
| 220 | it.effect( |
| 221 | "fails with ConfigError when tails are declared without a registry proxy", |
| 222 | () => |
| 223 | Effect.gen(function* () { |
| 224 | const runtime = yield* Runtime.Runtime; |
| 225 | const error = yield* runtime |
| 226 | .start(producerWorker("tails-no-registry", "tails-nowhere", "unused")) |
| 227 | .pipe(Effect.flip); |
| 228 | expect(error._tag).toBe("ConfigError"); |
| 229 | expect(error.subtag).toBe("PluginNotFound"); |
| 230 | }).pipe(Effect.provide(noRegistryRuntimeLayer), Effect.scoped), |
| 231 | { timeout: 30_000 }, |
| 232 | ); |
| 233 | }); |