Skip to content

Browser Log Forwarding

Forward browser-side log entries and uncaught errors to a server endpoint so client and server logs appear in a single stream. In production, entries flow into your log aggregator (Datadog, Cloud Logging, etc.) and can be correlated with server-side traces by a shared request ID. The same setup works in development without any extra configuration.

When to use

Use this pattern when you need visibility into browser-side behavior from outside the browser:

  • Production: centralized error tracking, client-side performance monitoring, or correlating a user's browser errors with the server-side request that served the page.
  • Development: CI preview environments, terminal-only workflows, or any setup where DevTools is not open.

The pattern

Three components work together: a client-side capture layer, a server-side ingestion endpoint, and a batching/reliability layer that connects them.

1. Client-side capture with pino

Pino supports running in the browser. Use the browser.write option to intercept every structured log entry and forward it to the server instead of (or in addition to) printing to the browser console:

javascript
import pino from "pino";

const LOG_ENDPOINT = "/api/log";

const logger = pino({
  browser: {
    // still print to the browser console
    asObject: true,
    write: {
      trace: forwardToServer,
      debug: forwardToServer,
      info: forwardToServer,
      warn: forwardToServer,
      error: forwardToServer,
      fatal: forwardToServer,
    },
  },
});

export default logger;

Each entry that pino passes to write is already a structured JSON object with level, time, and msg fields — the same shape as server-side pino output. Add a source field so the server can tag it:

javascript
// sessionId is generated once per page load (see full example below)
function forwardToServer(entry) {
  // also log to the browser console
  const level = pino.levels.labels[entry.level] ?? "info";
  const consoleFn = console[level] ?? console.log;
  consoleFn(entry.msg, entry);

  buffer.push({
    ...entry,
    sessionId,
    source: "client",
    url: location.href,
  });
  scheduleFlush();
}

Capturing unstructured errors

Not all browser errors flow through pino. Capture uncaught exceptions and unhandled promise rejections so they also appear in the server log:

javascript
window.addEventListener("error", (event) => {
  forwardToServer({
    level: pino.levels.values.error,
    time: Date.now(),
    msg: event.message,
    source: "client",
    url: location.href,
    stack: event.error?.stack,
    "error.type": event.error?.name ?? "Error",
    "error.colno": event.colno,
    "error.lineno": event.lineno,
    "error.filename": event.filename,
  });
});

window.addEventListener("unhandledrejection", (event) => {
  const err = event.reason;
  forwardToServer({
    level: pino.levels.values.error,
    time: Date.now(),
    msg: err?.message ?? String(err),
    source: "client",
    url: location.href,
    stack: err?.stack,
    "error.type": err?.name ?? "UnhandledRejection",
  });
});

Pino browser limitations

Pino's browser build does not support:

  • Transports (pino.transport()) — no worker threads in the browser
  • pino.destination() — Node-only; use browser.write instead
  • Child logger module auto-detection — the stack-walking helper from the custom logger relies on Node's file:// stack frames and path.relative, which are unavailable in the browser
  • Formattersformatters.level and formatters.bindings are ignored; the browser.write functions receive the raw object

These limitations do not affect the forwarding pattern. The browser.write override gives you the structured entry directly — you serialize and send it yourself.

2. Server-side endpoint

An Express route accepts POSTed log entries and writes them to the same destination as server logs using the project's createLogger:

javascript
import { createInterface } from "readline";
import express from "express";
import pino from "pino";
import createLogger from "./logger.js";

const log = createLogger({ component: "client-logs" });

const router = express.Router();

// Stream NDJSON line by line — never buffer the full body.
router.post("/api/log", async (req, res) => {
  const rl = createInterface({ input: req });

  for await (const line of rl) {
    if (!line) continue;
    const {
      level: pinoLevel = pino.levels.values.info,
      msg = "",
      source,
      url,
      stack,
      ...rest
    } = JSON.parse(line);
    const severity = pino.levels.labels[pinoLevel] ?? "info";
    log[severity](
      { source, url, stack, ...rest },
      `[CLIENT] ${msg}`,
    );
  }

  res.status(204).end();
});

export default router;

Because the endpoint uses the same createLogger as the rest of the server, forwarded entries share the same format, destination, and severity mapping. In development they interleave in tail -f output; in production they flow into your log aggregator alongside server entries.

Protecting the endpoint in production

