added logger

This commit is contained in:
Bojan Kucera 2025-06-03 18:31:02 -04:00
parent dd27f3bf2c
commit 58ae897e90
13 changed files with 1493 additions and 12 deletions

43
libs/logger/src/index.ts Normal file
View file

@ -0,0 +1,43 @@
/**
* @stock-bot/logger - Enhanced logging library with Loki integration
*
* Main exports for the logger library
*/
// Core logger classes and functions
export { Logger, createLogger, getLogger, shutdownLoggers } from './logger';
// Utility functions
export {
createTimer,
formatError,
sanitizeMetadata,
generateCorrelationId,
extractHttpMetadata,
createBusinessEvent,
createSecurityEvent,
maskSensitiveData,
calculateLogSize,
LogThrottle
} from './utils';
// Express middleware
export {
loggingMiddleware,
errorLoggingMiddleware,
createRequestLogger
} from './middleware';
// Type exports
export type {
LogLevel,
LogContext,
LogMetadata,
LoggerOptions,
LokiTransportOptions,
PerformanceTimer,
LokiLogEntry,
StructuredLog
} from './types';
export type { LoggingMiddlewareOptions } from './middleware';

358
libs/logger/src/logger.ts Normal file
View file

@ -0,0 +1,358 @@
/**
* Enhanced logger with Loki integration for Stock Bot platform
*
* Features:
* - Multiple log levels (debug, info, warn, error)
* - Console and file logging
* - Loki integration for centralized logging
* - Structured logging with metadata
* - Performance optimized with batching
* - Service-specific context
*/
import winston from 'winston';
import LokiTransport from 'winston-loki';
import DailyRotateFile from 'winston-daily-rotate-file';
import { loggingConfig, lokiConfig } from '@stock-bot/config';
import type { LogLevel, LogContext, LogMetadata } from './types';
// Global logger instances cache
const loggerInstances = new Map<string, winston.Logger>();
/**
* Create or retrieve a logger instance for a specific service
*/
export function createLogger(serviceName: string, options?: {
level?: LogLevel;
enableLoki?: boolean;
enableFile?: boolean;
enableConsole?: boolean;
}): winston.Logger {
const key = `${serviceName}-${JSON.stringify(options || {})}`;
if (loggerInstances.has(key)) {
return loggerInstances.get(key)!;
}
const logger = buildLogger(serviceName, options);
loggerInstances.set(key, logger);
return logger;
}
/**
* Build a winston logger with all configured transports
*/
function buildLogger(serviceName: string, options?: {
level?: LogLevel;
enableLoki?: boolean;
enableFile?: boolean;
enableConsole?: boolean;
}): winston.Logger {
const {
level = loggingConfig.LOG_LEVEL as LogLevel,
enableLoki = true,
enableFile = loggingConfig.LOG_FILE,
enableConsole = loggingConfig.LOG_CONSOLE
} = options || {}; // Base logger configuration
const transports: winston.transport[] = [];
// Console transport
if (enableConsole) {
transports.push(new winston.transports.Console({
format: winston.format.combine(
winston.format.colorize(),
winston.format.simple(),
winston.format.printf(({ timestamp, level, service, message, metadata }) => {
const meta = metadata && Object.keys(metadata).length > 0
? `\n${JSON.stringify(metadata, null, 2)}`
: '';
return `${timestamp} [${level}] [${service}] ${message}${meta}`;
})
)
}));
}
// File transport with daily rotation
if (enableFile) {
// General log file
transports.push(new DailyRotateFile({
filename: `${loggingConfig.LOG_FILE_PATH}/${serviceName}-%DATE%.log`,
datePattern: loggingConfig.LOG_FILE_DATE_PATTERN,
zippedArchive: true,
maxSize: loggingConfig.LOG_FILE_MAX_SIZE,
maxFiles: loggingConfig.LOG_FILE_MAX_FILES,
format: winston.format.combine(
winston.format.timestamp(),
winston.format.json()
)
}));
// Separate error log file
if (loggingConfig.LOG_ERROR_FILE) {
transports.push(new DailyRotateFile({
level: 'error',
filename: `${loggingConfig.LOG_FILE_PATH}/${serviceName}-error-%DATE%.log`,
datePattern: loggingConfig.LOG_FILE_DATE_PATTERN,
zippedArchive: true,
maxSize: loggingConfig.LOG_FILE_MAX_SIZE,
maxFiles: loggingConfig.LOG_FILE_MAX_FILES,
format: winston.format.combine(
winston.format.timestamp(),
winston.format.json()
)
}));
}
}
// Loki transport for centralized logging
if (enableLoki && lokiConfig.LOKI_HOST) {
try {
const lokiTransport = new LokiTransport({
host: lokiConfig.LOKI_URL || `http://${lokiConfig.LOKI_HOST}:${lokiConfig.LOKI_PORT}`,
labels: {
service: serviceName,
environment: lokiConfig.LOKI_ENVIRONMENT_LABEL,
...(lokiConfig.LOKI_DEFAULT_LABELS ? JSON.parse(lokiConfig.LOKI_DEFAULT_LABELS) : {})
},
json: true,
batching: true,
interval: lokiConfig.LOKI_FLUSH_INTERVAL_MS,
timeout: lokiConfig.LOKI_PUSH_TIMEOUT,
basicAuth: lokiConfig.LOKI_USERNAME && lokiConfig.LOKI_PASSWORD
? `${lokiConfig.LOKI_USERNAME}:${lokiConfig.LOKI_PASSWORD}`
: undefined,
onConnectionError: (err) => {
console.error('Loki connection error:', err);
}
});
transports.push(lokiTransport);
} catch (error) {
console.warn('Failed to initialize Loki transport:', error);
}
}
const loggerConfig: winston.LoggerOptions = {
level,
format: winston.format.combine(
winston.format.timestamp({ format: 'YYYY-MM-DD HH:mm:ss.SSS' }),
winston.format.errors({ stack: true }),
winston.format.metadata({
fillExcept: ['message', 'level', 'timestamp', 'service']
}),
winston.format.json()
),
defaultMeta: {
service: serviceName,
environment: loggingConfig.LOG_ENVIRONMENT,
version: loggingConfig.LOG_SERVICE_VERSION
},
transports
};
return winston.createLogger(loggerConfig);
}
/**
* Enhanced Logger class with convenience methods
*/
export class Logger {
private winston: winston.Logger;
private serviceName: string;
private context: LogContext;
constructor(serviceName: string, context: LogContext = {}, options?: {
level?: LogLevel;
enableLoki?: boolean;
enableFile?: boolean;
enableConsole?: boolean;
}) {
this.serviceName = serviceName;
this.context = context;
this.winston = createLogger(serviceName, options);
}
/**
* Debug level logging
*/
debug(message: string, metadata?: LogMetadata): void {
this.log('debug', message, metadata);
}
/**
* Info level logging
*/
info(message: string, metadata?: LogMetadata): void {
this.log('info', message, metadata);
}
/**
* Warning level logging
*/
warn(message: string, metadata?: LogMetadata): void {
this.log('warn', message, metadata);
}
/**
* Error level logging
*/
error(message: string, error?: Error | any, metadata?: LogMetadata): void {
const logData: LogMetadata = { ...metadata };
if (error) {
if (error instanceof Error) {
logData.error = {
name: error.name,
message: error.message,
stack: loggingConfig.LOG_ERROR_STACK ? error.stack : undefined
};
} else {
logData.error = error;
}
}
this.log('error', message, logData);
}
/**
* HTTP request logging
*/
http(message: string, requestData?: {
method?: string;
url?: string;
statusCode?: number;
responseTime?: number;
userAgent?: string;
ip?: string;
}): void {
if (!loggingConfig.LOG_HTTP_REQUESTS) return;
this.log('http', message, {
request: requestData,
type: 'http_request'
});
}
/**
* Performance/timing logging
*/
performance(message: string, timing: {
operation: string;
duration: number;
startTime?: number;
endTime?: number;
}): void {
if (!loggingConfig.LOG_PERFORMANCE) return;
this.log('info', message, {
performance: timing,
type: 'performance'
});
}
/**
* Business event logging
*/
business(message: string, event: {
type: string;
entity?: string;
action?: string;
result?: 'success' | 'failure' | 'partial';
amount?: number;
symbol?: string;
}): void {
this.log('info', message, {
business: event,
type: 'business_event'
});
}
/**
* Security event logging
*/
security(message: string, event: {
type: 'authentication' | 'authorization' | 'access' | 'vulnerability';
user?: string;
resource?: string;
action?: string;
result?: 'success' | 'failure';
ip?: string;
severity?: 'low' | 'medium' | 'high' | 'critical';
}): void {
this.log('warn', message, {
security: event,
type: 'security_event'
});
}
/**
* Create a child logger with additional context
*/
child(context: LogContext): Logger {
return new Logger(this.serviceName, { ...this.context, ...context });
}
/**
* Add persistent context to this logger
*/
addContext(context: LogContext): void {
this.context = { ...this.context, ...context };
}
/**
* Internal logging method
*/
private log(level: LogLevel, message: string, metadata?: LogMetadata): void {
const logData = {
...this.context,
...metadata,
timestamp: new Date().toISOString()
};
this.winston.log(level, message, logData);
}
/**
* Get the underlying winston logger
*/
getWinstonLogger(): winston.Logger {
return this.winston;
}
/**
* Gracefully close all transports
*/
async close(): Promise<void> {
return new Promise((resolve) => {
this.winston.end(() => {
resolve();
});
});
}
}
/**
* Create a default logger instance for a service
*/
export function getLogger(serviceName: string, context?: LogContext): Logger {
return new Logger(serviceName, context);
}
/**
* Shutdown all logger instances gracefully
*/
export async function shutdownLoggers(): Promise<void> {
const closePromises = Array.from(loggerInstances.values()).map(logger =>
new Promise<void>((resolve) => {
logger.end(() => {
resolve();
});
})
);
await Promise.all(closePromises);
loggerInstances.clear();
}
// Export types for convenience
export type { LogLevel, LogContext, LogMetadata } from './types';

