Skip to content

perf(log): batch stdout writes and cache the timestamp - #266

Merged
pi0 merged 8 commits into
mainfrom
perf/async-logger
Jul 17, 2026
Merged

perf(log): batch stdout writes and cache the timestamp#266
pi0 merged 8 commits into
mainfrom
perf/async-logger

Conversation

@pi0x

@pi0x pi0x commented Jul 17, 2026

Copy link
Copy Markdown
Contributor

The log() middleware called console.log once per request and rebuilt an Intl-backed timestamp every time. This buffers lines and flushes once per event-loop turn, caches the timestamp, and stops emitting colors in production.

Changes

  • Batch writes. Lines buffer and flush once per event-loop turn (setImmediate, or queueMicrotask where it's unavailable) instead of one console.log per request. Under real concurrency many requests completing in the same turn share a single write. A drain-based backpressure path keeps a slow stdout from growing the buffer without bound.
  • Cache the timestamp. toLocaleTimeString() is Intl-backed and cost more than the rest of the line combined, yet its second-resolution output changes only once a second — so it's built once per second, not once per request.
  • No colors in production. ANSI escapes are noise once logs reach a file or a collector. Piping already stripped them, but a container started with a tty (docker -t, systemd) still got them. Resolved per log() call (the CLI sets NODE_ENV after this module is imported); FORCE_COLOR overrides.

Output is byte-identical to before. A line still buffered at process exit is flushed synchronously from an exit hook.

Benchmark

(removed in 78875ce to simplify diff)

pnpm bench:log drives a real server with oha at 64 connections and compares three servers — no logger, the previous console.log-per-request logger (vendored verbatim from the pre-change commit, _before.mjs), and the current log() — across the sinks logs actually go to. Batching only shows under real concurrency, so an in-process loop can't measure it.

Intel i7-10700K · Node v24.18.0 · oha 1.14.0 · 64 conns · median of 3×3s

RPS for each logger, with its overhead versus no logger in parentheses:

sink no logger old logger new logger diff
terminal 20,713 13,594 (-34.4%) 18,260 (-11.8%) +34.3%
pipe 20,297 15,900 (-21.7%) 18,559 (-8.6%) +16.7%
devnull 19,788 15,564 (-21.3%) 19,084 (-3.6%) +22.6%
  • terminal (a real TTY) is the worst case for the old logger — a synchronous colored console.log per request blocks on the terminal's rendering. It gave up 34% of throughput; the current logger gives up 12%.
  • pipe (logs streamed to a collector) is the production shape: +16.7%.
  • devnull isolates the CPU/formatting cost — the timestamp cache — from any I/O: +22.6%.

The terminal figure depends on the terminal emulator's rendering speed, so it's less reproducible than pipe/devnull; run-to-run noise is a few percent throughout.

Notes

  • Colors resolve per log() call, not at module scope — the CLI assigns NODE_ENV after importing this module, so a module-level check reads it too early.
  • No runtime-specific code: Node, Deno and Bun all go through process.stdout; workers fall back to console.log.
  • Adds the log() tests F46 asks for (the module had zero coverage).
  • Partially addresses the log.ts:4 item — colors are gated and console.log is no longer hard-coded; LogOptions still has no sink option, so that item stays open.

pnpm test green, lint and types clean.

🤖 Generated with Claude Code

`log()` called `console.log` once per request and rebuilt an Intl-backed
timestamp every time. Instead, buffer lines and flush once per event loop
turn via `setImmediate`, and cache the formatted time for the second it is
valid for (two timestamps in the same second always format identically).
Colors are now also resolved once per `log()` call rather than per request.

Under load this is 61 lines per stdout write instead of 1, worth +23% RPS
(14,616 -> 17,992; oha -c 64, 3s, median of 9 interleaved runs across
/dev/null, pipe and file sinks). Against a no-logger baseline the logger's
overhead drops from 28% to ~10%. Output is byte-identical.

Buffering means a line can be pending when the process exits, so flush what
is left synchronously from an `exit` hook.

Colors are additionally disabled when `NODE_ENV` is `production`: escapes
are noise once logs reach a file or a collector, and a container started
with a tty was still getting them. `FORCE_COLOR` overrides. This is
resolved per `log()` call, not at module scope, because the CLI assigns
`NODE_ENV` after this module is imported.

Adds the `log()` tests F46 asks for. Partially addresses the `log.ts:4`
item (colors gated, `console.log` no longer hard-coded); `LogOptions` still
has no sink option, so that item stays open.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@pi0x
pi0x requested a review from pi0 as a code owner July 17, 2026 11:44
@coderabbitai

coderabbitai Bot commented Jul 17, 2026

Copy link
Copy Markdown

Review Change Stack

📝 Walkthrough

Walkthrough

The request logger now supports conditional colors, cached formatting, batched stdout writes, backpressure handling, exit-time flushing, and fallback output. Tests cover these behaviors, while a benchmark compares logger throughput across implementations and output sinks.

Changes

Request logging

Layer / File(s) Summary
Formatting and color policy
src/log.ts, docs/1.guide/4.middleware.md
Color resolution, timestamp and status formatting, queued request lines, and conditional-color documentation are updated.
Buffered output and exit flushing
src/log.ts
Output is coalesced, paused during stdout backpressure, and synchronously flushed during process exit.
Logger behavior tests
test/log.test.ts
Tests cover formatting, passthrough responses, color rules, status colors, batching, ordering, and backpressure recovery.
Logging benchmark harness
package.json, test/bench-log/*
A benchmark compares no logger, the previous logger, and the current logger across sinks, reporting median rates and overhead.

Estimated code review effort: 4 (Complex) | ~60 minutes

Possibly related PRs

  • h3js/srvx#175: Both changes modify src/log.ts and its color behavior.
  • h3js/srvx#238: Both changes update srvx/log documentation and color rules.

Suggested reviewers: pi0

Poem

I’m a rabbit with logs in a tidy queue,
Batching each line as stdout winds through.
Colors fade when production is near,
Backpressure pauses, then drain brings cheer.
Benchmarks hop onward, fast and clear!

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Docstring Coverage ✅ Passed Docstring coverage is 100.00% which is sufficient. The required threshold is 80.00%.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly summarizes the main performance changes to log batching and timestamp caching.
✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch perf/async-logger

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@codecov

codecov Bot commented Jul 17, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 81.81818% with 12 lines in your changes missing coverage. Please review.

Files with missing lines Patch % Lines
src/log.ts 81.81% 10 Missing and 2 partials ⚠️

📢 Thoughts on this report? Let us know!

@pkg-pr-new

pkg-pr-new Bot commented Jul 17, 2026

Copy link
Copy Markdown

Open in StackBlitz

npm i https://pkg.pr.new/srvx@266

commit: dc92051

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 2

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@src/log.ts`:
- Around line 93-98: Update the exit-time write logic around
proc.getBuiltinModule and fs.writeSync to encode chunk once and repeatedly call
fs.writeSync with the remaining byte range, advancing by each returned byte
count until all bytes are flushed. Preserve the existing proc.stdout fallback
when the filesystem module is unavailable.
- Around line 31-38: Update the stdout handling around the write helper to
register a shared error handler on process.stdout that safely absorbs
broken-pipe errors, including EPIPE, outside the existing try/catch flow. Reuse
that handler for the stdout stream and add a regression test using a pipe that
closes before writing, verifying the process remains stable.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: a4ac02f6-6cf6-411a-92a5-4dda5d9bc944

📥 Commits

Reviewing files that changed from the base of the PR and between d2e2b0d and 6e89f3b.

📒 Files selected for processing (3)
  • docs/1.guide/4.middleware.md
  • src/log.ts
  • test/log.test.ts

Comment thread src/log.ts Outdated
Comment thread src/log.ts
pi0 and others added 4 commits July 17, 2026 12:11
Honor stdout.write()'s return value: when the buffer is over its high-water
mark, stop writing and wait for `drain` instead of letting Node's internal
buffer grow unbounded. On exit, encode via TextEncoder (runtime-agnostic;
`Buffer` is Node-only) and loop writeSync until all bytes land, guarding
against partial writes. Adds a backpressure/drain test.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 2

🧹 Nitpick comments (2)
test/bench-log/README.md (1)

28-30: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Align documentation with the suggested formatting refactor.

If you opt to rename the "overhead" column to "change" in _run.mjs to resolve the double-negative formatting, you can update this paragraph to match.

📝 Proposed update
-| `file`    | logs appended to a file on disk                                     |
-
-`overhead` is the throughput given up by adding the logger, relative to the
-no-logger baseline for that same sink. The run order of the cells is shuffled so
+| `file`    | logs appended to a file on disk                                     |
+
+`change` is the relative difference in throughput caused by adding the logger, compared
+to the no-logger baseline for that same sink. The run order of the cells is shuffled so
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@test/bench-log/README.md` around lines 28 - 30, Align the benchmark README
paragraph with the formatting refactor in _run.mjs: if the overhead column is
renamed to “change,” update this paragraph to describe the “change” column and
its meaning while preserving the explanation of the same-sink no-logger baseline
and shuffled run order.
test/bench-log/_run.mjs (1)

142-152: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Standardize the throughput change formatting.

If the logger implementation happens to improve throughput (e.g., due to caching improvements or natural machine variance), overhead evaluates to a negative number. Due to the hardcoded - prefix in the string template, this results in an awkward double-negative display (e.g., --5.0%).

Calculating the standard relative difference and formatting the sign dynamically makes the output much clearer.

✨ Proposed refactor
-  // Overhead = throughput given up by adding the logger, relative to no logger.
-  const overhead = ((none - withLog) / none) * 100;
-  return [name, none.toLocaleString(), withLog.toLocaleString(), `-${overhead.toFixed(1)}%`];
+  // Difference = relative throughput change caused by adding the logger.
+  const diff = ((withLog - none) / none) * 100;
+  const sign = diff > 0 ? "+" : "";
+  return [name, none.toLocaleString(), withLog.toLocaleString(), `${sign}${diff.toFixed(1)}%`];
 });
 
-const table = markdownTable(["sink", "no logger", "log()", "overhead"], rows, [
+const table = markdownTable(["sink", "no logger", "log()", "change"], rows, [
   "left",
   "right",
   "right",
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@test/bench-log/_run.mjs` around lines 142 - 152, Update the throughput
formatting in the rows mapping to avoid hardcoding a negative sign for overhead.
Calculate the relative difference consistently and format its sign dynamically,
so performance regressions and improvements display as single, clear signed
percentages while preserving the existing table output.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@test/bench-log/_run.mjs`:
- Around line 106-110: Update the cleanup logic in the benchmark runner’s
finally block to await the child process’s "exit" event only when the process is
still running; skip the wait when it has already terminated, while preserving
the existing SIGKILL and file-descriptor cleanup behavior.
- Around line 51-55: Update the imports used by the benchmark sink setup to
include the devNull export from node:os, then change the devnull sink in SINKS
to open devNull instead of hardcoding /dev/null. Preserve the existing sink
names and opening behavior.

---

Nitpick comments:
In `@test/bench-log/_run.mjs`:
- Around line 142-152: Update the throughput formatting in the rows mapping to
avoid hardcoding a negative sign for overhead. Calculate the relative difference
consistently and format its sign dynamically, so performance regressions and
improvements display as single, clear signed percentages while preserving the
existing table output.

In `@test/bench-log/README.md`:
- Around line 28-30: Align the benchmark README paragraph with the formatting
refactor in _run.mjs: if the overhead column is renamed to “change,” update this
paragraph to describe the “change” column and its meaning while preserving the
explanation of the same-sink no-logger baseline and shuffled run order.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: 6f1012e5-c1aa-4dcf-a341-2c7b7193ce6e

📥 Commits

Reviewing files that changed from the base of the PR and between 6e89f3b and 9ba691c.

📒 Files selected for processing (6)
  • package.json
  • src/log.ts
  • test/bench-log/README.md
  • test/bench-log/_run.mjs
  • test/bench-log/_server.mjs
  • test/log.test.ts
🚧 Files skipped from review as they are similar to previous changes (2)
  • test/log.test.ts
  • src/log.ts

Comment thread test/bench-log/_run.mjs Outdated
Comment thread test/bench-log/_run.mjs Outdated

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 2

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (2)
src/log.ts (2)

21-24: 🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

Fix FORCE_COLOR evaluation to correctly disable colors.

If FORCE_COLOR is explicitly set to "0" or "false" to disable colors, !!env?.FORCE_COLOR will still evaluate to true because non-empty strings are truthy. This results in colors being enabled when they were explicitly requested to be disabled.

🐛 Proposed fix
 function colorsEnabled(): boolean {
   const env = globalThis.process?.env;
-  return !!env?.FORCE_COLOR || env?.NODE_ENV !== "production";
+  if (env?.FORCE_COLOR !== undefined) {
+    return env.FORCE_COLOR !== "0" && env.FORCE_COLOR !== "false" && env.FORCE_COLOR !== "";
+  }
+  return env?.NODE_ENV !== "production";
 }
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/log.ts` around lines 21 - 24, Update colorsEnabled() so FORCE_COLOR
values "0" and "false" explicitly disable colors rather than being treated as
truthy strings. Preserve the existing non-production default when FORCE_COLOR is
unset, and continue enabling colors for other explicitly enabled values.

36-36: 🩺 Stability & Availability | 🟠 Major | ⚡ Quick win

Handle broken stdout writes outside try/catch.

process.stdout.write() can surface EPIPE as an 'error' event, so catching synchronous errors won't prevent a closed pipe from crashing the process. Add a shared stdout error handler to safely absorb broken-pipe errors.

🔧 Proposed fix
 const encoder = /* `@__PURE__` */ new TextEncoder();
 const stdout = globalThis.process?.stdout;
+stdout?.on?.("error", () => {});
 const write: (chunk: string) => boolean = /* `@__PURE__` */ (() => {
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/log.ts` at line 36, Update the stdout setup around the `stdout` symbol to
register a shared `'error'` event handler that safely absorbs broken-pipe
(`EPIPE`) errors, including errors emitted asynchronously by
`process.stdout.write()`. Preserve the existing optional-process behavior and
avoid relying solely on synchronous try/catch handling.
♻️ Duplicate comments (1)
test/bench-log/_run.mjs (1)

118-122: 🩺 Stability & Availability | 🟠 Major | ⚡ Quick win

Prevent the benchmark runner from deadlocking on server crashes.

If the child process crashes prematurely (e.g., port 3000 is already in use, or there is a syntax error in the server), waitReady() correctly throws after exhausting its retries, routing execution into this finally block. However, because the child process has already terminated, await once(child, "exit") will wait forever for an event that has already fired, deadlocking the benchmark runner instead of failing gracefully.

Check if the process is still running before awaiting its exit.

🔒️ Proposed fix to prevent deadlocks
   finally {
-    child.kill("SIGKILL");
-    await once(child, "exit"); // free port 3000 before the next cell
+    if (child.exitCode === null && child.signalCode === null) {
+      child.kill("SIGKILL");
+      await once(child, "exit"); // free port 3000 before the next cell
+    }
     if (fd !== null) closeSync(fd);
   }
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@test/bench-log/_run.mjs` around lines 118 - 122, Update the cleanup logic in
the finally block around child.kill and once(child, "exit") to await the exit
event only when the child process is still running; skip the wait when it has
already terminated, while preserving the existing kill and file-descriptor
cleanup behavior.
🧹 Nitpick comments (1)
src/log.ts (1)

159-159: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Standardize the time string locale.

toLocaleTimeString() without a specified locale uses the system locale, which can result in inconsistent log formats across different environments (e.g., 12-hour vs. 24-hour clock, AM/PM, or non-English characters). Consider using a fixed locale like "en-US" with { hour12: false } for consistent, predictable log outputs.

🛠️ Proposed fix
-      cachedTime = gray(`[${new Date(now).toLocaleTimeString()}]`);
+      cachedTime = gray(`[${new Date(now).toLocaleTimeString("en-US", { hour12: false })}]`);
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/log.ts` at line 159, Update the cachedTime assignment in the log
timestamp formatting path to call toLocaleTimeString with a fixed locale and
hour12: false, ensuring consistent 24-hour log output across environments while
preserving the existing gray wrapper and timestamp format.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@src/log.ts`:
- Line 164: Update the status formatter in the log() flow so each status code’s
formatted chunk is resolved once and reused for the remainder of that log()
call. Cache the result of paint(paintForStatus(code))(code + "") keyed by status
code, while preserving the existing output and per-call cache scope.

In `@test/bench-log/_run.mjs`:
- Around line 60-63: Replace the hardcoded "/dev/null" path in the devnull sink
definition with the cross-platform devNull export from node:os, adding that
import alongside the existing imports while preserving the current openSync
behavior.

---

Outside diff comments:
In `@src/log.ts`:
- Around line 21-24: Update colorsEnabled() so FORCE_COLOR values "0" and
"false" explicitly disable colors rather than being treated as truthy strings.
Preserve the existing non-production default when FORCE_COLOR is unset, and
continue enabling colors for other explicitly enabled values.
- Line 36: Update the stdout setup around the `stdout` symbol to register a
shared `'error'` event handler that safely absorbs broken-pipe (`EPIPE`) errors,
including errors emitted asynchronously by `process.stdout.write()`. Preserve
the existing optional-process behavior and avoid relying solely on synchronous
try/catch handling.

---

Duplicate comments:
In `@test/bench-log/_run.mjs`:
- Around line 118-122: Update the cleanup logic in the finally block around
child.kill and once(child, "exit") to await the exit event only when the child
process is still running; skip the wait when it has already terminated, while
preserving the existing kill and file-descriptor cleanup behavior.

---

Nitpick comments:
In `@src/log.ts`:
- Line 159: Update the cachedTime assignment in the log timestamp formatting
path to call toLocaleTimeString with a fixed locale and hour12: false, ensuring
consistent 24-hour log output across environments while preserving the existing
gray wrapper and timestamp format.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: 5f00a5cf-76e0-4a61-a779-3eb1cd433074

📥 Commits

Reviewing files that changed from the base of the PR and between 9ba691c and 37e757f.

📒 Files selected for processing (5)
  • src/log.ts
  • test/bench-log/README.md
  • test/bench-log/_before.mjs
  • test/bench-log/_run.mjs
  • test/bench-log/_server.mjs
🚧 Files skipped from review as they are similar to previous changes (1)
  • test/bench-log/_server.mjs

Comment thread src/log.ts
Comment thread test/bench-log/_run.mjs Outdated
Comment on lines +60 to +63
process.stdout.isTTY && { name: "terminal", stdio: "inherit" },
{ name: "pipe", stdio: "pipe" },
{ name: "devnull", open: () => openSync("/dev/null", "w") },
].filter(Boolean);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🩺 Stability & Availability | 🟡 Minor | ⚡ Quick win

Avoid hardcoding /dev/null for better cross-platform compatibility.

Opening /dev/null throws ENOENT on Windows. You can use the built-in devNull export from node:os to seamlessly resolve the correct discard path (/dev/null on POSIX, \\.\NUL on Windows).

🛠️ Proposed fix for cross-platform compatibility

First, update the imports at the top of the file to include devNull:

import { devNull } from "node:os";

Then, update the sink definition:

-  { name: "devnull", open: () => openSync("/dev/null", "w") },
+  { name: "devnull", open: () => openSync(devNull, "w") },
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@test/bench-log/_run.mjs` around lines 60 - 63, Replace the hardcoded
"/dev/null" path in the devnull sink definition with the cross-platform devNull
export from node:os, adding that import alongside the existing imports while
preserving the current openSync behavior.

Comment thread package.json Outdated
@pi0
pi0 merged commit c239925 into main Jul 17, 2026
15 of 16 checks passed
@pi0
pi0 deleted the perf/async-logger branch July 17, 2026 17:11
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants