collector = new ComponentMetricsCollector(); }); describe('Render Metrics', function () { it('records total renders with cached/uncached distinction', function () { $this->collector->recordRender('user-stats', 45.5, false); $this->collector->recordRender('user-stats', 12.3, true); $this->collector->recordRender('dashboard', 78.9, false); $metrics = $this->collector->getMetrics(); // Check total renders metrics exist (labels alphabetically sorted) expect($metrics)->toHaveKey('livecomponent_renders_total{cached=false,component_id=user-stats}'); expect($metrics)->toHaveKey('livecomponent_renders_total{cached=true,component_id=user-stats}'); expect($metrics)->toHaveKey('livecomponent_renders_total{cached=false,component_id=dashboard}'); // Verify counter values expect($metrics['livecomponent_renders_total{cached=false,component_id=user-stats}']->value)->toBe(1.0); expect($metrics['livecomponent_renders_total{cached=true,component_id=user-stats}']->value)->toBe(1.0); }); it('records render duration histogram', function () { $this->collector->recordRender('counter', 23.45, false); $metrics = $this->collector->getMetrics(); // Check duration histogram exists (labels alphabetically sorted) expect($metrics)->toHaveKey('livecomponent_render_duration_ms{cached=false,component_id=counter}'); $durationMetric = $metrics['livecomponent_render_duration_ms{cached=false,component_id=counter}']; expect($durationMetric->type)->toBe(MetricType::HISTOGRAM); expect($durationMetric->value)->toBe(23.45); expect($durationMetric->unit)->toBe('ms'); }); it('distinguishes between cached and uncached renders', function () { $this->collector->recordRender('product-list', 120.0, false); // Uncached $this->collector->recordRender('product-list', 5.0, true); // Cached $metrics = $this->collector->getMetrics(); // Labels alphabetically sorted $uncachedDuration = $metrics['livecomponent_render_duration_ms{cached=false,component_id=product-list}']; $cachedDuration = $metrics['livecomponent_render_duration_ms{cached=true,component_id=product-list}']; expect($uncachedDuration->value)->toBe(120.0); expect($cachedDuration->value)->toBe(5.0); }); }); describe('Action Metrics', function () { it('records action executions with success/error status', function () { $this->collector->recordAction('form', 'validate', 15.5, true); $this->collector->recordAction('form', 'submit', 45.0, false); $this->collector->recordAction('form', 'validate', 12.0, true); $metrics = $this->collector->getMetrics(); // Check action totals expect($metrics)->toHaveKey('livecomponent_actions_total{action=validate,component_id=form,status=success}'); expect($metrics)->toHaveKey('livecomponent_actions_total{action=submit,component_id=form,status=error}'); // Verify counter values expect($metrics['livecomponent_actions_total{action=validate,component_id=form,status=success}']->value)->toBe(2.0); expect($metrics['livecomponent_actions_total{action=submit,component_id=form,status=error}']->value)->toBe(1.0); }); it('records action duration histogram', function () { $this->collector->recordAction('cart', 'addItem', 34.56, true); $metrics = $this->collector->getMetrics(); expect($metrics)->toHaveKey('livecomponent_action_duration_ms{action=addItem,component_id=cart}'); $durationMetric = $metrics['livecomponent_action_duration_ms{action=addItem,component_id=cart}']; expect($durationMetric->type)->toBe(MetricType::HISTOGRAM); expect($durationMetric->value)->toBe(34.56); }); it('increments action error counter on failure', function () { $this->collector->recordAction('payment', 'charge', 100.0, false); $this->collector->recordAction('payment', 'charge', 95.0, false); $metrics = $this->collector->getMetrics(); // Check error counter expect($metrics)->toHaveKey('livecomponent_action_errors_total{action=charge,component_id=payment}'); expect($metrics['livecomponent_action_errors_total{action=charge,component_id=payment}']->value)->toBe(2.0); }); it('does not increment error counter on success', function () { $this->collector->recordAction('search', 'query', 50.0, true); $metrics = $this->collector->getMetrics(); // Error counter should not exist expect($metrics)->not->toHaveKey('livecomponent_action_errors_total{action=query,component_id=search}'); }); }); describe('Cache Metrics', function () { it('records cache hits', function () { $this->collector->recordCacheHit('user-profile', true); $this->collector->recordCacheHit('user-profile', true); $this->collector->recordCacheHit('dashboard', true); $metrics = $this->collector->getMetrics(); expect($metrics)->toHaveKey('livecomponent_cache_hits_total{component_id=user-profile}'); expect($metrics['livecomponent_cache_hits_total{component_id=user-profile}']->value)->toBe(2.0); expect($metrics['livecomponent_cache_hits_total{component_id=dashboard}']->value)->toBe(1.0); }); it('records cache misses', function () { $this->collector->recordCacheHit('stats', false); $this->collector->recordCacheHit('stats', false); $metrics = $this->collector->getMetrics(); expect($metrics)->toHaveKey('livecomponent_cache_misses_total{component_id=stats}'); expect($metrics['livecomponent_cache_misses_total{component_id=stats}']->value)->toBe(2.0); }); it('tracks mixed cache hits and misses', function () { $this->collector->recordCacheHit('widget', true); // Hit $this->collector->recordCacheHit('widget', false); // Miss $this->collector->recordCacheHit('widget', true); // Hit $metrics = $this->collector->getMetrics(); expect($metrics['livecomponent_cache_hits_total{component_id=widget}']->value)->toBe(2.0); expect($metrics['livecomponent_cache_misses_total{component_id=widget}']->value)->toBe(1.0); }); }); describe('Event Metrics', function () { it('records dispatched events', function () { $this->collector->recordEventDispatched('chat', 'message.sent'); $this->collector->recordEventDispatched('chat', 'message.sent'); $this->collector->recordEventDispatched('chat', 'user.typing'); $metrics = $this->collector->getMetrics(); expect($metrics)->toHaveKey('livecomponent_events_dispatched_total{component_id=chat,event=message.sent}'); expect($metrics['livecomponent_events_dispatched_total{component_id=chat,event=message.sent}']->value)->toBe(2.0); expect($metrics['livecomponent_events_dispatched_total{component_id=chat,event=user.typing}']->value)->toBe(1.0); }); it('records received events', function () { $this->collector->recordEventReceived('notification', 'alert.received'); $this->collector->recordEventReceived('notification', 'alert.received'); $metrics = $this->collector->getMetrics(); expect($metrics)->toHaveKey('livecomponent_events_received_total{component_id=notification,event=alert.received}'); expect($metrics['livecomponent_events_received_total{component_id=notification,event=alert.received}']->value)->toBe(2.0); }); }); describe('Hydration Metrics', function () { it('records hydration duration', function () { $this->collector->recordHydration('interactive-map', 156.78); $metrics = $this->collector->getMetrics(); expect($metrics)->toHaveKey('livecomponent_hydration_duration_ms{component_id=interactive-map}'); $metric = $metrics['livecomponent_hydration_duration_ms{component_id=interactive-map}']; expect($metric->type)->toBe(MetricType::HISTOGRAM); expect($metric->value)->toBe(156.78); }); }); describe('Batch Metrics', function () { it('records batch operations with success/failure counts', function () { $this->collector->recordBatch( operationCount: 5, durationMs: 123.45, successCount: 4, failureCount: 1 ); $metrics = $this->collector->getMetrics(); // Check batch operation counter expect($metrics)->toHaveKey('livecomponent_batch_operations_total{status=executed}'); expect($metrics['livecomponent_batch_operations_total{status=executed}']->value)->toBe(1.0); // Check batch size histogram expect($metrics)->toHaveKey('livecomponent_batch_size'); expect($metrics['livecomponent_batch_size']->value)->toBe(5.0); // Check batch duration histogram expect($metrics)->toHaveKey('livecomponent_batch_duration_ms'); expect($metrics['livecomponent_batch_duration_ms']->value)->toBe(123.45); // Check success/failure counters expect($metrics)->toHaveKey('livecomponent_batch_success_total'); expect($metrics['livecomponent_batch_success_total']->value)->toBe(4.0); expect($metrics)->toHaveKey('livecomponent_batch_failure_total'); expect($metrics['livecomponent_batch_failure_total']->value)->toBe(1.0); }); it('records multiple batch operations', function () { $this->collector->recordBatch(3, 50.0, 3, 0); $this->collector->recordBatch(7, 150.0, 5, 2); $metrics = $this->collector->getMetrics(); // Batch operations counter should increment expect($metrics['livecomponent_batch_operations_total{status=executed}']->value)->toBe(2.0); // Success/failure totals should accumulate expect($metrics['livecomponent_batch_success_total']->value)->toBe(8.0); // 3 + 5 expect($metrics['livecomponent_batch_failure_total']->value)->toBe(2.0); }); it('handles batch with no failures', function () { $this->collector->recordBatch(10, 200.0, 10, 0); $metrics = $this->collector->getMetrics(); expect($metrics)->toHaveKey('livecomponent_batch_success_total'); expect($metrics)->not->toHaveKey('livecomponent_batch_failure_total'); }); it('handles batch with no successes', function () { $this->collector->recordBatch(5, 100.0, 0, 5); $metrics = $this->collector->getMetrics(); expect($metrics)->toHaveKey('livecomponent_batch_failure_total'); expect($metrics)->not->toHaveKey('livecomponent_batch_success_total'); }); }); describe('Fragment Metrics', function () { it('records fragment updates with count and duration', function () { $this->collector->recordFragmentUpdate('dashboard', 3, 45.67); $metrics = $this->collector->getMetrics(); // Check fragment update counter expect($metrics)->toHaveKey('livecomponent_fragment_updates_total{component_id=dashboard}'); expect($metrics['livecomponent_fragment_updates_total{component_id=dashboard}']->value)->toBe(1.0); // Check fragment count histogram expect($metrics)->toHaveKey('livecomponent_fragment_count'); expect($metrics['livecomponent_fragment_count']->value)->toBe(3.0); // Check fragment duration histogram expect($metrics)->toHaveKey('livecomponent_fragment_duration_ms'); expect($metrics['livecomponent_fragment_duration_ms']->value)->toBe(45.67); }); it('tracks multiple fragment updates', function () { $this->collector->recordFragmentUpdate('editor', 2, 30.0); $this->collector->recordFragmentUpdate('editor', 5, 60.0); $metrics = $this->collector->getMetrics(); expect($metrics['livecomponent_fragment_updates_total{component_id=editor}']->value)->toBe(2.0); }); }); describe('Upload Metrics', function () { it('records successful upload chunks', function () { $this->collector->recordUploadChunk('session-123', 0, 45.6, true); $this->collector->recordUploadChunk('session-123', 1, 42.3, true); $metrics = $this->collector->getMetrics(); expect($metrics)->toHaveKey('livecomponent_upload_chunks_total{session_id=session-123,status=success}'); expect($metrics['livecomponent_upload_chunks_total{session_id=session-123,status=success}']->value)->toBe(2.0); expect($metrics)->toHaveKey('livecomponent_upload_chunk_duration_ms{session_id=session-123}'); }); it('records failed upload chunks', function () { $this->collector->recordUploadChunk('session-456', 0, 100.0, false); $metrics = $this->collector->getMetrics(); expect($metrics)->toHaveKey('livecomponent_upload_chunks_total{session_id=session-456,status=error}'); expect($metrics['livecomponent_upload_chunks_total{session_id=session-456,status=error}']->value)->toBe(1.0); }); it('records upload completion', function () { $this->collector->recordUploadComplete('session-789', 5432.1, 10); $metrics = $this->collector->getMetrics(); expect($metrics)->toHaveKey('livecomponent_uploads_completed_total{session_id=session-789}'); expect($metrics['livecomponent_uploads_completed_total{session_id=session-789}']->value)->toBe(1.0); expect($metrics)->toHaveKey('livecomponent_upload_total_duration_ms'); expect($metrics['livecomponent_upload_total_duration_ms']->value)->toBe(5432.1); expect($metrics)->toHaveKey('livecomponent_upload_chunk_count'); expect($metrics['livecomponent_upload_chunk_count']->value)->toBe(10.0); }); }); describe('Summary Generation', function () { it('generates summary with aggregated statistics', function () { // Create variety of metrics $this->collector->recordRender('comp-1', 50.0, false); $this->collector->recordRender('comp-1', 10.0, true); $this->collector->recordAction('comp-1', 'action1', 30.0, true); $this->collector->recordAction('comp-1', 'action2', 40.0, false); $this->collector->recordCacheHit('comp-1', true); $this->collector->recordCacheHit('comp-1', true); $this->collector->recordCacheHit('comp-1', false); $this->collector->recordEventDispatched('comp-1', 'event1'); $summary = $this->collector->getSummary(); expect($summary)->toHaveKey('total_renders'); expect($summary)->toHaveKey('total_actions'); expect($summary)->toHaveKey('cache_hits'); expect($summary)->toHaveKey('cache_misses'); expect($summary)->toHaveKey('total_events'); expect($summary)->toHaveKey('action_errors'); expect($summary)->toHaveKey('cache_hit_rate'); expect($summary['total_renders'])->toBe(2); expect($summary['total_actions'])->toBe(2); expect($summary['cache_hits'])->toBe(2); expect($summary['cache_misses'])->toBe(1); expect($summary['total_events'])->toBe(1); expect($summary['action_errors'])->toBe(1); }); it('calculates cache hit rate correctly', function () { $this->collector->recordCacheHit('test', true); $this->collector->recordCacheHit('test', true); $this->collector->recordCacheHit('test', true); $this->collector->recordCacheHit('test', false); $summary = $this->collector->getSummary(); // 3 hits / 4 total = 75% expect($summary['cache_hit_rate'])->toBe(75.0); }); it('handles zero cache accesses gracefully', function () { $this->collector->recordRender('test', 10.0); $summary = $this->collector->getSummary(); expect($summary['cache_hit_rate'])->toBe(0.0); }); it('returns zero values for empty collector', function () { $summary = $this->collector->getSummary(); expect($summary['total_renders'])->toBe(0); expect($summary['total_actions'])->toBe(0); expect($summary['cache_hits'])->toBe(0); expect($summary['cache_misses'])->toBe(0); expect($summary['total_events'])->toBe(0); expect($summary['action_errors'])->toBe(0); expect($summary['cache_hit_rate'])->toBe(0.0); }); }); describe('Prometheus Export', function () { it('exports metrics in Prometheus format', function () { $this->collector->recordRender('test-component', 45.5, false); $this->collector->recordAction('test-component', 'increment', 12.3, true); $prometheus = $this->collector->exportPrometheus(); // Check Prometheus format expect($prometheus)->toContain('# HELP LiveComponents metrics'); expect($prometheus)->toContain('# TYPE livecomponent_* counter/histogram'); // Check metric lines exist expect($prometheus)->toContain('livecomponent_renders_total'); expect($prometheus)->toContain('livecomponent_actions_total'); expect($prometheus)->toContain('component_id="test-component"'); }); it('includes timestamps in Prometheus format', function () { $this->collector->recordRender('counter', 10.0); $prometheus = $this->collector->exportPrometheus(); // Should contain timestamp (unix timestamp format) expect($prometheus)->toMatch('/\d+\.\d+\s+\d+/'); }); it('handles labels with special characters', function () { // Test with special characters: quote and newline $this->collector->recordAction('form"test', "action\nname", 10.0); $prometheus = $this->collector->exportPrometheus(); // Should escape special characters in Prometheus format // Quote: " becomes \" expect($prometheus)->toContain('form\\"test'); // Newline: \n becomes \n (literal backslash-n in output) expect($prometheus)->toContain('action\nname'); }); it('exports empty string for empty collector', function () { $prometheus = $this->collector->exportPrometheus(); expect($prometheus)->toContain('# HELP LiveComponents metrics'); expect($prometheus)->toContain('# TYPE livecomponent_* counter/histogram'); }); }); describe('Metric Key Building', function () { it('creates unique keys for metrics with different labels', function () { $this->collector->recordRender('component-a', 10.0, true); $this->collector->recordRender('component-b', 20.0, true); $metrics = $this->collector->getMetrics(); expect($metrics)->toHaveKey('livecomponent_renders_total{cached=true,component_id=component-a}'); expect($metrics)->toHaveKey('livecomponent_renders_total{cached=true,component_id=component-b}'); }); it('sorts labels alphabetically in metric keys', function () { // Record action (labels: component_id, action, status) $this->collector->recordAction('test', 'action1', 10.0, true); $metrics = $this->collector->getMetrics(); // Key should have labels sorted: action, component_id, status expect($metrics)->toHaveKey('livecomponent_actions_total{action=action1,component_id=test,status=success}'); }); }); describe('Metric Retrieval', function () { it('retrieves metric by exact key', function () { $this->collector->recordRender('my-component', 50.0); $metric = $this->collector->getMetric('livecomponent_renders_total{cached=false,component_id=my-component}'); expect($metric)->not->toBeNull(); expect($metric->value)->toBe(1.0); }); it('returns null for non-existent metric', function () { $metric = $this->collector->getMetric('non_existent_metric'); expect($metric)->toBeNull(); }); it('retrieves all metrics', function () { $this->collector->recordRender('comp1', 10.0); $this->collector->recordRender('comp2', 20.0); $this->collector->recordAction('comp1', 'action1', 30.0); $metrics = $this->collector->getMetrics(); expect($metrics)->toBeArray(); expect(count($metrics))->toBeGreaterThan(0); }); }); describe('Reset Functionality', function () { it('clears all metrics on reset', function () { // Add various metrics $this->collector->recordRender('test', 10.0); $this->collector->recordAction('test', 'action', 20.0); $this->collector->recordCacheHit('test', true); expect($this->collector->getMetrics())->not->toBeEmpty(); // Reset $this->collector->reset(); expect($this->collector->getMetrics())->toBeEmpty(); }); it('allows recording new metrics after reset', function () { $this->collector->recordRender('test', 10.0); $this->collector->reset(); $this->collector->recordRender('test', 20.0); $metrics = $this->collector->getMetrics(); expect($metrics)->toHaveCount(2); // renders_total + render_duration_ms }); it('resets summary to zero values', function () { $this->collector->recordRender('test', 10.0); $this->collector->reset(); $summary = $this->collector->getSummary(); expect($summary['total_renders'])->toBe(0); expect($summary['total_actions'])->toBe(0); expect($summary['cache_hits'])->toBe(0); }); }); describe('Counter Incrementing', function () { it('increments counter metric on repeated calls', function () { $this->collector->recordRender('counter-test', 10.0); $this->collector->recordRender('counter-test', 15.0); $this->collector->recordRender('counter-test', 20.0); $metrics = $this->collector->getMetrics(); $counterMetric = $metrics['livecomponent_renders_total{cached=false,component_id=counter-test}']; expect($counterMetric->value)->toBe(3.0); }); it('increments by custom amount in batch operations', function () { // Batch with 5 successes $this->collector->recordBatch(5, 100.0, 5, 0); $metrics = $this->collector->getMetrics(); expect($metrics['livecomponent_batch_success_total']->value)->toBe(5.0); }); }); describe('Histogram Updates', function () { it('updates histogram value on repeated observations', function () { $this->collector->recordRender('histogram-test', 50.0); $this->collector->recordRender('histogram-test', 100.0); $metrics = $this->collector->getMetrics(); $histogramMetric = $metrics['livecomponent_render_duration_ms{cached=false,component_id=histogram-test}']; // Latest observation should be stored expect($histogramMetric->value)->toBe(100.0); }); }); describe('Metric Metadata', function () { it('stores metric type correctly', function () { $this->collector->recordRender('test', 10.0); $metrics = $this->collector->getMetrics(); $counterMetric = $metrics['livecomponent_renders_total{cached=false,component_id=test}']; expect($counterMetric->type)->toBe(MetricType::COUNTER); $histogramMetric = $metrics['livecomponent_render_duration_ms{cached=false,component_id=test}']; expect($histogramMetric->type)->toBe(MetricType::HISTOGRAM); }); it('stores labels correctly', function () { $this->collector->recordAction('my-component', 'my-action', 50.0, true); $metrics = $this->collector->getMetrics(); $actionMetric = $metrics['livecomponent_actions_total{action=my-action,component_id=my-component,status=success}']; expect($actionMetric->labels)->toHaveKey('component_id'); expect($actionMetric->labels)->toHaveKey('action'); expect($actionMetric->labels)->toHaveKey('status'); expect($actionMetric->labels['component_id'])->toBe('my-component'); expect($actionMetric->labels['action'])->toBe('my-action'); expect($actionMetric->labels['status'])->toBe('success'); }); it('stores unit for duration histograms', function () { $this->collector->recordRender('test', 10.0); $metrics = $this->collector->getMetrics(); $histogramMetric = $metrics['livecomponent_render_duration_ms{cached=false,component_id=test}']; expect($histogramMetric->unit)->toBe('ms'); }); it('stores timestamp for all metrics', function () { $this->collector->recordRender('test', 10.0); $metrics = $this->collector->getMetrics(); foreach ($metrics as $metric) { expect($metric->timestamp)->not->toBeNull(); } }); }); });