Skip to main content
Glama
waldzellai

Exa Websets MCP Server

by waldzellai
WebhookAttemptTracker.ts16 kB
/** * Webhook Attempt Tracker Implementation * * Tracks webhook delivery attempts, maintains delivery history, and provides * analytics and reporting capabilities for webhook performance monitoring. */ import { EventEmitter } from 'events'; import { WebhookDeliveryAttempt, WebhookDeliveryResult } from './WebhookSender.js'; import { WebsetEvent } from '../types/websets.js'; /** * Webhook attempt tracking configuration */ export interface WebhookAttemptTrackerConfig { /** Maximum number of attempts to keep in memory */ maxAttemptsInMemory: number; /** Maximum age of attempts to keep in memory (milliseconds) */ maxAttemptAge: number; /** Whether to enable detailed logging */ enableDetailedLogging: boolean; /** Cleanup interval in milliseconds */ cleanupInterval: number; /** Whether to track response bodies */ trackResponseBodies: boolean; /** Maximum response body size to track */ maxResponseBodySize: number; } /** * Webhook delivery statistics */ export interface WebhookDeliveryStats { /** Total attempts made */ totalAttempts: number; /** Successful attempts */ successfulAttempts: number; /** Failed attempts */ failedAttempts: number; /** Success rate (0-1) */ successRate: number; /** Average response time in milliseconds */ averageResponseTime: number; /** Median response time in milliseconds */ medianResponseTime: number; /** 95th percentile response time in milliseconds */ p95ResponseTime: number; /** Most common error types */ commonErrors: Array<{ error: string; count: number }>; /** Status code distribution */ statusCodeDistribution: Record<number, number>; /** Attempts by webhook ID */ attemptsByWebhook: Record<string, number>; } /** * Webhook attempt filter criteria */ export interface WebhookAttemptFilter { /** Filter by webhook ID */ webhookId?: string; /** Filter by event type */ eventType?: string; /** Filter by success status */ success?: boolean; /** Filter by status code */ statusCode?: number; /** Filter by date range */ dateRange?: { start: Date; end: Date; }; /** Filter by attempt number */ attemptNumber?: number; /** Filter by error pattern */ errorPattern?: RegExp; } /** * Tracked webhook attempt with additional metadata */ export interface TrackedWebhookAttempt extends WebhookDeliveryAttempt { /** Event type for easier filtering */ eventType: string; /** Whether this was the final attempt */ finalAttempt: boolean; /** Total delivery result (if final attempt) */ deliveryResult?: WebhookDeliveryResult; } /** * Default webhook attempt tracker configuration */ const DEFAULT_WEBHOOK_ATTEMPT_TRACKER_CONFIG: WebhookAttemptTrackerConfig = { maxAttemptsInMemory: 10000, maxAttemptAge: 24 * 60 * 60 * 1000, // 24 hours enableDetailedLogging: false, cleanupInterval: 60 * 60 * 1000, // 1 hour trackResponseBodies: true, maxResponseBodySize: 1024, // 1KB }; /** * Webhook attempt tracker for monitoring delivery performance */ export class WebhookAttemptTracker extends EventEmitter { private readonly config: WebhookAttemptTrackerConfig; private readonly attempts = new Map<string, TrackedWebhookAttempt>(); private readonly attemptsByWebhook = new Map<string, Set<string>>(); private readonly attemptsByEvent = new Map<string, Set<string>>(); private cleanupInterval?: NodeJS.Timeout; constructor(config: Partial<WebhookAttemptTrackerConfig> = {}) { super(); this.config = { ...DEFAULT_WEBHOOK_ATTEMPT_TRACKER_CONFIG, ...config }; this.startCleanup(); } /** * Track a webhook delivery attempt * @param attempt The delivery attempt to track * @param event The associated event * @param finalAttempt Whether this was the final attempt * @param deliveryResult The complete delivery result (if final attempt) */ trackAttempt( attempt: WebhookDeliveryAttempt, event: WebsetEvent, finalAttempt: boolean = false, deliveryResult?: WebhookDeliveryResult ): void { // Truncate response body if too large let responseBody = attempt.responseBody; if (responseBody && responseBody.length > this.config.maxResponseBodySize) { responseBody = responseBody.substring(0, this.config.maxResponseBodySize) + '...'; } const trackedAttempt: TrackedWebhookAttempt = { ...attempt, responseBody: this.config.trackResponseBodies ? responseBody : undefined, eventType: event.type, finalAttempt, deliveryResult, }; // Store attempt this.attempts.set(attempt.id, trackedAttempt); // Index by webhook ID if (!this.attemptsByWebhook.has(attempt.webhookId)) { this.attemptsByWebhook.set(attempt.webhookId, new Set()); } this.attemptsByWebhook.get(attempt.webhookId)!.add(attempt.id); // Index by event type if (!this.attemptsByEvent.has(event.type)) { this.attemptsByEvent.set(event.type, new Set()); } this.attemptsByEvent.get(event.type)!.add(attempt.id); // Emit tracking event this.emit('attemptTracked', trackedAttempt); // Log if enabled if (this.config.enableDetailedLogging) { console.log(`Tracked webhook attempt: ${attempt.id}`, { webhookId: attempt.webhookId, eventType: event.type, success: attempt.success, statusCode: attempt.statusCode, duration: attempt.duration, finalAttempt, }); } // Cleanup if memory limit exceeded if (this.attempts.size > this.config.maxAttemptsInMemory) { this.cleanupOldAttempts(); } } /** * Get attempts by filter criteria * @param filter Filter criteria * @returns Array of matching attempts */ getAttempts(filter: WebhookAttemptFilter = {}): TrackedWebhookAttempt[] { const results: TrackedWebhookAttempt[] = []; for (const attempt of this.attempts.values()) { if (this.matchesFilter(attempt, filter)) { results.push(attempt); } } // Sort by attempt time (newest first) return results.sort((a, b) => b.attemptedAt.getTime() - a.attemptedAt.getTime()); } /** * Get attempts for a specific webhook * @param webhookId The webhook ID * @param limit Maximum number of attempts to return * @returns Array of attempts for the webhook */ getWebhookAttempts(webhookId: string, limit: number = 100): TrackedWebhookAttempt[] { const attemptIds = this.attemptsByWebhook.get(webhookId); if (!attemptIds) { return []; } const attempts: TrackedWebhookAttempt[] = []; for (const attemptId of attemptIds) { const attempt = this.attempts.get(attemptId); if (attempt) { attempts.push(attempt); } } // Sort by attempt time (newest first) and limit return attempts .sort((a, b) => b.attemptedAt.getTime() - a.attemptedAt.getTime()) .slice(0, limit); } /** * Get attempts for a specific event type * @param eventType The event type * @param limit Maximum number of attempts to return * @returns Array of attempts for the event type */ getEventTypeAttempts(eventType: string, limit: number = 100): TrackedWebhookAttempt[] { const attemptIds = this.attemptsByEvent.get(eventType); if (!attemptIds) { return []; } const attempts: TrackedWebhookAttempt[] = []; for (const attemptId of attemptIds) { const attempt = this.attempts.get(attemptId); if (attempt) { attempts.push(attempt); } } // Sort by attempt time (newest first) and limit return attempts .sort((a, b) => b.attemptedAt.getTime() - a.attemptedAt.getTime()) .slice(0, limit); } /** * Get delivery statistics * @param filter Optional filter criteria * @returns Delivery statistics */ getStats(filter: WebhookAttemptFilter = {}): WebhookDeliveryStats { const attempts = this.getAttempts(filter); if (attempts.length === 0) { return { totalAttempts: 0, successfulAttempts: 0, failedAttempts: 0, successRate: 0, averageResponseTime: 0, medianResponseTime: 0, p95ResponseTime: 0, commonErrors: [], statusCodeDistribution: {}, attemptsByWebhook: {}, }; } const successfulAttempts = attempts.filter(a => a.success).length; const failedAttempts = attempts.length - successfulAttempts; const responseTimes = attempts.map(a => a.duration).sort((a, b) => a - b); // Calculate percentiles const medianIndex = Math.floor(responseTimes.length / 2); const p95Index = Math.floor(responseTimes.length * 0.95); // Count errors const errorCounts = new Map<string, number>(); const statusCodeCounts = new Map<number, number>(); const webhookCounts = new Map<string, number>(); for (const attempt of attempts) { // Count errors if (attempt.error) { const count = errorCounts.get(attempt.error) || 0; errorCounts.set(attempt.error, count + 1); } // Count status codes if (attempt.statusCode) { const count = statusCodeCounts.get(attempt.statusCode) || 0; statusCodeCounts.set(attempt.statusCode, count + 1); } // Count by webhook const count = webhookCounts.get(attempt.webhookId) || 0; webhookCounts.set(attempt.webhookId, count + 1); } // Convert to arrays and objects const commonErrors = Array.from(errorCounts.entries()) .map(([error, count]) => ({ error, count })) .sort((a, b) => b.count - a.count) .slice(0, 10); // Top 10 errors const statusCodeDistribution: Record<number, number> = {}; for (const [code, count] of statusCodeCounts) { statusCodeDistribution[code] = count; } const attemptsByWebhook: Record<string, number> = {}; for (const [webhookId, count] of webhookCounts) { attemptsByWebhook[webhookId] = count; } return { totalAttempts: attempts.length, successfulAttempts, failedAttempts, successRate: attempts.length > 0 ? successfulAttempts / attempts.length : 0, averageResponseTime: responseTimes.reduce((sum, time) => sum + time, 0) / responseTimes.length, medianResponseTime: responseTimes[medianIndex] || 0, p95ResponseTime: responseTimes[p95Index] || 0, commonErrors, statusCodeDistribution, attemptsByWebhook, }; } /** * Get recent failed attempts * @param limit Maximum number of attempts to return * @param hours Number of hours to look back * @returns Array of recent failed attempts */ getRecentFailedAttempts(limit: number = 50, hours: number = 24): TrackedWebhookAttempt[] { const cutoffTime = new Date(Date.now() - hours * 60 * 60 * 1000); return this.getAttempts({ success: false, dateRange: { start: cutoffTime, end: new Date(), }, }).slice(0, limit); } /** * Get webhook health status * @param webhookId The webhook ID * @param hours Number of hours to analyze * @returns Health status information */ getWebhookHealth(webhookId: string, hours: number = 24): { healthy: boolean; successRate: number; totalAttempts: number; recentErrors: string[]; averageResponseTime: number; } { const cutoffTime = new Date(Date.now() - hours * 60 * 60 * 1000); const attempts = this.getAttempts({ webhookId, dateRange: { start: cutoffTime, end: new Date(), }, }); if (attempts.length === 0) { return { healthy: true, successRate: 0, totalAttempts: 0, recentErrors: [], averageResponseTime: 0, }; } const successfulAttempts = attempts.filter(a => a.success).length; const successRate = successfulAttempts / attempts.length; const recentErrors = attempts .filter(a => !a.success && a.error) .map(a => a.error!) .slice(0, 5); // Last 5 errors const averageResponseTime = attempts.reduce((sum, a) => sum + a.duration, 0) / attempts.length; return { healthy: successRate >= 0.95, // Consider healthy if 95%+ success rate successRate, totalAttempts: attempts.length, recentErrors, averageResponseTime, }; } /** * Check if an attempt matches filter criteria * @param attempt The attempt to check * @param filter The filter criteria * @returns True if attempt matches filter */ private matchesFilter(attempt: TrackedWebhookAttempt, filter: WebhookAttemptFilter): boolean { if (filter.webhookId && attempt.webhookId !== filter.webhookId) { return false; } if (filter.eventType && attempt.eventType !== filter.eventType) { return false; } if (filter.success !== undefined && attempt.success !== filter.success) { return false; } if (filter.statusCode && attempt.statusCode !== filter.statusCode) { return false; } if (filter.attemptNumber && attempt.attemptNumber !== filter.attemptNumber) { return false; } if (filter.dateRange) { const attemptTime = attempt.attemptedAt.getTime(); if (attemptTime < filter.dateRange.start.getTime() || attemptTime > filter.dateRange.end.getTime()) { return false; } } if (filter.errorPattern && attempt.error && !filter.errorPattern.test(attempt.error)) { return false; } return true; } /** * Start cleanup interval */ private startCleanup(): void { this.cleanupInterval = setInterval(() => { this.cleanupOldAttempts(); }, this.config.cleanupInterval); } /** * Cleanup old attempts from memory */ private cleanupOldAttempts(): void { const cutoffTime = new Date(Date.now() - this.config.maxAttemptAge); const toRemove: string[] = []; for (const [attemptId, attempt] of this.attempts) { if (attempt.attemptedAt < cutoffTime) { toRemove.push(attemptId); } } // Remove old attempts for (const attemptId of toRemove) { const attempt = this.attempts.get(attemptId); if (attempt) { this.attempts.delete(attemptId); // Remove from indexes const webhookAttempts = this.attemptsByWebhook.get(attempt.webhookId); if (webhookAttempts) { webhookAttempts.delete(attemptId); if (webhookAttempts.size === 0) { this.attemptsByWebhook.delete(attempt.webhookId); } } const eventAttempts = this.attemptsByEvent.get(attempt.eventType); if (eventAttempts) { eventAttempts.delete(attemptId); if (eventAttempts.size === 0) { this.attemptsByEvent.delete(attempt.eventType); } } } } if (toRemove.length > 0) { this.emit('attemptsCleanedUp', toRemove.length); if (this.config.enableDetailedLogging) { console.log(`Cleaned up ${toRemove.length} old webhook attempts`); } } } /** * Clear all tracked attempts */ clear(): void { const count = this.attempts.size; this.attempts.clear(); this.attemptsByWebhook.clear(); this.attemptsByEvent.clear(); this.emit('attemptsCleared', count); } /** * Get current memory usage * @returns Memory usage information */ getMemoryUsage(): { totalAttempts: number; webhooksTracked: number; eventTypesTracked: number; memoryLimitReached: boolean; } { return { totalAttempts: this.attempts.size, webhooksTracked: this.attemptsByWebhook.size, eventTypesTracked: this.attemptsByEvent.size, memoryLimitReached: this.attempts.size >= this.config.maxAttemptsInMemory, }; } /** * Shutdown the tracker */ shutdown(): void { if (this.cleanupInterval) { clearInterval(this.cleanupInterval); this.cleanupInterval = undefined; } this.emit('shutdown'); } }

Latest Blog Posts

MCP directory API

We provide all the information about MCP servers via our MCP API.

curl -X GET 'https://glama.ai/api/mcp/v1/servers/waldzellai/exa-mcp-server-websets'

If you have feedback or need assistance with the MCP directory API, please join our Discord server