Skip to content

V5 Release#347

Merged
terehov merged 67 commits into
developmentfrom
v5
Jul 14, 2026
Merged

V5 Release#347
terehov merged 67 commits into
developmentfrom
v5

Conversation

@terehov

@terehov terehov commented Jul 14, 2026

Copy link
Copy Markdown
Contributor

No description provided.

terehov added 30 commits July 2, 2026 08:46
Replace the flat settings and overwrite hooks with grouped settings
(mask/json/pretty/stack/meta), one log pipeline (mask -> logObj -> meta ->
middleware -> format -> transports) and a middleware chain. Entry points
now inject their runtime environment instead of sharing a module-level
singleton, the default output type is environment-aware, and JSON output
is flat and fields-first. Adds async-context correlation on top of
AsyncLocalStorage, path/regex/censor masking that survives shared
references and cycles, runtime level changes with optional browser
persistence, and Logger.fromEnv. Every env read is guarded per property
so Deno without --allow-env can import and log.
Ship optional features as their own exports so the core stays lean:
pino/otel/genai presets, file/http/ringbuffer/worker transports, standard
serializers, a testing helper, log throttling, a console wrapper with
re-entrancy protection, box drawing, a minimal lite logger, and a tslog
bin that pretty-prints NDJSON.
Conditional exports pick the entry per runtime (node, browser/worker,
deno/bun/react-native) and map every subpath individually. sideEffects
is false and enforced by a new audit script; emitted JS keeps comments
so the PURE annotation on the default logger lets bundlers drop it when
unused. prepare-publish writes the ESM-only dist package.json and the
Node floor moves to 20.
Move every suite to grouped settings and cover the new surface:
transports and presets, async context, custom levels, hash and path
masking, the CLI, lite, throttle and the console wrapper. New
hostile-input regressions cover circular and BigInt error causes,
throwing getters, shared-reference and Map/Set masking, sparse arrays,
and constructing a logger when every env read throws.
Update the README, RECIPES and llms.txt for grouped settings and the
subpath exports, add MIGRATION_v4_to_v5.md mapping the removed flat keys
to their new homes, and write the 5.0.0 release notes and changelog.
A new check-doc-sync script keeps llms.txt and RECIPES consistent with
the source; it runs in the check script and as a CI step.
Unknown top-level and group keys now warn in development with a
did-you-mean suggestion, and every v4 flat key (including the 4.11
internalFramePatterns and prettyLogLevelMethod) points at its grouped
home, so a carried-over config can no longer silently disable masking.
strictConfig turns both into typed TslogConfigError codes. Enumeration
is guarded against hostile proxies and prototype-chain lookups.
json.stableKeyOrder now defaults to false: head keys keep their stable
order, but the deep sorted copy of user fields is opt-in.
Serialize the static _meta block and per-level head keys once per
logger instead of on every log; per call only the message, the user's
fields and the two timestamps are stringified. A differential suite
asserts the planned line is byte-identical to the object path for
every covered shape, and anything else (embedded errors, extra meta
keys, positional args, stableKeyOrder) falls back to the full path.
log.info("msg", { fields }) now spreads a single trailing plain object
exactly like the pino object-first shape, driven by a call-shape hint
so Buffers, Maps and class instances keep their positional bucket.
User fields can no longer clobber the level/levelId/time head keys,
shared references serialize in full (only true cycles collapse to
"[Circular]"), and own __proto__ keys are dropped on every path.
Resolve util.formatWithOptions through process.getBuiltinModule before
falling back to a global require, so ESM Node, Deno and Bun get native
inspect instead of the low-fidelity polyfill (Maps and URLs rendered as
empty objects); the polyfill itself now prints a URL's href. Drop the
deep URL-normalization walk from the no-mask fast path — only top-level
URL arguments are expanded (on both the masked and unmasked paths) and
nested URLs serialize via URL#toJSON. Skip the prefix spread and the
middleware context allocation when unused, copy lazy-returned argument
arrays so middleware cannot mutate caller state, cache per-call
transport lines in plain locals instead of two Maps, and omit the meta
path key entirely when stack capture is off (it serialized as the junk
string "[undefined]" on every universal/browser record).
The script referenced a benchmarks/run.mjs that never existed, so
npm run bench failed out of the box; bench.mjs is the real entry.
Drop the bench:gate alias until a real regression gate exists.
logger.flush() now awaits in-flight async write() promises, tracked per
transport object so a parent's flush also covers writes a sub-logger
dispatched to a shared sink. Plain async transport functions work too:
the TransportFn wrapper used to discard their promise. Disposal gets an
ownership model: a sub-logger flushes inherited transports but only
disposes the ones it attached itself, so a request-scoped `await using`
child can no longer terminate the root logger's sinks, and the sync
disposer sequences flush before dispose.
An fs error (disk full, permissions, a path blocked by a file) used to
kill the process via an unhandled rejection or an unlistened stream
error. Errors are now contained and reported through onError (one
console.error per burst by default), and a failed or broken open is
retried on the next write. A new flushSync() writes the unconfirmed
tail through its own descriptor, skipping the single possibly
mid-syscall line so process.exit does not duplicate it.

