import format from 'date-fns/format' import {nanoid} from 'nanoid/non-secure' import {Sentry} from '#/logger/sentry' import * as env from '#/env' import {DebugContext} from '#/logger/debugContext' import {add} from '#/logger/logDump' export enum LogLevel { Debug = 'debug', Info = 'info', Log = 'log', Warn = 'warn', Error = 'error', } type Transport = ( level: LogLevel, message: string | Error, metadata: Metadata, timestamp: number, ) => void /** * A union of some of Sentry's breadcrumb properties as well as Sentry's * `captureException` parameter, `CaptureContext`. */ type Metadata = { /** * Applied as Sentry breadcrumb types. Defaults to `default`. * * @see https://develop.sentry.dev/sdk/event-payloads/breadcrumbs/#breadcrumb-types */ type?: | 'default' | 'debug' | 'error' | 'navigation' | 'http' | 'info' | 'query' | 'transaction' | 'ui' | 'user' /** * Passed through to `Sentry.captureException` * * @see https://github.com/getsentry/sentry-javascript/blob/903addf9a1a1534a6cb2ba3143654b918a86f6dd/packages/types/src/misc.ts#L65 */ tags?: { [key: string]: | number | string | boolean | bigint | symbol | null | undefined } /** * Any additional data, passed through to Sentry as `extra` param on * exceptions, or the `data` param on breadcrumbs. */ [key: string]: unknown } & Parameters[1] export type ConsoleTransportEntry = { id: string timestamp: number level: LogLevel message: string | Error metadata: Metadata } const enabledLogLevels: { [key in LogLevel]: LogLevel[] } = { [LogLevel.Debug]: [ LogLevel.Debug, LogLevel.Info, LogLevel.Log, LogLevel.Warn, LogLevel.Error, ], [LogLevel.Info]: [LogLevel.Info, LogLevel.Log, LogLevel.Warn, LogLevel.Error], [LogLevel.Log]: [LogLevel.Log, LogLevel.Warn, LogLevel.Error], [LogLevel.Warn]: [LogLevel.Warn, LogLevel.Error], [LogLevel.Error]: [LogLevel.Error], } export function prepareMetadata(metadata: Metadata): Metadata { return Object.keys(metadata).reduce((acc, key) => { let value = metadata[key] if (value instanceof Error) { value = value.toString() } return {...acc, [key]: value} }, {}) } /** * Used in dev mode to nicely log to the console */ export const consoleTransport: Transport = ( level, message, metadata, timestamp, ) => { const extra = Object.keys(metadata).length ? ' ' + JSON.stringify(prepareMetadata(metadata), null, ' ') : '' const log = { [LogLevel.Debug]: console.debug, [LogLevel.Info]: console.info, [LogLevel.Log]: console.log, [LogLevel.Warn]: console.warn, [LogLevel.Error]: console.error, }[level] if (message instanceof Error) { console.info( `${format(timestamp, 'HH:mm:ss')} ${message.toString()}${extra}`, ) log(message) } else { log(`${format(timestamp, 'HH:mm:ss')} ${message.toString()}${extra}`) } } export const sentryTransport: Transport = ( level, message, {type, tags, ...metadata}, timestamp, ) => { const meta = prepareMetadata(metadata) /** * If a string, report a breadcrumb */ if (typeof message === 'string') { const severity = ( { [LogLevel.Debug]: 'debug', [LogLevel.Info]: 'info', [LogLevel.Log]: 'log', // Sentry value here is undefined [LogLevel.Warn]: 'warning', [LogLevel.Error]: 'error', } as const )[level] Sentry.addBreadcrumb({ message, data: meta, type: type || 'default', level: severity, timestamp: timestamp / 1000, // Sentry expects seconds }) /** * Send all higher levels with `captureMessage`, with appropriate severity * level */ if (level === 'error' || level === 'warn' || level === 'log') { const messageLevel = ({ [LogLevel.Log]: 'log', [LogLevel.Warn]: 'warning', [LogLevel.Error]: 'error', }[level] || 'log') as Sentry.Breadcrumb['level'] // Defer non-critical messages so they're sent in a batch queueMessageForSentry(message, { level: messageLevel, tags, extra: meta, }) } } else { /** * It's otherwise an Error and should be reported with captureException */ Sentry.captureException(message, { tags, extra: meta, }) } } const queuedMessages: [string, Parameters[1]][] = [] let sentrySendTimeout: ReturnType | null = null function queueMessageForSentry( message: string, captureContext: Parameters[1], ) { queuedMessages.push([message, captureContext]) if (!sentrySendTimeout) { // Throttle sending messages with a leading delay // so that we can get Sentry out of the critical path. sentrySendTimeout = setTimeout(() => { sentrySendTimeout = null sendQueuedMessages() }, 7000) } } function sendQueuedMessages() { while (queuedMessages.length > 0) { const record = queuedMessages.shift() if (record) { Sentry.captureMessage(record[0], record[1]) } } } /** * Main class. Defaults are provided in the constructor so that subclasses are * technically possible, if we need to go that route in the future. */ export class Logger { LogLevel = LogLevel DebugContext = DebugContext enabled: boolean level: LogLevel transports: Transport[] = [] protected debugContextRegexes: RegExp[] = [] constructor({ enabled = !env.IS_TEST, level = env.LOG_LEVEL as LogLevel, debug = env.LOG_DEBUG || '', }: { enabled?: boolean level?: LogLevel debug?: string } = {}) { this.enabled = enabled !== false this.level = debug ? LogLevel.Debug : level ?? LogLevel.Info // default to info this.debugContextRegexes = (debug || '').split(',').map(context => { return new RegExp(context.replace(/[^\w:*]/, '').replace(/\*/g, '.*')) }) } debug(message: string, metadata: Metadata = {}, context?: string) { if (context && !this.debugContextRegexes.find(reg => reg.test(context))) return this.transport(LogLevel.Debug, message, metadata) } info(message: string, metadata: Metadata = {}) { this.transport(LogLevel.Info, message, metadata) } log(message: string, metadata: Metadata = {}) { this.transport(LogLevel.Log, message, metadata) } warn(message: string, metadata: Metadata = {}) { this.transport(LogLevel.Warn, message, metadata) } error(error: Error | string, metadata: Metadata = {}) { this.transport(LogLevel.Error, error, metadata) } addTransport(transport: Transport) { this.transports.push(transport) return () => { this.transports.splice(this.transports.indexOf(transport), 1) } } disable() { this.enabled = false } enable() { this.enabled = true } protected transport( level: LogLevel, message: string | Error, metadata: Metadata = {}, ) { if (!this.enabled) return const timestamp = Date.now() const meta = metadata || {} // send every log to syslog add({ id: nanoid(), timestamp, level, message, metadata: meta, }) if (!enabledLogLevels[this.level].includes(level)) return for (const transport of this.transports) { transport(level, message, meta, timestamp) } } } /** * Logger instance. See `@/logger/README` for docs. * * Basic usage: * * `logger.debug(message[, metadata, debugContext])` * `logger.info(message[, metadata])` * `logger.warn(message[, metadata])` * `logger.error(error[, metadata])` * `logger.disable()` * `logger.enable()` */ export const logger = new Logger() if (env.IS_DEV && !env.IS_TEST) { logger.addTransport(consoleTransport) /* * Comment this out to disable Sentry transport in dev */ // logger.addTransport(sentryTransport) } else if (env.IS_PROD) { logger.addTransport(sentryTransport) }