/** * Main DiagnosticsClient — singleton for remote diagnostics collection * * @module client */ import type { DiagnosticsConfig, DiagnosticsSession, ClientState, LogLevel, TraceSpan, LogEntry, Breadcrumb, NetworkRequest, DeviceState, } from './types.js'; import { BreadcrumbTrail } from './breadcrumbs.js'; import { NetworkInterceptor } from './network.js'; import { collectDeviceState } from './device.js'; // DOM type declarations for ESLint type ErrorEvent = { message: string; filename: string; lineno: number; colno: number; error?: { stack?: string }; }; export interface DiagnosticsClientOptions extends DiagnosticsConfig { /** Custom logger */ logger?: { debug: (msg: string, meta?: Record) => void; info: (msg: string, meta?: Record) => void; warn: (msg: string, meta?: Record) => void; error: (msg: string, meta?: Record) => void; }; } /** * Diagnostics client for remote debug session collection */ export class DiagnosticsClient { private static instance: DiagnosticsClient | null = null; private config: DiagnosticsClientOptions & { pollIntervalMs: number; maxBreadcrumbs: number; captureConsole: boolean; captureErrors: boolean; captureNetwork: boolean; networkExcludePatterns: RegExp[]; logger: NonNullable; }; private state: ClientState = { type: 'idle' }; private breadcrumbs: BreadcrumbTrail; private networkInterceptor: NetworkInterceptor | null = null; private pollTimer: ReturnType | null = null; private logBuffer: LogEntry[] = []; private traceBuffer: TraceSpan[] = []; private networkBuffer: NetworkRequest[] = []; private flushTimer: ReturnType | null = null; private lastEtag: string | null = null; private constructor(config: DiagnosticsClientOptions) { this.config = { ...config, pollIntervalMs: config.pollIntervalMs ?? 5000, maxBreadcrumbs: config.maxBreadcrumbs ?? 100, captureConsole: config.captureConsole ?? true, captureErrors: config.captureErrors ?? true, captureNetwork: config.captureNetwork ?? true, networkExcludePatterns: config.networkExcludePatterns ?? [], logger: config.logger ?? { debug: () => {}, info: () => {}, warn: () => {}, error: () => {}, }, }; this.breadcrumbs = new BreadcrumbTrail({ maxSize: this.config.maxBreadcrumbs, }); } /** * Get singleton instance */ static getInstance(config?: DiagnosticsClientOptions): DiagnosticsClient { if (!DiagnosticsClient.instance) { if (!config) { throw new Error('DiagnosticsClient must be initialized with config first'); } DiagnosticsClient.instance = new DiagnosticsClient(config); } return DiagnosticsClient.instance; } /** * Check if client is initialized */ static isInitialized(): boolean { return DiagnosticsClient.instance !== null; } /** * Reset singleton (for testing) */ static reset(): void { DiagnosticsClient.instance?.stop(); DiagnosticsClient.instance = null; } /** * Start polling for active debug sessions */ async start(): Promise { if (this.state.type === 'polling' || this.state.type === 'active') { this.config.logger.warn('[diagnostics] Already started'); return; } this.state = { type: 'polling', session: null }; this.config.logger.info('[diagnostics] Starting diagnostics client'); // Initial poll await this.pollForSession(); // Start polling timer this.pollTimer = setInterval(() => { this.pollForSession().catch(err => { this.config.logger.error('[diagnostics] Poll error', { error: err.message }); }); }, this.config.pollIntervalMs); // Start auto-flush timer (every 30 seconds) this.flushTimer = setInterval(() => { this.flush().catch(err => { this.config.logger.error('[diagnostics] Flush error', { error: err.message }); }); }, 30000); // Setup auto-capture if configured if (this.config.captureNetwork) { this.setupNetworkCapture(); } if (this.config.captureConsole) { this.setupConsoleCapture(); } if (this.config.captureErrors) { this.setupErrorCapture(); } this.breadcrumbs.add('diagnostics', 'Client started'); } /** * Stop polling and cleanup */ stop(): void { this.config.logger.info('[diagnostics] Stopping diagnostics client'); if (this.pollTimer) { clearInterval(this.pollTimer); this.pollTimer = null; } if (this.flushTimer) { clearInterval(this.flushTimer); this.flushTimer = null; } this.networkInterceptor?.stop(); this.networkInterceptor = null; // Final flush this.flush().catch(() => {}); this.state = { type: 'idle' }; this.breadcrumbs.add('diagnostics', 'Client stopped'); } /** * Check if a debug session is currently active */ isSessionActive(): boolean { return this.state.type === 'active'; } /** * Get current session if active */ getCurrentSession(): DiagnosticsSession | null { return this.state.type === 'active' || this.state.type === 'polling' ? (this.state as { session: DiagnosticsSession | null }).session : null; } /** * Get current client state */ getState(): ClientState { return this.state; } /** * Record a log entry */ log(level: LogLevel, message: string, context: Record = {}): void { const entry: LogEntry = { level, message, timestamp: new Date().toISOString(), module: (context.module as string) ?? 'unknown', context, correlationId: context.correlationId as string, }; this.logBuffer.push(entry); this.breadcrumbs.add('log', `[${level.toUpperCase()}] ${message.slice(0, 100)}`, { level }); // Auto-flush on fatal if (level === 'fatal') { this.flush().catch(() => {}); } } /** * Record a trace span (auto-instrumented) */ async trace(name: string, operation: () => Promise): Promise; async trace(name: string, operation: () => T): Promise; async trace(name: string, operation: () => T | Promise): Promise { const span: TraceSpan = { spanId: this.generateId(), name, kind: 'internal', startTime: new Date().toISOString(), attributes: {}, status: 'unset', }; this.breadcrumbs.add('trace', `Starting: ${name}`, { spanId: span.spanId }); try { const result = await operation(); span.endTime = new Date().toISOString(); span.durationMs = new Date(span.endTime).getTime() - new Date(span.startTime).getTime(); span.status = 'ok'; this.traceBuffer.push(span); this.breadcrumbs.add('trace', `Completed: ${name}`, { spanId: span.spanId, durationMs: span.durationMs, }); return result; } catch (error) { span.endTime = new Date().toISOString(); span.durationMs = new Date(span.endTime).getTime() - new Date(span.startTime).getTime(); span.status = 'error'; span.statusMessage = error instanceof Error ? error.message : String(error); this.traceBuffer.push(span); this.breadcrumbs.add('trace', `Failed: ${name}`, { spanId: span.spanId, error: span.statusMessage, }); throw error; } } /** * Add a manual breadcrumb */ breadcrumb(category: string, message: string, data?: Record): void { this.breadcrumbs.add(category, message, data); } /** * Get all breadcrumbs */ getBreadcrumbs(): Breadcrumb[] { return this.breadcrumbs.getAll(); } /** * Collect and return device state */ collectDeviceState(): DeviceState { return collectDeviceState(); } /** * Poll server for active session config */ private async pollForSession(): Promise { try { const url = new URL('/api/diagnostics/config', this.config.serverUrl); url.searchParams.set('productId', this.config.productId); url.searchParams.set('installId', this.config.anonymousInstallId); const headers: Record = { Accept: 'application/json', }; if (this.lastEtag) { headers['If-None-Match'] = this.lastEtag; } const token = await this.getAuthToken(); if (token) { headers['Authorization'] = `Bearer ${token}`; } const response = await fetch(url.toString(), { headers }); if (response.status === 304) { // No change return; } if (!response.ok) { throw new Error(`HTTP ${response.status}`); } // Store ETag for caching const etag = response.headers.get('ETag'); if (etag) { this.lastEtag = etag; } const session: DiagnosticsSession | null = await response.json(); // Update state if (session && session.status === 'active') { if (this.state.type !== 'active') { this.config.logger.info('[diagnostics] Session activated', { sessionId: session.id }); this.breadcrumbs.add('diagnostics', 'Session activated', { sessionId: session.id }); } this.state = { type: 'active', session }; } else { if (this.state.type === 'active') { this.config.logger.info('[diagnostics] Session ended'); this.breadcrumbs.add('diagnostics', 'Session ended'); } this.state = { type: 'polling', session: null }; } } catch (error) { this.config.logger.error('[diagnostics] Failed to poll for session', { error: error instanceof Error ? error.message : String(error), }); this.state = { type: 'error', error: error instanceof Error ? error : new Error(String(error)), }; } } /** * Flush buffered data to server */ private async flush(): Promise { const session = this.getCurrentSession(); if (!session) { // No active session, clear buffers this.logBuffer = []; this.traceBuffer = []; this.networkBuffer = []; return; } const sessionId = session.id; const logs = this.logBuffer.splice(0, 50); // Server max: 50 const traces = this.traceBuffer.splice(0, 50); // Server max: 50 const network = this.networkBuffer.splice(0, 50); const crumbs = this.breadcrumbs.getAll(); this.breadcrumbs.clear(); // Encode breadcrumbs + network captures as log entries so we can ingest // without requiring additional server-side schemas/endpoints. const synthesizedLogs = [] as LogEntry[]; for (const c of crumbs) { synthesizedLogs.push({ level: 'info', message: `[breadcrumb] ${c.category}: ${c.message}`, timestamp: c.timestamp, module: 'diagnostics.breadcrumb', context: c.data ?? {}, }); } for (const n of network) { synthesizedLogs.push({ level: n.error ? 'error' : 'info', message: `[network] ${n.method} ${n.url} ${n.status ?? ''}`.trim(), timestamp: n.startTime, module: 'diagnostics.network', context: { requestHeaders: n.requestHeaders, requestBody: n.requestBody, status: n.status, responseHeaders: n.responseHeaders, responseBody: n.responseBody, startTime: n.startTime, endTime: n.endTime, durationMs: n.durationMs, error: n.error, }, }); } const allLogs = [...logs, ...synthesizedLogs]; if (allLogs.length === 0 && traces.length === 0) { return; } const token = await this.getAuthToken(); const headers: Record = { 'Content-Type': 'application/json', ...(token ? { Authorization: `Bearer ${token}` } : {}), }; try { if (allLogs.length > 0) { const url = new URL( `/api/diagnostics/sessions/${encodeURIComponent(sessionId)}/logs`, this.config.serverUrl ); const response = await fetch(url.toString(), { method: 'POST', headers, body: JSON.stringify({ sessionId, logs: allLogs }), }); if (!response.ok) throw new Error(`HTTP ${response.status}`); } if (traces.length > 0) { const url = new URL( `/api/diagnostics/sessions/${encodeURIComponent(sessionId)}/traces`, this.config.serverUrl ); const response = await fetch(url.toString(), { method: 'POST', headers, body: JSON.stringify({ sessionId, traces }), }); if (!response.ok) throw new Error(`HTTP ${response.status}`); } this.config.logger.debug('[diagnostics] Flushed batch', { logs: allLogs.length, traces: traces.length, }); } catch (error) { this.config.logger.error('[diagnostics] Failed to flush batch', { error: error instanceof Error ? error.message : String(error), }); // Put items back in buffers for retry if (logs.length > 0) this.logBuffer.unshift(...logs); if (traces.length > 0) this.traceBuffer.unshift(...traces); if (network.length > 0) this.networkBuffer.unshift(...network); // Breadcrumbs were converted; keep a small breadcrumb trail hint for later flush. for (const c of crumbs.slice(-10)) { this.breadcrumbs.add(c.category, c.message, c.data); } } } /** * Setup network capture */ private setupNetworkCapture(): void { this.networkInterceptor = new NetworkInterceptor( request => { this.networkBuffer.push(request); }, { excludePatterns: this.config.networkExcludePatterns, } ); this.networkInterceptor.start(); this.breadcrumbs.add('diagnostics', 'Network capture enabled'); } /** * Setup console capture */ private setupConsoleCapture(): void { const originalConsole = { log: console.log.bind(console), info: console.info.bind(console), warn: console.warn.bind(console), error: console.error.bind(console), }; const capture = (level: LogLevel, args: unknown[]) => { const message = args .map(a => (typeof a === 'object' ? JSON.stringify(a) : String(a))) .join(' '); this.log(level, message, { module: 'console', source: 'captured' }); }; console.log = (...args: unknown[]) => { capture('debug', args); originalConsole.log(...args); }; console.info = (...args: unknown[]) => { capture('info', args); originalConsole.info(...args); }; console.warn = (...args: unknown[]) => { capture('warn', args); originalConsole.warn(...args); }; console.error = (...args: unknown[]) => { capture('error', args); originalConsole.error(...args); }; this.breadcrumbs.add('diagnostics', 'Console capture enabled'); } /** * Setup error capture */ private setupErrorCapture(): void { if (typeof window === 'undefined') return; const handler = (event: ErrorEvent) => { this.log('error', event.message, { module: 'window.onerror', source: 'captured', filename: event.filename, lineno: event.lineno, colno: event.colno, error: event.error?.stack, }); this.breadcrumbs.add('error', `Uncaught: ${event.message.slice(0, 100)}`); }; window.addEventListener('error', handler); this.breadcrumbs.add('diagnostics', 'Error capture enabled'); } /** * Get auth token */ private async getAuthToken(): Promise { if (!this.config.getAuthToken) return null; try { const token = await this.config.getAuthToken(); return token; } catch { return null; } } /** * Generate unique ID */ private generateId(): string { return `${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 9)}`; } }