Files
Tom Moor 0139b91b5d chore: Replace lodash with es-toolkit (#12281)
* chore: Replace lodash with es-toolkit

Migrate all direct lodash imports to es-toolkit/compat for a smaller,
faster, lodash-compatible utility library. Transitive lodash usage from
other packages remains unchanged.

* fix: Restore isPlainObject semantics in CanCan policy

The lodash migration aliased `isObject` to `lodash/isPlainObject` and
the codemod incorrectly mapped the local name to es-toolkit's `isObject`,
which also returns true for arrays and functions. This caused condition
objects in policy definitions to be skipped, breaking authorization
checks across the codebase.

* fix: Restore unicode-aware length counting in validators

es-toolkit/compat's size() returns string.length, while lodash's _.size()
counts unicode code points. Switch to [...value].length to preserve the
previous behavior so multi-byte characters like emoji count as one.
2026-05-06 21:03:47 -04:00

250 lines
6.8 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/* oxlint-disable no-console */
import type { IncomingMessage } from "node:http";
import { styleText } from "node:util";
import { isArray, isEmpty, isObject } from "es-toolkit/compat";
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";
// oxlint-disable-next-line @typescript-eslint/no-explicit-any
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 ? styleText("bold", `[${label as string}] `) : ""
}${message as string} ${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 });
}
/**
* Detailed information for very detailed logs, more detailed than debug. "silly" is the
* lowest priority npm log level.
*
* @param category A log message category that will be prepended
* @param extra Arbitrary data to be logged that will appear in verbose logs
*/
public silly(label: LogCategory, message: string, extra?: Extra) {
this.output.silly(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");
this.output.warn(message, this.sanitize(extra));
}
/**
* 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(1);
}
/**
* 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 => {
// Errors have non-enumerable message/stack which are dropped by spreads
// and JSON serialization, so convert them to a plain object up-front.
if (input instanceof Error) {
return {
name: input.name,
message: input.message,
stack: input.stack,
} as unknown as 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) {
// oxlint-disable-next-line @typescript-eslint/no-explicit-any
return "[…]" as any as T;
}
if (isArray(input)) {
// oxlint-disable-next-line @typescript-eslint/no-explicit-any
return input.map((item) => this.sanitize(item, level + 1)) as any as T;
}
if (isObject(input)) {
// oxlint-disable-next-line @typescript-eslint/no-explicit-any
const output: Record<string, any> = { ...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 as T;
}
return input;
};
}
export default new Logger();