Skip to main content
Glama
logger.tsβ€’14.5 kB
/** * Comprehensive E2E Test Logger * * Provides detailed logging for E2E tests with structured JSON output, * API request/response tracking, timing information, and error context. * * Features: * - Structured JSON logging for easy parsing and analysis * - API request/response logging with timing * - Test data lifecycle tracking * - Error logging with full context and stack traces * - Sanitized parameter logging (removes sensitive data) * - Separate log files per test suite * - Performance metrics and statistics */ import { writeFileSync, appendFileSync, existsSync, mkdirSync } from 'fs'; import { join } from 'path'; import type { LogParameters, LogResponse, LogMetadata, ApiError, } from '../types/index.js'; export interface LogEntry { timestamp: string; testSuite: string; testName?: string; operation: | 'tool_call' | 'test_data_creation' | 'test_data_cleanup' | 'error' | 'info'; toolName?: string; parameters?: LogParameters; response?: LogResponse; timing?: { start: number; end: number; duration: number; }; success: boolean; error?: ApiError; metadata?: LogMetadata; } export interface TestRunSummary { testSuite: string; startTime: string; endTime?: string; totalTests: number; passedTests: number; failedTests: number; totalApiCalls: number; successfulApiCalls: number; failedApiCalls: number; averageResponseTime: number; createdRecords: Array<{ type: string; id: string; timestamp: string }>; errors: Array<{ timestamp: string; message: string; testName?: string }>; } class E2ELogger { private logsDir: string; private currentTestSuite?: string; private currentTestRun?: TestRunSummary; private apiCallTimes: number[] = []; private runId?: string; // Safely extract ApiError fields from unknown errors private extractApiError(err: unknown): ApiError { const base: ApiError = { message: err instanceof Error ? err.message : typeof err === 'string' ? err : 'Unknown error', stack: err instanceof Error ? err.stack : undefined, }; if (err && typeof err === 'object' && 'code' in err) { const codeVal = (err as Record<string, unknown>).code; if (typeof codeVal === 'string') { base.code = codeVal; } } return base; } constructor() { // Create logs directory in test/e2e/outputs (logs was removed in cleanup) this.logsDir = join(process.cwd(), 'test', 'e2e', 'outputs'); this.ensureLogsDirectory(); } private ensureLogsDirectory(): void { if (!existsSync(this.logsDir)) { mkdirSync(this.logsDir, { recursive: true }); } } /** * Start logging for a test suite */ startTestSuite(testSuiteName: string): void { this.currentTestSuite = testSuiteName; this.currentTestRun = { testSuite: testSuiteName, startTime: new Date().toISOString(), totalTests: 0, passedTests: 0, failedTests: 0, totalApiCalls: 0, successfulApiCalls: 0, failedApiCalls: 0, averageResponseTime: 0, createdRecords: [], errors: [], }; this.log({ timestamp: new Date().toISOString(), testSuite: testSuiteName, operation: 'info', success: true, metadata: { message: `Starting test suite: ${testSuiteName}` }, }); } /** * End logging for a test suite and write summary */ endTestSuite(): void { if (!this.currentTestRun || !this.currentTestSuite) return; this.currentTestRun.endTime = new Date().toISOString(); this.currentTestRun.averageResponseTime = this.apiCallTimes.length > 0 ? this.apiCallTimes.reduce((a, b) => a + b, 0) / this.apiCallTimes.length : 0; // Write test run summary const summaryFile = join( this.logsDir, `${this.currentTestSuite}-summary-${Date.now()}.json` ); writeFileSync(summaryFile, JSON.stringify(this.currentTestRun, null, 2)); this.log({ timestamp: new Date().toISOString(), testSuite: this.currentTestSuite, operation: 'info', success: true, metadata: { message: `Completed test suite: ${this.currentTestSuite}`, summary: this.currentTestRun, }, }); // Reset for next test suite this.currentTestSuite = undefined; this.currentTestRun = undefined; this.apiCallTimes = []; } /** * Log a tool call with timing and response data */ logToolCall( toolName: string, parameters: Record<string, unknown>, response: any, timing: { start: number; end: number }, testName?: string, error?: Error ): void { const duration = timing.end - timing.start; this.apiCallTimes.push(duration); if (this.currentTestRun) { this.currentTestRun.totalApiCalls++; if (error) { this.currentTestRun.failedApiCalls++; } else { this.currentTestRun.successfulApiCalls++; } } const logEntry: LogEntry = { timestamp: new Date().toISOString(), testSuite: this.currentTestSuite || 'unknown', testName, operation: 'tool_call', toolName, parameters: this.sanitizeParameters(parameters), response: this.sanitizeResponse(response), timing: { start: timing.start, end: timing.end, duration, }, success: !error, }; if (error) { logEntry.error = this.extractApiError(error); if (this.currentTestRun) { this.currentTestRun.errors.push({ timestamp: logEntry.timestamp, message: error.message, testName, }); } } this.log(logEntry); } /** * Log test data creation */ logTestDataCreation( type: string, id: string, data: any, testName?: string ): void { if (this.currentTestRun) { this.currentTestRun.createdRecords.push({ type, id, timestamp: new Date().toISOString(), }); } this.log({ timestamp: new Date().toISOString(), testSuite: this.currentTestSuite || 'unknown', testName, operation: 'test_data_creation', success: true, metadata: { recordType: type, recordId: id, recordData: this.sanitizeResponse(data), }, }); } /** * Log test data cleanup */ logTestDataCleanup( type: string, id: string, success: boolean, error?: Error, testName?: string ): void { const logEntry: LogEntry = { timestamp: new Date().toISOString(), testSuite: this.currentTestSuite || 'unknown', testName, operation: 'test_data_cleanup', success, metadata: { recordType: type, recordId: id, }, }; if (error) { logEntry.error = { message: error.message, stack: error.stack, }; } this.log(logEntry); } /** * Log test completion */ logTestCompletion(testName: string, passed: boolean, error?: Error): void { if (this.currentTestRun) { this.currentTestRun.totalTests++; if (passed) { this.currentTestRun.passedTests++; } else { this.currentTestRun.failedTests++; } } const logEntry: LogEntry = { timestamp: new Date().toISOString(), testSuite: this.currentTestSuite || 'unknown', testName, operation: 'info', success: passed, metadata: { testResult: passed ? 'PASSED' : 'FAILED' }, }; if (error) { logEntry.error = { message: error.message, stack: error.stack, }; } this.log(logEntry); } /** * Log general information */ logInfo( message: string, metadata?: Record<string, unknown>, testName?: string ): void { this.log({ timestamp: new Date().toISOString(), testSuite: this.currentTestSuite || 'unknown', testName, operation: 'info', success: true, metadata: { message, ...metadata }, }); } /** * Log errors with full context */ logError( error: Error, context?: Record<string, unknown>, testName?: string ): void { if (this.currentTestRun) { this.currentTestRun.errors.push({ timestamp: new Date().toISOString(), message: error.message, testName, }); } this.log({ timestamp: new Date().toISOString(), testSuite: this.currentTestSuite || 'unknown', testName, operation: 'error', success: false, error: this.extractApiError(error), metadata: context, }); } /** * Write log entry to file */ private log(entry: LogEntry): void { const logFile = this.getLogFile(); const logLine = JSON.stringify(entry) + '\n'; try { appendFileSync(logFile, logLine); } catch (error: unknown) { console.error('Failed to write log entry:', error); } // Also log to console in development for immediate feedback if (process.env.NODE_ENV === 'development' || process.env.E2E_DEBUG_LOGS) { console.error( `[E2E-LOG] ${entry.operation.toUpperCase()}: ${entry.toolName || entry.metadata?.message || 'info'}` ); if (entry.error) { console.error(`[E2E-ERROR] ${entry.error.message}`); } } } /** * Get log file path for current test suite */ private getLogFile(): string { const timestamp = new Date().toISOString().split('T')[0]; // YYYY-MM-DD const testSuite = this.currentTestSuite || this.inferTestSuiteFromCallStack() || 'unknown'; // Use a more stable filename that doesn't change during the test suite run const runId = this.getOrCreateRunId(); return join(this.logsDir, `${testSuite}-${timestamp}-${runId}.jsonl`); } /** * Infer test suite name from call stack when not explicitly set */ private inferTestSuiteFromCallStack(): string | null { const stack = new Error().stack; if (!stack) return null; // Look for test file names in the stack const testFilePattern = /\/test\/e2e\/suites\/([^\/]+)\.e2e\.test\.ts/; const match = stack.match(testFilePattern); if (match && match[1]) { return match[1]; // Extract the test suite name from filename } // Fallback patterns for other test file structures const fallbackPattern = /\/([^\/]+)\.e2e\.test\.ts/; const fallbackMatch = stack.match(fallbackPattern); if (fallbackMatch && fallbackMatch[1]) { return fallbackMatch[1]; } return null; } /** * Get or create a stable run ID for the current test session */ private getOrCreateRunId(): string { if (!this.runId) { // Create a stable run ID based on start time const startTime = new Date(); this.runId = `${startTime.getHours().toString().padStart(2, '0')}${startTime.getMinutes().toString().padStart(2, '0')}${startTime.getSeconds().toString().padStart(2, '0')}`; } return this.runId; } /** * Sanitize parameters by removing sensitive data */ private sanitizeParameters( params: Record<string, unknown> ): Record<string, unknown> { const sensitiveKeys = [ 'api_key', 'apiKey', 'token', 'password', 'secret', 'authorization', 'auth', 'key', 'credentials', ]; const sanitized = { ...params }; const sanitizeObject = (obj: any): any => { if (typeof obj !== 'object' || obj === null) return obj; if (Array.isArray(obj)) { return obj.map(sanitizeObject); } const result: any = {}; for (const [key, value] of Object.entries(obj)) { const lowerKey = key.toLowerCase(); if (sensitiveKeys.some((sensitive) => lowerKey.includes(sensitive))) { result[key] = '[REDACTED]'; } else if (typeof value === 'object') { result[key] = sanitizeObject(value); } else { result[key] = value; } } return result; }; return sanitizeObject(sanitized); } /** * Sanitize response data by limiting size and removing sensitive data */ private sanitizeResponse(response: any): any { if (!response) return response; // Convert response to string to check size const responseStr = JSON.stringify(response); // If response is too large (>10KB), create a safe preview if (responseStr.length > 10000) { return { _truncated: true, _originalSize: responseStr.length, _preview: { type: 'truncated', length: responseStr.length, sample: responseStr.substring(0, 500), // Raw string sample, no parsing }, _message: 'Response truncated for logging. Original size: ' + responseStr.length + ' characters', }; } return response; } /** * Get current test run statistics */ getCurrentStats(): TestRunSummary | undefined { return this.currentTestRun; } } // Export singleton instance export const e2eLogger = new E2ELogger(); // Export helper functions for test files export function startTestSuite(suiteName: string): void { e2eLogger.startTestSuite(suiteName); } export function endTestSuite(): void { e2eLogger.endTestSuite(); } export function logToolCall( toolName: string, parameters: Record<string, unknown>, response: any, timing: { start: number; end: number }, testName?: string, error?: Error ): void { e2eLogger.logToolCall( toolName, parameters, response, timing, testName, error ); } export function logTestDataCreation( type: string, id: string, data: any, testName?: string ): void { e2eLogger.logTestDataCreation(type, id, data, testName); } export function logTestDataCleanup( type: string, id: string, success: boolean, error?: Error, testName?: string ): void { e2eLogger.logTestDataCleanup(type, id, success, error, testName); } export function logInfo( message: string, metadata?: Record<string, unknown>, testName?: string ): void { e2eLogger.logInfo(message, metadata, testName); } export function logError( error: Error, context?: Record<string, unknown>, testName?: string ): void { e2eLogger.logError(error, context, testName); } export function logTestCompletion( testName: string, passed: boolean, error?: Error ): void { e2eLogger.logTestCompletion(testName, passed, error); }

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/kesslerio/attio-mcp-server'

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