Structured Logging for Edge Functions

A console.log("auth failed for " + userId) looks fine in development and becomes useless in production. When a million of those land in your log store, you cannot filter by status, group by stage, or join one request’s lines together — and that interpolated userId may be leaking PII into a system that was never meant to hold it. Edge functions have no disk to tail and no debugger to attach, so the structure of your logs is the entire difference between a queryable telemetry stream and an undifferentiated wall of text.

This guide is part of Observability and Debugging Edge Middleware. It defines an edge-safe structured logging pattern: a fixed schema of required fields, a correlation ID that ties a request’s lines together, head-based sampling to control volume, and redaction to keep secrets out of your log backend.

Root cause: stdout is your only channel

Every edge platform captures the function’s stdout and stderr — wrangler tail and Logpush on Cloudflare, the dashboard and Log Drains on Vercel and Netlify. That is the constraint that shapes everything: you have exactly one channel, console, and whatever you write to it must be self-describing, because nothing downstream knows the meaning of your fields unless you encode it.

The edge runtime gives you console.log, JSON.stringify, and crypto.randomUUID() — enough to emit one JSON object per event. The discipline is to make every object share a stable schema so the log backend can index it, and to do the serialization cheaply because you are spending CPU from a tight budget.

Step 1: Define a required-field schema

Fix the fields every log line must carry. A consistent schema is what makes logs filterable: level for severity routing, traceId for correlation, stage for grouping, durationMs and status for latency and outcome analysis.

// logger.ts
export type Level = "debug" | "info" | "warn" | "error";

export interface LogRecord {
  ts: string;          // ISO-8601 timestamp
  level: Level;
  traceId: string;     // correlation ID, shared across the request
  stage: string;       // which middleware stage emitted this
  msg: string;
  durationMs?: number;
  status?: number;
  [extra: string]: unknown;
}

Step 2: Build a request-scoped logger with a correlation ID

Create one logger per request, seeded with the correlation ID. Derive that ID from the incoming traceparent when present so edge logs join the same trace as upstream and downstream services; otherwise mint one.

import type { Level, LogRecord } from "./logger";

export class EdgeLogger {
  constructor(public readonly traceId: string, private readonly stage = "edge") {}

  static fromRequest(req: Request): EdgeLogger {
    const traceparent = req.headers.get("traceparent");
    const traceId = traceparent?.split("-")[1] ?? crypto.randomUUID().replace(/-/g, "");
    return new EdgeLogger(traceId);
  }

  child(stage: string): EdgeLogger {
    return new EdgeLogger(this.traceId, stage);
  }

  private write(level: Level, msg: string, extra: Record<string, unknown> = {}): void {
    const record: LogRecord = {
      ts: new Date().toISOString(),
      level,
      traceId: this.traceId,
      stage: this.stage,
      msg,
      ...extra,
    };
    const line = JSON.stringify(record);
    if (level === "error" || level === "warn") console.error(line);
    else console.log(line);
  }

  info(msg: string, extra?: Record<string, unknown>) { this.write("info", msg, extra); }
  warn(msg: string, extra?: Record<string, unknown>) { this.write("warn", msg, extra); }
  error(msg: string, extra?: Record<string, unknown>) { this.write("error", msg, extra); }
}

The child(stage) method gives each middleware stage its own logger that shares the request’s correlation ID, so filtering by traceId reconstructs the full request and grouping by stage isolates one step.

Step 3: Redact secrets before they reach a line

Never log raw headers, cookies, or query strings. Use an allowlist: copy through the fields you explicitly trust and hash or drop everything else. Hashing a token lets you correlate occurrences without storing the secret.

const SAFE_HEADERS = new Set(["content-type", "user-agent", "accept-language"]);
const SENSITIVE_PARAMS = new Set(["token", "key", "sig", "password", "secret"]);

export function safeHeaders(headers: Headers): Record<string, string> {
  const out: Record<string, string> = {};
  for (const [k, v] of headers) {
    if (SAFE_HEADERS.has(k.toLowerCase())) out[k] = v;
  }
  return out;
}

export function safePath(url: string): string {
  const u = new URL(url);
  for (const key of u.searchParams.keys()) {
    if (SENSITIVE_PARAMS.has(key.toLowerCase())) u.searchParams.set(key, "[redacted]");
  }
  return u.pathname + (u.search || "");
}

Treat Authorization, Cookie, and Set-Cookie as never-loggable. They are excluded by virtue of not being on the allowlist, but make that explicit in review.

Step 4: Apply head-based sampling

At high volume, logging every request overwhelms ingestion and inflates cost. Sample deterministically from the correlation ID so the decision is consistent across all of a request’s lines — you never get half a request — and always keep errors.

export function shouldKeep(traceId: string, status: number, rate = 0.05): boolean {
  if (status >= 500) return true;            // always keep server errors
  if (status === 429) return true;           // always keep rate-limit events
  const bucket = parseInt(traceId.slice(0, 4), 16) / 0xffff;
  return bucket < rate;                       // keep a deterministic fraction
}

