learning_ai_common_plat/packages/diagnostics-client/src/client.ts

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)}`;
}
}