refactor(logs): move to core package (#4586)

This commit is contained in:
Meier Lukas
2025-12-16 23:37:44 +01:00
committed by GitHub
parent d86af072bf
commit d348abfe4a
145 changed files with 971 additions and 708 deletions

View File

@@ -0,0 +1,17 @@
export const logLevels = ["error", "warn", "info", "debug"] as const;
export type LogLevel = (typeof logLevels)[number];
export const logLevelConfiguration = {
error: {
prefix: "🔴",
},
warn: {
prefix: "🟡",
},
info: {
prefix: "🟢",
},
debug: {
prefix: "🔵",
},
} satisfies Record<LogLevel, { prefix: string }>;

View File

@@ -0,0 +1,11 @@
import { z } from "zod/v4";
import { createEnv, runtimeEnvWithPrefix } from "../env";
import { logLevels } from "./constants";
export const logsEnv = createEnv({
server: {
LEVEL: z.enum(logLevels).default("info"),
},
runtimeEnv: runtimeEnvWithPrefix("LOG_"),
});

View File

@@ -0,0 +1,9 @@
export class ErrorWithMetadata extends Error {
public metadata: Record<string, unknown>;
constructor(message: string, metadata: Record<string, unknown> = {}, options?: ErrorOptions) {
super(message, options);
this.name = "Error";
this.metadata = metadata;
}
}

View File

@@ -0,0 +1,86 @@
import { logsEnv } from "../env";
import { formatMetadata } from "./metadata";
const ERROR_OBJECT_PRUNE_DEPTH = logsEnv.LEVEL === "debug" ? 10 : 3;
const ERROR_STACK_LINE_LIMIT = logsEnv.LEVEL === "debug" ? undefined : 5;
const ERROR_CAUSE_DEPTH = logsEnv.LEVEL === "debug" ? 10 : 5;
/**
* Formats the cause of an error in the format
* @example caused by Error: {message}
* {stack-trace}
* @param cause next cause in the chain
* @param iteration current iteration of the function
* @returns formatted and stacked causes
*/
export const formatErrorCause = (cause: unknown, iteration = 0): string => {
// Prevent infinite recursion
if (iteration > ERROR_CAUSE_DEPTH) {
return "";
}
if (cause instanceof Error) {
if (!cause.cause) {
return `\ncaused by ${formatErrorTitle(cause)}\n${formatErrorStack(cause.stack)}`;
}
return `\ncaused by ${formatErrorTitle(cause)}\n${formatErrorStack(cause.stack)}${formatErrorCause(cause.cause, iteration + 1)}`;
}
if (typeof cause === "object" && cause !== null) {
if ("cause" in cause) {
const { cause: innerCause, ...rest } = cause;
return `\ncaused by ${JSON.stringify(prune(rest, ERROR_OBJECT_PRUNE_DEPTH))}${formatErrorCause(innerCause, iteration + 1)}`;
}
return `\ncaused by ${JSON.stringify(prune(cause, ERROR_OBJECT_PRUNE_DEPTH))}`;
}
return `\ncaused by ${cause as string}`;
};
const ignoredErrorProperties = ["stack", "message", "name", "cause"];
/**
* Formats the title of an error
* @example {name}: {message} {metadata}
* @param error error to format title from
* @returns formatted error title
*/
export const formatErrorTitle = (error: Error) => {
const title = error.message.length === 0 ? error.name : `${error.name}: ${error.message}`;
const metadata = formatMetadata(error, ignoredErrorProperties);
return `${title} ${metadata}`;
};
/**
* Formats the stack trance of an error
* We remove the first line as it contains the error name and message
* @param stack stack trace
* @returns formatted stack trace
*/
export const formatErrorStack = (stack: string | undefined) =>
stack
?.split("\n")
.slice(1, ERROR_STACK_LINE_LIMIT ? ERROR_STACK_LINE_LIMIT + 1 : undefined)
.join("\n") ?? "";
/**
* Removes nested properties from an object beyond a certain depth
*/
const prune = (value: unknown, depth: number): unknown => {
if (typeof value !== "object" || value === null) {
return value;
}
if (Array.isArray(value)) {
if (depth === 0) return [];
return value.map((item) => prune(item, depth - 1));
}
if (depth === 0) {
return {};
}
return Object.fromEntries(Object.entries(value).map(([key, val]) => [key, prune(val, depth - 1)]));
};

View File

@@ -0,0 +1,25 @@
import { format } from "winston";
import { formatErrorCause, formatErrorStack } from "./error";
import { formatMetadata } from "./metadata";
export const logFormat = format.combine(
format.colorize(),
format.timestamp(),
format.errors({ stack: true, cause: true }),
format.printf(({ level, message, timestamp, cause, stack, ...metadata }) => {
const firstLine = `${timestamp as string} ${level}: ${message as string} ${formatMetadata(metadata)}`;
if (!cause && !stack) {
return firstLine;
}
const formatedStack = formatErrorStack(stack as string | undefined);
if (!cause) {
return `${firstLine}\n${formatedStack}`;
}
return `${firstLine}\n${formatedStack}${formatErrorCause(cause)}`;
}),
);

View File

@@ -0,0 +1,12 @@
import { ErrorWithMetadata } from "@homarr/core/infrastructure/logs/error";
export const formatMetadata = (metadata: Record<string, unknown> | Error, ignoreKeys?: string[]) => {
const metadataObject = metadata instanceof ErrorWithMetadata ? metadata.metadata : metadata;
const filteredMetadata = Object.keys(metadataObject)
.filter((key) => !ignoreKeys?.includes(key))
.map((key) => ({ key, value: metadataObject[key as keyof typeof metadataObject] }))
.filter(({ value }) => typeof value !== "object" && typeof value !== "function");
return filteredMetadata.map(({ key, value }) => `${key}="${value as string}"`).join(" ");
};

View File

@@ -0,0 +1,18 @@
import winston from "winston";
import { logsEnv } from "./env";
import { logFormat } from "./format";
import { logTransports } from "./transports";
const logger = winston.createLogger({
format: logFormat,
transports: logTransports,
level: logsEnv.LEVEL,
});
interface DefaultMetadata {
module: string;
}
export const createLogger = (metadata: DefaultMetadata & Record<string, unknown>) => logger.child(metadata);
export type Logger = winston.Logger;

View File

@@ -0,0 +1,21 @@
import { transports } from "winston";
import type { transport } from "winston";
import { RedisTransport } from "./redis-transport";
const getTransports = () => {
const defaultTransports: transport[] = [new transports.Console()];
// Only add the Redis transport if we are not in CI
if (!(Boolean(process.env.CI) || Boolean(process.env.DISABLE_REDIS_LOGS))) {
return defaultTransports.concat(
new RedisTransport({
level: "debug",
}),
);
}
return defaultTransports;
};
export const logTransports = getTransports();

View File

@@ -0,0 +1,44 @@
import superjson from "superjson";
import Transport from "winston-transport";
import type { RedisClient } from "../../redis/client";
import { createRedisClient } from "../../redis/client";
const messageSymbol = Symbol.for("message");
const levelSymbol = Symbol.for("level");
//
// Inherit from `winston-transport` so you can take advantage
// of the base functionality and `.exceptions.handle()`.
//
export class RedisTransport extends Transport {
private redis: RedisClient | null = null;
public static readonly publishChannel = "pubSub:logging";
/**
* Log the info to the Redis channel
*/
log(info: { [messageSymbol]: string; [levelSymbol]: string }, callback: () => void) {
setImmediate(() => {
this.emit("logged", info);
});
// Is only initialized here because it did not work when initialized in the constructor or outside the class
this.redis ??= createRedisClient();
this.redis
.publish(
RedisTransport.publishChannel,
superjson.stringify({
message: info[messageSymbol],
level: info[levelSymbol],
}),
)
.then(() => {
callback();
})
.catch(() => {
// Ignore errors
});
}
}