Decide the sample verdict once at the end of the request and use it to gate the request-summary line, while letting warn/error lines through unconditionally. This keeps every error traceable while capping the firehose of healthy traffic.

Step 5: Emit a request summary line

At the end of the chain, write a single summary that captures the whole request’s outcome and latency. This is the line you sample and the one most dashboards key off.

export default {
  async fetch(req: Request, env: unknown, ctx: ExecutionContext): Promise<Response> {
    const log = EdgeLogger.fromRequest(req);
    const start = performance.now();

    const res = await runChain(req, log);

    const durationMs = performance.now() - start;
    if (shouldKeep(log.traceId, res.status)) {
      log.info("request", {
        durationMs,
        status: res.status,
        method: req.method,
        path: safePath(req.url),
        headers: safeHeaders(req.headers),
      });
    }
    return res;
  },
};

If you ship logs to an external sink with fetch rather than relying on platform capture, wrap that POST in ctx.waitUntil so it never blocks the response.

Configuration

On Cloudflare, enable structured ingestion by configuring Logpush to forward console output to R2, S3, or an HTTP endpoint; declare the binding in wrangler.jsonc. On Vercel, attach a Log Drain in project settings to stream Edge function logs to your observability vendor. On Netlify, configure a Log Drain in site settings. No code changes are needed across providers — the same JSON lines flow through each platform’s capture pipeline.

Local vs production divergence

Aspect Local dev Production
Log destination Terminal stdout Logpush / Log Drain / dashboard
Sampling Disabled — keep everything Head-based at your configured rate
traceparent source Injected manually Supplied by upstream proxy
Timestamp precision Full May be coarsened by the runtime
PII risk Low (your own data) High — redaction is mandatory
Volume A handful of requests Millions — schema and sampling critical

Step 6: Validate with Vitest

Capture console.log, drive a request through the logger, and assert the emitted JSON has the required fields, a stable correlation ID, and no leaked secrets.

import { describe, it, expect, vi, afterEach } from "vitest";
import { EdgeLogger, safePath, shouldKeep } from "../src/edge-logger";

afterEach(() => vi.restoreAllMocks());

describe("EdgeLogger", () => {
  it("emits valid JSON with required fields and a shared trace ID", () => {
    const spy = vi.spyOn(console, "log").mockImplementation(() => {});
    const req = new Request("https://x.test/p", { headers: { traceparent: "00-" + "a".repeat(32) + "-" + "b".repeat(16) + "-01" } });
    const log = EdgeLogger.fromRequest(req);
    log.child("auth").info("ok", { status: 200 });

    const record = JSON.parse(spy.mock.calls[0][0] as string);
    expect(record.traceId).toBe("a".repeat(32));
    expect(record.stage).toBe("auth");
    expect(record.status).toBe(200);
    expect(record.ts).toMatch(/^\d{4}-\d{2}-\d{2}T/);
  });

  it("redacts sensitive query parameters", () => {
    expect(safePath("https://x.test/cb?token=abc123&page=2")).toBe("/cb?token=%5Bredacted%5D&page=2");
  });

  it("always keeps server errors regardless of sample rate", () => {
    expect(shouldKeep("f".repeat(32), 500, 0)).toBe(true);
    expect(shouldKeep("f".repeat(32), 200, 0)).toBe(false);
  });
});

Pitfalls

  • Interpolated strings. console.log(\auth failed ${id}`)` is unqueryable. Always emit a JSON object with discrete fields.
  • Logging raw headers or cookies. Tokens and session IDs leak straight into your log store. Use the allowlist and redact.
  • Per-line random sampling. Sampling each line independently gives you fragments of requests. Derive the verdict deterministically from the correlation ID.
  • Dropping errors. A blanket sample rate hides failures. Always keep 5xx and 429.
  • Blocking on an external sink. Awaiting a log-shipping fetch adds latency. Defer it with ctx.waitUntil.

Production deployment checklist

  • The correlation ID is derived from traceparent
  • Cookies, Authorization
  • 5xx and 429
  • External log shipping, if used, runs inside

Frequently Asked Questions

Should I log to console or POST logs to an external endpoint?

Prefer console — every edge platform captures stdout and routes it through Logpush or a Log Drain with no added request latency. Only POST to an external sink when you need a destination the platform cannot reach, and in that case wrap the fetch in ctx.waitUntil so it never blocks the response.

How do I correlate edge logs with my origin server logs?

Use the trace ID from the W3C traceparent header as the correlation ID in both systems. When the edge function reads traceparent on entry and the origin reads the same header, filtering by trace ID in your log backend reconstructs the full request across both tiers.

What sample rate should I use?

Start at around 5% of healthy traffic while keeping 100% of errors and rate-limit events. Tune from there against ingestion cost and how much successful-request detail your dashboards need. Because errors are always kept, lowering the rate never hides failures.

Does JSON.stringify cost too much CPU at the edge?

For a compact record with a handful of fields it is negligible relative to the per-request CPU budget. The cost grows if you serialize large nested objects or full request bodies, so keep log records flat and small, and never stringify a response body into a log line.