@unito/integration-sdk
Version:
Integration SDK
278 lines (239 loc) • 8.96 kB
text/typescript
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);