import { styleText } from 'util'; const LOG_LEVELS = ['debug', 'info', 'warn', 'error', 'disabled'] as const; export type LogLevelName = (typeof LOG_LEVELS)[number]; const LOG_LEVEL_SEVERITY = Object.fromEntries(LOG_LEVELS.map((name, i) => [name, i])) as Record; const DEFAULT_LOG_LEVEL: LogLevelName = 'info'; function readEnvLogLevel(): LogLevelName { const value = process.env.INTEGRATION_LOG_LEVEL; if (value && (LOG_LEVELS as readonly string[]).includes(value)) { return value as LogLevelName; } return DEFAULT_LOG_LEVEL; } type PrimitiveValue = undefined | null | string | string[] | number | number[] | boolean | boolean[]; type Value = { [key: string]: PrimitiveValue | Value | PrimitiveValue[] | Value[]; }; export type Metadata = Value & { message?: never }; type ForbidenMetadataKey = 'message'; /** * See https://docs.datadoghq.com/logs/log_collection/?tab=host#custom-log-forwarding * - Datadog Agent splits at 256kB (256000 bytes)... * - ... but the same docs say that "for optimal performance, it is * recommended that an individual log be no greater than 25kB" * -> Truncating at 25kB - a bit of wiggle room for metadata = 20kB. */ const MAX_LOG_MESSAGE_SIZE = parseInt(process.env.MAX_LOG_MESSAGE_SIZE ?? '20000', 10); const LOG_LINE_TRUNCATED_SUFFIX = ' - LOG LINE TRUNCATED'; /** * For *LogMeta* sanitization, we let in anything that was passed, except for clearly-problematic keys */ const LOGMETA_BLACKLIST = [ // Security 'access_token', 'bot_auth_code', 'client_secret', 'jwt', 'oauth_token', 'password', 'refresh_token', 'shared_secret', 'token', // Privacy 'billing_email', 'email', 'first_name', 'last_name', ]; /** * Logger class that can be configured with metadata add creation and when logging to add additional context to your logs. */ export default class Logger { private metadata: Metadata; private level: LogLevelName; constructor(metadata: Metadata = {}) { this.metadata = structuredClone(metadata); this.level = readEnvLogLevel(); } /** * Sets the minimum log level. Messages below this level are suppressed. * @param level The minimum log level name. */ public setLevel(level: LogLevelName): void { this.level = level; } /** * Logs an error message with the 'error' log level. * @param message The error message to be logged. * @param metadata Optional metadata to be associated with the log message. */ public error(message: string, metadata?: Metadata): void { this.log('error', message, metadata); } /** * Logs a warning message with the 'warn' log level. * @param message The warning message to be logged. * @param metadata Optional metadata to be associated with the log message. */ public warn(message: string, metadata?: Metadata): void { this.log('warn', message, metadata); } /** * Logs an informational message with the 'info' log level. * @param message The informational message to be logged. * @param metadata Optional metadata to be associated with the log message. */ public info(message: string, metadata?: Metadata): void { this.log('info', message, metadata); } /** * Logs a debug message with the 'debug' log level. * @param message The debug message to be logged. * @param metadata Optional metadata to be associated with the log message. */ public debug(message: string, metadata?: Metadata): void { this.log('debug', message, metadata); } /** * Decorates the logger with additional metadata. * @param metadata Additional metadata to be added to the logger. */ public decorate(metadata: Metadata): void { this.metadata = { ...this.metadata, ...metadata }; } /** * Return a copy of the Logger's metadata. * @returns The {@link Metadata} associated with the logger. */ public getMetadata(): Metadata { return structuredClone(this.metadata); } /** * Sets a key-value pair in the metadata. If the key already exists, it will be overwritten. * * @param key Key of the metadata to be set. * May be any string other than 'message', which is reserved for the actual message logged. * @param value Value of the metadata to be set. */ public setMetadata( key: Key extends ForbidenMetadataKey ? never : Key, value: PrimitiveValue | Value, ): void { this.metadata[key] = value; } /** * Clears the Logger's metadata. */ public clearMetadata(): void { this.metadata = {}; } public log(logLevel: Exclude, message: string, metadata?: Metadata): void { if (LOG_LEVEL_SEVERITY[logLevel] < LOG_LEVEL_SEVERITY[this.level]) { return; } // We need to provide the date to Datadog. Otherwise, the date is set to when they receive the log. const date = Date.now(); if (message.length > MAX_LOG_MESSAGE_SIZE) { message = `${message.substring(0, MAX_LOG_MESSAGE_SIZE)}${LOG_LINE_TRUNCATED_SUFFIX}`; } let processedMetadata = Logger.snakifyKeys({ ...this.metadata, ...metadata, logMessageSize: message.length }); processedMetadata = Logger.pruneSensitiveMetadata(processedMetadata); const processedLogs = { ...processedMetadata, message, date, status: logLevel, }; if (process.env.NODE_ENV === 'development') { const coloredMessage = Logger.colorize(message, processedLogs, logLevel); const metadata = { date: new Date(processedLogs.date).toISOString(), ...(processedMetadata.error && { error: processedMetadata.error }), }; const metadataString = Object.keys(metadata).length > 1 ? ` ${JSON.stringify(metadata, null, 2)}` : ` ${JSON.stringify(metadata)}`; console[logLevel](`${coloredMessage}${metadataString}`); } else { console[logLevel](JSON.stringify(processedLogs)); } } private static snakifyKeys(value: Value): Value { const result: Value = {}; for (const key in value) { let deepValue; if (Array.isArray(value[key])) { if (value[key].every(v => typeof v === 'object')) { deepValue = value[key].map(item => this.snakifyKeys(item as Value)); } else { deepValue = value[key]; } } else if (typeof value[key] === 'object' && value[key] !== null) { deepValue = this.snakifyKeys(value[key] as Value); } else { deepValue = value[key]; } const snakifiedKey = key.replace(/[\w](? `${k[0]}_${k[1]}`).toLowerCase(); result[snakifiedKey] = deepValue; } return result; } private static pruneSensitiveMetadata(metadata: Value, topLevelMeta?: Value): Value { const prunedMetadata: Value = {}; for (const key in metadata) { if (LOGMETA_BLACKLIST.includes(key)) { prunedMetadata[key] = '[REDACTED]'; (topLevelMeta ?? prunedMetadata).has_sensitive_attribute = true; } else if (Array.isArray(metadata[key])) { if (metadata[key].every(value => typeof value === 'object')) { prunedMetadata[key] = metadata[key].map(value => Logger.pruneSensitiveMetadata(value as Value, topLevelMeta ?? prunedMetadata), ); } else { prunedMetadata[key] = metadata[key]; } } else if (typeof metadata[key] === 'object' && metadata[key] !== null) { prunedMetadata[key] = Logger.pruneSensitiveMetadata(metadata[key] as Value, topLevelMeta ?? prunedMetadata); } else { prunedMetadata[key] = metadata[key]; } } return prunedMetadata; } /** * Colorizes the log message based on the log level and status codes. * @param message The message to colorize. * @param metadata The metadata associated with the log. * @param logLevel The log level of the message. * @returns The colorized output string. */ private static colorize(message: string, metadata: Value, logLevel: LogLevelName): string { if (!process.stdout.isTTY) { return `${logLevel}: ${message}`; } // Extract status code from logs let statusCode: number | undefined; if (metadata.http && typeof metadata.http === 'object' && !Array.isArray(metadata.http)) { const statusCodeValue = metadata.http.status_code; if (typeof statusCodeValue === 'number') { statusCode = statusCodeValue; } else if (typeof statusCodeValue === 'string') { statusCode = parseInt(statusCodeValue, 10); } } // Color based on status code first if (statusCode) { if (statusCode >= 400) { return `${styleText('red', logLevel)}: ${styleText('red', message)}`; } else if (statusCode >= 300) { return `${styleText('yellow', logLevel)}: ${styleText('yellow', message)}`; } else if (statusCode >= 200) { return `${styleText('green', logLevel)}: ${styleText('green', message)}`; } } // Fall back to log level if no status code found switch (logLevel) { case 'error': return `${styleText('red', logLevel)}: ${styleText('red', message)}`; case 'warn': return `${styleText('yellow', logLevel)}: ${styleText('yellow', message)}`; case 'info': return `${styleText('green', logLevel)}: ${styleText('green', message)}`; case 'debug': return `${styleText('cyan', logLevel)}: ${styleText('cyan', message)}`; default: return `${logLevel}: ${message}`; } } } export const NULL_LOGGER = new Logger(); NULL_LOGGER.setLevel('disabled');