Last active
October 27, 2024 16:52
-
-
Save arabold/3a56def70f451c74956c26e1720a9778 to your computer and use it in GitHub Desktop.
Simple, opinionated Logger for use with Node.js and Web Browsers
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
/** | |
* Simple, opinionated Logger for use with Node.js and Web Browsers with no | |
* additional dependencies. | |
* | |
* The logger supports and auto-detects the following environments: | |
* - Node.js (with and without JSDOM) | |
* = Browser | |
* - Electron | |
* - React Native - Logs to console or browser window depending on whether a debugger is connected | |
* - Unit Test & CI/CD - Logs are disabled by default | |
* | |
* The default log level is `INFO` for Node.js production environments and `DEBUG` for | |
* anything else. A different default level can be specified using an environment | |
* variable `LOG_LEVEL` (for Node.js like environments) or by setting `window.LOG_LEVEL` | |
* (for Browser like environments). | |
* | |
* The logger will automatically detect if the used terminal supports colored output. | |
* To disable colored terminal output set `FORCE_COLOR` environment variable to `0` or | |
* `false`. To enforce colors regardless of any detected support, set `FORCE_COLOR` to | |
* `1` or `true`. | |
* | |
* @example | |
* import { Logger } from "./logger"; | |
* | |
* const logger = Logger.create(import.meta.url); | |
* | |
* function myFunc() { | |
* try { | |
* logger.info("Hello!"); | |
* logger.info("With payload:", { foo: "bar" }); | |
* // ... | |
* } | |
* catch (error) { | |
* logger.error("Oh no!", { error }); | |
* } | |
* } | |
*/ | |
/* eslint-disable no-console, @typescript-eslint/no-explicit-any, @typescript-eslint/ban-ts-comment */ | |
// biome-ignore lint/suspicious/noExplicitAny: Avoid TypeScript errors when trying to access globals not available in our target environment | |
type _any = any; | |
const _global: _any = | |
typeof global !== 'undefined' | |
? global | |
: typeof globalThis !== 'undefined' | |
? globalThis | |
: {}; | |
const _console = typeof console !== 'undefined' ? console : undefined; | |
const _process = typeof process !== 'undefined' ? process : undefined; | |
const _window = _global.window; | |
const _document = _global.document; | |
const _navigator = _global.navigator; | |
const _noop = () => {}; | |
/** | |
* ANSI color codes for terminal output | |
*/ | |
const colors = { | |
reset: '\x1b[0m', | |
bold: '\x1b[1m', | |
dim: '\x1b[2m', | |
italic: '\x1b[3m', // non-standard feature | |
underline: '\x1b[4m', | |
blink: '\x1b[5m', | |
reverse: '\x1b[7m', | |
hidden: '\x1b[8m', | |
fg: { | |
black: '\x1b[30m', | |
red: '\x1b[31m', | |
green: '\x1b[32m', | |
yellow: '\x1b[33m', | |
blue: '\x1b[34m', | |
magenta: '\x1b[35m', | |
cyan: '\x1b[36m', | |
white: '\x1b[37m', | |
crimson: '\x1b[38m', | |
}, | |
bg: { | |
black: '\x1b[40m', | |
red: '\x1b[41m', | |
green: '\x1b[42m', | |
yellow: '\x1b[43m', | |
blue: '\x1b[44m', | |
magenta: '\x1b[45m', | |
cyan: '\x1b[46m', | |
white: '\x1b[47m', | |
crimson: '\x1b[48m', | |
}, | |
}; | |
/** | |
* Supported log levels. Use `NONE` to disable logging completely. By default | |
* only `DEBUG` and above are logged. To set a different default level set | |
* the `LOG_LEVEL` environment variable or explicitly set the | |
* {@see Logger.defaultLevel} property. Set to `NONE` to fully disable logging. | |
*/ | |
export enum LogLevels { | |
/** Lowest debug level; This will not be logged unless explicitly enabled */ | |
SILLY = 0, | |
/** Debug messages for development environments */ | |
DEBUG = 1, | |
/** Info messages will be logged in production environments by default */ | |
INFO = 2, | |
/** Warning about a potential issue or an expected error occured */ | |
WARN = 3, | |
/** An unexpected error occurred */ | |
ERROR = 4, | |
/** Critical application error that cannot be recovered */ | |
CRITICAL = 5, | |
/** Disable logging */ | |
NONE = 99, | |
} | |
/** Names of the log levels used for display */ | |
export const LogLevelNames: { [key in LogLevels]: string } = { | |
[LogLevels.SILLY]: 'SILLY', | |
[LogLevels.DEBUG]: 'DEBUG', | |
[LogLevels.INFO]: 'INFO', | |
[LogLevels.WARN]: 'WARN', | |
[LogLevels.ERROR]: 'ERROR', | |
[LogLevels.CRITICAL]: 'CRITICAL', | |
[LogLevels.NONE]: 'NONE', | |
}; | |
/** | |
* Static mapping of error levels to `console.*` functions. | |
* While technically this can used to customize the log output to use a different | |
* function such as `process.stdout.write`, it is not recommended. Instead create | |
* a new log handler function and register it using {@see Logger.registerHandler}. | |
*/ | |
export const ConsoleLogFuncs: { | |
[key in LogLevels]: (...args: _any[]) => void; | |
} = { | |
[LogLevels.SILLY]: _console?.debug.bind(_console) ?? _noop, | |
[LogLevels.DEBUG]: _console?.debug.bind(_console) ?? _noop, | |
[LogLevels.INFO]: _console?.info.bind(_console) ?? _noop, | |
[LogLevels.WARN]: _console?.warn.bind(_console) ?? _noop, | |
[LogLevels.ERROR]: _console?.error.bind(_console) ?? _noop, | |
[LogLevels.CRITICAL]: _console?.error.bind(_console) ?? _noop, | |
[LogLevels.NONE]: () => { | |
/* intentionally left empty */ | |
}, | |
}; | |
/** Any kind of payload data, a plain old JavaScript object */ | |
export type Payload = { [key: string]: _any; error?: Error | unknown }; | |
/** A single log entry */ | |
export interface LogEntry { | |
timestamp: Date; | |
level: LogLevels; | |
message: string; | |
payload?: Payload; | |
meta?: Payload; | |
} | |
/** A custom log handler */ | |
export type LogHandlerFunc = (logEntry: LogEntry) => void; | |
/** | |
* Logger | |
*/ | |
export class Logger { | |
/** | |
* Fields that will automatically be scrubbed from logs. Field names will automatically transformed to lower case | |
* and special characters stripped before matching the string, so e.g. "access_token", "access-token" and | |
* "accessToken" will all match "accesstoken". | |
* | |
* To disable scrubbing, set this to an empty array | |
*/ | |
static scrubValues = [ | |
'password', | |
'newpassword', | |
'oldpassword', | |
'secret', | |
'passwd', | |
'apikey', | |
'token', | |
'accesstoken', | |
'refreshtoken', | |
'idtoken', | |
'authtoken', | |
'privatekey', | |
'clientsecret', | |
'creds', | |
'credentials', | |
'mysqlpwd', | |
'stripetoken', | |
'cardnumber', | |
]; | |
/** A list of class names, primarily from network libraries, that will be collapsed in the logs in order to keep it shorter and more readable */ | |
static collapseClasses = [ | |
'ClientRequest', | |
'IncomingMessage', | |
'Buffer', | |
'TLSSocket', | |
'Socket', | |
'WebSocket', | |
'WebSocketTransport', | |
'ReadableState', | |
'WritableState', | |
'HttpsAgent', | |
'HttpAgent', | |
'CDPSession', // Puppeteer | |
]; | |
/** Regular expression that can be used to check for possible credit card numbers when scrubbing fields */ | |
private static _creditCardRegEx = | |
/^(?:4[0-9]{12}(?:[0-9]{3})?|[25][1-7][0-9]{14}|6(?:011|5[0-9][0-9])[0-9]{12}|3[47][0-9]{13}|3(?:0[0-5]|[68][0-9])[0-9]{11}|(?:2131|1800|35\d{3})\d{11})$/; | |
/** Metadata shared between all instances of the Logger */ | |
private static _globalMetaData?: Payload; | |
/** List of log handler functions */ | |
private static _handlers: LogHandlerFunc[] = []; | |
/** | |
* Instantiates a new logger | |
* | |
* @param tag - Logger tag, e.g. the name of the current JavaScript file. | |
* For convenience, paths such as `__filename` and `import.meta.url` | |
* will automatically be truncated to the file name only. | |
* @param meta - Logger meta, e.g. the if of the vessel currently logging | |
* | |
* @example | |
* const logger = Logger.create(__filename); | |
* | |
* @example | |
* const logger = Logger.create(import.meta.url); | |
* | |
*/ | |
static create(tag?: string, meta?: Payload): Logger { | |
return new Logger(tag, meta); | |
} | |
/** Registers a new log handler */ | |
static registerHandler(handlerFunc: LogHandlerFunc) { | |
Logger._handlers.push(handlerFunc); | |
} | |
/** Removes a log handler */ | |
static unregisterHandler(handlerFunc: LogHandlerFunc) { | |
Logger._handlers = Logger._handlers.filter((func) => func === handlerFunc); | |
} | |
/** Removes all log handlers */ | |
static clearHandlers() { | |
Logger._handlers = []; | |
} | |
/** | |
* Returns `true` if code is running with JSDOM | |
*/ | |
static get isJSDOM(): boolean { | |
return ( | |
_navigator?.userAgent?.includes('Node.js') || | |
_navigator?.userAgent?.includes('jsdom') | |
); | |
} | |
/** | |
* Returns `true` if code is running in a web browser environment | |
*/ | |
static get isWebWorker(): boolean { | |
return ( | |
typeof _global.WorkerGlobalScope !== 'undefined' && | |
_global.self instanceof _global.WorkerGlobalScope | |
); | |
} | |
/** | |
* Returns `true` if code is running in a web browser environment | |
*/ | |
static get isBrowser(): boolean { | |
return ( | |
(typeof _window !== 'undefined' || Logger.isWebWorker) && !Logger.isJSDOM | |
); | |
} | |
/** | |
* Returns `true` if code is running in a web browser environment | |
*/ | |
static get isIE(): boolean { | |
return Logger.isBrowser && _document?.documentMode && _window?.StyleMedia; | |
} | |
/** | |
* Returns `true` if code is running in React Native | |
*/ | |
static get isReactNative(): boolean { | |
return Logger.isBrowser && _navigator?.product === 'ReactNative'; | |
} | |
/** | |
* Returns `true` if code is running in React Native and the Debugger is attached | |
*/ | |
static get isReactNativeDebugger(): boolean { | |
return ( | |
Logger.isReactNative && | |
typeof _global.DedicatedWorkerGlobalScope !== 'undefined' | |
); | |
} | |
/** | |
* Returns `true` if code is running in a Node.js-like environment (including Electron) | |
*/ | |
static get isNode(): boolean { | |
// Note that Webpack et al are often adding process, module, require, etc. | |
return ( | |
typeof _process === 'object' && | |
typeof _process?.env === 'object' && | |
!Logger.isBrowser | |
); | |
} | |
/** Returns `true` if code is running in Electron */ | |
static get isElectron(): boolean { | |
const userAgent = _navigator?.userAgent?.toLowerCase(); | |
if (userAgent.includes(' electron/')) { | |
// Test for Electron in case no Node.js environment is loaded | |
return true; | |
} | |
return Logger.isNode && typeof _process?.versions?.electron !== 'undefined'; | |
} | |
/** Returns `true` if running in a CI environment, e.g. during an automated build */ | |
static get isCI(): boolean { | |
if (!Logger.isNode || !_process?.env) { | |
return false; | |
} | |
const { CI, CONTINUOUS_INTEGRATION, BUILD_NUMBER, RUN_ID } = _process.env; | |
// Shamelessly stolen from https://github.com/watson/ci-info | |
return !!( | |
CI || // Travis CI, CircleCI, Cirrus CI, Gitlab CI, Appveyor, CodeShip, dsari | |
CONTINUOUS_INTEGRATION || // Travis CI, Cirrus CI | |
BUILD_NUMBER || // Jenkins, TeamCity | |
RUN_ID || // TaskCluster, dsari | |
false | |
); | |
} | |
/** Retruns `true` if running as part of a unit test */ | |
static get isUnitTest(): boolean { | |
if (_window?.__karma__ !== undefined) { | |
return true; | |
} | |
if (Logger.isNode && _process?.env) { | |
const { JEST_WORKER_ID } = _process.env; | |
return !!(JEST_WORKER_ID || typeof _global.it === 'function'); | |
} | |
return false; | |
} | |
/** | |
* Retruns `true` if `--silent` is passed on command line, e.g. when executed via an `npm` script | |
*/ | |
static get isSilent(): boolean { | |
if (!Logger.isNode) { | |
return false; | |
} | |
return _process?.argv?.includes('--silent') ?? false; | |
} | |
/** Checking if console exists */ | |
static get hasConsole(): boolean { | |
return !!( | |
_console && | |
'log' in _console && | |
'debug' in _console && | |
'info' in _console && | |
'warn' in _console && | |
'error' in _console | |
); | |
} | |
/** Enable/disable all logging */ | |
static enabled: boolean = | |
!Logger.isCI && !Logger.isSilent && !Logger.isUnitTest && Logger.hasConsole; | |
/** The default log level if no other option is specified */ | |
static defaultLevel: LogLevels = | |
Logger._parseLogLevel(_process?.env?.LOG_LEVEL) ?? | |
Logger._parseLogLevel(_global.LOG_LEVEL) ?? | |
Logger._parseLogLevel(_window?.LOG_LEVEL) ?? | |
(_process?.env?.NODE_ENV === 'production' | |
? LogLevels.INFO | |
: LogLevels.DEBUG); | |
/** | |
* Set Global Meta Data for Logger. The global meta data will only be included in | |
* Loggers that are instantiated _after_ the meta data was set using this function. | |
* Therefore it is recommended to put all Logger initialization into a `bootstrap.ts` | |
* file and import this at the very beginning of your app or project, making sure | |
* its code is executed before anything else. | |
* | |
* @param meta meta data | |
*/ | |
static setGlobalMeta(meta: Payload): void { | |
Logger._globalMetaData = { ...meta }; | |
} | |
/** | |
* Like `JSON.stringify` but handles circular references and serializes error objects. | |
*/ | |
static stringify(value: _any, space?: string | number | undefined): string { | |
return JSON.stringify(value, Logger._serializeObj, space); | |
} | |
/** | |
* Returns the URL or file name of the current module. This is used to automatically | |
* set the logger tag to the file name or URL of the module that is using the logger. | |
*/ | |
private static getMetaUrl(): string { | |
if (typeof __filename !== 'undefined') { | |
// CommonJS environment (Node.js with CommonJS or transpiled with CommonJS output) | |
return __filename; | |
} | |
if ( | |
// @ts-ignore | |
typeof import.meta !== 'undefined' && | |
// @ts-ignore | |
typeof import.meta.url === 'string' | |
) { | |
// ES module environment | |
// @ts-ignore | |
return import.meta.url; | |
} | |
return ''; | |
} | |
private static getCwd(): string { | |
if (typeof _process?.cwd === 'function') { | |
return _process?.cwd(); | |
} | |
if (typeof _window?.location?.href === 'string') { | |
return _window.location.href; | |
} | |
return ''; | |
} | |
private static stripCommonPath(basePath: string, targetPath: string): string { | |
// Remove trailing slashes and split paths into segments | |
const baseSegments = basePath | |
.replace(/^\w+:\/\//, '') | |
.replace(/\/+$/, '') | |
.split('/'); | |
const targetSegments = targetPath | |
.replace(/^\w+:\/\//, '') | |
.replace(/\/+$/, '') | |
.split('/'); | |
// Find the index where paths diverge | |
let divergeIndex = 0; | |
while ( | |
divergeIndex < baseSegments.length && | |
divergeIndex < targetSegments.length && | |
baseSegments[divergeIndex] === targetSegments[divergeIndex] | |
) { | |
divergeIndex++; | |
} | |
// Return the part of the target path that diverges from the base path | |
return targetSegments.slice(divergeIndex).join('/') || '.'; | |
} | |
/** | |
* Destroys circular references for use with JSON serialization | |
* | |
* @param from - Source object or array | |
* @param seen - Array with object already serialized. Set to `[]` (empty array) | |
* when using this function! | |
* @param scrub - If set, passwords and other sensitive data fields will be replaced by a placeholder and hidden from console or log files | |
*/ | |
private static _destroyCircular(from: _any, seen: _any[], scrub = false) { | |
let to: _any; | |
if (Array.isArray(from)) { | |
to = []; | |
} else { | |
to = {}; | |
} | |
seen.push(from); | |
for (const key in from) { | |
if (Object.hasOwn(from, key)) { | |
const value = from[key]; | |
if ( | |
typeof value === 'string' && | |
(Logger.scrubValues.includes( | |
key.toLowerCase().replace(/[^a-z0-9]/g, ''), | |
) || | |
Logger._creditCardRegEx.test(value) || | |
value.startsWith('Bearer ')) | |
) { | |
// Looks like a sensitive value. Hide it from the logging endpoint | |
to[key] = '[hidden]'; | |
continue; | |
} | |
if (typeof value === 'function') { | |
// No Logging of functions | |
continue; | |
} | |
if (typeof value === 'symbol') { | |
// Use the symbol's name | |
to[key] = value.toString(); | |
continue; | |
} | |
if (!value || typeof value !== 'object') { | |
// Simple data types | |
to[key] = value; | |
continue; | |
} | |
if (typeof value === 'object' && typeof value.toJSON === 'function') { | |
to[key] = value.toJSON(); | |
continue; | |
} | |
if (typeof value === 'object' && value.constructor) { | |
// Superagent/Axios includes a lot of detail information in the error object. | |
// For the sake of readable logs, we remove all of that garbage here. | |
const className = value.constructor.name; | |
if (Logger.collapseClasses.includes(className)) { | |
to[key] = `[${className}]`; | |
continue; | |
} | |
} | |
if (!seen.includes(from[key])) { | |
to[key] = Logger._destroyCircular(from[key], seen.slice(0), scrub); | |
continue; | |
} | |
to[key] = '[Circular]'; | |
} | |
} | |
if (typeof from.name === 'string') { | |
to.name = from.name; | |
} | |
if (typeof from.message === 'string') { | |
to.message = from.message; | |
} | |
if (typeof from.stack === 'string') { | |
to.stack = from.stack; | |
} | |
return to; | |
} | |
/** | |
* Helper function to serialize class instances to plain objects for logging | |
* | |
* @param key - Property key | |
* @param value - Property value | |
* | |
* @example | |
* const myObj = { foo: "bar" }; | |
* const str = JSON.stringify(myObj, serializeObj, "\t"); | |
*/ | |
private static _serializeObj(key: _any, value: _any): _any { | |
if (typeof value === 'object' && value !== null) { | |
return Logger._destroyCircular(value, []); | |
} | |
return value; | |
} | |
private static _parseLogLevel( | |
value?: string | number | null, | |
): LogLevels | undefined { | |
const logLevelMap = { | |
S: LogLevels.SILLY, | |
D: LogLevels.DEBUG, | |
I: LogLevels.INFO, | |
W: LogLevels.WARN, | |
E: LogLevels.ERROR, | |
C: LogLevels.CRITICAL, | |
N: LogLevels.NONE, | |
}; | |
if (typeof value === 'string') { | |
const firstChar = value?.substring(0, 1).toUpperCase(); | |
const entry = Object.entries(logLevelMap).find( | |
([key]) => firstChar === key, | |
); | |
return entry?.[1]; | |
} | |
if (typeof value === 'number') { | |
return (value >= LogLevels.SILLY && value <= LogLevels.CRITICAL) || | |
value === LogLevels.NONE | |
? value | |
: undefined; | |
} | |
return undefined; | |
} | |
/** Metadata passed with each log line */ | |
meta: Payload; | |
/** Current log level; if `undefined` the default level is used */ | |
logLevel?: LogLevels; | |
private constructor(tag?: string, meta?: Payload, logLevel?: LogLevels) { | |
if (logLevel) { | |
this.logLevel = logLevel; | |
} else if (Logger.isNode) { | |
this.logLevel = Logger._parseLogLevel(_process?.env?.LOG_LEVEL); | |
} | |
let sanitizedTag = tag ?? 'default'; | |
if (/\.(?:tsx?|jsx?)(?:\?.*)?$/i.test(sanitizedTag)) { | |
// Strip off unnecessary path information from the logger tag (e.g. if using __filename as tag) | |
const cwd = Logger.getCwd(); | |
sanitizedTag = Logger.stripCommonPath(cwd, sanitizedTag); | |
// Some final cleanup: Remove typical build folders and file extensions | |
sanitizedTag = sanitizedTag.replace( | |
/^(?:[/\\]?(?:src|dist|build|public)[/\\])?[/\\]?(.*?)\.(?:tsx?|jsx?|mjs|cjs)$/, | |
'$1', | |
); | |
} | |
this.meta = { | |
...Logger._globalMetaData, | |
...meta, | |
tag: sanitizedTag, | |
}; | |
} | |
silly(message: string, payload?: Payload): Logger { | |
return this._logInternal(LogLevels.SILLY, message, payload); | |
} | |
trace(message: string, payload?: Payload): Logger { | |
return this._logInternal(LogLevels.SILLY, message, payload); | |
} | |
debug(message: string, payload?: Payload): Logger { | |
return this._logInternal(LogLevels.DEBUG, message, payload); | |
} | |
info(message: string, payload?: Payload): Logger { | |
return this._logInternal(LogLevels.INFO, message, payload); | |
} | |
warn(message: string, payload?: Payload): Logger { | |
return this._logInternal(LogLevels.WARN, message, payload); | |
} | |
error(message: string, payload?: Payload): Logger; | |
error(message: string, error?: Error): Logger; | |
error(error: Error): Logger; | |
error(...args: [string, (Error | Payload)?] | [Error]): Logger { | |
return this._logExceptionInternal(LogLevels.ERROR, ...args); | |
} | |
critical(message: string, payload?: Payload): Logger; | |
critical(message: string, error?: Error): Logger; | |
critical(error: Error): Logger; | |
critical(...args: [string, (Error | Payload)?] | [Error]): Logger { | |
return this._logExceptionInternal(LogLevels.CRITICAL, ...args); | |
} | |
private _dispatchToHandlers(logEntry: LogEntry) { | |
for (const handler of Logger._handlers) { | |
handler(logEntry); | |
} | |
} | |
/** | |
* Write message to logs | |
* @param level | |
* @param logFunc | |
* @param message | |
* @param payloadRaw | |
*/ | |
private _logInternal( | |
level: LogLevels, | |
message: string, | |
payloadRaw?: Payload, | |
): Logger { | |
const logLevel = | |
this.logLevel ?? | |
Logger._parseLogLevel(_global?.LOG_LEVEL) ?? | |
Logger._parseLogLevel(_window?.LOG_LEVEL) ?? | |
Logger.defaultLevel; | |
if (!Logger.enabled || level < logLevel) { | |
return this; | |
} | |
const hasPayload = typeof payloadRaw !== 'undefined' && payloadRaw !== null; | |
let payload: _any = null; | |
try { | |
// The Browser logger seems to have issues with circular references if no | |
// debugger is attached. So we break all circular references here to be safe. | |
payload = hasPayload | |
? Logger._destroyCircular(payloadRaw, [], true) | |
: undefined; | |
} catch (error) { | |
payload = `[${(error as Error).message}]`; | |
} | |
this._dispatchToHandlers({ | |
timestamp: new Date(), | |
level, | |
message, | |
payload, | |
meta: this.meta, | |
}); | |
return this; | |
} | |
/** | |
* Special handling for error objects | |
* | |
* @param level | |
* @param logFunc | |
* @param messageRaw | |
* @param payloadRaw | |
*/ | |
private _logExceptionInternal( | |
level: LogLevels, | |
...args: [string, (Error | Payload)?] | [Error] | |
): Logger { | |
const logLevel = | |
this.logLevel ?? | |
Logger._parseLogLevel(_global.LOG_LEVEL) ?? | |
Logger._parseLogLevel(_window?.LOG_LEVEL) ?? | |
Logger.defaultLevel; | |
if (!Logger.enabled || level < logLevel) { | |
return this; | |
} | |
let message = ''; | |
let payload: Payload | undefined; | |
if (typeof args[0] === 'string' || args[0] instanceof String) { | |
message = args.splice(0, 1)[0] as string; | |
} | |
if (args[0] instanceof Error) { | |
const error = args[0]; | |
if (message.length === 0) { | |
message = error.message; | |
} | |
payload = { error }; | |
} else if (typeof args[0] === 'object') { | |
payload = { ...(args[0] as object) }; | |
} | |
return this._logInternal(level, message, payload); | |
} | |
} | |
/** Log output to interactive terminal in a human readable format and in colors (if supported) */ | |
export function createTTYLogger(forceColor = false): LogHandlerFunc { | |
// Colors see https://stackoverflow.com/questions/9781218/how-to-change-node-jss-console-font-color | |
const logLevelColors = { | |
[LogLevels.SILLY]: `${colors.underline}${colors.dim}${colors.fg.white}`, | |
[LogLevels.DEBUG]: `${colors.underline}${colors.dim}${colors.fg.white}`, | |
[LogLevels.INFO]: `${colors.underline}${colors.bold}${colors.fg.white}`, | |
[LogLevels.WARN]: `${colors.underline}${colors.fg.yellow}`, | |
[LogLevels.ERROR]: `${colors.underline}${colors.fg.red}`, | |
[LogLevels.CRITICAL]: `${colors.bg.red}${colors.fg.black}`, | |
[LogLevels.NONE]: '', | |
}; | |
const supportsColor = (): boolean => { | |
if (forceColor) { | |
// Colors enforced in any case | |
return true; | |
} | |
if (Logger.isReactNative) { | |
// In React Native we have no process.env but we most likely support colors in Expo console | |
return true; | |
} | |
if (!Logger.isNode) { | |
// No colors if we aren't running in Node (as then we don't have `process` available) | |
return false; | |
} | |
if (!_process?.stdout?.isTTY) { | |
// No colors if we have no interactive terminal | |
return false; | |
} | |
if (_process?.env?.TERM === 'dumb') { | |
// No colors if we are running in a dumb terminal | |
return false; | |
} | |
if (_process?.platform === 'win32') { | |
// A reasonably new Windows version should support colors | |
return true; | |
} | |
if (Logger.isCI) { | |
// Most CI/CD platforms support colors as well | |
return true; | |
} | |
if (_process?.env?.TERM === 'xterm-kitty') { | |
// Kitty terminal supports colors | |
return true; | |
} | |
if (_process?.env?.TERM_PROGRAM) { | |
const version = Number.parseInt( | |
(_process?.env?.TERM_PROGRAM_VERSION || '').split('.')[0], | |
10, | |
); | |
switch (_process?.env?.TERM_PROGRAM) { | |
case 'iTerm.app': { | |
return true; | |
} | |
case 'Apple_Terminal': { | |
return true; | |
} | |
// No default | |
} | |
} | |
if (/-256(color)?$/i.test(_process?.env?.TERM ?? '')) { | |
return true; | |
} | |
if ( | |
/^screen|^xterm|^vt100|^vt220|^rxvt|color|ansi|cygwin|linux/i.test( | |
_process?.env?.TERM ?? '', | |
) | |
) { | |
return true; | |
} | |
if (_process?.env?.COLORTERM) { | |
return true; | |
} | |
if (_process?.env?.COLOR === '1') { | |
return true; | |
} | |
return false; | |
}; | |
const enableColors = supportsColor(); | |
// The payload is already sanitized so we can simply print it using `utils.inspect()` or `JSON.strigify()`: | |
let stringify: (value: Payload) => string; | |
try { | |
// eslint-disable-next-line @typescript-eslint/no-var-requires | |
const util = require('node:util'); | |
stringify = (value) => | |
typeof util.inspect === 'function' | |
? util.inspect(value, false, null, enableColors) | |
: JSON.stringify(value, null, 2); | |
} catch (error) { | |
stringify = (value) => JSON.stringify(value, null, 2); | |
} | |
return function log({ level, message, payload, meta }: LogEntry) { | |
// Try to load `util` package. React Native requires you to install `util` manually using `npm i util --save`. | |
const logFunc = ConsoleLogFuncs[level]; | |
if (enableColors) { | |
const log = [ | |
`${colors.fg.magenta}[${meta?.tag}]`, | |
`${logLevelColors[level]}${LogLevelNames[level]}${colors.reset}`, | |
`${colors.fg.white}${message}${colors.reset}`, | |
].join(' '); | |
if (payload) { | |
logFunc( | |
log, | |
// Dim the output of the payload for better overall readability, | |
stringify(payload) | |
.split('\n') | |
.map((line) => `\n${colors.dim}${line}${colors.reset}`) // dim each line | |
.join(''), | |
); | |
} else { | |
logFunc(log); | |
} | |
} else { | |
const log = [`[${meta?.tag}]`, LogLevelNames[level], message].join(' '); | |
if (payload) { | |
logFunc(log, `\n${stringify(payload)}`); | |
} else { | |
logFunc(log); | |
} | |
} | |
}; | |
} | |
/** Log to Browser Console; Use colorful logs for Chrome, Safari, etc. */ | |
export function createBrowserLogger(): LogHandlerFunc { | |
return function log({ level, message, payload, meta }: LogEntry) { | |
const logFunc = ConsoleLogFuncs[level]; | |
const darkMode = !!_window?.matchMedia?.('(prefers-color-scheme: dark)') | |
.matches; | |
if ( | |
payload && | |
_console && | |
typeof _console.group === 'function' && | |
typeof _console.groupCollapsed === 'function' | |
) { | |
// Automatically expand warnings and errors | |
const groupFunc = | |
level >= LogLevels.WARN ? _console.group : _console.groupCollapsed; | |
groupFunc.bind(_console)( | |
`%c[${meta?.tag}]%c %c${message}`, | |
'color:magenta;', | |
'', // reset color | |
darkMode | |
? 'color:white;font-weight:normal' | |
: 'color:black;font-weight:normal', | |
); | |
logFunc(payload); | |
_console.groupEnd(); | |
} else if (payload) { | |
logFunc( | |
`%c[${meta?.tag}] %c${message}`, | |
'color:magenta;', | |
darkMode ? 'color:white' : 'color:black;', | |
payload, | |
); | |
} else { | |
logFunc( | |
`%c[${meta?.tag}] %c${message}`, | |
'color:magenta;', | |
darkMode ? 'color:white' : 'color:black;', | |
); | |
} | |
}; | |
} | |
export function createJsonConsoleLogger() { | |
return function log(logEntry: LogEntry) { | |
const logFunc = ConsoleLogFuncs[logEntry.level]; | |
logFunc( | |
JSON.stringify({ | |
...logEntry, | |
timestamp: logEntry.timestamp.toISOString(), | |
level: LogLevelNames[logEntry.level], | |
}), | |
); // no need to break circular references anymore here | |
}; | |
} | |
/** Automatically chooses the "best" handler depending on whether we're running on a terminal, in a browser or in any other supported environment */ | |
export function createDefaultLogger(forceColor?: boolean): LogHandlerFunc { | |
const logTTY = createTTYLogger(forceColor); | |
const logBrowser = createBrowserLogger(); | |
const logJson = createJsonConsoleLogger(); | |
return function log(logEntry: LogEntry) { | |
if ( | |
Logger.isBrowser && | |
!Logger.isIE && | |
(!Logger.isReactNative || Logger.isReactNativeDebugger) | |
) { | |
return logBrowser(logEntry); | |
} | |
if (Logger.isBrowser || _process?.stdout?.isTTY || forceColor) { | |
return logTTY(logEntry); | |
} | |
return logJson(logEntry); | |
}; | |
} | |
// Check if FORCE_COLOR environment variable is set | |
const forceColorEnv = ['true', 'on', 'yes', '1'].includes( | |
String(_process?.env?.FORCE_COLOR).toLowerCase(), | |
); | |
Logger.registerHandler(createDefaultLogger(forceColorEnv)); |
Example Sentry Logger
This extension forwards all log messages automatically to Sentry.io as so-called Breadcrumbs. This can be very helpful to reconstruct what exactly happened prior to an error. In fact, I rarely find myself looking at the raw logs anymore but can fully rely on Sentry to help me identify the root cause.
// createSentryLogger.tsx
import * as Sentry from '@sentry/minimal'
import { Severity } from '@sentry/types'
import { LogEntry, LogLevels } from "./Logger";
/** Forward logs to Sentry.io. By default only log levels WARNING and above will be forwarded. */
export default function createSentryLogger(minLevel: LogLevels = LogLevels.WARN, enableBreadcrumbs: boolean = true) {
const mapLevelToSeverity: { [key in LogLevels]: Severity | undefined } = {
[LogLevels.SILLY]: Severity.Debug,
[LogLevels.DEBUG]: Severity.Debug,
[LogLevels.INFO]: Severity.Info,
[LogLevels.WARN]: Severity.Warning,
[LogLevels.ERROR]: Severity.Error,
[LogLevels.CRITICAL]: Severity.Critical,
[LogLevels.NONE]: undefined
}
return function log(logEntry: LogEntry) {
const severity = mapLevelToSeverity[logEntry.level]
if (typeof severity !== 'undefined' && logEntry.level >= minLevel) {
const message = logEntry.message
const extras = logEntry.payload ? { ...logEntry.payload } : {}
const exception = extras.error instanceof Error ? extras.error : undefined
if (exception) {
delete extras.error
}
const captureContext = { extra: extras, level: severity, tags: { log_tag: logEntry.meta?.tag } }
if (message && message !== exception?.message) {
Sentry.captureMessage(message, captureContext)
}
if (exception) {
Sentry.captureException(exception, captureContext)
}
} else if (enableBreadcrumbs) {
Sentry.addBreadcrumb({
category: logEntry.meta?.tag,
message: logEntry.message,
data: logEntry.payload,
level: severity,
type: 'default'
})
}
}
}
Example Use
// index.ts
import Logger from "./Logger";
import createSentryLogger from "./createSentryLogger";
Logger.registerHandler(createSentryLogger());
// ... your main application code ...
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Logger
Example usage in Node.js
Example usage in Browser