- 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.
407 lines
17 KiB
PHP
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+$/');
|
|
}
|
|
}
|
|
});
|
|
});
|
|
});
|