UNPKG

@voilajsx/appkit

Version:

Minimal and framework agnostic Node.js toolkit designed for AI agentic backend development

401 lines 15.1 kB
/** * HTTP transport for external logging services with automatic format detection * @module @voilajsx/appkit/logger * @file src/logger/transports/http.ts * * @llm-rule WHEN: Need to send logs to external services like Datadog, Elasticsearch, Splunk * @llm-rule AVOID: Manual HTTP setup - auto-detects service format from URL * @llm-rule NOTE: Supports Datadog, Elasticsearch, Splunk with automatic format conversion */ import https from 'https'; import http from 'http'; /** * HTTP transport with automatic service detection and format optimization */ export class HttpTransport { url; batchSize; timeout; minimal; // HTTP state batch = []; flushTimer = null; parsedUrl; serviceType; /** * Creates HTTP transport with direct environment access (like auth pattern) * @llm-rule WHEN: Logger initialization with VOILA_LOGGING_HTTP_URL environment variable * @llm-rule AVOID: Manual HTTP configuration - environment detection handles this * @llm-rule NOTE: Auto-detects service type from URL and formats payloads accordingly */ constructor(config) { // Direct access to config (like auth module pattern) this.url = config.http.url; this.batchSize = config.http.batchSize; this.timeout = config.http.timeout; this.minimal = config.minimal; this.parsedUrl = new URL(this.url); this.serviceType = this.detectServiceType(); // Initialize HTTP transport this.setupBatchFlush(); } /** * Detect external service type from URL for format optimization * @llm-rule WHEN: Determining payload format based on service provider * @llm-rule AVOID: Manual service configuration - URL detection is automatic */ detectServiceType() { const hostname = this.parsedUrl.hostname.toLowerCase(); const pathname = this.parsedUrl.pathname; if (hostname.includes('datadog')) return 'datadog'; if (hostname.includes('elastic') || pathname.includes('_bulk')) return 'elasticsearch'; if (hostname.includes('splunk')) return 'splunk'; return 'generic'; } /** * Write log entry to HTTP endpoint via batching * @llm-rule WHEN: Sending logs to external monitoring services * @llm-rule AVOID: Calling directly - logger routes entries automatically */ write(entry) { try { // Optimize entry based on scope and service type const optimizedEntry = this.optimizeEntry(entry); // Add to batch this.batch.push(optimizedEntry); // Flush if batch is full if (this.batch.length >= this.batchSize) { this.flushBatch(); } } catch (error) { console.error('HTTP transport write error:', error.message); } } /** * Optimize log entry for HTTP transmission * @llm-rule WHEN: Reducing payload size and optimizing for external services * @llm-rule AVOID: Always sending full entries - minimal scope reduces bandwidth */ optimizeEntry(entry) { if (!this.minimal) { return entry; // Full scope - keep everything } // Minimal scope optimization for HTTP transmission const { timestamp, level, message, component, requestId, userId, method, url, statusCode, durationMs, error, service, version, environment, ...rest } = entry; const minimal = { timestamp, level, message, }; // Add essential context for external monitoring if (component) minimal.component = component; if (requestId) minimal.requestId = requestId; if (userId) minimal.userId = userId; // Add HTTP context (important for APM) if (method) minimal.method = method; if (url) minimal.url = url; if (statusCode) minimal.statusCode = statusCode; if (durationMs) minimal.durationMs = durationMs; // Add service identification if (service) minimal.service = service; if (version) minimal.version = version; if (environment) minimal.environment = environment; // Optimize error information for monitoring if (error) { minimal.error = this.optimizeError(error); } // Add essential metadata for correlation const essentialMeta = this.filterEssentialMeta(rest); if (Object.keys(essentialMeta).length > 0) { minimal.meta = essentialMeta; } return minimal; } /** * Optimize error object for HTTP transmission * @llm-rule WHEN: Sending error data to external monitoring services * @llm-rule AVOID: Including stack traces - security risk and bandwidth waste */ optimizeError(error) { if (typeof error === 'string') { return error; } if (typeof error === 'object' && error !== null) { const optimized = { message: error.message, }; // Add important error fields for monitoring if (error.name && error.name !== 'Error') { optimized.name = error.name; } if (error.code) { optimized.code = error.code; } if (error.statusCode) { optimized.statusCode = error.statusCode; } // Never include stack traces in HTTP transmissions (security) return optimized; } return error; } /** * Filter metadata for essential monitoring fields * @llm-rule WHEN: Keeping HTTP payload size manageable while preserving correlation * @llm-rule AVOID: Sending all metadata - focus on monitoring and correlation fields */ filterEssentialMeta(meta) { const essential = {}; // Essential monitoring and correlation fields const essentialKeys = [ 'traceId', 'spanId', 'sessionId', 'tenantId', 'appName', 'ip' ]; for (const key of essentialKeys) { if (meta[key] !== undefined) { essential[key] = meta[key]; } } // Include correlation IDs for (const [key, value] of Object.entries(meta)) { if (key.endsWith('Id') && !essential[key]) { essential[key] = value; } } return essential; } /** * Setup automatic batch flushing * @llm-rule WHEN: Transport initialization - ensures logs are sent regularly * @llm-rule AVOID: Manual flushing - automatic batching optimizes HTTP requests */ setupBatchFlush() { this.flushTimer = setInterval(() => { if (this.batch.length > 0) { this.flushBatch(); } }, 10000); // Flush every 10 seconds } /** * Flush current batch to HTTP endpoint * @llm-rule WHEN: Batch is full or timer triggers * @llm-rule AVOID: Individual HTTP requests - batching reduces overhead significantly */ async flushBatch() { if (this.batch.length === 0) { return; } const currentBatch = [...this.batch]; this.batch = []; try { await this.sendBatch(currentBatch); } catch (error) { console.error('HTTP batch flush failed:', error.message); // Re-add failed entries for retry (up to batch size limit) const retryEntries = currentBatch.slice(0, this.batchSize); this.batch.unshift(...retryEntries); } } /** * Send batch of log entries via HTTP * @llm-rule WHEN: Transmitting batched logs to external service * @llm-rule AVOID: Custom formatting - service detection handles optimal format */ async sendBatch(entries) { if (entries.length === 0) return; const payload = this.formatPayload(entries); await this.makeHttpRequest(payload); } /** * Format log entries for specific external services * @llm-rule WHEN: Converting logs to service-specific format for optimal ingestion * @llm-rule AVOID: Generic format for known services - optimized formats work better */ formatPayload(entries) { switch (this.serviceType) { case 'datadog': return JSON.stringify({ logs: entries.map(entry => ({ timestamp: entry.timestamp, level: entry.level, message: entry.message, attributes: this.extractDatadogAttributes(entry), })), }); case 'elasticsearch': // Elasticsearch bulk format return entries .map(entry => { const indexMeta = JSON.stringify({ index: {} }); const logData = JSON.stringify(entry); return indexMeta + '\n' + logData; }) .join('\n') + '\n'; case 'splunk': return entries .map(entry => JSON.stringify({ time: new Date(entry.timestamp).getTime() / 1000, event: entry, })) .join('\n'); case 'generic': default: // Generic format with metadata return JSON.stringify({ logs: entries, scope: this.minimal ? 'minimal' : 'full', count: entries.length, service: entries[0]?.service || 'unknown', }); } } /** * Extract Datadog-specific attributes from log entry * @llm-rule WHEN: Formatting logs for Datadog ingestion * @llm-rule AVOID: Sending raw entry - Datadog expects specific attribute structure */ extractDatadogAttributes(entry) { const { timestamp, level, message, ...attributes } = entry; return { service: attributes.service || 'unknown', component: attributes.component, requestId: attributes.requestId, userId: attributes.userId, method: attributes.method, url: attributes.url, statusCode: attributes.statusCode, durationMs: attributes.durationMs, environment: attributes.environment, version: attributes.version, ...attributes.meta, }; } /** * Make HTTP request with retry logic and exponential backoff * @llm-rule WHEN: Sending HTTP request to external service * @llm-rule AVOID: Single attempt - external services can be temporarily unavailable */ async makeHttpRequest(payload) { const maxRetries = 3; let lastError; for (let attempt = 1; attempt <= maxRetries; attempt++) { try { await this.executeHttpRequest(payload); return; // Success } catch (error) { lastError = error; if (attempt < maxRetries) { const delay = 1000 * Math.pow(2, attempt - 1); // Exponential backoff console.warn(`HTTP request attempt ${attempt} failed, retrying in ${delay}ms:`, error.message); await this.sleep(delay); } } } throw lastError; } /** * Execute single HTTP request with timeout * @llm-rule WHEN: Making actual HTTP call to external service * @llm-rule AVOID: Infinite timeouts - external services should respond quickly */ executeHttpRequest(payload) { return new Promise((resolve, reject) => { const isHttps = this.parsedUrl.protocol === 'https:'; const httpModule = isHttps ? https : http; const options = { hostname: this.parsedUrl.hostname, port: this.parsedUrl.port || (isHttps ? 443 : 80), path: this.parsedUrl.pathname + this.parsedUrl.search, method: 'POST', headers: { 'Content-Type': 'application/json', 'Content-Length': Buffer.byteLength(payload), 'User-Agent': 'VoilaJSX-AppKit-Logging/1.0.0', }, timeout: this.timeout, }; const req = httpModule.request(options, (res) => { let responseData = ''; res.on('data', (chunk) => { responseData += chunk; }); res.on('end', () => { if (res.statusCode && res.statusCode >= 200 && res.statusCode < 300) { resolve(); } else { reject(new Error(`HTTP ${res.statusCode}: ${responseData}`)); } }); }); req.on('timeout', () => { req.destroy(); reject(new Error(`HTTP request timeout after ${this.timeout}ms`)); }); req.on('error', (error) => { reject(new Error(`HTTP request failed: ${error.message}`)); }); // Write payload and end request req.write(payload); req.end(); }); } /** * Sleep for specified milliseconds * @llm-rule WHEN: Implementing retry delays and exponential backoff * @llm-rule AVOID: Busy waiting - proper sleep prevents CPU waste */ sleep(ms) { return new Promise(resolve => setTimeout(resolve, ms)); } /** * Check if this transport should log the given level * @llm-rule WHEN: Logger asks if transport handles this level * @llm-rule AVOID: Complex level logic - simple comparison is sufficient */ shouldLog(level, configLevel) { const levels = { error: 0, warn: 1, info: 2, debug: 3 }; return levels[level] <= levels[configLevel]; } /** * Flush pending logs to HTTP endpoint * @llm-rule WHEN: App shutdown or ensuring logs are sent * @llm-rule AVOID: Frequent flushing - impacts performance and external service limits */ async flush() { await this.flushBatch(); } /** * Close HTTP transport and cleanup resources * @llm-rule WHEN: App shutdown or logger cleanup * @llm-rule AVOID: Abrupt shutdown - graceful close ensures logs are sent */ async close() { // Clear flush timer if (this.flushTimer) { clearInterval(this.flushTimer); this.flushTimer = null; } // Flush remaining logs await this.flushBatch(); } } //# sourceMappingURL=http.js.map