/** * Debugging Utilities for CDC Subscriptions * * Provides tools for debugging and monitoring CDC subscriptions: * - Subscription inspector for state inspection * - Event log with circular buffer * - Performance profiler * - Diagnostic snapshots */ import type { CDCChangeEvent, Row } from '../types' import { HealthCheckResult } from './health-monitor' import { CircuitBreakerStats } from './circuit-breaker' import { SubscriptionState } from './state-machine' import type { LogEntry } from './logger' /** * Event log entry */ export interface EventLogEntry { /** Event ID */ id: string /** Event timestamp */ timestamp: Date /** Event operation */ operation: string /** Table name */ table: string /** Schema name */ schema: string /** LSN */ lsn: string /** Processing duration in ms */ processingDurationMs?: number | undefined /** Whether event was handled successfully */ handled: boolean /** Error if handling failed */ error?: string | undefined /** Handler count */ handlerCount?: number | undefined } /** * Subscription diagnostic snapshot */ export interface SubscriptionSnapshot { /** Snapshot timestamp */ timestamp: Date /** Subscription ID */ subscriptionId: string /** Table name */ table: string /** Schema name */ schema: string /** Current state */ state: SubscriptionState /** Time in current state (ms) */ timeInStateMs: number /** Health status */ health?: HealthCheckResult /** Circuit breaker stats */ circuitBreaker?: CircuitBreakerStats /** Memory usage estimate (bytes) */ memoryEstimate: number /** Recent events count */ recentEventsCount: number /** Last LSN processed */ lastLsn?: string /** Handler counts */ handlers: { change: number insert: number update: number delete: number error: number } /** Recent state transitions */ recentTransitions: Array<{ from: string to: string event: string timestamp: string }> /** Recent event log */ recentEvents: EventLogEntry[] /** Recent logs */ recentLogs: LogEntry[] } /** * Performance metrics */ export interface PerformanceMetrics { /** Events per second */ eventsPerSecond: number /** Average processing time (ms) */ avgProcessingTimeMs: number /** P95 processing time (ms) */ p95ProcessingTimeMs: number /** P99 processing time (ms) */ p99ProcessingTimeMs: number /** Handler execution times by type */ handlerTimes: Record /** Memory usage trend */ memoryTrend: Array<{ timestamp: number; bytes: number }> /** Event rate trend */ eventRateTrend: Array<{ timestamp: number; rate: number }> } /** * Circular buffer for efficient event log */ class CircularBuffer { private buffer: (T | undefined)[] = [] private head = 0 private size = 0 constructor(private readonly capacity: number) { this.buffer = new Array(capacity).fill(undefined) } push(item: T): void { this.buffer[this.head] = item this.head = (this.head + 1) % this.capacity if (this.size < this.capacity) { this.size++ } } toArray(): T[] { const result: T[] = [] if (this.size === 0) return result if (this.size < this.capacity) { // Buffer not full yet, items are at the beginning for (let i = 0; i < this.size; i++) { const item = this.buffer[i] if (item !== undefined) { result.push(item) } } } else { // Buffer is full, items wrap around for (let i = 0; i < this.capacity; i++) { const index = (this.head + i) % this.capacity const item = this.buffer[index] if (item !== undefined) { result.push(item) } } } return result } getLatest(n: number): T[] { const all = this.toArray() return all.slice(-n) } clear(): void { this.buffer = new Array(this.capacity).fill(undefined) this.head = 0 this.size = 0 } get length(): number { return this.size } } /** * Event Log - Circular buffer for event history */ export class EventLog { private buffer: CircularBuffer constructor(maxSize: number = 1000) { this.buffer = new CircularBuffer(maxSize) } /** * Log an event */ log(entry: Omit): void { this.buffer.push({ ...entry, timestamp: new Date(), }) } /** * Log a CDC change event */ logEvent( event: CDCChangeEvent, options: { processingDurationMs?: number handled?: boolean error?: string handlerCount?: number } = {} ): void { this.log({ id: event.id, operation: event.operation, table: event.table, schema: event.schema, lsn: event.lsn, processingDurationMs: options.processingDurationMs, handled: options.handled ?? true, error: options.error, handlerCount: options.handlerCount, }) } /** * Get all logged events */ getAll(): EventLogEntry[] { return this.buffer.toArray() } /** * Get recent events */ getRecent(n: number): EventLogEntry[] { return this.buffer.getLatest(n) } /** * Filter events by criteria */ filter( predicate: (entry: EventLogEntry) => boolean ): EventLogEntry[] { return this.buffer.toArray().filter(predicate) } /** * Get events by operation */ getByOperation(operation: string): EventLogEntry[] { return this.filter((e) => e.operation === operation) } /** * Get events by table */ getByTable(table: string, schema?: string): EventLogEntry[] { return this.filter( (e) => e.table === table && (!schema || e.schema === schema) ) } /** * Get failed events */ getFailed(): EventLogEntry[] { return this.filter((e) => !e.handled) } /** * Clear the log */ clear(): void { this.buffer.clear() } /** * Get statistics */ getStats(): { total: number byOperation: Record byTable: Record failed: number avgProcessingTime: number } { const entries = this.buffer.toArray() const byOperation: Record = {} const byTable: Record = {} let failed = 0 let totalProcessingTime = 0 let processedCount = 0 for (const entry of entries) { byOperation[entry.operation] = (byOperation[entry.operation] ?? 0) + 1 byTable[entry.table] = (byTable[entry.table] ?? 0) + 1 if (!entry.handled) { failed++ } if (entry.processingDurationMs !== undefined) { totalProcessingTime += entry.processingDurationMs processedCount++ } } return { total: entries.length, byOperation, byTable, failed, avgProcessingTime: processedCount > 0 ? Math.round(totalProcessingTime / processedCount) : 0, } } } /** * Performance Profiler */ export class PerformanceProfiler { private processingTimes: CircularBuffer private handlerTimes = new Map() private memorySnapshots: CircularBuffer<{ timestamp: number; bytes: number }> private eventRateSnapshots: CircularBuffer<{ timestamp: number; rate: number }> private eventCount = 0 private lastRateCheck = Date.now() private rateWindowEvents = 0 constructor(sampleSize: number = 1000, snapshotCount: number = 60) { this.processingTimes = new CircularBuffer(sampleSize) this.memorySnapshots = new CircularBuffer(snapshotCount) this.eventRateSnapshots = new CircularBuffer(snapshotCount) } /** * Record event processing time */ recordProcessingTime(durationMs: number): void { this.processingTimes.push(durationMs) this.eventCount++ this.rateWindowEvents++ } /** * Record handler execution time */ recordHandlerTime(handlerType: string, durationMs: number): void { const current = this.handlerTimes.get(handlerType) ?? { total: 0, count: 0 } current.total += durationMs current.count++ this.handlerTimes.set(handlerType, current) } /** * Take a memory snapshot */ recordMemorySnapshot(bytes: number): void { this.memorySnapshots.push({ timestamp: Date.now(), bytes }) } /** * Update event rate calculation */ updateEventRate(): void { const now = Date.now() const elapsed = (now - this.lastRateCheck) / 1000 if (elapsed >= 1) { const rate = this.rateWindowEvents / elapsed this.eventRateSnapshots.push({ timestamp: now, rate }) this.rateWindowEvents = 0 this.lastRateCheck = now } } /** * Get performance metrics */ getMetrics(): PerformanceMetrics { const times = this.processingTimes.toArray().sort((a, b) => a - b) const memoryTrend = this.memorySnapshots.toArray() const eventRateTrend = this.eventRateSnapshots.toArray() // Calculate percentiles const p95Index = Math.floor(times.length * 0.95) const p99Index = Math.floor(times.length * 0.99) // Calculate handler times const handlerTimes: Record = {} for (const [type, data] of this.handlerTimes) { handlerTimes[type] = { avg: Math.round(data.total / data.count), count: data.count, } } // Calculate events per second from recent snapshots const recentRate = eventRateTrend.slice(-5) const eventsPerSecond = recentRate.length > 0 ? Math.round(recentRate.reduce((sum, s) => sum + s.rate, 0) / recentRate.length * 100) / 100 : 0 return { eventsPerSecond, avgProcessingTimeMs: times.length > 0 ? Math.round(times.reduce((a, b) => a + b, 0) / times.length) : 0, p95ProcessingTimeMs: times[p95Index] ?? 0, p99ProcessingTimeMs: times[p99Index] ?? 0, handlerTimes, memoryTrend, eventRateTrend, } } /** * Reset profiler */ reset(): void { this.processingTimes.clear() this.handlerTimes.clear() this.memorySnapshots.clear() this.eventRateSnapshots.clear() this.eventCount = 0 this.rateWindowEvents = 0 this.lastRateCheck = Date.now() } } /** * Subscription Inspector - Aggregates debugging info */ export class SubscriptionInspector<_T extends Row = Row> { readonly eventLog: EventLog readonly profiler: PerformanceProfiler private handlers = { change: 0, insert: 0, update: 0, delete: 0, error: 0, } constructor( public readonly subscriptionId: string, options: { maxEventLogSize?: number sampleSize?: number snapshotCount?: number } = {} ) { this.eventLog = new EventLog(options.maxEventLogSize ?? 1000) this.profiler = new PerformanceProfiler( options.sampleSize ?? 1000, options.snapshotCount ?? 60 ) } /** * Record a handler registration */ recordHandler(type: keyof typeof this.handlers): void { this.handlers[type]++ } /** * Record a handler removal */ removeHandler(type: keyof typeof this.handlers): void { this.handlers[type] = Math.max(0, this.handlers[type] - 1) } /** * Get handler counts */ getHandlerCounts(): typeof this.handlers { return { ...this.handlers } } /** * Create a diagnostic snapshot */ createSnapshot(context: { table: string schema: string state: SubscriptionState timeInStateMs: number health?: HealthCheckResult circuitBreaker?: CircuitBreakerStats memoryEstimate: number lastLsn?: string recentTransitions: Array<{ from: string to: string event: string timestamp: string }> recentLogs: LogEntry[] }): SubscriptionSnapshot { const snapshot: SubscriptionSnapshot = { timestamp: new Date(), subscriptionId: this.subscriptionId, table: context.table, schema: context.schema, state: context.state, timeInStateMs: context.timeInStateMs, memoryEstimate: context.memoryEstimate, recentEventsCount: this.eventLog.getAll().length, handlers: this.getHandlerCounts(), recentTransitions: context.recentTransitions, recentEvents: this.eventLog.getRecent(20), recentLogs: context.recentLogs, } if (context.health !== undefined) { snapshot.health = context.health } if (context.circuitBreaker !== undefined) { snapshot.circuitBreaker = context.circuitBreaker } if (context.lastLsn !== undefined) { snapshot.lastLsn = context.lastLsn } return snapshot } /** * Get summary for logging */ getSummary(): string { const stats = this.eventLog.getStats() const metrics = this.profiler.getMetrics() return [ `Subscription: ${this.subscriptionId}`, `Events: ${stats.total} total, ${stats.failed} failed`, `Performance: ${metrics.eventsPerSecond}/s, avg ${metrics.avgProcessingTimeMs}ms`, `Handlers: ${Object.entries(this.handlers).map(([k, v]) => `${k}:${v}`).join(', ')}`, ].join('\n') } /** * Reset inspector */ reset(): void { this.eventLog.clear() this.profiler.reset() } } /** * Create a subscription inspector */ export function createInspector<_T extends Row = Row>( subscriptionId: string, options?: ConstructorParameters[1] ): SubscriptionInspector<_T> { return new SubscriptionInspector<_T>(subscriptionId, options) } /** * Format a snapshot for console output */ export function formatSnapshot(snapshot: SubscriptionSnapshot): string { const lines = [ '='.repeat(60), `CDC Subscription Diagnostic Snapshot`, `Timestamp: ${snapshot.timestamp.toISOString()}`, '='.repeat(60), '', `Subscription ID: ${snapshot.subscriptionId}`, `Table: ${snapshot.schema}.${snapshot.table}`, `State: ${snapshot.state} (${Math.round(snapshot.timeInStateMs / 1000)}s)`, `Memory: ${Math.round(snapshot.memoryEstimate / 1024)}KB`, `Last LSN: ${snapshot.lastLsn ?? 'N/A'}`, '', 'Handlers:', ...Object.entries(snapshot.handlers).map(([k, v]) => ` ${k}: ${v}`), '', ] if (snapshot.health) { lines.push( 'Health:', ` Status: ${snapshot.health.status}`, ` Score: ${snapshot.health.score}`, ` Latency: ${snapshot.health.latency.average}ms avg, ${snapshot.health.latency.p95}ms p95`, ` Events/s: ${snapshot.health.throughput.eventsPerSecond}`, '' ) } if (snapshot.circuitBreaker) { lines.push( 'Circuit Breaker:', ` State: ${snapshot.circuitBreaker.state}`, ` Failures: ${snapshot.circuitBreaker.failureCount}`, ` Open Count: ${snapshot.circuitBreaker.openCount}`, '' ) } if (snapshot.recentTransitions.length > 0) { lines.push( 'Recent Transitions:', ...snapshot.recentTransitions.map( (t) => ` ${t.from} -> ${t.to} (${t.event}) at ${t.timestamp}` ), '' ) } if (snapshot.recentEvents.length > 0) { lines.push( 'Recent Events:', ...snapshot.recentEvents.slice(-5).map( (e) => ` ${e.operation} on ${e.table} (${e.lsn}) - ${e.handled ? 'OK' : 'FAILED'}` ), '' ) } lines.push('='.repeat(60)) return lines.join('\n') }