refactor(logs): move to core package (#4586)
This commit is contained in:
17
packages/core/src/infrastructure/logs/constants.ts
Normal file
17
packages/core/src/infrastructure/logs/constants.ts
Normal 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 }>;
|
||||
11
packages/core/src/infrastructure/logs/env.ts
Normal file
11
packages/core/src/infrastructure/logs/env.ts
Normal 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_"),
|
||||
});
|
||||
9
packages/core/src/infrastructure/logs/error.ts
Normal file
9
packages/core/src/infrastructure/logs/error.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
86
packages/core/src/infrastructure/logs/format/error.ts
Normal file
86
packages/core/src/infrastructure/logs/format/error.ts
Normal 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)]));
|
||||
};
|
||||
25
packages/core/src/infrastructure/logs/format/index.ts
Normal file
25
packages/core/src/infrastructure/logs/format/index.ts
Normal 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)}`;
|
||||
}),
|
||||
);
|
||||
12
packages/core/src/infrastructure/logs/format/metadata.ts
Normal file
12
packages/core/src/infrastructure/logs/format/metadata.ts
Normal 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(" ");
|
||||
};
|
||||
18
packages/core/src/infrastructure/logs/index.ts
Normal file
18
packages/core/src/infrastructure/logs/index.ts
Normal 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;
|
||||
21
packages/core/src/infrastructure/logs/transports/index.ts
Normal file
21
packages/core/src/infrastructure/logs/transports/index.ts
Normal 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();
|
||||
@@ -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
|
||||
});
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user