code-server/packages/logger/src/logger.ts

442 lines
10 KiB
TypeScript

/**
* Log level.
*/
export enum Level {
Trace,
Debug,
Info,
Warning,
Error,
}
/**
* A field to log.
*/
export class Field<T> {
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,
) { }
}
// `undefined` is allowed to make it easy to conditionally display a field.
// For example: `error && field("error", error)`
// tslint:disable-next-line no-any
export type FieldArray = Array<Field<any> | 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];
/**
* Creates a time field
*/
export const time = (expected: number): Time => {
return new Time(expected, Date.now());
};
export const field = <T>(name: string, value: T): Field<T> => {
return new Field(name, value);
};
/**
* 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 = <string[]>[];
/**
* 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<Field<any>>): 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<Field<any>>): 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] = this.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] = this.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<Field<any>>): 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
}
/**
* Convert fully-formed hex to rgb.
*/
private hexToRgb(hex: string): [number, number, number] {
const integer = parseInt(hex.substr(1), 16);
return [
(integer >> 16) & 0xFF,
(integer >> 8) & 0xFF,
integer & 0xFF,
];
}
}
/**
* 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 = this.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 "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.Warning; 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.Warning,
});
}
/**
* Outputs a trace message.
*/
public trace(fn: LogCallback): void;
public trace(message: string, ...fields: FieldArray): void;
public trace(message: LogCallback | string, ...fields: FieldArray): void {
this.handle({
type: "trace",
message,
fields,
tagColor: "#888888",
level: Level.Trace,
});
}
/**
* 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: "trace" | "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.filter((f) => !!f).concat(this.defaultFields)
: passedFields.filter((f) => !!f)) as Array<Field<any>>; // tslint:disable-line no-any
const now = Date.now();
let times: Array<Field<Time>> = [];
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`, this.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
}
/**
* 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;
}
/**
* Convert rgb to hex.
*/
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(),
);