UNPKG

alfred-logger-sdk

Version:

Production-ready data collection SDK for feeding structured events to LLM Data Agents with auto-capture capabilities

423 lines (352 loc) 12.2 kB
const HttpClient = require('./httpClient'); const AutoCapture = require('./autoCapture'); const os = require('os'); const crypto = require('crypto'); class Logger { constructor(config = {}) { this.validateConfig(config); this.config = { endpoint: config.endpoint, apiKey: config.apiKey, batchSize: Math.min(config.batchSize || 50, 1000), maxBufferSize: config.maxBufferSize || 10000, flushInterval: Math.max(config.flushInterval || 5000, 1000), sessionId: config.sessionId || this.generateSessionId(), appName: this.sanitizeString(config.appName || 'unknown-app'), environment: this.sanitizeString(config.environment || 'production'), autoGenerateTraceId: config.autoGenerateTraceId !== false, maxEventSize: config.maxEventSize || 1024 * 1024, sanitizePayloads: config.sanitizePayloads !== false, customContext: config.customContext || {}, autoCapture: config.autoCapture || {}, ...config }; if (!this.config.endpoint) { throw new Error('Backend endpoint is required for data collection'); } this.httpClient = new HttpClient(this.config); this.eventBuffer = []; this.context = this.buildContext(); this.currentTraceId = null; this.traceStack = []; this.isShuttingDown = false; this.pendingFlushes = new Set(); // Initialize auto-capture if enabled this.autoCapture = new AutoCapture(this, this.config.autoCapture); if (this.config.autoCapture.enabled !== false) { this.autoCapture.start(); } this.startFlushTimer(); } validateConfig(config) { if (!config.endpoint) { throw new Error('Backend endpoint is required for data collection'); } if (typeof config.endpoint !== 'string' || !this.isValidUrl(config.endpoint)) { throw new Error('Invalid endpoint URL'); } if (config.apiKey && typeof config.apiKey !== 'string') { throw new Error('API key must be a string'); } } isValidUrl(string) { try { const url = new URL(string); return url.protocol === 'http:' || url.protocol === 'https:'; } catch (_) { return false; } } sanitizeString(str) { if (typeof str !== 'string') return 'invalid'; return str.replace(/[<>"'&]/g, '').substring(0, 100); } sanitizeData(data) { if (!this.config.sanitizePayloads) return data; const sensitiveFields = ['password', 'token', 'key', 'secret', 'auth', 'credential']; const sanitize = (obj) => { if (obj === null || typeof obj !== 'object') return obj; if (Array.isArray(obj)) { return obj.map(sanitize); } const sanitized = {}; for (const [key, value] of Object.entries(obj)) { const lowerKey = key.toLowerCase(); if (sensitiveFields.some(field => lowerKey.includes(field))) { sanitized[key] = '[REDACTED]'; } else { sanitized[key] = sanitize(value); } } return sanitized; }; return sanitize(data); } generateSessionId() { return crypto.randomBytes(16).toString('hex'); } buildContext() { const baseContext = { sessionId: this.config.sessionId, appName: this.config.appName, environment: this.config.environment, hostname: os.hostname(), platform: os.platform(), nodeVersion: process.version, pid: process.pid, startTime: new Date().toISOString() }; // Merge with custom context, sanitizing custom values const customContext = this.sanitizeData(this.config.customContext || {}); return { ...baseContext, ...customContext }; } startFlushTimer() { this.flushTimer = setInterval(() => { this.flush(); }, this.config.flushInterval); } collectEvent(eventType, data, metadata = {}) { if (this.isShuttingDown) { throw new Error('Logger is shutting down'); } if (!eventType || typeof eventType !== 'string') { throw new Error('Event type must be a non-empty string'); } if (this.eventBuffer.length >= this.config.maxBufferSize) { console.warn('Event buffer full, dropping oldest events'); this.eventBuffer = this.eventBuffer.slice(-this.config.batchSize); } let traceId = metadata.traceId || this.currentTraceId; if (!traceId && this.config.autoGenerateTraceId) { traceId = this.generateTraceId(); } const sanitizedData = this.sanitizeData(data); const serializedData = JSON.stringify(sanitizedData); if (serializedData.length > this.config.maxEventSize) { throw new Error(`Event data too large: ${serializedData.length} bytes`); } const event = { eventId: this.generateEventId(), timestamp: new Date().toISOString(), eventType: this.sanitizeString(eventType), data: sanitizedData, metadata: { ...this.sanitizeData(metadata), traceId, context: this.context } }; this.eventBuffer.push(event); if (this.eventBuffer.length >= this.config.batchSize) { this.flush().catch(err => { console.error('Failed to flush events:', err.message); }); } return event; } generateEventId() { return `evt_${Date.now()}_${crypto.randomBytes(8).toString('hex')}`; } generateTraceId() { return `trace_${Date.now()}_${crypto.randomBytes(12).toString('hex')}`; } decorateAxios(axiosInstance) { axiosInstance.interceptors.request.use((config) => { config.metadata = { startTime: Date.now() }; const sanitizedPayload = this.sanitizeData(config.data); this.collectEvent('api_request', { url: config.url, method: config.method, payload: sanitizedPayload }, { traceId: config.traceId || this.getCurrentTraceId() }); return config; }); axiosInstance.interceptors.response.use((response) => { const duration = Date.now() - response.config.metadata.startTime; const sanitizedResponse = this.sanitizeData(response.data); this.collectEvent('api_response', { url: response.config.url, status: response.status, duration, response: sanitizedResponse }, { traceId: response.config.traceId || this.getCurrentTraceId() }); return response; }, (error) => { const duration = Date.now() - (error.config?.metadata?.startTime || Date.now()); this.collectEvent('api_response', { url: error.config?.url || 'unknown', status: error.response?.status || 500, duration, response: error.message }, { traceId: error.config?.traceId || this.getCurrentTraceId() }); return Promise.reject(error); }); } startTrace(traceId = null) { const newTraceId = traceId || this.generateTraceId(); if (this.currentTraceId) { this.traceStack.push(this.currentTraceId); } this.currentTraceId = newTraceId; return newTraceId; } endTrace() { const endedTraceId = this.currentTraceId; this.currentTraceId = this.traceStack.pop() || null; return endedTraceId; } getCurrentTraceId() { return this.currentTraceId; } withTrace(traceId, callback) { const startedTraceId = this.startTrace(traceId); try { const result = callback(startedTraceId); if (result && typeof result.then === 'function') { return result.finally(() => this.endTrace()); } this.endTrace(); return result; } catch (error) { this.endTrace(); throw error; } } async flush() { if (this.eventBuffer.length === 0 || this.isShuttingDown) return; const batch = [...this.eventBuffer]; this.eventBuffer = []; const flushPromise = this.httpClient.sendBatch(batch).catch(error => { console.error('Failed to send batch:', error.message); this.eventBuffer.unshift(...batch.slice(0, Math.min(batch.length, 100))); throw error; }); this.pendingFlushes.add(flushPromise); try { await flushPromise; } finally { this.pendingFlushes.delete(flushPromise); } } userAction(action, details = {}, traceId = null) { const metadata = traceId ? { traceId } : {}; return this.collectEvent('user_action', { action, ...details }, metadata); } systemEvent(event, details = {}, traceId = null) { const metadata = traceId ? { traceId } : {}; return this.collectEvent('system_event', { event, ...details }, metadata); } performanceMetric(metric, value, unit = 'ms', traceId = null) { const metadata = traceId ? { traceId } : {}; return this.collectEvent('performance', { metric, value, unit }, metadata); } error(error, context = {}, traceId = null) { const metadata = traceId ? { traceId } : {}; return this.collectEvent('error', { message: error.message || error, stack: error.stack, name: error.name, ...context }, metadata); } customData(dataType, payload, traceId = null) { const metadata = traceId ? { traceId } : {}; return this.collectEvent('custom', { dataType, payload }, metadata); } expressMiddleware() { return (req, res, next) => { try { const traceId = req.headers['x-trace-id'] || this.generateTraceId(); req.traceId = traceId; const start = Date.now(); const sanitizedBody = this.sanitizeData(req.body); this.collectEvent('api_request', { url: req.originalUrl, method: req.method, payload: sanitizedBody, userAgent: req.headers['user-agent'], ip: req.ip || req.connection.remoteAddress }, { traceId }); res.on('finish', () => { try { const duration = Date.now() - start; this.collectEvent('api_response', { url: req.originalUrl, status: res.statusCode, duration, response: {} }, { traceId }); } catch (error) { console.error('Error logging response:', error.message); } }); } catch (error) { console.error('Error in express middleware:', error.message); } next(); }; } setCustomContext(key, value) { if (typeof key === 'object' && key !== null) { // Allow setting multiple context values at once Object.assign(this.config.customContext, this.sanitizeData(key)); } else if (typeof key === 'string') { // Set single context value this.config.customContext[key] = this.sanitizeData(value); } else { throw new Error('Context key must be a string or object'); } // Rebuild context to include new custom values this.context = this.buildContext(); } getCustomContext(key) { if (key) { return this.config.customContext[key]; } return { ...this.config.customContext }; } removeCustomContext(key) { if (Array.isArray(key)) { key.forEach(k => delete this.config.customContext[k]); } else { delete this.config.customContext[key]; } // Rebuild context without removed values this.context = this.buildContext(); } clearCustomContext() { this.config.customContext = {}; this.context = this.buildContext(); } enableAutoCapture(config = {}) { this.autoCapture.updateConfig({ ...config, enabled: true }); this.autoCapture.start(); } disableAutoCapture() { this.autoCapture.stop(); this.autoCapture.updateConfig({ enabled: false }); } updateAutoCaptureConfig(config) { this.autoCapture.updateConfig(config); } async shutdown() { this.isShuttingDown = true; // Stop auto-capture if (this.autoCapture) { this.autoCapture.stop(); } if (this.flushTimer) { clearInterval(this.flushTimer); } try { await this.flush(); await Promise.all(this.pendingFlushes); } catch (error) { console.error('Error during shutdown:', error.message); } } } module.exports = Logger;