# Observability for Edge Functions — Logs, Traces, and Metrics

URL: https://www.devopsness.com/blog/observability-for-edge-functions-logs-traces-metrics
Author: Kiril Urbonas
Published: 2026-07-13
Updated: 2026-07-13
Categories: Cloud
Tags: Monitoring, Cloud, Observability, Performance, Edge

Summary: 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.

---
# 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.

```js
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:

```js
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:

```bash
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:

```js
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-ray` and `traceparent` in both sides are what stitch them.

This is the observability layer for the [edge functions](/blog/edge-computing-playbook-what-to-run-at-the-edge) 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.

