feat: implement structured logging for sync operations

- Add comprehensive structured logging utility (lib/utils/sync-logger.ts)
  - Log levels: DEBUG, INFO, WARN, ERROR
  - Automatic error categorization (NETWORK_ERROR, AUTH_ERROR, DATABASE_ERROR, etc.)
  - Phase tracking (INITIALIZING, FETCHING, MAPPING, VALIDATING, UPSERTING, DELETING, COMPLETING)
  - Contextual metadata (syncId, entityType, phase, duration, record counts)
  - Timing helpers for operations

- Update sync-service.ts with structured logging
  - Replace console.log/error with structured logger
  - Consistent error categorization across all error paths
  - Fix swallowed errors in sync history updates
  - Add child loggers for entity-specific operations

- Update entity-sync.ts with structured logging
  - Complete phase tracking throughout sync lifecycle
  - Detailed context for warnings and errors
  - Update chunked sync methods with structured logging
  - Update picklist sync methods (issue types, sub-issue types)
  - Better debugging info with timing and sample data

- Add sync failure analysis script (scripts/analyze-sync-failures.ts)
  - Query sync history by date range, entity type, or status
  - Generate failure summaries by entity
  - Automatic error categorization
  - Calculate success rates and statistics
  - Display detailed sync records with timing

- Add comprehensive documentation (docs/SYNC_LOGGING_IMPROVEMENTS.md)
  - Usage guide and examples
  - Migration guide for developers
  - Before/after comparisons

This addresses inconsistent logging and improves debugging capabilities for sync operations.
This commit is contained in:
root 2026-01-23 08:02:02 -05:00
parent df333348f6
commit e25eb3fa5e
5 changed files with 1056 additions and 139 deletions

View file

@ -0,0 +1,360 @@
/**
* Sync Failure Analysis Script
* Query and analyze sync_history for failures and patterns
* Run with: npx tsx scripts/analyze-sync-failures.ts [options]
*/
import { config } from 'dotenv';
import { resolve } from 'path';
import postgresClient from '../lib/services/postgres-client';
// Load environment variables
config({ path: resolve(__dirname, '../.env.local') });
interface SyncHistoryRecord {
id: number;
entity_type: string;
sync_type: string;
status: string;
started_at: Date;
completed_at: Date | null;
records_added: number;
records_updated: number;
records_deleted: number;
error_message: string | null;
triggered_by: string;
}
interface FailureSummary {
entity_type: string;
failure_count: number;
last_failure: Date;
common_errors: string[];
}
/**
* Parse command line arguments
*/
function parseArgs(): {
days: number;
entity?: string;
status?: string;
showAll: boolean;
} {
const args = process.argv.slice(2);
const options = {
days: 7,
entity: undefined as string | undefined,
status: undefined as string | undefined,
showAll: false,
};
for (let i = 0; i < args.length; i++) {
const arg = args[i];
if (arg === '--days' || arg === '-d') {
options.days = parseInt(args[++i]);
} else if (arg === '--entity' || arg === '-e') {
options.entity = args[++i];
} else if (arg === '--status' || arg === '-s') {
options.status = args[++i];
} else if (arg === '--all' || arg === '-a') {
options.showAll = true;
} else if (arg === '--help' || arg === '-h') {
console.log(`
Sync Failure Analysis Script
Usage: npx tsx scripts/analyze-sync-failures.ts [options]
Options:
-d, --days <number> Number of days to look back (default: 7)
-e, --entity <type> Filter by entity type
-s, --status <status> Filter by status (failed, completed, started)
-a, --all Show all records, not just failures
-h, --help Show this help message
Examples:
# Show failures from last 7 days
npx tsx scripts/analyze-sync-failures.ts
# Show all syncs from yesterday
npx tsx scripts/analyze-sync-failures.ts --days 1 --all
# Show ticket sync failures from last 30 days
npx tsx scripts/analyze-sync-failures.ts --days 30 --entity tickets
# Show all completed syncs
npx tsx scripts/analyze-sync-failures.ts --status completed --all
`);
process.exit(0);
}
}
return options;
}
/**
* Query sync history
*/
async function querySyncHistory(
days: number,
entity?: string,
status?: string
): Promise<SyncHistoryRecord[]> {
const params: any[] = [];
let query = `
SELECT
id,
entity_type,
sync_type,
status,
started_at,
completed_at,
records_added,
records_updated,
records_deleted,
error_message,
triggered_by
FROM sync_history
WHERE started_at >= NOW() - INTERVAL '${days} days'
`;
if (entity) {
params.push(entity);
query += ` AND entity_type = $${params.length}`;
}
if (status) {
params.push(status);
query += ` AND status = $${params.length}`;
}
query += ' ORDER BY started_at DESC';
const result = await postgresClient.query<SyncHistoryRecord>(query, params);
return result.rows;
}
/**
* Get failure summary by entity
*/
async function getFailureSummary(days: number): Promise<FailureSummary[]> {
const query = `
SELECT
entity_type,
COUNT(*) as failure_count,
MAX(started_at) as last_failure,
ARRAY_AGG(DISTINCT SUBSTRING(error_message, 1, 100)) as common_errors
FROM sync_history
WHERE status = 'failed'
AND started_at >= NOW() - INTERVAL '${days} days'
GROUP BY entity_type
ORDER BY failure_count DESC, last_failure DESC
`;
const result = await postgresClient.query<any>(query);
return result.rows.map(row => ({
entity_type: row.entity_type,
failure_count: parseInt(row.failure_count),
last_failure: row.last_failure,
common_errors: row.common_errors.filter((e: string | null) => e !== null),
}));
}
/**
* Categorize error message
*/
function categorizeError(errorMessage: string | null): string {
if (!errorMessage) return 'UNKNOWN';
if (errorMessage.includes('NETWORK_ERROR')) return 'NETWORK';
if (errorMessage.includes('AUTH_ERROR')) return 'AUTH';
if (errorMessage.includes('RATE_LIMIT_ERROR')) return 'RATE_LIMIT';
if (errorMessage.includes('DATABASE_CONSTRAINT_ERROR')) return 'DB_CONSTRAINT';
if (errorMessage.includes('DATABASE_ERROR')) return 'DATABASE';
if (errorMessage.includes('API_ERROR')) return 'API';
if (errorMessage.includes('MAPPING_ERROR')) return 'MAPPING';
if (errorMessage.includes('VALIDATION_ERROR')) return 'VALIDATION';
return 'OTHER';
}
/**
* Format duration
*/
function formatDuration(startedAt: Date, completedAt: Date | null): string {
if (!completedAt) return 'N/A';
const duration = new Date(completedAt).getTime() - new Date(startedAt).getTime();
const seconds = Math.floor(duration / 1000);
const minutes = Math.floor(seconds / 60);
if (minutes > 0) {
return `${minutes}m ${seconds % 60}s`;
}
return `${seconds}s`;
}
/**
* Main analysis function
*/
async function analyzeSyncFailures() {
console.log('🔍 Sync Failure Analysis\n');
console.log('═══════════════════════════════════════════════════════════\n');
const options = parseArgs();
try {
// Test database connection
const isConnected = await postgresClient.testConnection();
if (!isConnected) {
console.error('❌ Database connection failed');
process.exit(1);
}
// Get sync history
const records = await querySyncHistory(
options.days,
options.entity,
options.status || (options.showAll ? undefined : 'failed')
);
console.log(`📊 Analysis Period: Last ${options.days} day(s)`);
if (options.entity) {
console.log(`🎯 Entity Filter: ${options.entity}`);
}
if (options.status) {
console.log(`📌 Status Filter: ${options.status}`);
}
console.log(`📝 Total Records: ${records.length}\n`);
if (records.length === 0) {
console.log('✅ No sync records found for the specified criteria.\n');
process.exit(0);
}
// Display failure summary
if (!options.entity && !options.showAll) {
console.log('═══════════════════════════════════════════════════════════');
console.log('📈 FAILURE SUMMARY BY ENTITY');
console.log('═══════════════════════════════════════════════════════════\n');
const summary = await getFailureSummary(options.days);
if (summary.length === 0) {
console.log('✅ No failures found in the specified period!\n');
} else {
summary.forEach(item => {
console.log(`Entity: ${item.entity_type}`);
console.log(` Failures: ${item.failure_count}`);
console.log(` Last Failure: ${new Date(item.last_failure).toLocaleString()}`);
console.log(` Common Errors:`);
item.common_errors.slice(0, 3).forEach(err => {
console.log(` - ${err.substring(0, 80)}...`);
});
console.log();
});
}
}
// Display detailed records
console.log('═══════════════════════════════════════════════════════════');
console.log('📋 DETAILED SYNC RECORDS');
console.log('═══════════════════════════════════════════════════════════\n');
// Group by status
const byStatus = records.reduce((acc, record) => {
if (!acc[record.status]) acc[record.status] = [];
acc[record.status].push(record);
return acc;
}, {} as Record<string, SyncHistoryRecord[]>);
Object.entries(byStatus).forEach(([status, statusRecords]) => {
const statusIcon = status === 'completed' ? '✅' : status === 'failed' ? '❌' : '⏳';
console.log(`${statusIcon} ${status.toUpperCase()} (${statusRecords.length} records)`);
console.log('─────────────────────────────────────────────────────────\n');
statusRecords.forEach(record => {
const duration = formatDuration(record.started_at, record.completed_at);
const errorCategory = categorizeError(record.error_message);
console.log(` ID: ${record.id}`);
console.log(` Entity: ${record.entity_type}`);
console.log(` Type: ${record.sync_type}`);
console.log(` Started: ${new Date(record.started_at).toLocaleString()}`);
console.log(` Duration: ${duration}`);
console.log(` Records: +${record.records_added} ~${record.records_updated} -${record.records_deleted}`);
console.log(` Triggered By: ${record.triggered_by}`);
if (record.error_message) {
console.log(` Error Category: ${errorCategory}`);
console.log(` Error: ${record.error_message}`);
}
console.log();
});
});
// Statistics
console.log('═══════════════════════════════════════════════════════════');
console.log('📊 STATISTICS');
console.log('═══════════════════════════════════════════════════════════\n');
const totalRecordsAdded = records.reduce((sum, r) => sum + r.records_added, 0);
const totalRecordsUpdated = records.reduce((sum, r) => sum + r.records_updated, 0);
const totalRecordsDeleted = records.reduce((sum, r) => sum + r.records_deleted, 0);
const failedCount = records.filter(r => r.status === 'failed').length;
const completedCount = records.filter(r => r.status === 'completed').length;
const successRate = records.length > 0 ? ((completedCount / records.length) * 100).toFixed(1) : '0';
console.log(`Total Syncs: ${records.length}`);
console.log(` Completed: ${completedCount}`);
console.log(` Failed: ${failedCount}`);
console.log(` Success Rate: ${successRate}%`);
console.log();
console.log(`Total Records Processed:`);
console.log(` Added: ${totalRecordsAdded}`);
console.log(` Updated: ${totalRecordsUpdated}`);
console.log(` Deleted: ${totalRecordsDeleted}`);
console.log();
// Error categories
if (failedCount > 0) {
const errorCategories = records
.filter(r => r.status === 'failed')
.reduce((acc, r) => {
const category = categorizeError(r.error_message);
acc[category] = (acc[category] || 0) + 1;
return acc;
}, {} as Record<string, number>);
console.log('Error Categories:');
Object.entries(errorCategories)
.sort(([, a], [, b]) => b - a)
.forEach(([category, count]) => {
console.log(` ${category}: ${count}`);
});
console.log();
}
console.log('═══════════════════════════════════════════════════════════\n');
} catch (error) {
console.error('❌ Analysis failed:', error);
if (error instanceof Error) {
console.error('Stack trace:', error.stack);
}
process.exit(1);
} finally {
await postgresClient.close();
}
}
// Run analysis
analyzeSyncFailures()
.then(() => {
console.log('✅ Analysis complete\n');
process.exit(0);
})
.catch((error) => {
console.error('❌ Fatal error:', error);
process.exit(1);
});