collector = new ComponentMetricsCollector(); }); describe('Prometheus Format Validation', function () { it('exports valid prometheus format with header', function () { $this->collector->recordRender('test-component', 50.5); $prometheus = $this->collector->exportPrometheus(); expect($prometheus)->toContain('# HELP LiveComponents metrics'); expect($prometheus)->toContain('# TYPE livecomponent_* counter/histogram'); }); it('exports metrics with proper line format', function () { $this->collector->recordRender('test-component', 50.5); $prometheus = $this->collector->exportPrometheus(); $lines = explode("\n", trim($prometheus)); // Find metric line (skip header comments) $metricLines = array_filter($lines, fn($line) => !str_starts_with($line, '#') && !empty($line)); foreach ($metricLines as $line) { // Format: metric_name{labels} value timestamp expect($line)->toMatch('/^[a-z_]+\{.*\}\s+[\d.]+\s+\d+$/'); } }); it('exports counter metrics correctly', function () { $this->collector->recordRender('counter-test', 10.0); $prometheus = $this->collector->exportPrometheus(); // Counter should be in format: livecomponent_renders_total{...} 1.00 timestamp expect($prometheus)->toContain('livecomponent_renders_total'); expect($prometheus)->toMatch('/livecomponent_renders_total\{.*\}\s+1\.00\s+\d+/'); }); it('exports histogram metrics correctly', function () { $this->collector->recordRender('histogram-test', 45.75); $prometheus = $this->collector->exportPrometheus(); // Histogram should be in format: livecomponent_render_duration_ms{...} 45.75 timestamp expect($prometheus)->toContain('livecomponent_render_duration_ms'); expect($prometheus)->toMatch('/livecomponent_render_duration_ms\{.*\}\s+45\.75\s+\d+/'); }); }); describe('Label Formatting', function () { it('formats labels with alphabetical sorting', function () { $this->collector->recordAction('component-1', 'submit', 100.0); $prometheus = $this->collector->exportPrometheus(); // Labels should be sorted: action, component_id, status expect($prometheus)->toContain('action="submit"'); expect($prometheus)->toContain('component_id="component-1"'); expect($prometheus)->toContain('status="success"'); // Verify alphabetical order in label string $metricLines = array_filter( explode("\n", $prometheus), fn($line) => str_contains($line, 'livecomponent_actions_total') ); foreach ($metricLines as $line) { // Extract label section between {} preg_match('/\{(.*?)\}/', $line, $matches); if (isset($matches[1])) { $labels = explode(',', $matches[1]); $labelKeys = array_map(fn($label) => explode('=', $label)[0], $labels); // Verify alphabetical order $sortedKeys = $labelKeys; sort($sortedKeys); expect($labelKeys)->toBe($sortedKeys); } } }); it('escapes special characters in label values', function () { // Component with special characters: quotes, backslashes, newlines $this->collector->recordRender('test"component', 50.0); $this->collector->recordAction('comp\\path', "action\nname", 10.0); // Use double quotes for actual newline $prometheus = $this->collector->exportPrometheus(); // Quotes should be escaped as \" expect($prometheus)->toContain('test\\"component'); // Backslashes should be escaped as \\ expect($prometheus)->toContain('comp\\\\path'); // Newlines should be escaped as \n in Prometheus format // The actual newline character (\n) should appear as backslash-n in the output expect($prometheus)->toMatch('/action\\\\nname/'); // Regex to find literal \n }); it('handles empty labels correctly', function () { // recordHistogram with no labels (internal method tested via batch) $this->collector->recordBatch(10, 500.0, 8, 2); $prometheus = $this->collector->exportPrometheus(); // Some metrics should have empty labels {} expect($prometheus)->toContain('livecomponent_batch_size'); expect($prometheus)->toContain('livecomponent_batch_duration_ms'); // These metrics have labels expect($prometheus)->toContain('livecomponent_batch_operations_total{status="executed"}'); }); it('formats boolean labels as strings', function () { $this->collector->recordRender('component-1', 50.0, cached: false); $this->collector->recordRender('component-2', 25.0, cached: true); $prometheus = $this->collector->exportPrometheus(); // Boolean should be formatted as "true" or "false" strings expect($prometheus)->toContain('cached="false"'); expect($prometheus)->toContain('cached="true"'); expect($prometheus)->not->toContain('cached=true'); expect($prometheus)->not->toContain('cached=false'); }); }); describe('Value Formatting', function () { it('formats float values with 2 decimal places', function () { $this->collector->recordRender('test', 123.456); $prometheus = $this->collector->exportPrometheus(); // Values should have .2f format expect($prometheus)->toContain('123.46'); // Rounded to 2 decimals }); it('formats integer values with .00', function () { $this->collector->recordAction('test', 'click', 100.0); // Creates counter = 1 $prometheus = $this->collector->exportPrometheus(); // Counter value should be 1.00 expect($prometheus)->toMatch('/livecomponent_actions_total\{.*\}\s+1\.00/'); }); it('handles zero values correctly', function () { // Create metric and then subtract to get 0 (through reset and re-create) $this->collector->recordRender('test', 0.0); $prometheus = $this->collector->exportPrometheus(); expect($prometheus)->toContain('0.00'); }); }); describe('Timestamp Formatting', function () { it('includes unix timestamp for each metric', function () { $beforeTime = time(); $this->collector->recordRender('test', 50.0); $afterTime = time(); $prometheus = $this->collector->exportPrometheus(); // Extract timestamp from metric line preg_match('/livecomponent_renders_total\{.*\}\s+[\d.]+\s+(\d+)/', $prometheus, $matches); expect($matches)->toHaveKey(1); $timestamp = (int) $matches[1]; // Timestamp should be within test execution time range expect($timestamp)->toBeGreaterThanOrEqual($beforeTime); expect($timestamp)->toBeLessThanOrEqual($afterTime + 1); // +1 for race condition }); it('uses metric timestamp if available', function () { $this->collector->recordRender('test', 50.0); $prometheus = $this->collector->exportPrometheus(); // Timestamp should be present in all metric lines $metricLines = array_filter( explode("\n", $prometheus), fn($line) => !str_starts_with($line, '#') && !empty($line) ); foreach ($metricLines as $line) { expect($line)->toMatch('/\d+$/'); // Line ends with timestamp } }); }); describe('Multiple Metrics Export', function () { it('exports multiple metrics with different labels', function () { $this->collector->recordRender('component-1', 50.0, cached: false); $this->collector->recordRender('component-2', 30.0, cached: true); $this->collector->recordAction('component-1', 'submit', 100.0); $this->collector->recordAction('component-2', 'click', 75.0); $prometheus = $this->collector->exportPrometheus(); // Should have multiple metric lines $metricLines = array_filter( explode("\n", $prometheus), fn($line) => !str_starts_with($line, '#') && !empty($line) ); expect(count($metricLines))->toBeGreaterThan(4); // At least 2 renders + 2 actions (each creates 2 metrics) }); it('exports metrics in consistent format', function () { $this->collector->recordRender('comp-1', 10.0); $this->collector->recordAction('comp-2', 'test', 20.0); $this->collector->recordCacheHit('comp-3', true); $this->collector->recordEventDispatched('comp-4', 'updated'); $prometheus = $this->collector->exportPrometheus(); $metricLines = array_filter( explode("\n", $prometheus), fn($line) => !str_starts_with($line, '#') && !empty($line) ); // All lines should match Prometheus format foreach ($metricLines as $line) { // Format: metric_name{labels} value timestamp expect($line)->toMatch('/^[a-z_]+(\{.*?\})?\s+[\d.]+\s+\d+$/'); } }); }); describe('Special Cases', function () { it('handles empty metrics collection', function () { $prometheus = $this->collector->exportPrometheus(); // Should still have headers even with no metrics expect($prometheus)->toContain('# HELP LiveComponents metrics'); expect($prometheus)->toContain('# TYPE livecomponent_* counter/histogram'); // Should only have headers (2 lines + empty line) $lines = explode("\n", $prometheus); $metricLines = array_filter($lines, fn($line) => !str_starts_with($line, '#') && !empty($line)); expect(count($metricLines))->toBe(0); }); it('handles metrics with complex label combinations', function () { $this->collector->recordAction('cart-component', 'add-to-cart', 150.0, success: true); $this->collector->recordAction('cart-component', 'add-to-cart', 200.0, success: false); $this->collector->recordAction('checkout-component', 'submit-payment', 300.0, success: true); $prometheus = $this->collector->exportPrometheus(); // Should have multiple metrics with different label combinations expect($prometheus)->toContain('component_id="cart-component"'); expect($prometheus)->toContain('action="add-to-cart"'); expect($prometheus)->toContain('status="success"'); expect($prometheus)->toContain('status="error"'); expect($prometheus)->toContain('component_id="checkout-component"'); expect($prometheus)->toContain('action="submit-payment"'); }); it('handles incremental counter updates', function () { // Record same action 3 times $this->collector->recordAction('test', 'click', 10.0); $this->collector->recordAction('test', 'click', 15.0); $this->collector->recordAction('test', 'click', 20.0); $prometheus = $this->collector->exportPrometheus(); // Counter should be incremented to 3 preg_match('/livecomponent_actions_total\{.*\}\s+([\d.]+)/', $prometheus, $matches); expect($matches)->toHaveKey(1); expect((float) $matches[1])->toBe(3.0); }); }); describe('Integration with Metric Types', function () { it('exports all LiveComponent metric types', function () { // Render metrics $this->collector->recordRender('comp', 50.0); // Action metrics $this->collector->recordAction('comp', 'submit', 100.0); // Cache metrics $this->collector->recordCacheHit('comp', true); // Event metrics $this->collector->recordEventDispatched('comp', 'updated'); $this->collector->recordEventReceived('comp', 'refresh'); // Hydration metrics $this->collector->recordHydration('comp', 30.0); // Batch metrics $this->collector->recordBatch(10, 500.0, 8, 2); // Fragment metrics $this->collector->recordFragmentUpdate('comp', 5, 75.0); // Upload metrics $this->collector->recordUploadChunk('session-1', 1, 200.0); $this->collector->recordUploadComplete('session-1', 2000.0, 10); $prometheus = $this->collector->exportPrometheus(); // Verify all metric types are present expect($prometheus)->toContain('livecomponent_renders_total'); expect($prometheus)->toContain('livecomponent_render_duration_ms'); expect($prometheus)->toContain('livecomponent_actions_total'); expect($prometheus)->toContain('livecomponent_action_duration_ms'); expect($prometheus)->toContain('livecomponent_cache_hits_total'); expect($prometheus)->toContain('livecomponent_events_dispatched_total'); expect($prometheus)->toContain('livecomponent_events_received_total'); expect($prometheus)->toContain('livecomponent_hydration_duration_ms'); expect($prometheus)->toContain('livecomponent_batch_operations_total'); expect($prometheus)->toContain('livecomponent_fragment_updates_total'); expect($prometheus)->toContain('livecomponent_upload_chunks_total'); expect($prometheus)->toContain('livecomponent_uploads_completed_total'); }); }); describe('Prometheus Format Compliance', function () { it('follows Prometheus metric naming conventions', function () { $this->collector->recordRender('test', 50.0); $this->collector->recordAction('test', 'click', 100.0); $prometheus = $this->collector->exportPrometheus(); // Metric names should be lowercase with underscores // Should end with _total for counters or _ms for duration histograms $metricLines = array_filter( explode("\n", $prometheus), fn($line) => !str_starts_with($line, '#') && !empty($line) ); foreach ($metricLines as $line) { preg_match('/^([a-z_]+)/', $line, $matches); $metricName = $matches[1] ?? ''; expect($metricName)->toMatch('/^[a-z][a-z0-9_]*$/'); // Counters should end with _total if (str_contains($line, 'renders_total') || str_contains($line, 'actions_total')) { expect($metricName)->toMatch('/_total$/'); } // Duration metrics should end with _ms if (str_contains($line, 'duration')) { expect($metricName)->toMatch('/_ms$/'); } } }); it('uses valid label key naming', function () { $this->collector->recordAction('test', 'submit', 100.0); $prometheus = $this->collector->exportPrometheus(); // Extract label keys preg_match_all('/([a-z_]+)="[^"]*"/', $prometheus, $matches); $labelKeys = $matches[1] ?? []; // Label keys should be lowercase with underscores foreach ($labelKeys as $key) { expect($key)->toMatch('/^[a-z][a-z0-9_]*$/'); } }); it('produces parseable prometheus output', function () { $this->collector->recordRender('test', 50.0); $this->collector->recordAction('test', 'click', 100.0); $prometheus = $this->collector->exportPrometheus(); // Basic structure validation $lines = explode("\n", $prometheus); foreach ($lines as $line) { if (empty($line)) { continue; } // Either comment or metric line if (str_starts_with($line, '#')) { // Comment line - should start with # HELP or # TYPE expect($line)->toMatch('/^#\s+(HELP|TYPE)\s+/'); } else { // Metric line - should have metric name, optional labels, value, timestamp expect($line)->toMatch('/^[a-z_]+(\{.*?\})?\s+[\d.]+\s+\d+$/'); } } }); }); });