Skip to content

perf(evlog): eliminate object allocations on hot paths#181

Merged
HugoRCD merged 5 commits intomainfrom
perf/optimize-hot-paths
Mar 14, 2026
Merged

perf(evlog): eliminate object allocations on hot paths#181
HugoRCD merged 5 commits intomainfrom
perf/optimize-hot-paths

Conversation

@HugoRCD
Copy link
Copy Markdown
Owner

@HugoRCD HugoRCD commented Mar 14, 2026

Summary

  • Replace deepDefaults() with in-place mergeInto() — eliminates 1-N allocations per set(), error(), info(), warn() call
  • Remove double context spread in emit() — mutate context directly (terminal operation), pass reference to emitWideEvent()
  • Add ownsEvent fast path in emitWideEvent() — stamp timestamp, level, and globalEnv fields directly instead of { ...globalEnv, ...event } spread
  • In-place addLog() — push to existing array instead of array spread + context spread
  • Cache compiled RegExp in matchesPattern() — avoid recompiling glob patterns on every shouldKeep() call

Benchmark results (vs baseline)

Core benchmarks:

Operation Before After Speedup
log.set() deep nested 1.96M ops/s 8.64M ops/s 4.4x
log.set() multiple sequential 1.86M ops/s 7.88M ops/s 4.2x
log.emit() with context 265K ops/s 1.80M ops/s 6.8x
Full lifecycle (create+set+emit) 252K ops/s 1.80M ops/s 7.2x
Medium payload (50 fields) 56K ops/s 583K ops/s 10.4x
Large payload (200 fields) 9.6K ops/s 132K ops/s 13.8x

Comparison vs alternatives (evlog now wins 5/7):

Benchmark Before After vs Consola
Simple string log 1.65M 2.05M (+24%) 1.35x slower (was 1.60x)
Structured (5 fields) 1.34M 1.81M (+35%) evlog wins (was losing)
Deep nested 1.41M 1.84M (+30%) 1.74x faster
Child/scoped 1.39M 1.92M (+39%) 6.93x faster
Wide event lifecycle 902K 1.72M (+91%) 8.06x faster vs pino
Burst (100 logs) 13.8K 19.6K (+42%) 2.12x slower (was 2.95x)
Logger creation 18.8M 21.1M (+12%) 69.5x faster

Bundle size

Total gzip decreased from 38.61 kB → 37.88 kB (-730 B).

Replace `deepDefaults()` with in-place `mergeInto()` across all logger
methods (`set`, `error`, `info`, `warn`), remove double context spreads
in `emit()`, and add an `ownsEvent` fast path in `emitWideEvent()` that
stamps metadata directly instead of spreading `globalEnv + event`.

Also: in-place `addLog()` push, cached RegExp in `matchesPattern()`.
@vercel
Copy link
Copy Markdown

vercel bot commented Mar 14, 2026

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
evlog-docs Ready Ready Preview, Comment, Open in v0 Mar 14, 2026 7:15pm

@github-actions
Copy link
Copy Markdown
Contributor

github-actions bot commented Mar 14, 2026

Thank you for following the naming conventions! 🙏

@HugoRCD HugoRCD self-assigned this Mar 14, 2026
@HugoRCD HugoRCD changed the title perf(core): eliminate object allocations on hot paths perf(evlog): eliminate object allocations on hot paths Mar 14, 2026
@pkg-pr-new
Copy link
Copy Markdown

pkg-pr-new bot commented Mar 14, 2026

npm i https://pkg.pr.new/evlog@181
npm i https://pkg.pr.new/@evlog/nuxthub@181

commit: e65c327

@github-actions
Copy link
Copy Markdown
Contributor

github-actions bot commented Mar 14, 2026

Benchmark report

Performance

Threshold: 10% · 🔴 regression · 🟡 warning · 🟢 improvement · ⚪ unchanged

