& so it begins
This commit is contained in:
222
packages/logger/client.ts
Normal file
222
packages/logger/client.ts
Normal file
@@ -0,0 +1,222 @@
|
||||
type LogLevel = "error" | "warn" | "info" | "http" | "debug";
|
||||
|
||||
export interface LogEntry {
|
||||
level: LogLevel;
|
||||
timestamp: string;
|
||||
message: any;
|
||||
metadata?: any;
|
||||
}
|
||||
|
||||
interface Error {
|
||||
code: string;
|
||||
message: string;
|
||||
description?: string;
|
||||
detail?: string;
|
||||
error?: any;
|
||||
actionable?: boolean;
|
||||
}
|
||||
|
||||
class BrowserLogger {
|
||||
private queue: LogEntry[] = [];
|
||||
private timer: ReturnType<typeof setInterval> | null = null;
|
||||
private readonly BATCH_INTERVAL = 5000; // 5 seconds
|
||||
private readonly BATCH_SIZE_LIMIT = 50;
|
||||
private readonly isDev: boolean;
|
||||
|
||||
constructor(isDev: boolean) {
|
||||
this.isDev = isDev;
|
||||
if (!this.isDev) {
|
||||
this.startBatchTimer();
|
||||
this.setupBeforeUnloadHandler();
|
||||
}
|
||||
}
|
||||
|
||||
private startBatchTimer() {
|
||||
this.timer = setInterval(() => this.flush(), this.BATCH_INTERVAL);
|
||||
}
|
||||
|
||||
private setupBeforeUnloadHandler() {
|
||||
// Flush logs before page unload to avoid losing them
|
||||
if (typeof window !== "undefined") {
|
||||
window.addEventListener("beforeunload", () => {
|
||||
this.flushSync();
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
private async flush() {
|
||||
if (this.queue.length === 0) return;
|
||||
|
||||
const batch = [...this.queue];
|
||||
this.queue = [];
|
||||
|
||||
try {
|
||||
// Forward batch to Hono route
|
||||
await fetch("/api/logs", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ logs: batch }),
|
||||
});
|
||||
} catch (err) {
|
||||
console.error("Axiom batch upload failed", err);
|
||||
// Re-add failed logs back to queue (up to a limit to avoid memory issues)
|
||||
if (this.queue.length < this.BATCH_SIZE_LIMIT * 2) {
|
||||
this.queue.push(...batch);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private flushSync() {
|
||||
// Synchronous flush for beforeunload using sendBeacon
|
||||
if (this.queue.length === 0) return;
|
||||
|
||||
const batch = [...this.queue];
|
||||
this.queue = [];
|
||||
|
||||
try {
|
||||
const blob = new Blob([JSON.stringify({ logs: batch })], {
|
||||
type: "application/json",
|
||||
});
|
||||
navigator.sendBeacon("/api/logs", blob);
|
||||
} catch (err) {
|
||||
console.error("Failed to send logs via sendBeacon", err);
|
||||
}
|
||||
}
|
||||
|
||||
private serializeError(error: unknown): any {
|
||||
if (error instanceof Error) {
|
||||
return {
|
||||
name: error.name,
|
||||
message: error.message,
|
||||
stack: error.stack,
|
||||
...(error.cause && { cause: this.serializeError(error.cause) }),
|
||||
};
|
||||
}
|
||||
return error;
|
||||
}
|
||||
|
||||
private createLogEntry(
|
||||
level: LogLevel,
|
||||
message: any,
|
||||
metadata?: any,
|
||||
): LogEntry {
|
||||
// Handle Error serialization for message
|
||||
const cleanMessage =
|
||||
message instanceof Error ? this.serializeError(message) : message;
|
||||
|
||||
// Handle Error serialization for metadata
|
||||
const cleanMetadata =
|
||||
metadata instanceof Error
|
||||
? this.serializeError(metadata)
|
||||
: metadata;
|
||||
|
||||
return {
|
||||
level,
|
||||
timestamp: new Date().toISOString(),
|
||||
message: cleanMessage,
|
||||
metadata: cleanMetadata || {},
|
||||
};
|
||||
}
|
||||
|
||||
private async sendLog(entry: LogEntry) {
|
||||
// Always log errors to console, even in production (for user debugging)
|
||||
// In dev, log everything to console
|
||||
const shouldConsoleLog = this.isDev || entry.level === "error";
|
||||
|
||||
if (shouldConsoleLog) {
|
||||
const consoleMethod =
|
||||
entry.level === "http" ? "debug" : entry.level;
|
||||
console[consoleMethod](
|
||||
`[client-${entry.level}] ${entry.timestamp}:`,
|
||||
entry.message,
|
||||
entry.metadata && Object.keys(entry.metadata).length > 0
|
||||
? entry.metadata
|
||||
: "",
|
||||
);
|
||||
}
|
||||
|
||||
// In production, add to queue for batching
|
||||
if (!this.isDev) {
|
||||
this.queue.push(entry);
|
||||
|
||||
// Safety flush if queue gets too large
|
||||
if (this.queue.length >= this.BATCH_SIZE_LIMIT) {
|
||||
await this.flush();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
error(message: any, metadata?: any) {
|
||||
this.sendLog(this.createLogEntry("error", message, metadata));
|
||||
}
|
||||
|
||||
warn(message: any, metadata?: any) {
|
||||
this.sendLog(this.createLogEntry("warn", message, metadata));
|
||||
}
|
||||
|
||||
info(message: any, metadata?: any) {
|
||||
this.sendLog(this.createLogEntry("info", message, metadata));
|
||||
}
|
||||
|
||||
http(message: any, metadata?: any) {
|
||||
this.sendLog(this.createLogEntry("http", message, metadata));
|
||||
}
|
||||
|
||||
debug(message: any, metadata?: any) {
|
||||
this.sendLog(this.createLogEntry("debug", message, metadata));
|
||||
}
|
||||
|
||||
// Manual flush method for advanced use cases
|
||||
async forceFlush() {
|
||||
await this.flush();
|
||||
}
|
||||
|
||||
// Cleanup method
|
||||
destroy() {
|
||||
if (this.timer) {
|
||||
clearInterval(this.timer);
|
||||
this.timer = null;
|
||||
}
|
||||
this.flushSync();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Factory function to create a BrowserLogger instance
|
||||
*
|
||||
* @param isDev - Whether the app is running in development mode
|
||||
* @returns A new BrowserLogger instance
|
||||
*
|
||||
* @example
|
||||
* // SvelteKit
|
||||
* import { dev } from '$app/environment';
|
||||
* const logger = getLoggerInstance(dev);
|
||||
*
|
||||
* @example
|
||||
* // Next.js
|
||||
* const logger = getLoggerInstance(process.env.NODE_ENV === 'development');
|
||||
*
|
||||
* @example
|
||||
* // Vite
|
||||
* const logger = getLoggerInstance(import.meta.env.DEV);
|
||||
*/
|
||||
function getLoggerInstance(isDev: boolean): BrowserLogger {
|
||||
return new BrowserLogger(isDev);
|
||||
}
|
||||
|
||||
function getError(logger: BrowserLogger, payload: Error, error?: any) {
|
||||
logger.error(payload);
|
||||
if (error) {
|
||||
logger.error(error);
|
||||
}
|
||||
return {
|
||||
code: payload.code,
|
||||
message: payload.message,
|
||||
description: payload.description,
|
||||
detail: payload.detail,
|
||||
error: error,
|
||||
actionable: payload.actionable,
|
||||
} as Error;
|
||||
}
|
||||
|
||||
export { getError, getLoggerInstance };
|
||||
143
packages/logger/index.ts
Normal file
143
packages/logger/index.ts
Normal file
@@ -0,0 +1,143 @@
|
||||
import DailyRotateFile from "winston-daily-rotate-file";
|
||||
import { settings } from "@pkg/settings";
|
||||
import type { Err } from "@pkg/result";
|
||||
import winston from "winston";
|
||||
import util from "util";
|
||||
import path from "path";
|
||||
|
||||
process.on("warning", (warning) => {
|
||||
const msg = String(warning?.message || "");
|
||||
const name = String((warning as any)?.name || "");
|
||||
|
||||
// Ignore the noisy timer warning from Node/kafkajs interplay
|
||||
if (
|
||||
name === "TimeoutNegativeWarning" ||
|
||||
msg.includes("TimeoutNegativeWarning") ||
|
||||
msg.includes("Timeout duration was set to 1")
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Keep other warnings visible
|
||||
console.warn(warning);
|
||||
});
|
||||
|
||||
const levels = {
|
||||
error: 0,
|
||||
warn: 1,
|
||||
info: 2,
|
||||
http: 3,
|
||||
debug: 4,
|
||||
};
|
||||
|
||||
const colors = {
|
||||
error: "red",
|
||||
warn: "yellow",
|
||||
info: "green",
|
||||
http: "magenta",
|
||||
debug: "white",
|
||||
};
|
||||
|
||||
const level = () => {
|
||||
const envLevel = process.env.LOG_LEVEL?.toLowerCase();
|
||||
if (envLevel && envLevel in levels) {
|
||||
return envLevel;
|
||||
}
|
||||
return settings.isDevelopment ? "debug" : "warn";
|
||||
};
|
||||
|
||||
// Console format with colors
|
||||
const consoleFormat = winston.format.combine(
|
||||
winston.format.errors({ stack: true }),
|
||||
winston.format.timestamp({ format: "YYYY-MM-DD HH:mm:ss:ms" }),
|
||||
winston.format.colorize({ all: true }),
|
||||
winston.format.printf((info) => {
|
||||
const { level, message, timestamp, ...extra } = info;
|
||||
|
||||
let formattedMessage = "";
|
||||
if (message instanceof Error) {
|
||||
formattedMessage = message.stack || message.message;
|
||||
} else if (typeof message === "object") {
|
||||
formattedMessage = util.inspect(message, {
|
||||
depth: null,
|
||||
colors: true,
|
||||
});
|
||||
} else {
|
||||
formattedMessage = message as any as string;
|
||||
}
|
||||
|
||||
// Handle extra fields (if any)
|
||||
const formattedExtra =
|
||||
Object.keys(extra).length > 0
|
||||
? `\n${util.inspect(extra, { depth: null, colors: true })}`
|
||||
: "";
|
||||
|
||||
return `[${level}] ${timestamp}: ${formattedMessage}${formattedExtra}`;
|
||||
}),
|
||||
);
|
||||
|
||||
// JSON format for file logging
|
||||
const fileFormat = winston.format.combine(
|
||||
winston.format.errors({ stack: true }),
|
||||
winston.format.timestamp(),
|
||||
winston.format.json(),
|
||||
);
|
||||
|
||||
// File transport with daily rotation
|
||||
const fileTransport = new DailyRotateFile({
|
||||
filename: path.join("logs", "app-%DATE%.log"),
|
||||
datePattern: "YYYY-MM-DD",
|
||||
maxSize: "20m",
|
||||
maxFiles: "14d",
|
||||
format: fileFormat,
|
||||
});
|
||||
|
||||
// Error file transport with daily rotation
|
||||
const errorFileTransport = new DailyRotateFile({
|
||||
filename: path.join("logs", "error-%DATE%.log"),
|
||||
datePattern: "YYYY-MM-DD",
|
||||
maxSize: "20m",
|
||||
maxFiles: "14d",
|
||||
level: "error",
|
||||
format: fileFormat,
|
||||
});
|
||||
|
||||
const transports: winston.transport[] = [
|
||||
new winston.transports.Console({ format: consoleFormat }),
|
||||
fileTransport,
|
||||
errorFileTransport,
|
||||
];
|
||||
|
||||
winston.addColors(colors);
|
||||
|
||||
const logger = winston.createLogger({
|
||||
level: level(),
|
||||
levels,
|
||||
transports,
|
||||
format: fileFormat,
|
||||
exceptionHandlers: [
|
||||
new winston.transports.Console({ format: consoleFormat }),
|
||||
fileTransport,
|
||||
],
|
||||
rejectionHandlers: [
|
||||
new winston.transports.Console({ format: consoleFormat }),
|
||||
fileTransport,
|
||||
],
|
||||
});
|
||||
|
||||
const stream = { write: (message: string) => logger.http(message.trim()) };
|
||||
|
||||
function getError(payload: Err, error?: any) {
|
||||
logger.error(JSON.stringify({ payload, error }, null, 2));
|
||||
console.error(error);
|
||||
return {
|
||||
code: payload.code,
|
||||
message: payload.message,
|
||||
description: payload.description,
|
||||
detail: payload.detail,
|
||||
error: error instanceof Error ? error.message : error,
|
||||
actionable: payload.actionable,
|
||||
} as Err;
|
||||
}
|
||||
|
||||
export { getError, logger, stream };
|
||||
18
packages/logger/package.json
Normal file
18
packages/logger/package.json
Normal file
@@ -0,0 +1,18 @@
|
||||
{
|
||||
"name": "@pkg/logger",
|
||||
"module": "index.ts",
|
||||
"type": "module",
|
||||
"devDependencies": {
|
||||
"@types/bun": "latest"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"typescript": "^5.0.0"
|
||||
},
|
||||
"dependencies": {
|
||||
"@axiomhq/winston": "^1.3.1",
|
||||
"@pkg/result": "workspace:*",
|
||||
"@pkg/settings": "workspace:*",
|
||||
"winston": "^3.17.0",
|
||||
"winston-daily-rotate-file": "^5.0.0"
|
||||
}
|
||||
}
|
||||
5
packages/logger/tsconfig.json
Normal file
5
packages/logger/tsconfig.json
Normal file
@@ -0,0 +1,5 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"esModuleInterop": true,
|
||||
},
|
||||
}
|
||||
Reference in New Issue
Block a user