UNPKG

@unito/integration-sdk

Version:

Integration SDK

278 lines (239 loc) 8.96 kB
import { styleText } from 'util'; const enum LogLevel { ERROR = 'error', WARN = 'warn', INFO = 'info', LOG = 'log', DEBUG = 'debug', } 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; constructor( metadata: Metadata = {}, private isDisabled: boolean = false, ) { this.metadata = structuredClone(metadata); } /** * Logs a message with the 'log' log level. * @param message The message to be logged. * @param metadata Optional metadata to be associated with the log message. */ public log(message: string, metadata?: Metadata): void { this.send(LogLevel.LOG, message, metadata); } /** * 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.send(LogLevel.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.send(LogLevel.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.send(LogLevel.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.send(LogLevel.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 extends string>( key: Key extends ForbidenMetadataKey ? never : Key, value: PrimitiveValue | Value, ): void { this.metadata[key] = value; } /** * Clears the Logger's metadata. */ public clearMetadata(): void { this.metadata = {}; } private send(logLevel: LogLevel, message: string, metadata?: Metadata): void { if (this.isDisabled) { 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])) { deepValue = value[key].map(item => this.snakifyKeys(item as Value)); } 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](?<!_)([A-Z])/g, k => `${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])) { prunedMetadata[key] = metadata[key].map(value => Logger.pruneSensitiveMetadata(value as Value, topLevelMeta ?? prunedMetadata), ); } 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: LogLevel): 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 LogLevel.ERROR: return `${styleText('red', logLevel)}: ${styleText('red', message)}`; case LogLevel.WARN: return `${styleText('yellow', logLevel)}: ${styleText('yellow', message)}`; case LogLevel.INFO: case LogLevel.LOG: return `${styleText('green', logLevel)}: ${styleText('green', message)}`; case LogLevel.DEBUG: return `${styleText('cyan', logLevel)}: ${styleText('cyan', message)}`; default: return `${logLevel}: ${message}`; } } } export const NULL_LOGGER = new Logger({}, true);