1
mirror of https://github.com/thepeacockproject/Peacock synced 2024-11-29 09:15:11 +01:00
Peacock/components/loggingInterop.ts
2023-04-06 00:21:12 +01:00

322 lines
8.5 KiB
TypeScript

/*
* The Peacock Project - a HITMAN server replacement.
* Copyright (C) 2021-2023 The Peacock Project Team
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
import type { NextFunction, Response } from "express"
import type { RequestWithJwt } from "./types/types"
import picocolors from "picocolors"
import winston from "winston"
import "winston-daily-rotate-file"
const isDebug = ["*", "true", "peacock", "yes"].includes(
process.env.DEBUG || "false",
)
const isTest = ["*", "true", "peacock", "yes"].includes(
process.env.TEST || "false",
)
/**
* Represents the different log levels.
*/
export enum LogLevel {
/**
* For errors. Displays in red.
*/
ERROR = "error",
/**
* For warnings. Displays in yellow.
*/
WARN = "warn",
/**
* For information. Displays in blue.
* This is also the fallback for invalid log level values.
*/
INFO = "info",
/**
* For debugging. Displays in light blue.
*/
DEBUG = "debug",
/**
* For outputting stacktraces.
*/
TRACE = "trace",
/**
* For extremely verbose purposes.
*/
SILLY = "silly",
}
/**
* Represents the different internal log categories used by Peacock.
*/
export enum LogCategory {
/**
* Remove the category from the log
*/
NONE = "none",
/**
* Set the category to the name of the calling function
*/
CALLER = "caller",
/**
* Used for logging HTTP request
*/
HTTP = "http",
}
const LOG_LEVEL_NONE = "none"
//NOTE: Tests run in "strict mode", so only enable CALLER by default for debug-mode.
const LOG_CATEGORY_DEFAULT =
isDebug && !isTest ? LogCategory.CALLER : LogCategory.NONE
const fileLogLevel = process.env.LOG_LEVEL_FILE || LogLevel.SILLY
const consoleLogLevel = process.env.LOG_LEVEL_CONSOLE || LogLevel.SILLY
const disabledLogCategories =
process.env.LOG_CATEGORY_DISABLED?.split(",") || []
const transports = []
if (fileLogLevel !== LOG_LEVEL_NONE) {
const fileTransport = new winston.transports.DailyRotateFile({
filename: "logs/peacock-%DATE%.json",
datePattern: "YYYYMMDDHHmmss",
frequency: "1d",
maxFiles: process.env.LOG_MAX_FILES,
level: fileLogLevel,
format: winston.format.printf((info) => {
return JSON.stringify(info.data)
}),
})
transports.push(fileTransport)
}
if (consoleLogLevel !== LOG_LEVEL_NONE) {
const consoleTransport = new winston.transports.Console({
level: consoleLogLevel,
format: winston.format.combine(
winston.format((info) => {
if (
!info.data.category ||
!disabledLogCategories.includes(info.data.category)
) {
return info
}
return false
})(),
winston.format.printf((info) => {
if (info.data.stack) {
return `${info.message}\n${info.data.stack}`
}
return info.message
}),
),
})
transports.push(consoleTransport)
}
const winstonLogLevel = {}
Object.values(LogLevel).forEach((e, i) => (winstonLogLevel[e] = i))
const logger = winston.createLogger({
levels: winstonLogLevel,
transports: transports,
})
/**
* Adds leading zeros to a number so that the length of the string will always
* be the number of places specified.
*
* @param num The number.
* @param places The intended width of the number (character count).
* @example
* zeroPad(5, 2) // -> "05"
*/
const zeroPad = (num: string | number, places: number) =>
String(num).padStart(places, "0")
/**
* Outputs all given arguments as a debug level indented JSON-message to the console.
*
* @param args The values to log.
*/
export function logDebug(...args: unknown[]): void {
log(LogLevel.DEBUG, JSON.stringify(args, undefined, " "))
}
/**
* Outputs a log message to the console.
*
* @param level The message's level.
* @param data The data to output.
* @param category The message's category.
* @see LogLevel
*
* @function log
*/
export function log(
level: LogLevel,
data: string,
category: LogCategory | string = LOG_CATEGORY_DEFAULT,
): void {
if (category === LogCategory.CALLER) {
category = log.caller?.name.toString() || "unknown"
}
const message = data || "No message specified"
const now = new Date()
const stampParts: number[] = [
now.getHours(),
now.getMinutes(),
now.getSeconds(),
]
const millis = zeroPad(now.getMilliseconds(), 3)
const timestamp = `${stampParts
.map((part) => zeroPad(part, 2))
.join(":")}:${millis}`
const timestampColored = picocolors.gray(timestamp)
const categoryColored = picocolors.gray(category)
let levelString: string
let levelStringColored: string
let stack = undefined
switch (level) {
case LogLevel.ERROR:
levelString = "Error"
levelStringColored = picocolors.red(levelString)
break
case LogLevel.WARN:
levelString = "Warn"
levelStringColored = picocolors.yellow(levelString)
break
case LogLevel.INFO:
default:
levelString = "Info"
levelStringColored = picocolors.blue(levelString)
break
case LogLevel.DEBUG:
levelString = "Debug"
levelStringColored = picocolors.blueBright(levelString)
break
case LogLevel.TRACE:
levelString = "Trace"
levelStringColored = picocolors.bgYellow(levelString)
stack = new Error("Trace").stack
break
case LogLevel.SILLY:
levelString = "Silly"
levelStringColored = picocolors.bgMagenta(levelString)
break
}
const categoryAndLevel =
category === LogCategory.NONE
? levelStringColored
: `${levelStringColored} | ${categoryColored}`
logger.log(
level,
`[${timestampColored}] [${categoryAndLevel}] ${message}`,
{
data: {
timestamp: timestamp,
category: category,
level: levelString,
message: message,
stack: stack,
},
},
)
}
/**
* Express middleware that logs all requests and their details with the info log level.
*
* @param req The Express request object.
* @param res The Express response object.
* @param next The Express next function.
* @see LogLevel.INFO
*/
export function loggingMiddleware(
req: RequestWithJwt,
res: Response,
next?: NextFunction,
): void {
log(
LogLevel.INFO,
`${picocolors.green(req.method)} ${picocolors.underline(req.url)}`,
LogCategory.HTTP,
)
next?.()
}
export function requestLoggingMiddleware(
req: RequestWithJwt,
res: Response,
next?: NextFunction,
): void {
res.once("finish", () => {
const debug = {
method: req.method,
url: req.url,
body: req.body,
statusCode: res.statusCode,
statusMessage: res.statusMessage,
}
log(LogLevel.DEBUG, JSON.stringify(debug), LogCategory.HTTP)
})
next?.()
}
export function errorLoggingMiddleware(
err: Error,
req: RequestWithJwt,
res: Response,
next?: NextFunction,
): void {
const debug = {
method: req.method,
url: req.url,
body: req.body,
error: `${err.name} - ${err.message} - ${
err.cause || "Unknown cause"
}\n${err.stack || "No stack"}`,
}
log(
LogLevel.ERROR,
`${picocolors.green(req.method)} ${picocolors.underline(
req.url,
)} gave an unexpected error! Please see log for details.`,
LogCategory.HTTP,
)
log(LogLevel.DEBUG, JSON.stringify(debug), LogCategory.HTTP)
next?.()
}