diff --git a/.prettierrc b/.prettierrc new file mode 100644 index 0000000..9756a1b --- /dev/null +++ b/.prettierrc @@ -0,0 +1,7 @@ +{ + "tabWidth": 2, + "useTabs": false, + "singleQuote": true, + "trailingComma": "all", + "printWidth": 125 +} diff --git a/src/logger.ts b/src/logger.ts index f34a8db..f37b408 100644 --- a/src/logger.ts +++ b/src/logger.ts @@ -9,172 +9,189 @@ import cj from 'color-json'; export { z } from 'zod'; const logLevelColours = { - error: 'red', - warn: 'yellow', - info: 'green', - verbose: 'blue', - debug: 'magenta', + error: 'red', + warn: 'yellow', + info: 'green', + verbose: 'blue', + debug: 'magenta', } as const; const colourLevel = (level: keyof typeof logLevelColours) => { - const colour = logLevelColours[level]; - return chalk[colour](level); + const colour = logLevelColours[level]; + return chalk[colour](level); }; declare const splatSymbol: unique symbol; type Meta = { - [splatSymbol]: unknown[]; + [splatSymbol]: unknown[]; }; +const inATerminal = process.stdout.isTTY; + const formatMeta = (meta: Meta) => { - const splats = meta[Symbol.for('splat') as typeof splatSymbol]; - const splat = (splats && splats.length > 0) ? splats.length === 1 ? splats[0] : splats : undefined; - if (!splat) return ''; - return Object.keys(splat).length >= 1 ? cj(JSON.stringify(splat)) : ''; + const splats = meta[Symbol.for('splat') as typeof splatSymbol]; + const splat = splats && splats.length > 0 ? (splats.length === 1 ? splats[0] : splats) : undefined; + if (!splat) return ''; + if (!inATerminal) return Object.keys(splat).length >= 1 ? JSON.stringify(splat) : ''; + return Object.keys(splat).length >= 1 ? cj(JSON.stringify(splat)) : ''; }; type SerialisedError = { - name: string; - message: string; - stack?: string; - cause?: SerialisedError; -} + name: string; + message: string; + stack?: string; + cause?: SerialisedError; +}; const serialiseError = (error: Error): SerialisedError => ({ - name: error.name, - message: error.message, - stack: error.stack, - cause: error.cause ? serialiseError(error.cause as Error) : undefined, + name: error.name, + message: error.message, + stack: error.stack, + cause: error.cause ? serialiseError(error.cause as Error) : undefined, }); type LogLevel = 'debug' | 'info' | 'warn' | 'error'; export type BaseSchema = { - [level in LogLevel]?: Record>; + [level in LogLevel]?: Record>; }; type Options = { - service: string; - schema?: Schema; - defaultMeta?: Record; -} + service: string; + schema?: Schema; + defaultMeta?: Record; +}; -type MetaForSchema = - Message extends keyof Schema[Level] - ? Schema[Level][Message] extends z.ZodType +type MetaForSchema = Message extends keyof Schema[Level] + ? Schema[Level][Message] extends z.ZodType ? z.input : undefined - : never; + : never; type DebugMeta = MetaForSchema; type InfoMeta = MetaForSchema; type WarnMeta = MetaForSchema; type ErrorMeta = { - error: Error; + error: Error; } & MetaForSchema; export class Logger { - private logger: WinstonLogger; - private schema?: Schema; - - constructor(options: Options) { - const logLevel = process.env.LOG_LEVEL ? (['silly', 'info', 'debug', 'warn', 'error'].includes(process.env.LOG_LEVEL) ? process.env.LOG_LEVEL : 'silly') : 'silly'; - this.logger = createLogger({ - level: logLevel, - format: format.combine( - format.errors({ stack: true }), - format.json() - ), - defaultMeta: { - ...(options.defaultMeta ?? {}), - name: pkg.name, - pid: process.pid, - commitHash: getCommitHash(), - service: options.service, - }, - transports: [], - }); - - // Don't log while running tests - // This allows the methods to still be hooked - // while not messing up the test output - if (process.env.NODE_ENV === 'test') { - this.logger.silent = true; - } - - // Use Axiom for logging if all the needed envs are provided - if (process.env.AXIOM_ORG_ID && process.env.AXIOM_DATASET && process.env.AXIOM_TOKEN) { - this.logger.add(new AxiomTransport({ - // Exception and rejection handling is not optional - // Allowing this to be optional is a mistake waiting to happen - handleExceptions: true, - handleRejections: true, - token: process.env.AXIOM_TOKEN, - })); - } - - // Add the console logger if we're not running tests, there are no transports or the user has added it to the `TRANSPORTS` env - if (process.env.NODE_ENV !== 'test' && (this.logger.transports.length === 0 || process.env.TRANSPORTS?.split(',').map(_ => _.toLowerCase()).includes('console'))) { - this.logger.add( - new transports.Console({ - format: format.combine( - format.timestamp(), - format.printf(({ service, level, message, timestamp, ...meta }) => { - const formattedDate = new Date(timestamp as string).toLocaleTimeString('en'); - const serviceName = (service as string) ?? 'app'; - const formattedLevel = colourLevel(level as keyof typeof logLevelColours); - const formattedMeta = formatMeta(meta as Meta); - return `${formattedDate} [${serviceName}] [${formattedLevel}]: ${message as string} ${formattedMeta}`.trim(); - }), - ), - // Exception and rejection handling is not optional - // Allowing this to be optional is a mistake waiting to happen - handleExceptions: true, - handleRejections: true, - }), - ); - } - - // Save the schema if we have one - this.schema = options.schema; - } - - private log(level: LogLevel, message: Message, data: (Schema[LogLevel][Message] extends z.ZodType ? z.input : undefined)) { - // Ensure meta is valid before logging - const parser = this.schema?.[level]?.[message as string]; - const parsedData = parser?.safeParse(data); - // This ensures that we never go over the limit of keys in axiom - // NOTE: We can always use `json_parse` in axiom to manage the data later. - const meta = parsedData?.success ? parsedData.data : { - data: JSON.stringify(data), - error: parsedData?.error - }; - - // Call the actual logger - this.logger[level](message as string, meta); + private logger: WinstonLogger; + private schema?: Schema; + + constructor(options: Options) { + const logLevel = process.env.LOG_LEVEL + ? ['silly', 'info', 'debug', 'warn', 'error'].includes(process.env.LOG_LEVEL) + ? process.env.LOG_LEVEL + : 'silly' + : 'silly'; + this.logger = createLogger({ + level: logLevel, + format: format.combine(format.errors({ stack: true }), format.json()), + defaultMeta: { + ...(options.defaultMeta ?? {}), + name: pkg.name, + pid: process.pid, + commitHash: getCommitHash(), + service: options.service, + }, + transports: [], + }); + + // Don't log while running tests + // This allows the methods to still be hooked + // while not messing up the test output + if (process.env.NODE_ENV === 'test') { + this.logger.silent = true; } - debug(message: Message, meta: DebugMeta) { - this.log('debug', message, meta); + // Use Axiom for logging if all the needed envs are provided + if (process.env.AXIOM_ORG_ID && process.env.AXIOM_DATASET && process.env.AXIOM_TOKEN) { + this.logger.add( + new AxiomTransport({ + // Exception and rejection handling is not optional + // Allowing this to be optional is a mistake waiting to happen + handleExceptions: true, + handleRejections: true, + token: process.env.AXIOM_TOKEN, + }), + ); } - info(message: Message, meta: InfoMeta) { - this.log('info', message, meta); + // Add the console logger if we're not running tests, there are no transports or the user has added it to the `TRANSPORTS` env + if ( + process.env.NODE_ENV !== 'test' && + (this.logger.transports.length === 0 || + process.env.TRANSPORTS?.split(',') + .map((_) => _.toLowerCase()) + .includes('console')) + ) { + this.logger.add( + new transports.Console({ + format: format.combine( + format.timestamp(), + format.printf(({ service, level, message, timestamp, ...meta }) => { + const formattedDate = new Date(timestamp as string).toLocaleTimeString('en'); + const serviceName = (service as string) ?? 'app'; + const formattedLevel = colourLevel(level as keyof typeof logLevelColours); + const formattedMeta = formatMeta(meta as Meta); + return `${formattedDate} [${serviceName}] [${formattedLevel}]: ${message as string} ${formattedMeta}`.trim(); + }), + ), + // Exception and rejection handling is not optional + // Allowing this to be optional is a mistake waiting to happen + handleExceptions: true, + handleRejections: true, + }), + ); } - warn(message: Message, meta: WarnMeta) { - this.log('warn', message, meta); - } + // Save the schema if we have one + this.schema = options.schema; + } + + private log( + level: LogLevel, + message: Message, + data: Schema[LogLevel][Message] extends z.ZodType ? z.input : undefined, + ) { + // Ensure meta is valid before logging + const parser = this.schema?.[level]?.[message as string]; + const parsedData = parser?.safeParse(data); + // This ensures that we never go over the limit of keys in axiom + // NOTE: We can always use `json_parse` in axiom to manage the data later. + const meta = parsedData?.success + ? parsedData.data + : { + data: JSON.stringify(data), + error: parsedData?.error, + }; - error(message: Message, meta: ErrorMeta) { - // If the error isn't an error object make it so - // This is to prevent issues where something other than an Error is thrown - // When passing this to transports like Axiom it really needs to be a real Error class - if (meta?.error && !(meta?.error instanceof Error)) meta.error = new Error(`Unknown Error: ${String(meta.error)}`); - this.log('error', message, { - ...meta, - error: serialiseError(meta?.error as Error) - }); - } + // Call the actual logger + this.logger[level](message as string, meta); + } + + debug(message: Message, meta: DebugMeta) { + this.log('debug', message, meta); + } + + info(message: Message, meta: InfoMeta) { + this.log('info', message, meta); + } + + warn(message: Message, meta: WarnMeta) { + this.log('warn', message, meta); + } + + error(message: Message, meta: ErrorMeta) { + // If the error isn't an error object make it so + // This is to prevent issues where something other than an Error is thrown + // When passing this to transports like Axiom it really needs to be a real Error class + if (meta?.error && !(meta?.error instanceof Error)) meta.error = new Error(`Unknown Error: ${String(meta.error)}`); + this.log('error', message, { + ...meta, + error: serialiseError(meta?.error as Error), + }); + } }