// probe.gl, MIT license /* eslint-disable no-console */ import {VERSION, isBrowser} from '@probe.gl/env'; import {LocalStorage} from './utils/local-storage'; import {formatTime, leftPad} from './utils/formatters'; import {addColor} from './utils/color'; import {autobind} from './utils/autobind'; import assert from './utils/assert'; import {getHiResTimestamp} from './utils/hi-res-timestamp'; /** "Global" log configuration settings */ type LogConfiguration = { enabled?: boolean; level?: number; [key: string]: unknown; }; /** Options when logging a message */ type LogOptions = { method?: Function; time?: boolean; total?: number; delta?: number; tag?: string; message?: string; once?: boolean; nothrottle?: boolean; args?: any; }; type LogFunction = () => void; type Table = Record; // Instrumentation in other packages may override console methods, so preserve them here const originalConsole = { debug: isBrowser() ? console.debug || console.log : console.log, log: console.log, info: console.info, warn: console.warn, error: console.error }; const DEFAULT_LOG_CONFIGURATION: Required = { enabled: true, level: 0 }; function noop() {} // eslint-disable-line @typescript-eslint/no-empty-function const cache = {}; const ONCE = {once: true}; /** A console wrapper */ export class Log { static VERSION = VERSION; id: string; VERSION: string = VERSION; _startTs: number = getHiResTimestamp(); _deltaTs: number = getHiResTimestamp(); _storage: LocalStorage; userData = {}; // TODO - fix support from throttling groups LOG_THROTTLE_TIMEOUT: number = 0; // Time before throttled messages are logged again constructor({id} = {id: ''}) { this.id = id; this.userData = {}; this._storage = new LocalStorage( `__probe-${this.id}__`, DEFAULT_LOG_CONFIGURATION ); this.timeStamp(`${this.id} started`); autobind(this); Object.seal(this); } set level(newLevel: number) { this.setLevel(newLevel); } get level(): number { return this.getLevel(); } isEnabled(): boolean { return this._storage.config.enabled; } getLevel(): number { return this._storage.config.level; } /** @return milliseconds, with fractions */ getTotal(): number { return Number((getHiResTimestamp() - this._startTs).toPrecision(10)); } /** @return milliseconds, with fractions */ getDelta(): number { return Number((getHiResTimestamp() - this._deltaTs).toPrecision(10)); } /** @deprecated use logLevel */ set priority(newPriority: number) { this.level = newPriority; } /** @deprecated use logLevel */ get priority(): number { return this.level; } /** @deprecated use logLevel */ getPriority(): number { return this.level; } // Configure enable(enabled: boolean = true): this { this._storage.setConfiguration({enabled}); return this; } setLevel(level: number): this { this._storage.setConfiguration({level}); return this; } /** return the current status of the setting */ get(setting: string): any { return this._storage.config[setting]; } // update the status of the setting set(setting: string, value: any): void { this._storage.setConfiguration({[setting]: value}); } /** Logs the current settings as a table */ settings(): void { if (console.table) { console.table(this._storage.config); } else { console.log(this._storage.config); } } // Unconditional logging assert(condition: unknown, message?: string): asserts condition { if (!condition) { throw new Error(message || 'Assertion failed'); } } /** Warn, but only once, no console flooding */ warn(message: string, ...args: unknown[]): LogFunction; warn(message: string): LogFunction { return this._getLogFunction(0, message, originalConsole.warn, arguments, ONCE); } /** Print an error */ error(message: string, ...args: unknown[]): LogFunction; error(message: string): LogFunction { return this._getLogFunction(0, message, originalConsole.error, arguments); } /** Print a deprecation warning */ deprecated(oldUsage: string, newUsage: string): LogFunction { return this.warn(`\`${oldUsage}\` is deprecated and will be removed \ in a later version. Use \`${newUsage}\` instead`); } /** Print a removal warning */ removed(oldUsage: string, newUsage: string): LogFunction { return this.error(`\`${oldUsage}\` has been removed. Use \`${newUsage}\` instead`); } // Conditional logging /** Log to a group */ probe(logLevel, message?, ...args: unknown[]): LogFunction; probe(logLevel, message?): LogFunction { return this._getLogFunction(logLevel, message, originalConsole.log, arguments, { time: true, once: true }); } /** Log a debug message */ log(logLevel, message?, ...args: unknown[]): LogFunction; log(logLevel, message?): LogFunction { return this._getLogFunction(logLevel, message, originalConsole.debug, arguments); } /** Log a normal message */ info(logLevel, message?, ...args: unknown[]): LogFunction; info(logLevel, message?): LogFunction { return this._getLogFunction(logLevel, message, console.info, arguments); } /** Log a normal message, but only once, no console flooding */ once(logLevel, message?, ...args: unknown[]): LogFunction; once(logLevel, message?) { return this._getLogFunction( logLevel, message, originalConsole.debug || originalConsole.info, arguments, ONCE ); } /** Logs an object as a table */ table(logLevel, table?, columns?): LogFunction { if (table) { return this._getLogFunction( logLevel, table, console.table || noop, (columns && [columns]) as unknown as IArguments, { tag: getTableHeader(table) } ); } return noop; } time(logLevel, message) { return this._getLogFunction(logLevel, message, console.time ? console.time : console.info); } timeEnd(logLevel, message) { return this._getLogFunction( logLevel, message, console.timeEnd ? console.timeEnd : console.info ); } timeStamp(logLevel, message?) { return this._getLogFunction(logLevel, message, console.timeStamp || noop); } group(logLevel, message, opts = {collapsed: false}) { const options = normalizeArguments({logLevel, message, opts}); const {collapsed} = opts; // @ts-expect-error options.method = (collapsed ? console.groupCollapsed : console.group) || console.info; return this._getLogFunction(options); } groupCollapsed(logLevel, message, opts = {}) { return this.group(logLevel, message, Object.assign({}, opts, {collapsed: true})); } groupEnd(logLevel) { return this._getLogFunction(logLevel, '', console.groupEnd || noop); } // EXPERIMENTAL withGroup(logLevel: number, message: string, func: Function): void { this.group(logLevel, message)(); try { func(); } finally { this.groupEnd(logLevel)(); } } trace(): void { if (console.trace) { console.trace(); } } // PRIVATE METHODS /** Deduces log level from a variety of arguments */ _shouldLog(logLevel: unknown): boolean { return this.isEnabled() && this.getLevel() >= normalizeLogLevel(logLevel); } _getLogFunction( logLevel: unknown, message?: unknown, method?: Function, args?: IArguments, opts?: LogOptions ): LogFunction { if (this._shouldLog(logLevel)) { // normalized opts + timings opts = normalizeArguments({logLevel, message, args, opts}); method = method || opts.method; assert(method); opts.total = this.getTotal(); opts.delta = this.getDelta(); // reset delta timer this._deltaTs = getHiResTimestamp(); const tag = opts.tag || opts.message; if (opts.once && tag) { if (!cache[tag]) { cache[tag] = getHiResTimestamp(); } else { return noop; } } // TODO - Make throttling work with groups // if (opts.nothrottle || !throttle(tag, this.LOG_THROTTLE_TIMEOUT)) { // return noop; // } message = decorateMessage(this.id, opts.message, opts); // Bind console function so that it can be called after being returned return method.bind(console, message, ...opts.args); } return noop; } } /** * Get logLevel from first argument: * - log(logLevel, message, args) => logLevel * - log(message, args) => 0 * - log({logLevel, ...}, message, args) => logLevel * - log({logLevel, message, args}) => logLevel */ function normalizeLogLevel(logLevel: unknown): number { if (!logLevel) { return 0; } let resolvedLevel; switch (typeof logLevel) { case 'number': resolvedLevel = logLevel; break; case 'object': // Backward compatibility // TODO - deprecate `priority` // @ts-expect-error resolvedLevel = logLevel.logLevel || logLevel.priority || 0; break; default: return 0; } // 'log level must be a number' assert(Number.isFinite(resolvedLevel) && resolvedLevel >= 0); return resolvedLevel; } /** * "Normalizes" the various argument patterns into an object with known types * - log(logLevel, message, args) => {logLevel, message, args} * - log(message, args) => {logLevel: 0, message, args} * - log({logLevel, ...}, message, args) => {logLevel, message, args} * - log({logLevel, message, args}) => {logLevel, message, args} */ export function normalizeArguments(opts: { logLevel; message; collapsed?: boolean; args?: IArguments | undefined; opts?; }): { logLevel: number; message: string; args: any[]; } { const {logLevel, message} = opts; opts.logLevel = normalizeLogLevel(logLevel); // We use `arguments` instead of rest parameters (...args) because IE // does not support the syntax. Rest parameters is transpiled to code with // perf impact. Doing it here instead avoids constructing args when logging is // disabled. // TODO - remove when/if IE support is dropped const args: any[] = opts.args ? Array.from(opts.args) : []; // args should only contain arguments that appear after `message` // eslint-disable-next-line no-empty while (args.length && args.shift() !== message) {} switch (typeof logLevel) { case 'string': case 'function': if (message !== undefined) { args.unshift(message); } opts.message = logLevel; break; case 'object': Object.assign(opts, logLevel); break; default: } // Resolve functions into strings by calling them if (typeof opts.message === 'function') { opts.message = opts.message(); } const messageType = typeof opts.message; // 'log message must be a string' or object assert(messageType === 'string' || messageType === 'object'); // original opts + normalized opts + opts arg + fixed up message return Object.assign(opts, {args}, opts.opts); } function decorateMessage(id, message, opts) { if (typeof message === 'string') { const time = opts.time ? leftPad(formatTime(opts.total)) : ''; message = opts.time ? `${id}: ${time} ${message}` : `${id}: ${message}`; message = addColor(message, opts.color, opts.background); } return message; } function getTableHeader(table: Table): string { for (const key in table) { for (const title in table[key]) { return title || 'untitled'; } } return 'empty'; }