New shared exit hooks (src/internal/exitHooks.ts) flush transports on
beforeExit and drain the file transport synchronously on exit, so even
process.exit(0) or an uncaught exception keeps the buffered tail. The
flush cascade is latched to run once per process — an app that logs
inside its own beforeExit handler used to ping-pong with the flush hook
and never exit. Hooks unregister only after disposal has drained.
One hung collector connection used to stall the send chain — and with
it every later batch and flush() — forever. Each attempt now carries an
AbortSignal timeout (timeoutMs, default 10s), failed batches retry with
exponential backoff (hard 4xx fails fast), and a single-pump drain
replaces the unbounded promise chain so maxBufferedLines genuinely
bounds memory while the collector is down: the oldest lines drop first
and the throttled report carries a sample. The backoff timer is
deliberately ref'd — unref'd, the process exited mid-retry with flush()
never settling and onError never firing. Also adds fetch keepalive
passthrough and a beforeExit/pagehide flush hook.
Attaching the worker transport used to keep the process alive forever:
the thread was never unref'd, and flushing an idle transport even
spawned one. The worker is now unref'd after its listeners attach (a
message listener re-refs the port, silently undoing an earlier unref)
and flush() refs it only for the round-trip — an awaited promise alone
does not keep Node alive, so an `await flush()` as the last statement
used to exit before the drain finished. Flushes are serialized and
skipped when nothing was queued, a dead worker settles only its own
outstanding flushes (not a replacement's), respawns up to maxRespawns
and then falls back to inline writes, and the eval'd bootstrap uses
dynamic imports so it works whether the parent runs CJS or ESM.

Adds a lifecycle suite with subprocess tests covering exit draining,
the no-hang guarantees, the latched beforeExit cascade and the ref'd
retry backoff, and documents the delivery guarantees and app-owned
SIGTERM shutdown pattern.
Bindings give static correlation fields a first-class home: a
`bindings` settings key whose fields land on every JSON record, merge
down the sub-logger chain (child keys win) and always lose to
per-call fields. They are masked once, at construction, from the raw
parent values, so hash or function censors keep the same token across
generations, and a lone logged Error keeps them at the top level
instead of swallowing them into the error payload. Keys that would
hijack the record (message, _meta, integer-like) are dropped with a
development warning.

Custom levels now install real methods: customLevels and addLevel()
define logger.audit(...) and friends, typed via the new createLogger
helper and addLevel's return type. Level names resolve
case-insensitively everywhere, including transport minLevel, which
previously ignored custom levels entirely. Reserved member collisions
and same-name-different-case duplicates throw at construction; a
drifting log(id, name) pair warns in development.
React Native used to fall into the node branch: server-only stack
parsing, a bogus hostname field, and JSON output in the Metro
console. It is now detected via navigator.product, reports runtime
"react-native" (with the Hermes engine version when available and no
hostname key), and defaults to pretty output.

Stack frames need a hybrid parser. Hermes — the default engine —
emits V8-style frames whose release locations carry an "address at"
prefix and whose Metro dev URLs put :line:col AFTER the query string,
while JSC emits fn@bundle:line:col with often slash-free bundle
names. The server parser now strips the prefix and pops the position
before dropping the query, and a lenient JSC matcher covers the rest,
so frames from all four real-world shapes (Hermes/JSC, dev/release)
parse instead of being silently dropped.
Every server log carried hostname "unknown" unless one of the
HOSTNAME/HOST/COMPUTERNAME env vars happened to be set. Those stay
the explicit override, then the OS hostname is asked directly —
Deno.hostname() on Deno, otherwise node:os through
process.getBuiltinModule so no import is added and tree-shaking
holds — before falling back to location.hostname. Deno used to
consult Deno.hostname() before the env override; every probe is
guarded so permissionless runs stay silent.
runInContext silently no-oped on Cloudflare Workers: the nodejs_als
flag makes node:async_hooks importable, but there is no global and no
process.getBuiltinModule to probe. A new contextStorage setting takes
an AsyncLocalStorage instance (anything with its run/getStore shape),
wired eagerly so a context entered through the app's own als.run() is
attached from the very first log call. Sub-loggers inherit the
instance; a nullish override keeps the parent's.

The lazily created store now lives in a box shared across the whole
sub-logger family, so creation order no longer decides whether a
child sees the context. runInContext warns once per logger in
development when no storage exists, a malformed instance is flagged
at construction (once, not per descendant), and the caller's ctx
object is copied so mutating it later cannot rewrite live logs.
NO_COLOR on an interactive TTY used to flip the default type to JSON.
Per no-color.org it only means "no color": a TTY now gets uncolored
pretty output while CI and piped output still get JSON. Style
resolution gained a clear precedence — an explicit pretty.style wins
over FORCE_COLOR, which wins over NO_COLOR — which also makes the
CLI's --color/--no-color flags authoritative; without a flag the CLI
now colors only when stdout is an interactive TTY, so pipes stay
ANSI-free.

Updates the docs that still claimed NO_COLOR forced JSON, and adds a
runtime fidelity suite covering React Native detection, the real
Hermes/JSC frame shapes, hostname resolution, contextStorage
injection, and the NO_COLOR behavior.
Exports-unaware bundlers resolve the module field, which pointed at
the Node entry — its static node:util import breaks browser builds.
The universal entry picks its environment at construction time and
works everywhere.
The browser twin of the JSON log-types test still asserted the old
"1" bucket for a trailing object; against a rebuilt bundle the fields
spread at the top level, exactly as the Node suite already asserts.
BaseLogger used to import the masking engine, the precompiled JSON
line renderer, the pretty meta builder and the settings validator
directly, so every bundle paid for all of them regardless of config.
They now arrive as a feature set the entry composes (the standard
entries inject the full one, exported as fullCoreFeatures), the
transport formatter resolution takes the JSON renderer as an
argument, and render/json exposes the plan-free path as
renderJsonUnplanned — byte-identical output, pinned by the existing
differential suite.

A BaseLogger constructed without a feature set keeps correct JSON via
the plan-free renderer and the masking engine's mask-off fast path,
and rejects settings it cannot honor (active mask config, pretty
type, strictConfig) instead of silently degrading.
The full entry ships masking, pretty rendering and stack parsing
whether or not the config uses them — the output type is a runtime
value, so bundlers cannot drop them. tslog/slim composes the same
pipeline (levels, sub-loggers, bindings, custom levels, middleware,
runInContext, transports; byte-identical fields-first JSON) without
those subsystems: about 9.6KB gzip against 19.9KB for the full
browser entry.

Where slim cannot honor a setting it fails loudly: active mask config
and type "pretty" throw at construction, settings are read through a
descriptor-faithful clone rather than a spread (a prototype-held mask
getter must not sneak past the check), and the resolved mask group is
frozen so a later mutation throws instead of silently logging secrets
in plaintext. Stack capture is absent (_meta.path never attaches,
error stacks are empty arrays) and the pretty transport stub reads
error name/message through guarded accessors, because the transport
line is built before per-transport isolation applies.
check-bundle-size bundles both entries straight from src with esbuild
and fails when the gzipped output exceeds its budget, so the size
numbers in the docs cannot rot silently. Runs in CI next to the
doc-sync check. Entry paths are interpolated with forward slashes
(Windows backslashes would be eaten as escape sequences inside the
generated import) and the script derives its root via fileURLToPath —
import.meta.dirname needs a newer Node than engines guarantees.
A "Which build should I use?" section after the install guides maps
the lifecycle to the right distribution: the main entry for
development and production (env-aware output covers both), tslog/slim
for size-critical bundles, tslog/testing for tests, tslog/lite for
native devtools line numbers, and the CLI for reading production
NDJSON. Also aligns the quoted gzip sizes across README and RECIPES
with the measured numbers.
type: "json" lines on the Node entry now buffer per event-loop turn and
land as one process.stdout.write (early flush past 8KB) instead of one
console.log per line - the per-line util.format dispatch and unbatched
stream write dominated logger throughput once serialization got fast.

The sink is wired through new optional EnvironmentProvider hooks
(writeJsonLine/flushJsonSink), so browser/universal/slim entries keep
console.log. Delivery is safeguarded: logger.flush() and await using
resolve once stdout accepted every chunk (non-false write return or ack
callback - a naive test stub can never park flush forever), beforeExit
flushes asynchronously, and a process exit hook drains the tail via
fs.writeSync with a partial-write loop. Write accounting uses an
idempotent settle latch so a sync-callback-then-throw stream cannot
desync it, and the EPIPE-swallowing error guard is attached per stream,
covering a replaced process.stdout.

Code intercepting console.log no longer sees Node JSON output - spy on
process.stdout.write or use type: "hidden" plus a transport (noted in
the migration guide). Tests capture through a shared helper that spies
both targets and forces a sync flush.
New top-level clock setting (() => Date) stamps _meta.date per log:
deterministic tests and offset/monotonic clocks without fake timers.
Sub-loggers inherit it by reference; a throwing or invalid-Date clock
is ignored so it can never break logging. Middleware-set ctx.meta.date
still wins, as before.

json.time controls the top-level timestamp representation: "iso"
(default), "epoch" (pino-style ms number), false (omit the key - a
user field named like timeKey then passes through), or a custom
(date) => string | number function whose failures and out-of-contract
results degrade to the ISO string instead of dropping the line.
_meta.date always stays UTC ISO. The precompiled line plan only ever
fires for the "iso" default; the gate lives at the renderJson entry so
starting on another mode cannot poison the plan cache, and the plan now
bails on ANY head-key collision instead of emitting duplicate keys.

toIsoString gets the missing edge guard: years outside 1000-9999 defer
to toISOString and an Invalid Date renders as an honest marker instead
of garbage or a throw - the pretty renderer gets the same guard (it
crashed with RangeError on a middleware-smuggled Invalid Date).

tslog/testing builds on the seam: createTestLogger(settings, { now,
normalize }) freezes only that logger's clock and pins hostname/
runtimeVersion/path for snapshot-stable output, and normalizeMeta()
scrubs records or captured lines standalone.
The otel preset claimed collector/OTLP suitability while emitting the
spec's prose field names (Timestamp, SeverityNumber, Body) - a shape no
collector ingests. It now has both output modes, honestly labeled:

- otlpFormat/toOtlpJson/toOtlpLogRecord produce the actual OTLP/JSON
  wire format: camelCase proto3 fields, int64 nanosecond timestamps as
  strings, typed AnyValue attributes, and the resourceLogs[].
  scopeLogs[].logRecords[] envelope with resource attributes kept
  separate from record attributes. Logged errors map onto the
  exception.type/message/stacktrace semantic conventions with the cause
  chain rendered as Caused-by sections; spread-shape calls resolve via
  the renderer's shape hint so no numeric bucket keys leak into
  attributes; attribute keys are deduplicated and trace/span ids are
  lowercase-hex validated so one malformed id cannot get a whole
  envelope rejected.
- otelFormat/toOtelRecord keep the data-model prose shape for custom
  pipelines, now documented as NOT collector-ingestible, with resource
  attributes winning collisions (resource identity semantics).

httpTransport gains an encodeBody option (a throwing encoder fails only
its batch, via onError); pairing it with the new otlpBatchBody merges a
batch of single-record envelopes into one request body, so batches POST
straight to a collector's /v1/logs.
The preset passed tslog's IErrorObject through verbatim, so err carried
a parsed frame ARRAY - pino-pretty degraded to a raw JSON blob and
error trackers keying on err.stack (Datadog, GCP Error Reporting,
Sentry's pino integration) got nothing, exactly on the logs that matter
most. Errors now reshape to pino's serializer output: type from the
constructor name, message, stack as the raw multi-line string (native
Error#stack when readable, rebuilt from frames otherwise), extra
enumerable own properties copied, and the cause chain recursed. A
hand-built error-like with a non-error cause passes it through verbatim
instead of crashing, and a non-array stack is treated as absent.

errorShape: "tslog" keeps the structured frame arrays for consumers
that prefer them.
The per-transport line was computed as an argument to the isolated
write call, so a throwing formatter - a custom LogFormatter, or a
hostile record blowing up a built-in renderer - escaped straight out of
logger.info() and starved every remaining transport, contradicting the
documented isolation contract. The format step is now guarded like the
write step: the failure is reported through the same channel and the
record is STILL delivered with an empty line, since the record is the
primary payload and bare-function transports never read the line.

Format-less transports on hidden loggers now receive the structured
JSON line, as core/transports documents - they previously got pretty
lines, paying styling cost in the exact configuration used for
transport-owned production output.
README gains a timestamps-and-clock section, the batched-stdout
explanation (including the console.log interception note), the OTLP
collector example replacing the broken otelFormat-to-collector one, and
the pino error-shape note. RECIPES reworks the OpenTelemetry recipe
around otlpFormat + otlpBatchBody and adds deterministic-test and
timestamp-control recipes. llms.txt and the changelog pick up the same
surface: clock, json.time, createTestLogger options, normalizeMeta, the
OTLP exports, errorShape, and httpTransport's encodeBody.
terehov added 26 commits July 5, 2026 16:04
The browser path regex captured segments as colon-free runs, so a
Vite-on-Windows frame like /@fs/C:/Git/app/main.js:12:3 was truncated at
the drive-letter colon to /@fs/C, dropping the line and column. Allow a
single drive-letter colon inside a path segment when it is immediately
followed by a slash, so the trailing :line:col still binds correctly.
Resolves the missing log position under Vite on Windows.

Fixes #323, #302
Avoids colliding with the common convention of naming a log's own
free-form metadata object "meta"; the reserved key now reads
unambiguously as tslog's own runtime block.
These examples predate the v5 rewrite and no longer reflect the
current API. Drops the associated build-example/dev-ts scripts,
tsconfig.example.json, and the nodemon/ts-node devDependencies they
needed.
Move trailing inline comments above their annotated lines and
reformat the code comparison table so nothing overflows the docs
site's content column. Also moves the agents/LLM section higher,
reworks the Performance section to stop citing unpublished
benchmark numbers, and corrects a stale claim about the default
output format in the migration table.
Replace the light docsify-replica theme with a crafted dark canvas,
a real type scale, and tslog's own log-level palette used with
intent (hero terminal, level pills). Adds a runtime-logo strip to
the header/hero and GitHub-alert-to-Starlight-aside conversion,
colored log-levels table, and license-link fixes in the README sync
script.
…y, per-call enablement

Refines the source-map resolver introduced in the previous commit (#307):

- Replace O(n) linear scan in `findSegment` with O(log n) binary search.
  Segments are already sorted by `genColumn`; a single bundled output
  line can carry hundreds of segments, so the scan cost matters.

- Update `fullFilePath` to the remapped position when source-map
  resolution succeeds, so templates using `{fullFilePath}` stay
  consistent with `{filePath}`/`{fileLine}`/`{fileColumn}`. The original
  transpiled `fullFilePath` is preserved when no remap occurs.

- Evaluate `sourceMapResolutionEnabled()` per call instead of freezing
  it at logger construction. Flipping `TSLOG_SOURCE_MAPS` or `NODE_ENV`
  at runtime now takes effect immediately without recreating the logger.

- Cap the parsed-map cache at 256 entries (FIFO eviction) to bound
  memory for pathological module graphs.

- Add tests for binary search with multiple segments, fullFilePath
  consistency after remap, nested-layout source anchoring, and
  per-call enablement toggling (37 tests, up from 33).
Build the remapped fullFilePath from the resolver's own source path
instead of the cwd-relativized effectivePath, so the field keeps its
"full path" meaning when the resolved source sits under cwd. Also
update two doc comments left stale by the per-call enablement change
(createSourceMapResolver no longer returns undefined when disabled).
Applies the combined Dependabot updates (PRs #342-#346) onto the v5
docs. Builds clean with 0 vulnerabilities.
Objects and arrays reach the console method by reference instead of being
pre-rendered through inspect, so browser DevTools shows collapsible trees
(#263, #295) and levelMethod-routed warn/error keep their native stack
groups (#226). Errors still render through the pretty error template.
if(condition) returns the logger when truthy and a cached no-op proxy when
falsy, so a per-call condition reads as a fluent chain (#299). The no-op
covers default and custom levels, is not thenable, and reads non-method
properties through to the real logger.
Deno type-checked the runner against the compiled dist JS, where optional
parameters are inferred as required, so the whole file failed type-checking
and none of its tests ever executed. Type the import boundary explicitly
and update the masking tests to the v5 mask group (they still used removed
v4 flat keys). Ignore the deno.lock the run writes.
Document the new passObjectsNatively and if() features across README,
RECIPES and llms.txt; correct the censor scoping and stack-capture default
claims; fix the pinoTransport example (sink is required) and the migration
guide's getEnvironment/runtime-string examples; add missing settings
(bindings, prefix, persistLevel, mask.placeholder, pretty.template) and the
tslog/pretty/box subpath to the doc surface; refresh the AGENTS.md source
tree and default-type wording; ship MIGRATION_v4_to_v5.md with the npm
package since the README and runtime warnings reference it.
In a real browser (window + document) non-Error args now reach the console
by reference, so DevTools renders collapsible trees out of the box. Node,
workers/edge, and React Native keep the rendered string, since consoles
captured as text degrade raw references. The docs cover when to opt out:
rendered strings give log-time snapshots (raw references show post-mutation
state when expanded) and text-matchable output (the DevTools filter only
searches the rendered string).

Suites that assert rendered-string/CSS mechanics pin the setting off; the
native default gets dedicated Node and browser tests. The browser pretty
specs also drop the removed v4 stylePrettyLogs key for pretty.style.
…ase notes

Add logger.if(), passObjectsNatively, source-mapped error positions, worker
transport, OTLP wire format, tslog/slim, and related DX APIs to the release
docs. Expand MIGRATION with §6c/§10, and tighten README highlights and
settings examples for getContext, prefix, and persistLevel.
The transport lifecycle subprocess used `using`, which plain Node 20 cannot
parse. Use Symbol.dispose instead, which matches synchronous scope exit.

Worker.runner file-stream tests could time out on slow CI runners before the
stream drained. Wait for bytes on disk before flush/close and fail loudly when
the poll helper times out.
fix(tests): wait for file bytes before close in worker.runner truncation test
@codecov

codecov Bot commented Jul 14, 2026

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.
✅ Project coverage is 100.00%. Comparing base (1b0ebf6) to head (51cc454).

Additional details and impacted files
@@               Coverage Diff               @@
##           development      #347     +/-   ##
===============================================
  Coverage       100.00%   100.00%             
===============================================
  Files               11        54     +43     
  Lines             1420      7076   +5656     
  Branches           464      2648   +2184     
===============================================
+ Hits              1420      7076   +5656     

☔ View full report in Codecov by Harness.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

…r CI stability

The previous 500-iteration setImmediate poll was too brittle under coverage
and variable CI load on Node 20/24. Use wall-clock timeout instead.
@terehov terehov merged commit c60ab03 into development Jul 14, 2026
21 checks passed
@terehov terehov deleted the v5 branch July 14, 2026 12:41
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.

1 participant