View file

@ -0,0 +1,200 @@
/**
* Hono middleware for request logging
*/
import type { Context, Next } from 'hono';
import { Logger } from './logger';
import { generateCorrelationId, createTimer } from './utils';
export interface LoggingMiddlewareOptions {
logger?: Logger;
serviceName?: string;
skipPaths?: string[];
skipSuccessfulRequests?: boolean;
logRequestBody?: boolean;
logResponseBody?: boolean;
maxBodySize?: number;
}
/**
* Hono middleware for HTTP request/response logging
*/
export function loggingMiddleware(options: LoggingMiddlewareOptions = {}) {
const {
serviceName = 'unknown-service',
skipPaths = ['/health', '/metrics', '/favicon.ico'],
skipSuccessfulRequests = false,
logRequestBody = false,
logResponseBody = false,
maxBodySize = 1024
} = options;
// Create logger if not provided
const logger = options.logger || new Logger(serviceName);
return async (c: Context, next: Next) => {
const url = new URL(c.req.url);
const path = url.pathname;
// Skip certain paths
if (skipPaths.some(skipPath => path.startsWith(skipPath))) {
return next();
} // Generate correlation ID
const correlationId = generateCorrelationId();
// Store correlation ID in context for later use
c.set('correlationId', correlationId);
// Set correlation ID as response header
c.header('x-correlation-id', correlationId);
// Start timer
const timer = createTimer(`${c.req.method} ${path}`); // Extract request metadata
const requestMetadata: any = {
method: c.req.method,
url: c.req.url,
path: path,
userAgent: c.req.header('user-agent'),
contentType: c.req.header('content-type'),
contentLength: c.req.header('content-length'),
ip: c.req.header('x-forwarded-for') || c.req.header('x-real-ip') || 'unknown'
};
// Add request body if enabled
if (logRequestBody) {
try {
const body = await c.req.text();
if (body) {
requestMetadata.body = body.length > maxBodySize
? body.substring(0, maxBodySize) + '...[truncated]'
: body;
}
} catch (error) {
// Body might not be available or already consumed
}
} // Log request start
logger.http('HTTP Request started', {
method: c.req.method,
url: c.req.url,
userAgent: c.req.header('user-agent'),
ip: c.req.header('x-forwarded-for') || c.req.header('x-real-ip') || 'unknown'
});
// Process request
await next();
// Calculate response time
const timing = timer.end();
// Get response information
const response = c.res;
const status = response.status;
// Determine log level based on status code
const isError = status >= 400;
const isSuccess = status >= 200 && status < 300;
// Skip successful requests if configured
if (skipSuccessfulRequests && isSuccess) {
return;
}
const responseMetadata: any = {
correlationId,
request: requestMetadata,
response: {
statusCode: status,
statusText: response.statusText,
contentLength: response.headers.get('content-length'),
contentType: response.headers.get('content-type')
},
performance: timing
};
// Add response body if enabled
if (logResponseBody) {
try {
const responseBody = await response.clone().text();
if (responseBody) {
responseMetadata.response.body = responseBody.length > maxBodySize
? responseBody.substring(0, maxBodySize) + '...[truncated]'
: responseBody;
}
} catch (error) {
// Response body might not be available
}
} // Log based on status code
if (isError) {
logger.error('HTTP Request failed', undefined, {
correlationId,
request: requestMetadata,
response: {
statusCode: status,
statusText: response.statusText,
contentLength: response.headers.get('content-length'),
contentType: response.headers.get('content-type')
},
performance: timing
});
} else {
logger.http('HTTP Request completed', {
method: c.req.method,
url: c.req.url,
statusCode: status,
responseTime: timing.duration,
userAgent: c.req.header('user-agent'),
ip: c.req.header('x-forwarded-for') || c.req.header('x-real-ip') || 'unknown'
});
}
};
}
/**
* Error logging middleware for Hono
*/
export function errorLoggingMiddleware(logger?: Logger) {
return async (c: Context, next: Next) => {
const errorLogger = logger || new Logger('error-handler');
try {
await next();
} catch (err) {
const correlationId = c.get('correlationId') || c.req.header('x-correlation-id');
const url = new URL(c.req.url);
const requestMetadata = {
method: c.req.method,
url: c.req.url,
path: url.pathname,
userAgent: c.req.header('user-agent'),
ip: c.req.header('x-forwarded-for') || c.req.header('x-real-ip') || 'unknown'
};
errorLogger.error('Unhandled HTTP error', err instanceof Error ? err : new Error(String(err)), {
correlationId,
request: requestMetadata,
response: {
statusCode: c.res.status
}
});
// Re-throw the error so Hono can handle it
throw err;
}
};
}
/**
* Create a child logger with request context for Hono
*/
export function createRequestLogger(c: Context, baseLogger: Logger): Logger {
const correlationId = c.get('correlationId') || c.req.header('x-correlation-id');
const url = new URL(c.req.url);
return baseLogger.child({
correlationId,
requestId: correlationId,
method: c.req.method,
path: url.pathname,
userAgent: c.req.header('user-agent'),
ip: c.req.header('x-forwarded-for') || c.req.header('x-real-ip') || 'unknown'
});
}

