pino-quiet
v1.0.1
Published
A lightweight pino transport that collapses repeated consecutive log messages.
Maintainers
Readme
pino-quietis a lightweight Pino transport that reduces log noise by collapsing repeated consecutive log messages into a single entry with a repetition counter.
It improves readability, cuts storage/ingestion costs, and requires zero changes to your existing logging calls.
{"level":30,"msg":"Connecting..."} ┐
{"level":30,"msg":"Connecting..."} ├──▶ {"level":30,"msg":"Connecting... (x3)","repeats":3}
{"level":30,"msg":"Connecting..."} ┘
{"level":30,"msg":"Connected!"} ──▶ {"level":30,"msg":"Connected!"}Features
- Zero-config — works out of the box with sane defaults.
- Noise reduction — turns 1,000
"Connection failed"logs into one(x1000)log. - Two comparison modes — fast message-only matching (
simple, default), or deep object comparison (strict). - Composable — can sit before
pino-pretty(or any transport) in a pipeline, or run standalone as a terminal transport. - Self-healing buffers — an idle repeated log auto-flushes after
flushIntervalMsinstead of hiding a stale count forever. - Hot-loop safe —
maxRepeatscaps how long a single batch can grow before force-flushing. - Level-aware matching — optionally never collapse an
infoand anerrorthat happen to share text. - Pluggable equality — bring your own
comparatorfor full control over what counts as "the same log". - Observability hook —
onFlushlets you feed collapse events into your own metrics. - Fully typed — first-class TypeScript definitions ship with the package.
Installation
npm i pino-quietpnpm add pino-quietyarn add pino-quietRequires Node.js >= 18 and pino ^10.
Quick start
Standalone (writes to stdout / a file — classic mode)
import pino from 'pino';
const logger = pino({
transport: {
target: 'pino-quiet',
// options are entirely optional — sensible defaults are used otherwise
},
});
logger.info('Connecting...');
logger.info('Connecting...');
logger.info('Connecting...');
logger.info('Connected!');
// Output:
// {"level":30,"time":...,"msg":"Connecting... (x3)","repeats":3}
// {"level":30,"time":...,"msg":"Connected!"}Strict mode (deep object comparison)
If you log metadata objects, you might want to deduplicate based on the entire object content (ignoring time, pid, and hostname).
const logger = pino({
transport: {
target: 'pino-quiet',
options: {
strict: true, // Enable deep comparison
},
},
});
// These collapse: the { id: 1 } payload matches even though timestamps differ.
logger.error({ id: 1 }, 'Transaction failed');
logger.error({ id: 1 }, 'Transaction failed');Options
| Option | Type | Default | Description |
| ----------------------- | --------------------------------------------- | ----------------------- | --------------------------------------------------------------------------------------------------- |
| destination | string \| number | 1 (stdout) | Destination fd or file path. Ignored when pipeline: true. |
| strict | boolean | false | Compare the whole log object (minus ignoreKeys) instead of just msg. |
| countField | string | 'repeats' | Field name used to store the total occurrence count. |
| ignoreKeys | string[] | [] (merged with built-ins) | Extra fields excluded from strict comparisons. Merged with time/pid/hostname, never replaces them. |
| comparator | (prev, curr) => boolean | — | Custom equality function; overrides strict/simple logic entirely when provided. |
| levelAware | boolean | false | If true, logs at different levels are never treated as duplicates. |
| caseInsensitive | boolean | false | Case-insensitive msg comparison (simple mode only). |
| maxRepeats | number | Infinity | Force-flush after this many repeats, even without a new/different log arriving. |
| flushIntervalMs | number | 5000 | Auto-flush a buffered log after this many ms of silence. 0 disables this (matches pre-1.0 behavior). |
| minRepeatsToAnnotate | number | 1 | Only annotate msg/countField once the repeat count reaches this value. |
| annotateMessage | boolean | true | If false, only countField is set; msg is left untouched. |
| suffixFormat | (count: number) => string | (count) => ` (x${count})` | Customize the suffix appended to msg. |
| includeTimestamps | boolean | false | Add first/last-seen epoch-ms timestamps to every flushed log. |
| firstSeenField | string | 'firstSeen' | Field name for the first-seen timestamp (requires includeTimestamps). |
| lastSeenField | string | 'lastSeen' | Field name for the last-seen timestamp (requires includeTimestamps). |
| onFlush | (log, meta) => void | — | Called on every flush with the emitted log and { repeats, firstSeen, lastSeen }. Never throws. |
| pipeline | boolean | false | Pass-through Transform mode for composing with other transports (see below). |
| mkdir | boolean | false | Passed through to SonicBoom (destination mode only). |
| append | boolean | true | Passed through to SonicBoom (destination mode only). |
| sync | boolean | false | Passed through to SonicBoom (destination mode only). |
How it works
pino-quietbuffers the most recently seen log.- When a new log arrives, it's compared against the buffered one using
comparator(if given), otherwisestrict/simple matching. - Match: the counter increments; nothing is written yet.
- No match (or the buffered log has sat idle past
flushIntervalMs, or hitmaxRepeats): the buffered log is flushed (annotated with its final count) and the new log takes its place. - On stream shutdown, whatever is still buffered is flushed so no log is ever lost.
Composing with other transports
By default (pipeline not set), pino-quiet is a terminal transport: it owns its destination and writes directly to it, exactly like in the Quick start examples above. That's the simplest setup and is unchanged from 0.9.x.
To place pino-quiet before another transport — most commonly pino-pretty — set pipeline: true. This turns it into a plain pass-through stream that emits deduplicated NDJSON for the next stage to consume, instead of writing anywhere itself:
import pino from 'pino';
const logger = pino({
transport: {
pipeline: [
{
target: 'pino-quiet',
options: { pipeline: true, flushIntervalMs: 2000 },
},
{
target: 'pino-pretty',
options: { colorize: true },
},
],
},
});
logger.info('Database connection failed');
logger.info('Database connection failed');
logger.info('Database connection failed');
logger.error('Giving up on database. Exiting.');[12:00:01] INFO: Database connection failed (x3)
repeats: 3
[12:00:01] ERROR: Giving up on database. Exiting.This works with any transport that can be a pipeline stage, not just pino-pretty — for example shipping the collapsed stream on to a log-forwarding transport. See example/pipeline-with-pino-pretty.ts for a runnable copy of this example.
Note:
destination,mkdir,append, andsyncare ignored in pipeline mode — the last stage in the pipeline is responsible for the actual destination.
Migrating from 0.9.x
- The stray
]bug is fixed. If you had a workaround stripping a trailing]from pino-quiet's output, remove it. flushIntervalMsnow defaults to5000, not disabled. If your tests or tooling depend on the exact pre-1.0 timing (never auto-flush), setflushIntervalMs: 0.- Everything else is additive — no other option names or defaults changed.
Recipes
options: { levelAware: true }options: { strict: true, ignoreKeys: ['requestId', 'traceId'] }options: {
onFlush: (log, meta) => {
metrics.histogram('log.repeats', meta.repeats);
},
}options: { maxRepeats: 500, flushIntervalMs: 1000 }Contributing
Contributions welcome! Please open an issue for feature requests or bugs before submitting a PR.
git clone https://github.com/Silent-Watcher/pino-quiet.git
cd pino-quiet
npm install
npm testSee CHANGELOG.md for release history.
License
MIT — see LICENSE for details.