This endpoint accepts arbitrary JSON from the browser. Without protection, any unauthenticated client can POST fake log entries, pollute your log stream, or inflate ingestion costs. Add guards before exposing it publicly:

  • Body size limit. Cap the request body with middleware (e.g., express.raw({ limit: "64kb" }) before the handler) or a reverse proxy client_max_body_size directive. Adjust to match your batch size.
  • Rate limiting. Apply a per-IP rate limit (e.g., express-rate-limit) to prevent abuse. A reasonable default is 30 requests per minute per IP.
  • Origin validation. In production, check the Origin or Referer header against your known domains and reject requests from unknown origins.
  • Authentication. If your app has user sessions, require a valid session cookie on the log endpoint. For public-facing apps without sessions, origin validation and rate limiting are usually sufficient.

3. Batching and reliability

Sending one fetch per log call is wasteful and slow. Buffer entries and flush on an interval or when the buffer reaches a size threshold. Use navigator.sendBeacon on page hide to avoid losing entries during navigation:

javascript
const FLUSH_INTERVAL_MS = 2000;
const FLUSH_SIZE = 10;
const MAX_RETRIES = 3;
const MAX_BUFFER_SIZE = 500;

let buffer = [];
let flushTimer = null;

function scheduleFlush() {
  if (buffer.length >= FLUSH_SIZE) {
    flush();
    return;
  }
  if (!flushTimer) {
    flushTimer = setTimeout(flush, FLUSH_INTERVAL_MS);
  }
}

async function flush() {
  clearTimeout(flushTimer);
  flushTimer = null;

  if (buffer.length === 0) return;

  const batch = buffer;
  buffer = [];

  try {
    const res = await fetch(LOG_ENDPOINT, {
      method: "POST",
      headers: { "Content-Type": "application/x-ndjson" },
      body: toNDJSON(batch),
    });
    if (!res.ok) throw new Error(res.status);
  } catch {
    // Put failed entries back, bounded by retry count and buffer size
    const retriable = batch
      .map((e) => ({ ...e, _retries: (e._retries ?? 0) + 1 }))
      .filter((e) => e._retries <= MAX_RETRIES);
    buffer = [...retriable, ...buffer].slice(0, MAX_BUFFER_SIZE);
    scheduleFlush();
  }
}

function toNDJSON(entries) {
  return entries.map((e) => JSON.stringify(e)).join("\n");
}

// Flush remaining entries when the page is hidden or unloaded.
// sendBeacon is fire-and-forget and survives navigation.
document.addEventListener("visibilitychange", () => {
  if (document.visibilityState === "hidden" && buffer.length > 0) {
    const blob = new Blob(
      [toNDJSON(buffer)],
      { type: "application/x-ndjson" },
    );
    navigator.sendBeacon(LOG_ENDPOINT, blob);
    buffer = [];
  }
});

Key design decisions:

  • Never break the app. Logging failures are swallowed. The retry loop is bounded by MAX_RETRIES and MAX_BUFFER_SIZE so it cannot grow without limit.
  • sendBeacon on visibility change. fetch in beforeunload/pagehide handlers is unreliable — the browser may cancel it. sendBeacon is designed for exactly this case.
  • Bounded retries. Each entry is retried up to MAX_RETRIES times on the next flush interval. After that it is dropped. This prevents a downed endpoint from accumulating unbounded memory.

Wiring it all together

Client entry point

Create a single module that sets up the logger, error listeners, and batching. Import it early in your application entry point:

javascript
// client/browser-logging.js
import pino from "pino";
import { v4 as uuid } from "uuid";

const LOG_ENDPOINT = import.meta.env?.PROD ?
  "https://logs.example.com/v1/browser" :
  "/api/log";
const FLUSH_INTERVAL_MS = 2000;
const FLUSH_SIZE = 10;
const MAX_RETRIES = 3;
const MAX_BUFFER_SIZE = 500;

// One session ID per page load — included in every forwarded entry
// so all logs from one browser session can be grouped.
const sessionId = uuid();

let buffer = [];
let flushTimer = null;

function scheduleFlush() {
  if (buffer.length >= FLUSH_SIZE) {
    flush();
    return;
  }
  if (!flushTimer) {
    flushTimer = setTimeout(flush, FLUSH_INTERVAL_MS);
  }
}

async function flush() {
  clearTimeout(flushTimer);
  flushTimer = null;
  if (buffer.length === 0) return;

  const batch = buffer;
  buffer = [];

  try {
    const res = await fetch(LOG_ENDPOINT, {
      method: "POST",
      headers: { "Content-Type": "application/x-ndjson" },
      body: toNDJSON(batch),
    });
    if (!res.ok) throw new Error(res.status);
  } catch {
    const retriable = batch
      .map((e) => ({ ...e, _retries: (e._retries ?? 0) + 1 }))
      .filter((e) => e._retries <= MAX_RETRIES);
    buffer = [...retriable, ...buffer].slice(0, MAX_BUFFER_SIZE);
    scheduleFlush();
  }
}

