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 */
16import * as NodeServices from "@effect/platform-node/NodeServices";
17import { describe, expect, it, layer } from "@effect/vitest";
18import * as Effect from "effect/Effect";
19import * as Layer from "effect/Layer";
20import * as Schedule from "effect/Schedule";
21import * as FetchHttpClient from "effect/http/FetchHttpClient";
22import * as Docker from "../Docker.ts";
23import * as Globals from "../globals/Globals.ts";
24import * as Internet from "../globals/Internet.ts";
25import * as Storage from "../globals/Storage.ts";
26import * as Paths from "../internal/Paths.ts";
27import * as Runtime from "../Runtime.ts";
28import * as Workerd from "../workerd/Workerd.ts";
29import {
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. */
40interface 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`. */
48const CONSUMER_SCRIPT = `
49const events = [];
50export 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
62const producerScript = (marker: string) => `
63export default {
64 fetch() {
65 console.log(${JSON.stringify(marker)});
66 return new Response("hello from producer");
67 },
68};
69`;
70
71const baseWorker = {
72 compatibilityDate: "2026-03-10",
73 compatibilityFlags: [],
74 bindings: [],
75} as const;
76
77const consumerWorker = (name: string) => ({
78 ...baseWorker,
79 name,
80 modules: [
81 { name: "main.js", type: "ESModule", content: CONSUMER_SCRIPT } as const,
82 ],
83});
84
85const 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
98layer(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.
199describe("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});