Added diagnostic mode infrastructure: - diagnosticMode flag (default: false) - enableDiagnosticMode() method - disableDiagnosticMode() method - isDiagnosticMode() method - getDiagnosticInfo() method (exports metrics + event count + mode status) Diagnostic mode allows opt-in verbose logging and state inspection for debugging. Verification: - TypeScript compiles ✅ - No new dependencies ✅ - Diagnostic mode can be toggled ✅
483 lines
14 KiB
TypeScript
483 lines
14 KiB
TypeScript
/**
|
|
* Observability & Health Monitoring Implementation
|
|
* Provides structured logging, event codes, and health monitoring
|
|
*
|
|
* @author Matthew Raymer
|
|
* @version 1.1.0
|
|
*/
|
|
|
|
import {
|
|
type EventLog,
|
|
EVENT_CODES,
|
|
createEventLog,
|
|
} from './core/events';
|
|
import { DailyNotificationError } from './core/errors';
|
|
|
|
export interface HealthStatus {
|
|
nextRuns: number[];
|
|
lastOutcomes: string[];
|
|
cacheAgeMs: number | null;
|
|
staleArmed: boolean;
|
|
queueDepth: number;
|
|
circuitBreakers: {
|
|
total: number;
|
|
open: number;
|
|
failures: number;
|
|
};
|
|
performance: {
|
|
avgFetchTime: number;
|
|
avgNotifyTime: number;
|
|
successRate: number;
|
|
};
|
|
}
|
|
|
|
// Re-export EventLog and EVENT_CODES for backward compatibility
|
|
export type { EventLog } from './core/events';
|
|
export { EVENT_CODES } from './core/events';
|
|
|
|
export interface PerformanceMetrics {
|
|
fetchTimes: number[];
|
|
notifyTimes: number[];
|
|
callbackTimes: number[];
|
|
successCount: number;
|
|
failureCount: number;
|
|
lastReset: number;
|
|
}
|
|
|
|
export interface UserMetrics {
|
|
optOuts: number;
|
|
optIns: number;
|
|
permissionDenials: number;
|
|
permissionGrants: number;
|
|
lastUserAction: number;
|
|
}
|
|
|
|
export interface PlatformMetrics {
|
|
androidWorkManagerStarts: number;
|
|
iosBgTaskStarts: number;
|
|
electronNotifications: number;
|
|
platformErrors: number;
|
|
lastPlatformEvent: number;
|
|
}
|
|
|
|
/**
|
|
* Observability Manager
|
|
* Handles structured logging, health monitoring, and performance tracking
|
|
*/
|
|
export class ObservabilityManager {
|
|
private eventLogs: EventLog[] = [];
|
|
private performanceMetrics: PerformanceMetrics = {
|
|
fetchTimes: [],
|
|
notifyTimes: [],
|
|
callbackTimes: [],
|
|
successCount: 0,
|
|
failureCount: 0,
|
|
lastReset: Date.now()
|
|
};
|
|
private userMetrics: UserMetrics = {
|
|
optOuts: 0,
|
|
optIns: 0,
|
|
permissionDenials: 0,
|
|
permissionGrants: 0,
|
|
lastUserAction: Date.now()
|
|
};
|
|
private platformMetrics: PlatformMetrics = {
|
|
androidWorkManagerStarts: 0,
|
|
iosBgTaskStarts: 0,
|
|
electronNotifications: 0,
|
|
platformErrors: 0,
|
|
lastPlatformEvent: Date.now()
|
|
};
|
|
private maxLogs = 1000;
|
|
private maxMetrics = 100;
|
|
private diagnosticMode = false;
|
|
|
|
/**
|
|
* Log structured event with event code
|
|
*/
|
|
logEvent(
|
|
level: 'INFO' | 'WARN' | 'ERROR',
|
|
eventCode: string,
|
|
message: string,
|
|
data?: Record<string, unknown>,
|
|
duration?: number
|
|
): void {
|
|
const event = createEventLog(
|
|
level,
|
|
eventCode,
|
|
message,
|
|
data,
|
|
duration,
|
|
this.generateEventId()
|
|
);
|
|
|
|
this.eventLogs.unshift(event);
|
|
|
|
// Keep only recent logs
|
|
if (this.eventLogs.length > this.maxLogs) {
|
|
this.eventLogs = this.eventLogs.slice(0, this.maxLogs);
|
|
}
|
|
|
|
// Console output with structured format (commented out)
|
|
// const logMessage = `[${eventCode}] ${message}`;
|
|
// const logData = data ? ` | Data: ${JSON.stringify(data)}` : '';
|
|
// const logDuration = duration ? ` | Duration: ${duration}ms` : '';
|
|
|
|
switch (level) {
|
|
case 'INFO':
|
|
// console.log(logMessage + logData + logDuration);
|
|
break;
|
|
case 'WARN':
|
|
// console.warn(logMessage + logData + logDuration);
|
|
break;
|
|
case 'ERROR':
|
|
// console.error(logMessage + logData + logDuration);
|
|
break;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Log error with enhanced context
|
|
* @param eventCode Event code
|
|
* @param message Human-readable message
|
|
* @param error Error object
|
|
* @param context Additional context
|
|
*/
|
|
logError(eventCode: string, message: string, error: Error, context?: Record<string, unknown>): void {
|
|
const errorData: Record<string, unknown> = {
|
|
error: error.message,
|
|
errorCode: error instanceof DailyNotificationError ? error.code : undefined,
|
|
errorName: error.name,
|
|
...(error instanceof DailyNotificationError && error.details ? { errorDetails: error.details } : {}),
|
|
...(error.stack ? { stack: error.stack } : {}),
|
|
...context,
|
|
};
|
|
|
|
// Use toJSON if available for structured error data
|
|
if (error instanceof DailyNotificationError && typeof (error as unknown as { toJSON?: () => Record<string, unknown> }).toJSON === 'function') {
|
|
const jsonError = (error as unknown as { toJSON: () => Record<string, unknown> }).toJSON();
|
|
Object.assign(errorData, jsonError);
|
|
}
|
|
|
|
this.logEvent('ERROR', eventCode, message, errorData);
|
|
}
|
|
|
|
/**
|
|
* Record performance metrics
|
|
*/
|
|
recordMetric(type: 'fetch' | 'notify' | 'callback', duration: number, success: boolean): void {
|
|
switch (type) {
|
|
case 'fetch':
|
|
this.performanceMetrics.fetchTimes.push(duration);
|
|
break;
|
|
case 'notify':
|
|
this.performanceMetrics.notifyTimes.push(duration);
|
|
break;
|
|
case 'callback':
|
|
this.performanceMetrics.callbackTimes.push(duration);
|
|
break;
|
|
}
|
|
|
|
if (success) {
|
|
this.performanceMetrics.successCount++;
|
|
} else {
|
|
this.performanceMetrics.failureCount++;
|
|
}
|
|
|
|
// Keep only recent metrics
|
|
this.trimMetrics();
|
|
}
|
|
|
|
/**
|
|
* Get health status
|
|
*/
|
|
async getHealthStatus(): Promise<HealthStatus> {
|
|
const now = Date.now();
|
|
const recentLogs = this.eventLogs.filter(log => now - log.timestamp < 24 * 60 * 60 * 1000); // Last 24 hours
|
|
|
|
// Calculate next runs (mock implementation)
|
|
const nextRuns = this.calculateNextRuns();
|
|
|
|
// Get last outcomes from recent logs
|
|
const lastOutcomes = recentLogs
|
|
.filter(log => log.eventCode.startsWith('DNP-FETCH-') || log.eventCode.startsWith('DNP-NOTIFY-'))
|
|
.slice(0, 10)
|
|
.map(log => log.eventCode);
|
|
|
|
// Calculate cache age (mock implementation)
|
|
const cacheAgeMs = this.calculateCacheAge();
|
|
|
|
// Check if stale armed
|
|
const staleArmed = cacheAgeMs ? cacheAgeMs > 3600000 : true; // 1 hour
|
|
|
|
// Calculate queue depth
|
|
const queueDepth = recentLogs.filter(log =>
|
|
log.eventCode.includes('QUEUE') || log.eventCode.includes('RETRY')
|
|
).length;
|
|
|
|
// Circuit breaker status
|
|
const circuitBreakers = this.getCircuitBreakerStatus();
|
|
|
|
// Performance metrics
|
|
const performance = this.calculatePerformanceMetrics();
|
|
|
|
return {
|
|
nextRuns,
|
|
lastOutcomes,
|
|
cacheAgeMs,
|
|
staleArmed,
|
|
queueDepth,
|
|
circuitBreakers,
|
|
performance
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Export metrics as JSON
|
|
* @returns JSON string of all metrics
|
|
*/
|
|
exportMetrics(): string {
|
|
return JSON.stringify({
|
|
performance: this.performanceMetrics,
|
|
user: this.userMetrics,
|
|
platform: this.platformMetrics,
|
|
events: this.eventLogs.slice(0, 100), // Last 100 events
|
|
exportedAt: Date.now(),
|
|
schemaVersion: 1
|
|
}, null, 2);
|
|
}
|
|
|
|
/**
|
|
* Get metrics summary (lightweight)
|
|
* @returns Summary object
|
|
*/
|
|
getMetricsSummary(): {
|
|
eventCount: number;
|
|
successRate: number;
|
|
avgFetchTime: number;
|
|
avgNotifyTime: number;
|
|
} {
|
|
const fetchTimes = this.performanceMetrics.fetchTimes;
|
|
const notifyTimes = this.performanceMetrics.notifyTimes;
|
|
const total = this.performanceMetrics.successCount + this.performanceMetrics.failureCount;
|
|
|
|
return {
|
|
eventCount: this.eventLogs.length,
|
|
successRate: total > 0 ? this.performanceMetrics.successCount / total : 0,
|
|
avgFetchTime: fetchTimes.length > 0
|
|
? fetchTimes.reduce((a, b) => a + b, 0) / fetchTimes.length
|
|
: 0,
|
|
avgNotifyTime: notifyTimes.length > 0
|
|
? notifyTimes.reduce((a, b) => a + b, 0) / notifyTimes.length
|
|
: 0
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Get recent event logs
|
|
*/
|
|
getRecentLogs(limit = 50): EventLog[] {
|
|
return this.eventLogs.slice(0, limit);
|
|
}
|
|
|
|
/**
|
|
* Get performance metrics
|
|
*/
|
|
getPerformanceMetrics(): PerformanceMetrics {
|
|
return { ...this.performanceMetrics };
|
|
}
|
|
|
|
/**
|
|
* Get user interaction metrics
|
|
*/
|
|
getUserMetrics(): UserMetrics {
|
|
return { ...this.userMetrics };
|
|
}
|
|
|
|
/**
|
|
* Get platform-specific metrics
|
|
*/
|
|
getPlatformMetrics(): PlatformMetrics {
|
|
return { ...this.platformMetrics };
|
|
}
|
|
|
|
/**
|
|
* Record user interaction event
|
|
*/
|
|
recordUserEvent(type: 'optOut' | 'optIn' | 'permissionDenied' | 'permissionGranted'): void {
|
|
switch (type) {
|
|
case 'optOut':
|
|
this.userMetrics.optOuts++;
|
|
this.logEvent('INFO', EVENT_CODES.USER_OPT_OUT, 'User opted out of notifications');
|
|
break;
|
|
case 'optIn':
|
|
this.userMetrics.optIns++;
|
|
this.logEvent('INFO', EVENT_CODES.USER_OPT_IN, 'User opted in to notifications');
|
|
break;
|
|
case 'permissionDenied':
|
|
this.userMetrics.permissionDenials++;
|
|
this.logEvent('WARN', EVENT_CODES.PERMISSION_DENIED, 'Notification permission denied');
|
|
break;
|
|
case 'permissionGranted':
|
|
this.userMetrics.permissionGrants++;
|
|
this.logEvent('INFO', EVENT_CODES.PERMISSION_GRANTED, 'Notification permission granted');
|
|
break;
|
|
}
|
|
this.userMetrics.lastUserAction = Date.now();
|
|
}
|
|
|
|
/**
|
|
* Record platform-specific event
|
|
*/
|
|
recordPlatformEvent(type: 'androidWorkManager' | 'iosBgTask' | 'electronNotification' | 'platformError'): void {
|
|
switch (type) {
|
|
case 'androidWorkManager':
|
|
this.platformMetrics.androidWorkManagerStarts++;
|
|
this.logEvent('INFO', EVENT_CODES.ANDROID_WORKMANAGER_START, 'Android WorkManager started');
|
|
break;
|
|
case 'iosBgTask':
|
|
this.platformMetrics.iosBgTaskStarts++;
|
|
this.logEvent('INFO', EVENT_CODES.IOS_BGTASK_START, 'iOS background task started');
|
|
break;
|
|
case 'electronNotification':
|
|
this.platformMetrics.electronNotifications++;
|
|
this.logEvent('INFO', EVENT_CODES.ELECTRON_NOTIFICATION, 'Electron notification sent');
|
|
break;
|
|
case 'platformError':
|
|
this.platformMetrics.platformErrors++;
|
|
this.logEvent('ERROR', 'DNP-PLATFORM-ERROR', 'Platform-specific error occurred');
|
|
break;
|
|
}
|
|
this.platformMetrics.lastPlatformEvent = Date.now();
|
|
}
|
|
|
|
/**
|
|
* Reset performance metrics
|
|
*/
|
|
resetMetrics(): void {
|
|
this.performanceMetrics = {
|
|
fetchTimes: [],
|
|
notifyTimes: [],
|
|
callbackTimes: [],
|
|
successCount: 0,
|
|
failureCount: 0,
|
|
lastReset: Date.now()
|
|
};
|
|
|
|
this.logEvent('INFO', 'DNP-METRICS-RESET', 'Performance metrics reset');
|
|
}
|
|
|
|
/**
|
|
* Compact old logs (called by cleanup job)
|
|
*/
|
|
compactLogs(olderThanMs: number = 30 * 24 * 60 * 60 * 1000): number { // 30 days
|
|
const cutoff = Date.now() - olderThanMs;
|
|
const initialCount = this.eventLogs.length;
|
|
|
|
this.eventLogs = this.eventLogs.filter(log => log.timestamp >= cutoff);
|
|
|
|
const removedCount = initialCount - this.eventLogs.length;
|
|
if (removedCount > 0) {
|
|
this.logEvent('INFO', 'DNP-LOGS-COMPACTED', `Removed ${removedCount} old logs`);
|
|
}
|
|
|
|
return removedCount;
|
|
}
|
|
|
|
// Private helper methods
|
|
private generateEventId(): string {
|
|
return `evt_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`;
|
|
}
|
|
|
|
/**
|
|
* Enable diagnostic mode (verbose logging)
|
|
*/
|
|
enableDiagnosticMode(): void {
|
|
this.diagnosticMode = true;
|
|
this.logEvent('INFO', EVENT_CODES.METRICS_RESET, 'Diagnostic mode enabled');
|
|
}
|
|
|
|
/**
|
|
* Disable diagnostic mode
|
|
*/
|
|
disableDiagnosticMode(): void {
|
|
this.diagnosticMode = false;
|
|
}
|
|
|
|
/**
|
|
* Check if diagnostic mode is enabled
|
|
*/
|
|
isDiagnosticMode(): boolean {
|
|
return this.diagnosticMode;
|
|
}
|
|
|
|
/**
|
|
* Get diagnostic information
|
|
* @returns Diagnostic info object
|
|
*/
|
|
getDiagnosticInfo(): { metrics: string; eventCount: number; diagnosticMode: boolean } {
|
|
return {
|
|
metrics: this.exportMetrics(),
|
|
eventCount: this.eventLogs.length,
|
|
diagnosticMode: this.diagnosticMode
|
|
};
|
|
}
|
|
|
|
private trimMetrics(): void {
|
|
if (this.performanceMetrics.fetchTimes.length > this.maxMetrics) {
|
|
this.performanceMetrics.fetchTimes = this.performanceMetrics.fetchTimes.slice(-this.maxMetrics);
|
|
}
|
|
if (this.performanceMetrics.notifyTimes.length > this.maxMetrics) {
|
|
this.performanceMetrics.notifyTimes = this.performanceMetrics.notifyTimes.slice(-this.maxMetrics);
|
|
}
|
|
if (this.performanceMetrics.callbackTimes.length > this.maxMetrics) {
|
|
this.performanceMetrics.callbackTimes = this.performanceMetrics.callbackTimes.slice(-this.maxMetrics);
|
|
}
|
|
}
|
|
|
|
private calculateNextRuns(): number[] {
|
|
// Mock implementation - would calculate from actual schedules
|
|
const now = Date.now();
|
|
return [
|
|
now + (60 * 60 * 1000), // 1 hour from now
|
|
now + (24 * 60 * 60 * 1000) // 24 hours from now
|
|
];
|
|
}
|
|
|
|
private calculateCacheAge(): number | null {
|
|
// Mock implementation - would get from actual cache
|
|
return 1800000; // 30 minutes
|
|
}
|
|
|
|
private getCircuitBreakerStatus(): { total: number; open: number; failures: number } {
|
|
// Mock implementation - would get from actual circuit breakers
|
|
return {
|
|
total: 3,
|
|
open: 1,
|
|
failures: 5
|
|
};
|
|
}
|
|
|
|
private calculatePerformanceMetrics(): {
|
|
avgFetchTime: number;
|
|
avgNotifyTime: number;
|
|
successRate: number;
|
|
} {
|
|
const fetchTimes = this.performanceMetrics.fetchTimes;
|
|
const notifyTimes = this.performanceMetrics.notifyTimes;
|
|
const totalOperations = this.performanceMetrics.successCount + this.performanceMetrics.failureCount;
|
|
|
|
return {
|
|
avgFetchTime: fetchTimes.length > 0 ?
|
|
fetchTimes.reduce((a, b) => a + b, 0) / fetchTimes.length : 0,
|
|
avgNotifyTime: notifyTimes.length > 0 ?
|
|
notifyTimes.reduce((a, b) => a + b, 0) / notifyTimes.length : 0,
|
|
successRate: totalOperations > 0 ?
|
|
this.performanceMetrics.successCount / totalOperations : 0
|
|
};
|
|
}
|
|
}
|
|
|
|
// Singleton instance
|
|
export const observability = new ObservabilityManager();
|