A Winston logger gives a Node.js application one place to define severity, record shape, metadata, and output destinations. The useful result is not more log lines. It is consistent JSON that can identify the service, request, operation, and error without a parser guessing at free-form text.
For Fluxtail, keep that application boundary simple: Winston writes JSON to standard output, the container runtime captures it, and a supported collector parses and forwards it. Fluxtail then provides Live Tail and filters for the received message, service, severity, host, labels, and time. This design keeps network retries and buffering outside the request process.
Install Winston and create an explicit logger
Install the current Winston 3 release in the application:
npm install winston@3
The official Winston documentation recommends creating a logger rather than using the module's default logger. The default logger has no transports until configured, which can also lead to growing memory use if code logs through it without adding one.
Create logger.js:
const winston = require('winston');
const sensitiveKeys = new Set([
'apikey',
'api_key',
'authorization',
'cookie',
'password',
'token',
]);
function sanitize(value, seen = new WeakSet()) {
if (value === null || typeof value !== 'object') return value;
if (value instanceof Date || Buffer.isBuffer(value)) return value;
if (seen.has(value)) return '[Circular]';
seen.add(value);
if (Array.isArray(value)) {
return value.map((item) => sanitize(item, seen));
}
if (Object.getPrototypeOf(value) !== Object.prototype) return value;
return Object.fromEntries(
Object.entries(value).map(([key, item]) => [
key,
sensitiveKeys.has(key.toLowerCase())
? '[REDACTED]'
: sanitize(item, seen),
]),
);
}
function errorFields(error) {
return {
name: error.name,
message: error.message,
stack: error.stack,
};
}
const redact = winston.format((info) => {
for (const key of Object.keys(info)) {
info[key] = sensitiveKeys.has(key.toLowerCase())
? '[REDACTED]'
: sanitize(info[key]);
}
return info;
});
function readLevel(env = process.env) {
const level = env.LOG_LEVEL || 'info';
if (!Object.prototype.hasOwnProperty.call(winston.config.npm.levels, level)) {
throw new Error(`Invalid LOG_LEVEL: ${level}`);
}
return level;
}
const logger = winston.createLogger({
level: readLevel(),
defaultMeta: {
service_name: process.env.SERVICE_NAME || 'checkout-api',
labels: {
environment: process.env.NODE_ENV || 'development',
},
},
format: winston.format.combine(
winston.format.errors({ stack: true }),
winston.format.timestamp(),
redact(),
winston.format.json(),
),
transports: [
new winston.transports.Console({
stderrLevels: [],
consoleWarnLevels: [],
}),
],
});
module.exports = { errorFields, logger, readLevel };
The order of the format pipeline is deliberate. errors({ stack: true }) makes the message and stack of a directly logged Error enumerable. timestamp() then adds an ISO timestamp, the redaction pass handles structured fields, and json() serializes the final record.
Redaction is a second line of defense, not permission to log arbitrary objects. Prefer an allowlist of fields and never attach a full request, response, session, environment, or configuration object. Extend the sensitive-key set for the application's schema, and test custom class instances separately because this small example only descends into arrays and plain objects. Key-based redaction cannot remove a secret already embedded inside a free-form message, error message, or stack trace.
Understand Winston logging levels
Winston uses its npm levels unless you supply a custom set. Lower numbers are more important:
error: 0
warn: 1
info: 2
http: 3
verbose: 4
debug: 5
silly: 6
Setting level: 'info' emits error, warn, and info, but filters out http, verbose, debug, and silly. A production default of info is usually easier to operate than a hard-coded debug. Validate LOG_LEVEL at startup, as the example does, so a typo does not silently change the intended signal.
Keep level meaning stable across services. Use error for a failed operation, warn for degraded or risky behavior, info for meaningful state changes, and debug for temporary diagnostic detail. Do not log every successful request at error simply to make it visible.
A transport level is a threshold, not an exact match. A transport configured at warn receives both warn and the more important error records. Exact-level routing requires an explicit filter.
Winston's Logform formats treat each info object as mutable. Keep shared enrichment and redaction at logger level, put finalizing formats such as json() last, and avoid changing shared nested metadata inside per-transport formats. Transport-specific format pipelines receive shallow copies, so nested objects can still be shared.
Add stable metadata and child loggers
Use defaultMeta for fields shared by every record. Use a child logger for context that belongs to one request or job:
const { errorFields, logger } = require('./logger');
const requestLog = logger.child({
request_id: 'req_9f2c',
route: '/checkout',
});
requestLog.info('checkout started', {
event: 'checkout.started',
order_id: 'ord_4921',
headers: { authorization: 'Bearer example-value' },
});
try {
throw new Error('gateway timeout');
} catch (error) {
requestLog.error(error, {
event: 'checkout.failed',
order_id: 'ord_4921',
});
requestLog.warn('checkout retry scheduled', {
event: 'checkout.retry_scheduled',
order_id: 'ord_4921',
upstream_error: errorFields(error),
});
}
requestLog.debug('debug marker should be filtered');
Winston merges metadata into its info object. Per-call metadata must not supply level, message, timestamp, or the destination field severity; it should also never override logger-owned service_name or labels. In particular, Winston documents that a message property inside metadata is concatenated with the primary message, so do not reuse it for another concept.
Use consistent names and types: request_id should not become requestId in one service and a number in another. Keep bounded identifiers such as event, request_id, order_id, and route. Avoid dynamic field names such as an order ID used as a JSON key.
The error record from the example has this shape; the timestamp and stack locations will vary:
{
"event": "checkout.failed",
"labels": { "environment": "production" },
"level": "error",
"message": "gateway timeout",
"order_id": "ord_4921",
"request_id": "req_9f2c",
"route": "/checkout",
"service_name": "checkout-api",
"stack": "Error: gateway timeout\n at ...",
"timestamp": "2026-09-15T14:12:30.000Z"
}
format.errors({ stack: true }) expands an Error logged directly, as in requestLog.error(error, metadata). It does not expand an error nested inside metadata: requestLog.warn('failed', { error }) normally serializes that nested value as {}. The errorFields() helper above explicitly maps name, message, and stack, producing a searchable object:
{
"event": "checkout.retry_scheduled",
"level": "warn",
"message": "checkout retry scheduled",
"request_id": "req_9f2c",
"upstream_error": {
"name": "Error",
"message": "gateway timeout",
"stack": "Error: gateway timeout\n at ..."
}
}
Child loggers are a good fit for normal createLogger() instances. Winston's README warns that .child() can behave unexpectedly when an application subclasses Winston's Logger, so avoid combining those patterns without a focused test.
Prefer JSON on stdout in containers
The Console transport writes one finalized JSON object per event. In a container, let Docker or the orchestrator capture stdout and stderr. That avoids application-owned log files, file rotation, sidecar volume coordination, and a network transport competing with requests inside the Node.js process.
The boundary is important: a successful logger.info() call means Winston accepted the event for its transport. It does not prove that a collector stored it remotely. Standard streams and transports can also buffer, and the Node.js process I/O documentation explains that stdout writes can be synchronous or asynchronous depending on the platform and destination. Slow or unread pipes can block writes, while an immediate process.exit() can truncate pending output.
On graceful shutdown, stop accepting work, finish in-flight requests, end the logger, and wait for its finish event before allowing normal process exit:
let loggerFailed = false;
logger.on('error', (error) => {
loggerFailed = true;
process.stderr.write(`logger failure: ${error.message}\n`);
process.exitCode = 1;
});
logger.once('finish', () => {
if (!loggerFailed && process.exitCode === undefined) {
process.exitCode = 0;
}
});
logger.end();
Call end() only after the application stops accepting work and finishes in-flight operations because the logger cannot accept later events. Reaching finish proves Winston handed records to its transports; it does not prove stdout, the collector, or Fluxtail durably received them.
Exception and rejection handlers are last-chance reporting, not recovery. Keep Winston's normal process termination behavior, let the service supervisor restart the process, and do not use exitOnError: false to keep running after an unknown top-level failure. Choose either Winston handlers or the application's top-level handlers as the owner so the same failure is not reported and terminated twice.
Parse Winston JSON and map it for Fluxtail
Fluxtail does not need a custom Winston HTTP transport. Start with its public Docker and Fluent Bit setup, which tails Docker's JSON log files with filesystem buffering and sends them to an authenticated HTTP JSON receiver.
After the collector parses Docker's outer log field as JSON, map Winston's level to Fluxtail's top-level severity. The logger already emits timestamp, message, service_name, and labels with the documented payload names:
# Place this after the JSON parser filter and before any filter that
# supplies a default severity.
[FILTER]
Name modify
Match docker.*
Rename level severity
The relevant collector stages should preserve this order:
[FILTER]
Name parser
Match docker.*
Key_Name log
Parser json
Reserve_Data On
Preserve_Key Off
[FILTER]
Name modify
Match docker.*
Rename level severity
[OUTPUT]
Name http
Match docker.*
Host ${FLUXTAIL_RECEIVER_HOST}
Port ${FLUXTAIL_RECEIVER_PORT}
URI ${FLUXTAIL_RECEIVER_PATH}
Format json
Header Authorization Bearer ${FLUXTAIL_TOKEN}
Header Content-Type application/json
tls On
tls.verify On
Retry_Limit False
storage.total_limit_size 1G
The parser named json, the Docker tail input, storage.path, and storage.type filesystem come from the published Fluxtail collector configuration. Use the receiver host, port, path, and receiver-bound token exactly as provided. The output limit is an example bound: size it from event rate, disk capacity, and the outage window. When the limit is reached, Fluent Bit discards older queued chunks to admit newer ones, so monitor collector retries, dropped chunks, and filesystem use.
The public HTTP JSON receiver documentation defines the accepted object fields and HTTP 202 response. Fluent Bit owns batching and retry behavior here; Winston only writes JSON to stdout. The official Fluent Bit parser, HTTP output, and buffering references cover those collector controls.
Test the record before relying on it
Run syntax checks and capture a local sample:
node --check logger.js
node --check example.js
NODE_ENV=production LOG_LEVEL=info node example.js \
> /tmp/winston-smoke.stdout.jsonl \
2> /tmp/winston-smoke.stderr.log
Parse every line rather than checking that the file is merely non-empty:
node -e '
const fs = require("node:fs");
const raw = fs.readFileSync("/tmp/winston-smoke.stdout.jsonl", "utf8");
const stderr = fs.readFileSync("/tmp/winston-smoke.stderr.log", "utf8");
if (!raw.trim()) throw new Error("stdout is empty");
if (stderr !== "") throw new Error("unexpected stderr output");
const rows = raw.trim().split("\n").map(JSON.parse);
if (rows.some((row) =>
typeof row.level !== "string" ||
typeof row.message !== "string" ||
typeof row.service_name !== "string" ||
typeof row.request_id !== "string" ||
Number.isNaN(Date.parse(row.timestamp)))) {
throw new Error("invalid core log contract");
}
const direct = rows.find((row) => row.event === "checkout.failed");
if (!direct?.stack) throw new Error("missing direct error stack");
const nested = rows.find((row) => row.event === "checkout.retry_scheduled");
if (!nested?.upstream_error?.name ||
!nested?.upstream_error?.message ||
!nested?.upstream_error?.stack) {
throw new Error("missing nested error fields");
}
if (raw.includes("example-value")) throw new Error("redaction failed");
if (raw.includes("debug marker")) throw new Error("debug was not filtered");
if (rows.some((row) => Object.hasOwn(row, "severity"))) {
throw new Error("collector severity mapping happened in the application");
}
'
Then run the container with the collector, emit a unique marker, and inspect the collector's own logs for parsing or HTTP errors. In Fluxtail Live Tail, search the exact marker and confirm service_name, normalized severity, host, timestamp, message, and expected labels. The current search and filters guide documents filtering by text, service, severity, host, labels, stream, and time.
Repeat the test after changing Winston formats, collector parsers, severity mapping, receiver credentials, or buffering policy. A local JSON test catches application-shape regressions; the Live Tail check proves that one known event traversed the current collector path.
Create a Fluxtail account, configure an HTTP JSON receiver, and verify one Winston marker end to end before expanding collection to more services.