Status Benchmark Base (ops/sec) Current (ops/sec) Change p99 base p99 current
🔴 pipeline — serialization in drain > push 50 + JSON.stringify batch in drain 2.1K 1.5K -27.3% 11.1536ms 8.4680ms
🔴 pipeline — push throughput > push 100 events (no flush) 26.6K 22.5K -15.4% 0.1531ms 0.1745ms
🔴 pipeline — buffer overflow > push 1100 events (100 dropped, buffer=1000) 2.3K 2.0K -11.9% 1.9095ms 1.9773ms
🟡 pipeline — push + batch trigger > push 200 events (triggers 4 batch flushes) 17.5K 16.3K -6.8% 0.0802ms 0.0821ms
🟡 createError + parseError round-trip > create + parse (full) 78.5K 74.3K -5.4% 0.0226ms 0.0229ms
🟡 parseError > parse EvlogError 5.76M 5.53M -4.1% 0.0002ms 0.0002ms
🟡 createError + parseError round-trip > create + parse (simple) 109.2K 105.3K -3.5% 0.0182ms 0.0185ms
🟡 log.emit() > emit with error 21.3K 20.6K -3.4% 0.1096ms 0.0965ms
🟡 createTraceContextEnricher > with traceparent + tracestate 3.15M 3.05M -3.4% 0.0004ms 0.0004ms
pipeline — push + batch trigger > push 50 events (triggers 1 batch flush) 59.9K 58.1K -3.0% 0.0380ms 0.0375ms
createRequestLogger > with method + path 7.52M 7.35M -2.3% 0.0002ms 0.0002ms
createError > full options 109.1K 106.7K -2.2% 0.0177ms 0.0182ms
EvlogError serialization > toJSON() 4.48M 4.38M -2.2% 0.0003ms 0.0003ms
createError > with status 110.8K 108.8K -1.8% 0.0177ms 0.0180ms
log.set() payload sizes > large payload (200 nested fields) 18.7K 18.4K -1.7% 0.1681ms 0.1676ms
head sampling > no sampling configured 155.2K 152.6K -1.7% 0.0129ms 0.0139ms
createError > with cause 82.1K 80.9K -1.5% 0.0214ms 0.0215ms
createTraceContextEnricher > with traceparent 1.84M 1.81M -1.4% 0.0006ms 0.0009ms
head + tail sampling combined > full emit with force-keep (tail sampling hit) 383.5K 378.2K -1.4% 0.0057ms 0.0057ms
log.emit() > emit minimal event 853.5K 842.4K -1.3% 0.0019ms 0.0015ms
EvlogError serialization > JSON.stringify() 692.7K 683.8K -1.3% 0.0016ms 0.0016ms
createLogger > with nested context 7.06M 7.00M -0.8% 0.0002ms 0.0002ms
createGeoEnricher > Vercel headers (full) 1.94M 1.93M -0.8% 0.0007ms 0.0006ms
createGeoEnricher > no geo headers 1.18M 1.17M -0.6% 0.0009ms 0.0016ms
head sampling > with sampling rates 228.4K 227.7K -0.3% 0.0082ms 0.0084ms
createLogger > no initial context 7.50M 7.49M -0.2% 0.0002ms 0.0002ms
tail sampling (shouldKeep) > status match 14.54M 14.52M -0.1% 0.0001ms 0.0001ms
tail sampling (shouldKeep) > path glob match 14.49M 14.54M +0.3% 0.0001ms 0.0001ms
pretty print (development mode) > emit + pretty print 263.7K 264.6K +0.3% 0.0079ms 0.0074ms
createError > string message 106.7K 107.1K +0.4% 0.0190ms 0.0184ms
head + tail sampling combined > full emit with sampling (likely sampled out) 857.0K 861.6K +0.5% 0.0034ms 0.0034ms
log.set() payload sizes > medium payload (50 fields) 145.5K 146.3K +0.6% 0.0125ms 0.0125ms
tail sampling (shouldKeep) > duration match 14.43M 14.51M +0.6% 0.0001ms 0.0001ms
JSON.stringify baseline > raw JSON.stringify (same payload) 664.1K 668.1K +0.6% 0.0020ms 0.0017ms
createTraceContextEnricher > no trace headers 5.85M 5.89M +0.7% 0.0002ms 0.0002ms
client log formatting > build + serialize (rich log) 510.3K 514.4K +0.8% 0.0032ms 0.0021ms
createRequestLogger > with method + path + requestId 5.00M 5.04M +0.9% 0.0002ms 0.0002ms
client log serialization > JSON.stringify — minimal log 1.35M 1.37M +1.0% 0.0012ms 0.0009ms
createGeoEnricher > Cloudflare headers (country only) 448.0K 452.9K +1.1% 0.0026ms 0.0026ms
createRequestSizeEnricher > with content-length 8.03M 8.13M +1.3% 0.0002ms 0.0001ms
createLogger > with shallow context 7.50M 7.59M +1.3% 0.0002ms 0.0002ms
client log formatting > build formatted log object (with identity spread) 1.17M 1.19M +1.3% 0.0009ms 0.0009ms
createUserAgentEnricher > Googlebot 1.46M 1.48M +1.3% 0.0010ms 0.0009ms
silent mode (no output) > emit silent (event build only) 263.0K 266.6K +1.4% 0.0076ms 0.0076ms
client log serialization > JSON.stringify — batch of 50 17.3K 17.6K +1.4% 0.0772ms 0.0690ms
client log serialization > JSON.stringify — batch of 10 81.5K 82.7K +1.5% 0.0211ms 0.0205ms
JSON serialization (production mode) > emit + JSON.stringify 264.1K 268.2K +1.6% 0.0074ms 0.0080ms
createUserAgentEnricher > Chrome desktop 874.5K 888.6K +1.6% 0.0024ms 0.0023ms
EvlogError serialization > toString() 1.32M 1.34M +1.7% 0.0009ms 0.0013ms
log.set() payload sizes > small payload (2 fields) 623.1K 635.1K +1.9% 0.0017ms 0.0017ms
log.emit() > full lifecycle (create + set + emit) 246.3K 251.6K +2.2% 0.0080ms 0.0079ms
log.set() > shallow merge (3 fields) 5.36M 5.49M +2.3% 0.0003ms 0.0003ms
full enricher pipeline > all enrichers (all headers present) 188.5K 193.0K +2.4% 0.0062ms 0.0060ms
full enricher pipeline > all enrichers (no headers) 911.9K 933.9K +2.4% 0.0012ms 0.0012ms
client log formatting > build formatted log object (minimal) 1.36M 1.40M +2.7% 0.0014ms 0.0008ms
tail sampling (shouldKeep) > no match (fast path) 14.18M 14.59M +2.9% 0.0001ms 0.0001ms
parseError > parse plain Error 14.21M 14.64M +3.0% 0.0001ms 0.0001ms
🟢 client log serialization > JSON.stringify — rich log 608.1K 626.9K +3.1% 0.0026ms 0.0018ms
🟢 parseError > parse string 13.29M 13.79M +3.8% 0.0001ms 0.0001ms
🟢 log.emit() > emit with context 249.3K 259.5K +4.1% 0.0089ms 0.0087ms
🟢 createUserAgentEnricher > no user-agent header 10.41M 10.92M +4.9% 0.0001ms 0.0001ms
🟢 log.set() > deep nested merge 4.65M 4.89M +5.2% 0.0003ms 0.0003ms
🟢 log.set() > shallow merge (10 fields) 4.81M 5.06M +5.2% 0.0003ms 0.0003ms
🟢 createRequestSizeEnricher > no content-length 7.06M 7.46M +5.7% 0.0002ms 0.0002ms
🟢 parseError > parse fetch-like error 13.31M 14.37M +8.0% 0.0001ms 0.0001ms
🟢 log.set() > multiple sequential sets 1.80M 1.97M +9.4% 0.0007ms 0.0007ms
🟢 createUserAgentEnricher > Firefox Linux 1.18M 1.32M +12.1% 0.0016ms 0.0010ms
🟢 pipeline — push throughput > push 1000 events (no flush) 1.8K 2.2K +23.1% 11.6833ms 0.6956ms
🟢 pipeline — push throughput > push 1 event (no flush) 393.9K 510.1K +29.5% 0.0036ms 0.0034ms
Summary
  • 3 regressions (>10% slower)
  • 6 warnings (3-10% slower)
  • 3 improvements (>10% faster)
  • 57 unchanged

