mirror of
https://github.com/tdurieux/anonymous_github.git
synced 2026-05-15 22:48:00 +02:00
error logging improvement, regex fix
This commit is contained in:
@@ -0,0 +1,172 @@
|
||||
import { createClient, RedisClientType } from "redis";
|
||||
import config from "../config";
|
||||
|
||||
export const ERROR_LOG_KEY = "admin:errors";
|
||||
export const ERROR_LOG_MAX = 1000;
|
||||
|
||||
export type Logger = {
|
||||
debug: (...args: unknown[]) => void;
|
||||
info: (...args: unknown[]) => void;
|
||||
warn: (...args: unknown[]) => void;
|
||||
error: (...args: unknown[]) => void;
|
||||
};
|
||||
|
||||
type Level = "debug" | "info" | "warn" | "error";
|
||||
|
||||
const LEVEL_ORDER: Record<Level, number> = {
|
||||
debug: 10,
|
||||
info: 20,
|
||||
warn: 30,
|
||||
error: 40,
|
||||
};
|
||||
|
||||
function resolveThreshold(): number {
|
||||
const raw = (process.env.LOG_LEVEL || "").toLowerCase() as Level;
|
||||
if (raw in LEVEL_ORDER) return LEVEL_ORDER[raw];
|
||||
return process.env.NODE_ENV === "production"
|
||||
? LEVEL_ORDER.info
|
||||
: LEVEL_ORDER.debug;
|
||||
}
|
||||
|
||||
const threshold = resolveThreshold();
|
||||
|
||||
function formatArg(a: unknown): string {
|
||||
if (typeof a === "string") return a;
|
||||
if (a instanceof Error) return JSON.stringify(serializeError(a));
|
||||
try {
|
||||
return JSON.stringify(a);
|
||||
} catch {
|
||||
return String(a);
|
||||
}
|
||||
}
|
||||
|
||||
let redisClient: RedisClientType | null = null;
|
||||
let redisDisabled = false;
|
||||
|
||||
function getRedis(): RedisClientType | null {
|
||||
if (redisDisabled) return null;
|
||||
if (redisClient) return redisClient;
|
||||
try {
|
||||
redisClient = createClient({
|
||||
socket: {
|
||||
host: config.REDIS_HOSTNAME,
|
||||
port: config.REDIS_PORT,
|
||||
// Give up on first failure — we don't want the redis client's
|
||||
// reconnect timer keeping the event loop alive (breaks unit tests
|
||||
// that just import the logger), and we don't want logger.error to
|
||||
// recursively retrigger if redis is down.
|
||||
reconnectStrategy: false,
|
||||
},
|
||||
}) as RedisClientType;
|
||||
redisClient.on("error", () => {
|
||||
if (!redisDisabled) {
|
||||
redisDisabled = true;
|
||||
try {
|
||||
redisClient?.disconnect();
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
}
|
||||
});
|
||||
redisClient.connect().catch(() => {
|
||||
redisDisabled = true;
|
||||
});
|
||||
return redisClient;
|
||||
} catch {
|
||||
redisDisabled = true;
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function persistError(entry: {
|
||||
ts: string;
|
||||
module: string;
|
||||
message: string;
|
||||
raw: unknown[];
|
||||
}) {
|
||||
const client = getRedis();
|
||||
if (!client || !client.isOpen) return;
|
||||
const payload = JSON.stringify(entry);
|
||||
client
|
||||
.multi()
|
||||
.lPush(ERROR_LOG_KEY, payload)
|
||||
.lTrim(ERROR_LOG_KEY, 0, ERROR_LOG_MAX - 1)
|
||||
.exec()
|
||||
.catch(() => undefined);
|
||||
}
|
||||
|
||||
function emit(level: Level, module: string, args: unknown[]) {
|
||||
if (LEVEL_ORDER[level] < threshold) return;
|
||||
const ts = new Date().toISOString();
|
||||
const formatted = args.map(formatArg);
|
||||
const line = `${ts} ${level.toUpperCase()} [${module}] ${formatted.join(" ")}`;
|
||||
const sink =
|
||||
level === "error"
|
||||
? console.error
|
||||
: level === "warn"
|
||||
? console.warn
|
||||
: level === "debug"
|
||||
? console.debug
|
||||
: console.log;
|
||||
sink(line);
|
||||
if (level === "error") {
|
||||
persistError({
|
||||
ts,
|
||||
module,
|
||||
message: typeof args[0] === "string" ? args[0] : "",
|
||||
raw: args.map((a) => {
|
||||
if (a instanceof Error) return serializeError(a);
|
||||
return a;
|
||||
}),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export function createLogger(module: string): Logger {
|
||||
return {
|
||||
debug: (...args) => emit("debug", module, args),
|
||||
info: (...args) => emit("info", module, args),
|
||||
warn: (...args) => emit("warn", module, args),
|
||||
error: (...args) => emit("error", module, args),
|
||||
};
|
||||
}
|
||||
|
||||
type ErrorLike = {
|
||||
name?: string;
|
||||
message?: string;
|
||||
stack?: string;
|
||||
status?: number;
|
||||
httpStatus?: number;
|
||||
code?: string | number;
|
||||
cause?: unknown;
|
||||
request?: { url?: string; method?: string };
|
||||
response?: { url?: string; status?: number };
|
||||
};
|
||||
|
||||
export function serializeError(err: unknown): Record<string, unknown> {
|
||||
if (err == null) return { value: err };
|
||||
if (typeof err !== "object") return { value: String(err) };
|
||||
|
||||
const e = err as ErrorLike;
|
||||
const out: Record<string, unknown> = {};
|
||||
if (e.name) out.name = e.name;
|
||||
if (e.message) out.message = e.message;
|
||||
|
||||
// Octokit RequestError / HTTP-shaped errors: surface status + url + method,
|
||||
// skip the giant headers/response body dump.
|
||||
if (typeof e.status === "number") out.status = e.status;
|
||||
if (e.request?.url) out.url = e.request.url;
|
||||
if (e.request?.method) out.method = e.request.method;
|
||||
if (!e.request && e.response?.url) out.url = e.response.url;
|
||||
|
||||
// AnonymousError carries an httpStatus and an inner cause.
|
||||
if (typeof e.httpStatus === "number") out.httpStatus = e.httpStatus;
|
||||
if (e.code !== undefined && e.code !== e.message) out.code = e.code;
|
||||
if (e.cause) out.cause = serializeError(e.cause);
|
||||
|
||||
// Only include the stack when there's nothing else useful — avoids dumping
|
||||
// a stack for handled HTTP errors but keeps debuggability for plain Errors.
|
||||
if (!out.status && !out.httpStatus && e.stack) out.stack = e.stack;
|
||||
|
||||
return out;
|
||||
}
|
||||
Reference in New Issue
Block a user