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.
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.
A normal service runs on a box you can name. An edge function runs everywhere and nowhere. A few constraints drive everything else:
That last point is the one people trip on, so start there.
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.
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.
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.
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.
waitUntil, and its time is bounded. Batch small and send often.cf-ray and traceparent in 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.
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.
Get the latest tutorials, guides, and insights on AI, DevOps, Cloud, and Infrastructure delivered directly to your inbox.
Verifying signed tokens at the edge with WebCrypto blocks bad traffic early and saves a full origin hop. Here's the pattern we ship, and the traps.
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.
Explore more articles in this category
The edge is fast because it's constrained. This is the decision map for what belongs at the edge, what belongs at origin, and how compute, data, caching, and auth fit together.
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.
Verifying signed tokens at the edge with WebCrypto blocks bad traffic early and saves a full origin hop. Here's the pattern we ship, and the traps.
Evergreen posts worth revisiting.