Warning

Performance regressions detected (>10% slower). Review before merging.

Bundle size

Threshold: 5% · 🔴 larger · 🟡 warning · 🟢 smaller · ⚪ unchanged · 🆕 new

Status Entry Base (gzip) Current (gzip) Change Raw delta
framework/nitro 6.85 kB 6.85 kB 0.0% 0 B
logger 3.64 kB 3.64 kB 0.0% 0 B
framework/next 3.02 kB 3.02 kB 0.0% 0 B
adapter/sentry 2.33 kB 2.33 kB 0.0% 0 B
adapter/otlp 2.09 kB 2.09 kB 0.0% 0 B
enrichers 1.92 kB 1.92 kB 0.0% 0 B
framework/sveltekit 1.54 kB 1.54 kB 0.0% 0 B
adapter/posthog 1.48 kB 1.48 kB 0.0% 0 B
adapter/fs 1.42 kB 1.42 kB 0.0% 0 B
pipeline 1.35 kB 1.35 kB 0.0% 0 B
utils 1.34 kB 1.34 kB 0.0% 0 B
adapter/axiom 1.30 kB 1.30 kB 0.0% 0 B
browser 1.21 kB 1.21 kB 0.0% 0 B
error 1.21 kB 1.21 kB 0.0% 0 B
framework/nestjs 1.21 kB 1.21 kB 0.0% 0 B
adapter/better-stack 1.08 kB 1.08 kB 0.0% 0 B
framework/elysia 1.06 kB 1.06 kB 0.0% 0 B
framework/fastify 1010 B 1010 B 0.0% 0 B
workers 960 B 960 B 0.0% 0 B
framework/express 702 B 702 B 0.0% 0 B
framework/hono 593 B 593 B 0.0% 0 B
toolkit 243 B 243 B 0.0% 0 B
core (index) 205 B 205 B 0.0% 0 B
types 31 B 31 B 0.0% 0 B
Total 37.71 kB 37.71 kB 0.0% 0 B

