[go: up one dir, main page]

Skip to content

Instantly share code, notes, and snippets.

@arabold
Last active October 27, 2024 16:52
Show Gist options
  • Save arabold/3a56def70f451c74956c26e1720a9778 to your computer and use it in GitHub Desktop.
Save arabold/3a56def70f451c74956c26e1720a9778 to your computer and use it in GitHub Desktop.
Simple, opinionated Logger for use with Node.js and Web Browsers
/**
* 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));
@arabold
Copy link
Author
arabold commented Nov 10, 2019

Logger

  • Written in TypeScript for a clean and self-explanatory API.
  • Supports and automatically detects Node.js, Electron, Browser, Web Worker, React and React Native environments.
  • Outputs colorful logs when running in the browser or in a Terminal window to make logging friendly and useful.
  • Outputs JSON structures with added details when running without TTY for easier processing and analysis.
  • Automatically chooses suitable, human, or machine-readable output format depending on the environment.
  • Zero 3rd party dependencies (not counting TypeScript).

Example usage in Node.js

import Logger from "./logger";

const logger = Logger.create(__filename);

function myFunc() {
  try {
    logger.info("Hello!");
    logger.info("With payload:", { foo: "bar" });
    // ...
  }
  catch (error) {
    logger.error("Oh no!", { error });
  }
}

Example usage in Browser

import Logger from "./logger";

const logger = Logger.create("myFunc");

function myFunc() {
  try {
    logger.info("Hello!");
    logger.info("With payload:", { foo: "bar" });
    // ...
  }
  catch (error) {
    logger.error("Oh no!", { error });
  }
}

@arabold
Copy link
Author
arabold commented Mar 6, 2020

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