Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 15 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -96,6 +96,7 @@ transports may produce a high memory usage issue.
* [Creating your logger](#creating-your-own-logger)
* [Streams, `objectMode`, and `info` objects](#streams-objectmode-and-info-objects)
* [Formats]
* [Filtering before formatting](#filtering-before-formatting)
* [Combining formats](#combining-formats)
* [String interpolation](#string-interpolation)
* [Filtering `info` Objects](#filtering-info-objects)
Expand Down Expand Up @@ -160,6 +161,7 @@ A logger accepts the following parameters:
| `level` | `'info'` | Log only if [`info.level`](#streams-objectmode-and-info-objects) is less than or equal to this level |
| `levels` | `winston.config.npm.levels` | Levels (and colors) representing log priorities |
| `format` | `winston.format.json` | Formatting for `info` messages (see: [Formats]) |
| `filterBeforeFormat` | `false` | Skip logger formatting for levels rejected by every standard transport (see [Filtering before formatting](#filtering-before-formatting)) |
| `transports` | `[]` _(No transports)_ | Set of logging targets for `info` messages |
| `exitOnError` | `true` | If false, handled exceptions will not cause `process.exit` |
| `silent` | `false` | If true, all logs are suppressed |
Expand Down Expand Up @@ -336,6 +338,19 @@ const logger = createLogger({
To see what built-in formats are available and learn more about creating your
own custom logging formats, see [`logform`][logform].

### Filtering before formatting

Set `filterBeforeFormat: true` in `createLogger` or `configure` to skip logger-wide
formatting when every standard transport rejects a record's level. It defaults
to `false` and respects current transport-level overrides. Handler-marked records,
no-transport buffering, unknown levels and custom write/filter implementations
retain the existing path.

Discarded records also disappear from the logger's readable output, and their
formatters do not run. Enable this only if you do not rely on those records,
formatter side effects (including stateful formats such as `ms()`), or routing
changes made by a formatter. Later level changes cannot recover discarded records.

### Combining formats

Any number of formats may be combined into a single format using
Expand Down
2 changes: 2 additions & 0 deletions index.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -85,6 +85,7 @@ declare namespace winston {
levels?: Config.AbstractConfigSetLevels;
silent?: boolean;
format?: logform.Format;
filterBeforeFormat?: boolean;
level?: string;
exitOnError?: Function | boolean;
defaultMeta?: any;
Expand All @@ -100,6 +101,7 @@ declare namespace winston {

silent: boolean;
format: logform.Format;
filterBeforeFormat: boolean;
levels: Config.AbstractConfigSetLevels;
level: string;
transports: Transport[];
Expand Down
43 changes: 42 additions & 1 deletion lib/winston/logger.js
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ const { LEVEL, SPLAT } = require('triple-beam');
const isStream = require('is-stream');
const ExceptionHandler = require('./exception-handler');
const RejectionHandler = require('./rejection-handler');
const TransportStream = require('winston-transport');
const LegacyTransportStream = require('winston-transport/legacy');
const Profiler = require('./profiler');
const { warn } = require('./common');
Expand Down Expand Up @@ -83,6 +84,7 @@ class Logger extends Transform {
configure({
silent,
format,
filterBeforeFormat = false,
defaultMeta,
levels,
level = 'info',
Expand All @@ -104,6 +106,7 @@ class Logger extends Transform {

this.silent = silent;
this.format = format || this.format || require('logform/json')();
this.filterBeforeFormat = filterBeforeFormat;

this.defaultMeta = defaultMeta || null;
// Hoist other options onto this instance.
Expand Down Expand Up @@ -331,14 +334,52 @@ class Logger extends Transform {
// (and re-throw) any errors generated by the user-provided format, but also
// guarantee that the streams callback is invoked so that we can continue flowing.
try {
this.push(this.format.transform(info, this.format.options));
if (!this.filterBeforeFormat || !this._isLevelFiltered(info)) {
this.push(this.format.transform(info, this.format.options));
}
} finally {
this._writableState.sync = false;
// eslint-disable-next-line callback-return
callback();
}
}

/**
* Whether every pipe target is known to reject this record by level.
* Custom filtering, handler records and no-transport buffering must retain
* the normal format/write path. This is intentionally more conservative
* than isLevelEnabled, which does not model all transport routing.
* @param {Object} info - The record with its normalized LEVEL symbol.
* @returns {boolean} Whether formatting and delivery can be skipped.
* @private
*/
_isLevelFiltered(info) {
if (info.exception || info.rejection || typeof this.levels[info[LEVEL]] !== 'number') {
return false;
}

const transports = this.transports;
return transports.length > 0 && transports.every(transport => {
// Do not assume that legacy/custom streams use the base level filter.
if (transport.write !== TransportStream.prototype.write ||
transport._write !== TransportStream.prototype._write ||
transport._writev !== TransportStream.prototype._writev ||
transport._accept !== TransportStream.prototype._accept) {
return false;
}

// Match winston-transport's effective threshold, including an explicit
// transport level when the parent level is unset or unrecognized.
const level = transport.level || (transport.parent && transport.parent.level);
const levels = transport.levels;
if (!level || !levels) return false;

const threshold = levels[level];
const priority = levels[info[LEVEL]];
return typeof threshold === 'number' && typeof priority === 'number' && threshold < priority;
});
}

/**
* Delays the 'finish' event until all transport pipe targets have
* also emitted 'finish' or are already finished.
Expand Down
3 changes: 3 additions & 0 deletions test/typescript-definitions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,13 +2,16 @@ import * as winston from '../index';

let logger: winston.Logger = winston.createLogger({
level: 'info',
filterBeforeFormat: true,
format: winston.format.json(),
transports: [
new winston.transports.Console({ level: 'info' }),
],
});

let err: Error = new Error('ttdt');
const filterBeforeFormat: boolean = logger.filterBeforeFormat;
logger.configure({ filterBeforeFormat: false });
logger.error('The error was: ', err);
logger.log('info', 'hey dude', { foo: 'bar' });
logger.log({ level: 'info', message: 'hey dude', meta: { foo: 'bar' } });
Expand Down
202 changes: 202 additions & 0 deletions test/unit/winston/logger-filter-before-format.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,202 @@
'use strict';

const { once } = require('events');
const { Writable } = require('stream');
const { LEVEL, MESSAGE } = require('triple-beam');
const winston = require('../../../lib/winston');

describe('Logger filterBeforeFormat', function () {
let loggers;
let streams;

beforeEach(function () {
loggers = [];
streams = [];
});

afterEach(function () {
loggers.forEach(logger => {
logger.close();
logger.destroy();
});
streams.forEach(stream => stream.destroy());
jest.restoreAllMocks();
});

function createTransport(options = {}) {
const transport = new winston.transports.Console(options);
// Keep the real transport filtering; replace only console I/O.
transport.log = jest.fn((info, callback) => callback());
streams.push(transport);
return transport;
}

function createLogger(options = {}) {
const transport = createTransport();
const transform = jest.fn(info => info);
const logger = winston.createLogger({
format: winston.format.combine(
winston.format(transform)(),
winston.format.label({ label: 'example' }),
winston.format.json()
),
transports: [transport],
...options
});
loggers.push(logger);
return { logger, transport, transform };
}

it.each([false, true])('filters formatting and readable output only when enabled (%s)', async enabled => {
const { logger, transport, transform } = createLogger(enabled ? { filterBeforeFormat: true } : {});
const records = [];
logger.on('data', info => records.push(info.message));
logger.debug('filtered');
logger.info('visible');
await new Promise(setImmediate);
expect(transform).toHaveBeenCalledTimes(enabled ? 1 : 2);
expect(transport.log).toHaveBeenCalledTimes(1);
expect(records).toEqual(enabled ? ['visible'] : ['filtered', 'visible']);
});

const entryPoints = [
['log(level, message)', (logger, level) => logger.log(level, 'message')],
['write(info)', (logger, level) => logger.write({ level, message: 'message' })],
['child', (logger, level) => logger.child({ request: 123 })[level]('message')]
];

it.each(entryPoints)('skips rejected formatting through %s', (name, log) => {
const { logger, transport, transform } = createLogger({ filterBeforeFormat: true });
log(logger, 'debug');
log(logger, 'info');
expect(transform).toHaveBeenCalledTimes(1);
expect(transport.log).toHaveBeenCalledTimes(1);
expect(transport.log.mock.calls[0][0]).toMatchObject({
level: 'info', message: 'message', label: 'example', [LEVEL]: 'info'
});
expect(JSON.parse(transport.log.mock.calls[0][0][MESSAGE])).toMatchObject({
level: 'info', message: 'message', label: 'example'
});
});

it.each([
['debug', 'info', 0, 0],
[null, 'debug', 1, 1],
['invalid', null, 1, 0]
])('respects parent %s and transport %s thresholds', (level, transportLevel, formatted, emitted) => {
const { logger, transport, transform } = createLogger({ filterBeforeFormat: true, level });
transport.level = transportLevel;
logger.debug('message');
expect(transform).toHaveBeenCalledTimes(formatted);
expect(transport.log).toHaveBeenCalledTimes(emitted);
});

it('supports custom level priorities', function () {
const { logger, transport, transform } = createLogger({
filterBeforeFormat: true, levels: { urgent: 0, normal: 1, detail: 2 }, level: 'normal'
});
logger.detail('filtered');
logger.urgent('visible');
expect(transform).toHaveBeenCalledTimes(1);
expect(transport.log.mock.calls[0][0][LEVEL]).toBe('urgent');
});

it('checks every transport and responds to changing thresholds and transports', function () {
const { logger, transport, transform } = createLogger({ filterBeforeFormat: true });
const other = createTransport({ level: 'debug' });
logger.add(other);
logger.debug('visible on other');
logger.remove(other);
logger.debug('filtered');
transport.level = 'debug';
logger.debug('visible on original');
delete transport.level;
logger.level = 'debug';
logger.debug('visible via parent');
expect(transform).toHaveBeenCalledTimes(3);
expect(other.log).toHaveBeenCalledTimes(1);
expect(transport.log).toHaveBeenCalledTimes(2);
});

it('preserves unknown-level diagnostics and formatting', function () {
const error = jest.spyOn(console, 'error').mockImplementation(() => {});
const { logger, transform } = createLogger({ filterBeforeFormat: true });
logger.log('unknown', 'message');
expect(error).toHaveBeenCalledWith('[winston] Unknown logger level: %s', 'unknown');
expect(transform).toHaveBeenCalledTimes(1);
});

it('can be enabled and resets to the default on reconfiguration', function () {
const { logger, transform } = createLogger();
logger.configure({ filterBeforeFormat: true, transports: [createTransport()] });
logger.debug('filtered');
expect(transform).not.toHaveBeenCalled();
logger.configure({ transports: [createTransport()] });
logger.debug('formatted');
expect(logger.filterBeforeFormat).toBe(false);
expect(transform).toHaveBeenCalledTimes(1);
});

it('keeps no-transport buffering for a transport added later', async function () {
jest.spyOn(console, 'error').mockImplementation(() => {});
const { logger, transform } = createLogger({ filterBeforeFormat: true, transports: [] });
logger.debug('buffered');
const transport = createTransport({ level: 'debug' });
logger.add(transport);
await new Promise(setImmediate);
expect(transform).toHaveBeenCalledTimes(1);
expect(transport.log.mock.calls[0][0].message).toBe('buffered');
});

it.each(['exception', 'rejection'])('formats marked %s records even without handler streams', kind => {
const { logger, transform } = createLogger({ filterBeforeFormat: true });
logger.log({ level: 'debug', message: 'marked', [kind]: true });
expect(transform).toHaveBeenCalledTimes(1);
});

it('preserves a custom transport that accepts otherwise rejected levels', function () {
const { logger, transport, transform } = createLogger({ filterBeforeFormat: true });
transport._write = function (info, enc, callback) { this.log(info, callback); };
logger.debug('accepted by the custom transport');
expect(transform).toHaveBeenCalledTimes(1);
expect(transport.log).toHaveBeenCalledTimes(1);
});

it('does not prefilter a generic piped stream', async function () {
const { logger, transform } = createLogger({ filterBeforeFormat: true });
const records = [];
const stream = new Writable({
objectMode: true,
write(info, enc, callback) { records.push(info); callback(); }
});
streams.push(stream);
logger.pipe(stream);
logger.debug('visible to the stream');
await new Promise(setImmediate);
expect(transform).toHaveBeenCalledTimes(1);
expect(records[0].message).toBe('visible to the stream');
});

it('calls write callbacks and finishes after rejected records', async function () {
const { logger } = createLogger({ filterBeforeFormat: true });
const callback = jest.fn();
const finished = once(logger, 'finish');
logger.write({ level: 'debug', message: 'filtered' }, callback);
logger.end();
await finished;
expect(callback).toHaveBeenCalledTimes(1);
});

it('releases the write callback if a transport level getter throws', function () {
const { logger, transport } = createLogger({ filterBeforeFormat: true });
const failure = new Error('level lookup failed');
Object.defineProperty(transport, 'level', {
configurable: true,
get() { throw failure; }
});
expect(() => logger.info('fails')).toThrow(failure);
delete transport.level;
logger.info('still visible');
expect(transport.log).toHaveBeenCalledTimes(1);
});
});