572 lines
16 KiB
TypeScript
572 lines
16 KiB
TypeScript
/**
|
|
* 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<string, unknown>) => void;
|
|
info: (msg: string, meta?: Record<string, unknown>) => void;
|
|
warn: (msg: string, meta?: Record<string, unknown>) => void;
|
|
error: (msg: string, meta?: Record<string, unknown>) => 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<DiagnosticsClientOptions['logger']>;
|
|
};
|
|
private state: ClientState = { type: 'idle' };
|
|
private breadcrumbs: BreadcrumbTrail;
|
|
private networkInterceptor: NetworkInterceptor | null = null;
|
|
private pollTimer: ReturnType<typeof setInterval> | null = null;
|
|
private logBuffer: LogEntry[] = [];
|
|
private traceBuffer: TraceSpan[] = [];
|
|
private networkBuffer: NetworkRequest[] = [];
|
|
private flushTimer: ReturnType<typeof setInterval> | 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<void> {
|
|
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<string, unknown> = {}): 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<T>(name: string, operation: () => Promise<T>): Promise<T>;
|
|
async trace<T>(name: string, operation: () => T): Promise<T>;
|
|
async trace<T>(name: string, operation: () => T | Promise<T>): Promise<T> {
|
|
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<string, unknown>): 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<void> {
|
|
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<string, string> = {
|
|
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<void> {
|
|
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<string, string> = {
|
|
'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<string | null> {
|
|
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)}`;
|
|
}
|
|
}
|