ecs-logs-js
Version:
Simple Node.js console logger that outputs human friendly and ecs-logs compatible messages
239 lines (204 loc) • 6.78 kB
text/typescript
import safeStringify from 'fast-safe-stringify'
import { serializeError } from 'serialize-error'
import extractStack from 'extract-stack'
import replaceString from 'replace-string'
import chalk from 'chalk'
import yaml from 'js-yaml'
const LEVELS_MAP = {
emerg: 0,
alert: 1,
crit: 2,
error: 3,
warn: 4,
notice: 5,
info: 6,
debug: 7,
}
/** Available log levels. */
export type LEVEL = keyof typeof LEVELS_MAP
interface LogLine {
level: string
time: string
message: string
data: unknown
}
/** Checks that the level is valid */
function validateLevel(level: string): asserts level is LEVEL {
if (!(level in LEVELS_MAP)) {
throw new Error(`Invalid log level '${level}'`)
}
}
class ErrorArrayStack {
stack: string[];
message: string;
name: string;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
[key: string]: any;
constructor(message: string, stack: string[]) {
this.message = message;
this.name = 'ErrorArrayStack';
this.stack = stack;
}
}
/** JSON.stringify replacer that converts unstringifyable values to stringifyable ones */
// eslint-disable-next-line @typescript-eslint/no-explicit-any
function jsonStringifyReplacer(_key: string, value: unknown): any {
if (typeof value === 'bigint') {
return value.toString()
}
if (value instanceof Map) {
return Array.from(value.entries())
}
if (value instanceof Set) {
return Array.from(value)
}
if (value instanceof Error) {
const serializedError = serializeError(value)
// Tidy up the stack trace a bit and convert it to an array
const s = new ErrorArrayStack(serializedError.message || '', extractStack.lines(value))
for (const key in value) {
if (Object.prototype.hasOwnProperty.call(value, key) && key !== 'message' && key !== 'stack' && key !== 'name') {
// eslint-disable-next-line @typescript-eslint/no-explicit-any, @typescript-eslint/no-unsafe-assignment
s[key] = (value as {[key: string]: any})[key];
}
}
return s;
}
return value
}
/** Options that can be passed to the Logger constructor. */
export interface LoggerOptions {
/**
* Sets the maximum log level that will be output. By setting this option to 'info', it can be used to disable debug logs in production.
* @default 'debug'
*/
level?: LEVEL
/**
* Enables the human friendly development output.
* @default process.env.NODE_ENV === 'development'
*/
devMode?: boolean
}
/** Creates a new logger instance. */
export class Logger {
level: LEVEL = 'debug'
devMode = process.env.NODE_ENV === 'development'
constructor(options: LoggerOptions = {}) {
if (options.level) {
validateLevel(options.level)
this.level = options.level
}
if (options.devMode) {
this.devMode = options.devMode
}
}
/**
* Logs a message at the given log level.
* @param level Log level for the message.
* @param message The message to log.
* @param data Any additional data to log with the message. This can be any type.
*/
log = (level: LEVEL, message: string, data?: unknown): void => {
if (LEVELS_MAP[level] > LEVELS_MAP[this.level]) {
return
}
const logLineObject: LogLine = {
level: level.toUpperCase(),
time: new Date().toISOString(),
message,
data,
}
// Create JSON string with all the exotic values converted to JSON safe versions
let logLine = safeStringify(logLineObject, jsonStringifyReplacer)
// Format the logs in a human friendly way in development mode
if (this.devMode) {
// Construct the main log line and add some highlighting styles
// Just parse the production log because it already has all the data conversions applied
const log: LogLine = JSON.parse(logLine) as LogLine
logLine = chalk.bold(`\n${log.level}: ${log.message}`)
if (level === 'warn') {
logLine = chalk.yellow(logLine)
} else if (LEVELS_MAP[level] <= LEVELS_MAP.error) {
logLine = chalk.red(logLine)
}
// Convert data to a compact and readable format
if (log.data) {
let data = yaml.safeDump(log.data, { schema: yaml.JSON_SCHEMA, lineWidth: Infinity })
// Indent the data slightly
data = data
.trim()
.split('\n')
.map((line) => ` ${line}`)
.join('\n')
// Shorten the absolute file paths
data = replaceString(data, process.cwd(), '.')
logLine += `\n${data}`
}
}
process.stdout.write(logLine + '\n')
}
/**
* Logs a message at the EMERG log level.
* @param message The message to log.
* @param data Any additional data to log with the message. This can be any type.
*/
emerg = (message: string, data?: unknown): void => {
this.log('emerg', message, data)
}
/**
* Logs a message at the ALERT log level.
* @param message The message to log.
* @param data Any additional data to log with the message. This can be any type.
*/
alert = (message: string, data?: unknown): void => {
this.log('alert', message, data)
}
/**
* Logs a message at the CRIT log level.
* @param message The message to log.
* @param data Any additional data to log with the message. This can be any type.
*/
crit = (message: string, data?: unknown): void => {
this.log('crit', message, data)
}
/**
* Logs a message at the ERROR log level.
* @param message The message to log.
* @param data Any additional data to log with the message. This can be any type.
*/
error = (message: string, data?: unknown): void => {
this.log('error', message, data)
}
/**
* Logs a message at the WARN log level.
* @param message The message to log.
* @param data Any additional data to log with the message. This can be any type.
*/
warn = (message: string, data?: unknown): void => {
this.log('warn', message, data)
}
/**
* Logs a message at the NOTICE log level.
* @param message The message to log.
* @param data Any additional data to log with the message. This can be any type.
*/
notice = (message: string, data?: unknown): void => {
this.log('notice', message, data)
}
/**
* Logs a message at the INFO log level.
* @param message The message to log.
* @param data Any additional data to log with the message. This can be any type.
*/
info = (message: string, data?: unknown): void => {
this.log('info', message, data)
}
/**
* Logs a message at the DEBUG log level.
* @param message The message to log.
* @param data Any additional data to log with the message. This can be any type.
*/
debug = (message: string, data?: unknown): void => {
this.log('debug', message, data)
}
}