/** * Log level. */ export enum Level { Debug = 0, Info = 1, Warn = 2, Error = 3, } /** * A field to log. */ export class Field { public constructor( public readonly identifier: string, public readonly value: T, ) { } /** * Convert field to JSON. */ public toJSON(): object { return { identifier: this.identifier, value: this.value, }; } } /** * Represents the time something takes. */ export class Time { public constructor( public readonly expected: number, public readonly ms: number, ) { } } // tslint:disable-next-line no-any export type FieldArray = Array>; // 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]; /** * Creates a time field */ export const time = (expected: number): Time => { return new Time(expected, Date.now()); }; export const field = (name: string, value: T): Field => { return new Field(name, value); }; /** * Hashes a string. */ const 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; }; /** * Convert rgb to hex. */ const 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; }; /** * Convert fully-formed hex to rgb. */ const hexToRgb = (hex: string): [number, number, number] => { const integer = parseInt(hex.substr(1), 16); return [ (integer >> 16) & 0xFF, (integer >> 8) & 0xFF, integer & 0xFF, ]; }; /** * Generates a deterministic color from a string using hashing. */ const hashStringToColor = (str: string): string => { const hash = djb2(str); return rgbToHex( (hash & 0xFF0000) >> 16, (hash & 0x00FF00) >> 8, hash & 0x0000FF, ); }; /** * This formats & builds text for logging. * It should only be used to build one log item at a time since it stores the * currently built items and appends to that. */ export abstract class Formatter { protected format = ""; protected args = []; /** * Add a tag. */ public abstract tag(name: string, color: string): void; /** * Add string or arbitrary variable. */ public abstract push(arg: string, color?: string, weight?: string): void; public abstract push(arg: any): void; // tslint:disable-line no-any // tslint:disable-next-line no-any public abstract fields(fields: Array>): void; /** * Flush out the built arguments. */ public flush(): any[] { // tslint:disable-line no-any const args = [this.format, ...this.args]; this.format = ""; this.args = []; return args; } /** * Get the format string for the value type. */ protected getType(arg: any): string { // tslint:disable-line no-any switch (typeof arg) { case "object": return "%o"; case "number": return "%d"; default: return "%s"; } } } /** * Browser formatter. */ export class BrowserFormatter extends Formatter { public tag(name: string, color: string): void { this.format += `%c ${name} `; this.args.push( `border: 1px solid #222; background-color: ${color}; padding-top: 1px;` + " padding-bottom: 1px; font-size: 12px; font-weight: bold; color: white;" + (name.length === 4 ? "padding-left: 3px; padding-right: 4px;" : ""), ); // A space to separate the tag from the title. this.push(" "); } public push(arg: any, color: string = "inherit", weight: string = "normal"): void { // tslint:disable-line no-any if (color || weight) { this.format += "%c"; this.args.push( (color ? `color: ${color};` : "") + (weight ? `font-weight: ${weight};` : ""), ); } this.format += this.getType(arg); this.args.push(arg); } // tslint:disable-next-line no-any public fields(fields: Array>): void { // tslint:disable-next-line no-console console.groupCollapsed(...this.flush()); 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); // tslint:disable-next-line no-console console.log(...this.flush()); }); // tslint:disable-next-line no-console console.groupEnd(); } } /** * Server (Node) formatter. */ export class ServerFormatter extends Formatter { public tag(name: string, color: string): void { const [r, g, b] = hexToRgb(color); while (name.length < 5) { name += " "; } this.format += "\u001B[1m"; this.format += `\u001B[38;2;${r};${g};${b}m${name} \u001B[0m`; } public push(arg: any, color?: string, weight?: string): void { // tslint:disable-line no-any if (weight === "bold") { this.format += "\u001B[1m"; } if (color) { const [r, g, b] = hexToRgb(color); this.format += `\u001B[38;2;${r};${g};${b}m`; } this.format += this.getType(arg); if (weight || color) { this.format += "\u001B[0m"; } this.args.push(arg); } // tslint:disable-next-line no-any public fields(fields: Array>): void { // tslint:disable-next-line no-any const obj: { [key: string]: any} = {}; this.format += "\u001B[38;2;140;140;140m"; fields.forEach((field) => { obj[field.identifier] = field.value; }); this.args.push(JSON.stringify(obj)); console.log(...this.flush()); // tslint:disable-line no-console } } /** * Class for logging. */ export class Logger { public level = Level.Info; private readonly nameColor?: string; private muted: boolean = false; public constructor( private _formatter: Formatter, private readonly name?: string, private readonly defaultFields?: FieldArray, ) { if (name) { this.nameColor = hashStringToColor(name); } const envLevel = typeof global !== "undefined" && typeof global.process !== "undefined" ? global.process.env.LOG_LEVEL : process.env.LOG_LEVEL; if (envLevel) { switch (envLevel) { 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; } /** * Outputs information. */ public info(fn: LogCallback): void; public info(message: string, ...fields: FieldArray): void; public info(message: LogCallback | string, ...fields: FieldArray): void { this.handle({ type: "info", message, fields, tagColor: "#008FBF", level: Level.Info, }); } /** * Outputs a warning. */ public warn(fn: LogCallback): void; public warn(message: string, ...fields: FieldArray): void; public warn(message: LogCallback | string, ...fields: FieldArray): void { this.handle({ type: "warn", message, fields, tagColor: "#FF9D00", level: Level.Warn, }); } /** * Outputs a debug message. */ public debug(fn: LogCallback): void; public debug(message: string, ...fields: FieldArray): void; public debug(message: LogCallback | string, ...fields: FieldArray): void { this.handle({ type: "debug", message, fields, tagColor: "#84009E", level: Level.Debug, }); } /** * Outputs an error. */ public error(fn: LogCallback): void; public error(message: string, ...fields: FieldArray): void; public error(message: LogCallback | string, ...fields: FieldArray): void { this.handle({ type: "error", 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); if (this.muted) { l.mute(); } return l; } /** * Outputs a message. */ private handle(options: { type: "info" | "warn" | "debug" | "error"; message: string | LogCallback; fields?: FieldArray; level: Level; tagColor: string; }): void { if (this.level > options.level || this.muted) { return; } let passedFields = options.fields || []; if (typeof options.message === "function") { const values = options.message(); options.message = values.shift() as string; passedFields = values as FieldArray; } const fields = this.defaultFields ? passedFields.concat(this.defaultFields) : passedFields; 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.tag(options.type.toUpperCase(), options.tagColor); if (this.name && this.nameColor) { this._formatter.tag(this.name.toUpperCase(), this.nameColor); } this._formatter.push(options.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`, rgbToHex(red > 0 ? red : 0, green > 0 ? green : 0, 0)); }); } // tslint:disable no-console if (hasFields) { this._formatter.fields(fields); } else { console.log(...this._formatter.flush()); } // tslint:enable no-console } } export const logger = new Logger( typeof process === "undefined" || typeof process.stdout === "undefined" ? new BrowserFormatter() : new ServerFormatter(), );