335 lines
7.9 KiB
TypeScript
335 lines
7.9 KiB
TypeScript
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<typeof Sentry.captureException>[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<typeof Sentry.captureMessage>[1]][] =
|
|
[]
|
|
let sentrySendTimeout: ReturnType<typeof setTimeout> | null = null
|
|
function queueMessageForSentry(
|
|
message: string,
|
|
captureContext: Parameters<typeof Sentry.captureMessage>[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)
|
|
}
|