Observability for Edge Functions — Logs, Traces, and Metrics
Edge code runs in hundreds of PoPs, lives for milliseconds, and gives you no shell. Here's how we get logs, traces, and metrics out of it anyway.
Key takeaways
- Edge code runs in hundreds of PoPs, lives for milliseconds, and gives you no shell.
- Here's how we get logs, traces, and metrics out of it anyway.
On this page
Observability for Edge Functions — Logs, Traces, and Metrics
The first time a Worker started throwing 500s in production, our instinct was to SSH in and tail a log. There is nothing to SSH into. The code was running in something like 300 points of presence, each instance lived for a few milliseconds, and the only thing we had was a spike on a dashboard and a customer complaint from São Paulo.
That is the whole problem in one sentence. Edge functions are the least observable place your code will ever run, and the usual playbook — attach a debugger, grep a log file, add a console.log and redeploy — mostly does not apply. You have to design the telemetry in from the start.
Why this is harder than a server#
A normal service runs on a box you can name. An edge function runs everywhere and nowhere. A few constraints drive everything else:
- Hundreds of locations. A single deploy is live in every PoP the platform runs. "Which server?" is not a useful question anymore. "Which region, which colo?" is.
- Short lifetimes. An invocation handles one request and disappears. There is no long-lived process holding a metrics buffer you can scrape.
- No shell, no disk. You cannot exec into it. There is no local file to write to and rotate.
- Tight CPU budgets. Cloudflare Workers give you around 10–50ms of CPU on many plans. Vercel Edge is similar. Blocking to flush a batch of spans eats the budget you needed for the actual work.
- Limited async time after the response. You get a little runway after returning the response, but it is bounded and not guaranteed. Anything you send has to fit in it.
That last point is the one people trip on, so start there.
The waitUntil pattern#
You cannot block the response to ship telemetry. If you await a log POST before returning, every user pays for your observability in latency. The fix on both Cloudflare and Vercel is the execution context's waitUntil: hand it a promise, return the response now, and the runtime keeps the invocation alive long enough to finish the send.
export default {
async fetch(request, env, ctx) {
const start = Date.now();
const response = await handle(request, env);
ctx.waitUntil(
fetch("https://logs.internal.example.com/ingest", {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({
ts: Date.now(),
colo: request.cf?.colo,
country: request.cf?.country,
status: response.status,
dur_ms: Date.now() - start,
ray: request.headers.get("cf-ray"),
}),
}).catch(() => {}) // never let telemetry take down the request
);
return response;
},
};
Two rules we enforce. The telemetry send is always after the response is built, never in its path. And it always swallows its own errors — a dead log endpoint should never turn a 200 into a 500.
Logs#
Firing a POST per request from every PoP works until traffic grows, and then it becomes a bill and a firehose. Two things keep it sane.
Structured logs, always. Emit JSON, not strings, with fields you will actually filter on: colo, country, status, route, request id. Prose logs are useless at edge volume because you cannot aggregate them.
Sampling for the boring case. Keep every error and a small fraction of successes:
const sample = response.status >= 500 || Math.random() < 0.02;
if (sample) ctx.waitUntil(shipLog(record));
For high volume, stop hand-rolling the transport and let the platform push logs for you. Cloudflare Logpush batches request logs straight to your sink:
curl -X POST \
"https://api.cloudflare.com/client/v4/zones/$ZONE/logpush/jobs" \
-H "Authorization: Bearer $CF_TOKEN" \
-d '{
"name": "workers-to-r2",
"destination_conf": "r2://edge-logs/{DATE}?account-id='$ACCT'&access-key-id='$AK'&secret-access-key='$SK'",
"dataset": "workers_trace_events",
"output_options": {
"field_names": ["EventTimestampMs","Outcome","ScriptName","Logs","Exceptions"],
"timestamp_format": "rfc3339"
}
}'
Tail workers (wrangler tail) are the live view for debugging a specific bad deploy. Logpush is the durable stream you query later. Vercel exposes runtime logs and drains you point at the same downstream. Use both for what each is good at.
Traces#
A request that touches the edge and then your origin is one story told in two places. Without a shared trace id you are guessing at the seam.
Propagate W3C traceparent from the edge into origin. The edge function is where the trace starts, so it is where you mint or forward the header:
const traceparent =
request.headers.get("traceparent") ??
`00-${crypto.randomUUID().replace(/-/g, "")}-${rand16()}-01`;
const originReq = new Request(originURL, request);
originReq.headers.set("traceparent", traceparent);
const res = await fetch(originReq);
OpenTelemetry runs at the edge, but the CPU budget fights you. A full OTel SDK with a batching span processor is heavy for a Worker, and exporting spans synchronously blows the limit. The move is to build minimal spans, hand the export to ctx.waitUntil, and send OTLP over HTTP after the response. Keep spans few and coarse — one for the edge handler, one for the origin call — rather than instrumenting every function.
Metrics#
Server-side timings tell you what your code did. They do not tell you what the user in São Paulo felt. For that you need two things.
RUM for real latency by region. Real-user monitoring from the browser captures actual TTFB and load time per country, which is the only number that reflects whether the edge is buying you anything. If your p75 in one region is triple the rest, that is a routing or PoP problem no server metric will show.
Per-PoP error rates. Aggregate your structured logs by colo and status. A global error rate of 0.3% can hide one unhealthy colo sitting at 12%. The average lies; the breakdown does not.
Gotchas we hit#
- You cannot block to flush. Every flush goes through
waitUntil, and its time is bounded. Batch small and send often. - Cardinality is the cost. Tagging metrics with request id or full URL explodes series count and the invoice with it. Tag with colo, country, route template, status. Nothing unbounded.
- Correlating edge and origin. Without a propagated trace id, edge logs and origin logs are two disconnected piles. The
cf-rayandtraceparentin both sides are what stitch them.
This is the observability layer for the edge functions you have already decided to run at the edge; the harder question of what to run there lives in that playbook.
The call we'd make#
Start with structured logs shipped through waitUntil, sample successes hard, and move to Logpush the moment volume hurts. Add traceparent propagation on day one even before you have a trace backend, because retrofitting trace ids across the edge/origin seam later is miserable. Hold traces until logs and RUM are boring. Instrument the seam, not every line, and let the platform carry the firehose so your CPU budget goes to the request.
Stay Updated
Get the latest tutorials, guides, and insights on AI, DevOps, Cloud, and Infrastructure delivered directly to your inbox.
Short-Lived Credentials — STS, Dynamic Secrets, and Why Static Keys Die
Static keys leak and live forever. Short-lived credentials from STS and Vault expire on their own — here's the token-exchange machinery and the TTL math that make it work.
Detecting and Rotating Leaked Cloud Credentials
Static keys leak. The question isn't if but how fast you notice and how clean your response runbook is when the pager goes off.
More from Cloud
Explore more articles in this category
Best Serverless Databases in 2026 (Compared)
A practitioner comparison of the leading serverless databases by use case, cold-start behavior, branching, pricing model, and lock-in.
Cloudflare D1: The Edge SQLite Database Guide (2026)
A practitioner's look at Cloudflare D1, the serverless SQLite database built for Workers, covering setup, read replication, limits, and fit.
Neon vs PlanetScale: Serverless SQL Compared (2026)
A practitioner comparison of Neon's serverless Postgres against PlanetScale's Vitess-backed MySQL to help you pick the right database.
You might have missed
Evergreen posts worth revisiting.