119
libs/logger/src/types.ts Normal file
View file

@ -0,0 +1,119 @@
/**
* Type definitions for the logger library
*/
// Standard log levels
export type LogLevel = 'error' | 'warn' | 'info' | 'http' | 'verbose' | 'debug' | 'silly';
// Context that persists across log calls
export interface LogContext {
[key: string]: any;
requestId?: string;
sessionId?: string;
userId?: string;
traceId?: string;
spanId?: string;
correlationId?: string;
}
// Metadata for individual log entries
export interface LogMetadata {
[key: string]: any;
correlationId?: string;
error?: {
name: string;
message: string;
stack?: string;
} | any;request?: {
method?: string;
url?: string;
path?: string;
statusCode?: number;
responseTime?: number;
userAgent?: string;
ip?: string;
body?: string;
headers?: Record<string, string>;
contentType?: string;
contentLength?: string;
};
performance?: {
operation: string;
duration: number;
startTime?: number;
endTime?: number;
};
business?: {
type: string;
entity?: string;
action?: string;
result?: 'success' | 'failure' | 'partial';
amount?: number;
symbol?: string;
};
security?: {
type: 'authentication' | 'authorization' | 'access' | 'vulnerability';
user?: string;
resource?: string;
action?: string;
result?: 'success' | 'failure';
ip?: string;
severity?: 'low' | 'medium' | 'high' | 'critical';
};
type?: 'http_request' | 'performance' | 'business_event' | 'security_event' | 'system' | 'custom';
}
// Logger configuration options
export interface LoggerOptions {
level?: LogLevel;
enableLoki?: boolean;
enableFile?: boolean;
enableConsole?: boolean;
}
// Loki-specific configuration
export interface LokiTransportOptions {
host: string;
port?: number;
url?: string;
username?: string;
password?: string;
tenantId?: string;
labels?: Record<string, string>;
batchSize?: number;
interval?: number;
timeout?: number;
json?: boolean;
batching?: boolean;
basicAuth?: string;
}
// Performance timer utility type
export interface PerformanceTimer {
operation: string;
startTime: number;
end(): { operation: string; duration: number; startTime: number; endTime: number };
}
// Log entry structure for Loki
export interface LokiLogEntry {
timestamp: string;
level: LogLevel;
message: string;
service: string;
environment?: string;
version?: string;
metadata?: LogMetadata;
}
// Structured log format
export interface StructuredLog {
timestamp: string;
level: LogLevel;
service: string;
message: string;
context?: LogContext;
metadata?: LogMetadata;
environment?: string;
version?: string;
}

