feat-dev-e2e-test/packages/logger/src/logger.ts
jordan 0d38924c2c
Some checks failed
ci/woodpecker/push/woodpecker Pipeline failed
ci/woodpecker/manual/woodpecker Pipeline was successful
Initialize project from skeleton template
2026-02-03 01:38:28 +00:00

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);
}