/** * WioEX Stream SDK - Error Reporter * * Handles error reporting to WioEX API with batching and rate limiting */ import type { ErrorReportingLevel, ErrorContext, ErrorReportPayload } from './types.js'; import { ErrorQueue, type ErrorQueueConfig } from './utils/ErrorQueue.js'; import { Logger } from './utils/Logger.js'; /** * Config interface for error reporter (flexible for internal use) */ interface ErrorReporterConfig { apiKey?: string; // Optional - for backward compatibility token?: string; // Token-based auth (recommended) errorReportingLevel?: ErrorReportingLevel; errorReportingUrl?: string | undefined; includeMessageData?: boolean | undefined; includeConnectionData?: boolean | undefined; errorQueue?: ErrorQueueConfig | undefined; debug?: boolean; // Enable debug logging baseUrl?: string; // Base URL for API endpoints } /** * Error Reporter for WioEX Stream SDK * * Reports errors to WioEX API for monitoring and debugging * Uses batching and rate limiting for optimal performance */ export class ErrorReporter { private readonly config: ErrorReporterConfig; private readonly errorReportingUrl: string; private readonly sdkVersion: string = '1.5.0'; private readonly errorQueue: ErrorQueue; private apiKeyHash: string | null = null; private readonly logger: Logger; constructor(config: ErrorReporterConfig) { this.config = config; this.errorReportingUrl = config.errorReportingUrl ?? `${config.baseUrl ?? 'https://api.wioex.com'}/sdk/errors`; // Initialize logger with debug mode const debugMode = config.debug ?? this.isDebugMode(); this.logger = new Logger(debugMode, 'WioEX:ErrorReporter'); // Initialize error queue with batching and deduplication this.errorQueue = new ErrorQueue( config.errorQueue ?? {}, this.sendErrorBatch.bind(this), debugMode ); } /** * Report an error to WioEX API (uses queue for batching) */ public async report(error: Error, context: ErrorContext = {}): Promise { const level = this.config.errorReportingLevel ?? 'detailed'; // Skip reporting if level is 'none' if (level === 'none') { return; } try { const payload = this.buildErrorPayload(error, context, level); // Add to queue instead of sending immediately (batched + deduplicated) await this.errorQueue.add(payload); } catch (reportError: unknown) { // Silently fail - don't throw errors from error reporter // This prevents infinite error loops this.logger.warn('Failed to queue error for reporting:', reportError); } } /** * Flush error queue immediately (useful before disconnect) */ public async flush(): Promise { await this.errorQueue.flush(); } /** * Destroy error reporter and cleanup resources */ public destroy(): void { this.errorQueue.destroy(); } /** * Build error report payload */ private buildErrorPayload( error: Error, context: ErrorContext, level: ErrorReportingLevel ): ErrorReportPayload { const payload: ErrorReportPayload = { sdk_version: this.sdkVersion, sdk_type: 'stream', category: 'stream', runtime: this.getRuntime(), api_key_id: this.getApiKeyIdentification(), reporting_level: level, error: { type: error.name !== '' ? error.name : 'Error', message: error.message, }, timestamp: Date.now(), }; // Add stack trace for standard and detailed levels if (level === 'standard' || level === 'detailed') { if (error.stack !== undefined) { payload.error.stack = this.sanitizeStackTrace(error.stack, level); } } // Add error code if available if ('code' in error && typeof error.code === 'string') { payload.error.code = error.code; } // Add context for standard and detailed levels if (level === 'standard' || level === 'detailed') { payload.context = this.sanitizeContext(context, level); } return payload; } /** * Sanitize stack trace based on reporting level */ private sanitizeStackTrace(stack: string, level: ErrorReportingLevel): string { if (level === 'minimal') { // For minimal, only include first line return stack.split('\n')[0] ?? stack; } if (level === 'standard') { // For standard, limit to first 5 lines and remove absolute paths return stack .split('\n') .slice(0, 5) .map((line) => this.sanitizePath(line)) .join('\n'); } // For detailed, include full stack but sanitize paths return stack .split('\n') .map((line) => this.sanitizePath(line)) .join('\n'); } /** * Sanitize file paths in error messages/stack traces */ private sanitizePath(text: string): string { // Remove absolute paths, keep relative paths // Match common path patterns: /absolute/path, C:\absolute\path, file:// return text .replace(/file:\/\/\/[^\s)]+\//g, '') .replace(/[A-Z]:\\[^\s)]+\\/gi, '') .replace(/\/(?:home|Users|var)\/[^\s)]+\//g, ''); } /** * Sanitize context data based on reporting level */ private sanitizeContext(context: ErrorContext, level: ErrorReportingLevel): ErrorContext { const sanitized: ErrorContext = {}; // Connection state is always included if (context.connectionState !== undefined) { sanitized.connectionState = context.connectionState; } // Subscribed stocks are always included if (context.subscribedStocks !== undefined) { sanitized.subscribedStocks = context.subscribedStocks; } // For standard and detailed, include more context if (level === 'standard' || level === 'detailed') { // Include message data if configured if (this.config.includeMessageData ?? level === 'detailed') { if (context.lastMessage !== undefined && typeof context.lastMessage === 'string') { sanitized.lastMessage = this.truncateString(context.lastMessage, 500); } if (context.triggerMessage !== undefined) { sanitized.triggerMessage = this.sanitizeMessageData(context.triggerMessage, level); } } // Include connection details if configured if (this.config.includeConnectionData ?? level === 'detailed') { // Add any additional context keys (but sanitize them) for (const [key, value] of Object.entries(context)) { if ( key !== 'connectionState' && key !== 'subscribedStocks' && key !== 'lastMessage' && key !== 'triggerMessage' ) { sanitized[key] = this.sanitizeValue(value, level); } } } } return sanitized; } /** * Sanitize message data */ private sanitizeMessageData(data: unknown, level: ErrorReportingLevel): unknown { if (data === null || data === undefined) { return data; } if (typeof data === 'string') { return level === 'detailed' ? data : this.truncateString(data, 200); } if (typeof data === 'object') { try { const jsonStr = JSON.stringify(data); return level === 'detailed' ? jsonStr : this.truncateString(jsonStr, 200); } catch { return '[Object]'; } } return String(data); } /** * Sanitize generic value */ private sanitizeValue(value: unknown, level: ErrorReportingLevel): unknown { if (value === null || value === undefined) { return value; } if (typeof value === 'string') { return level === 'detailed' ? value : this.truncateString(value, 100); } if (typeof value === 'number' || typeof value === 'boolean') { return value; } if (Array.isArray(value)) { return level === 'detailed' ? value : (value as unknown[]).slice(0, 5); } if (typeof value === 'object') { return level === 'detailed' ? value : '[Object]'; } return String(value); } /** * Truncate string to maximum length */ private truncateString(str: string, maxLength: number): string { if (str.length <= maxLength) { return str; } return str.substring(0, maxLength) + '...'; } /** * Get API key identification (cached for performance) */ private getApiKeyIdentification(): string { // Return cached hash if available if (this.apiKeyHash !== null) { return this.apiKeyHash; } // Use apiKey if available (backward compatibility) if (this.config.apiKey) { this.apiKeyHash = this.syncHashApiKey(this.config.apiKey); return this.apiKeyHash; } // Use token hash if apiKey not available (token-based auth) if (this.config.token) { this.apiKeyHash = this.syncHashApiKey(this.config.token); return this.apiKeyHash; } // Fallback (should not happen in normal usage) this.apiKeyHash = 'unknown'; return this.apiKeyHash; } /** * Synchronous hash function for API key identification */ private syncHashApiKey(apiKey: string): string { let hash = 0; for (let i = 0; i < apiKey.length; i++) { const char = apiKey.charCodeAt(i); hash = (hash << 5) - hash + char; hash = hash & hash; // Convert to 32-bit integer } // Convert to hex and pad to 16 chars return Math.abs(hash).toString(16).padStart(16, '0').substring(0, 16); } /** * Get runtime environment information */ private getRuntime(): string { if (typeof window !== 'undefined') { // Browser environment const nav = window.navigator; return `Browser/${nav.userAgent.split(' ').pop() ?? 'Unknown'}`; } if (process?.versions?.node !== undefined) { // Node.js environment return `Node.js/${process.versions.node}`; } return 'Unknown'; } /** * Send batch of errors to WioEX API * Note: Sends errors individually since batch endpoint is not yet available */ private async sendErrorBatch(errors: ErrorReportPayload[]): Promise { if (errors.length === 0) { return; } // Send errors individually (batch endpoint not yet available on server) // Use Promise.allSettled to avoid one failure blocking others await Promise.allSettled( errors.map(async (error) => { if (typeof fetch !== 'undefined') { const response = await fetch(this.errorReportingUrl, { method: 'POST', headers: { 'Content-Type': 'application/json', 'User-Agent': `WioEX-Stream-SDK/${this.sdkVersion}`, }, body: JSON.stringify(error), }); if (!response.ok) { throw new Error(`HTTP ${response.status}: ${response.statusText}`); } } else { // Fallback for Node.js < 18: use https module await this.sendWithHttps(error); } }) ); } /** * Send error using Node.js https module */ private async sendWithHttps(payload: ErrorReportPayload): Promise { // Dynamic import for Node.js const https = await import('https'); const { URL } = await import('url'); const url = new URL(this.errorReportingUrl); const data = JSON.stringify(payload); return new Promise((resolve, reject) => { const req = https.request( { hostname: url.hostname, port: url.port !== '' ? url.port : 443, path: url.pathname + url.search, method: 'POST', headers: { 'Content-Type': 'application/json', 'Content-Length': Buffer.byteLength(data), 'User-Agent': `WioEX-Stream-SDK/${this.sdkVersion}`, }, }, (res) => { if (res.statusCode !== undefined && res.statusCode >= 400) { reject(new Error(`HTTP ${res.statusCode}: ${res.statusMessage ?? 'Error'}`)); } else { resolve(); } } ); req.on('error', reject); req.write(data); req.end(); }); } /** * Check if debug mode is enabled */ private isDebugMode(): boolean { if (process?.env?.['DEBUG'] !== undefined) { return process.env['DEBUG'] === 'wioex:*' || process.env['DEBUG'] === 'wioex:errors'; } return false; } }