feat(utils): add observability and resilience utilities

Implement comprehensive utility modules for InfiniteAPI:

Logging:
- structured-logger: JSON logging with levels, sanitization, metrics
- logger-adapter: Pino/Console/Structured adapter pattern
- baileys-logger: WhatsApp-specific logger with categories

Observability:
- trace-context: Distributed tracing with spans, correlation IDs
- prometheus-metrics: Counter, Gauge, Histogram, Summary metrics

Resilience:
- cache-utils: LRU cache with TTL, multi-level support
- circuit-breaker: 3-state circuit breaker with fallbacks
- retry-utils: Exponential backoff with jitter, predicates

Event Streaming:
- baileys-event-stream: Priority queues, backpressure, DLQ

Includes unit tests for all new modules.
This commit is contained in:
Claude
2026-01-20 01:26:06 +00:00
parent 5b5ac85443
commit 882a1f57cf
16 changed files with 7695 additions and 0 deletions
+740
View File
@@ -0,0 +1,740 @@
/**
* @fileoverview Gerenciamento de eventos do Baileys
* @module Utils/baileys-event-stream
*
* Fornece:
* - Event buffering com backpressure
* - Event transformation e filtering
* - Priority queues para eventos
* - Batch processing
* - Dead letter queue para eventos falhos
* - Replay de eventos
* - Integração com logging e métricas
*/
import { EventEmitter } from 'events'
import { metrics } from './prometheus-metrics.js'
import type { BaileysLogCategory } from './baileys-logger.js'
/**
* Tipos de eventos do Baileys
*/
export type BaileysEventType =
| 'connection.update'
| 'creds.update'
| 'messaging-history.set'
| 'chats.set'
| 'contacts.set'
| 'messages.upsert'
| 'messages.update'
| 'messages.delete'
| 'messages.reaction'
| 'message-receipt.update'
| 'groups.upsert'
| 'groups.update'
| 'group-participants.update'
| 'presence.update'
| 'chats.update'
| 'chats.delete'
| 'labels.edit'
| 'labels.association'
| 'call'
| 'blocklist.set'
| 'blocklist.update'
| string // Para eventos customizados
/**
* Prioridade de eventos
*/
export type EventPriority = 'critical' | 'high' | 'normal' | 'low'
/**
* Valores numéricos de prioridade
*/
const PRIORITY_VALUES: Record<EventPriority, number> = {
critical: 0,
high: 1,
normal: 2,
low: 3,
}
/**
* Evento do stream
*/
export interface StreamEvent<T = unknown> {
id: string
type: BaileysEventType
data: T
timestamp: number
priority: EventPriority
category: BaileysLogCategory
metadata?: Record<string, unknown>
retryCount?: number
originalTimestamp?: number
}
/**
* Opções do Event Stream
*/
export interface EventStreamOptions {
/** Tamanho máximo do buffer (default: 10000) */
maxBufferSize?: number
/** Se deve aplicar backpressure quando buffer cheio */
enableBackpressure?: boolean
/** Limite de highWaterMark para backpressure */
highWaterMark?: number
/** Limite de lowWaterMark para retomar */
lowWaterMark?: number
/** Tamanho do batch para processamento */
batchSize?: number
/** Intervalo de flush em ms (0 = desabilitado) */
flushInterval?: number
/** Máximo de retries para eventos falhos */
maxRetries?: number
/** Tamanho da dead letter queue */
deadLetterQueueSize?: number
/** Se deve coletar métricas */
collectMetrics?: boolean
/** Nome do stream para métricas */
streamName?: string
}
/**
* Handler de evento
*/
export type EventHandler<T = unknown> = (event: StreamEvent<T>) => void | Promise<void>
/**
* Filtro de evento
*/
export type EventFilter<T = unknown> = (event: StreamEvent<T>) => boolean
/**
* Transformador de evento
*/
export type EventTransformer<T = unknown, R = unknown> = (event: StreamEvent<T>) => StreamEvent<R>
/**
* Resultado de processamento de batch
*/
export interface BatchResult {
processed: number
failed: number
duration: number
}
/**
* Estatísticas do stream
*/
export interface EventStreamStats {
bufferSize: number
totalReceived: number
totalProcessed: number
totalFailed: number
totalDropped: number
deadLetterQueueSize: number
isBackpressured: boolean
lastEventTimestamp?: number
eventsByType: Record<string, number>
eventsByPriority: Record<EventPriority, number>
}
/**
* Mapeamento de tipo de evento para categoria
*/
const EVENT_CATEGORY_MAP: Record<string, BaileysLogCategory> = {
'connection.update': 'connection',
'creds.update': 'auth',
'messaging-history.set': 'sync',
'chats.set': 'sync',
'contacts.set': 'sync',
'messages.upsert': 'message',
'messages.update': 'message',
'messages.delete': 'message',
'messages.reaction': 'message',
'message-receipt.update': 'message',
'groups.upsert': 'group',
'groups.update': 'group',
'group-participants.update': 'group',
'presence.update': 'presence',
'chats.update': 'message',
'chats.delete': 'message',
'call': 'call',
'blocklist.set': 'sync',
'blocklist.update': 'sync',
}
/**
* Prioridade padrão por tipo de evento
*/
const EVENT_PRIORITY_MAP: Partial<Record<BaileysEventType, EventPriority>> = {
'connection.update': 'critical',
'creds.update': 'critical',
'messages.upsert': 'high',
'messages.update': 'high',
'call': 'high',
'presence.update': 'low',
'messaging-history.set': 'normal',
}
/**
* Gera ID único para evento
*/
function generateEventId(): string {
return `${Date.now()}-${Math.random().toString(36).substring(2, 11)}`
}
/**
* Classe principal do Event Stream
*/
export class BaileysEventStream extends EventEmitter {
private buffer: StreamEvent[] = []
private handlers: Map<BaileysEventType | '*', Set<EventHandler>> = new Map()
private filters: EventFilter[] = []
private transformers: EventTransformer[] = []
private deadLetterQueue: StreamEvent[] = []
private options: Required<EventStreamOptions>
private stats: EventStreamStats
private isProcessing = false
private flushTimer?: ReturnType<typeof setInterval>
private paused = false
constructor(options: EventStreamOptions = {}) {
super()
this.options = {
maxBufferSize: options.maxBufferSize ?? 10000,
enableBackpressure: options.enableBackpressure ?? true,
highWaterMark: options.highWaterMark ?? 8000,
lowWaterMark: options.lowWaterMark ?? 2000,
batchSize: options.batchSize ?? 100,
flushInterval: options.flushInterval ?? 0,
maxRetries: options.maxRetries ?? 3,
deadLetterQueueSize: options.deadLetterQueueSize ?? 1000,
collectMetrics: options.collectMetrics ?? true,
streamName: options.streamName ?? 'baileys',
}
this.stats = this.createInitialStats()
// Iniciar flush periódico se configurado
if (this.options.flushInterval > 0) {
this.flushTimer = setInterval(() => this.flush(), this.options.flushInterval)
}
}
private createInitialStats(): EventStreamStats {
return {
bufferSize: 0,
totalReceived: 0,
totalProcessed: 0,
totalFailed: 0,
totalDropped: 0,
deadLetterQueueSize: 0,
isBackpressured: false,
eventsByType: {},
eventsByPriority: {
critical: 0,
high: 0,
normal: 0,
low: 0,
},
}
}
/**
* Adiciona evento ao stream
*/
push<T>(type: BaileysEventType, data: T, options?: { priority?: EventPriority; metadata?: Record<string, unknown> }): boolean {
// Verificar backpressure
if (this.options.enableBackpressure && this.buffer.length >= this.options.highWaterMark) {
this.stats.isBackpressured = true
this.emit('backpressure', { bufferSize: this.buffer.length })
if (this.buffer.length >= this.options.maxBufferSize) {
this.stats.totalDropped++
this.emit('dropped', { type, reason: 'buffer_full' })
if (this.options.collectMetrics) {
metrics.errors.inc({ category: 'event_stream', code: 'dropped' })
}
return false
}
}
const event: StreamEvent<T> = {
id: generateEventId(),
type,
data,
timestamp: Date.now(),
priority: options?.priority || EVENT_PRIORITY_MAP[type] || 'normal',
category: EVENT_CATEGORY_MAP[type] || 'unknown',
metadata: options?.metadata,
retryCount: 0,
}
// Aplicar transformadores
let transformedEvent: StreamEvent = event
for (const transformer of this.transformers) {
transformedEvent = transformer(transformedEvent)
}
// Aplicar filtros
for (const filter of this.filters) {
if (!filter(transformedEvent)) {
return false
}
}
// Adicionar ao buffer na posição correta (por prioridade)
this.insertByPriority(transformedEvent)
// Atualizar estatísticas
this.stats.totalReceived++
this.stats.bufferSize = this.buffer.length
this.stats.lastEventTimestamp = Date.now()
this.stats.eventsByType[type] = (this.stats.eventsByType[type] || 0) + 1
this.stats.eventsByPriority[event.priority]++
if (this.options.collectMetrics) {
metrics.socketEvents.inc({ event: type })
}
this.emit('event', transformedEvent)
// Processar se não estiver pausado
if (!this.paused && !this.isProcessing) {
this.processNext()
}
return true
}
/**
* Insere evento no buffer por prioridade
*/
private insertByPriority(event: StreamEvent): void {
const eventPriorityValue = PRIORITY_VALUES[event.priority]
// Encontrar posição correta
let insertIndex = this.buffer.length
for (let i = 0; i < this.buffer.length; i++) {
if (PRIORITY_VALUES[this.buffer[i].priority] > eventPriorityValue) {
insertIndex = i
break
}
}
this.buffer.splice(insertIndex, 0, event)
}
/**
* Registra handler para tipo de evento
*/
on<T = unknown>(event: BaileysEventType | '*', handler: EventHandler<T>): this {
if (!this.handlers.has(event)) {
this.handlers.set(event, new Set())
}
this.handlers.get(event)!.add(handler as EventHandler)
return this
}
/**
* Remove handler
*/
off(event: BaileysEventType | '*', handler: EventHandler): this {
const handlers = this.handlers.get(event)
if (handlers) {
handlers.delete(handler)
}
return this
}
/**
* Registra handler único
*/
once<T = unknown>(event: BaileysEventType, handler: EventHandler<T>): this {
const wrappedHandler: EventHandler<T> = (e) => {
this.off(event, wrappedHandler as EventHandler)
return handler(e)
}
return this.on(event, wrappedHandler)
}
/**
* Adiciona filtro
*/
addFilter(filter: EventFilter): this {
this.filters.push(filter)
return this
}
/**
* Remove filtro
*/
removeFilter(filter: EventFilter): this {
const index = this.filters.indexOf(filter)
if (index !== -1) {
this.filters.splice(index, 1)
}
return this
}
/**
* Adiciona transformador
*/
addTransformer(transformer: EventTransformer): this {
this.transformers.push(transformer)
return this
}
/**
* Processa próximos eventos
*/
private async processNext(): Promise<void> {
if (this.isProcessing || this.paused || this.buffer.length === 0) {
return
}
this.isProcessing = true
try {
// Pegar batch de eventos
const batch = this.buffer.splice(0, this.options.batchSize)
this.stats.bufferSize = this.buffer.length
// Verificar se saiu de backpressure
if (this.stats.isBackpressured && this.buffer.length <= this.options.lowWaterMark) {
this.stats.isBackpressured = false
this.emit('drain')
}
// Processar batch
const startTime = Date.now()
let processed = 0
let failed = 0
for (const event of batch) {
try {
await this.processEvent(event)
processed++
this.stats.totalProcessed++
} catch (error) {
failed++
this.stats.totalFailed++
await this.handleFailedEvent(event, error as Error)
}
}
const duration = Date.now() - startTime
this.emit('batch-processed', { processed, failed, duration } as BatchResult)
// Continuar processando se houver mais
if (this.buffer.length > 0) {
setImmediate(() => this.processNext())
}
} finally {
this.isProcessing = false
}
}
/**
* Processa um evento
*/
private async processEvent(event: StreamEvent): Promise<void> {
// Handlers específicos do tipo
const typeHandlers = this.handlers.get(event.type)
if (typeHandlers) {
for (const handler of typeHandlers) {
await handler(event)
}
}
// Handlers globais
const globalHandlers = this.handlers.get('*')
if (globalHandlers) {
for (const handler of globalHandlers) {
await handler(event)
}
}
}
/**
* Trata evento que falhou
*/
private async handleFailedEvent(event: StreamEvent, error: Error): Promise<void> {
event.retryCount = (event.retryCount || 0) + 1
if (event.retryCount <= this.options.maxRetries) {
// Re-adicionar ao buffer para retry
event.originalTimestamp = event.originalTimestamp || event.timestamp
event.timestamp = Date.now()
this.buffer.push(event)
this.stats.bufferSize = this.buffer.length
this.emit('retry', { event, error, attempt: event.retryCount })
} else {
// Enviar para dead letter queue
this.addToDeadLetterQueue(event, error)
}
if (this.options.collectMetrics) {
metrics.errors.inc({ category: 'event_stream', code: 'processing_failed' })
}
}
/**
* Adiciona evento à dead letter queue
*/
private addToDeadLetterQueue(event: StreamEvent, error: Error): void {
const dlqEvent = {
...event,
metadata: {
...event.metadata,
error: error.message,
errorStack: error.stack,
movedToDlqAt: Date.now(),
},
}
this.deadLetterQueue.push(dlqEvent)
// Limitar tamanho da DLQ
while (this.deadLetterQueue.length > this.options.deadLetterQueueSize) {
this.deadLetterQueue.shift()
}
this.stats.deadLetterQueueSize = this.deadLetterQueue.length
this.emit('dead-letter', dlqEvent)
}
/**
* Força flush do buffer
*/
async flush(): Promise<BatchResult> {
const startTime = Date.now()
let processed = 0
let failed = 0
while (this.buffer.length > 0 && !this.paused) {
const batch = this.buffer.splice(0, this.options.batchSize)
for (const event of batch) {
try {
await this.processEvent(event)
processed++
this.stats.totalProcessed++
} catch (error) {
failed++
this.stats.totalFailed++
await this.handleFailedEvent(event, error as Error)
}
}
}
this.stats.bufferSize = this.buffer.length
return {
processed,
failed,
duration: Date.now() - startTime,
}
}
/**
* Pausa o processamento
*/
pause(): void {
this.paused = true
this.emit('pause')
}
/**
* Resume o processamento
*/
resume(): void {
this.paused = false
this.emit('resume')
this.processNext()
}
/**
* Verifica se está pausado
*/
isPaused(): boolean {
return this.paused
}
/**
* Limpa o buffer
*/
clear(): void {
this.buffer = []
this.stats.bufferSize = 0
this.emit('clear')
}
/**
* Retorna eventos da dead letter queue
*/
getDeadLetterQueue(): StreamEvent[] {
return [...this.deadLetterQueue]
}
/**
* Limpa dead letter queue
*/
clearDeadLetterQueue(): void {
this.deadLetterQueue = []
this.stats.deadLetterQueueSize = 0
}
/**
* Replay eventos da dead letter queue
*/
async replayDeadLetterQueue(): Promise<BatchResult> {
const events = this.deadLetterQueue.splice(0)
this.stats.deadLetterQueueSize = 0
let processed = 0
let failed = 0
const startTime = Date.now()
for (const event of events) {
// Reset retry count
event.retryCount = 0
delete event.metadata?.error
delete event.metadata?.errorStack
delete event.metadata?.movedToDlqAt
try {
await this.processEvent(event)
processed++
} catch (error) {
failed++
this.addToDeadLetterQueue(event, error as Error)
}
}
return {
processed,
failed,
duration: Date.now() - startTime,
}
}
/**
* Retorna estatísticas
*/
getStats(): EventStreamStats {
return { ...this.stats }
}
/**
* Reseta estatísticas
*/
resetStats(): void {
this.stats = this.createInitialStats()
this.stats.bufferSize = this.buffer.length
this.stats.deadLetterQueueSize = this.deadLetterQueue.length
}
/**
* Destroy e limpa recursos
*/
destroy(): void {
if (this.flushTimer) {
clearInterval(this.flushTimer)
}
this.buffer = []
this.deadLetterQueue = []
this.handlers.clear()
this.filters = []
this.transformers = []
this.removeAllListeners()
}
}
/**
* Factory para criar event stream
*/
export function createEventStream(options?: EventStreamOptions): BaileysEventStream {
return new BaileysEventStream(options)
}
/**
* Filtros pré-definidos
*/
export const eventFilters = {
/** Filtra por tipo de evento */
byType:
(...types: BaileysEventType[]): EventFilter =>
(event) =>
types.includes(event.type),
/** Filtra por categoria */
byCategory:
(...categories: BaileysLogCategory[]): EventFilter =>
(event) =>
categories.includes(event.category),
/** Filtra por prioridade mínima */
byMinPriority:
(minPriority: EventPriority): EventFilter =>
(event) =>
PRIORITY_VALUES[event.priority] <= PRIORITY_VALUES[minPriority],
/** Filtra eventos recentes (dentro de ms) */
recentOnly:
(maxAgeMs: number): EventFilter =>
(event) =>
Date.now() - event.timestamp <= maxAgeMs,
/** Combina filtros com AND */
and:
(...filters: EventFilter[]): EventFilter =>
(event) =>
filters.every((f) => f(event)),
/** Combina filtros com OR */
or:
(...filters: EventFilter[]): EventFilter =>
(event) =>
filters.some((f) => f(event)),
}
/**
* Transformadores pré-definidos
*/
export const eventTransformers = {
/** Adiciona timestamp de processamento */
addProcessingTimestamp: (): EventTransformer => (event) => ({
...event,
metadata: {
...event.metadata,
processingTimestamp: Date.now(),
},
}),
/** Adiciona ID de trace */
addTraceId:
(traceIdGenerator: () => string): EventTransformer =>
(event) => ({
...event,
metadata: {
...event.metadata,
traceId: traceIdGenerator(),
},
}),
/** Eleva prioridade baseado em condição */
elevatepriorityIf:
(condition: (event: StreamEvent) => boolean, newPriority: EventPriority): EventTransformer =>
(event) =>
condition(event)
? { ...event, priority: newPriority }
: event,
}
export default BaileysEventStream
+493
View File
@@ -0,0 +1,493 @@
/**
* @fileoverview Logger customizado específico para Baileys
* @module Utils/baileys-logger
*
* Fornece:
* - Logger pré-configurado para contexto Baileys/WhatsApp
* - Categorização de eventos por tipo (connection, message, media, etc.)
* - Filtros específicos para reduzir ruído
* - Métricas de eventos WhatsApp
* - Formatação otimizada para debugging
*/
import type { ILogger } from './logger.js'
import { StructuredLogger, createStructuredLogger, type LogLevel, type LogEntry } from './structured-logger.js'
/**
* Categorias de log específicas do Baileys
*/
export type BaileysLogCategory =
| 'connection' // Eventos de conexão WebSocket
| 'auth' // Autenticação e QR code
| 'message' // Envio/recebimento de mensagens
| 'media' // Upload/download de mídia
| 'group' // Operações de grupo
| 'presence' // Status de presença
| 'call' // Chamadas de voz/vídeo
| 'sync' // Sincronização de dados
| 'encryption' // Operações de criptografia
| 'retry' // Retentativas de operações
| 'socket' // Eventos de socket baixo nível
| 'binary' // Codificação/decodificação binária
| 'unknown' // Categoria desconhecida
/**
* Configuração do Baileys Logger
*/
export interface BaileysLoggerConfig {
/** Nível de log padrão */
level: LogLevel
/** Categorias a serem ignoradas */
ignoredCategories?: BaileysLogCategory[]
/** Categorias com nível de log elevado (debug sempre) */
verboseCategories?: BaileysLogCategory[]
/** Se deve logar payloads de mensagens (pode ser sensível) */
logMessagePayloads?: boolean
/** Se deve logar dados binários em hex */
logBinaryData?: boolean
/** Prefixo para identificar instância */
instanceId?: string
/** Handler para eventos específicos */
eventHandler?: (category: BaileysLogCategory, entry: LogEntry) => void
/** Limite de tamanho para payloads logados (bytes) */
maxPayloadSize?: number
}
/**
* Métricas específicas do Baileys
*/
export interface BaileysLoggerMetrics {
connectionAttempts: number
connectionSuccesses: number
connectionFailures: number
messagesSent: number
messagesReceived: number
mediaUploads: number
mediaDownloads: number
retryAttempts: number
encryptionOperations: number
errorsByCategory: Record<BaileysLogCategory, number>
lastConnectionTime?: string
lastMessageTime?: string
}
/**
* Padrões para detectar categoria de log
*/
const CATEGORY_PATTERNS: Array<{ pattern: RegExp; category: BaileysLogCategory }> = [
{ pattern: /connect|disconnect|socket|ws|websocket|open|close/i, category: 'connection' },
{ pattern: /auth|qr|pairing|login|logout|creds/i, category: 'auth' },
{ pattern: /message|msg|chat|text|send|recv|read|receipt/i, category: 'message' },
{ pattern: /media|image|video|audio|document|sticker|upload|download/i, category: 'media' },
{ pattern: /group|participant|admin|subject|invite/i, category: 'group' },
{ pattern: /presence|online|offline|typing|available/i, category: 'presence' },
{ pattern: /call|voice|video|ring/i, category: 'call' },
{ pattern: /sync|history|initial|full/i, category: 'sync' },
{ pattern: /encrypt|decrypt|signal|key|cipher/i, category: 'encryption' },
{ pattern: /retry|attempt|backoff|reconnect/i, category: 'retry' },
{ pattern: /binary|encode|decode|proto|buffer/i, category: 'binary' },
]
/**
* Logger customizado para Baileys
*/
export class BaileysLogger implements ILogger {
private structuredLogger: StructuredLogger
private config: Required<BaileysLoggerConfig>
private metrics: BaileysLoggerMetrics
private childContext: Record<string, unknown> = {}
constructor(config: Partial<BaileysLoggerConfig> = {}) {
this.config = {
level: config.level || 'info',
ignoredCategories: config.ignoredCategories || [],
verboseCategories: config.verboseCategories || [],
logMessagePayloads: config.logMessagePayloads ?? false,
logBinaryData: config.logBinaryData ?? false,
instanceId: config.instanceId || this.generateInstanceId(),
eventHandler: config.eventHandler || (() => {}),
maxPayloadSize: config.maxPayloadSize || 1024,
}
this.structuredLogger = createStructuredLogger({
level: this.config.level,
name: `baileys:${this.config.instanceId}`,
jsonFormat: process.env.NODE_ENV === 'production',
redactFields: ['password', 'token', 'secret', 'key', 'authKey', 'macKey'],
})
this.metrics = this.createInitialMetrics()
}
private generateInstanceId(): string {
return Math.random().toString(36).substring(2, 8)
}
private createInitialMetrics(): BaileysLoggerMetrics {
return {
connectionAttempts: 0,
connectionSuccesses: 0,
connectionFailures: 0,
messagesSent: 0,
messagesReceived: 0,
mediaUploads: 0,
mediaDownloads: 0,
retryAttempts: 0,
encryptionOperations: 0,
errorsByCategory: {
connection: 0,
auth: 0,
message: 0,
media: 0,
group: 0,
presence: 0,
call: 0,
sync: 0,
encryption: 0,
retry: 0,
socket: 0,
binary: 0,
unknown: 0,
},
}
}
get level(): string {
return this.config.level
}
set level(newLevel: string) {
this.config.level = newLevel as LogLevel
this.structuredLogger.level = newLevel
}
/**
* Cria logger filho com contexto adicional
*/
child(obj: Record<string, unknown>): BaileysLogger {
const childLogger = new BaileysLogger(this.config)
childLogger.childContext = { ...this.childContext, ...obj }
return childLogger
}
/**
* Detecta categoria do log baseado no conteúdo
*/
private detectCategory(obj: unknown, msg?: string): BaileysLogCategory {
const searchText = [
msg || '',
typeof obj === 'string' ? obj : '',
typeof obj === 'object' && obj !== null ? JSON.stringify(obj) : '',
].join(' ')
for (const { pattern, category } of CATEGORY_PATTERNS) {
if (pattern.test(searchText)) {
return category
}
}
return 'unknown'
}
/**
* Verifica se categoria deve ser logada
*/
private shouldLogCategory(category: BaileysLogCategory, level: LogLevel): boolean {
if (this.config.ignoredCategories.includes(category)) {
return false
}
// Categorias verbose sempre logam em debug ou superior
if (this.config.verboseCategories.includes(category)) {
return true
}
return true
}
/**
* Sanitiza payload de mensagem
*/
private sanitizePayload(obj: unknown): unknown {
if (!this.config.logMessagePayloads) {
if (typeof obj === 'object' && obj !== null) {
const sanitized = { ...(obj as Record<string, unknown>) }
// Remover campos sensíveis de mensagem
const sensitiveFields = ['body', 'text', 'content', 'caption', 'payload', 'data']
for (const field of sensitiveFields) {
if (field in sanitized) {
const value = sanitized[field]
if (typeof value === 'string' && value.length > 0) {
sanitized[field] = `[${value.length} chars]`
} else if (Buffer.isBuffer(value)) {
sanitized[field] = `[Buffer: ${value.length} bytes]`
}
}
}
return sanitized
}
}
// Limitar tamanho do payload
if (typeof obj === 'object' && obj !== null) {
const str = JSON.stringify(obj)
if (str.length > this.config.maxPayloadSize) {
return {
_truncated: true,
_originalSize: str.length,
_preview: str.substring(0, 200) + '...',
}
}
}
return obj
}
/**
* Atualiza métricas baseado no log
*/
private updateMetrics(category: BaileysLogCategory, level: LogLevel, obj: unknown): void {
const objStr = typeof obj === 'object' ? JSON.stringify(obj) : String(obj)
switch (category) {
case 'connection':
if (/attempt|trying|connecting/i.test(objStr)) {
this.metrics.connectionAttempts++
} else if (/success|connected|open/i.test(objStr)) {
this.metrics.connectionSuccesses++
this.metrics.lastConnectionTime = new Date().toISOString()
} else if (/fail|error|close/i.test(objStr)) {
this.metrics.connectionFailures++
}
break
case 'message':
if (/send|sent|outgoing/i.test(objStr)) {
this.metrics.messagesSent++
this.metrics.lastMessageTime = new Date().toISOString()
} else if (/recv|received|incoming/i.test(objStr)) {
this.metrics.messagesReceived++
this.metrics.lastMessageTime = new Date().toISOString()
}
break
case 'media':
if (/upload/i.test(objStr)) {
this.metrics.mediaUploads++
} else if (/download/i.test(objStr)) {
this.metrics.mediaDownloads++
}
break
case 'retry':
this.metrics.retryAttempts++
break
case 'encryption':
this.metrics.encryptionOperations++
break
}
if (level === 'error' || level === 'fatal') {
this.metrics.errorsByCategory[category]++
}
}
/**
* Método principal de log
*/
private log(level: LogLevel, obj: unknown, msg?: string): void {
const category = this.detectCategory(obj, msg)
if (!this.shouldLogCategory(category, level)) {
return
}
// Atualizar métricas
this.updateMetrics(category, level, obj)
// Sanitizar payload
const sanitizedObj = this.sanitizePayload(obj)
// Adicionar contexto do Baileys
const enrichedObj = {
category,
instanceId: this.config.instanceId,
...this.childContext,
...(typeof sanitizedObj === 'object' && sanitizedObj !== null ? sanitizedObj : { value: sanitizedObj }),
}
// Log estruturado
this.structuredLogger[level](enrichedObj, msg)
// Handler de evento
if (this.config.eventHandler) {
const entry: LogEntry = {
timestamp: new Date().toISOString(),
level,
levelValue: 0,
message: msg || '',
name: `baileys:${this.config.instanceId}`,
data: enrichedObj,
}
this.config.eventHandler(category, entry)
}
}
trace(obj: unknown, msg?: string): void {
this.log('trace', obj, msg)
}
debug(obj: unknown, msg?: string): void {
this.log('debug', obj, msg)
}
info(obj: unknown, msg?: string): void {
this.log('info', obj, msg)
}
warn(obj: unknown, msg?: string): void {
this.log('warn', obj, msg)
}
error(obj: unknown, msg?: string): void {
this.log('error', obj, msg)
}
/**
* Log de conexão específico
*/
logConnection(event: 'connecting' | 'connected' | 'disconnected' | 'error', details?: Record<string, unknown>): void {
const level: LogLevel = event === 'error' ? 'error' : event === 'disconnected' ? 'warn' : 'info'
this.log(level, { event, ...details, category: 'connection' }, `Connection ${event}`)
}
/**
* Log de mensagem específico
*/
logMessage(
direction: 'send' | 'receive',
messageType: string,
jid: string,
details?: Record<string, unknown>
): void {
const sanitizedJid = this.sanitizeJid(jid)
this.log(
'info',
{
direction,
messageType,
jid: sanitizedJid,
...details,
category: 'message',
},
`Message ${direction}: ${messageType}`
)
}
/**
* Log de mídia específico
*/
logMedia(
operation: 'upload' | 'download',
mediaType: string,
size: number,
details?: Record<string, unknown>
): void {
this.log(
'info',
{
operation,
mediaType,
sizeBytes: size,
sizeFormatted: this.formatBytes(size),
...details,
category: 'media',
},
`Media ${operation}: ${mediaType}`
)
}
/**
* Sanitiza JID para log (remove parte do número)
*/
private sanitizeJid(jid: string): string {
if (process.env.NODE_ENV === 'production') {
// Em produção, mascara parte do número
const parts = jid.split('@')
if (parts.length === 2 && parts[0].length > 4) {
return `${parts[0].substring(0, 4)}****@${parts[1]}`
}
}
return jid
}
/**
* Formata bytes para leitura humana
*/
private formatBytes(bytes: number): string {
if (bytes === 0) return '0 B'
const k = 1024
const sizes = ['B', 'KB', 'MB', 'GB']
const i = Math.floor(Math.log(bytes) / Math.log(k))
return `${parseFloat((bytes / Math.pow(k, i)).toFixed(2))} ${sizes[i]}`
}
/**
* Retorna métricas do logger
*/
getMetrics(): BaileysLoggerMetrics {
return { ...this.metrics }
}
/**
* Retorna métricas do structured logger interno
*/
getStructuredMetrics() {
return this.structuredLogger.getMetrics()
}
/**
* Reseta métricas
*/
resetMetrics(): void {
this.metrics = this.createInitialMetrics()
this.structuredLogger.resetMetrics()
}
/**
* Retorna instance ID
*/
getInstanceId(): string {
return this.config.instanceId
}
}
/**
* Factory para criar Baileys Logger
*/
export function createBaileysLogger(config?: Partial<BaileysLoggerConfig>): BaileysLogger {
return new BaileysLogger(config)
}
/**
* Singleton para logger padrão do Baileys
*/
let defaultBaileysLogger: BaileysLogger | null = null
export function getDefaultBaileysLogger(): BaileysLogger {
if (!defaultBaileysLogger) {
defaultBaileysLogger = createBaileysLogger({
level: 'info',
})
}
return defaultBaileysLogger
}
export function setDefaultBaileysLogger(logger: BaileysLogger): void {
defaultBaileysLogger = logger
}
export default BaileysLogger
+518
View File
@@ -0,0 +1,518 @@
/**
* @fileoverview Sistema de cache inteligente
* @module Utils/cache-utils
*
* Fornece:
* - Cache em memória com TTL configurável
* - Invalidação automática e manual
* - Métricas de hit/miss
* - Estratégia LRU (Least Recently Used)
* - Cache distribuído (preparado para Redis)
* - Namespace para isolamento
* - Serialização customizável
*/
import { LRUCache } from 'lru-cache'
import { metrics } from './prometheus-metrics.js'
/**
* Opções de configuração do cache
*/
export interface CacheOptions<V> {
/** Tempo de vida em ms (default: 5 minutos) */
ttl?: number
/** Tamanho máximo do cache (default: 1000) */
maxSize?: number
/** Função para calcular tamanho de um item */
sizeCalculation?: (value: V) => number
/** Se deve atualizar TTL no acesso */
updateAgeOnGet?: boolean
/** Namespace para isolamento */
namespace?: string
/** Callback quando item expira */
onExpire?: (key: string, value: V) => void
/** Se deve coletar métricas */
collectMetrics?: boolean
/** Nome do cache para métricas */
metricName?: string
}
/**
* Estatísticas do cache
*/
export interface CacheStats {
hits: number
misses: number
size: number
maxSize: number
hitRate: number
}
/**
* Item do cache com metadados
*/
export interface CacheItem<V> {
value: V
createdAt: number
expiresAt: number
accessCount: number
lastAccess: number
}
/**
* Resultado de operação de cache
*/
export interface CacheResult<V> {
value: V | undefined
hit: boolean
expired?: boolean
key: string
}
/**
* Classe principal do Cache
*/
export class Cache<V = unknown> {
private cache: LRUCache<string, CacheItem<V>>
private options: Required<CacheOptions<V>>
private stats: { hits: number; misses: number }
private namespace: string
constructor(options: CacheOptions<V> = {}) {
this.options = {
ttl: options.ttl ?? 5 * 60 * 1000, // 5 minutos
maxSize: options.maxSize ?? 1000,
sizeCalculation: options.sizeCalculation ?? (() => 1),
updateAgeOnGet: options.updateAgeOnGet ?? false,
namespace: options.namespace ?? 'default',
onExpire: options.onExpire ?? (() => {}),
collectMetrics: options.collectMetrics ?? true,
metricName: options.metricName ?? 'cache',
}
this.namespace = this.options.namespace
this.stats = { hits: 0, misses: 0 }
this.cache = new LRUCache<string, CacheItem<V>>({
max: this.options.maxSize,
ttl: this.options.ttl,
updateAgeOnGet: this.options.updateAgeOnGet,
sizeCalculation: (item) => this.options.sizeCalculation(item.value),
dispose: (value, key) => {
this.options.onExpire(key, value.value)
},
})
}
/**
* Obtém valor do cache
*/
get(key: string): V | undefined {
const fullKey = this.getFullKey(key)
const item = this.cache.get(fullKey)
if (item) {
this.stats.hits++
item.accessCount++
item.lastAccess = Date.now()
if (this.options.collectMetrics) {
metrics.cacheHits.inc({ cache: this.options.metricName })
}
return item.value
}
this.stats.misses++
if (this.options.collectMetrics) {
metrics.cacheMisses.inc({ cache: this.options.metricName })
}
return undefined
}
/**
* Obtém valor com resultado detalhado
*/
getWithResult(key: string): CacheResult<V> {
const fullKey = this.getFullKey(key)
const item = this.cache.get(fullKey)
if (item) {
this.stats.hits++
item.accessCount++
item.lastAccess = Date.now()
if (this.options.collectMetrics) {
metrics.cacheHits.inc({ cache: this.options.metricName })
}
return {
value: item.value,
hit: true,
key,
}
}
this.stats.misses++
if (this.options.collectMetrics) {
metrics.cacheMisses.inc({ cache: this.options.metricName })
}
return {
value: undefined,
hit: false,
key,
}
}
/**
* Define valor no cache
*/
set(key: string, value: V, ttl?: number): void {
const fullKey = this.getFullKey(key)
const now = Date.now()
const itemTtl = ttl ?? this.options.ttl
const item: CacheItem<V> = {
value,
createdAt: now,
expiresAt: now + itemTtl,
accessCount: 0,
lastAccess: now,
}
this.cache.set(fullKey, item, { ttl: itemTtl })
if (this.options.collectMetrics) {
metrics.cacheSize.set({ cache: this.options.metricName }, this.cache.size)
}
}
/**
* Verifica se chave existe
*/
has(key: string): boolean {
const fullKey = this.getFullKey(key)
return this.cache.has(fullKey)
}
/**
* Remove item do cache
*/
delete(key: string): boolean {
const fullKey = this.getFullKey(key)
const result = this.cache.delete(fullKey)
if (this.options.collectMetrics) {
metrics.cacheSize.set({ cache: this.options.metricName }, this.cache.size)
}
return result
}
/**
* Limpa todo o cache
*/
clear(): void {
this.cache.clear()
this.stats = { hits: 0, misses: 0 }
if (this.options.collectMetrics) {
metrics.cacheSize.set({ cache: this.options.metricName }, 0)
}
}
/**
* Obtém ou define valor (cache-aside pattern)
*/
async getOrSet(key: string, factory: () => V | Promise<V>, ttl?: number): Promise<V> {
const existing = this.get(key)
if (existing !== undefined) {
return existing
}
const value = await factory()
this.set(key, value, ttl)
return value
}
/**
* Obtém ou define valor síncronamente
*/
getOrSetSync(key: string, factory: () => V, ttl?: number): V {
const existing = this.get(key)
if (existing !== undefined) {
return existing
}
const value = factory()
this.set(key, value, ttl)
return value
}
/**
* Invalida itens por padrão
*/
invalidateByPattern(pattern: RegExp): number {
let count = 0
const prefix = `${this.namespace}:`
for (const key of this.cache.keys()) {
const shortKey = key.startsWith(prefix) ? key.slice(prefix.length) : key
if (pattern.test(shortKey)) {
this.cache.delete(key)
count++
}
}
if (this.options.collectMetrics) {
metrics.cacheSize.set({ cache: this.options.metricName }, this.cache.size)
}
return count
}
/**
* Invalida itens por prefixo
*/
invalidateByPrefix(prefix: string): number {
return this.invalidateByPattern(new RegExp(`^${prefix}`))
}
/**
* Retorna estatísticas do cache
*/
getStats(): CacheStats {
const total = this.stats.hits + this.stats.misses
return {
hits: this.stats.hits,
misses: this.stats.misses,
size: this.cache.size,
maxSize: this.options.maxSize,
hitRate: total > 0 ? this.stats.hits / total : 0,
}
}
/**
* Retorna tamanho atual
*/
get size(): number {
return this.cache.size
}
/**
* Retorna todas as chaves
*/
keys(): string[] {
const prefix = `${this.namespace}:`
return Array.from(this.cache.keys()).map((k) => (k.startsWith(prefix) ? k.slice(prefix.length) : k))
}
/**
* Retorna todos os valores
*/
values(): V[] {
return Array.from(this.cache.values()).map((item) => item.value)
}
/**
* Retorna todos os itens com metadados
*/
entries(): Array<{ key: string; item: CacheItem<V> }> {
const prefix = `${this.namespace}:`
return Array.from(this.cache.entries()).map(([key, item]) => ({
key: key.startsWith(prefix) ? key.slice(prefix.length) : key,
item,
}))
}
/**
* Atualiza TTL de um item
*/
touch(key: string, ttl?: number): boolean {
const fullKey = this.getFullKey(key)
const item = this.cache.get(fullKey)
if (!item) {
return false
}
const newTtl = ttl ?? this.options.ttl
item.expiresAt = Date.now() + newTtl
this.cache.set(fullKey, item, { ttl: newTtl })
return true
}
/**
* Obtém item expirado (se ainda em memória)
*/
peek(key: string): V | undefined {
const fullKey = this.getFullKey(key)
const item = this.cache.peek(fullKey)
return item?.value
}
private getFullKey(key: string): string {
return `${this.namespace}:${key}`
}
}
/**
* Factory para criar cache com tipo
*/
export function createCache<V>(options?: CacheOptions<V>): Cache<V> {
return new Cache<V>(options)
}
/**
* Cache com múltiplos níveis (L1: memória, L2: externo)
*/
export class MultiLevelCache<V> {
private l1: Cache<V>
private l2?: {
get: (key: string) => Promise<V | undefined>
set: (key: string, value: V, ttl?: number) => Promise<void>
delete: (key: string) => Promise<boolean>
}
constructor(
l1Options: CacheOptions<V>,
l2?: {
get: (key: string) => Promise<V | undefined>
set: (key: string, value: V, ttl?: number) => Promise<void>
delete: (key: string) => Promise<boolean>
}
) {
this.l1 = new Cache<V>(l1Options)
this.l2 = l2
}
async get(key: string): Promise<V | undefined> {
// Tentar L1 primeiro
const l1Value = this.l1.get(key)
if (l1Value !== undefined) {
return l1Value
}
// Tentar L2 se disponível
if (this.l2) {
const l2Value = await this.l2.get(key)
if (l2Value !== undefined) {
// Promover para L1
this.l1.set(key, l2Value)
return l2Value
}
}
return undefined
}
async set(key: string, value: V, ttl?: number): Promise<void> {
this.l1.set(key, value, ttl)
if (this.l2) {
await this.l2.set(key, value, ttl)
}
}
async delete(key: string): Promise<boolean> {
const l1Result = this.l1.delete(key)
let l2Result = false
if (this.l2) {
l2Result = await this.l2.delete(key)
}
return l1Result || l2Result
}
getL1(): Cache<V> {
return this.l1
}
}
/**
* Decorator para cachear resultado de método
*/
export function cached<V>(options: CacheOptions<V> & { keyGenerator?: (...args: unknown[]) => string } = {}) {
const cache = new Cache<V>(options)
const keyGenerator = options.keyGenerator ?? ((...args) => JSON.stringify(args))
return function (
_target: unknown,
propertyKey: string,
descriptor: TypedPropertyDescriptor<(...args: unknown[]) => V | Promise<V>>
) {
const originalMethod = descriptor.value
if (!originalMethod) return descriptor
descriptor.value = async function (...args: unknown[]): Promise<V> {
const key = `${propertyKey}:${keyGenerator(...args)}`
const cachedValue = cache.get(key)
if (cachedValue !== undefined) {
return cachedValue
}
const result = await originalMethod.apply(this, args)
cache.set(key, result)
return result
}
return descriptor
}
}
/**
* Wrapper para função com cache
*/
export function withCache<T extends (...args: unknown[]) => unknown>(
fn: T,
options: CacheOptions<ReturnType<T>> & { keyGenerator?: (...args: Parameters<T>) => string } = {}
): T {
const cache = new Cache<ReturnType<T>>(options)
const keyGenerator = options.keyGenerator ?? ((...args) => JSON.stringify(args))
return ((...args: Parameters<T>): ReturnType<T> => {
const key = keyGenerator(...args)
const cachedValue = cache.get(key)
if (cachedValue !== undefined) {
return cachedValue as ReturnType<T>
}
const result = fn(...args) as ReturnType<T>
if (result instanceof Promise) {
return result.then((value) => {
cache.set(key, value as ReturnType<T>)
return value
}) as ReturnType<T>
}
cache.set(key, result)
return result
}) as T
}
/**
* Cache global singleton por namespace
*/
const globalCaches: Map<string, Cache<unknown>> = new Map()
export function getGlobalCache<V>(namespace: string, options?: CacheOptions<V>): Cache<V> {
if (!globalCaches.has(namespace)) {
globalCaches.set(namespace, new Cache<V>({ ...options, namespace }))
}
return globalCaches.get(namespace) as Cache<V>
}
export function clearGlobalCaches(): void {
for (const cache of globalCaches.values()) {
cache.clear()
}
globalCaches.clear()
}
export default Cache
+532
View File
@@ -0,0 +1,532 @@
/**
* @fileoverview Proteção contra falhas em cascata - Circuit Breaker
* @module Utils/circuit-breaker
*
* Fornece:
* - Estados: Closed, Open, Half-Open
* - Thresholds configuráveis
* - Recuperação automática
* - Callbacks de estado
* - Integração com métricas
* - Fallback handlers
*/
import { EventEmitter } from 'events'
import { metrics } from './prometheus-metrics.js'
/**
* Estados do Circuit Breaker
*/
export type CircuitState = 'closed' | 'open' | 'half-open'
/**
* Opções de configuração do Circuit Breaker
*/
export interface CircuitBreakerOptions {
/** Nome do circuit breaker (para métricas) */
name: string
/** Número de falhas para abrir o circuito */
failureThreshold?: number
/** Número de sucessos para fechar o circuito (em half-open) */
successThreshold?: number
/** Tempo em ms para tentar half-open após open */
resetTimeout?: number
/** Timeout para operações em ms */
timeout?: number
/** Função para determinar se erro deve contar como falha */
isFailure?: (error: Error) => boolean
/** Coletar métricas */
collectMetrics?: boolean
/** Função de fallback quando circuito está aberto */
fallback?: <T>() => T | Promise<T>
/** Callback quando estado muda */
onStateChange?: (from: CircuitState, to: CircuitState) => void
/** Callback em falha */
onFailure?: (error: Error) => void
/** Callback em sucesso */
onSuccess?: () => void
/** Callback quando circuito abre */
onOpen?: () => void
/** Callback quando circuito fecha */
onClose?: () => void
}
/**
* Estatísticas do Circuit Breaker
*/
export interface CircuitBreakerStats {
state: CircuitState
failures: number
successes: number
totalCalls: number
totalFailures: number
totalSuccesses: number
lastFailureTime?: number
lastSuccessTime?: number
lastStateChange?: number
isOpen: boolean
isClosed: boolean
isHalfOpen: boolean
}
/**
* Erro lançado quando circuito está aberto
*/
export class CircuitOpenError extends Error {
constructor(
public readonly circuitName: string,
public readonly state: CircuitState
) {
super(`Circuit breaker "${circuitName}" is ${state}`)
this.name = 'CircuitOpenError'
}
}
/**
* Erro de timeout
*/
export class CircuitTimeoutError extends Error {
constructor(
public readonly circuitName: string,
public readonly timeoutMs: number
) {
super(`Circuit breaker "${circuitName}" operation timed out after ${timeoutMs}ms`)
this.name = 'CircuitTimeoutError'
}
}
/**
* Classe principal do Circuit Breaker
*/
export class CircuitBreaker extends EventEmitter {
private state: CircuitState = 'closed'
private failures = 0
private successes = 0
private totalCalls = 0
private totalFailures = 0
private totalSuccesses = 0
private lastFailureTime?: number
private lastSuccessTime?: number
private lastStateChange?: number
private resetTimer?: ReturnType<typeof setTimeout>
private options: Required<CircuitBreakerOptions>
constructor(options: CircuitBreakerOptions) {
super()
this.options = {
name: options.name,
failureThreshold: options.failureThreshold ?? 5,
successThreshold: options.successThreshold ?? 2,
resetTimeout: options.resetTimeout ?? 30000,
timeout: options.timeout ?? 10000,
isFailure: options.isFailure ?? (() => true),
collectMetrics: options.collectMetrics ?? true,
fallback: options.fallback ?? (() => {
throw new CircuitOpenError(this.options.name, this.state)
}),
onStateChange: options.onStateChange ?? (() => {}),
onFailure: options.onFailure ?? (() => {}),
onSuccess: options.onSuccess ?? (() => {}),
onOpen: options.onOpen ?? (() => {}),
onClose: options.onClose ?? (() => {}),
}
this.lastStateChange = Date.now()
}
/**
* Executa operação protegida pelo circuit breaker
*/
async execute<T>(operation: () => T | Promise<T>): Promise<T> {
this.totalCalls++
// Verificar estado
if (this.state === 'open') {
if (this.options.collectMetrics) {
metrics.errors.inc({ category: 'circuit_breaker', code: 'open' })
}
return this.options.fallback() as T
}
// Executar com timeout
try {
const result = await this.executeWithTimeout(operation)
this.recordSuccess()
return result
} catch (error) {
this.recordFailure(error as Error)
throw error
}
}
/**
* Executa operação síncrona protegida
*/
executeSync<T>(operation: () => T): T {
this.totalCalls++
if (this.state === 'open') {
if (this.options.collectMetrics) {
metrics.errors.inc({ category: 'circuit_breaker', code: 'open' })
}
return this.options.fallback() as T
}
try {
const result = operation()
this.recordSuccess()
return result
} catch (error) {
this.recordFailure(error as Error)
throw error
}
}
/**
* Executa com timeout
*/
private async executeWithTimeout<T>(operation: () => T | Promise<T>): Promise<T> {
return new Promise<T>((resolve, reject) => {
const timer = setTimeout(() => {
reject(new CircuitTimeoutError(this.options.name, this.options.timeout))
}, this.options.timeout)
Promise.resolve(operation())
.then((result) => {
clearTimeout(timer)
resolve(result)
})
.catch((error) => {
clearTimeout(timer)
reject(error)
})
})
}
/**
* Registra sucesso
*/
private recordSuccess(): void {
this.totalSuccesses++
this.lastSuccessTime = Date.now()
this.failures = 0
if (this.options.collectMetrics) {
metrics.socketEvents.inc({ event: 'circuit_success' })
}
this.options.onSuccess()
this.emit('success')
if (this.state === 'half-open') {
this.successes++
if (this.successes >= this.options.successThreshold) {
this.transitionTo('closed')
}
}
}
/**
* Registra falha
*/
private recordFailure(error: Error): void {
// Verificar se erro deve contar como falha
if (!this.options.isFailure(error)) {
return
}
this.totalFailures++
this.lastFailureTime = Date.now()
this.failures++
this.successes = 0
if (this.options.collectMetrics) {
metrics.errors.inc({ category: 'circuit_breaker', code: 'failure' })
}
this.options.onFailure(error)
this.emit('failure', error)
if (this.state === 'half-open') {
this.transitionTo('open')
} else if (this.state === 'closed' && this.failures >= this.options.failureThreshold) {
this.transitionTo('open')
}
}
/**
* Transiciona para novo estado
*/
private transitionTo(newState: CircuitState): void {
const oldState = this.state
if (oldState === newState) {
return
}
this.state = newState
this.lastStateChange = Date.now()
// Limpar timer existente
if (this.resetTimer) {
clearTimeout(this.resetTimer)
this.resetTimer = undefined
}
// Resetar contadores baseado no novo estado
if (newState === 'closed') {
this.failures = 0
this.successes = 0
this.options.onClose()
this.emit('close')
} else if (newState === 'open') {
this.successes = 0
this.options.onOpen()
this.emit('open')
// Agendar tentativa de half-open
this.resetTimer = setTimeout(() => {
this.transitionTo('half-open')
}, this.options.resetTimeout)
} else if (newState === 'half-open') {
this.successes = 0
this.failures = 0
this.emit('half-open')
}
this.options.onStateChange(oldState, newState)
this.emit('state-change', { from: oldState, to: newState })
}
/**
* Força abertura do circuito
*/
trip(): void {
this.transitionTo('open')
}
/**
* Força fechamento do circuito
*/
reset(): void {
this.transitionTo('closed')
}
/**
* Retorna estado atual
*/
getState(): CircuitState {
return this.state
}
/**
* Verifica se circuito está aberto
*/
isOpen(): boolean {
return this.state === 'open'
}
/**
* Verifica se circuito está fechado
*/
isClosed(): boolean {
return this.state === 'closed'
}
/**
* Verifica se circuito está half-open
*/
isHalfOpen(): boolean {
return this.state === 'half-open'
}
/**
* Retorna estatísticas
*/
getStats(): CircuitBreakerStats {
return {
state: this.state,
failures: this.failures,
successes: this.successes,
totalCalls: this.totalCalls,
totalFailures: this.totalFailures,
totalSuccesses: this.totalSuccesses,
lastFailureTime: this.lastFailureTime,
lastSuccessTime: this.lastSuccessTime,
lastStateChange: this.lastStateChange,
isOpen: this.isOpen(),
isClosed: this.isClosed(),
isHalfOpen: this.isHalfOpen(),
}
}
/**
* Retorna nome do circuit breaker
*/
getName(): string {
return this.options.name
}
/**
* Destroy e limpa recursos
*/
destroy(): void {
if (this.resetTimer) {
clearTimeout(this.resetTimer)
}
this.removeAllListeners()
}
}
/**
* Factory para criar circuit breaker
*/
export function createCircuitBreaker(options: CircuitBreakerOptions): CircuitBreaker {
return new CircuitBreaker(options)
}
/**
* Registry de circuit breakers
*/
export class CircuitBreakerRegistry {
private breakers: Map<string, CircuitBreaker> = new Map()
/**
* Obtém ou cria circuit breaker
*/
get(name: string, options?: Omit<CircuitBreakerOptions, 'name'>): CircuitBreaker {
if (!this.breakers.has(name)) {
const breaker = new CircuitBreaker({ ...options, name })
this.breakers.set(name, breaker)
}
return this.breakers.get(name)!
}
/**
* Verifica se circuit breaker existe
*/
has(name: string): boolean {
return this.breakers.has(name)
}
/**
* Remove circuit breaker
*/
remove(name: string): boolean {
const breaker = this.breakers.get(name)
if (breaker) {
breaker.destroy()
return this.breakers.delete(name)
}
return false
}
/**
* Retorna todos os circuit breakers
*/
getAll(): Map<string, CircuitBreaker> {
return new Map(this.breakers)
}
/**
* Retorna estatísticas de todos os circuit breakers
*/
getAllStats(): Record<string, CircuitBreakerStats> {
const stats: Record<string, CircuitBreakerStats> = {}
for (const [name, breaker] of this.breakers) {
stats[name] = breaker.getStats()
}
return stats
}
/**
* Reseta todos os circuit breakers
*/
resetAll(): void {
for (const breaker of this.breakers.values()) {
breaker.reset()
}
}
/**
* Destroy todos os circuit breakers
*/
destroyAll(): void {
for (const breaker of this.breakers.values()) {
breaker.destroy()
}
this.breakers.clear()
}
}
/**
* Registry global
*/
export const globalCircuitRegistry = new CircuitBreakerRegistry()
/**
* Decorator para proteger método com circuit breaker
*/
export function circuitBreaker(options: Omit<CircuitBreakerOptions, 'name'> & { name?: string } = {}) {
return function (
_target: unknown,
propertyKey: string,
descriptor: TypedPropertyDescriptor<(...args: unknown[]) => unknown>
) {
const originalMethod = descriptor.value
if (!originalMethod) return descriptor
const name = options.name || propertyKey
const breaker = globalCircuitRegistry.get(name, options)
descriptor.value = async function (...args: unknown[]): Promise<unknown> {
return breaker.execute(() => originalMethod.apply(this, args))
}
return descriptor
}
}
/**
* Wrapper para função com circuit breaker
*/
export function withCircuitBreaker<T extends (...args: unknown[]) => unknown>(
fn: T,
options: CircuitBreakerOptions
): T {
const breaker = new CircuitBreaker(options)
return (async (...args: Parameters<T>): Promise<ReturnType<T>> => {
return breaker.execute(() => fn(...args)) as Promise<ReturnType<T>>
}) as unknown as T
}
/**
* Verifica saúde de todos os circuit breakers
*/
export function getCircuitHealth(): {
healthy: boolean
openCircuits: string[]
stats: Record<string, CircuitBreakerStats>
} {
const stats = globalCircuitRegistry.getAllStats()
const openCircuits: string[] = []
for (const [name, stat] of Object.entries(stats)) {
if (stat.isOpen) {
openCircuits.push(name)
}
}
return {
healthy: openCircuits.length === 0,
openCircuits,
stats,
}
}
export default CircuitBreaker
+19
View File
@@ -16,3 +16,22 @@ export * from './event-buffer'
export * from './process-message'
export * from './message-retry-manager'
export * from './browser-utils'
// === Novos Utilitários de Observabilidade e Resiliência ===
// Logging estruturado
export * from './structured-logger'
export * from './logger-adapter'
export * from './baileys-logger'
// Observabilidade e rastreamento
export * from './trace-context'
export * from './prometheus-metrics'
// Resiliência e performance
export * from './cache-utils'
export * from './circuit-breaker'
export * from './retry-utils'
// Event streaming
export * from './baileys-event-stream'
+373
View File
@@ -0,0 +1,373 @@
/**
* @fileoverview Adaptador entre diferentes sistemas de logging
* @module Utils/logger-adapter
*
* Fornece:
* - Adapter pattern para integrar diferentes loggers
* - Mapeamento de níveis de log entre sistemas
* - Transformação de formatos de log
* - Compatibilidade com Pino, Console e StructuredLogger
*/
import type { ILogger } from './logger.js'
import type P from 'pino'
import { StructuredLogger, createStructuredLogger, type LogLevel, LOG_LEVEL_VALUES } from './structured-logger.js'
/**
* Tipo de logger suportado
*/
export type LoggerType = 'pino' | 'console' | 'structured' | 'custom'
/**
* Configuração do adapter
*/
export interface LoggerAdapterConfig {
/** Tipo de logger de origem */
sourceType: LoggerType
/** Tipo de logger de destino */
targetType: LoggerType
/** Mapeamento customizado de níveis */
levelMapping?: Record<string, LogLevel>
/** Transformador de contexto */
contextTransformer?: (context: Record<string, unknown>) => Record<string, unknown>
/** Filtro de logs */
logFilter?: (level: LogLevel, message: string, data?: unknown) => boolean
}
/**
* Mapeamento padrão de níveis Pino para StructuredLogger
*/
const PINO_LEVEL_MAPPING: Record<number, LogLevel> = {
10: 'trace',
20: 'debug',
30: 'info',
40: 'warn',
50: 'error',
60: 'fatal',
}
/**
* Mapeamento reverso para Pino
*/
const STRUCTURED_TO_PINO_LEVEL: Record<LogLevel, number> = {
trace: 10,
debug: 20,
info: 30,
warn: 40,
error: 50,
fatal: 60,
silent: 100,
}
/**
* Classe adaptadora principal
*/
export class LoggerAdapter implements ILogger {
private sourceLogger: ILogger
private targetLogger: ILogger | null = null
private config: LoggerAdapterConfig
constructor(sourceLogger: ILogger, config: Partial<LoggerAdapterConfig> = {}) {
this.sourceLogger = sourceLogger
this.config = {
sourceType: config.sourceType || 'pino',
targetType: config.targetType || 'structured',
levelMapping: config.levelMapping,
contextTransformer: config.contextTransformer,
logFilter: config.logFilter,
}
}
get level(): string {
return this.sourceLogger.level
}
set level(newLevel: string) {
this.sourceLogger.level = newLevel
if (this.targetLogger) {
this.targetLogger.level = newLevel
}
}
/**
* Define o logger de destino
*/
setTargetLogger(logger: ILogger): void {
this.targetLogger = logger
}
/**
* Cria um logger filho
*/
child(obj: Record<string, unknown>): LoggerAdapter {
const transformedContext = this.config.contextTransformer ? this.config.contextTransformer(obj) : obj
const childAdapter = new LoggerAdapter(this.sourceLogger.child(transformedContext), this.config)
if (this.targetLogger) {
childAdapter.setTargetLogger(this.targetLogger.child(transformedContext))
}
return childAdapter
}
/**
* Mapeia nível de log
*/
private mapLevel(level: string | number): LogLevel {
if (typeof level === 'number') {
return PINO_LEVEL_MAPPING[level] || 'info'
}
if (this.config.levelMapping && level in this.config.levelMapping) {
return this.config.levelMapping[level]
}
return (level as LogLevel) || 'info'
}
/**
* Verifica se o log deve ser processado
*/
private shouldLog(level: LogLevel, msg: string, obj?: unknown): boolean {
if (this.config.logFilter) {
return this.config.logFilter(level, msg, obj)
}
return true
}
/**
* Processa log em ambos loggers
*/
private processLog(level: LogLevel, obj: unknown, msg?: string): void {
if (!this.shouldLog(level, msg || '', obj)) {
return
}
// Log no source logger
const sourceMethod = this.sourceLogger[level as keyof ILogger]
if (typeof sourceMethod === 'function') {
;(sourceMethod as (obj: unknown, msg?: string) => void).call(this.sourceLogger, obj, msg)
}
// Log no target logger se configurado
if (this.targetLogger) {
const targetMethod = this.targetLogger[level as keyof ILogger]
if (typeof targetMethod === 'function') {
;(targetMethod as (obj: unknown, msg?: string) => void).call(this.targetLogger, obj, msg)
}
}
}
trace(obj: unknown, msg?: string): void {
this.processLog('trace', obj, msg)
}
debug(obj: unknown, msg?: string): void {
this.processLog('debug', obj, msg)
}
info(obj: unknown, msg?: string): void {
this.processLog('info', obj, msg)
}
warn(obj: unknown, msg?: string): void {
this.processLog('warn', obj, msg)
}
error(obj: unknown, msg?: string): void {
this.processLog('error', obj, msg)
}
}
/**
* Wrapper para converter Pino logger em StructuredLogger
*/
export class PinoToStructuredAdapter implements ILogger {
private pinoLogger: P.Logger
private structuredLogger: StructuredLogger
constructor(pinoLogger: P.Logger, structuredLoggerConfig?: Parameters<typeof createStructuredLogger>[0]) {
this.pinoLogger = pinoLogger
this.structuredLogger = createStructuredLogger({
level: this.mapPinoLevel(pinoLogger.level),
...structuredLoggerConfig,
})
}
get level(): string {
return this.pinoLogger.level
}
set level(newLevel: string) {
this.pinoLogger.level = newLevel
this.structuredLogger.level = newLevel
}
private mapPinoLevel(pinoLevel: string): LogLevel {
const levelMap: Record<string, LogLevel> = {
trace: 'trace',
debug: 'debug',
info: 'info',
warn: 'warn',
error: 'error',
fatal: 'fatal',
silent: 'silent',
}
return levelMap[pinoLevel] || 'info'
}
child(obj: Record<string, unknown>): PinoToStructuredAdapter {
const adapter = new PinoToStructuredAdapter(this.pinoLogger.child(obj))
return adapter
}
trace(obj: unknown, msg?: string): void {
this.pinoLogger.trace(obj as object, msg)
this.structuredLogger.trace(obj, msg)
}
debug(obj: unknown, msg?: string): void {
this.pinoLogger.debug(obj as object, msg)
this.structuredLogger.debug(obj, msg)
}
info(obj: unknown, msg?: string): void {
this.pinoLogger.info(obj as object, msg)
this.structuredLogger.info(obj, msg)
}
warn(obj: unknown, msg?: string): void {
this.pinoLogger.warn(obj as object, msg)
this.structuredLogger.warn(obj, msg)
}
error(obj: unknown, msg?: string): void {
this.pinoLogger.error(obj as object, msg)
this.structuredLogger.error(obj, msg)
}
/**
* Obtém métricas do structured logger
*/
getMetrics() {
return this.structuredLogger.getMetrics()
}
}
/**
* Factory para criar adapter baseado no tipo de logger
*/
export function createLoggerAdapter(
logger: ILogger,
config?: Partial<LoggerAdapterConfig>
): LoggerAdapter {
return new LoggerAdapter(logger, config)
}
/**
* Converte qualquer logger para a interface ILogger
*/
export function normalizeLogger(logger: unknown): ILogger {
if (isILogger(logger)) {
return logger
}
// Se for um objeto com métodos de log
if (typeof logger === 'object' && logger !== null) {
const logObj = logger as Record<string, unknown>
return {
level: (logObj.level as string) || 'info',
child: (obj: Record<string, unknown>) => {
if (typeof logObj.child === 'function') {
return normalizeLogger((logObj.child as (obj: Record<string, unknown>) => unknown)(obj))
}
return normalizeLogger(logger)
},
trace: createLogMethod(logObj, 'trace'),
debug: createLogMethod(logObj, 'debug'),
info: createLogMethod(logObj, 'info'),
warn: createLogMethod(logObj, 'warn'),
error: createLogMethod(logObj, 'error'),
}
}
// Fallback: console logger
return createConsoleLogger()
}
/**
* Verifica se objeto implementa ILogger
*/
export function isILogger(obj: unknown): obj is ILogger {
if (typeof obj !== 'object' || obj === null) {
return false
}
const logger = obj as Record<string, unknown>
return (
typeof logger.child === 'function' &&
typeof logger.trace === 'function' &&
typeof logger.debug === 'function' &&
typeof logger.info === 'function' &&
typeof logger.warn === 'function' &&
typeof logger.error === 'function'
)
}
/**
* Cria método de log genérico
*/
function createLogMethod(
logger: Record<string, unknown>,
level: string
): (obj: unknown, msg?: string) => void {
return (obj: unknown, msg?: string) => {
if (typeof logger[level] === 'function') {
;(logger[level] as (obj: unknown, msg?: string) => void)(obj, msg)
} else if (typeof (console as Record<string, unknown>)[level] === 'function') {
;(console as Record<string, (...args: unknown[]) => void>)[level](obj, msg)
}
}
}
/**
* Cria um logger baseado em console
*/
export function createConsoleLogger(prefix?: string): ILogger {
const formatMessage = (level: string, obj: unknown, msg?: string): string => {
const timestamp = new Date().toISOString()
const prefixStr = prefix ? `[${prefix}]` : ''
const message = msg || (typeof obj === 'string' ? obj : '')
const data = typeof obj === 'object' ? JSON.stringify(obj) : ''
return `${timestamp} ${prefixStr}[${level.toUpperCase()}] ${message} ${data}`.trim()
}
return {
level: 'info',
child(obj: Record<string, unknown>): ILogger {
const childPrefix = prefix ? `${prefix}:${Object.values(obj)[0]}` : String(Object.values(obj)[0])
return createConsoleLogger(childPrefix)
},
trace(obj: unknown, msg?: string): void {
console.debug(formatMessage('trace', obj, msg))
},
debug(obj: unknown, msg?: string): void {
console.debug(formatMessage('debug', obj, msg))
},
info(obj: unknown, msg?: string): void {
console.info(formatMessage('info', obj, msg))
},
warn(obj: unknown, msg?: string): void {
console.warn(formatMessage('warn', obj, msg))
},
error(obj: unknown, msg?: string): void {
console.error(formatMessage('error', obj, msg))
},
}
}
export default LoggerAdapter
+784
View File
@@ -0,0 +1,784 @@
/**
* @fileoverview Exposição de métricas no formato Prometheus
* @module Utils/prometheus-metrics
*
* Fornece:
* - Counters para contagem de eventos
* - Gauges para valores instantâneos
* - Histograms para distribuição de valores
* - Summaries para percentis
* - Endpoint /metrics pronto para scraping
* - Labels dinâmicas
* - Integração com Baileys events
*
* Nota: Funciona de forma standalone sem prom-client,
* mas pode ser integrado com prom-client se disponível.
*/
/**
* Tipo de métrica
*/
export type MetricType = 'counter' | 'gauge' | 'histogram' | 'summary'
/**
* Labels para métricas
*/
export type Labels = Record<string, string>
/**
* Buckets padrão para histogramas (em ms)
*/
export const DEFAULT_BUCKETS = [5, 10, 25, 50, 100, 250, 500, 1000, 2500, 5000, 10000]
/**
* Percentis padrão para summaries
*/
export const DEFAULT_PERCENTILES = [0.5, 0.9, 0.95, 0.99]
/**
* Interface base para métricas
*/
export interface BaseMetric {
name: string
help: string
type: MetricType
labelNames: string[]
}
/**
* Valor de uma métrica com labels
*/
export interface MetricValue {
labels: Labels
value: number
timestamp?: number
}
/**
* Valores de histograma
*/
export interface HistogramValue {
labels: Labels
buckets: Map<number, number>
sum: number
count: number
}
/**
* Valores de summary
*/
export interface SummaryValue {
labels: Labels
values: number[]
sum: number
count: number
}
/**
* Classe Counter - incrementa monotonicamente
*/
export class Counter implements BaseMetric {
readonly type = 'counter' as const
private values: Map<string, MetricValue> = new Map()
constructor(
public name: string,
public help: string,
public labelNames: string[] = []
) {}
/**
* Incrementa o counter
*/
inc(labelsOrValue?: Labels | number, value?: number): void {
let labels: Labels = {}
let incValue = 1
if (typeof labelsOrValue === 'number') {
incValue = labelsOrValue
} else if (labelsOrValue) {
labels = labelsOrValue
incValue = value ?? 1
}
const key = this.labelsToKey(labels)
const existing = this.values.get(key)
if (existing) {
existing.value += incValue
existing.timestamp = Date.now()
} else {
this.values.set(key, {
labels,
value: incValue,
timestamp: Date.now(),
})
}
}
/**
* Retorna valor atual
*/
get(labels: Labels = {}): number {
const key = this.labelsToKey(labels)
return this.values.get(key)?.value ?? 0
}
/**
* Reseta o counter
*/
reset(labels?: Labels): void {
if (labels) {
const key = this.labelsToKey(labels)
this.values.delete(key)
} else {
this.values.clear()
}
}
/**
* Retorna todos os valores
*/
getValues(): MetricValue[] {
return Array.from(this.values.values())
}
private labelsToKey(labels: Labels): string {
return JSON.stringify(labels)
}
/**
* Cria versão com labels pré-definidas
*/
labels(labels: Labels): { inc: (value?: number) => void } {
return {
inc: (value?: number) => this.inc(labels, value),
}
}
}
/**
* Classe Gauge - valor que pode aumentar e diminuir
*/
export class Gauge implements BaseMetric {
readonly type = 'gauge' as const
private values: Map<string, MetricValue> = new Map()
constructor(
public name: string,
public help: string,
public labelNames: string[] = []
) {}
/**
* Define valor
*/
set(labelsOrValue: Labels | number, value?: number): void {
let labels: Labels = {}
let setValue: number
if (typeof labelsOrValue === 'number') {
setValue = labelsOrValue
} else {
labels = labelsOrValue
setValue = value ?? 0
}
const key = this.labelsToKey(labels)
this.values.set(key, {
labels,
value: setValue,
timestamp: Date.now(),
})
}
/**
* Incrementa valor
*/
inc(labelsOrValue?: Labels | number, value?: number): void {
let labels: Labels = {}
let incValue = 1
if (typeof labelsOrValue === 'number') {
incValue = labelsOrValue
} else if (labelsOrValue) {
labels = labelsOrValue
incValue = value ?? 1
}
const key = this.labelsToKey(labels)
const existing = this.values.get(key)
const currentValue = existing?.value ?? 0
this.set(labels, currentValue + incValue)
}
/**
* Decrementa valor
*/
dec(labelsOrValue?: Labels | number, value?: number): void {
let labels: Labels = {}
let decValue = 1
if (typeof labelsOrValue === 'number') {
decValue = labelsOrValue
} else if (labelsOrValue) {
labels = labelsOrValue
decValue = value ?? 1
}
const key = this.labelsToKey(labels)
const existing = this.values.get(key)
const currentValue = existing?.value ?? 0
this.set(labels, currentValue - decValue)
}
/**
* Define para timestamp atual
*/
setToCurrentTime(labels: Labels = {}): void {
this.set(labels, Date.now() / 1000)
}
/**
* Retorna valor atual
*/
get(labels: Labels = {}): number {
const key = this.labelsToKey(labels)
return this.values.get(key)?.value ?? 0
}
/**
* Reseta o gauge
*/
reset(labels?: Labels): void {
if (labels) {
const key = this.labelsToKey(labels)
this.values.delete(key)
} else {
this.values.clear()
}
}
/**
* Retorna todos os valores
*/
getValues(): MetricValue[] {
return Array.from(this.values.values())
}
private labelsToKey(labels: Labels): string {
return JSON.stringify(labels)
}
/**
* Cria versão com labels pré-definidas
*/
labels(labels: Labels): {
set: (value: number) => void
inc: (value?: number) => void
dec: (value?: number) => void
} {
return {
set: (value: number) => this.set(labels, value),
inc: (value?: number) => this.inc(labels, value),
dec: (value?: number) => this.dec(labels, value),
}
}
/**
* Timer helper - retorna função para parar e registrar duração
*/
startTimer(labels: Labels = {}): () => number {
const start = process.hrtime.bigint()
return () => {
const duration = Number(process.hrtime.bigint() - start) / 1_000_000_000 // segundos
this.set(labels, duration)
return duration
}
}
}
/**
* Classe Histogram - distribuição de valores em buckets
*/
export class Histogram implements BaseMetric {
readonly type = 'histogram' as const
private values: Map<string, HistogramValue> = new Map()
private buckets: number[]
constructor(
public name: string,
public help: string,
public labelNames: string[] = [],
buckets: number[] = DEFAULT_BUCKETS
) {
this.buckets = [...buckets].sort((a, b) => a - b)
}
/**
* Observa um valor
*/
observe(labelsOrValue: Labels | number, value?: number): void {
let labels: Labels = {}
let observeValue: number
if (typeof labelsOrValue === 'number') {
observeValue = labelsOrValue
} else {
labels = labelsOrValue
observeValue = value ?? 0
}
const key = this.labelsToKey(labels)
let histValue = this.values.get(key)
if (!histValue) {
histValue = {
labels,
buckets: new Map(this.buckets.map((b) => [b, 0])),
sum: 0,
count: 0,
}
this.values.set(key, histValue)
}
// Incrementar buckets apropriados
for (const bucket of this.buckets) {
if (observeValue <= bucket) {
histValue.buckets.set(bucket, (histValue.buckets.get(bucket) ?? 0) + 1)
}
}
histValue.sum += observeValue
histValue.count++
}
/**
* Timer helper
*/
startTimer(labels: Labels = {}): () => number {
const start = process.hrtime.bigint()
return () => {
const duration = Number(process.hrtime.bigint() - start) / 1_000_000 // ms
this.observe(labels, duration)
return duration
}
}
/**
* Retorna valores do histogram
*/
get(labels: Labels = {}): HistogramValue | undefined {
const key = this.labelsToKey(labels)
return this.values.get(key)
}
/**
* Reseta o histogram
*/
reset(labels?: Labels): void {
if (labels) {
const key = this.labelsToKey(labels)
this.values.delete(key)
} else {
this.values.clear()
}
}
/**
* Retorna todos os valores
*/
getValues(): HistogramValue[] {
return Array.from(this.values.values())
}
/**
* Retorna buckets configurados
*/
getBuckets(): number[] {
return [...this.buckets]
}
private labelsToKey(labels: Labels): string {
return JSON.stringify(labels)
}
/**
* Cria versão com labels pré-definidas
*/
labels(labels: Labels): {
observe: (value: number) => void
startTimer: () => () => number
} {
return {
observe: (value: number) => this.observe(labels, value),
startTimer: () => this.startTimer(labels),
}
}
}
/**
* Classe Summary - percentis de valores
*/
export class Summary implements BaseMetric {
readonly type = 'summary' as const
private values: Map<string, SummaryValue> = new Map()
private percentiles: number[]
private maxAge: number // ms
private maxSize: number
constructor(
public name: string,
public help: string,
public labelNames: string[] = [],
options: { percentiles?: number[]; maxAge?: number; maxSize?: number } = {}
) {
this.percentiles = options.percentiles ?? DEFAULT_PERCENTILES
this.maxAge = options.maxAge ?? 600000 // 10 min
this.maxSize = options.maxSize ?? 1000
}
/**
* Observa um valor
*/
observe(labelsOrValue: Labels | number, value?: number): void {
let labels: Labels = {}
let observeValue: number
if (typeof labelsOrValue === 'number') {
observeValue = labelsOrValue
} else {
labels = labelsOrValue
observeValue = value ?? 0
}
const key = this.labelsToKey(labels)
let summaryValue = this.values.get(key)
if (!summaryValue) {
summaryValue = {
labels,
values: [],
sum: 0,
count: 0,
}
this.values.set(key, summaryValue)
}
summaryValue.values.push(observeValue)
summaryValue.sum += observeValue
summaryValue.count++
// Limitar tamanho
if (summaryValue.values.length > this.maxSize) {
const removed = summaryValue.values.shift()
if (removed !== undefined) {
summaryValue.sum -= removed
summaryValue.count--
}
}
}
/**
* Timer helper
*/
startTimer(labels: Labels = {}): () => number {
const start = process.hrtime.bigint()
return () => {
const duration = Number(process.hrtime.bigint() - start) / 1_000_000 // ms
this.observe(labels, duration)
return duration
}
}
/**
* Calcula percentil
*/
getPercentile(labels: Labels, percentile: number): number | undefined {
const key = this.labelsToKey(labels)
const summaryValue = this.values.get(key)
if (!summaryValue || summaryValue.values.length === 0) {
return undefined
}
const sorted = [...summaryValue.values].sort((a, b) => a - b)
const index = Math.ceil(percentile * sorted.length) - 1
return sorted[Math.max(0, index)]
}
/**
* Retorna valores do summary
*/
get(labels: Labels = {}): SummaryValue | undefined {
const key = this.labelsToKey(labels)
return this.values.get(key)
}
/**
* Reseta o summary
*/
reset(labels?: Labels): void {
if (labels) {
const key = this.labelsToKey(labels)
this.values.delete(key)
} else {
this.values.clear()
}
}
/**
* Retorna todos os valores
*/
getValues(): SummaryValue[] {
return Array.from(this.values.values())
}
/**
* Retorna percentis configurados
*/
getPercentiles(): number[] {
return [...this.percentiles]
}
private labelsToKey(labels: Labels): string {
return JSON.stringify(labels)
}
/**
* Cria versão com labels pré-definidas
*/
labels(labels: Labels): {
observe: (value: number) => void
startTimer: () => () => number
} {
return {
observe: (value: number) => this.observe(labels, value),
startTimer: () => this.startTimer(labels),
}
}
}
/**
* Registry de métricas
*/
export class MetricsRegistry {
private metrics: Map<string, Counter | Gauge | Histogram | Summary> = new Map()
private prefix: string
private defaultLabels: Labels
constructor(options: { prefix?: string; defaultLabels?: Labels } = {}) {
this.prefix = options.prefix || ''
this.defaultLabels = options.defaultLabels || {}
}
/**
* Registra uma métrica
*/
register<T extends Counter | Gauge | Histogram | Summary>(metric: T): T {
const fullName = this.prefix ? `${this.prefix}_${metric.name}` : metric.name
this.metrics.set(fullName, metric)
return metric
}
/**
* Obtém uma métrica
*/
get(name: string): Counter | Gauge | Histogram | Summary | undefined {
const fullName = this.prefix ? `${this.prefix}_${name}` : name
return this.metrics.get(fullName)
}
/**
* Remove uma métrica
*/
remove(name: string): boolean {
const fullName = this.prefix ? `${this.prefix}_${name}` : name
return this.metrics.delete(fullName)
}
/**
* Reseta todas as métricas
*/
resetAll(): void {
for (const metric of this.metrics.values()) {
metric.reset()
}
}
/**
* Retorna métricas no formato Prometheus
*/
async metrics(): Promise<string> {
const lines: string[] = []
for (const [name, metric] of this.metrics) {
lines.push(`# HELP ${name} ${metric.help}`)
lines.push(`# TYPE ${name} ${metric.type}`)
if (metric instanceof Counter || metric instanceof Gauge) {
for (const value of metric.getValues()) {
const labelsStr = this.formatLabels({ ...this.defaultLabels, ...value.labels })
lines.push(`${name}${labelsStr} ${value.value}`)
}
} else if (metric instanceof Histogram) {
for (const value of metric.getValues()) {
const labelsStr = this.formatLabels({ ...this.defaultLabels, ...value.labels })
const buckets = metric.getBuckets()
for (const bucket of buckets) {
const bucketLabels = this.formatLabels({
...this.defaultLabels,
...value.labels,
le: String(bucket),
})
lines.push(`${name}_bucket${bucketLabels} ${value.buckets.get(bucket) ?? 0}`)
}
// +Inf bucket
const infLabels = this.formatLabels({
...this.defaultLabels,
...value.labels,
le: '+Inf',
})
lines.push(`${name}_bucket${infLabels} ${value.count}`)
lines.push(`${name}_sum${labelsStr} ${value.sum}`)
lines.push(`${name}_count${labelsStr} ${value.count}`)
}
} else if (metric instanceof Summary) {
for (const value of metric.getValues()) {
const labelsStr = this.formatLabels({ ...this.defaultLabels, ...value.labels })
for (const percentile of metric.getPercentiles()) {
const quantileLabels = this.formatLabels({
...this.defaultLabels,
...value.labels,
quantile: String(percentile),
})
const percentileValue = metric.getPercentile(value.labels, percentile)
if (percentileValue !== undefined) {
lines.push(`${name}${quantileLabels} ${percentileValue}`)
}
}
lines.push(`${name}_sum${labelsStr} ${value.sum}`)
lines.push(`${name}_count${labelsStr} ${value.count}`)
}
}
lines.push('')
}
return lines.join('\n')
}
/**
* Retorna content type para Prometheus
*/
contentType(): string {
return 'text/plain; version=0.0.4; charset=utf-8'
}
private formatLabels(labels: Labels): string {
const entries = Object.entries(labels)
if (entries.length === 0) return ''
const formatted = entries.map(([k, v]) => `${k}="${this.escapeLabel(v)}"`).join(',')
return `{${formatted}}`
}
private escapeLabel(value: string): string {
return value.replace(/\\/g, '\\\\').replace(/"/g, '\\"').replace(/\n/g, '\\n')
}
}
// === Métricas Padrão do Baileys ===
/**
* Registry global para métricas do Baileys
*/
export const baileysMetrics = new MetricsRegistry({ prefix: 'baileys' })
/**
* Métricas pré-definidas para Baileys
*/
export const metrics = {
// Conexão
connectionAttempts: baileysMetrics.register(
new Counter('connection_attempts_total', 'Total de tentativas de conexão', ['status'])
),
connectionState: baileysMetrics.register(
new Gauge('connection_state', 'Estado atual da conexão (0=desconectado, 1=conectado)', ['instance'])
),
connectionDuration: baileysMetrics.register(
new Gauge('connection_duration_seconds', 'Duração da conexão atual em segundos', ['instance'])
),
// Mensagens
messagesSent: baileysMetrics.register(
new Counter('messages_sent_total', 'Total de mensagens enviadas', ['type'])
),
messagesReceived: baileysMetrics.register(
new Counter('messages_received_total', 'Total de mensagens recebidas', ['type'])
),
messageLatency: baileysMetrics.register(
new Histogram('message_latency_ms', 'Latência de envio de mensagem em ms', ['type'], [10, 50, 100, 250, 500, 1000, 2500, 5000])
),
// Mídia
mediaUploads: baileysMetrics.register(
new Counter('media_uploads_total', 'Total de uploads de mídia', ['type', 'status'])
),
mediaDownloads: baileysMetrics.register(
new Counter('media_downloads_total', 'Total de downloads de mídia', ['type', 'status'])
),
mediaSize: baileysMetrics.register(
new Histogram('media_size_bytes', 'Tamanho de mídia em bytes', ['type', 'direction'], [1024, 10240, 102400, 1048576, 10485760])
),
// Erros
errors: baileysMetrics.register(
new Counter('errors_total', 'Total de erros', ['category', 'code'])
),
// Retries
retries: baileysMetrics.register(
new Counter('retries_total', 'Total de retentativas', ['operation'])
),
retryLatency: baileysMetrics.register(
new Histogram('retry_latency_ms', 'Latência de retentativas em ms', ['operation'])
),
// Socket
socketEvents: baileysMetrics.register(
new Counter('socket_events_total', 'Total de eventos de socket', ['event'])
),
socketLatency: baileysMetrics.register(
new Histogram('socket_latency_ms', 'Latência de operações de socket em ms', ['operation'])
),
// Criptografia
encryptionOperations: baileysMetrics.register(
new Counter('encryption_operations_total', 'Total de operações de criptografia', ['operation'])
),
// Cache
cacheHits: baileysMetrics.register(new Counter('cache_hits_total', 'Total de cache hits', ['cache'])),
cacheMisses: baileysMetrics.register(new Counter('cache_misses_total', 'Total de cache misses', ['cache'])),
cacheSize: baileysMetrics.register(new Gauge('cache_size', 'Tamanho atual do cache', ['cache'])),
}
/**
* Helper para criar endpoint HTTP de métricas
*/
export function createMetricsHandler(registry: MetricsRegistry = baileysMetrics) {
return async (_req: unknown, res: { setHeader: (name: string, value: string) => void; end: (body: string) => void }) => {
const metricsOutput = await registry.metrics()
res.setHeader('Content-Type', registry.contentType())
res.end(metricsOutput)
}
}
export default baileysMetrics
+635
View File
@@ -0,0 +1,635 @@
/**
* @fileoverview Lógica de retry inteligente
* @module Utils/retry-utils
*
* Fornece:
* - Exponential backoff
* - Jitter para evitar thundering herd
* - Max attempts configurável
* - Predicates de retry customizáveis
* - Integração com circuit breaker
* - Hooks de eventos
* - Cancelamento
*/
import { EventEmitter } from 'events'
import { metrics } from './prometheus-metrics.js'
import type { CircuitBreaker } from './circuit-breaker.js'
/**
* Estratégias de backoff
*/
export type BackoffStrategy = 'exponential' | 'linear' | 'constant' | 'fibonacci'
/**
* Opções de configuração de retry
*/
export interface RetryOptions {
/** Número máximo de tentativas (default: 3) */
maxAttempts?: number
/** Delay base em ms (default: 1000) */
baseDelay?: number
/** Delay máximo em ms (default: 30000) */
maxDelay?: number
/** Estratégia de backoff (default: exponential) */
backoffStrategy?: BackoffStrategy
/** Multiplicador para exponential backoff (default: 2) */
backoffMultiplier?: number
/** Percentual de jitter (0-1, default: 0.1) */
jitter?: number
/** Função para determinar se deve retry */
shouldRetry?: (error: Error, attempt: number) => boolean | Promise<boolean>
/** Timeout por tentativa em ms */
timeout?: number
/** Nome da operação para métricas */
operationName?: string
/** Coletar métricas */
collectMetrics?: boolean
/** Circuit breaker para integração */
circuitBreaker?: CircuitBreaker
/** Callback antes de cada retry */
onRetry?: (error: Error, attempt: number, delay: number) => void | Promise<void>
/** Callback em sucesso */
onSuccess?: (result: unknown, attempt: number) => void
/** Callback em falha final */
onFailure?: (error: Error, attempts: number) => void
/** Signal para cancelamento */
abortSignal?: AbortSignal
}
/**
* Resultado de operação com retry
*/
export interface RetryResult<T> {
success: boolean
result?: T
error?: Error
attempts: number
totalDuration: number
lastAttemptDuration: number
}
/**
* Contexto de retry
*/
export interface RetryContext {
attempt: number
maxAttempts: number
lastError?: Error
startTime: number
aborted: boolean
}
/**
* Erro de retry esgotado
*/
export class RetryExhaustedError extends Error {
constructor(
public readonly originalError: Error,
public readonly attempts: number,
public readonly operationName?: string
) {
super(
`Retry exhausted after ${attempts} attempts${operationName ? ` for "${operationName}"` : ''}: ${originalError.message}`
)
this.name = 'RetryExhaustedError'
}
}
/**
* Erro de abort
*/
export class RetryAbortedError extends Error {
constructor(public readonly attempt: number) {
super(`Retry aborted at attempt ${attempt}`)
this.name = 'RetryAbortedError'
}
}
/**
* Calcula delay com base na estratégia
*/
export function calculateDelay(
attempt: number,
baseDelay: number,
maxDelay: number,
strategy: BackoffStrategy,
multiplier: number,
jitter: number
): number {
let delay: number
switch (strategy) {
case 'exponential':
delay = baseDelay * Math.pow(multiplier, attempt - 1)
break
case 'linear':
delay = baseDelay * attempt
break
case 'constant':
delay = baseDelay
break
case 'fibonacci': {
const fib = fibonacciNumber(attempt)
delay = baseDelay * fib
break
}
default:
delay = baseDelay
}
// Aplicar cap de delay máximo
delay = Math.min(delay, maxDelay)
// Aplicar jitter
if (jitter > 0) {
const jitterAmount = delay * jitter
delay = delay + (Math.random() * 2 - 1) * jitterAmount
}
return Math.max(0, Math.round(delay))
}
/**
* Calcula número de Fibonacci
*/
function fibonacciNumber(n: number): number {
if (n <= 1) return 1
let a = 1,
b = 1
for (let i = 2; i < n; i++) {
const c = a + b
a = b
b = c
}
return b
}
/**
* Sleep com suporte a abort
*/
async function sleep(ms: number, signal?: AbortSignal): Promise<void> {
return new Promise((resolve, reject) => {
const timer = setTimeout(resolve, ms)
if (signal) {
if (signal.aborted) {
clearTimeout(timer)
reject(new RetryAbortedError(0))
return
}
const abortHandler = () => {
clearTimeout(timer)
reject(new RetryAbortedError(0))
}
signal.addEventListener('abort', abortHandler, { once: true })
}
})
}
/**
* Executa operação com timeout
*/
async function executeWithTimeout<T>(
operation: () => Promise<T>,
timeout: number,
signal?: AbortSignal
): Promise<T> {
return new Promise<T>((resolve, reject) => {
let completed = false
const timer = setTimeout(() => {
if (!completed) {
completed = true
reject(new Error(`Operation timed out after ${timeout}ms`))
}
}, timeout)
if (signal?.aborted) {
clearTimeout(timer)
reject(new RetryAbortedError(0))
return
}
operation()
.then((result) => {
if (!completed) {
completed = true
clearTimeout(timer)
resolve(result)
}
})
.catch((error) => {
if (!completed) {
completed = true
clearTimeout(timer)
reject(error)
}
})
})
}
/**
* Função principal de retry
*/
export async function retry<T>(
operation: (context: RetryContext) => T | Promise<T>,
options: RetryOptions = {}
): Promise<T> {
const config = {
maxAttempts: options.maxAttempts ?? 3,
baseDelay: options.baseDelay ?? 1000,
maxDelay: options.maxDelay ?? 30000,
backoffStrategy: options.backoffStrategy ?? 'exponential',
backoffMultiplier: options.backoffMultiplier ?? 2,
jitter: options.jitter ?? 0.1,
shouldRetry: options.shouldRetry ?? (() => true),
timeout: options.timeout,
operationName: options.operationName ?? 'operation',
collectMetrics: options.collectMetrics ?? true,
circuitBreaker: options.circuitBreaker,
onRetry: options.onRetry ?? (() => {}),
onSuccess: options.onSuccess ?? (() => {}),
onFailure: options.onFailure ?? (() => {}),
abortSignal: options.abortSignal,
}
const context: RetryContext = {
attempt: 0,
maxAttempts: config.maxAttempts,
startTime: Date.now(),
aborted: false,
}
let lastError: Error | undefined
// Verificar abort inicial
if (config.abortSignal?.aborted) {
throw new RetryAbortedError(0)
}
for (let attempt = 1; attempt <= config.maxAttempts; attempt++) {
context.attempt = attempt
// Verificar abort
if (config.abortSignal?.aborted) {
context.aborted = true
throw new RetryAbortedError(attempt)
}
// Verificar circuit breaker
if (config.circuitBreaker?.isOpen()) {
throw new Error(`Circuit breaker "${config.circuitBreaker.getName()}" is open`)
}
try {
// Executar operação
let result: T
if (config.timeout) {
result = await executeWithTimeout(
() => Promise.resolve(operation(context)),
config.timeout,
config.abortSignal
)
} else {
result = await operation(context)
}
// Sucesso
if (config.collectMetrics) {
metrics.retries.inc({ operation: config.operationName })
}
config.onSuccess(result, attempt)
return result
} catch (error) {
lastError = error as Error
context.lastError = lastError
// Verificar se deve retry
const shouldRetry = await config.shouldRetry(lastError, attempt)
if (!shouldRetry || attempt >= config.maxAttempts) {
// Falha final
if (config.collectMetrics) {
metrics.errors.inc({ category: 'retry', code: 'exhausted' })
}
config.onFailure(lastError, attempt)
throw new RetryExhaustedError(lastError, attempt, config.operationName)
}
// Calcular delay
const delay = calculateDelay(
attempt,
config.baseDelay,
config.maxDelay,
config.backoffStrategy,
config.backoffMultiplier,
config.jitter
)
// Callback de retry
await config.onRetry(lastError, attempt, delay)
if (config.collectMetrics) {
metrics.retryLatency.observe({ operation: config.operationName }, delay)
}
// Aguardar delay
await sleep(delay, config.abortSignal)
}
}
// Nunca deve chegar aqui, mas TypeScript precisa
throw new RetryExhaustedError(lastError || new Error('Unknown error'), config.maxAttempts, config.operationName)
}
/**
* Retry com resultado detalhado
*/
export async function retryWithResult<T>(
operation: (context: RetryContext) => T | Promise<T>,
options: RetryOptions = {}
): Promise<RetryResult<T>> {
const startTime = Date.now()
let attempts = 0
let lastAttemptStart = startTime
try {
const result = await retry(
(context) => {
attempts = context.attempt
lastAttemptStart = Date.now()
return operation(context)
},
options
)
return {
success: true,
result,
attempts,
totalDuration: Date.now() - startTime,
lastAttemptDuration: Date.now() - lastAttemptStart,
}
} catch (error) {
return {
success: false,
error: error as Error,
attempts,
totalDuration: Date.now() - startTime,
lastAttemptDuration: Date.now() - lastAttemptStart,
}
}
}
/**
* Factory para criar função de retry configurada
*/
export function createRetrier(defaultOptions: RetryOptions = {}) {
return <T>(
operation: (context: RetryContext) => T | Promise<T>,
options?: RetryOptions
): Promise<T> => {
return retry(operation, { ...defaultOptions, ...options })
}
}
/**
* Decorator para adicionar retry a método
*/
export function withRetry(options: RetryOptions = {}) {
return function (
_target: unknown,
propertyKey: string,
descriptor: TypedPropertyDescriptor<(...args: unknown[]) => unknown>
) {
const originalMethod = descriptor.value
if (!originalMethod) return descriptor
descriptor.value = async function (...args: unknown[]): Promise<unknown> {
return retry(
() => originalMethod.apply(this, args),
{ ...options, operationName: options.operationName || propertyKey }
)
}
return descriptor
}
}
/**
* Wrapper para função com retry
*/
export function retryable<T extends (...args: unknown[]) => unknown>(
fn: T,
options: RetryOptions = {}
): (...args: Parameters<T>) => Promise<ReturnType<T>> {
return async (...args: Parameters<T>): Promise<ReturnType<T>> => {
return retry(() => fn(...args), options) as Promise<ReturnType<T>>
}
}
/**
* Classe para gerenciar retries com estado
*/
export class RetryManager extends EventEmitter {
private activeRetries: Map<string, { cancel: () => void; context: RetryContext }> = new Map()
private defaultOptions: RetryOptions
constructor(defaultOptions: RetryOptions = {}) {
super()
this.defaultOptions = defaultOptions
}
/**
* Executa operação com retry
*/
async execute<T>(
id: string,
operation: (context: RetryContext) => T | Promise<T>,
options?: RetryOptions
): Promise<T> {
// Cancelar retry anterior com mesmo ID
this.cancel(id)
const abortController = new AbortController()
const mergedOptions = { ...this.defaultOptions, ...options, abortSignal: abortController.signal }
const retryPromise = retry((context) => {
this.activeRetries.set(id, {
cancel: () => abortController.abort(),
context,
})
this.emit('attempt', { id, attempt: context.attempt })
return operation(context)
}, mergedOptions)
try {
const result = await retryPromise
this.emit('success', { id })
return result
} catch (error) {
this.emit('failure', { id, error })
throw error
} finally {
this.activeRetries.delete(id)
}
}
/**
* Cancela retry em andamento
*/
cancel(id: string): boolean {
const active = this.activeRetries.get(id)
if (active) {
active.cancel()
this.activeRetries.delete(id)
this.emit('cancelled', { id })
return true
}
return false
}
/**
* Cancela todos os retries
*/
cancelAll(): void {
for (const [id, active] of this.activeRetries) {
active.cancel()
this.emit('cancelled', { id })
}
this.activeRetries.clear()
}
/**
* Verifica se há retry ativo
*/
isActive(id: string): boolean {
return this.activeRetries.has(id)
}
/**
* Retorna contexto de retry ativo
*/
getContext(id: string): RetryContext | undefined {
return this.activeRetries.get(id)?.context
}
/**
* Retorna IDs de retries ativos
*/
getActiveIds(): string[] {
return Array.from(this.activeRetries.keys())
}
}
/**
* Predicates comuns para shouldRetry
*/
export const retryPredicates = {
/** Sempre retry (até max attempts) */
always: () => true,
/** Nunca retry */
never: () => false,
/** Retry apenas em erros de rede */
onNetworkError: (error: Error) => {
const networkErrors = ['ECONNREFUSED', 'ECONNRESET', 'ETIMEDOUT', 'ENOTFOUND', 'EAI_AGAIN']
return networkErrors.some((code) => error.message.includes(code) || (error as NodeJS.ErrnoException).code === code)
},
/** Retry apenas em erros específicos */
onErrorCodes:
(codes: string[]) =>
(error: Error): boolean => {
return codes.some((code) => error.message.includes(code) || (error as NodeJS.ErrnoException).code === code)
},
/** Retry exceto em erros específicos */
exceptErrorCodes:
(codes: string[]) =>
(error: Error): boolean => {
return !codes.some((code) => error.message.includes(code) || (error as NodeJS.ErrnoException).code === code)
},
/** Retry em erros HTTP 5xx ou timeout */
onServerError: (error: Error) => {
const message = error.message.toLowerCase()
return (
message.includes('500') ||
message.includes('502') ||
message.includes('503') ||
message.includes('504') ||
message.includes('timeout')
)
},
/** Combina múltiplos predicates com OR */
or:
(...predicates: Array<(error: Error, attempt: number) => boolean>) =>
(error: Error, attempt: number): boolean => {
return predicates.some((p) => p(error, attempt))
},
/** Combina múltiplos predicates com AND */
and:
(...predicates: Array<(error: Error, attempt: number) => boolean>) =>
(error: Error, attempt: number): boolean => {
return predicates.every((p) => p(error, attempt))
},
}
/**
* Configurações pré-definidas de retry
*/
export const retryConfigs = {
/** Retry agressivo (muitas tentativas, delays curtos) */
aggressive: {
maxAttempts: 10,
baseDelay: 100,
maxDelay: 5000,
backoffStrategy: 'exponential' as const,
jitter: 0.2,
},
/** Retry conservador (poucas tentativas, delays longos) */
conservative: {
maxAttempts: 3,
baseDelay: 2000,
maxDelay: 60000,
backoffStrategy: 'exponential' as const,
jitter: 0.1,
},
/** Retry rápido (para operações curtas) */
fast: {
maxAttempts: 5,
baseDelay: 50,
maxDelay: 1000,
backoffStrategy: 'linear' as const,
jitter: 0.05,
},
/** Retry para operações de rede */
network: {
maxAttempts: 5,
baseDelay: 1000,
maxDelay: 30000,
backoffStrategy: 'exponential' as const,
jitter: 0.1,
shouldRetry: retryPredicates.onNetworkError,
},
}
export default retry
+482
View File
@@ -0,0 +1,482 @@
/**
* @fileoverview Sistema de logging estruturado para InfiniteAPI
* @module Utils/structured-logger
*
* Fornece:
* - Níveis de log configuráveis (trace, debug, info, warn, error, fatal)
* - Formatação JSON para análise
* - Contexto hierárquico com child loggers
* - Integração com sistemas externos (hooks)
* - Suporte a métricas de logging
* - Sanitização de dados sensíveis
*/
import type { ILogger } from './logger.js'
/**
* Níveis de log disponíveis (ordenados por severidade)
*/
export type LogLevel = 'trace' | 'debug' | 'info' | 'warn' | 'error' | 'fatal' | 'silent'
/**
* Valores numéricos para cada nível de log
*/
export const LOG_LEVEL_VALUES: Record<LogLevel, number> = {
trace: 10,
debug: 20,
info: 30,
warn: 40,
error: 50,
fatal: 60,
silent: 100,
}
/**
* Configuração do logger estruturado
*/
export interface StructuredLoggerConfig {
/** Nível mínimo de log a ser registrado */
level: LogLevel
/** Nome do serviço/componente */
name?: string
/** Contexto adicional a ser incluído em todos os logs */
context?: Record<string, unknown>
/** Se deve formatar como JSON (true) ou texto legível (false) */
jsonFormat?: boolean
/** Campos a serem sanitizados (senhas, tokens, etc.) */
redactFields?: string[]
/** Hook para enviar logs para sistemas externos */
externalHook?: (entry: LogEntry) => void | Promise<void>
/** Se deve incluir stack trace em erros */
includeStackTrace?: boolean
/** Timezone para timestamps (default: UTC) */
timezone?: string
}
/**
* Entrada de log estruturada
*/
export interface LogEntry {
/** Timestamp ISO 8601 */
timestamp: string
/** Nível do log */
level: LogLevel
/** Valor numérico do nível */
levelValue: number
/** Mensagem principal */
message: string
/** Nome do logger/componente */
name?: string
/** Contexto adicional */
context?: Record<string, unknown>
/** Dados do objeto logado */
data?: Record<string, unknown>
/** Stack trace (para erros) */
stack?: string
/** ID de correlação para rastreamento */
correlationId?: string
/** Duração de operação em ms (se aplicável) */
durationMs?: number
}
/**
* Métricas de logging
*/
export interface LoggerMetrics {
totalLogs: number
logsByLevel: Record<LogLevel, number>
errorsCount: number
lastLogTimestamp?: string
}
/**
* Campos padrão a serem sanitizados
*/
const DEFAULT_REDACT_FIELDS = [
'password',
'passwd',
'secret',
'token',
'accessToken',
'refreshToken',
'apiKey',
'api_key',
'authorization',
'auth',
'credentials',
'privateKey',
'private_key',
]
/**
* Classe principal do Logger Estruturado
*/
export class StructuredLogger implements ILogger {
private config: Required<StructuredLoggerConfig>
private metrics: LoggerMetrics
private childContext: Record<string, unknown> = {}
constructor(config: StructuredLoggerConfig) {
this.config = {
level: config.level,
name: config.name || 'app',
context: config.context || {},
jsonFormat: config.jsonFormat ?? true,
redactFields: [...DEFAULT_REDACT_FIELDS, ...(config.redactFields || [])],
externalHook: config.externalHook || (() => {}),
includeStackTrace: config.includeStackTrace ?? true,
timezone: config.timezone || 'UTC',
}
this.metrics = {
totalLogs: 0,
logsByLevel: {
trace: 0,
debug: 0,
info: 0,
warn: 0,
error: 0,
fatal: 0,
silent: 0,
},
errorsCount: 0,
}
}
/**
* Getter para o nível atual do logger (compatibilidade com ILogger)
*/
get level(): string {
return this.config.level
}
/**
* Setter para o nível do logger
*/
set level(newLevel: string) {
if (newLevel in LOG_LEVEL_VALUES) {
this.config.level = newLevel as LogLevel
}
}
/**
* Cria um logger filho com contexto adicional
*/
child(obj: Record<string, unknown>): StructuredLogger {
const childLogger = new StructuredLogger({
...this.config,
context: { ...this.config.context, ...this.childContext, ...obj },
})
childLogger.childContext = { ...this.childContext, ...obj }
return childLogger
}
/**
* Verifica se o nível de log está habilitado
*/
isLevelEnabled(level: LogLevel): boolean {
return LOG_LEVEL_VALUES[level] >= LOG_LEVEL_VALUES[this.config.level]
}
/**
* Método principal de logging
*/
private log(level: LogLevel, obj: unknown, msg?: string): void {
if (!this.isLevelEnabled(level)) {
return
}
const entry = this.createLogEntry(level, obj, msg)
// Atualizar métricas
this.updateMetrics(level)
// Output
this.output(entry)
// Hook externo (async, não bloqueia)
if (this.config.externalHook) {
Promise.resolve(this.config.externalHook(entry)).catch(() => {
// Silently ignore hook errors
})
}
}
/**
* Cria uma entrada de log estruturada
*/
private createLogEntry(level: LogLevel, obj: unknown, msg?: string): LogEntry {
const timestamp = new Date().toISOString()
let message = msg || ''
let data: Record<string, unknown> | undefined
let stack: string | undefined
// Processar objeto
if (obj instanceof Error) {
message = message || obj.message
if (this.config.includeStackTrace && obj.stack) {
stack = obj.stack
}
data = {
errorName: obj.name,
errorMessage: obj.message,
...(obj as unknown as Record<string, unknown>),
}
} else if (typeof obj === 'object' && obj !== null) {
data = this.sanitize(obj as Record<string, unknown>)
if (!message && 'msg' in (obj as Record<string, unknown>)) {
message = String((obj as Record<string, unknown>).msg)
}
} else if (typeof obj === 'string') {
message = message || obj
}
// Extrair correlationId e durationMs se presentes
const correlationId = data?.correlationId as string | undefined
const durationMs = data?.durationMs as number | undefined
return {
timestamp,
level,
levelValue: LOG_LEVEL_VALUES[level],
message,
name: this.config.name,
context: Object.keys(this.config.context).length > 0 ? this.config.context : undefined,
data,
stack,
correlationId,
durationMs,
}
}
/**
* Sanitiza dados sensíveis
*/
private sanitize(obj: Record<string, unknown>): Record<string, unknown> {
const sanitized: Record<string, unknown> = {}
for (const [key, value] of Object.entries(obj)) {
const lowerKey = key.toLowerCase()
if (this.config.redactFields.some((field) => lowerKey.includes(field.toLowerCase()))) {
sanitized[key] = '[REDACTED]'
} else if (typeof value === 'object' && value !== null && !Array.isArray(value)) {
sanitized[key] = this.sanitize(value as Record<string, unknown>)
} else {
sanitized[key] = value
}
}
return sanitized
}
/**
* Atualiza métricas internas
*/
private updateMetrics(level: LogLevel): void {
this.metrics.totalLogs++
this.metrics.logsByLevel[level]++
this.metrics.lastLogTimestamp = new Date().toISOString()
if (level === 'error' || level === 'fatal') {
this.metrics.errorsCount++
}
}
/**
* Output do log
*/
private output(entry: LogEntry): void {
const output = this.config.jsonFormat ? JSON.stringify(entry) : this.formatText(entry)
switch (entry.level) {
case 'trace':
case 'debug':
console.debug(output)
break
case 'info':
console.info(output)
break
case 'warn':
console.warn(output)
break
case 'error':
case 'fatal':
console.error(output)
break
}
}
/**
* Formata log como texto legível
*/
private formatText(entry: LogEntry): string {
const parts = [
`[${entry.timestamp}]`,
`[${entry.level.toUpperCase()}]`,
entry.name ? `[${entry.name}]` : '',
entry.correlationId ? `[${entry.correlationId}]` : '',
entry.message,
entry.durationMs !== undefined ? `(${entry.durationMs}ms)` : '',
]
let text = parts.filter(Boolean).join(' ')
if (entry.data && Object.keys(entry.data).length > 0) {
text += ` | ${JSON.stringify(entry.data)}`
}
if (entry.stack) {
text += `\n${entry.stack}`
}
return text
}
// Métodos de conveniência para cada nível de log
trace(obj: unknown, msg?: string): void {
this.log('trace', obj, msg)
}
debug(obj: unknown, msg?: string): void {
this.log('debug', obj, msg)
}
info(obj: unknown, msg?: string): void {
this.log('info', obj, msg)
}
warn(obj: unknown, msg?: string): void {
this.log('warn', obj, msg)
}
error(obj: unknown, msg?: string): void {
this.log('error', obj, msg)
}
fatal(obj: unknown, msg?: string): void {
this.log('fatal', obj, msg)
}
/**
* Log com contexto temporário
*/
withContext(context: Record<string, unknown>): StructuredLogger {
return this.child(context)
}
/**
* Log com correlation ID
*/
withCorrelationId(correlationId: string): StructuredLogger {
return this.child({ correlationId })
}
/**
* Log de operação com duração
*/
logOperation<T>(
operationName: string,
operation: () => T | Promise<T>,
level: LogLevel = 'info'
): T | Promise<T> {
const startTime = Date.now()
const contextLogger = this.child({ operation: operationName })
contextLogger.log(level, { event: 'operation_start' }, `Starting ${operationName}`)
const handleResult = (result: T): T => {
const durationMs = Date.now() - startTime
contextLogger.log(level, { event: 'operation_complete', durationMs }, `Completed ${operationName}`)
return result
}
const handleError = (error: Error): never => {
const durationMs = Date.now() - startTime
contextLogger.error({ event: 'operation_error', durationMs, error }, `Failed ${operationName}`)
throw error
}
try {
const result = operation()
if (result instanceof Promise) {
return result.then(handleResult).catch(handleError) as Promise<T>
}
return handleResult(result)
} catch (error) {
return handleError(error as Error)
}
}
/**
* Retorna métricas do logger
*/
getMetrics(): LoggerMetrics {
return { ...this.metrics }
}
/**
* Reseta métricas
*/
resetMetrics(): void {
this.metrics = {
totalLogs: 0,
logsByLevel: {
trace: 0,
debug: 0,
info: 0,
warn: 0,
error: 0,
fatal: 0,
silent: 0,
},
errorsCount: 0,
}
}
}
/**
* Factory para criar logger estruturado
*/
export function createStructuredLogger(config: Partial<StructuredLoggerConfig> = {}): StructuredLogger {
return new StructuredLogger({
level: config.level || 'info',
...config,
})
}
/**
* Logger padrão singleton
*/
let defaultLogger: StructuredLogger | null = null
export function getDefaultLogger(): StructuredLogger {
if (!defaultLogger) {
defaultLogger = createStructuredLogger({
level: 'info',
name: 'baileys',
jsonFormat: process.env.NODE_ENV === 'production',
})
}
return defaultLogger
}
export function setDefaultLogger(logger: StructuredLogger): void {
defaultLogger = logger
}
/**
* Utilitário para medir tempo de execução
*/
export function createTimer(): { elapsed: () => number; elapsedMs: () => string } {
const start = process.hrtime.bigint()
return {
elapsed: () => Number(process.hrtime.bigint() - start) / 1_000_000,
elapsedMs: () => `${(Number(process.hrtime.bigint() - start) / 1_000_000).toFixed(2)}ms`,
}
}
export default StructuredLogger
+656
View File
@@ -0,0 +1,656 @@
/**
* @fileoverview Contexto de rastreamento para requests
* @module Utils/trace-context
*
* Fornece:
* - Geração de trace IDs únicos
* - Context propagation entre operações
* - Correlation IDs para rastrear requests
* - Performance timing
* - Span tracking para operações aninhadas
* - Baggage para dados contextuais
*/
import { randomBytes } from 'crypto'
import { AsyncLocalStorage } from 'async_hooks'
/**
* Identificadores de trace
*/
export interface TraceIds {
/** ID único do trace (16 bytes hex) */
traceId: string
/** ID do span atual (8 bytes hex) */
spanId: string
/** ID do span pai (opcional) */
parentSpanId?: string
/** ID de correlação para logging */
correlationId: string
}
/**
* Dados de baggage (contexto propagado)
*/
export type Baggage = Record<string, string | number | boolean>
/**
* Status de um span
*/
export type SpanStatus = 'unset' | 'ok' | 'error'
/**
* Span representa uma unidade de trabalho
*/
export interface Span {
/** Nome da operação */
name: string
/** IDs de rastreamento */
traceIds: TraceIds
/** Timestamp de início (ms) */
startTime: number
/** Timestamp de fim (ms) */
endTime?: number
/** Duração em ms */
duration?: number
/** Status do span */
status: SpanStatus
/** Atributos do span */
attributes: Record<string, unknown>
/** Eventos ocorridos durante o span */
events: SpanEvent[]
/** Se o span está finalizado */
ended: boolean
}
/**
* Evento dentro de um span
*/
export interface SpanEvent {
/** Nome do evento */
name: string
/** Timestamp do evento */
timestamp: number
/** Atributos do evento */
attributes?: Record<string, unknown>
}
/**
* Contexto completo de trace
*/
export interface TraceContext {
/** IDs de rastreamento */
traceIds: TraceIds
/** Baggage (dados propagados) */
baggage: Baggage
/** Span atual */
currentSpan?: Span
/** Stack de spans (para spans aninhados) */
spanStack: Span[]
/** Timestamp de criação do contexto */
createdAt: number
/** Metadados adicionais */
metadata: Record<string, unknown>
}
/**
* Opções para criar um novo contexto
*/
export interface CreateContextOptions {
/** Trace ID existente (para propagação) */
traceId?: string
/** Parent span ID */
parentSpanId?: string
/** Correlation ID existente */
correlationId?: string
/** Baggage inicial */
baggage?: Baggage
/** Metadados iniciais */
metadata?: Record<string, unknown>
}
/**
* Opções para criar um span
*/
export interface CreateSpanOptions {
/** Nome do span */
name: string
/** Atributos iniciais */
attributes?: Record<string, unknown>
/** Se deve ser filho do span atual */
asChild?: boolean
}
/**
* Storage assíncrono para contexto de trace
*/
const traceStorage = new AsyncLocalStorage<TraceContext>()
/**
* Gera um ID hexadecimal aleatório
*/
function generateId(bytes: number): string {
return randomBytes(bytes).toString('hex')
}
/**
* Gera um trace ID (16 bytes = 32 chars hex)
*/
export function generateTraceId(): string {
return generateId(16)
}
/**
* Gera um span ID (8 bytes = 16 chars hex)
*/
export function generateSpanId(): string {
return generateId(8)
}
/**
* Gera um correlation ID mais legível
*/
export function generateCorrelationId(): string {
const timestamp = Date.now().toString(36)
const random = generateId(4)
return `${timestamp}-${random}`
}
/**
* Cria um novo contexto de trace
*/
export function createTraceContext(options: CreateContextOptions = {}): TraceContext {
const traceId = options.traceId || generateTraceId()
const spanId = generateSpanId()
const correlationId = options.correlationId || generateCorrelationId()
return {
traceIds: {
traceId,
spanId,
parentSpanId: options.parentSpanId,
correlationId,
},
baggage: options.baggage || {},
spanStack: [],
createdAt: Date.now(),
metadata: options.metadata || {},
}
}
/**
* Obtém o contexto de trace atual
*/
export function getCurrentContext(): TraceContext | undefined {
return traceStorage.getStore()
}
/**
* Obtém o contexto de trace atual ou cria um novo
*/
export function getOrCreateContext(): TraceContext {
const existing = getCurrentContext()
if (existing) {
return existing
}
return createTraceContext()
}
/**
* Executa função com contexto de trace
*/
export function runWithContext<T>(context: TraceContext, fn: () => T): T {
return traceStorage.run(context, fn)
}
/**
* Executa função com novo contexto de trace
*/
export function runWithNewContext<T>(options: CreateContextOptions, fn: () => T): T {
const context = createTraceContext(options)
return runWithContext(context, fn)
}
/**
* Executa função assíncrona com contexto de trace
*/
export async function runWithContextAsync<T>(
context: TraceContext,
fn: () => Promise<T>
): Promise<T> {
return traceStorage.run(context, fn)
}
/**
* Cria um novo span
*/
export function createSpan(options: CreateSpanOptions): Span {
const context = getCurrentContext()
const parentSpan = context?.currentSpan
const span: Span = {
name: options.name,
traceIds: {
traceId: context?.traceIds.traceId || generateTraceId(),
spanId: generateSpanId(),
parentSpanId: options.asChild && parentSpan ? parentSpan.traceIds.spanId : undefined,
correlationId: context?.traceIds.correlationId || generateCorrelationId(),
},
startTime: Date.now(),
status: 'unset',
attributes: options.attributes || {},
events: [],
ended: false,
}
return span
}
/**
* Inicia um span no contexto atual
*/
export function startSpan(options: CreateSpanOptions): Span {
const context = getOrCreateContext()
const span = createSpan({ ...options, asChild: true })
// Push span atual para stack e define novo como atual
if (context.currentSpan) {
context.spanStack.push(context.currentSpan)
}
context.currentSpan = span
return span
}
/**
* Finaliza um span
*/
export function endSpan(span: Span, status?: SpanStatus): void {
if (span.ended) {
return
}
span.endTime = Date.now()
span.duration = span.endTime - span.startTime
span.status = status || 'ok'
span.ended = true
// Pop span do stack no contexto
const context = getCurrentContext()
if (context && context.currentSpan === span) {
context.currentSpan = context.spanStack.pop()
}
}
/**
* Adiciona evento a um span
*/
export function addSpanEvent(span: Span, name: string, attributes?: Record<string, unknown>): void {
if (span.ended) {
return
}
span.events.push({
name,
timestamp: Date.now(),
attributes,
})
}
/**
* Define atributos em um span
*/
export function setSpanAttributes(span: Span, attributes: Record<string, unknown>): void {
if (span.ended) {
return
}
Object.assign(span.attributes, attributes)
}
/**
* Marca span como erro
*/
export function setSpanError(span: Span, error: Error): void {
if (span.ended) {
return
}
span.status = 'error'
span.attributes.error = true
span.attributes.errorMessage = error.message
span.attributes.errorName = error.name
if (error.stack) {
span.attributes.errorStack = error.stack
}
addSpanEvent(span, 'exception', {
'exception.type': error.name,
'exception.message': error.message,
})
}
/**
* Decorator para rastrear função automaticamente
*/
export function traced(name?: string) {
return function <T extends (...args: unknown[]) => unknown>(
_target: unknown,
propertyKey: string,
descriptor: TypedPropertyDescriptor<T>
): TypedPropertyDescriptor<T> {
const originalMethod = descriptor.value
if (!originalMethod) {
return descriptor
}
const spanName = name || propertyKey
descriptor.value = function (this: unknown, ...args: Parameters<T>): ReturnType<T> {
const span = startSpan({ name: spanName })
try {
const result = originalMethod.apply(this, args) as ReturnType<T>
if (result instanceof Promise) {
return result
.then((value) => {
endSpan(span, 'ok')
return value
})
.catch((error) => {
setSpanError(span, error as Error)
endSpan(span, 'error')
throw error
}) as ReturnType<T>
}
endSpan(span, 'ok')
return result
} catch (error) {
setSpanError(span, error as Error)
endSpan(span, 'error')
throw error
}
} as T
return descriptor
}
}
/**
* Wrapper para rastrear função
*/
export function traceFunction<T extends (...args: unknown[]) => unknown>(
name: string,
fn: T
): T {
return function (this: unknown, ...args: Parameters<T>): ReturnType<T> {
const span = startSpan({ name })
try {
const result = fn.apply(this, args) as ReturnType<T>
if (result instanceof Promise) {
return result
.then((value) => {
endSpan(span, 'ok')
return value
})
.catch((error) => {
setSpanError(span, error as Error)
endSpan(span, 'error')
throw error
}) as ReturnType<T>
}
endSpan(span, 'ok')
return result
} catch (error) {
setSpanError(span, error as Error)
endSpan(span, 'error')
throw error
}
} as T
}
/**
* Executa operação com span automático
*/
export async function withSpan<T>(
name: string,
operation: (span: Span) => Promise<T>,
attributes?: Record<string, unknown>
): Promise<T> {
const span = startSpan({ name, attributes })
try {
const result = await operation(span)
endSpan(span, 'ok')
return result
} catch (error) {
setSpanError(span, error as Error)
endSpan(span, 'error')
throw error
}
}
/**
* Executa operação síncrona com span automático
*/
export function withSpanSync<T>(
name: string,
operation: (span: Span) => T,
attributes?: Record<string, unknown>
): T {
const span = startSpan({ name, attributes })
try {
const result = operation(span)
endSpan(span, 'ok')
return result
} catch (error) {
setSpanError(span, error as Error)
endSpan(span, 'error')
throw error
}
}
// === Gerenciamento de Baggage ===
/**
* Define item no baggage
*/
export function setBaggage(key: string, value: string | number | boolean): void {
const context = getCurrentContext()
if (context) {
context.baggage[key] = value
}
}
/**
* Obtém item do baggage
*/
export function getBaggage(key: string): string | number | boolean | undefined {
const context = getCurrentContext()
return context?.baggage[key]
}
/**
* Obtém todo o baggage
*/
export function getAllBaggage(): Baggage {
const context = getCurrentContext()
return context?.baggage || {}
}
/**
* Remove item do baggage
*/
export function removeBaggage(key: string): void {
const context = getCurrentContext()
if (context) {
delete context.baggage[key]
}
}
// === Utilitários de Headers ===
/**
* Headers padrão para propagação de trace
*/
export const TRACE_HEADERS = {
TRACE_ID: 'x-trace-id',
SPAN_ID: 'x-span-id',
PARENT_SPAN_ID: 'x-parent-span-id',
CORRELATION_ID: 'x-correlation-id',
BAGGAGE: 'baggage',
} as const
/**
* Injeta contexto em headers HTTP
*/
export function injectTraceHeaders(headers: Record<string, string>): Record<string, string> {
const context = getCurrentContext()
if (!context) {
return headers
}
const result = { ...headers }
result[TRACE_HEADERS.TRACE_ID] = context.traceIds.traceId
result[TRACE_HEADERS.SPAN_ID] = context.traceIds.spanId
result[TRACE_HEADERS.CORRELATION_ID] = context.traceIds.correlationId
if (context.traceIds.parentSpanId) {
result[TRACE_HEADERS.PARENT_SPAN_ID] = context.traceIds.parentSpanId
}
// Baggage como lista de key=value
if (Object.keys(context.baggage).length > 0) {
result[TRACE_HEADERS.BAGGAGE] = Object.entries(context.baggage)
.map(([k, v]) => `${k}=${encodeURIComponent(String(v))}`)
.join(',')
}
return result
}
/**
* Extrai contexto de headers HTTP
*/
export function extractTraceHeaders(headers: Record<string, string | undefined>): CreateContextOptions {
const options: CreateContextOptions = {}
if (headers[TRACE_HEADERS.TRACE_ID]) {
options.traceId = headers[TRACE_HEADERS.TRACE_ID]
}
if (headers[TRACE_HEADERS.PARENT_SPAN_ID]) {
options.parentSpanId = headers[TRACE_HEADERS.PARENT_SPAN_ID]
}
if (headers[TRACE_HEADERS.CORRELATION_ID]) {
options.correlationId = headers[TRACE_HEADERS.CORRELATION_ID]
}
// Parse baggage
if (headers[TRACE_HEADERS.BAGGAGE]) {
options.baggage = {}
const pairs = headers[TRACE_HEADERS.BAGGAGE].split(',')
for (const pair of pairs) {
const [key, value] = pair.split('=')
if (key && value) {
options.baggage[key.trim()] = decodeURIComponent(value.trim())
}
}
}
return options
}
/**
* Exporta trace context para serialização
*/
export function exportContext(context: TraceContext): string {
return JSON.stringify({
traceIds: context.traceIds,
baggage: context.baggage,
metadata: context.metadata,
})
}
/**
* Importa trace context de string serializada
*/
export function importContext(serialized: string): CreateContextOptions {
try {
const data = JSON.parse(serialized)
return {
traceId: data.traceIds?.traceId,
parentSpanId: data.traceIds?.spanId,
correlationId: data.traceIds?.correlationId,
baggage: data.baggage,
metadata: data.metadata,
}
} catch {
return {}
}
}
// === Timer Utilities ===
/**
* Timer de alta precisão
*/
export interface PrecisionTimer {
/** Retorna tempo decorrido em milliseconds */
elapsed(): number
/** Retorna tempo decorrido formatado */
elapsedFormatted(): string
/** Para o timer e retorna duração */
stop(): number
}
/**
* Cria um timer de alta precisão
*/
export function createPrecisionTimer(): PrecisionTimer {
const start = process.hrtime.bigint()
let stopped = false
let finalDuration = 0
return {
elapsed(): number {
if (stopped) return finalDuration
return Number(process.hrtime.bigint() - start) / 1_000_000
},
elapsedFormatted(): string {
const ms = this.elapsed()
if (ms < 1) return `${(ms * 1000).toFixed(2)}µs`
if (ms < 1000) return `${ms.toFixed(2)}ms`
return `${(ms / 1000).toFixed(2)}s`
},
stop(): number {
if (!stopped) {
finalDuration = Number(process.hrtime.bigint() - start) / 1_000_000
stopped = true
}
return finalDuration
},
}
}
export default {
createTraceContext,
getCurrentContext,
getOrCreateContext,
runWithContext,
runWithNewContext,
createSpan,
startSpan,
endSpan,
withSpan,
withSpanSync,
injectTraceHeaders,
extractTraceHeaders,
createPrecisionTimer,
}