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:
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.
The settings
Section titled “The settings”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.
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 aregion, ahostor ahostname.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, in Effect
Section titled “A Tail Worker, in Effect”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:
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:
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();Naming it
Section titled “Naming it”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-loggertailswatch-pr-1-api), and it deploys first, since Cloudflare needs it to exist. If it has notailhandler,infra checksaystail-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.tswatch-logger (fetch, tail) SEEN: kv watch-seenwatch-api (fetch) tailed by loggerTest it
Section titled “Test it”Worker.runTail hands a Tail Worker events, in memory:
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.
One more knob: a queue’s concurrency
Section titled “One more knob: a queue’s concurrency”A queue consumer’s maxConcurrency caps how many batches Cloudflare runs
at once, across invocations. Left out, Cloudflare scales up to its
maximum.
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.
Good to know
Section titled “Good to know”- 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 (
scriptNameisnullunderinfra 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.
- Plan and ship: how a setting rolls out.
- Local development