Files
outline/server/logging/Logger.ts

252 lines
6.3 KiB
TypeScript

/* eslint-disable no-console */
import { IncomingMessage } from "http";
import chalk from "chalk";
import isArray from "lodash/isArray";
import isEmpty from "lodash/isEmpty";
import isObject from "lodash/isObject";
import isString from "lodash/isString";
import winston from "winston";
import env from "@server/env";
import Metrics from "@server/logging/Metrics";
import Sentry from "@server/logging/sentry";
import ShutdownHelper from "@server/utils/ShutdownHelper";
import * as Tracing from "./tracer";
type LogCategory =
| "lifecycle"
| "authentication"
| "multiplayer"
| "http"
| "commands"
| "worker"
| "task"
| "processor"
| "email"
| "queue"
| "websockets"
| "database"
| "utils"
| "plugins";
type Extra = Record<string, any>;
class Logger {
output: winston.Logger;
public constructor() {
this.output = winston.createLogger({
// The check for log level validity is here in addition to the ENV validation
// as entering an incorrect LOG_LEVEL in env could otherwise prevent the
// related error message from being displayed.
level: [
"error",
"warn",
"info",
"http",
"verbose",
"debug",
"silly",
].includes(env.LOG_LEVEL)
? env.LOG_LEVEL
: "info",
});
this.output.add(
new winston.transports.Console({
format: env.isProduction
? winston.format.json()
: winston.format.combine(
winston.format.colorize(),
winston.format.printf(
({ message, level, label, ...extra }) =>
`${level}: ${
label ? chalk.bold("[" + label + "] ") : ""
}${message} ${isEmpty(extra) ? "" : JSON.stringify(extra)}`
)
),
})
);
if (
env.DEBUG &&
env.DEBUG !== "http" &&
!["silly", "debug"].includes(env.LOG_LEVEL)
) {
this.warn(
`"DEBUG" set in configuration but the "LOG_LEVEL" configuration is filtering debug messages. To see all logging, set "LOG_LEVEL" to "debug".`
);
}
}
/**
* Log information
*
* @param category A log message category that will be prepended
* @param extra Arbitrary data to be logged that will appear in prod logs
*/
public info(label: LogCategory, message: string, extra?: Extra) {
this.output.info(message, { ...this.sanitize(extra), label });
}
/**
* Debug information
*
* @param category A log message category that will be prepended
* @param extra Arbitrary data to be logged that will appear in development logs
*/
public debug(label: LogCategory, message: string, extra?: Extra) {
this.output.debug(message, { ...this.sanitize(extra), label });
}
/**
* Log a warning
*
* @param message A warning message
* @param extra Arbitrary data to be logged that will appear in prod logs
*/
public warn(message: string, extra?: Extra) {
Metrics.increment("logger.warning");
if (env.SENTRY_DSN) {
Sentry.withScope((scope) => {
scope.setLevel("warning");
for (const key in extra) {
scope.setExtra(key, this.sanitize(extra[key]));
}
Sentry.captureMessage(message);
});
}
if (env.isProduction) {
this.output.warn(message, this.sanitize(extra));
} else if (extra) {
console.warn(message, extra);
} else {
console.warn(message);
}
}
/**
* Report a runtime error
*
* @param message A description of the error
* @param error The error that occurred
* @param extra Arbitrary data to be logged that will appear in prod logs
* @param request An optional request object to attach to the error
*/
public error(
message: string,
error: Error,
extra?: Extra,
request?: IncomingMessage
) {
Metrics.increment("logger.error", {
name: error.name,
});
Tracing.setError(error);
if (env.SENTRY_DSN) {
Sentry.withScope((scope) => {
scope.setLevel("error");
for (const key in extra) {
scope.setExtra(key, this.sanitize(extra[key]));
}
if (request) {
scope.addEventProcessor((event) =>
Sentry.Handlers.parseRequest(event, request)
);
}
Sentry.captureException(error);
});
}
if (env.isProduction) {
this.output.error(message, {
error: error.message,
stack: error.stack,
});
} else {
console.error(message);
console.error(error);
if (extra) {
console.error(extra);
}
}
}
/**
* Report a fatal error and shut down the server
*
* @param message A description of the error
* @param error The error that occurred
* @param extra Arbitrary data to be logged that will appear in prod logs
*/
public fatal(message: string, error: Error, extra?: Extra) {
this.error(message, error, extra);
void ShutdownHelper.execute();
}
/**
* Sanitize data attached to logs and errors to remove sensitive information.
*
* @param input The data to sanitize
* @returns The sanitized data
*/
private sanitize = <T>(input: T, level = 0): T => {
// Short circuit if we're not in production to enable easier debugging
if (!env.isProduction) {
return input;
}
const sensitiveFields = [
"accessToken",
"refreshToken",
"token",
"password",
"content",
];
if (level > 3) {
return "[…]" as any as T;
}
if (isString(input)) {
if (sensitiveFields.some((field) => input.includes(field))) {
return "[Filtered]" as any as T;
}
}
if (isArray(input)) {
return input.map(this.sanitize) as any as T;
}
if (isObject(input)) {
const output = { ...input };
for (const key of Object.keys(output)) {
if (isObject(output[key])) {
output[key] = this.sanitize(output[key], level + 1);
} else if (isArray(output[key])) {
output[key] = output[key].map((value: unknown) =>
this.sanitize(value, level + 1)
);
} else if (sensitiveFields.includes(key)) {
output[key] = "[Filtered]";
} else {
output[key] = this.sanitize(output[key], level + 1);
}
}
return output;
}
return input;
};
}
export default new Logger();