Streaming Worker Logs with wrangler tail

After working through this guide you will be able to attach a live log stream to a deployed Cloudflare Worker, read every field of the event envelope it emits, narrow the stream with server-side filters, pipe the JSON form into jq for real triage, and use the stream to watch a canary deploy succeed or fail in real time.

wrangler tail opens a WebSocket from your terminal to the running script and pushes one envelope per invocation back to you, typically within a second of the request being served. It is the only signal in the edge observability toolkit with no ingestion delay, which makes it the right instrument for a bug you can reproduce on demand and the wrong instrument for a question about last Tuesday. Nothing is stored: when you close the terminal, the window you were watching is gone.

Key implementation objectives:

  • Authenticate Wrangler against the correct account and attach a tail to a named, deployed Worker.
  • Read the envelope — outcome, exceptions, logs, scriptName, event.request — and know which field answers which question.
  • Filter server-side by status, method, client IP, search string and sampling rate, then pipe --format json into jq.
  • Emit one structured JSON line per request from inside the Worker so the stream is greppable rather than a wall of prose.

Prerequisites and environment setup

You need Node 18 or newer, Wrangler 3.60 or newer, a Cloudflare account, and a Worker that is already deployed — tail attaches to production scripts, not to wrangler dev. Confirm the toolchain and the account binding first, because attaching to the wrong account is the most common reason a tail sits silent:

node --version            # v18.x or later
npx wrangler --version    # 3.60.0 or later
npx wrangler whoami       # prints the account name and id the CLI is bound to

Authenticate interactively with OAuth, which is the right choice on a workstation:

npx wrangler login

In CI or on a shared jump host, use a scoped API token instead. The token needs the Workers Scripts: Read permission at minimum; tail does not require write access:

export CLOUDFLARE_API_TOKEN="<token with Workers Scripts:Read>"
export CLOUDFLARE_ACCOUNT_ID="<account id from wrangler whoami>"
npx wrangler tail --name edge-router

Confirm the script name you intend to tail actually exists and has a live deployment:

npx wrangler deployments list --name edge-router

Expected output shows at least one deployment with a version id and a timestamp. If the list is empty, the Worker was never deployed to this account and no tail will ever produce events.

Step-by-step procedure

Step 1 — Open a tail against a deployed Worker

From inside a project directory containing wrangler.jsonc, the script name is inferred. Outside one, pass --name explicitly:

# Inferred from wrangler.jsonc in the current directory
npx wrangler tail

# Explicit, from anywhere
npx wrangler tail --name edge-router --format pretty

The CLI prints a confirmation line and then blocks. Nothing appears until the Worker receives a request — an idle stream is the normal state, not a failure. Send a request to a route the Worker owns and the first envelope arrives within a second.

Step 2 — Read the event envelope

Each invocation produces one envelope. In --format json mode it is a single line of JSON; the pretty format renders the same data for humans. This is the whole structure, and every field earns its place:

{
  "outcome": "ok",
  "scriptName": "edge-router",
  "eventTimestamp": 1753689600120,
  "exceptions": [],
  "logs": [
    {
      "level": "log",
      "timestamp": 1753689600123,
      "message": ["{\"lvl\":\"info\",\"rid\":\"7f3a\",\"msg\":\"origin fetch\",\"ms\":38}"]
    }
  ],
  "event": {
    "request": {
      "url": "https://api.example.com/v1/orders?page=2",
      "method": "GET",
      "headers": { "user-agent": "curl/8.6.0", "accept": "*/*" },
      "cf": { "colo": "AMS", "country": "NL" }
    }
  }
}
Anatomy of a tail event envelope Five envelope fields on the left — outcome, scriptName, exceptions, logs and event.request — each joined by an arrow to a description of what that field tells you. What each envelope field is for tail event field the question it answers outcome ok / exception / exceededCpu / canceled scriptName which Worker emitted this event exceptions name and message of every uncaught throw logs your console.log calls, in emission order event.request url, method and headers you filter on

Two details save time later. outcome is the platform’s verdict, not yours: it reports exception only when something propagated out of your handler, and exceededCpu when the isolate was killed for burning its CPU budget. And event.request.cf carries colo, the three-letter code of the point of presence that served the request — the field you group by when a failure is regional.

Step 3 — Emit structured lines from inside the Worker

Raw console.log("here") calls make a tail unreadable at any real traffic level. Emit one JSON object per log call instead, always carrying a request id, so the stream can be filtered and parsed. Generating the id at the edge is also the foundation of tracing requests across the edge with correlation headers:

type Level = "debug" | "info" | "warn" | "error";

function makeLogger(rid: string, colo: string) {
  return (lvl: Level, msg: string, fields: Record<string, unknown> = {}) => {
    console.log(JSON.stringify({ lvl, rid, colo, msg, ...fields }));
  };
}

