Appearance
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; usebrowser.writeinstead- Child logger
moduleauto-detection — the stack-walking helper from the custom logger relies on Node'sfile://stack frames andpath.relative, which are unavailable in the browser - Formatters —
formatters.levelandformatters.bindingsare ignored; thebrowser.writefunctions 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 proxyclient_max_body_sizedirective. 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
OriginorRefererheader 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_RETRIESandMAX_BUFFER_SIZEso it cannot grow without limit. sendBeaconon visibility change.fetchinbeforeunload/pagehidehandlers is unreliable — the browser may cancel it.sendBeaconis designed for exactly this case.- Bounded retries. Each entry is retried up to
MAX_RETRIEStimes 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
modulepath 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
sessionIdthat groups them with other entries from the same page load - The
[CLIENT]tag andsource: "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_RETRIEStimes, 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_RETRIEStimes, 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