logs-interceptor
Version:
High-performance, production-ready log interceptor for Node.js applications with Loki integration
470 lines • 18.3 kB
JavaScript
"use strict";
var __importDefault = (this && this.__importDefault) || function (mod) {
return (mod && mod.__esModule) ? mod : { "default": mod };
};
Object.defineProperty(exports, "__esModule", { value: true });
exports.LogsInterceptor = void 0;
const api_1 = require("@opentelemetry/api");
const axios_1 = __importDefault(require("axios"));
const events_1 = require("events");
const perf_hooks_1 = require("perf_hooks");
const utils_1 = require("./utils");
function createDebugLogger(enabled) {
const formatMessage = (level, message, context) => {
const timestamp = new Date().toISOString();
const prefix = `[${timestamp}] [logs-interceptor:${level.toUpperCase()}]`;
if (context && Object.keys(context).length > 0) {
return `${prefix} ${message} ${(0, utils_1.safeStringify)(context)}`;
}
return `${prefix} ${message}`;
};
const noop = () => { };
if (!enabled) {
return { debug: noop, info: noop, warn: noop, error: noop };
}
return {
debug: (message, context) => {
process.stderr.write(formatMessage('debug', message, context) + '\n');
},
info: (message, context) => {
process.stderr.write(formatMessage('info', message, context) + '\n');
},
warn: (message, context) => {
process.stderr.write(formatMessage('warn', message, context) + '\n');
},
error: (message, context) => {
process.stderr.write(formatMessage('error', message, context) + '\n');
},
};
}
class LogsInterceptor extends events_1.EventEmitter {
constructor(config) {
super();
this.buffer = [];
this.flushTimer = null;
this.isDestroyed = false;
this.config = this.validateAndNormalizeConfig(config);
this.debugLogger = createDebugLogger(this.config.debug);
this.startTime = Date.now();
// Initialize metrics
this.metrics = {
logsProcessed: 0,
logsDropped: 0,
flushCount: 0,
errorCount: 0,
bufferSize: 0,
avgFlushTime: 0,
lastFlushTime: 0,
};
// Store original console methods
this.originalConsole = {
log: console.log.bind(console),
info: console.info.bind(console),
warn: console.warn.bind(console),
error: console.error.bind(console),
debug: console.debug.bind(console),
};
this.setupHttpClient();
this.setupProcessHandlers();
if (this.config.interceptConsole) {
this.interceptConsole();
}
this.debugLogger.info('LogsInterceptor initialized', {
appName: this.config.appName,
environment: this.config.environment,
bufferSize: this.config.buffer.maxSize,
flushInterval: this.config.buffer.flushInterval,
});
}
validateAndNormalizeConfig(config) {
if (!config.transport?.url) {
throw new Error('Transport URL is required');
}
if (!config.transport?.tenantId) {
throw new Error('Tenant ID is required');
}
if (!config.appName) {
throw new Error('App name is required');
}
return {
transport: {
url: config.transport.url,
tenantId: config.transport.tenantId,
authToken: config.transport.authToken ?? '',
timeout: config.transport.timeout ?? 5000,
maxRetries: config.transport.maxRetries ?? 3,
retryDelay: config.transport.retryDelay ?? 1000,
compression: config.transport.compression ?? true,
},
appName: config.appName,
version: config.version ?? '1.0.0',
environment: config.environment ?? 'production',
labels: config.labels ?? {},
dynamicLabels: {
trace_id: () => {
const span = api_1.trace.getSpan(api_1.context.active());
return span?.spanContext().traceId ?? 'undefined';
},
span_id: () => {
const span = api_1.trace.getSpan(api_1.context.active());
return span?.spanContext().spanId ?? 'undefined';
},
...(config.dynamicLabels ?? {}),
},
buffer: {
maxSize: config.buffer?.maxSize ?? 100,
flushInterval: config.buffer?.flushInterval ?? 5000,
maxAge: config.buffer?.maxAge ?? 30000,
autoFlush: config.buffer?.autoFlush ?? true,
},
filter: {
levels: config.filter?.levels ?? ['debug', 'info', 'warn', 'error', 'fatal'],
patterns: config.filter?.patterns ?? [],
samplingRate: config.filter?.samplingRate ?? 1.0,
maxMessageLength: config.filter?.maxMessageLength ?? 8192,
},
enableMetrics: config.enableMetrics ?? true,
enableHealthCheck: config.enableHealthCheck ?? true,
interceptConsole: config.interceptConsole ?? false,
preserveOriginalConsole: config.preserveOriginalConsole ?? true,
debug: config.debug ?? false,
silentErrors: config.silentErrors ?? false,
};
}
setupHttpClient() {
const headers = {
'Content-Type': 'application/json',
'X-Scope-OrgID': this.config.transport.tenantId,
};
if (this.config.transport.authToken) {
headers['Authorization'] = `Bearer ${this.config.transport.authToken}`;
}
this.httpClient = axios_1.default.create({
baseURL: this.config.transport.url,
timeout: this.config.transport.timeout,
headers,
});
// Add response interceptor for error handling
this.httpClient.interceptors.response.use((response) => response, (error) => {
this.metrics.errorCount++;
this.emit('error', error);
if (!this.config.silentErrors) {
this.debugLogger.error('HTTP request failed', {
error: error.message,
status: error.response?.status,
statusText: error.response?.statusText,
data: error.response?.data,
});
}
throw error;
});
}
setupProcessHandlers() {
const gracefulShutdown = () => {
this.debugLogger.info('Graceful shutdown initiated');
this.flush().finally(() => {
process.exit(0);
});
};
process.on('SIGINT', gracefulShutdown);
process.on('SIGTERM', gracefulShutdown);
process.on('exit', () => {
this.flushSync();
});
process.on('uncaughtException', (error) => {
this.error('Uncaught exception', { error: error.message, stack: error.stack });
this.flushSync();
process.exit(1);
});
process.on('unhandledRejection', (reason) => {
this.error('Unhandled rejection', { reason: (0, utils_1.safeStringify)(reason) });
this.flushSync();
});
}
interceptConsole() {
const methodMap = {
log: 'info',
info: 'info',
warn: 'warn',
error: 'error',
debug: 'debug',
};
['log', 'info', 'warn', 'error', 'debug'].forEach((method) => {
const original = this.originalConsole[method];
console[method] = (...args) => {
const message = args
.map((arg) => (typeof arg === 'string' ? arg : (0, utils_1.safeStringify)(arg)))
.join(' ');
this.log(methodMap[method] || 'info', message);
if (this.config.preserveOriginalConsole) {
original(...args);
}
};
});
}
restoreConsole() {
console.log = this.originalConsole.log;
console.info = this.originalConsole.info;
console.warn = this.originalConsole.warn;
console.error = this.originalConsole.error;
console.debug = this.originalConsole.debug;
}
shouldLog(level, message) {
// Check if level is enabled
if (!this.config.filter.levels.includes(level)) {
return false;
}
// Check message patterns
if (this.config.filter.patterns.length > 0) {
const shouldInclude = this.config.filter.patterns.some(pattern => pattern.test(message));
if (!shouldInclude) {
return false;
}
}
// Apply sampling
if (!(0, utils_1.shouldSample)(this.config.filter.samplingRate)) {
this.metrics.logsDropped++;
return false;
}
return true;
}
createLogEntry(level, message, context) {
// Truncate message if too long
const truncatedMessage = message.length > this.config.filter.maxMessageLength
? message.substring(0, this.config.filter.maxMessageLength) + '...[truncated]'
: message;
// Compute dynamic labels
const dynamicLabels = Object.entries(this.config.dynamicLabels).reduce((acc, [key, fn]) => {
try {
acc[key] = String(fn());
}
catch (error) {
acc[key] = 'error';
this.debugLogger.warn(`Failed to compute dynamic label ${key}`, { error });
}
return acc;
}, {});
return {
timestamp: new Date().toISOString(),
level,
message: truncatedMessage,
context,
traceId: dynamicLabels.trace_id,
spanId: dynamicLabels.span_id,
labels: {
app: this.config.appName,
version: this.config.version,
environment: this.config.environment,
level,
...this.config.labels,
...dynamicLabels,
},
};
}
scheduleFlush() {
if (this.flushTimer || !this.config.buffer.autoFlush)
return;
this.flushTimer = setTimeout(() => {
this.flush().catch((error) => {
this.debugLogger.error('Scheduled flush failed', { error });
});
}, this.config.buffer.flushInterval);
}
async retryOperation(operation, maxRetries = this.config.transport.maxRetries, delay = this.config.transport.retryDelay) {
let lastError;
for (let attempt = 1; attempt <= maxRetries; attempt++) {
try {
return await operation();
}
catch (error) {
lastError = error;
if (attempt === maxRetries) {
throw lastError;
}
this.debugLogger.warn(`Attempt ${attempt} failed, retrying in ${delay}ms`, {
error: lastError.message,
});
await new Promise(resolve => setTimeout(resolve, delay * attempt));
}
}
throw lastError;
}
formatForLoki(entries) {
const streamMap = new Map();
entries.forEach((entry) => {
const streamKey = JSON.stringify(entry.labels);
const timestamp = String(Date.parse(entry.timestamp) * 1000000); // nanoseconds
// Format log line as plain string, not JSON
let logLine = `[${entry.level.toUpperCase()}] ${entry.message}`;
if (entry.context && Object.keys(entry.context).length > 0) {
logLine += ` ${(0, utils_1.safeStringify)(entry.context)}`;
}
if (entry.traceId && entry.traceId !== 'undefined') {
logLine += ` traceId=${entry.traceId}`;
}
if (entry.spanId && entry.spanId !== 'undefined') {
logLine += ` spanId=${entry.spanId}`;
}
if (!streamMap.has(streamKey)) {
streamMap.set(streamKey, []);
}
streamMap.get(streamKey).push([timestamp, logLine]);
});
return {
streams: Array.from(streamMap.entries()).map(([streamKey, values]) => ({
stream: JSON.parse(streamKey),
values: values.sort((a, b) => a[0].localeCompare(b[0])), // Sort by timestamp
})),
};
}
flushSync() {
if (this.buffer.length === 0)
return;
try {
// This is a synchronous flush for process exit scenarios
// In a real implementation, you might want to use a synchronous HTTP library
// or implement a proper shutdown sequence
this.debugLogger.warn('Synchronous flush attempted - some logs may be lost');
}
catch (error) {
this.debugLogger.error('Synchronous flush failed', { error });
}
}
// Public API methods
log(level, message, context) {
if (this.isDestroyed || !this.shouldLog(level, message)) {
return;
}
const entry = this.createLogEntry(level, message, context);
this.buffer.push(entry);
this.metrics.logsProcessed++;
this.metrics.bufferSize = this.buffer.length;
this.emit('log', entry);
// Auto-flush if buffer is full
if (this.buffer.length >= this.config.buffer.maxSize) {
this.flush().catch((error) => {
this.debugLogger.error('Auto-flush failed', { error });
});
}
else {
this.scheduleFlush();
}
}
debug(message, context) {
this.log('debug', message, context);
}
info(message, context) {
this.log('info', message, context);
}
warn(message, context) {
this.log('warn', message, context);
}
error(message, context) {
this.log('error', message, context);
}
fatal(message, context) {
this.log('fatal', message, context);
// Force immediate flush for fatal errors
this.flush().catch(() => {
// Silent fail for fatal errors
});
}
trackEvent(eventName, properties) {
this.info(`[EVENT] ${eventName}`, properties);
}
async flush() {
if (this.flushTimer) {
clearTimeout(this.flushTimer);
this.flushTimer = null;
}
if (this.buffer.length === 0) {
return;
}
const entries = this.buffer.splice(0);
const startTime = perf_hooks_1.performance.now();
try {
const payload = this.formatForLoki(entries);
// Debug payload
if (this.config.debug) {
this.debugLogger.debug('Sending payload to Loki', {
url: this.config.transport.url,
streamCount: payload.streams.length,
totalLogs: payload.streams.reduce((sum, s) => sum + s.values.length, 0),
firstStream: payload.streams[0] ? {
labels: payload.streams[0].stream,
valueCount: payload.streams[0].values.length,
firstValue: payload.streams[0].values[0]
} : 'no streams'
});
}
await this.retryOperation(async () => {
let data = JSON.stringify(payload);
const headers = {};
// Add gzip compression if enabled
if (this.config.transport.compression) {
const zlib = require('zlib');
data = zlib.gzipSync(data);
headers['Content-Encoding'] = 'gzip';
}
await this.httpClient.post('', data, { headers });
});
const flushTime = perf_hooks_1.performance.now() - startTime;
this.metrics.flushCount++;
this.metrics.lastFlushTime = Date.now();
this.metrics.avgFlushTime =
(this.metrics.avgFlushTime * (this.metrics.flushCount - 1) + flushTime) /
this.metrics.flushCount;
this.metrics.bufferSize = this.buffer.length;
this.emit('flush', { count: entries.length, duration: flushTime });
this.debugLogger.debug(`Flushed ${entries.length} logs in ${flushTime.toFixed(2)}ms`);
}
catch (error) {
// Re-add entries to buffer on failure
this.buffer.unshift(...entries);
this.metrics.errorCount++;
this.emit('error', error);
throw error;
}
}
getMetrics() {
return { ...this.metrics, bufferSize: this.buffer.length };
}
getHealth() {
const now = Date.now();
const timeSinceLastFlush = now - this.metrics.lastFlushTime;
const bufferUtilization = this.buffer.length / this.config.buffer.maxSize;
return {
healthy: this.metrics.errorCount < 10 &&
timeSinceLastFlush < this.config.buffer.flushInterval * 5 &&
bufferUtilization < 0.9,
lastSuccessfulFlush: this.metrics.lastFlushTime,
consecutiveErrors: this.metrics.errorCount,
bufferUtilization,
uptime: now - this.startTime,
};
}
async destroy() {
if (this.isDestroyed)
return;
this.debugLogger.info('Destroying LogsInterceptor');
this.isDestroyed = true;
if (this.flushTimer) {
clearTimeout(this.flushTimer);
this.flushTimer = null;
}
if (this.config.interceptConsole) {
this.restoreConsole();
}
// Final flush
try {
await this.flush();
}
catch (error) {
this.debugLogger.error('Final flush failed during destroy', { error });
}
this.removeAllListeners();
this.debugLogger.info('LogsInterceptor destroyed');
}
}
exports.LogsInterceptor = LogsInterceptor;
//# sourceMappingURL=logger.js.map