Skip to main content
Glama
logger.ts12 kB
// SPDX-FileCopyrightText: Copyright Orangebot, Inc. and Medplum contributors // SPDX-License-Identifier: Apache-2.0 import type { ILogger, ILoggerConfig, LoggerOptions, LogMessage } from '@medplum/core'; import { isObject, LogLevel, LogLevelNames, normalizeErrorString, parseLogLevel, splitN } from '@medplum/core'; import { normalize } from 'node:path'; import winston from 'winston'; import 'winston-daily-rotate-file'; import { DEFAULT_LOG_LIMIT, MAX_LOG_LIMIT } from './constants'; import type { AgentArgs } from './types'; export const LoggerType = { MAIN: 'main', CHANNEL: 'channel', } as const; export type LoggerType = (typeof LoggerType)[keyof typeof LoggerType]; export const DEFAULT_LOGGER_CONFIG = { logDir: __dirname, maxFileSizeMb: 10, filesToKeep: 10, logLevel: LogLevel.INFO, } as const satisfies AgentLoggerConfig; export const LOGGER_CONFIG_INTEGER_KEYS = ['maxFileSizeMb', 'filesToKeep'] as const; export type LoggerConfigIntegerKey = (typeof LOGGER_CONFIG_INTEGER_KEYS)[number]; const LEVELS_TO_UPPERCASE = { debug: 'DEBUG', info: 'INFO', warn: 'WARN', error: 'ERROR', } as const; export type ValidWinstonLogLevel = keyof typeof LEVELS_TO_UPPERCASE; export const LOGGER_CONFIG_KEYS = [ 'logDir', 'maxFileSizeMb', 'filesToKeep', 'logLevel', ] as const satisfies (keyof typeof DEFAULT_LOGGER_CONFIG)[]; export type LoggerConfigKey = (typeof LOGGER_CONFIG_KEYS)[number]; export interface AgentLoggerConfig { logDir: string; maxFileSizeMb: number; filesToKeep: number; logLevel: LogLevel; } export interface AgentMultiLoggerConfig { main: AgentLoggerConfig; channel: AgentLoggerConfig; } export interface PartialAgentMultiLoggerConfig { main?: Partial<AgentLoggerConfig>; channel?: Partial<AgentLoggerConfig>; } export interface WinstonWrapperLoggerOptions extends LoggerOptions { metadata?: Record<string, any>; } export interface WinstonWrapperLoggerInitOptions extends WinstonWrapperLoggerOptions { parentLogger?: WinstonWrapperLogger; } export interface FetchLogsOptions { limit?: number; } export function cleanupLoggerConfig(config: Partial<AgentLoggerConfig>, configPathRoot: string = 'config'): string[] { const warnings = []; if (typeof config.logDir !== 'undefined' && !(typeof config.logDir === 'string' && config.logDir.length > 0)) { warnings.push(`${configPathRoot}.logDir must be a valid filepath string`); // Cleanup invalid logger config prop config.logDir = undefined; } if ( typeof config.maxFileSizeMb !== 'undefined' && !(typeof config.maxFileSizeMb === 'number' && config.maxFileSizeMb > 0 && Number.isInteger(config.maxFileSizeMb)) ) { warnings.push(`${configPathRoot}.maxFileSizeMb must be a valid integer`); // Cleanup invalid logger config prop config.maxFileSizeMb = undefined; } if ( typeof config.filesToKeep !== 'undefined' && !(typeof config.filesToKeep === 'number' && config.filesToKeep > 0 && Number.isInteger(config.filesToKeep)) ) { warnings.push(`${configPathRoot}.filesToKeep must be a valid integer`); // Cleanup invalid logger config prop config.filesToKeep = undefined; } if ( typeof config.logLevel !== 'undefined' && !(typeof config.logLevel === 'number' && LogLevelNames[config.logLevel] !== undefined) ) { warnings.push(`${configPathRoot}.logLevel must be a valid log level between LogLevel.NONE and LogLevel.DEBUG`); // Cleanup invalid logger config prop config.logLevel = undefined; } return warnings; } export function cleanupMultiLoggerConfig(candidate: unknown): string[] { const warnings = []; const fullConfig = candidate as AgentMultiLoggerConfig; for (const configType of ['main', 'channel'] as const) { if (!isObject(fullConfig[configType])) { warnings.push(`config.${configType} is not a valid object`); // Cleanup invalid logger config fullConfig[configType] = {} as AgentLoggerConfig; continue; } warnings.push(...cleanupLoggerConfig(fullConfig[configType], `logger.${configType}`)); } return warnings; } export function mergeLoggerConfigWithDefaults( config: PartialAgentMultiLoggerConfig ): asserts config is AgentMultiLoggerConfig { config.main ??= DEFAULT_LOGGER_CONFIG; config.channel ??= DEFAULT_LOGGER_CONFIG; for (const configType of ['main', 'channel'] as const) { for (const [key, value] of Object.entries(DEFAULT_LOGGER_CONFIG) as unknown as [ keyof AgentLoggerConfig, number | string, ][]) { (config[configType] as Partial<AgentLoggerConfig>)[key] ??= value as any; // We expect that this value matches the type for the given key } } } export function parseLoggerConfigFromArgs(args: AgentArgs): [AgentMultiLoggerConfig, string[]] { const config: { main: Partial<AgentLoggerConfig>; channel: Partial<AgentLoggerConfig> } = { main: {}, channel: {}, } as const; const warnings = [] as string[]; for (const [propName, propVal] of Object.entries(args)) { // Skip args not pertaining to the logger, or that do not have defined values if (!propName.startsWith('logger.') || propVal === undefined) { continue; } // 'logger', [prefix], [name] const [_, configType, settingName] = splitN(propName, '.', 3) as ['logger', 'main' | 'channel', LoggerConfigKey]; if (!LOGGER_CONFIG_KEYS.includes(settingName)) { warnings.push(`${propName} is not a valid setting name`); } // If the setting is 'logLevel', we should convert to the LogLevel enum let configValue: string | number | undefined; if (settingName === 'logLevel') { try { configValue = parseLogLevel(propVal); } catch (err) { // Invalid log level warnings.push(`Error while parsing ${propName}: ${normalizeErrorString(err)}`); } } else if (LOGGER_CONFIG_INTEGER_KEYS.includes(settingName as LoggerConfigIntegerKey)) { try { configValue = Number.parseInt(propVal, 10); } catch (_err) { warnings.push(`Error while parsing ${propName}: ${propVal} is not a valid integer`); } } else { configValue = propVal; } if (configType === 'main') { config.main[settingName] = configValue as any; } else if (configType === 'channel') { config.channel[settingName] = configValue as any; } else { warnings.push(`${configType} is not a valid config type, must be main or channel`); } } warnings.push(...cleanupMultiLoggerConfig(config)); mergeLoggerConfigWithDefaults(config); return [config, warnings]; } export function getWinstonLevelFromMedplumLevel(level: LogLevel): string { switch (level) { // Return error for NONE since we are going to turn silent on anyways case LogLevel.NONE: case LogLevel.ERROR: return 'error'; case LogLevel.WARN: return 'warn'; case LogLevel.INFO: return 'info'; case LogLevel.DEBUG: return 'debug'; default: throw new Error('Invalid log level'); } } export function createWinstonFromLoggerConfig(config: AgentLoggerConfig, loggerType: LoggerType): winston.Logger { const level = getWinstonLevelFromMedplumLevel(config.logLevel); // When testing, just use the default config - it pipes raw JSON to stdout const logger = winston.createLogger({ level, silent: config.logLevel === LogLevel.NONE, format: winston.format.combine( winston.format.timestamp(), // Custom transform to match previous Medplum logger output { transform: (info) => { const { message, level, ...otherProps } = info; return { ...otherProps, level: LEVELS_TO_UPPERCASE[level as ValidWinstonLogLevel], msg: message, } as unknown as winston.Logform.TransformableInfo; }, }, winston.format.json() ), transports: [new winston.transports.Console({ forceConsole: true })], }); if (process.env.NODE_ENV !== 'test') { const dailyRotateTransport = new winston.transports.DailyRotateFile({ filename: `${loggerType === LoggerType.MAIN ? 'medplum-agent-main' : 'medplum-agent-channels'}-%DATE%.log`, dirname: normalize(config.logDir), maxSize: `${config.maxFileSizeMb}m`, maxFiles: config.filesToKeep, json: true, }); // Log any errors that happen // This is important for debugging broken logger configurations that are not outputting logs dailyRotateTransport.on('error', (err: unknown) => { console.error('Error in winston transport', err); }); logger.add(dailyRotateTransport); } return logger; } export function isWinstonWrapperLogger(logger: ILogger): logger is WinstonWrapperLogger { return logger instanceof WinstonWrapperLogger; } export class WinstonWrapperLogger implements ILogger { readonly loggerType: LoggerType; private readonly parentLogger?: WinstonWrapperLogger; private readonly config: AgentLoggerConfig; private readonly metadata?: Record<string, any>; private readonly prefix?: string; private readonly winston: winston.Logger; level: LogLevel; constructor(config: AgentLoggerConfig, loggerType: LoggerType, options?: WinstonWrapperLoggerInitOptions) { this.loggerType = loggerType; this.parentLogger = options?.parentLogger; this.winston = this.parentLogger ? this.parentLogger.getWinston() : createWinstonFromLoggerConfig(config, loggerType); this.config = config; this.level = config.logLevel; this.metadata = options?.metadata; this.prefix = options?.prefix; } debug(msg: string, data?: Record<string, any> | Error): void { this.log(LogLevel.DEBUG, msg, data); } info(msg: string, data?: Record<string, any> | Error): void { this.log(LogLevel.INFO, msg, data); } warn(msg: string, data?: Record<string, any> | Error): void { this.log(LogLevel.WARN, msg, data); } error(msg: string, data?: Record<string, any> | Error): void { this.log(LogLevel.ERROR, msg, data); } log(level: LogLevel, msg: string, data?: Record<string, any> | Error): void { if (level > this.level) { return; } if (data instanceof Error) { data = { error: data.toString(), stack: data.stack?.split('\n'), }; } const dataToLog = { ...data, ...this.metadata }; const msgToLog = this.prefix ? `${this.prefix}${msg}` : msg; switch (level) { case LogLevel.DEBUG: this.winston.debug(msgToLog, dataToLog); return; case LogLevel.INFO: this.winston.info(msgToLog, dataToLog); return; case LogLevel.WARN: this.winston.warn(msgToLog, dataToLog); return; case LogLevel.ERROR: this.winston.error(msgToLog, dataToLog); } } clone(override?: Partial<ILoggerConfig>): WinstonWrapperLogger { return new WinstonWrapperLogger(this.config, this.loggerType, { parentLogger: this.parentLogger ?? this, prefix: override?.options?.prefix ?? this.prefix, metadata: override?.metadata ?? this.metadata, }); } getWinston(): winston.Logger { return this.winston; } async fetchLogs(options?: FetchLogsOptions): Promise<LogMessage[]> { if ( options?.limit !== undefined && (typeof options.limit !== 'number' || options.limit <= 0 || options.limit > MAX_LOG_LIMIT) ) { throw new Error( `Invalid limit: ${options.limit} - must be a valid positive integer less than or equal to ${MAX_LOG_LIMIT}` ); } const limit = options?.limit ?? DEFAULT_LOG_LIMIT; return new Promise((resolve, reject) => { this.winston.query( { order: 'desc', limit, fields: ['level', 'msg', 'timestamp'] }, (err, results: { dailyRotateFile: LogMessage[] }) => { if (err) { reject(err); return; } resolve(results.dailyRotateFile); } ); }); } }

Latest Blog Posts

MCP directory API

We provide all the information about MCP servers via our MCP API.

curl -X GET 'https://glama.ai/api/mcp/v1/servers/medplum/medplum'

If you have feedback or need assistance with the MCP directory API, please join our Discord server