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
JavaScript
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;