171 lines
4.8 KiB
TypeScript
171 lines
4.8 KiB
TypeScript
import type { LogLevel, LogContext, LogEntry, LoggerConfig, LogTransport } from './types';
|
|
|
|
const LEVEL_PRIORITY: Record<LogLevel, number> = {
|
|
debug: 0,
|
|
info: 1,
|
|
warn: 2,
|
|
error: 3,
|
|
};
|
|
|
|
/** Default transport: sends batched logs via sendBeacon or fetch. */
|
|
class HttpTransport implements LogTransport {
|
|
constructor(private endpoint: string) {}
|
|
|
|
send(entries: LogEntry[]): void {
|
|
const payload = JSON.stringify(entries);
|
|
|
|
// sendBeacon is fire-and-forget, works during page unload
|
|
if (typeof navigator !== 'undefined' && navigator.sendBeacon) {
|
|
const blob = new Blob([payload], { type: 'application/json' });
|
|
const sent = navigator.sendBeacon(this.endpoint, blob);
|
|
if (sent) return;
|
|
}
|
|
|
|
// Fallback to fetch (non-blocking)
|
|
fetch(this.endpoint, {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: payload,
|
|
keepalive: true,
|
|
}).catch(() => {
|
|
// Silently drop - we don't want logging failures to break the app
|
|
});
|
|
}
|
|
}
|
|
|
|
/** Console transport for development. */
|
|
class ConsoleTransport implements LogTransport {
|
|
send(entries: LogEntry[]): void {
|
|
for (const entry of entries) {
|
|
const method = entry.level === 'debug' ? 'log' : entry.level;
|
|
const ctx = Object.keys(entry.context).length > 0 ? entry.context : undefined;
|
|
if (entry.error) {
|
|
console[method](`[${entry.level.toUpperCase()}] ${entry.message}`, entry.error, ctx);
|
|
} else if (ctx) {
|
|
console[method](`[${entry.level.toUpperCase()}] ${entry.message}`, ctx);
|
|
} else {
|
|
console[method](`[${entry.level.toUpperCase()}] ${entry.message}`);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
export class Logger {
|
|
private buffer: LogEntry[] = [];
|
|
private timer: ReturnType<typeof setTimeout> | null = null;
|
|
private transport: LogTransport;
|
|
private minLevel: number;
|
|
private baseContext: LogContext;
|
|
private batchSize: number;
|
|
private flushInterval: number;
|
|
|
|
constructor(config: LoggerConfig) {
|
|
this.minLevel = LEVEL_PRIORITY[config.level];
|
|
this.batchSize = config.batchSize ?? 20;
|
|
this.flushInterval = config.flushInterval ?? 5000;
|
|
this.baseContext = { service: config.service };
|
|
|
|
if (config.endpoint) {
|
|
this.transport = new HttpTransport(config.endpoint);
|
|
} else {
|
|
this.transport = new ConsoleTransport();
|
|
}
|
|
|
|
this.startFlushTimer();
|
|
|
|
// Flush on page unload
|
|
if (typeof window !== 'undefined') {
|
|
window.addEventListener('visibilitychange', () => {
|
|
if (document.visibilityState === 'hidden') {
|
|
this.flush();
|
|
}
|
|
});
|
|
window.addEventListener('pagehide', () => this.flush());
|
|
}
|
|
}
|
|
|
|
/** Create a child logger with additional context fields. */
|
|
withContext(ctx: LogContext): Logger {
|
|
const child = Object.create(this) as Logger;
|
|
child.baseContext = { ...this.baseContext, ...ctx };
|
|
return child;
|
|
}
|
|
|
|
debug(message: string, ctx?: LogContext): void {
|
|
this.log('debug', message, ctx);
|
|
}
|
|
|
|
info(message: string, ctx?: LogContext): void {
|
|
this.log('info', message, ctx);
|
|
}
|
|
|
|
warn(message: string, ctx?: LogContext): void {
|
|
this.log('warn', message, ctx);
|
|
}
|
|
|
|
error(message: string, error?: Error | unknown, ctx?: LogContext): void {
|
|
const entry = this.createEntry('error', message, ctx);
|
|
if (error instanceof Error) {
|
|
entry.error = {
|
|
name: error.name,
|
|
message: error.message,
|
|
stack: error.stack,
|
|
};
|
|
} else if (error !== undefined) {
|
|
entry.error = {
|
|
name: 'UnknownError',
|
|
message: String(error),
|
|
};
|
|
}
|
|
this.enqueue(entry);
|
|
}
|
|
|
|
/** Force-flush the buffer immediately. */
|
|
flush(): void {
|
|
if (this.buffer.length === 0) return;
|
|
const entries = this.buffer.splice(0);
|
|
this.transport.send(entries);
|
|
}
|
|
|
|
/** Stop the flush timer (call on teardown). */
|
|
destroy(): void {
|
|
this.flush();
|
|
if (this.timer) {
|
|
clearInterval(this.timer);
|
|
this.timer = null;
|
|
}
|
|
}
|
|
|
|
private log(level: LogLevel, message: string, ctx?: LogContext): void {
|
|
if (LEVEL_PRIORITY[level] < this.minLevel) return;
|
|
this.enqueue(this.createEntry(level, message, ctx));
|
|
}
|
|
|
|
private createEntry(level: LogLevel, message: string, ctx?: LogContext): LogEntry {
|
|
return {
|
|
level,
|
|
message,
|
|
timestamp: new Date().toISOString(),
|
|
context: { ...this.baseContext, ...ctx },
|
|
};
|
|
}
|
|
|
|
private enqueue(entry: LogEntry): void {
|
|
this.buffer.push(entry);
|
|
if (this.buffer.length >= this.batchSize) {
|
|
this.flush();
|
|
}
|
|
}
|
|
|
|
private startFlushTimer(): void {
|
|
if (this.flushInterval > 0 && typeof setInterval !== 'undefined') {
|
|
this.timer = setInterval(() => this.flush(), this.flushInterval);
|
|
}
|
|
}
|
|
}
|
|
|
|
/** Create a logger instance. */
|
|
export function createLogger(config: LoggerConfig): Logger {
|
|
return new Logger(config);
|
|
}
|