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:
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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'
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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,
|
||||
}
|
||||
Reference in New Issue
Block a user