UNPKG

@coretext-ai/qa-discord-77f3255a-cccf-4fab-b131-c7d49ae1be7c

Version:
384 lines 15.2 kB
/** * LogShipper - Centralized log shipping service for discord-mcp-server * Sends logs to the centralized logging API endpoint with batching and retry logic */ export class LogShipper { constructor(config) { this.logQueue = []; this.flushTimer = null; this.isShuttingDown = false; this.lastSuccessfulFlush = 0; this.config = config; if (!this.config.enabled) { return; } this.validateConfig(); this.startFlushTimer(); // Log initialization this.logLocally('INFO', 'LogShipper initialized', { endpoint: this.config.endpoint, batchSize: this.config.batchSize, flushInterval: this.config.flushInterval, maxRetries: this.config.maxRetries }); } validateConfig() { if (!this.config.endpoint) { throw new Error('LogShipper: endpoint is required'); } // API key validation based on requireApiKey flag if (this.config.requireApiKey && !this.config.apiKey) { throw new Error('LogShipper: apiKey is required when requireApiKey is true'); } // Warning for missing API key during transition period if (!this.config.apiKey && !this.config.requireApiKey) { this.logLocally('WARN', 'API key not configured. Log shipping will work now but will require an API key in the future.', { help: 'Set LOG_INGESTION_API_KEY environment variable to prepare for future requirements' }); } if (!this.config.endpoint.startsWith('https://')) { throw new Error('LogShipper: endpoint must use HTTPS'); } } startFlushTimer() { if (this.flushTimer) { clearInterval(this.flushTimer); } this.flushTimer = setInterval(() => { this.flush().catch(error => { this.logLocally('ERROR', 'Scheduled flush failed', { error: error.message, queueSize: this.logQueue.length }); }); }, this.config.flushInterval); } /** * Add a log entry to the queue */ addLog(logEntry) { if (!this.config.enabled || this.isShuttingDown) { return; } this.logQueue.push(logEntry); // Trigger immediate flush if batch size reached if (this.logQueue.length >= this.config.batchSize) { this.flush().catch(error => { this.logLocally('ERROR', 'Immediate flush failed', { error: error.message, queueSize: this.logQueue.length }); }); } } /** * Flush all queued logs to the endpoint */ async flush() { if (!this.config.enabled || this.logQueue.length === 0) { return; } const batch = this.logQueue.splice(0, this.config.batchSize); try { await this.sendLogsWithRetry(batch); this.lastSuccessfulFlush = Date.now(); this.logLocally('DEBUG', 'Logs shipped successfully', { batchSize: batch.length, queueRemaining: this.logQueue.length, lastFlush: new Date(this.lastSuccessfulFlush).toISOString() }); } catch (error) { // Return logs to queue on failure this.logQueue.unshift(...batch); this.logLocally('ERROR', 'Failed to ship logs after retries', { error: error instanceof Error ? error.message : String(error), batchSize: batch.length, queueSize: this.logQueue.length }); throw error; } } /** * Send logs with retry logic */ async sendLogsWithRetry(logs) { for (let attempt = 1; attempt <= this.config.maxRetries; attempt++) { try { await this.sendLogs(logs); return; // Success } catch (error) { const status = error.status || error.response?.status; // Don't retry on client errors (4xx except 429) if (status >= 400 && status < 500 && status !== 429) { this.logLocally('ERROR', 'Client error - not retrying', { status, error: error.message, attempt }); throw error; } // On last attempt, throw the error if (attempt === this.config.maxRetries) { this.logLocally('ERROR', 'Max retries exceeded', { status, error: error.message, attempts: attempt }); throw error; } // Calculate delay with exponential backoff const baseDelay = status === 429 ? 1000 : 500; // Longer delay for rate limits const delay = baseDelay * Math.pow(2, attempt - 1); this.logLocally('WARN', 'Retrying log shipment', { status, error: error.message, attempt, nextAttemptIn: delay, maxRetries: this.config.maxRetries }); await new Promise(resolve => setTimeout(resolve, delay)); } } } /** * Map component values to API-accepted enum values */ mapComponentToValidEnum(component) { const validComponents = ['client', 'tools', 'oauth-client']; const componentMap = { 'server': 'client', 'client': 'client', 'tools': 'tools', 'oauth-client': 'oauth-client' }; const componentStr = String(component || 'client'); const mapped = componentMap[componentStr.toLowerCase()]; return mapped || 'client'; // Default to 'client' for any unmapped components } /** * Map log level to API-accepted enum values (lowercase) */ mapLogLevelToValidEnum(level) { const validLevels = ['info', 'error', 'warn', 'debug']; const levelMap = { 'info': 'info', 'error': 'error', 'warn': 'warn', 'warning': 'warn', 'debug': 'debug', 'fatal': 'error' // Map fatal to error as API doesn't support fatal }; const levelStr = String(level || 'info'); const mapped = levelMap[levelStr.toLowerCase()]; return mapped || 'info'; // Default to 'info' for any unmapped levels } /** * Validate and clean log entries to ensure API compatibility */ validateAndCleanLogs(logs) { return logs.map(log => { // Ensure all required fields are present and valid const cleanedLog = { timestamp: log.timestamp || new Date().toISOString(), level: this.mapLogLevelToValidEnum(log.level || 'info'), sessionId: log.sessionId || this.generateSessionId(), user: log.user || process.env.CORETEXT_USER || 'unknown', integration: log.integration || 'unknown', component: this.mapComponentToValidEnum(log.component || 'client'), action: log.action || 'unknown', message: String(log.message || 'No message'), serverName: log.serverName || 'discord-mcp-server', projectId: log.projectId || process.env.PROJECT_ID || '77f3255a-cccf-4fab-b131-c7d49ae1be7c', organizationId: log.organizationId || process.env.ORGANIZATION_ID || '550e8400-e29b-41d4-a716-446655440001' }; // Clean and validate metadata if (log.metadata && typeof log.metadata === 'object') { cleanedLog.metadata = this.cleanMetadata(log.metadata); } return cleanedLog; }).filter(log => { // Filter out any logs that still have invalid data return log.timestamp && log.level && log.user && log.message; }); } /** * Clean metadata object to ensure JSON serialization compatibility */ cleanMetadata(metadata) { if (!metadata || typeof metadata !== 'object') { return {}; } const cleaned = {}; for (const [key, value] of Object.entries(metadata)) { try { // Skip undefined values if (value === undefined) { continue; } // Convert functions to string representation if (typeof value === 'function') { cleaned[key] = '[Function]'; continue; } // Handle circular references and complex objects if (typeof value === 'object' && value !== null) { try { // Test if the object can be serialized JSON.stringify(value); cleaned[key] = value; } catch (e) { // If serialization fails, convert to string cleaned[key] = String(value); } } else { cleaned[key] = value; } } catch (error) { // If any error occurs with this field, skip it this.logLocally('WARN', 'Skipping problematic metadata field', { field: key, error: error instanceof Error ? error.message : String(error) }); } } return cleaned; } /** * Send logs to the API endpoint */ async sendLogs(logs) { const headers = { 'Content-Type': 'application/json' }; // Only include API key header if provided if (this.config.apiKey) { headers['X-API-Key'] = this.config.apiKey; } // Validate and clean logs before sending const cleanedLogs = this.validateAndCleanLogs(logs); const payload = { logs: cleanedLogs }; // Debug logging - log the exact payload being sent this.logLocally('DEBUG', 'Sending log payload', { payloadSize: JSON.stringify(payload).length, logCount: cleanedLogs.length, endpoint: this.config.endpoint, hasApiKey: !!this.config.apiKey, payload: JSON.stringify(payload, null, 2) }); const response = await fetch(this.config.endpoint, { method: 'POST', headers, body: JSON.stringify(payload) }); if (!response.ok) { // Get response body for debugging let responseBody = ''; try { responseBody = await response.text(); } catch (e) { responseBody = 'Unable to read response body'; } // Log detailed error information this.logLocally('ERROR', 'HTTP error response from log ingestion API', { status: response.status, statusText: response.statusText, responseBody, requestPayloadSize: JSON.stringify({ logs: cleanedLogs }).length, endpoint: this.config.endpoint, headers: Object.keys(headers) }); // Provide helpful message for authentication errors if ((response.status === 401 || response.status === 403) && !this.config.apiKey) { const error = new Error(`Authentication required (${response.status}). The log ingestion API now requires an API key. ` + 'Please set LOG_INGESTION_API_KEY environment variable and restart the server.'); error.status = response.status; throw error; } // Provide specific error message for 400 Bad Request if (response.status === 400) { const error = new Error(`Bad Request (400): The log payload format is invalid. ` + `Response: ${responseBody}. ` + 'Check the payload structure and field formats.'); error.status = response.status; throw error; } const error = new Error(`HTTP ${response.status}: ${response.statusText}. Response: ${responseBody}`); error.status = response.status; throw error; } return response.json(); } /** * Generate a unique session ID */ generateSessionId() { return `discord-mcp-server-${Date.now()}-${Math.random().toString(36).substring(2, 9)}`; } /** * Log locally to stderr (for debugging the log shipper itself) */ logLocally(level, message, metadata) { const timestamp = new Date().toISOString(); const user = process.env.CORETEXT_USER || 'unknown'; const logEntry = { timestamp, user, component: 'log-shipper', level, message, ...(metadata && { metadata }) }; console.error(`[DISCORD_MCP_SERVER-LOG-SHIPPER] ${JSON.stringify(logEntry)}`); } /** * Get health status of the log shipper */ getHealthStatus() { const now = Date.now(); const timeSinceLastFlush = now - this.lastSuccessfulFlush; const isHealthy = this.config.enabled && (this.lastSuccessfulFlush === 0 || timeSinceLastFlush < this.config.flushInterval * 3); return { healthy: isHealthy, lastFlush: this.lastSuccessfulFlush > 0 ? new Date(this.lastSuccessfulFlush).toISOString() : undefined, queueSize: this.logQueue.length, config: { enabled: this.config.enabled, endpoint: this.config.endpoint, batchSize: this.config.batchSize, flushInterval: this.config.flushInterval } }; } /** * Graceful shutdown - flush remaining logs */ async shutdown() { this.isShuttingDown = true; if (this.flushTimer) { clearInterval(this.flushTimer); this.flushTimer = null; } this.logLocally('INFO', 'LogShipper shutting down', { queueSize: this.logQueue.length }); if (this.logQueue.length > 0) { try { await this.flush(); this.logLocally('INFO', 'Final flush completed successfully'); } catch (error) { this.logLocally('ERROR', 'Final flush failed', { error: error instanceof Error ? error.message : String(error), lostLogs: this.logQueue.length }); } } } } //# sourceMappingURL=log-shipper.js.map