Files
michaelschiemer/tests/Unit/Framework/LiveComponents/Observability/ComponentMetricsExportTest.php
Michael Schiemer fc3d7e6357 feat(Production): Complete production deployment infrastructure
- Add comprehensive health check system with multiple endpoints
- Add Prometheus metrics endpoint
- Add production logging configurations (5 strategies)
- Add complete deployment documentation suite:
  * QUICKSTART.md - 30-minute deployment guide
  * DEPLOYMENT_CHECKLIST.md - Printable verification checklist
  * DEPLOYMENT_WORKFLOW.md - Complete deployment lifecycle
  * PRODUCTION_DEPLOYMENT.md - Comprehensive technical reference
  * production-logging.md - Logging configuration guide
  * ANSIBLE_DEPLOYMENT.md - Infrastructure as Code automation
  * README.md - Navigation hub
  * DEPLOYMENT_SUMMARY.md - Executive summary
- Add deployment scripts and automation
- Add DEPLOYMENT_PLAN.md - Concrete plan for immediate deployment
- Update README with production-ready features

All production infrastructure is now complete and ready for deployment.
2025-10-25 19:18:37 +02:00

407 lines
17 KiB
PHP

<?php
declare(strict_types=1);
use App\Framework\LiveComponents\Observability\ComponentMetricsCollector;
use App\Framework\Core\ValueObjects\Timestamp;
describe('ComponentMetricsCollector - Prometheus Export Validation', function () {
beforeEach(function () {
$this->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+$/');
}
}
});
});
});