92 lines
3.1 KiB
TypeScript
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',
|
|
}),
|
|
],
|
|
});
|
|
}
|