V5 Release#347
Merged
Merged
Conversation
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.
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).
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.
…enhance logging features
…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.
…ty.style for CSS tests
fix(tests): wait for file bytes before close in worker.runner truncation test
Codecov Report✅ All modified and coverable lines are covered by tests. 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. 🚀 New features to boost your workflow:
|
…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.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
No description provided.