export default {
  async fetch(request: Request): Promise<Response> {
    const rid = request.headers.get("x-debug-id") ?? crypto.randomUUID().slice(0, 8);
    const colo = (request as any).cf?.colo ?? "??";
    const log = makeLogger(rid, colo);
    const started = Date.now();

    try {
      log("info", "request in", { path: new URL(request.url).pathname });
      const upstream = await fetch(request);
      log("info", "origin fetch", { status: upstream.status, ms: Date.now() - started });

      const res = new Response(upstream.body, upstream);
      res.headers.set("x-request-id", rid);
      return res;
    } catch (err) {
      // Re-log explicitly: a caught error never reaches the envelope's exceptions array.
      log("error", "handler threw", { name: (err as Error).name, detail: (err as Error).message });
      throw err;
    }
  },
};

Every line now carries rid and colo, which are exactly the two things --search and post-hoc grouping need. Deploy before tailing:

npx wrangler deploy

Step 4 — Filter the stream server-side

Filters are applied at the point of presence, before events cross the wire, so they reduce both noise and the chance of hitting the stream’s rate ceiling. They compose:

# Only invocations that ended in an exception or a thrown error
npx wrangler tail --name edge-router --status error

# Only your own traffic — 'self' resolves to the IP you are connecting from
npx wrangler tail --name edge-router --ip self

# One method and one substring anywhere in the event (url, headers or log text)
npx wrangler tail --name edge-router --method POST --search "order-4471"

# A header match, useful when your client sets a marker header
npx wrangler tail --name edge-router --header "x-debug-id:7f3a"

# Deliberately reduce volume on a very busy script
npx wrangler tail --name edge-router --sampling-rate 0.05
Narrowing the tail stream with filters Four stages show an unfiltered stream reduced by status, search and IP filters from four thousand events per second down to one per replayed request, above a warning about the sampling rate flag. Each filter runs at the PoP, before the wire All events --format json ~4,000 per second unreadable --status error exceptions and non-2xx only ~40 per second --search order- one code path ~3 per second still noisy --ip self only your curl 1 per replay readable at last --sampling-rate is applied before your filters, not after at 0.01 you wait roughly a hundred times longer to see the rare failure you are chasing Narrow with --status, --search and --ip first; reach for --sampling-rate only when the stream still cannot keep up

The ordering point in the diagram is the one that catches people. --sampling-rate throws events away before --status error ever looks at them, so combining a low sampling rate with a narrow status filter is a reliable way to see nothing at all while a real failure is happening.

Step 5 — Pipe JSON into jq for triage

--format pretty is for reading; --format json is for working. Each event is one line of JSON, so jq streams it without buffering:

# One compact triage line per failed invocation
npx wrangler tail --name edge-router --status error --format json \
  | jq -c '{
      t: (.eventTimestamp / 1000 | todate),
      outcome,
      colo: .event.request.cf.colo,
      url: .event.request.url,
      err: (.exceptions[0].message // "none")
    }'
{"t":"2026-07-28T09:14:02Z","outcome":"exception","colo":"AMS","url":"https://api.example.com/v1/orders?page=2","err":"Cannot read properties of undefined (reading 'id')"}

Because the Worker emits JSON strings, you can parse the inner payload too and filter on your own fields:

# Surface only slow origin fetches, sorted as they arrive
npx wrangler tail --name edge-router --format json \
  | jq -c '.logs[].message[0] | fromjson? | select(.ms > 500) | {rid, colo, msg, ms}'

Count failures by point of presence over a fixed window to answer “is this regional?” in one command:

timeout 120 npx wrangler tail --name edge-router --status error --format json \
  | jq -r '.event.request.cf.colo' | sort | uniq -c | sort -rn
     47 SIN
      3 AMS
      2 CDG

A distribution that lopsided is a regional problem, not a code problem — which is exactly the branch the edge debugging playbook sends you down.

Step 6 — Tail a named environment during a canary deploy

Environments get their own script names, so tail them explicitly. Upload a version without promoting it, tail production, then promote while watching:

# Watch staging only
npx wrangler tail --name edge-router --env staging --format pretty

# Canary: upload, then promote a small share of traffic
npx wrangler versions upload --env production --tag "$(git rev-parse --short HEAD)"
npx wrangler versions deploy --env production

Keep two terminals open during the promotion. The first runs --status error and should stay empty. The second runs --search "$(git rev-parse --short HEAD)" against a version tag you log on every request, so you can confirm the new code is actually serving traffic rather than assuming it from the deploy output. Roll back with npx wrangler rollback the moment the first terminal starts producing lines.

Verification

Prove the pipeline end to end with a unique marker. Generate an id, drive a short curl loop carrying it, and watch the matching events land.

In terminal one:

export DEBUG_ID="verify-$(date +%s)"
npx wrangler tail --name edge-router --search "$DEBUG_ID" --format pretty

In terminal two:

for i in $(seq 1 10); do
  curl -s -o /dev/null -w '%{http_code} ' \
    -H "x-debug-id: $DEBUG_ID" \
    "https://api.example.com/v1/orders?page=$i"
  sleep 0.5
done; echo

Terminal two prints the status codes:

200 200 200 200 200 200 200 200 200 200

Terminal one shows ten envelopes, each within roughly a second of its request, carrying your structured lines:

GET https://api.example.com/v1/orders?page=1 - Ok @ 28/07/2026, 09:14:02
  (log) {"lvl":"info","rid":"verify-1753689600","colo":"AMS","msg":"request in","path":"/v1/orders"}
  (log) {"lvl":"info","rid":"verify-1753689600","colo":"AMS","msg":"origin fetch","status":200,"ms":38}

Two things are proven here: the filter matches on your marker, and the colo field tells you which point of presence served each request. Confirm the response header round-trip as well, since that is what makes edge and origin records joinable:

curl -sI -H "x-debug-id: $DEBUG_ID" https://api.example.com/v1/orders | grep -i 'x-request-id\|cf-ray'
x-request-id: verify-1753689600
cf-ray: 8a1b2c3d4e5f6789-AMS

Limits: tail is a debugging tool, not a retention strategy

The stream has hard ceilings, and all of them are silent. Once a script exceeds roughly a hundred requests per second, the platform samples the tail rather than falling behind. A single event larger than about 128 KB is dropped instead of chunked, so logging a whole response body means logging nothing. Concurrent tail sessions on one script are capped in the low tens, and each additional session increases the sampling pressure on all of them.

Tail retains nothing outside its window A fifty minute timeline with a tail session open for ten minutes in the middle; two error spikes outside the window are unrecorded while the one inside it is observed live. Only what you watch is ever seen 502 spike no session open 502 spike session closed wrangler tail open events visible live 09:00 09:10 09:20 09:30 09:40 09:50 caught live Tail retains nothing: if nobody is streaming, the evidence never existed pair it with a batch pipeline for anything you need to query after the fact

The practical rule: use tail while you are actively working a problem, and rely on Shipping Edge Logs with Logpush for anything you might want to query tomorrow. The two are complementary, not alternatives — the same console.log() calls feed both.

Troubleshooting

No events appear at all

An idle tail is normal; a tail that stays idle while you are certain traffic is flowing is not. Work down this list: run npx wrangler whoami and confirm the account id matches the one that owns the script; run npx wrangler deployments list --name edge-router and confirm a deployment exists; then confirm the route actually matches. A Worker bound to a hostname whose DNS record is not proxied never executes, so no envelope is ever produced. Route configuration is covered in Deploying Cloudflare Workers for Dynamic Request Routing.

The tail keeps disconnecting

Repeated reconnects usually mean one of three things: the script’s event volume is saturating the stream, too many people have a session open on the same script, or a corporate proxy is closing idle WebSockets. Narrow the filter first (--ip self is the most aggressive reduction available), then coordinate so a single engineer holds the stream during an incident. If disconnects continue on a quiet script, test from a different network to rule out the proxy.

Logs vanish after an early return

A logger placed at the bottom of the handler never runs on the paths that return early — redirects, 304s, cache hits, auth rejections. Those are precisely the paths worth watching. Move the final log into a finally block so it fires on every exit:

let status = 0;
try {
  const res = await handle(request);
  status = res.status;
  return res;
} finally {
  log("info", "request out", { status, ms: Date.now() - started });
}

Exceptions swallowed by a try/catch

If outcome reads ok and exceptions is empty on a request you know failed, your handler caught the error and returned a response. The platform only records exceptions that propagate out of the isolate. Re-log inside every catch with a distinguishing field ({"lvl":"error"} above), and tail on --search '"lvl":"error"' rather than --status error, which will never match a caught failure.

Sampling hides the failure you are chasing

If the script is busy, both the platform’s own tail sampling and any --sampling-rate you passed are discarding events before your filter runs. Drop --sampling-rate entirely, narrow with --ip self and reproduce the request yourself so your traffic is a large share of what remains. When the failure only occurs in real user traffic and cannot be reproduced, stop using tail for it: the volume that hides it from the stream is exactly the volume a batch log pipeline handles well.

Frequently Asked Questions

Does wrangler tail work against a local wrangler dev session? No. Tail attaches to a deployed script running on Cloudflare’s network; local development prints its logs directly to the wrangler dev console instead. Use wrangler dev --remote when you want the real cf object and edge behavior while still seeing output locally.

Will opening a tail slow down my Worker or affect users? No measurable effect on request latency, because event delivery happens outside the request path and the platform sheds events rather than applying backpressure. The one thing that costs you is your own logging: serializing large objects on every request consumes CPU budget whether or not anyone is tailing.

Why do I see fewer events than requests? Above roughly a hundred requests per second the tail stream is sampled server-side, and any --sampling-rate you passed discards events before your other filters are evaluated. Absence of an event in a tail is never evidence that the request did not happen.

How long are tail logs kept? They are not kept at all. The stream exists only while your terminal is attached, which is why tail belongs in the reproduce-and-fix loop and never in an alerting or audit path. Configure a log pipeline for retention.

Can I tail more than one Worker at once? Each wrangler tail invocation attaches to a single script, so run one process per Worker and merge the output yourself, or use a Tail Worker to consume several scripts’ events in code. Keep the total number of concurrent sessions low, since every extra consumer increases the sampling applied to all of them.

Back to Edge Observability & Debugging