Files
RedditBot/src/utils/logger.ts
T

92 lines
3.1 KiB
TypeScript

import winston from 'winston';
import Transport from 'winston-transport';
import { toJsonSafe } from './json-safe.js';
export type Logger = winston.Logger;
export interface LogSink {
push(level: 'info' | 'warn' | 'error' | 'debug', message: string, data?: unknown): void;
}
/**
* Attaches a sink (typically the dashboard's LogBroadcaster) as a winston
* transport so every `logger.info/warn/error/debug` call also lands in the
* dashboard's `/api/logs` buffer. Call once, as early in bootstrap as
* possible — anything logged before this point won't be visible in the UI.
*/
export function attachLogSink(logger: Logger, sink: LogSink): void {
class SinkTransport extends Transport {
log(info: Record<string, unknown>, callback: () => void): void {
setImmediate(() => this.emit('logged', info));
const level = (info.level as string) || 'info';
const allowed = ['info', 'warn', 'error', 'debug'] as const;
const lvl = (allowed as readonly string[]).includes(level)
? (level as 'info' | 'warn' | 'error' | 'debug')
: 'info';
const message = String(info.message ?? '');
const { level: _l, message: _m, timestamp: _t, ...meta } = info;
const hasMeta = Object.keys(meta).length > 0;
try {
sink.push(lvl, message, hasMeta ? meta : undefined);
} catch {
// never let the sink break logging
}
callback();
}
}
logger.add(new SinkTransport({ level: 'debug' }));
}
function safeStringify(value: unknown): string {
try {
return JSON.stringify(toJsonSafe(value));
} catch (err) {
return `[Unserializable: ${(err as Error).message}]`;
}
}
/** Strip credentials and network internals before either console or file output. */
const redactLogMetadata = winston.format((info) => {
const record = info as Record<string, unknown>;
const { level, message, timestamp, ...metadata } = record;
const safeMetadata = toJsonSafe(metadata);
for (const key of Object.keys(metadata)) delete record[key];
if (safeMetadata && !Array.isArray(safeMetadata) && typeof safeMetadata === 'object') {
Object.assign(record, safeMetadata);
}
record.level = level;
record.message = message;
if (timestamp !== undefined) record.timestamp = timestamp;
return info;
});
export function createLogger(): Logger {
return winston.createLogger({
level: process.env.LOG_LEVEL || 'info',
format: winston.format.combine(
winston.format.timestamp(),
winston.format.errors({ stack: true }),
redactLogMetadata(),
winston.format.json()
),
transports: [
new winston.transports.Console({
format: winston.format.combine(
winston.format.colorize(),
winston.format.printf(({ level, message, timestamp, ...meta }) => {
const metaStr = Object.keys(meta).length ? ` ${safeStringify(meta)}` : '';
return `${timestamp} [${level}]: ${message}${metaStr}`;
})
),
}),
new winston.transports.File({
filename: 'logs/error.log',
level: 'error',
}),
new winston.transports.File({
filename: 'logs/combined.log',
}),
],
});
}