230
libs/logger/src/utils.ts Normal file
View file

@ -0,0 +1,230 @@
/**
* Utility functions for logging operations
*/
import type { PerformanceTimer, LogMetadata } from './types';
/**
* Create a performance timer to measure operation duration
*/
export function createTimer(operation: string): PerformanceTimer {
const startTime = Date.now();
return {
operation,
startTime,
end() {
const endTime = Date.now();
return {
operation,
duration: endTime - startTime,
startTime,
endTime
};
}
};
}
/**
* Format error for logging
*/
export function formatError(error: Error | any): LogMetadata['error'] {
if (error instanceof Error) {
return {
name: error.name,
message: error.message,
stack: error.stack
};
}
if (typeof error === 'object' && error !== null) {
return {
name: error.constructor?.name || 'UnknownError',
message: error.message || error.toString(),
...error
};
}
return {
name: 'UnknownError',
message: String(error)
};
}
/**
* Sanitize sensitive data from log metadata
*/
export function sanitizeMetadata(metadata: LogMetadata): LogMetadata {
const sensitiveKeys = [
'password', 'token', 'secret', 'key', 'apiKey', 'api_key',
'authorization', 'auth', 'credential', 'credentials'
];
function sanitizeValue(value: any): any {
if (typeof value === 'string') {
return '[REDACTED]';
}
if (Array.isArray(value)) {
return value.map(sanitizeValue);
}
if (typeof value === 'object' && value !== null) {
const sanitized: any = {};
for (const [key, val] of Object.entries(value)) {
const lowerKey = key.toLowerCase();
if (sensitiveKeys.some(sensitive => lowerKey.includes(sensitive))) {
sanitized[key] = '[REDACTED]';
} else {
sanitized[key] = sanitizeValue(val);
}
}
return sanitized;
}
return value;
}
const sanitized: LogMetadata = {};
for (const [key, value] of Object.entries(metadata)) {
const lowerKey = key.toLowerCase();
if (sensitiveKeys.some(sensitive => lowerKey.includes(sensitive))) {
sanitized[key] = '[REDACTED]';
} else {
sanitized[key] = sanitizeValue(value);
}
}
return sanitized;
}
/**
* Generate a correlation ID for request tracking
*/
export function generateCorrelationId(): string {
return `${Date.now()}-${Math.random().toString(36).substr(2, 9)}`;
}
/**
* Extract relevant HTTP request information for logging
*/
export function extractHttpMetadata(req: any): LogMetadata['request'] {
if (!req) return undefined;
return {
method: req.method,
url: req.url || req.originalUrl,
userAgent: req.get?.('User-Agent') || req.headers?.['user-agent'],
ip: req.ip || req.connection?.remoteAddress || req.headers?.['x-forwarded-for']
};
}
/**
* Create standardized business event metadata
*/
export function createBusinessEvent(
type: string,
action: string,
options?: {
entity?: string;
result?: 'success' | 'failure' | 'partial';
amount?: number;
symbol?: string;
[key: string]: any;
}
): LogMetadata {
return {
business: {
type,
action,
...options
},
type: 'business_event'
};
}
/**
* Create standardized security event metadata
*/
export function createSecurityEvent(
type: 'authentication' | 'authorization' | 'access' | 'vulnerability',
options?: {
user?: string;
resource?: string;
action?: string;
result?: 'success' | 'failure';
ip?: string;
severity?: 'low' | 'medium' | 'high' | 'critical';
[key: string]: any;
}
): LogMetadata {
return {
security: {
type,
...options
},
type: 'security_event'
};
}
/**
* Mask sensitive data in strings
*/
export function maskSensitiveData(str: string): string {
// Credit card numbers
str = str.replace(/\b\d{4}[\s-]?\d{4}[\s-]?\d{4}[\s-]?\d{4}\b/g, '****-****-****-****');
// Email addresses
str = str.replace(/\b[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Z|a-z]{2,}\b/g, '***@***.***');
// API keys (common patterns)
str = str.replace(/\b[A-Za-z0-9]{32,}\b/g, '[API_KEY]');
// JWT tokens
str = str.replace(/eyJ[A-Za-z0-9_-]*\.[A-Za-z0-9_-]*\.[A-Za-z0-9_-]*/g, '[JWT_TOKEN]');
return str;
}
/**
* Calculate log entry size for batching
*/
export function calculateLogSize(entry: any): number {
return JSON.stringify(entry).length;
}
/**
* Throttle logging to prevent spam
*/
export class LogThrottle {
private counters = new Map<string, { count: number; lastReset: number }>();
private readonly maxLogs: number;
private readonly windowMs: number;
constructor(maxLogs = 100, windowMs = 60000) {
this.maxLogs = maxLogs;
this.windowMs = windowMs;
}
shouldLog(key: string): boolean {
const now = Date.now();
const counter = this.counters.get(key);
if (!counter || now - counter.lastReset > this.windowMs) {
this.counters.set(key, { count: 1, lastReset: now });
return true;
}
if (counter.count >= this.maxLogs) {
return false;
}
counter.count++;
return true;
}
reset(key?: string): void {
if (key) {
this.counters.delete(key);
} else {
this.counters.clear();
}
}
}