Skip to content

Settings, logs and Tail Workers

A Worker has settings beyond its code: where it runs, its limits, what it logs. Here they are on a Worker, with a second Worker that watches it:

test/fixtures/watch/api.ts
export default Worker.make('api', {
fetch,
placement: { mode: 'smart' },
limits: { cpuMs: 50 },
observability: (env) => (env === 'production' ? { logs: { headSamplingRate: 1 } } : { enabled: false }),
tailConsumers: ['logger'],
}).bind();

That’s the whole of test/fixtures/watch’s api: it runs near what it calls, stops an invocation after 50 ms of CPU, keeps every log in production and none in previews, and sends each invocation to the Worker logger.

Each takes Cloudflare’s names, so Cloudflare’s docs for them apply as they are. Each is one value for every environment, or a function of the environment’s name, as observability is above.

api.ts
export default Worker.make('api', {
fetch,
placement: { region: 'aws:us-east-1' },
limits: { cpuMs: 50, subrequests: 100 },
observability: { logs: { enabled: true, invocationLogs: true, headSamplingRate: 0.1 }, traces: { enabled: true } },
logpush: (env) => env === 'production',
tailConsumers: ['logger'],
}).bind();
  • placement: where the Worker runs. Near the visitor by default; { mode: 'smart' } near what it calls (a database far away, say); or pinned near a region, a host or a hostname.
  • limits: the most CPU time an invocation may take (cpuMs), and how many subrequests it may make (subrequests).
  • observability: Workers Logs and traces, each on or off, sampled (headSamplingRate, 0 to 1), with or without the invocation’s own log line.
  • logpush: whether its invocations go to Logpush. The Logpush job itself is made apart, in Cloudflare.
  • tailConsumers: its Tail Workers, by name. More below.

Leaving a setting out means Cloudflare’s default. So taking one out of your code undoes it on the next ship, rather than leaving the old value behind. What you read is what’s deployed.

placement and limits go with each version, so they roll out with its traffic, step by step, and roll back with it. The others are the Worker’s, not a version’s, so they’re set once the new version is live.

A Tail Worker gets a record of every invocation of the Workers it tails, after each one: its outcome, its console.log lines, its exceptions. It’s the place for your own alerting, or for sending logs somewhere Cloudflare doesn’t.

Give a Worker a tail handler. Its invocations arrive as Worker.TailEvents:

test/fixtures/watch/logger.ts
const tail = Effect.gen(function* () {
const seen = yield* Seen;
for (const event of yield* Worker.TailEvents) {
const said = event.logs.map((l) => (Array.isArray(l.message) ? l.message.join(' ') : String(l.message)));
yield* seen.put('last', JSON.stringify({ script: event.scriptName, outcome: event.outcome, logs: said }));
}
}).pipe(Effect.orDie);
const fetch = Effect.map(Effect.orDie(Effect.flatMap(Seen, (seen) => seen.get('last'))), (last) =>
new Response(Option.getOrElse(last, () => 'nothing yet')),
);
export default Worker.make('logger', { tail, fetch }).bind(Seen);

This one notes the last invocation in KV, and answers with it. A tail handler is an Effect like any other: it binds what it uses, and gets its own Invocation and scope.

Here’s one that only cares about failures:

logger.ts
const tail = Effect.gen(function* () {
for (const event of yield* Worker.TailEvents) {
for (const exception of event.exceptions) {
yield* Effect.logError(`${event.scriptName}: ${exception.name}: ${exception.message}`);
}
}
});
export default Worker.make('logger', { tail }).bind();

tailConsumers: ['logger'] names a Tail Worker:

  • Of the same app, by its name in the app. It’s renamed per environment with the rest (watch-pr-1-logger tails watch-pr-1-api), and it deploys first, since Cloudflare needs it to exist. If it has no tail handler, infra check says tail-handler-missing.
  • Outside the app, by its name in the account, taken on trust.

infra graph shows who tails whom:

$ pnpm infra graph test/fixtures/watch/app.ts
watch-logger (fetch, tail)
SEEN: kv watch-seen
watch-api (fetch)
tailed by logger

Worker.runTail hands a Tail Worker events, in memory:

test/settings.test.ts
test("in memory, a Tail Worker's handler gets the invocations, as TailEvents", async () => {
const layer = Kv.memory(Seen, []);
const event = { scriptName: 'watch-api', outcome: 'ok', eventTimestamp: 0, event: null, logs: [{ timestamp: 0, level: 'log', message: ['served'] }], exceptions: [] };
const last = await Effect.runPromise(
Effect.gen(function* () {
yield* Worker.runTail(logger, [event]);
return yield* Effect.orDie((yield* Seen).get('last'));
}).pipe(Effect.provide(layer)),
);
assert.deepEqual(Option.map(last, JSON.parse), Option.some({ script: 'watch-api', outcome: 'ok', logs: ['served'] }));
});

And infra dev wires the app’s own Tail Workers, so a request to api reaches logger locally too.

A queue consumer’s maxConcurrency caps how many batches Cloudflare runs at once, across invocations. Left out, Cloudflare scales up to its maximum.

test/settings.test.ts
class Jobs extends Queue.Make<Jobs>()('jobs', { schema: Schema.String }) {}
const worker = Worker.make('worker', { queues: [Jobs.consume(() => Effect.void, { maxConcurrency: 3 })] }).bind();

Useful when a consumer talks to something that can’t take a crowd. The rest of a consumer’s settings are in Queues.

  • Checked live: the watch fixture shipped with each setting, and Cloudflare kept them all; a request to the Worker reached its Tail Worker, log line and all; a ship without the settings undid them.
  • Locally, a tail event has no script name (scriptName is null under infra dev). On Cloudflare it’s there.
  • A Tail Worker outside the app isn’t run by infra dev; it says so when it starts.
  • Cloudflare’s newer streaming Tail Workers aren’t deployed yet.