Node.js und Logging – Teil 4: Konfiguration des LogServices
In diesem Artikel konfigurieren wir unseren LogService vollständig über Umgebungsvariablen. Aufbauend auf den vorherigen Teilen steuern wir LogLevel und LogFormat zentral per EnvVarService und machen das Logging damit umgebungsabhängig und flexibel. Von typensicheren Enums bis zur sauberen Winston-Integration entsteht eine Lösung, die sowohl lokal als auch im Monitoring überzeugt.
Heute wollen wir unseren in Teil 1 entwickelten LogService mithilfe des aus Teil 2 hervorgegangenen EnvVarService konfigurieren. Dafür werden wir Umgebungsvariablen gemäß der Vorarbeit aus Teil 3 definieren. In der Konfiguration greifen wir auf:
- LogLevel: damit wir z. B. in der Produktivumgebung nur auf Level Error und in der Testumgebung auf Debug-Level loggen.
- Log-Ausgabeformat: Wir bereiten zwei Varianten vor. Eine Variante für zeilenbasierte Ausgabe und eine zweite als reine JSON-Ausgabe. Letztere lässt sich in der Weiterverarbeitung einfach in Monitoring-Systeme wie Loki integrieren.
Neue Typdefinition
Für den Loglevel definieren wir ein Enum, um es im EnvVarService zur Eingabeprüfung zu verwenden.
// in /src/types.ts
export enum LogLevels {
CRITICAL = 'critical',
ERROR = 'error',
WARN = 'warn',
INFO = 'info',
VERBOSE = 'verbose',
DEBUG = 'debug',
}
export type LogLevel = typeof LogLevels[keyof typeof LogLevels]
Genauso verfahren wir mit dem Logformat und erstellen ein Enum sowie die Typdefinition.
// in /src/types.ts
export enum LogFormats {
PLAIN = 'plain',
JSON = 'json',
}
export type LogFormat = typeof LogFormats[keyof typeof LogFormats]
Neue Umgebungsvariablen
Entsprechend nutzen wir die Umgebungsvariablen LOG_LEVEL und LOG_FORMAT. Die Ergänzung in der .env.dist als Dokumentation für unser Zukunfts-Ich darf nicht fehlen:
# Log levels as defined in our enurm: critical, error, warn, info, verbose, debug
LOG_LEVEL=
# Log formats as defined in our enum: "plain" and "json"
LOG_FORMAT=
Erweiterung des EnvVarServices
Für den EnvVarService erstellen wir einen neuen Kontext „log“ und füllen ihn mit dem passenden Parser für Umgebungsvariablen.
const parseLogValues = () => {
// Base schema for all log-related environment variables
const schema = z.object({
LOG_LEVEL: withDevDefault(z.enum(LogLevels), LogLevels.DEBUG),
LOG_FORMAT: withDevDefault(z.enum(LogFormats), LogFormats.PLAIN),
})
const parsed = schema.safeParse(process.env)
// handle error, so app doesn't start with invalid env vars
if (!parsed.success) {
throw new Error(
`❌ Invalid environment variables in log schema: ${JSON.stringify(parsed.error, null, 2)}`,
)
}
return {
logLevel: parsed.data.LOG_LEVEL,
logFormat: parsed.data.LOG_FORMAT,
}
}
// Export the parsed env vars per context: e.g. app or logging
export default {
app: parseBaseValues(),
logging: parseLogValues(),
}
Einbindung in LogService
Für die Einbindung in den LogService haben wir die Typen für LogServiceArgs vorbereitet. Diesen ergänzen wir um die beiden Werte und passen den Aufruf der Factory an:
import EnvVarService from '@/services/EnvVarService'
type LogServiceArgs = {
logLevel: LogLevel
logFormat: LogFormat
}
const LogService = ServiceFactory({
logLevel: EnvVarService.logging.logLevel,
logFormat: EnvVarService.logging.logFormat,
})
export default LogService
Die neuen Argumente nutzen wir bei der Erstellung des Loggers, indem wir den LogLevel statt fest auf Debug auf level: args.logLevel setzen. Beim Format müssen wir mehr Arbeit investieren:
- Wir definieren zwei Hilfsmethoden, die anhand eines Switch-Statements das passende Format zurückgeben.
- Für das
attachMetadataim Plain-Format benötigen wir ein Pendant im JSON-Format. Dieser fällt jedoch deutlich schlanker aus.
// The same attachMetadata function but for JSON format
const attachMetadataToJson = winston.format((info) => {
const splat = info[Symbol.for('splat')]
return {...info, splat}
})
// Format for unhandled exceptions and rejections
const getUnhandledFormat = (logFormat: LogFormat) => {
switch (logFormat) {
case LogFormats.JSON:
return winston.format.combine(
winston.format.timestamp(), // To add timestamp to log entries as ISO string
winston.format.json()
)
case LogFormats.PLAIN:
default:
return winston.format.combine(
winston.format.timestamp(), // To add timestamp to log entries as ISO string
winston.format.errors({stack: false}), // We receive all data by default, no stack trace needed here
winston.format.prettyPrint({colorize: true}), // To make unhandled Error output readable in console with indention
)
}
}
// Default format for regular log entries
const getDefaultFormat = (format: LogFormat) => {
switch (format) {
case LogFormats.JSON:
return winston.format.combine(
winston.format.timestamp(), // To add timestamp to log entries as ISO string
attachMetadataToJson(), // To attach metadata from splat to the message
winston.format.json()
)
case LogFormats.PLAIN:
default:
return winston.format.combine(
winston.format.colorize({all: true}), // To colorize the output based on log level
winston.format.timestamp(), // To add timestamp to log entries as ISO string
winston.format.errors({stack: true}), // To log JavaScript Error with stack trace
attachMetadata(), // To attach metadata from splat to the message
logFormat, // Our custom log format defined above as [timestamp] level message
)
}
}
Der Aufruf zum Erstellen des Loggers ergibt sich entsprechend:
// To make this definition readable, we move extensive configurations to variables/functions above
const logger = winston.createLogger({
levels: logLevelDefinition,
level: args.logLevel,
exitOnError: false, // handled exceptions will not cause process.exit
transports: [new winston.transports.Console({format: getDefaultFormat(args.logFormat)})],
format: getUnhandledFormat(args.logFormat), // Format for unhandled exceptions and rejections. Will be used by exceptionHandlers below
exceptionHandlers: [new winston.transports.Console()], // Will use the format defined above: unhandledFormat
rejectionHandlers: [new winston.transports.Console()], // Will use the format defined above: unhandledFormat
})
Fazit
Mit der aktuellen Implementierung können wir über Umgebungsvariablen den LogLevel und das LogFormat konfigurieren. Dieses Codestück ist eines der wenigen, die ich nicht teste. Es mag funktionieren, Winston zu mocken und mithilfe von Spies die Aufrufe zu testen. Nur erschließt sich mir damit kein direkter Mehrwert.
„CodeCover Fanatics: 100 % Testabdeckung“ oder „Pragmatiker: 70–90 % reichen“ – ich gehöre zu den Pragmatikern.
Aus welchem Lager kommst du?Im nächsten Artikel stellen wir sicher, dass die Logausgaben bei den Testläufen ausgestellt werden. Mit unserer Vorbereitung sollte das ein Leichtes sein.