Files
noteflow/client/src/lib/observability/messages.ts
Travis Vasceannie 100ca5596b
Some checks failed
CI / test-python (push) Failing after 16m26s
CI / test-rust (push) Has been cancelled
CI / test-typescript (push) Has been cancelled
mac
2026-01-24 12:47:35 -05:00

440 lines
17 KiB
TypeScript

/**
* Log message transformation utilities for human-readable display.
*
* Transforms technical log messages into user-friendly descriptions
* while preserving technical details for the expanded view.
*/
/** Function that transforms a log message using details context */
type MessageTransformer = (details: Record<string, string>) => string;
/**
* Message templates for known log patterns.
* Keys are matched against the start of log messages (case-insensitive).
*/
const MESSAGE_TEMPLATES: Record<string, MessageTransformer> = {
// Meeting lifecycle
'meeting stopped': (d) => {
const title = d.title || d.meeting_title;
return title ? `Meeting "${title}" has ended` : 'Meeting has ended';
},
'meeting started': (d) => {
const title = d.title || d.meeting_title;
return title ? `Started recording "${title}"` : 'Started recording';
},
'recording start failed': (d) => {
const reason = d.error || d.reason;
return reason ? `Recording failed to start: ${reason}` : 'Recording failed to start';
},
'created meeting': (d) => {
const title = d.title || d.meeting_title;
return title ? `Created new meeting: ${title}` : 'Created new meeting';
},
'meeting deleted': () => 'Meeting deleted successfully',
stopmeetingrpc: () => 'Meeting recording has been stopped',
// Cloud consent
'cloud consent granted': () => 'Cloud AI features enabled',
'cloud consent revoked': () => 'Cloud AI features disabled',
// Summarization
'summary generated': (d) => {
const model = d.model || d.provider;
return model ? `Summary ready (${model})` : 'Summary generated successfully';
},
summarizing: (d) => {
const count = d.segment_count || d.segments;
return count ? `Generating summary from ${count} segments...` : 'Generating summary...';
},
'summary generation failed': (d) => {
const reason = d.error || d.reason;
return reason ? `Summary generation failed: ${reason}` : 'Summary generation failed';
},
// Transcription
'segment processed': (d) => {
const { text } = d;
if (text && text.length > 50) {
return `Transcribed: "${text.substring(0, 50)}..."`;
}
return text ? `Transcribed: "${text}"` : 'Segment transcribed';
},
'transcript export completed': (d) => {
const fmt = d.format;
return fmt ? `Transcript exported as ${fmt.toUpperCase()}` : 'Transcript exported';
},
'starting transcript export': (d) => {
const fmt = d.format;
return fmt ? `Exporting transcript as ${fmt.toUpperCase()}...` : 'Exporting transcript...';
},
// Diarization
'diarization job': (d) => {
const { status } = d;
if (status === 'completed') {
return 'Speaker identification complete';
}
if (status === 'running' || status === 'processing') {
return 'Identifying speakers...';
}
if (status === 'failed') {
return 'Speaker identification failed';
}
return 'Speaker identification in progress';
},
refinespeak: () => 'Refining speaker labels...',
'speaker renamed': (d) => {
const from = d.old_name || d.from;
const to = d.new_name || d.to;
if (from && to) {
return `Renamed speaker "${from}" to "${to}"`;
}
return 'Speaker renamed';
},
// Triggers
'trigger snooze cleared': () => 'Recording prompt re-enabled',
'trigger detected': (d) => {
const type = d.trigger_type || d.type;
if (type === 'calendar') {
return 'Calendar meeting starting soon';
}
if (type === 'audio') {
return 'Audio activity detected';
}
return 'Recording trigger detected';
},
// Webhooks
webhook_registered: (d) => {
const name = d.name || d.webhook_name;
return name ? `Webhook "${name}" registered` : 'Webhook registered';
},
webhook_delivered: (d) => {
const event = d.event_type;
return event ? `Webhook delivered: ${event}` : 'Webhook delivered successfully';
},
webhook_failed: (d) => {
const reason = d.error;
return reason ? `Webhook delivery failed: ${reason}` : 'Webhook delivery failed';
},
// Calendar
'calendar sync': (d) => {
const { provider } = d;
const count = d.count || d.events_synced;
if (count) {
return `Synced ${count} calendar events${provider ? ` from ${provider}` : ''}`;
}
return provider ? `Calendar synced with ${provider}` : 'Calendar synced';
},
// Entity extraction
extracted: (d) => {
const count = d.count || d.entity_count;
return count ? `Extracted ${count} entities from transcript` : 'Entities extracted';
},
// Recovery
'recovered crashed meeting': () => 'Recovered meeting from unexpected shutdown',
'crash recovery complete': (d) => {
const count = d.meetings_recovered || d.count;
return count ? `Recovery complete: ${count} meeting(s) restored` : 'Recovery complete';
},
// Connection
'connection timeout': () => 'Server connection timed out',
'connection failed': () => 'Failed to connect to server',
connected: () => 'Connected to server',
disconnected: () => 'Disconnected from server',
// Server
'server started': (d) => {
const { port } = d;
return port ? `Server running on port ${port}` : 'Server started';
},
'server stopped': () => 'Server stopped',
// RPC operations (generic)
rpc: (d) => {
const { method, status } = d;
if (method && status === 'OK') {
const simpleName = method.split('/').pop() || method;
return `${simpleName} completed`;
}
return 'Operation completed';
},
// Audio device management
'audio device hydration complete': () => 'Audio device settings loaded',
'audio device prefs synced': () => 'Audio device settings synced',
'hydration: audio_devices synced': () => 'Audio devices restored from saved settings',
'loaddevices: stored preferences': () => 'Loaded saved audio device preferences',
'audio device selected': (d) => {
const deviceType = d.device_type;
const deviceName = d.device_name || 'device';
if (deviceType === 'input') {
return `Selected microphone: ${deviceName}`;
}
if (deviceType === 'output') {
return `Selected speaker: ${deviceName}`;
}
return `Selected audio device: ${deviceName}`;
},
'audio device preferences reset': () => 'Audio device settings reset to defaults',
'skipping output auto-select': () => 'Audio output selection waiting for user choice',
// Preferences and persistence
'preferences persistence recovered': () => 'Settings now saving correctly',
'failed to persist preferences': () => 'Could not save settings to disk',
'preferences may not persist': () => 'Settings may not be saved when app closes',
'hydration failed': () => 'Using browser-cached settings (disk settings unavailable)',
'replacing preferences': () => 'Updating settings',
'server address override': () => 'Custom server address configured',
// API and adapter status
'adapter fallback': () => 'Working in offline mode',
'event bridge initialization failed': () => 'Event system starting with limited features',
'tauri stream send_audio_chunk failed': () => 'Audio streaming interrupted',
'tauri stream stop_recording failed': () => 'Could not stop recording cleanly',
'stream state force-reset': () => 'Recording state reset',
'preflight connect failed': () => 'Connection check failed before recording',
'stream state check failed': () => 'Recording check failed - proceeding with protection',
// Reconnection
'server info fetch failed': () => 'Server version check skipped',
'integration revalidation failed': () => 'Reconnected - some integrations may need refresh',
'reconnection callback execution failed': () => 'Reconnection partially completed',
'state sync after reconnect failed': () => 'Sync incomplete after reconnecting',
'scheduled reconnect attempt failed': (d) => {
const { attempt } = d;
return attempt ? `Reconnection attempt ${attempt} failed` : 'Reconnection attempt failed';
},
'online event reconnect failed': () => 'Could not reconnect when network came online',
'initial reconnect attempt failed': () => 'Initial reconnection failed',
// Security and encryption
'decryption failed': () => 'Could not decrypt saved data - may need to re-enter API keys',
'failed to retrieve secure value': () => 'Could not load secure setting',
'secure storage migration failed': () => 'Secure storage needs recovery - some settings may be lost',
'secure storage migrated': () => 'Secure storage upgraded successfully',
'secure storage key mismatch': () => 'Secure storage needs recovery',
'failed to decrypt api keys': () => 'Could not load saved API keys - please re-enter them',
'failed to decrypt ai config': () => 'Could not load AI configuration - please re-enter API keys',
'secure storage write failed': () => 'Could not save secure setting',
// Storage operations
'storage write failed': () => 'Could not save setting',
'storage remove failed': () => 'Could not clear setting',
'storage clear by prefix': () => 'Could not clear settings group',
'storage save failed': () => 'Settings saved to memory only (disk save failed)',
'failed to clear local preferences': () => 'Could not clear local settings cache',
// Data loading
'failed to load meeting details': () => 'Could not load meeting details',
'failed to load recent meetings': () => 'Could not load recent meetings',
'failed to fetch meeting list': () => 'Could not load meetings list',
'failed to load tasks': () => 'Could not load tasks from meetings',
// Notifications and reminders
'notification permission request failed': () => 'Could not enable notifications',
'notification creation failed': () => 'Desktop notification failed - showing in-app message',
// Integration secrets
'secret retrieval failed': () => 'Could not load integration credentials',
'secret storage failed': () => 'Could not save integration credentials',
'secret clearing failed': () => 'Could not remove integration credentials',
// Cache operations
'cache event listener error': () => 'Cache update error',
'meeting cache write failed': () => 'Could not cache meeting data',
// Sync operations
'failed to persist preferences sync': () => 'Could not save settings to disk',
'failed to decode preference key': () => 'Settings key format error',
'validation event listener error': () => 'Settings validation error',
// Export
'tauri path apis unavailable': () => 'Using default download location',
// App policy
'recording blocked': () => 'Recording paused - blocked by app policy',
// Additional backend messages
'meeting_created': () => 'New meeting has been created in the database',
'meeting_updated': () => 'Meeting information has been updated',
'meeting_deleted': () => 'Meeting has been removed from the database',
'preference_created': () => 'New user preference has been saved',
'preference_updated': () => 'User preference has been updated',
'preference_deleted': () => 'User preference has been removed',
'webhook_delivery_failed': () => 'Webhook notification delivery failed',
'calendar_list_events_failed': () => 'Unable to retrieve calendar events',
'oauth_disconnect_success': () => 'Successfully disconnected from external service',
'oauth_disconnect_failed': () => 'Failed to disconnect from external service',
'diarization_offline_model_loaded': () => 'Speaker identification model has been loaded',
'streaming_turns_cleared': () => 'Speaker turn data has been cleared',
'asr_config_requested_but_no_service': () => 'Audio configuration requested but service is unavailable',
// Database and infrastructure
'running database migrations': () => 'Updating database...',
'project root discovered': () => 'Found project configuration',
'alembic version table exists': () => 'Checking database for updates',
'schema check': (d) => {
const tableCount = d['table count'];
const versionExists = d['alembic version exists'];
if (tableCount && versionExists !== undefined) {
return `Database ready (${tableCount} tables)`;
}
return 'Database schema check complete';
},
'database connection pool ready': () => 'Database connected',
'alembic version table exists, checking if migrations needed': () => 'Checking database for updates',
'running migrations': () => 'Updating database...',
'applying migrations': () => 'Applying database changes...',
'migration complete': () => 'Database updated successfully',
'no migrations needed': () => 'Database is up to date',
'database initialization complete': () => 'Database setup complete',
'connection established': () => 'Connected to database',
'connection closed': () => 'Database connection closed',
'session started': () => 'Session started',
'session closed': () => 'Session ended',
'server initializing': () => 'Starting up...',
'server ready': () => 'Server is ready',
'server shutting down': () => 'Server is shutting down',
};
const MESSAGE_ENTRIES = Object.entries(MESSAGE_TEMPLATES).map(([key, transformer]) => ({
key,
keyLower: key.toLowerCase(),
transformer,
}));
/**
* Transform a technical log message into a friendly, human-readable version.
*
* @param message - The original technical log message
* @param details - Key-value details from the log entry
* @returns A human-readable message, or the original if no transformation applies
*/
export function toFriendlyMessage(message: string, details: Record<string, string>): string {
const lowerMessage = message.toLowerCase();
// Find matching template by checking if message starts with any known key
for (const entry of MESSAGE_ENTRIES) {
if (lowerMessage.startsWith(entry.keyLower)) {
return entry.transformer(details);
}
}
// No match found, return original message with basic cleanup
return cleanupTechnicalMessage(message);
}
/**
* Clean up a technical message for display when no template matches.
* Applies formatting improvements to make messages more human-readable.
*/
function cleanupTechnicalMessage(message: string): string {
let cleaned = message;
// Remove [Namespace] prefixes from debug() function output
// e.g., "[Preferences:Replace] Replacing preferences" → "Replacing preferences"
cleaned = cleaned.replace(/^\[[^\]]+\]\s*/, '');
// Remove Namespace: prefixes
// e.g., "Preferences:Replace: message" → "message"
cleaned = cleaned.replace(/^[A-Za-z]+:[A-Za-z]+:\s*/, '');
// Remove common technical prefixes
cleaned = cleaned
.replace(/^RPC\s+/i, '')
.replace(/^gRPC\s+/i, '')
.replace(/^API\s+/i, '')
.replace(/^Tauri\s+/i, '')
.replace(/^TauriAdapter\./i, '');
// Convert method-style prefixes to readable text
// e.g., "getPreferences: received" → "Preferences received"
cleaned = cleaned.replace(
/^(get|set|load|save|fetch|create|delete|update)([A-Z][a-zA-Z]*):\s*/i,
(_: string, _verb: string, noun: string) => {
const readableNoun = noun.replace(/([A-Z])/g, ' $1').trim();
return `${readableNoun} `;
}
);
// Convert "ENTRY" and similar debug markers to more readable text
cleaned = cleaned.replace(/:\s*ENTRY$/i, ' started');
cleaned = cleaned.replace(/:\s*EXIT$/i, ' completed');
// Remove file extension references
// e.g., "use-audio-devices.ts MODULE LOADED" → "Audio devices module loaded"
cleaned = cleaned.replace(/[a-z-]+\.tsx?\s+(MODULE\s+)?LOADED/i, 'Module initialized');
// Convert snake_case segments to readable text
// Only for isolated snake_case words, not in the middle of sentences
cleaned = cleaned.replace(/\b([a-z]+)_([a-z]+)(?:_([a-z]+))?\b/gi, (_match: string, p1: string, p2: string, p3: string | undefined) => {
const parts = [p1, p2, p3].filter((p): p is string => !!p);
return parts.map((p: string) => p.charAt(0).toUpperCase() + p.slice(1).toLowerCase()).join(' ');
});
// Clean up double spaces
cleaned = cleaned.replace(/\s+/g, ' ').trim();
// Capitalize first letter if it's lowercase
if (cleaned.length > 0 && cleaned[0] === cleaned[0].toLowerCase()) {
cleaned = cleaned[0].toUpperCase() + cleaned.slice(1);
}
return cleaned;
}
/**
* Check if a message is likely technical and would benefit from the technical view.
*
* @param message - The log message to check
* @returns True if the message appears technical
*/
export function isTechnicalMessage(message: string): boolean {
const technicalPatterns = [
/^[A-Z][a-z]+[A-Z]/, // CamelCase
/^[a-z]+_[a-z]+/, // snake_case
/\b(?:id|uuid|rpc|grpc|api|http|tcp|udp)\b/i,
/\b[0-9a-f]{8,}/i, // Long hex strings (UUIDs, hashes)
/:\s*\d+\s*$/, // Ends with port number
/\b(?:status|code|err|error)=\w+/i, // Key=value pairs
];
return technicalPatterns.some((pattern) => pattern.test(message));
}
/**
* Get a summary description for a group of similar log messages.
*
* @param message - The common message pattern
* @param count - Number of occurrences
* @param details - Details from the first occurrence
* @returns A summary string describing the group
*/
export function getSummaryMessage(
message: string,
count: number,
details: Record<string, string>
): string {
const lowerMessage = message.toLowerCase();
// Segment processing is common and should be summarized specially
if (lowerMessage.includes('segment')) {
return `Processed ${count} transcript segments`;
}
// Webhook deliveries
if (lowerMessage.includes('webhook')) {
return `${count} webhook deliveries`;
}
// Generic summary
const friendlyMessage = toFriendlyMessage(message, details);
return `${friendlyMessage} (${count}x)`;
}