diff --git a/apps/dokploy/__test__/utils/log-type.test.ts b/apps/dokploy/__test__/utils/log-type.test.ts new file mode 100644 index 000000000..b2edfd58c --- /dev/null +++ b/apps/dokploy/__test__/utils/log-type.test.ts @@ -0,0 +1,108 @@ +import { describe, expect, test } from "vitest"; +import { getLogType } from "@/components/dashboard/docker/logs/utils"; + +describe("getLogType", () => { + describe("explicit level declared by structured loggers", () => { + test("JSON string levels (pino, winston, zap)", () => { + expect(getLogType('{"level":"trace","msg":"x"}').type).toBe("debug"); + expect(getLogType('{"level":"debug","msg":"x"}').type).toBe("debug"); + expect(getLogType('{"level":"info","msg":"x"}').type).toBe("info"); + expect(getLogType('{"level":"warn","msg":"x"}').type).toBe("warning"); + expect(getLogType('{"level":"warning","msg":"x"}').type).toBe("warning"); + expect(getLogType('{"level":"error","msg":"x"}').type).toBe("error"); + expect(getLogType('{"level":"fatal","msg":"x"}').type).toBe("error"); + }); + + test("JSON numeric levels (pino, bunyan)", () => { + expect(getLogType('{"level":20,"msg":"x"}').type).toBe("debug"); + expect(getLogType('{"level":30,"msg":"x"}').type).toBe("info"); + expect(getLogType('{"level":40,"msg":"x"}').type).toBe("warning"); + expect(getLogType('{"level":50,"msg":"x"}').type).toBe("error"); + expect(getLogType('{"level":60,"msg":"x"}').type).toBe("error"); + }); + + test("syslog/GELF numeric levels", () => { + expect(getLogType('{"level":3,"msg":"x"}').type).toBe("error"); + expect(getLogType('{"level":4,"msg":"x"}').type).toBe("warning"); + expect(getLogType('{"level":6,"msg":"x"}').type).toBe("info"); + expect(getLogType('{"level":7,"msg":"x"}').type).toBe("debug"); + }); + + test("GCP-style severity and ECS log.level", () => { + expect(getLogType('{"severity":"ERROR","message":"x"}').type).toBe( + "error", + ); + expect(getLogType('{"severity":"WARNING","message":"x"}').type).toBe( + "warning", + ); + expect(getLogType('{"log.level":"error","message":"x"}').type).toBe( + "error", + ); + }); + + test("logfmt levels", () => { + expect( + getLogType('ts=2026-06-12T10:00:00Z level=error msg="boom"').type, + ).toBe("error"); + expect( + getLogType('ts=2026-06-12T10:00:00Z level=info msg="ok"').type, + ).toBe("info"); + expect(getLogType("level=warn msg=careful").type).toBe("warning"); + }); + + test("declared level wins over keywords in the message (#4589, #1996)", () => { + // "version"/"GET" in this pino line would otherwise match the debug keywords + const pinoError = + '{"level":"error","version":"72b4450","method":"GET","path":"/api/campaigns","err":{"type":"ForbiddenError","stack":"ForbiddenError: at requireRole (/app/src/plugins/campaign.plugin.ts:166:15)"},"msg":"Forbidden"}'; + expect(getLogType(pinoError).type).toBe("error"); + + // info line containing error-like keywords (#4589) + expect( + getLogType( + '{"level":"info","msg":"Failed to open mempool file. Continuing anyway."}', + ).type, + ).toBe("info"); + + // successful job summary with "failed: false, error: none" (#4538) + expect( + getLogType( + 'level=info msg="Finished job, failed: false, skipped: false, error: none"', + ).type, + ).toBe("info"); + }); + + test("declared level wins over statusCode", () => { + expect(getLogType('{"level":"info","statusCode":500}').type).toBe("info"); + }); + + test("unknown level names fall back to keyword detection", () => { + expect( + getLogType('{"level":"verbose","msg":"connection failed"}').type, + ).toBe("error"); + }); + + test("env-var-like text is not treated as logfmt level", () => { + expect(getLogType("LOG_LEVEL=error NODE_ENV=production").type).not.toBe( + "error", + ); + }); + }); + + describe("fallback detection for unstructured logs (unchanged)", () => { + test("statusCode classification", () => { + expect(getLogType('{"statusCode":500,"msg":"x"}').type).toBe("error"); + expect(getLogType('{"statusCode":404,"msg":"x"}').type).toBe("warning"); + expect(getLogType('{"statusCode":200,"msg":"x"}').type).toBe("success"); + }); + + test("keyword classification", () => { + expect(getLogType("error: something broke").type).toBe("error"); + expect(getLogType("warning: disk almost full").type).toBe("warning"); + expect(getLogType("Server listening on port 8080").type).toBe("success"); + }); + + test("defaults to info", () => { + expect(getLogType("hello world").type).toBe("info"); + }); + }); +}); diff --git a/apps/dokploy/components/dashboard/docker/logs/utils.ts b/apps/dokploy/components/dashboard/docker/logs/utils.ts index 01c68e49a..3e4695905 100644 --- a/apps/dokploy/components/dashboard/docker/logs/utils.ts +++ b/apps/dokploy/components/dashboard/docker/logs/utils.ts @@ -72,8 +72,68 @@ export function parseLogs(logString: string): LogLine[] { .filter((log) => log !== null); } +const LEVEL_NAME_TO_TYPE: Record = { + trace: "debug", + debug: "debug", + info: "info", + information: "info", + notice: "info", + warn: "warning", + warning: "warning", + error: "error", + err: "error", + fatal: "error", + critical: "error", + panic: "error", + alert: "error", + emergency: "error", +}; + +const numericLevelToType = (level: number): LogType => { + // pino/bunyan scale: 10=trace 20=debug 30=info 40=warn 50=error 60=fatal + if (level >= 50) return "error"; + if (level >= 40) return "warning"; + if (level >= 30) return "info"; + if (level >= 10) return "debug"; + // syslog/GELF scale: 0=emergency ... 7=debug + if (level <= 3) return "error"; + if (level === 4) return "warning"; + if (level <= 6) return "info"; + return "debug"; +}; + +// Extract the log level explicitly declared by structured loggers +// (pino, bunyan, winston, zap, slog, logfmt, GCP severity) +const getExplicitLevelType = (message: string): LogType | null => { + // JSON string levels: {"level":"error"} / {"severity":"ERROR"} / {"log.level":"warn"} + const jsonStringMatch = message.match( + /"(?:level|severity|log\.level|loglevel)"\s*:\s*"([a-z]+)"/i, + ); + if (jsonStringMatch?.[1]) { + return LEVEL_NAME_TO_TYPE[jsonStringMatch[1].toLowerCase()] ?? null; + } + + // JSON numeric levels: {"level":50} + const jsonNumericMatch = message.match(/"level"\s*:\s*(\d{1,2})\b/); + if (jsonNumericMatch?.[1]) { + return numericLevelToType(Number(jsonNumericMatch[1])); + } + + // logfmt: level=error + const logfmtMatch = message.match(/(?:^|\s)(?:level|severity)=([a-z]+)\b/i); + if (logfmtMatch?.[1]) { + return LEVEL_NAME_TO_TYPE[logfmtMatch[1].toLowerCase()] ?? null; + } + + return null; +}; + // Detect log type based on message content export const getLogType = (message: string): LogStyle => { + // A level explicitly declared by the logger wins over any inference + const explicitType = getExplicitLevelType(message); + if (explicitType) return LOG_STYLES[explicitType]; + // Detect HTTP statusCode const statusMatch = message.match(/"statusCode"\s*:\s*"?(\d{3})"?/);