/** * Numeric log level. A higher number is higher severity. */ export enum Level { Trace, Debug, Info, Warn, Error, } /** * A message field. */ export class Field { public constructor(public readonly identifier: string, public readonly value: T) {} public toJSON(): { identifier: string; value: T } { return { identifier: this.identifier, value: this.value, } } } /** * A time field. */ export class Time { public constructor(public readonly expected: number, public readonly ms: number) {} } /** * A generic argument to log alongside a message. */ export type Argument = any // eslint-disable-line @typescript-eslint/no-explicit-any /** * `undefined` is allowed to make it easier to conditionally display a field. * For example: `error && field("error", error)` */ export type FieldArray = Array | undefined> /** * Functions can be used to remove the need to perform operations when the * logging level won't output the result anyway. */ export type LogCallback = () => [string, ...FieldArray] /** * Log how long something took. Call this before doing the thing then pass it * into the logger after finished to log the time it took. */ export const time = (expected: number): Time => { return new Time(expected, Date.now()) } /** * A field to show with the message. */ export const field = (name: string, value: T): Field => { return new Field(name, value) } /** * Represents a log message, its fields, level, and color. */ export type Message = { message: string | LogCallback fields?: FieldArray level: Level tagColor: string } /** * An extra function to call with a message. */ export type Extender = (msg: Message & { section?: string }) => void /** * Represents a message formatted for use with something like `console.log`. */ export type MessageFormat = { /** * For example, something like `[%s] %s`. */ format: "" /** * The arguments that get applied to the format string. */ args: string[] /** * The fields to add under the message. */ fields: Field[] } export function doLog(level: Level, ...args: Argument): void { switch (level) { case Level.Trace: return console.trace(...args) case Level.Debug: return console.debug(...args) case Level.Info: return console.info(...args) case Level.Warn: return console.warn(...args) case Level.Error: return console.error(...args) } } /** * Format and build a *single* log entry at a time. */ export abstract class Formatter { private message: MessageFormat private readonly minimumTagWidth = 5 /** * formatType is used for the strings returned from style() and reset(). */ public constructor( private readonly formatType: string = "%s", private readonly colors: boolean = true, ) { this.message = this.newMessage() } /** * Return a new/empty message. */ private newMessage(): MessageFormat { return { format: "", args: [], fields: [], } } /** * Add a tag. */ public tag(name: string, color: string): void { for (let i = name.length; i < this.minimumTagWidth; ++i) { name += " " } this.push(name + " ", color) } /** * Add a field or an argument. Arguments will display inline in the order they * were pushed. Fields will display differently based on the formatter. Fields * cannot have custom colors. */ public push(fields: Field[]): void public push(arg: Argument, color?: string, weight?: string): void public push(arg: Argument | Field[], color?: string, weight?: string): void { if (Array.isArray(arg) && arg.every((a) => a instanceof Field)) { return void this.message.fields.push(...arg) } if (this.colors && (color || weight)) { this.message.format += `${this.formatType}${this.getType(arg)}${this.formatType}` this.message.args.push(this.style(color, weight), arg, this.reset()) } else { this.message.format += `${this.getType(arg)}` this.message.args.push(arg) } } /** * Write everything out and reset state. */ public write(level: Level): void { const message = this.flush() this.doWrite(level, message) } /** * Return current values and reset state. */ protected flush(): MessageFormat { const message = this.message this.message = this.newMessage() return message } /** * Return a string that applies the specified color and weight. */ protected abstract style(color?: string, weight?: string): string /** * Return a string that resets all styles. */ protected abstract reset(): string /** * Write everything out. */ protected abstract doWrite(level: Level, message: MessageFormat): void /** * Get the format string for the value type. */ private getType(arg: Argument): string { switch (typeof arg) { case "object": return "%o" case "number": return "%d" default: return "%s" } } } /** * Display logs in the browser using CSS in the output. Fields are displayed on * individual lines within a group. */ export class BrowserFormatter extends Formatter { public constructor() { super("%c") } protected style(color?: string, weight?: string): string { return (color ? `color: ${color};` : "") + (weight ? `font-weight: ${weight};` : "") } protected reset(): string { return this.style("inherit", "normal") } public doWrite(level: Level, message: MessageFormat): void { console.groupCollapsed(message.format, ...message.args) message.fields.forEach((field) => { this.push(field.identifier, "#3794ff", "bold") if ( typeof field.value !== "undefined" && field.value.constructor && field.value.constructor.name ) { this.push(` (${field.value.constructor.name})`) } this.push(": ") this.push(field.value) const m = this.flush() doLog(level, m.format, ...m.args) }) console.groupEnd() } } /** * Display logs on the command line using ANSI color codes. Fields are displayed * in a single stringified object inline. */ export class ServerFormatter extends Formatter { public constructor() { super("%s", !!process.stdout.isTTY) } protected style(color?: string, weight?: string): string { return (weight === "bold" ? "\u001B[1m" : "") + (color ? this.hex(color) : "") } protected reset(): string { return "\u001B[0m" } private hex(hex: string): string { const [r, g, b] = this.hexToRgb(hex) return `\u001B[38;2;${r};${g};${b}m` } private hexToRgb(hex: string): [number, number, number] { const integer = parseInt(hex.substr(1), 16) return [(integer >> 16) & 0xff, (integer >> 8) & 0xff, integer & 0xff] } protected doWrite(level: Level, message: MessageFormat): void { if (message.fields.length === 0) { return doLog(level, "[%s] " + message.format, new Date().toISOString(), ...message.args) } const obj: { [key: string]: Argument } = {} message.fields.forEach((field) => (obj[field.identifier] = field.value)) doLog( level, "[%s] " + message.format + " %s%s%s", new Date().toISOString(), ...message.args, this.style("#8c8c8c"), JSON.stringify(obj), this.reset(), ) } } export class Logger { public level = Level.Info private readonly nameColor?: string private muted = false public constructor( private _formatter: Formatter, private readonly name?: string, private readonly defaultFields?: FieldArray, private readonly extenders: Extender[] = [], ) { if (name) { this.nameColor = this.hashStringToColor(name) } if (typeof process !== "undefined" && typeof process.env !== "undefined") { switch (process.env.LOG_LEVEL) { case "trace": this.level = Level.Trace break case "debug": this.level = Level.Debug break case "info": this.level = Level.Info break case "warn": this.level = Level.Warn break case "error": this.level = Level.Error break } } } public set formatter(formatter: Formatter) { this._formatter = formatter } /** * Supresses all output */ public mute(): void { this.muted = true } public extend(extender: Extender): void { this.extenders.push(extender) } public info(fn: LogCallback): void public info(message: string, ...fields: FieldArray): void public info(message: LogCallback | string, ...fields: FieldArray): void { this.handle({ message, fields, tagColor: "#008FBF", level: Level.Info, }) } public warn(fn: LogCallback): void public warn(message: string, ...fields: FieldArray): void public warn(message: LogCallback | string, ...fields: FieldArray): void { this.handle({ message, fields, tagColor: "#FF9D00", level: Level.Warn, }) } public trace(fn: LogCallback): void public trace(message: string, ...fields: FieldArray): void public trace(message: LogCallback | string, ...fields: FieldArray): void { this.handle({ message, fields, tagColor: "#888888", level: Level.Trace, }) } public debug(fn: LogCallback): void public debug(message: string, ...fields: FieldArray): void public debug(message: LogCallback | string, ...fields: FieldArray): void { this.handle({ message, fields, tagColor: "#84009E", level: Level.Debug, }) } public error(fn: LogCallback): void public error(message: string, ...fields: FieldArray): void public error(message: LogCallback | string, ...fields: FieldArray): void { this.handle({ message, fields, tagColor: "#B00000", level: Level.Error, }) } /** * Returns a sub-logger with a name. * Each name is deterministically generated a color. */ public named(name: string, ...fields: FieldArray): Logger { const l = new Logger(this._formatter, name, fields, this.extenders) if (this.muted) { l.mute() } return l } private handle(message: Message): void { if (this.level > message.level || this.muted) { return } let passedFields = message.fields || [] if (typeof message.message === "function") { const values = message.message() message.message = values.shift() as string passedFields = values as FieldArray } const fields = ( this.defaultFields ? passedFields.filter((f) => !!f).concat(this.defaultFields) : passedFields.filter((f) => !!f) ) as Array> const now = Date.now() let times: Array> = [] const hasFields = fields && fields.length > 0 if (hasFields) { times = fields.filter((f) => f.value instanceof Time) this._formatter.push(fields) } this._formatter.tag(Level[message.level].toLowerCase(), message.tagColor) if (this.name && this.nameColor) { this._formatter.tag(this.name, this.nameColor) } this._formatter.push(message.message) if (times.length > 0) { times.forEach((time) => { const diff = now - time.value.ms const expPer = diff / time.value.expected const min = 125 * (1 - expPer) const max = 125 + min const green = expPer < 1 ? max : min const red = expPer >= 1 ? max : min this._formatter.push(` ${time.identifier}=`, "#3794ff") this._formatter.push( `${diff}ms`, this.rgbToHex(red > 0 ? red : 0, green > 0 ? green : 0, 0), ) }) } this._formatter.write(message.level) this.extenders.forEach((extender) => { extender({ section: this.name, ...message, }) }) } /** * Hashes a string. */ private djb2(str: string): number { let hash = 5381 for (let i = 0; i < str.length; i++) { hash = (hash << 5) + hash + str.charCodeAt(i) /* hash * 33 + c */ } return hash } private rgbToHex(r: number, g: number, b: number): string { const integer = ((Math.round(r) & 0xff) << 16) + ((Math.round(g) & 0xff) << 8) + (Math.round(b) & 0xff) const str = integer.toString(16) return "#" + "000000".substring(str.length) + str } /** * Generates a deterministic color from a string using hashing. */ private hashStringToColor(str: string): string { const hash = this.djb2(str) return this.rgbToHex((hash & 0xff0000) >> 16, (hash & 0x00ff00) >> 8, hash & 0x0000ff) } } export const logger = new Logger( typeof process === "undefined" || typeof process.stdout === "undefined" ? new BrowserFormatter() : new ServerFormatter(), )