Instead of comparing PR benchmarks against a stored baseline from a
previous run (different runner, different load), the compare job now
builds and benchmarks main first, then the PR branch, on the same
machine. This eliminates cross-runner variance that caused false
regression reports.
Drop the custom A/B compare job (cross-runner noise made it unreliable)
and add CodSpeed for PR benchmark regression detection. CodSpeed uses
instrumentation instead of wall-clock timing, eliminating CI noise.

- Add `@codspeed/vitest-plugin` to vitest config
- Create `.github/workflows/codspeed.yml` for PR + push benchmarks
- Simplify `bench.yml` to only update-baseline (RESULTS.md tracking)
@codspeed-hq
Copy link
Copy Markdown

codspeed-hq bot commented Mar 14, 2026

Congrats! CodSpeed is installed 🎉

🆕 96 new benchmarks were detected.

You will start to see performance impacts in the reports once the benchmarks are run from your default branch.

Detected benchmarks


ℹ️ Only the first 20 benchmarks are displayed. Go to the app to view all benchmarks.


Open in CodSpeed

@HugoRCD HugoRCD merged commit 0c1f25c into main Mar 14, 2026
13 checks passed
@HugoRCD HugoRCD deleted the perf/optimize-hot-paths branch March 14, 2026 19:19
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant