claude-flow-novice
Version:
Claude Flow Novice - Advanced orchestration platform for multi-agent AI workflows with CFN Loop architecture Includes Local RuVector Accelerator and all CFN skills for complete functionality.
412 lines (411 loc) • 15.6 kB
JavaScript
/**
* Metrics Logger Service
*
* Unified metrics and execution logging with dual database writes (PostgreSQL + SQLite).
* Part of Task 2.3: Unified Metrics and Execution Logging
*
* Features:
* - Atomic dual writes to PostgreSQL and SQLite
* - Idempotent writes (retries don't create duplicates)
* - Batch logging support
* - Cost tracking ($0.001 precision)
* - Token counting
* - Query interface for metrics analysis
*/ import { v4 as uuidv4 } from 'uuid';
import { createIdempotentKey, hasBeenWritten, markWritten, batchCheckWritten, batchMarkWritten, validateCostAccuracy, roundCost } from '../lib/idempotent-write.js';
import { createLogger } from '../lib/logging.js';
import { StandardError, ErrorCode } from '../lib/errors.js';
const logger = createLogger('metrics-logger');
/**
* Metrics Logger Service
*/ export class MetricsLogger {
dbService;
sqliteAdapter;
postgresAdapter;
batchSize;
flushIntervalMs;
batchQueue = [];
flushTimer = null;
constructor(config){
this.dbService = config.dbService;
this.batchSize = config.batchSize || 100;
this.flushIntervalMs = config.flushIntervalMs || 5000; // 5 seconds
// Get database adapters
this.sqliteAdapter = this.dbService.getAdapter('sqlite');
this.postgresAdapter = this.dbService.getAdapter('postgres');
logger.info('MetricsLogger initialized', {
batch_size: this.batchSize,
flush_interval_ms: this.flushIntervalMs
});
}
/**
* Log single execution metrics
*
* @param metrics - Execution metrics to log
*/ async logExecution(metrics) {
try {
// Validate and normalize metrics
const normalizedMetrics = this.normalizeMetrics(metrics);
// Generate idempotency key
const idempotencyKey = createIdempotentKey(normalizedMetrics);
// Check if already written to both databases
const [sqliteWritten, postgresWritten] = await Promise.all([
hasBeenWritten(idempotencyKey, this.sqliteAdapter),
hasBeenWritten(idempotencyKey, this.postgresAdapter)
]);
if (sqliteWritten && postgresWritten) {
logger.debug('Metrics already logged (idempotent)', {
idempotency_key: idempotencyKey.substring(0, 16) + '...',
metrics_id: normalizedMetrics.id
});
return; // Already written, skip
}
// Atomic write to both databases
await this.dbService.executeTransaction([
{
database: 'sqlite',
operation: async (adapter)=>{
if (!sqliteWritten) {
await adapter.insert('execution_metrics', normalizedMetrics);
await markWritten(idempotencyKey, adapter, normalizedMetrics.id);
}
}
},
{
database: 'postgres',
operation: async (adapter)=>{
if (!postgresWritten) {
await adapter.insert('execution_metrics', normalizedMetrics);
await markWritten(idempotencyKey, adapter, normalizedMetrics.id);
}
}
}
]);
logger.info('Metrics logged successfully', {
metrics_id: normalizedMetrics.id,
agent_id: normalizedMetrics.agent_id,
task_id: normalizedMetrics.task_id,
duration_ms: normalizedMetrics.duration_ms,
cost_usd: normalizedMetrics.cost_usd,
status: normalizedMetrics.status
});
} catch (error) {
logger.error('Failed to log metrics', {
agent_id: metrics.agent_id,
task_id: metrics.task_id,
error: error instanceof Error ? error.message : String(error)
});
throw new StandardError(ErrorCode.DATABASE_ERROR, 'Failed to log execution metrics', {
metrics,
error
});
}
}
/**
* Log batch of execution metrics (atomic)
*
* @param metricsList - Array of execution metrics
*/ async logBatch(metricsList) {
try {
if (metricsList.length === 0) {
logger.debug('Empty batch, skipping');
return;
}
// Normalize all metrics
const normalizedMetrics = metricsList.map((m)=>this.normalizeMetrics(m));
// Deduplicate based on idempotency keys
const uniqueMetrics = await this.deduplicateBatch(normalizedMetrics);
if (uniqueMetrics.length === 0) {
logger.debug('All metrics already logged (idempotent)', {
original_count: metricsList.length
});
return;
}
// Prepare idempotency key entries
const idempotencyEntries = uniqueMetrics.map((m)=>({
key: createIdempotentKey(m),
metricsId: m.id
}));
// Batch write to both databases (atomic)
await this.dbService.executeTransaction([
{
database: 'sqlite',
operation: async (adapter)=>{
await adapter.insertMany('execution_metrics', uniqueMetrics);
await batchMarkWritten(idempotencyEntries, adapter);
}
},
{
database: 'postgres',
operation: async (adapter)=>{
await adapter.insertMany('execution_metrics', uniqueMetrics);
await batchMarkWritten(idempotencyEntries, adapter);
}
}
]);
logger.info('Batch metrics logged successfully', {
total_metrics: metricsList.length,
unique_metrics: uniqueMetrics.length,
duplicates_skipped: metricsList.length - uniqueMetrics.length
});
} catch (error) {
logger.error('Failed to log batch metrics', {
batch_size: metricsList.length,
error: error instanceof Error ? error.message : String(error)
});
throw new StandardError(ErrorCode.DATABASE_ERROR, 'Failed to log batch metrics', {
metricsList,
error
});
}
}
/**
* Query metrics with filters
*
* @param filters - Query filters
* @returns Array of execution metrics
*/ async queryMetrics(filters) {
try {
const queryOptions = {
filters: [],
limit: filters.limit || 100,
offset: filters.offset || 0,
orderBy: 'timestamp',
order: 'desc'
};
// Add filters
if (filters.agent_id) {
queryOptions.filters?.push({
field: 'agent_id',
operator: 'eq',
value: filters.agent_id
});
}
if (filters.skill_id) {
queryOptions.filters?.push({
field: 'skill_id',
operator: 'eq',
value: filters.skill_id
});
}
if (filters.task_id) {
queryOptions.filters?.push({
field: 'task_id',
operator: 'eq',
value: filters.task_id
});
}
if (filters.status) {
queryOptions.filters?.push({
field: 'status',
operator: 'eq',
value: filters.status
});
}
if (filters.start_date && filters.end_date) {
queryOptions.filters?.push({
field: 'timestamp',
operator: 'between',
value: [
filters.start_date,
filters.end_date
]
});
}
// Query from SQLite (primary source for metrics)
const results = await this.sqliteAdapter.list('execution_metrics', queryOptions);
logger.debug('Metrics query executed', {
filters,
result_count: results.length
});
return results;
} catch (error) {
logger.error('Failed to query metrics', {
filters,
error: error instanceof Error ? error.message : String(error)
});
throw new StandardError(ErrorCode.DATABASE_ERROR, 'Failed to query metrics', {
filters,
error
});
}
}
/**
* Get aggregated metrics by agent
*
* @param startDate - Start date for aggregation
* @param endDate - End date for aggregation
* @returns Array of aggregated metrics
*/ async getAggregatedMetrics(startDate, endDate) {
try {
// Query all metrics in date range
const filters = {
start_date: startDate,
end_date: endDate,
limit: 10000
};
const metrics = await this.queryMetrics(filters);
// Aggregate by agent_id
const aggregationMap = new Map();
metrics.forEach((m)=>{
if (!aggregationMap.has(m.agent_id)) {
aggregationMap.set(m.agent_id, {
agent_id: m.agent_id,
total_executions: 0,
total_cost_usd: 0,
total_tokens: 0,
avg_duration_ms: 0,
success_count: 0,
failure_count: 0,
success_rate: 0
});
}
const agg = aggregationMap.get(m.agent_id);
agg.total_executions++;
agg.total_cost_usd += m.cost_usd;
agg.total_tokens += m.tokens_used;
agg.avg_duration_ms += m.duration_ms;
if (m.status === 'success') {
agg.success_count++;
} else {
agg.failure_count++;
}
});
// Calculate averages and success rates
const results = [];
aggregationMap.forEach((agg)=>{
agg.avg_duration_ms = Math.round(agg.avg_duration_ms / agg.total_executions);
agg.success_rate = agg.success_count / agg.total_executions * 100;
agg.total_cost_usd = roundCost(agg.total_cost_usd);
results.push(agg);
});
// Sort by total cost (descending)
results.sort((a, b)=>b.total_cost_usd - a.total_cost_usd);
logger.info('Aggregated metrics calculated', {
agent_count: results.length,
total_executions: results.reduce((sum, agg)=>sum + agg.total_executions, 0)
});
return results;
} catch (error) {
logger.error('Failed to get aggregated metrics', {
error: error instanceof Error ? error.message : String(error)
});
throw new StandardError(ErrorCode.DATABASE_ERROR, 'Failed to get aggregated metrics', {
error
});
}
}
/**
* Add metrics to batch queue
*
* @param metrics - Execution metrics to queue
*/ async queueMetrics(metrics) {
this.batchQueue.push(metrics);
// Flush if batch size reached
if (this.batchQueue.length >= this.batchSize) {
await this.flush();
} else {
// Start flush timer if not already running
if (!this.flushTimer) {
this.flushTimer = setTimeout(()=>this.flush(), this.flushIntervalMs);
}
}
}
/**
* Flush batch queue
*/ async flush() {
if (this.batchQueue.length === 0) {
return;
}
// Clear timer
if (this.flushTimer) {
clearTimeout(this.flushTimer);
this.flushTimer = null;
}
// Copy and clear queue
const batch = [
...this.batchQueue
];
this.batchQueue = [];
// Log batch
await this.logBatch(batch);
}
/**
* Normalize metrics (add ID, validate, round cost)
*
* @param metrics - Raw metrics
* @returns Normalized metrics
*/ normalizeMetrics(metrics) {
// Generate ID if not provided
const id = metrics.id || uuidv4();
// Validate cost accuracy
const cost = roundCost(metrics.cost_usd);
if (!validateCostAccuracy(cost)) {
logger.warn('Cost accuracy validation failed', {
original_cost: metrics.cost_usd,
rounded_cost: cost
});
}
// Ensure timestamp is Date object
const timestamp = metrics.timestamp instanceof Date ? metrics.timestamp : new Date(metrics.timestamp);
return {
...metrics,
id,
timestamp,
cost_usd: cost,
metadata: metrics.metadata ? JSON.stringify(metrics.metadata) : undefined
};
}
/**
* Deduplicate batch based on idempotency keys
*
* @param metricsList - Batch of metrics
* @returns Unique metrics (not already written)
*/ async deduplicateBatch(metricsList) {
// Generate idempotency keys
const keys = metricsList.map((m)=>createIdempotentKey(m));
// Check which keys already exist in SQLite
const sqliteStatus = await batchCheckWritten(keys, this.sqliteAdapter);
// Check which keys already exist in PostgreSQL
const postgresStatus = await batchCheckWritten(keys, this.postgresAdapter);
// Filter out metrics that are already written to BOTH databases
const uniqueMetrics = [];
metricsList.forEach((metrics, index)=>{
const key = keys[index];
const inSQLite = sqliteStatus.get(key) || false;
const inPostgres = postgresStatus.get(key) || false;
if (!inSQLite || !inPostgres) {
// At least one database doesn't have this metric, include it
uniqueMetrics.push(metrics);
}
});
return uniqueMetrics;
}
/**
* Close and cleanup resources
*/ async close() {
// Flush remaining metrics
await this.flush();
// Clear timer
if (this.flushTimer) {
clearTimeout(this.flushTimer);
this.flushTimer = null;
}
logger.info('MetricsLogger closed');
}
}
/**
* Create MetricsLogger instance
*
* @param dbService - Database service
* @param batchSize - Optional batch size
* @param flushIntervalMs - Optional flush interval
* @returns MetricsLogger instance
*/ export function createMetricsLogger(dbService, batchSize, flushIntervalMs) {
return new MetricsLogger({
dbService,
batchSize,
flushIntervalMs
});
}
//# sourceMappingURL=metrics-logger.js.map