From 579dd5763d139e1644c2e192620728afa54c96d2 Mon Sep 17 00:00:00 2001 From: Meier Lukas Date: Wed, 26 Mar 2025 21:53:51 +0100 Subject: [PATCH] feat(logs): improve logs by logging errors with causes and metadata (#2703) * feat(logs): improve logs by logging errors with causes and metadata * fix: deepsource issue --- apps/nextjs/src/app/api/[...trpc]/route.ts | 3 ++ apps/nextjs/src/app/api/trpc/[trpc]/route.ts | 4 +- .../integration-test-connection.ts | 7 +-- packages/api/src/router/update-checker.ts | 4 +- packages/auth/configuration.ts | 4 +- packages/log/src/error.ts | 50 +++++++++++++++++++ packages/log/src/index.ts | 35 +++---------- packages/log/src/metadata.ts | 8 +++ packages/ping/src/index.ts | 6 +-- .../cached-request-integration-job-handler.ts | 5 +- 10 files changed, 81 insertions(+), 45 deletions(-) create mode 100644 packages/log/src/error.ts create mode 100644 packages/log/src/metadata.ts diff --git a/apps/nextjs/src/app/api/[...trpc]/route.ts b/apps/nextjs/src/app/api/[...trpc]/route.ts index 42a09e316..ea992afda 100644 --- a/apps/nextjs/src/app/api/[...trpc]/route.ts +++ b/apps/nextjs/src/app/api/[...trpc]/route.ts @@ -26,6 +26,9 @@ const handlerAsync = async (req: NextRequest) => { endpoint: "/", router: appRouter, createContext: () => createTRPCContext({ session, headers: req.headers }), + onError({ error, path, type }) { + logger.error(new Error(`tRPC Error with ${type} on '${path}'`, { cause: error.cause })); + }, }); }; diff --git a/apps/nextjs/src/app/api/trpc/[trpc]/route.ts b/apps/nextjs/src/app/api/trpc/[trpc]/route.ts index 66aa2ff71..f35afb7a3 100644 --- a/apps/nextjs/src/app/api/trpc/[trpc]/route.ts +++ b/apps/nextjs/src/app/api/trpc/[trpc]/route.ts @@ -31,9 +31,7 @@ const handler = auth(async (req) => { req, createContext: () => createTRPCContext({ session: req.auth, headers: req.headers }), onError({ error, path, type }) { - logger.error( - `tRPC Error with ${type} on '${path}': (${error.code}) - ${error.message}\n${error.stack}\n${error.cause}`, - ); + logger.error(new Error(`tRPC Error with ${type} on '${path}'`, { cause: error.cause })); }, }); diff --git a/packages/api/src/router/integration/integration-test-connection.ts b/packages/api/src/router/integration/integration-test-connection.ts index 96ae9f4f4..cd82b8cfa 100644 --- a/packages/api/src/router/integration/integration-test-connection.ts +++ b/packages/api/src/router/integration/integration-test-connection.ts @@ -1,5 +1,3 @@ -import { formatError } from "pretty-print-error"; - import { decryptSecret } from "@homarr/common/server"; import type { Integration } from "@homarr/db/schema"; import type { IntegrationKind, IntegrationSecretKind } from "@homarr/definitions"; @@ -41,7 +39,10 @@ export const testConnectionAsync = async ( }; } catch (error) { logger.warn( - `Failed to decrypt secret from database integration="${integration.name}" secretKind="${secret.kind}"\n${formatError(error)}`, + new Error( + `Failed to decrypt secret from database integration="${integration.name}" secretKind="${secret.kind}"`, + { cause: error }, + ), ); return null; } diff --git a/packages/api/src/router/update-checker.ts b/packages/api/src/router/update-checker.ts index d02744f92..6920358ce 100644 --- a/packages/api/src/router/update-checker.ts +++ b/packages/api/src/router/update-checker.ts @@ -1,5 +1,3 @@ -import { formatError } from "pretty-print-error"; - import { logger } from "@homarr/log"; import { updateCheckerRequestHandler } from "@homarr/request-handler/update-checker"; @@ -12,7 +10,7 @@ export const updateCheckerRouter = createTRPCRouter({ const data = await handler.getCachedOrUpdatedDataAsync({}); return data.data.availableUpdates; } catch (error) { - logger.error(`Failed to get available updates\n${formatError(error)}`); + logger.error(new Error("Failed to get available updates", { cause: error })); return undefined; // We return undefined to not show the indicator in the UI } }), diff --git a/packages/auth/configuration.ts b/packages/auth/configuration.ts index a1bf9b665..177e7e8fb 100644 --- a/packages/auth/configuration.ts +++ b/packages/auth/configuration.ts @@ -2,7 +2,6 @@ import type { ReadonlyHeaders } from "next/dist/server/web/spec-extension/adapte import { cookies } from "next/headers"; import NextAuth from "next-auth"; import Credentials from "next-auth/providers/credentials"; -import { formatError } from "pretty-print-error"; import { db } from "@homarr/db"; import type { SupportedAuthProvider } from "@homarr/definitions"; @@ -36,8 +35,7 @@ export const createConfiguration = ( return; } - logger.error(formatError(error)); - logger.error(formatError(error.cause)); + logger.error(error); }, }, trustHost: true, diff --git a/packages/log/src/error.ts b/packages/log/src/error.ts new file mode 100644 index 000000000..d8c0682dd --- /dev/null +++ b/packages/log/src/error.ts @@ -0,0 +1,50 @@ +import { formatMetadata } from "./metadata"; + +/** + * 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 > 5) { + 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)}`; + } + + 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 ? removeFirstLine(stack) : ""); +const removeFirstLine = (stack: string) => stack.split("\n").slice(1).join("\n"); diff --git a/packages/log/src/index.ts b/packages/log/src/index.ts index 2da0b175e..5a8e7af19 100644 --- a/packages/log/src/index.ts +++ b/packages/log/src/index.ts @@ -2,43 +2,22 @@ import type { transport as Transport } from "winston"; import winston, { format, transports } from "winston"; import { env } from "./env"; +import { formatErrorCause, formatErrorStack } from "./error"; +import { formatMetadata } from "./metadata"; import { RedisTransport } from "./redis-transport"; -/** - * 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 - */ -const formatCause = (cause: unknown, iteration = 0): string => { - // Prevent infinite recursion - if (iteration > 5) { - return ""; - } - - if (cause instanceof Error) { - if (!cause.cause) { - return `\ncaused by ${cause.stack}`; - } - - return `\ncaused by ${cause.stack}${formatCause(cause.cause, iteration + 1)}`; - } - - return `\ncaused by ${cause as string}`; -}; - -const logMessageFormat = format.printf(({ level, message, timestamp, cause, stack }) => { +const logMessageFormat = format.printf(({ level, message, timestamp, cause, stack, ...metadata }) => { if (!cause && !stack) { return `${timestamp as string} ${level}: ${message as string}`; } + const formatedStack = formatErrorStack(stack as string | undefined); + if (!cause) { - return `${timestamp as string} ${level}: ${message as string}\n${stack as string}`; + return `${timestamp as string} ${level}: ${message as string} ${formatMetadata(metadata)}\n${formatedStack}`; } - return `${timestamp as string} ${level}: ${message as string}\n${stack as string}${formatCause(cause)}`; + return `${timestamp as string} ${level}: ${message as string} ${formatMetadata(metadata)}\n${formatedStack}${formatErrorCause(cause)}`; }); const logTransports: Transport[] = [new transports.Console()]; diff --git a/packages/log/src/metadata.ts b/packages/log/src/metadata.ts new file mode 100644 index 000000000..089a14caa --- /dev/null +++ b/packages/log/src/metadata.ts @@ -0,0 +1,8 @@ +export const formatMetadata = (metadata: Record | Error, ignoreKeys?: string[]) => { + const filteredMetadata = Object.keys(metadata) + .filter((key) => !ignoreKeys?.includes(key)) + .map((key) => ({ key, value: metadata[key as keyof typeof metadata] })) + .filter(({ value }) => typeof value !== "object" && typeof value !== "function"); + + return filteredMetadata.map(({ key, value }) => `${key}="${value as string}"`).join(" "); +}; diff --git a/packages/ping/src/index.ts b/packages/ping/src/index.ts index d96d1a51a..b8f024c60 100644 --- a/packages/ping/src/index.ts +++ b/packages/ping/src/index.ts @@ -1,16 +1,16 @@ -import { formatError } from "pretty-print-error"; import type { fetch } from "undici"; import { fetchWithTrustedCertificatesAsync } from "@homarr/certificates/server"; +import { extractErrorMessage } from "@homarr/common"; import { logger } from "@homarr/log"; export const sendPingRequestAsync = async (url: string) => { try { return await fetchWithTimeoutAndCertificates(url).then((response) => ({ statusCode: response.status })); } catch (error) { - logger.error("packages/ping/src/index.ts:", formatError(error)); + logger.error(new Error(`Failed to send ping request to "${url}"`, { cause: error })); return { - error: formatError(error), + error: extractErrorMessage(error), }; } }; diff --git a/packages/request-handler/src/lib/cached-request-integration-job-handler.ts b/packages/request-handler/src/lib/cached-request-integration-job-handler.ts index 1c306f300..2c9bc39b8 100644 --- a/packages/request-handler/src/lib/cached-request-integration-job-handler.ts +++ b/packages/request-handler/src/lib/cached-request-integration-job-handler.ts @@ -1,4 +1,3 @@ -import { formatError } from "pretty-print-error"; import SuperJSON from "superjson"; import { hashObjectBase64, Stopwatch } from "@homarr/common"; @@ -107,7 +106,9 @@ export const createRequestIntegrationJobHandler = < ); } catch (error) { logger.error( - `Failed to run integration job integration=${integrationId} inputHash='${inputHash}' error=${formatError(error)}`, + new Error(`Failed to run integration job integration=${integrationId} inputHash='${inputHash}'`, { + cause: error, + }), ); } }