/** * Worker Analytics Tests */ import { describe, it, expect, beforeEach } from 'vitest'; import { WorkerAnalytics } from './workerAnalytics.js'; import { MetricAccumulator, INSTRUMENT_NAMES, resolveInstrumentName } from './workerAnalytics.js'; import { LogEvent } from './types.js'; import { CostTracker } from './tui/utils/costTracking.js'; describe('WorkerAnalytics', () => { let analytics: WorkerAnalytics; let costTracker: CostTracker; const baseTime = Date.now(); beforeEach(() => { costTracker = new CostTracker(); analytics = new WorkerAnalytics(costTracker, 3600000); // 1 hour snapshots }); describe('Basic Event Processing', () => { it('should process events and track worker', () => { const event: LogEvent = { ts: baseTime, worker: 'w-test-1', level: 'info', msg: 'Starting work', }; analytics.processEvent(event); const metrics = analytics.getWorkerMetrics('w-test-1'); expect(metrics).toBeDefined(); expect(metrics?.workerId).toBe('w-test-1'); expect(metrics?.totalEvents).toBe(1); }); it('should track multiple workers', () => { const events: LogEvent[] = [ { ts: baseTime, worker: 'w-1', level: 'info', msg: 'Event 1' }, { ts: baseTime + 1000, worker: 'w-2', level: 'info', msg: 'Event 2' }, { ts: baseTime + 2000, worker: 'w-1', level: 'info', msg: 'Event 3' }, ]; events.forEach(e => analytics.processEvent(e)); const allMetrics = analytics.getAllWorkerMetrics(); expect(allMetrics).toHaveLength(2); const w1Metrics = analytics.getWorkerMetrics('w-1'); const w2Metrics = analytics.getWorkerMetrics('w-2'); expect(w1Metrics?.totalEvents).toBe(2); expect(w2Metrics?.totalEvents).toBe(1); }); }); describe('Bead Completion Tracking', () => { it('should track bead completions', () => { const events: LogEvent[] = [ { ts: baseTime, worker: 'w-1', level: 'info', msg: 'Starting bead bd-123', bead: 'bd-123', }, { ts: baseTime + 5000, worker: 'w-1', level: 'info', msg: 'Bead completed successfully', bead: 'bd-123', }, { ts: baseTime + 10000, worker: 'w-1', level: 'info', msg: 'Working on bd-456', bead: 'bd-456', }, { ts: baseTime + 18000, worker: 'w-1', level: 'info', msg: 'Task finished', bead: 'bd-456', }, ]; events.forEach(e => analytics.processEvent(e)); const metrics = analytics.getWorkerMetrics('w-1'); expect(metrics?.beadsCompleted).toBe(2); expect(metrics?.avgCompletionTimeMs).toBeGreaterThan(0); }); it('should calculate beads per hour', () => { const oneHour = 3600000; const events: LogEvent[] = []; // Simulate 10 beads completed over 1 hour for (let i = 0; i < 10; i++) { events.push({ ts: baseTime + (i * oneHour / 10), worker: 'w-1', level: 'info', msg: `Starting bead bd-${i}`, bead: `bd-${i}`, }); events.push({ ts: baseTime + (i * oneHour / 10) + 1000, worker: 'w-1', level: 'info', msg: 'Completed', bead: `bd-${i}`, }); } events.forEach(e => analytics.processEvent(e)); const metrics = analytics.getWorkerMetrics('w-1', { startTime: baseTime, endTime: baseTime + oneHour, }); expect(metrics?.beadsCompleted).toBe(10); expect(metrics?.beadsPerHour).toBeCloseTo(10, 0); }); it('should calculate average completion time', () => { const events: LogEvent[] = [ { ts: baseTime, worker: 'w-1', level: 'info', msg: 'Start', bead: 'bd-1' }, { ts: baseTime + 5000, worker: 'w-1', level: 'info', msg: 'Completed', bead: 'bd-1' }, { ts: baseTime + 10000, worker: 'w-1', level: 'info', msg: 'Start', bead: 'bd-2' }, { ts: baseTime + 20000, worker: 'w-1', level: 'info', msg: 'Done', bead: 'bd-2' }, ]; events.forEach(e => analytics.processEvent(e)); const metrics = analytics.getWorkerMetrics('w-1'); // Average: (5000 + 10000) / 2 = 7500 expect(metrics?.avgCompletionTimeMs).toBe(7500); }); }); describe('Error Tracking', () => { it('should track error count', () => { const events: LogEvent[] = [ { ts: baseTime, worker: 'w-1', level: 'error', msg: 'Error 1' }, { ts: baseTime + 1000, worker: 'w-1', level: 'info', msg: 'Normal event' }, { ts: baseTime + 2000, worker: 'w-1', level: 'error', msg: 'Error 2' }, ]; events.forEach(e => analytics.processEvent(e)); const metrics = analytics.getWorkerMetrics('w-1'); expect(metrics?.errorCount).toBe(2); }); it('should calculate error rate', () => { const events: LogEvent[] = [ { ts: baseTime, worker: 'w-1', level: 'info', msg: 'Start', bead: 'bd-1' }, { ts: baseTime + 1000, worker: 'w-1', level: 'error', msg: 'Error!' }, { ts: baseTime + 2000, worker: 'w-1', level: 'info', msg: 'Completed', bead: 'bd-1' }, { ts: baseTime + 3000, worker: 'w-1', level: 'info', msg: 'Start', bead: 'bd-2' }, { ts: baseTime + 4000, worker: 'w-1', level: 'info', msg: 'Done', bead: 'bd-2' }, ]; events.forEach(e => analytics.processEvent(e)); const metrics = analytics.getWorkerMetrics('w-1'); // 1 error, 2 beads = 0.5 error rate expect(metrics?.errorRate).toBe(0.5); }); it('should handle zero beads (no error rate)', () => { const events: LogEvent[] = [ { ts: baseTime, worker: 'w-1', level: 'error', msg: 'Error!' }, { ts: baseTime + 1000, worker: 'w-1', level: 'error', msg: 'Another error!' }, ]; events.forEach(e => analytics.processEvent(e)); const metrics = analytics.getWorkerMetrics('w-1'); expect(metrics?.errorCount).toBe(2); expect(metrics?.errorRate).toBe(0); // No beads completed, so rate is 0 }); }); describe('Cost Tracking', () => { it('should track cost from CostTracker', () => { const events: LogEvent[] = [ { ts: baseTime, worker: 'w-1', level: 'info', msg: 'API call', input_tokens: 1000, output_tokens: 500, model: 'claude-sonnet-4-6', }, ]; events.forEach(e => analytics.processEvent(e)); const metrics = analytics.getWorkerMetrics('w-1'); expect(metrics?.totalCostUsd).toBeGreaterThan(0); expect(metrics?.totalTokens).toBe(1500); }); it('should calculate cost per bead', () => { const events: LogEvent[] = [ { ts: baseTime, worker: 'w-1', level: 'info', msg: 'Start', bead: 'bd-1', input_tokens: 1000, output_tokens: 500, }, { ts: baseTime + 1000, worker: 'w-1', level: 'info', msg: 'Completed', bead: 'bd-1', }, ]; events.forEach(e => analytics.processEvent(e)); const metrics = analytics.getWorkerMetrics('w-1'); expect(metrics?.beadsCompleted).toBe(1); expect(metrics?.costPerBead).toBeGreaterThan(0); expect(metrics?.tokensPerBead).toBe(1500); }); }); describe('Activity and Idle Time', () => { it('should track active time', () => { const events: LogEvent[] = [ { ts: baseTime, worker: 'w-1', level: 'info', msg: 'Event 1' }, { ts: baseTime + 1000, worker: 'w-1', level: 'info', msg: 'Event 2' }, { ts: baseTime + 2000, worker: 'w-1', level: 'info', msg: 'Event 3' }, ]; events.forEach(e => analytics.processEvent(e)); const metrics = analytics.getWorkerMetrics('w-1', { startTime: baseTime, endTime: baseTime + 10000, }); expect(metrics?.activeTimeMs).toBeGreaterThan(0); expect(metrics?.idleTimeMs).toBeGreaterThan(0); }); it('should calculate idle percentage', () => { const oneHour = 3600000; // Only 10 minutes of activity in 1 hour const events: LogEvent[] = [ { ts: baseTime, worker: 'w-1', level: 'info', msg: 'Start' }, { ts: baseTime + 600000, worker: 'w-1', level: 'info', msg: 'End' }, // 10 min later ]; events.forEach(e => analytics.processEvent(e)); const metrics = analytics.getWorkerMetrics('w-1', { startTime: baseTime, endTime: baseTime + oneHour, }); // Should have significant idle time expect(metrics?.idlePercentage).toBeGreaterThan(80); }); it('should handle activity gaps correctly', () => { const events: LogEvent[] = [ { ts: baseTime, worker: 'w-1', level: 'info', msg: 'Activity 1' }, { ts: baseTime + 1000, worker: 'w-1', level: 'info', msg: 'Activity 2' }, // 10 minute gap { ts: baseTime + 600000, worker: 'w-1', level: 'info', msg: 'Activity 3' }, { ts: baseTime + 601000, worker: 'w-1', level: 'info', msg: 'Activity 4' }, ]; events.forEach(e => analytics.processEvent(e)); const metrics = analytics.getWorkerMetrics('w-1', { startTime: baseTime, endTime: baseTime + 700000, }); // Should have two separate activity periods expect(metrics?.activeTimeMs).toBeLessThan(700000); }); }); describe('Time Windows', () => { it('should filter by time window: hour', () => { const oneHour = 3600000; const now = Date.now(); const events: LogEvent[] = [ { ts: now - oneHour - 1000, worker: 'w-1', level: 'info', msg: 'Old event' }, { ts: now - 30 * 60000, worker: 'w-1', level: 'info', msg: 'Recent event' }, ]; events.forEach(e => analytics.processEvent(e)); const metrics = analytics.getWorkerMetrics('w-1', { timeWindow: 'hour' }); expect(metrics?.totalEvents).toBe(1); // Only recent event }); it('should support custom time ranges', () => { const events: LogEvent[] = [ { ts: baseTime, worker: 'w-1', level: 'info', msg: 'Event 1' }, { ts: baseTime + 5000, worker: 'w-1', level: 'info', msg: 'Event 2' }, { ts: baseTime + 10000, worker: 'w-1', level: 'info', msg: 'Event 3' }, ]; events.forEach(e => analytics.processEvent(e)); const metrics = analytics.getWorkerMetrics('w-1', { startTime: baseTime + 4000, endTime: baseTime + 15000, }); expect(metrics?.totalEvents).toBe(2); // Events 2 and 3 }); }); describe('Aggregated Analytics', () => { it('should aggregate metrics across all workers', () => { const events: LogEvent[] = [ { ts: baseTime, worker: 'w-1', level: 'info', msg: 'Start', bead: 'bd-1' }, { ts: baseTime + 1000, worker: 'w-1', level: 'info', msg: 'Done', bead: 'bd-1' }, { ts: baseTime, worker: 'w-2', level: 'info', msg: 'Start', bead: 'bd-2' }, { ts: baseTime + 2000, worker: 'w-2', level: 'info', msg: 'Done', bead: 'bd-2' }, { ts: baseTime, worker: 'w-3', level: 'info', msg: 'Start', bead: 'bd-3' }, { ts: baseTime + 1500, worker: 'w-3', level: 'info', msg: 'Done', bead: 'bd-3' }, ]; events.forEach(e => analytics.processEvent(e)); const aggregated = analytics.getAggregatedAnalytics(); expect(aggregated.totalWorkers).toBe(3); expect(aggregated.totalBeadsCompleted).toBe(3); expect(aggregated.topPerformers).toHaveLength(3); }); it('should rank top performers', () => { const events: LogEvent[] = []; // w-1: 5 beads for (let i = 0; i < 5; i++) { events.push({ ts: baseTime + i * 1000, worker: 'w-1', level: 'info', msg: 'Start', bead: `bd-1-${i}` }); events.push({ ts: baseTime + i * 1000 + 500, worker: 'w-1', level: 'info', msg: 'Done', bead: `bd-1-${i}` }); } // w-2: 3 beads for (let i = 0; i < 3; i++) { events.push({ ts: baseTime + i * 1000, worker: 'w-2', level: 'info', msg: 'Start', bead: `bd-2-${i}` }); events.push({ ts: baseTime + i * 1000 + 500, worker: 'w-2', level: 'info', msg: 'Done', bead: `bd-2-${i}` }); } // w-3: 8 beads for (let i = 0; i < 8; i++) { events.push({ ts: baseTime + i * 1000, worker: 'w-3', level: 'info', msg: 'Start', bead: `bd-3-${i}` }); events.push({ ts: baseTime + i * 1000 + 500, worker: 'w-3', level: 'info', msg: 'Done', bead: `bd-3-${i}` }); } events.forEach(e => analytics.processEvent(e)); const aggregated = analytics.getAggregatedAnalytics(); expect(aggregated.topPerformers[0].workerId).toBe('w-3'); expect(aggregated.topPerformers[0].beadsCompleted).toBe(8); expect(aggregated.topPerformers[1].workerId).toBe('w-1'); expect(aggregated.topPerformers[2].workerId).toBe('w-2'); }); it('should identify high error rate workers', () => { const events: LogEvent[] = [ // w-1: 2 beads, 0 errors { ts: baseTime, worker: 'w-1', level: 'info', msg: 'Start', bead: 'bd-1' }, { ts: baseTime + 1000, worker: 'w-1', level: 'info', msg: 'Done', bead: 'bd-1' }, { ts: baseTime + 2000, worker: 'w-1', level: 'info', msg: 'Start', bead: 'bd-2' }, { ts: baseTime + 3000, worker: 'w-1', level: 'info', msg: 'Done', bead: 'bd-2' }, // w-2: 1 bead, 3 errors { ts: baseTime, worker: 'w-2', level: 'info', msg: 'Start', bead: 'bd-3' }, { ts: baseTime + 500, worker: 'w-2', level: 'error', msg: 'Error 1' }, { ts: baseTime + 700, worker: 'w-2', level: 'error', msg: 'Error 2' }, { ts: baseTime + 900, worker: 'w-2', level: 'error', msg: 'Error 3' }, { ts: baseTime + 1000, worker: 'w-2', level: 'info', msg: 'Done', bead: 'bd-3' }, ]; events.forEach(e => analytics.processEvent(e)); const aggregated = analytics.getAggregatedAnalytics(); expect(aggregated.highErrorRateWorkers[0].workerId).toBe('w-2'); expect(aggregated.highErrorRateWorkers[0].errorRate).toBe(3); }); }); describe('Time-Series Data', () => { it('should create time-series snapshots', () => { const oneHour = 3600000; const analytics = new WorkerAnalytics(costTracker, oneHour); // 1 hour interval // Generate events over 3 hours for (let hour = 0; hour < 3; hour++) { const event: LogEvent = { ts: baseTime + hour * oneHour + 1000, worker: 'w-1', level: 'info', msg: `Event at hour ${hour}`, }; analytics.processEvent(event); } const timeSeriesData = analytics.getTimeSeriesData('w-1'); // Should have at least 2 snapshots (one after each hour boundary) expect(timeSeriesData.length).toBeGreaterThanOrEqual(2); }); it('should get performance trends', () => { const events: LogEvent[] = []; const oneHour = 3600000; const analytics = new WorkerAnalytics(costTracker, oneHour); // Generate improving performance: more beads over time for (let hour = 0; hour < 3; hour++) { const beadCount = (hour + 1) * 2; // 2, 4, 6 beads per hour for (let i = 0; i < beadCount; i++) { events.push({ ts: baseTime + hour * oneHour + i * 1000, worker: 'w-1', level: 'info', msg: 'Start', bead: `bd-${hour}-${i}`, }); events.push({ ts: baseTime + hour * oneHour + i * 1000 + 500, worker: 'w-1', level: 'info', msg: 'Done', bead: `bd-${hour}-${i}`, }); } // Force a snapshot at the end of each hour events.push({ ts: baseTime + (hour + 1) * oneHour + 1000, worker: 'w-1', level: 'info', msg: 'Hourly marker', }); } events.forEach(e => analytics.processEvent(e)); const trend = analytics.getPerformanceTrends('w-1', 'beadsCompleted'); expect(trend.dataPoints.length).toBeGreaterThan(0); // Trend should be improving since beads increase over time // Note: This might be 'stable' if snapshots don't capture the progression well }); }); describe('Options and Filtering', () => { it('should filter by worker IDs', () => { const events: LogEvent[] = [ { ts: baseTime, worker: 'w-1', level: 'info', msg: 'Event 1' }, { ts: baseTime, worker: 'w-2', level: 'info', msg: 'Event 2' }, { ts: baseTime, worker: 'w-3', level: 'info', msg: 'Event 3' }, ]; events.forEach(e => analytics.processEvent(e)); const filtered = analytics.getAllWorkerMetrics({ workerIds: ['w-1', 'w-3'] }); expect(filtered).toHaveLength(2); expect(filtered.map(m => m.workerId).sort()).toEqual(['w-1', 'w-3']); }); it('should filter by minimum beads completed', () => { const events: LogEvent[] = [ { ts: baseTime, worker: 'w-1', level: 'info', msg: 'Start', bead: 'bd-1' }, { ts: baseTime + 1000, worker: 'w-1', level: 'info', msg: 'Done', bead: 'bd-1' }, { ts: baseTime, worker: 'w-2', level: 'info', msg: 'Event' }, ]; events.forEach(e => analytics.processEvent(e)); const filtered = analytics.getAllWorkerMetrics({ minBeadsCompleted: 1 }); expect(filtered).toHaveLength(1); expect(filtered[0].workerId).toBe('w-1'); }); it('should limit max workers in rankings', () => { const events: LogEvent[] = []; // Create 20 workers for (let i = 0; i < 20; i++) { events.push({ ts: baseTime, worker: `w-${i}`, level: 'info', msg: 'Start', bead: `bd-${i}`, }); events.push({ ts: baseTime + 1000, worker: `w-${i}`, level: 'info', msg: 'Done', bead: `bd-${i}`, }); } events.forEach(e => analytics.processEvent(e)); const aggregated = analytics.getAggregatedAnalytics({ maxWorkers: 5 }); expect(aggregated.topPerformers).toHaveLength(5); }); }); describe('Summary Output', () => { it('should generate formatted summary', () => { const events: LogEvent[] = [ { ts: baseTime, worker: 'w-1', level: 'info', msg: 'Start', bead: 'bd-1' }, { ts: baseTime + 1000, worker: 'w-1', level: 'info', msg: 'Done', bead: 'bd-1' }, { ts: baseTime, worker: 'w-1', level: 'error', msg: 'Error!' }, ]; events.forEach(e => analytics.processEvent(e)); const summary = analytics.getSummary(); expect(summary).toContain('Worker Analytics Summary'); expect(summary).toContain('Total Workers'); expect(summary).toContain('Total Beads Completed'); expect(summary).toContain('Error Rate'); }); }); describe('Clear and Reset', () => { it('should clear all data', () => { const events: LogEvent[] = [ { ts: baseTime, worker: 'w-1', level: 'info', msg: 'Event' }, { ts: baseTime + 1000, worker: 'w-2', level: 'info', msg: 'Event' }, ]; events.forEach(e => analytics.processEvent(e)); analytics.clear(); const allMetrics = analytics.getAllWorkerMetrics(); expect(allMetrics).toHaveLength(0); const metrics = analytics.getWorkerMetrics('w-1'); expect(metrics).toBeUndefined(); }); }); }); describe('MetricAccumulator', () => { let accumulator: MetricAccumulator; const baseTime = Date.now(); beforeEach(() => { accumulator = new MetricAccumulator(); }); function makeMetricEvent(worker: string, metricName: string, value: number, ts?: number): LogEvent { return { ts: ts ?? baseTime, worker, level: 'info', msg: `metric.${metricName}`, metric_name: metricName, value, }; } it('accumulates token-in counts', () => { accumulator.processEvent(makeMetricEvent('w-1', INSTRUMENT_NAMES.TOKENS_IN, 100)); accumulator.processEvent(makeMetricEvent('w-1', INSTRUMENT_NAMES.TOKENS_IN, 50)); const snap = accumulator.getSnapshot('w-1'); expect(snap).not.toBeNull(); expect(snap!.tokensIn).toBe(150); }); it('accumulates token-out counts', () => { accumulator.processEvent(makeMetricEvent('w-1', INSTRUMENT_NAMES.TOKENS_OUT, 200)); accumulator.processEvent(makeMetricEvent('w-1', INSTRUMENT_NAMES.TOKENS_OUT, 75)); const snap = accumulator.getSnapshot('w-1'); expect(snap!.tokensOut).toBe(275); }); it('accumulates cost USD', () => { accumulator.processEvent(makeMetricEvent('w-1', INSTRUMENT_NAMES.COST_USD, 0.05)); accumulator.processEvent(makeMetricEvent('w-1', INSTRUMENT_NAMES.COST_USD, 0.03)); const snap = accumulator.getSnapshot('w-1'); expect(snap!.costUsd).toBeCloseTo(0.08); }); it('tracks bead completions', () => { accumulator.processEvent(makeMetricEvent('w-1', INSTRUMENT_NAMES.BEAD_COMPLETED, 1)); accumulator.processEvent(makeMetricEvent('w-1', INSTRUMENT_NAMES.BEAD_COMPLETED, 1)); accumulator.processEvent(makeMetricEvent('w-1', INSTRUMENT_NAMES.BEAD_COMPLETED, 1)); const snap = accumulator.getSnapshot('w-1'); expect(snap!.beadsCompleted).toBe(3); }); it('tracks bead failures', () => { accumulator.processEvent(makeMetricEvent('w-1', INSTRUMENT_NAMES.BEAD_FAILED, 1)); const snap = accumulator.getSnapshot('w-1'); expect(snap!.beadsFailed).toBe(1); }); it('tracks worker errors', () => { accumulator.processEvent(makeMetricEvent('w-1', INSTRUMENT_NAMES.WORKER_ERRORS, 2)); const snap = accumulator.getSnapshot('w-1'); expect(snap!.errors).toBe(2); }); it('records bead duration samples', () => { accumulator.processEvent(makeMetricEvent('w-1', INSTRUMENT_NAMES.BEAD_DURATION, 5000)); accumulator.processEvent(makeMetricEvent('w-1', INSTRUMENT_NAMES.BEAD_DURATION, 3200)); const snap = accumulator.getSnapshot('w-1'); expect(snap!.durations).toEqual([5000, 3200]); }); it('isolates workers', () => { accumulator.processEvent(makeMetricEvent('w-1', INSTRUMENT_NAMES.TOKENS_IN, 100)); accumulator.processEvent(makeMetricEvent('w-2', INSTRUMENT_NAMES.TOKENS_IN, 200)); expect(accumulator.getSnapshot('w-1')!.tokensIn).toBe(100); expect(accumulator.getSnapshot('w-2')!.tokensIn).toBe(200); }); it('drains samples and clears buffer', () => { accumulator.processEvent(makeMetricEvent('w-1', INSTRUMENT_NAMES.TOKENS_IN, 100)); accumulator.processEvent(makeMetricEvent('w-2', INSTRUMENT_NAMES.COST_USD, 0.05)); const samples = accumulator.drainSamples(); expect(samples).toHaveLength(2); expect(samples[0].metricName).toBe(INSTRUMENT_NAMES.TOKENS_IN); expect(samples[1].metricName).toBe(INSTRUMENT_NAMES.COST_USD); // Second drain should be empty expect(accumulator.drainSamples()).toHaveLength(0); }); it('hasMetricData returns false before any metric event', () => { expect(accumulator.hasMetricData()).toBe(false); }); it('hasMetricData returns true after processing a metric', () => { accumulator.processEvent(makeMetricEvent('w-1', INSTRUMENT_NAMES.TOKENS_IN, 100)); expect(accumulator.hasMetricData()).toBe(true); }); it('ignores non-metric events', () => { const event: LogEvent = { ts: baseTime, worker: 'w-1', level: 'info', msg: 'bead.completed', }; accumulator.processEvent(event); expect(accumulator.hasMetricData()).toBe(false); expect(accumulator.getSnapshot('w-1')).toBeNull(); }); it('resets all state', () => { accumulator.processEvent(makeMetricEvent('w-1', INSTRUMENT_NAMES.TOKENS_IN, 100)); accumulator.reset(); expect(accumulator.hasMetricData()).toBe(false); expect(accumulator.getSnapshot('w-1')).toBeNull(); expect(accumulator.drainSamples()).toHaveLength(0); }); it('returns full snapshot with all fields', () => { accumulator.processEvent(makeMetricEvent('w-1', INSTRUMENT_NAMES.TOKENS_IN, 1000)); accumulator.processEvent(makeMetricEvent('w-1', INSTRUMENT_NAMES.TOKENS_OUT, 500)); accumulator.processEvent(makeMetricEvent('w-1', INSTRUMENT_NAMES.COST_USD, 0.12)); accumulator.processEvent(makeMetricEvent('w-1', INSTRUMENT_NAMES.BEAD_COMPLETED, 3)); accumulator.processEvent(makeMetricEvent('w-1', INSTRUMENT_NAMES.BEAD_FAILED, 1)); accumulator.processEvent(makeMetricEvent('w-1', INSTRUMENT_NAMES.WORKER_ERRORS, 2)); accumulator.processEvent(makeMetricEvent('w-1', INSTRUMENT_NAMES.BEAD_DURATION, 4000)); const snap = accumulator.getSnapshot('w-1'); expect(snap).toEqual({ tokensIn: 1000, tokensOut: 500, costUsd: 0.12, beadsCompleted: 3, beadsFailed: 1, errors: 2, durations: [4000], }); }); it('uses metric_value fallback when value is missing', () => { const event: LogEvent = { ts: baseTime, worker: 'w-1', level: 'info', msg: `metric.${INSTRUMENT_NAMES.TOKENS_IN}`, metric_name: INSTRUMENT_NAMES.TOKENS_IN, metric_value: 42, }; accumulator.processEvent(event); expect(accumulator.getSnapshot('w-1')!.tokensIn).toBe(42); }); describe('alias resolution (NEEDLE naming convention)', () => { it('resolves needle.worker.beads.completed to needle.bead.completed', () => { accumulator.processEvent(makeMetricEvent('w-1', 'needle.worker.beads.completed', 1)); accumulator.processEvent(makeMetricEvent('w-1', 'needle.worker.beads.completed', 1)); const snap = accumulator.getSnapshot('w-1'); expect(snap!.beadsCompleted).toBe(2); }); it('resolves needle.worker.beads.failed to needle.bead.failed', () => { accumulator.processEvent(makeMetricEvent('w-1', 'needle.worker.beads.failed', 1)); const snap = accumulator.getSnapshot('w-1'); expect(snap!.beadsFailed).toBe(1); }); it('drains alias-resolved samples with canonical name', () => { accumulator.processEvent(makeMetricEvent('w-1', 'needle.worker.beads.completed', 1)); const samples = accumulator.drainSamples(); expect(samples).toHaveLength(1); expect(samples[0].metricName).toBe('needle.bead.completed'); }); it('coaccumulates alias and canonical names for the same instrument', () => { // NEEDLE emits alias form accumulator.processEvent(makeMetricEvent('w-1', 'needle.worker.beads.completed', 2)); // Direct canonical form accumulator.processEvent(makeMetricEvent('w-1', 'needle.bead.completed', 3)); const snap = accumulator.getSnapshot('w-1'); expect(snap!.beadsCompleted).toBe(5); }); it('passes through unknown names unchanged', () => { expect(resolveInstrumentName('needle.worker.tokens.in')).toBe('needle.worker.tokens.in'); expect(resolveInstrumentName('custom.metric')).toBe('custom.metric'); }); }); });