feat: Observabilité et monitoring complet

Implémentation complète de la stack d'observabilité pour le monitoring
de la plateforme multi-tenant Classeo.

## Error Tracking (GlitchTip)
- Intégration Sentry SDK avec GlitchTip auto-hébergé
- Scrubber PII avant envoi (RGPD: emails, tokens JWT, NIR français)
- Contexte enrichi: tenant_id, user_id, correlation_id
- Configuration backend (sentry.yaml) et frontend (sentry.ts)

## Metrics (Prometheus)
- Endpoint /metrics avec restriction IP en production
- Métriques HTTP: requests_total, request_duration_seconds (histogramme)
- Métriques sécurité: login_failures_total par tenant
- Métriques santé: health_check_status (postgres, redis, rabbitmq)
- Storage Redis pour persistance entre requêtes

## Logs (Loki)
- Processors Monolog: CorrelationIdLogProcessor, PiiScrubberLogProcessor
- Détection PII: emails, téléphones FR, tokens JWT, NIR français
- Labels structurés: tenant_id, correlation_id, level

## Dashboards (Grafana)
- Dashboard principal: latence P50/P95/P99, error rate, RPS
- Dashboard par tenant: métriques isolées par sous-domaine
- Dashboard infrastructure: santé postgres/redis/rabbitmq
- Datasources avec UIDs fixes pour portabilité

## Alertes (Alertmanager)
- HighApiLatencyP95/P99: SLA monitoring (200ms/500ms)
- HighErrorRate: error rate > 1% pendant 2 min
- ExcessiveLoginFailures: détection brute force
- ApplicationUnhealthy: health check failures

## Infrastructure
- InfrastructureHealthChecker: service partagé (DRY)
- HealthCheckController: endpoint /health pour load balancers
- Pre-push hook: make ci && make e2e avant push
This commit is contained in:
2026-02-04 11:47:01 +01:00
parent 2ed60fdcc1
commit d3c6773be5
48 changed files with 5846 additions and 32 deletions

View File

@@ -0,0 +1,172 @@
<?php
declare(strict_types=1);
namespace App\Tests\Unit\Shared\Infrastructure\Monitoring;
use App\Shared\Infrastructure\Monitoring\HealthMetricsCollectorInterface;
use App\Shared\Infrastructure\Monitoring\MetricsController;
use PHPUnit\Framework\Attributes\CoversClass;
use PHPUnit\Framework\Attributes\Test;
use PHPUnit\Framework\TestCase;
use Prometheus\CollectorRegistry;
use Prometheus\MetricFamilySamples;
use Prometheus\RenderTextFormat;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\HttpKernel\Exception\AccessDeniedHttpException;
/**
* Stub for HealthMetricsCollectorInterface that doesn't make real connections.
*/
final class HealthMetricsCollectorStub implements HealthMetricsCollectorInterface
{
private bool $collected = false;
public function collect(): void
{
$this->collected = true;
}
public function wasCollectCalled(): bool
{
return $this->collected;
}
}
/**
* @see Story 1.8 - T3.3: Expose /metrics endpoint in backend
*/
#[CoversClass(MetricsController::class)]
final class MetricsControllerTest extends TestCase
{
private function createController(
?CollectorRegistry $registry = null,
string $appEnv = 'dev',
): MetricsController {
$registry ??= $this->createMock(CollectorRegistry::class);
$registry->method('getMetricFamilySamples')->willReturn([]);
$healthMetrics = new HealthMetricsCollectorStub();
return new MetricsController($registry, $healthMetrics, $appEnv);
}
#[Test]
public function itReturnsMetricsWithCorrectContentType(): void
{
$controller = $this->createController();
$request = Request::create('/metrics');
$response = $controller($request);
self::assertSame(Response::HTTP_OK, $response->getStatusCode());
self::assertSame(RenderTextFormat::MIME_TYPE, $response->headers->get('Content-Type'));
}
#[Test]
public function itRendersMetricsFromRegistry(): void
{
$sample = new MetricFamilySamples([
'name' => 'test_counter',
'type' => 'counter',
'help' => 'A test counter',
'labelNames' => [],
'samples' => [
[
'name' => 'test_counter',
'labelNames' => [],
'labelValues' => [],
'value' => 42,
],
],
]);
$registry = $this->createMock(CollectorRegistry::class);
$registry->method('getMetricFamilySamples')->willReturn([$sample]);
$healthMetrics = new HealthMetricsCollectorStub();
$controller = new MetricsController($registry, $healthMetrics);
$request = Request::create('/metrics');
$response = $controller($request);
$content = $response->getContent();
self::assertStringContainsString('test_counter', $content);
self::assertStringContainsString('42', $content);
}
#[Test]
public function itReturnsEmptyResponseWhenNoMetrics(): void
{
$controller = $this->createController();
$request = Request::create('/metrics');
$response = $controller($request);
self::assertSame(Response::HTTP_OK, $response->getStatusCode());
self::assertIsString($response->getContent());
}
#[Test]
public function itAllowsInternalIpInProduction(): void
{
$controller = $this->createController(appEnv: 'prod');
$request = Request::create('/metrics', server: ['REMOTE_ADDR' => '172.18.0.5']);
$response = $controller($request);
self::assertSame(Response::HTTP_OK, $response->getStatusCode());
}
#[Test]
public function itBlocksExternalIpInProduction(): void
{
$registry = $this->createMock(CollectorRegistry::class);
$healthMetrics = new HealthMetricsCollectorStub();
$controller = new MetricsController($registry, $healthMetrics, 'prod');
$request = Request::create('/metrics', server: ['REMOTE_ADDR' => '8.8.8.8']);
$this->expectException(AccessDeniedHttpException::class);
$this->expectExceptionMessage('Metrics endpoint is restricted to internal networks.');
$controller($request);
}
#[Test]
public function itAllowsAnyIpInDev(): void
{
$controller = $this->createController(appEnv: 'dev');
$request = Request::create('/metrics', server: ['REMOTE_ADDR' => '8.8.8.8']);
$response = $controller($request);
self::assertSame(Response::HTTP_OK, $response->getStatusCode());
}
#[Test]
public function itAllowsLocalhostInProduction(): void
{
$controller = $this->createController(appEnv: 'prod');
$request = Request::create('/metrics', server: ['REMOTE_ADDR' => '127.0.0.1']);
$response = $controller($request);
self::assertSame(Response::HTTP_OK, $response->getStatusCode());
}
#[Test]
public function itCollectsHealthMetricsBeforeRendering(): void
{
$registry = $this->createMock(CollectorRegistry::class);
$registry->method('getMetricFamilySamples')->willReturn([]);
$healthMetrics = new HealthMetricsCollectorStub();
$controller = new MetricsController($registry, $healthMetrics);
$request = Request::create('/metrics');
$controller($request);
self::assertTrue($healthMetrics->wasCollectCalled());
}
}