function toNDJSON(entries) {
  return entries.map((e) => JSON.stringify(e)).join("\n");
}

document.addEventListener("visibilitychange", () => {
  if (document.visibilityState === "hidden" && buffer.length > 0) {
    navigator.sendBeacon(
      LOG_ENDPOINT,
      new Blob([toNDJSON(buffer)], {
        type: "application/x-ndjson",
      }),
    );
    buffer = [];
  }
});

function forwardToServer(entry) {
  const level = pino.levels.labels[entry.level] ?? "info";
  const consoleFn = console[level] ?? console.log;
  consoleFn(entry.msg, entry);

  buffer.push({
    ...entry,
    sessionId,
    source: "client",
    url: location.href,
  });
  scheduleFlush();
}

// Structured logging via pino
const logger = pino({
  level: import.meta.env?.DEV ? "trace" : "warn",
  browser: {
    asObject: true,
    write: {
      trace: forwardToServer,
      debug: forwardToServer,
      info: forwardToServer,
      warn: forwardToServer,
      error: forwardToServer,
      fatal: forwardToServer,
    },
  },
});

// Unstructured error capture — sessionId is added by forwardToServer
window.addEventListener("error", (event) => {
  forwardToServer({
    level: pino.levels.values.error,
    time: Date.now(),
    msg: event.message,
    source: "client",
    url: location.href,
    stack: event.error?.stack,
    "error.type": event.error?.name ?? "Error",
    "error.filename": event.filename,
    "error.lineno": event.lineno,
    "error.colno": event.colno,
  });
});

window.addEventListener("unhandledrejection", (event) => {
  const err = event.reason;
  forwardToServer({
    level: pino.levels.values.error,
    time: Date.now(),
    msg: err?.message ?? String(err),
    source: "client",
    url: location.href,
    stack: err?.stack,
    "error.type": err?.name ?? "UnhandledRejection",
  });
});

export default logger;
javascript
// client/main.js
import log from "./browser-logging.js";

log.info("App started");
log.warn({ route: "/checkout" }, "Slow render detected");

Server entry point

Mount the log-forwarding route alongside your other middleware:

javascript
// server/app.js
import express from "express";
import clientLogRouter from "./client-log-router.js";
import createLogger from "./logger.js";

const app = express();
const log = createLogger({ service: "server" });

// Mount the client log endpoint
app.use(clientLogRouter);

app.get("/", (req, res) => {
  log.info({ path: "/" }, "Serving index");
  res.send("OK");
});

What tail -f shows

With both server and client logs flowing through the same pino instance, a single tail -f on the server log shows interleaved output:

[14:30:01] INFO  (server): Serving index
    path: "/"
[14:30:01] INFO  (client-logs): [CLIENT] App started
    source: "client"
    url: "http://localhost:3000/"
[14:30:02] WARN  (client-logs): [CLIENT] Slow render detected
    source: "client"
    url: "http://localhost:3000/"
    route: "/checkout"
[14:30:03] ERROR (client-logs):
    [CLIENT] Cannot read properties of undefined
    source: "client"
    url: "http://localhost:3000/checkout"
    error.type: "TypeError"
    stack: "TypeError: Cannot read properties..."

The [CLIENT] prefix and source: "client" field make it easy to distinguish client entries from server entries. Filter with grep "\[CLIENT\]" or query source = "client" in a log aggregator.

Integration with existing telemetry patterns

With the custom logger

The server endpoint uses createLogger from the custom logger pattern. This means forwarded client entries automatically get:

  • Google Cloud severity mapping
  • Pretty printing in development
  • Structured JSON in production
  • Auto-detected module path on the server side (the endpoint module)

With unit-of-work telemetry

Client log entries are not spans — they are standalone log lines. They do not participate in server-side unit-of-work telemetry traces. However, they share the same log stream, so a developer can correlate them by timestamp.

If you need to link a client action to a server trace, include a trace.id in both the client log entry and the corresponding API request header (X-Trace-Id). The server-side request span picks up the trace ID and all entries — client and server — become queryable by the same ID.

Correlating browser and server logs

In development, chronological interleaving in tail -f is usually enough to connect browser and server events. In production — where logs arrive from many users concurrently — you need an explicit correlation key.

Shared request ID

Generate a new request ID for each user action (not once per page load) and attach it to both the log entries and the API call for that action. The server extracts the same ID from the request header and includes it in its own log entries. Both sides are now queryable by a single ID.

javascript
// client-side: one request ID per user action
import { v4 as uuid } from "uuid";

async function checkout(cart) {
  const requestId = uuid();
  const actionLog = logger.child({ requestId });

  actionLog.info({ items: cart.length }, "Starting checkout");

  const res = await fetch("/api/checkout", {
    method: "POST",
    headers: {
      "Content-Type": "application/json",
      "X-Request-Id": requestId,
    },
    body: JSON.stringify(cart),
  });

  if (!res.ok) {
    actionLog.error({ status: res.status }, "Checkout failed");
  }
}
javascript
// server-side: extract and propagate the request ID
app.use((req, res, next) => {
  req.requestId = req.headers["x-request-id"] ?? uuid();
  req.log = log.child({ requestId: req.requestId });
  next();
});

Now requestId appears in both client-forwarded log entries and server request logs. Query your log aggregator with requestId = "<value>" to see the full browser-to-server flow for a single user action.

Because uncaught errors are not tied to a specific action, they include a sessionId instead. Generate a session ID once per page load and include it in every forwarded entry via forwardToServer (shown in the full wiring example above). This lets you group all errors from one browser session even when no action-level request ID is available.

W3C Trace Context

For projects already using distributed tracing (OpenTelemetry, Datadog APM), propagate the traceparent header instead of a custom X-Request-Id. The browser generates a trace ID and span ID, sends them via traceparent, and the server continues the same trace. This lets you see browser log entries and server spans in a single trace waterfall.

javascript
// client-side: generate a W3C traceparent header
function traceparent() {
  const traceId = crypto.randomUUID().replace(/-/g, "");
  const spanId = crypto.randomUUID().replace(/-/g, "").slice(0, 16);
  return `00-${traceId}-${spanId}-01`;
}

const tp = traceparent();
log.info({ traceparent: tp }, "Starting checkout");
fetch("/api/checkout", {
  headers: { traceparent: tp },
});

The server's tracing middleware (e.g., OpenTelemetry's HTTP instrumentation) picks up the traceparent header automatically and continues the trace.

Environment-specific configuration

The same forwarding mechanism works in both development and production. What changes between environments is the endpoint URL and the minimum log level.

Configuring the endpoint

In development, forward to the local server. In production, you may use the same path or point to a dedicated ingestion URL:

javascript
const LOG_ENDPOINT = import.meta.env?.PROD ?
  "https://logs.example.com/v1/browser" :
  "/api/log";

Adjusting log level per environment

In development you want trace-level detail. In production you typically want warn-and-above to reduce volume and cost:

javascript
const logger = pino({
  level: import.meta.env?.DEV ? "trace" : "warn",
  browser: {
    asObject: true,
    write: { /* ... */ },
  },
});

Disabling browser forwarding entirely

If you want forwarding only in development, conditionally import the module so it is tree-shaken from production builds:

javascript
// client/main.js
if (import.meta.env?.DEV) {
  await import("./browser-logging.js");
}

With Vite, import.meta.env.DEV is statically replaced at build time, so the entire import is eliminated from the production bundle.

Checks

  • Client entries arrive at the server within the flush interval
  • Uncaught exceptions and unhandled rejections appear in the server log with a sessionId that groups them with other entries from the same page load
  • The [CLIENT] tag and source: "client" field are present on forwarded entries
  • Navigation away from the page does not lose buffered entries (sendBeacon)
  • A failed flush retries up to MAX_RETRIES times, then drops the batch without throwing
  • A shared request ID or trace ID appears in both client and server log entries for the same user action
  • Log level filtering reduces production volume to warn-and-above (or whatever threshold is configured)
  • The endpoint rejects oversized payloads and is rate-limited in production

Trade-offs

Benefits:

  • Full-stack visibility in a single stream — no DevTools required in development, no separate log silo in production
  • Same structured format for client and server logs — easy to search, filter, and correlate
  • Same pino API on both sides — no new logging abstraction to learn
  • Minimal client footprint — pino browser build is small, batching amortizes network cost
  • Shared request IDs or W3C Trace Context let you follow a user action from browser to server in a single query

Costs:

  • Network overhead — every log call eventually hits the server (mitigated by batching and level filtering)
  • Limited offline buffering — failed batches are retried up to MAX_RETRIES times, but a prolonged outage still loses entries
  • No source map resolution — stack traces reference bundled line numbers (source map support is out of scope; use the browser's DevTools for that)
  • Pino browser limitations — no transports, no pino.destination(), no formatters (see above)
  • Additional endpoint to maintain — though it is a few lines of Express
  • Production volume — without level filtering, browser logs can be noisy and expensive in a log aggregator