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:
@@ -0,0 +1,45 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Shared\Infrastructure\Monitoring;
|
||||
|
||||
use App\Shared\Infrastructure\Audit\CorrelationIdHolder;
|
||||
use App\Shared\Infrastructure\Tenant\TenantContext;
|
||||
use Monolog\LogRecord;
|
||||
use Monolog\Processor\ProcessorInterface;
|
||||
|
||||
/**
|
||||
* Monolog processor that adds correlation_id and tenant_id to all log entries.
|
||||
*
|
||||
* Enables distributed tracing by ensuring every log entry can be correlated
|
||||
* with its originating request, even across async boundaries.
|
||||
*
|
||||
* @see Story 1.8 - T5.2: Processor to add correlation_id automatically
|
||||
* @see Story 1.8 - T5.3: Processor to add tenant_id automatically
|
||||
*/
|
||||
final readonly class CorrelationIdLogProcessor implements ProcessorInterface
|
||||
{
|
||||
public function __construct(
|
||||
private TenantContext $tenantContext,
|
||||
) {
|
||||
}
|
||||
|
||||
public function __invoke(LogRecord $record): LogRecord
|
||||
{
|
||||
$extra = $record->extra;
|
||||
|
||||
// Add correlation ID for distributed tracing
|
||||
$correlationId = CorrelationIdHolder::get();
|
||||
if ($correlationId !== null) {
|
||||
$extra['correlation_id'] = $correlationId->value();
|
||||
}
|
||||
|
||||
// Add tenant ID for multi-tenant filtering (use subdomain for consistency with Prometheus metrics)
|
||||
if ($this->tenantContext->hasTenant()) {
|
||||
$extra['tenant_id'] = $this->tenantContext->getCurrentTenantConfig()->subdomain;
|
||||
}
|
||||
|
||||
return $record->with(extra: $extra);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Shared\Infrastructure\Monitoring;
|
||||
|
||||
use DateTimeImmutable;
|
||||
use DateTimeInterface;
|
||||
|
||||
use function in_array;
|
||||
|
||||
use Symfony\Component\HttpFoundation\JsonResponse;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
use Symfony\Component\Routing\Attribute\Route;
|
||||
|
||||
/**
|
||||
* Health check endpoint for monitoring and load balancers.
|
||||
*
|
||||
* Returns aggregated health status of all critical dependencies.
|
||||
* Used by Grafana and Prometheus for uptime monitoring.
|
||||
*
|
||||
* @see Story 1.8 - T7: Health Check Endpoint (AC: #2)
|
||||
*/
|
||||
#[Route('/health', name: 'health_check', methods: ['GET'])]
|
||||
final readonly class HealthCheckController
|
||||
{
|
||||
public function __construct(
|
||||
private InfrastructureHealthCheckerInterface $healthChecker,
|
||||
) {
|
||||
}
|
||||
|
||||
public function __invoke(): JsonResponse
|
||||
{
|
||||
$checks = $this->healthChecker->checkAll();
|
||||
|
||||
$allHealthy = !in_array(false, $checks, true);
|
||||
$status = $allHealthy ? 'healthy' : 'unhealthy';
|
||||
|
||||
// Return 200 for healthy (instance is operational)
|
||||
// Return 503 when unhealthy (core dependencies are down)
|
||||
$httpStatus = $status === 'unhealthy' ? Response::HTTP_SERVICE_UNAVAILABLE : Response::HTTP_OK;
|
||||
|
||||
return new JsonResponse([
|
||||
'status' => $status,
|
||||
'checks' => $checks,
|
||||
'timestamp' => (new DateTimeImmutable())->format(DateTimeInterface::RFC3339_EXTENDED),
|
||||
], $httpStatus);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Shared\Infrastructure\Monitoring;
|
||||
|
||||
use Prometheus\CollectorRegistry;
|
||||
use Prometheus\Gauge;
|
||||
|
||||
/**
|
||||
* Collects infrastructure health metrics for Prometheus.
|
||||
*
|
||||
* Exposes health_check_status gauge for each service (postgres, redis, rabbitmq).
|
||||
* Values: 1 = healthy, 0 = unhealthy
|
||||
*
|
||||
* These metrics are used by Grafana "Infrastructure Health" dashboard panels.
|
||||
*
|
||||
* @see Story 1.8 - T7: Health Check Endpoint
|
||||
*/
|
||||
final class HealthMetricsCollector implements HealthMetricsCollectorInterface
|
||||
{
|
||||
private const string NAMESPACE = 'classeo';
|
||||
|
||||
private Gauge $healthStatus;
|
||||
|
||||
public function __construct(
|
||||
private readonly CollectorRegistry $registry,
|
||||
private readonly InfrastructureHealthCheckerInterface $healthChecker,
|
||||
) {
|
||||
$this->healthStatus = $this->registry->getOrRegisterGauge(
|
||||
self::NAMESPACE,
|
||||
'health_check_status',
|
||||
'Health status of infrastructure services (1=healthy, 0=unhealthy)',
|
||||
['service'],
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Update all health metrics.
|
||||
*
|
||||
* Called before rendering metrics to ensure fresh health status.
|
||||
*/
|
||||
public function collect(): void
|
||||
{
|
||||
$checks = $this->healthChecker->checkAll();
|
||||
|
||||
foreach ($checks as $service => $isHealthy) {
|
||||
$this->healthStatus->set($isHealthy ? 1.0 : 0.0, [$service]);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Shared\Infrastructure\Monitoring;
|
||||
|
||||
/**
|
||||
* Interface for health metrics collection.
|
||||
*
|
||||
* Allows testing MetricsController without depending on final HealthMetricsCollector.
|
||||
*/
|
||||
interface HealthMetricsCollectorInterface
|
||||
{
|
||||
/**
|
||||
* Update all health metrics.
|
||||
*
|
||||
* Called before rendering metrics to ensure fresh health status.
|
||||
*/
|
||||
public function collect(): void;
|
||||
}
|
||||
@@ -0,0 +1,97 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Shared\Infrastructure\Monitoring;
|
||||
|
||||
use Doctrine\DBAL\Connection;
|
||||
use Redis;
|
||||
use Symfony\Contracts\HttpClient\HttpClientInterface;
|
||||
use Throwable;
|
||||
|
||||
/**
|
||||
* Centralized infrastructure health checking service.
|
||||
*
|
||||
* Used by both HealthCheckController and HealthMetricsCollector
|
||||
* to avoid code duplication (DRY principle).
|
||||
*
|
||||
* @see Story 1.8 - T7: Health Check Endpoint
|
||||
*/
|
||||
final readonly class InfrastructureHealthChecker implements InfrastructureHealthCheckerInterface
|
||||
{
|
||||
public function __construct(
|
||||
private Connection $connection,
|
||||
private HttpClientInterface $httpClient,
|
||||
private string $redisUrl = 'redis://redis:6379',
|
||||
private string $rabbitmqManagementUrl = 'http://rabbitmq:15672',
|
||||
private string $rabbitmqUser = 'guest',
|
||||
private string $rabbitmqPassword = 'guest',
|
||||
) {
|
||||
}
|
||||
|
||||
public function checkPostgres(): bool
|
||||
{
|
||||
try {
|
||||
$this->connection->executeQuery('SELECT 1');
|
||||
|
||||
return true;
|
||||
} catch (Throwable) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
public function checkRedis(): bool
|
||||
{
|
||||
try {
|
||||
$parsed = parse_url($this->redisUrl);
|
||||
$host = $parsed['host'] ?? 'redis';
|
||||
$port = $parsed['port'] ?? 6379;
|
||||
|
||||
$redis = new Redis();
|
||||
$redis->connect($host, $port, 2.0); // 2 second timeout
|
||||
|
||||
$pong = $redis->ping();
|
||||
$redis->close();
|
||||
|
||||
return $pong === true || $pong === '+PONG' || $pong === 'PONG';
|
||||
} catch (Throwable) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
public function checkRabbitMQ(): bool
|
||||
{
|
||||
try {
|
||||
// Check RabbitMQ via management API health check endpoint
|
||||
$response = $this->httpClient->request('GET', $this->rabbitmqManagementUrl . '/api/health/checks/alarms', [
|
||||
'auth_basic' => [$this->rabbitmqUser, $this->rabbitmqPassword],
|
||||
'timeout' => 2,
|
||||
]);
|
||||
|
||||
if ($response->getStatusCode() !== 200) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$data = $response->toArray();
|
||||
|
||||
// RabbitMQ returns {"status":"ok"} when healthy
|
||||
return ($data['status'] ?? '') === 'ok';
|
||||
} catch (Throwable) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check all services and return aggregated status.
|
||||
*
|
||||
* @return array{postgres: bool, redis: bool, rabbitmq: bool}
|
||||
*/
|
||||
public function checkAll(): array
|
||||
{
|
||||
return [
|
||||
'postgres' => $this->checkPostgres(),
|
||||
'redis' => $this->checkRedis(),
|
||||
'rabbitmq' => $this->checkRabbitMQ(),
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Shared\Infrastructure\Monitoring;
|
||||
|
||||
/**
|
||||
* Interface for infrastructure health checking.
|
||||
*
|
||||
* Allows testing controllers that depend on health checks without
|
||||
* requiring real infrastructure connections.
|
||||
*/
|
||||
interface InfrastructureHealthCheckerInterface
|
||||
{
|
||||
public function checkPostgres(): bool;
|
||||
|
||||
public function checkRedis(): bool;
|
||||
|
||||
public function checkRabbitMQ(): bool;
|
||||
|
||||
/**
|
||||
* Check all services and return aggregated status.
|
||||
*
|
||||
* @return array{postgres: bool, redis: bool, rabbitmq: bool}
|
||||
*/
|
||||
public function checkAll(): array;
|
||||
}
|
||||
@@ -0,0 +1,129 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Shared\Infrastructure\Monitoring;
|
||||
|
||||
use App\Shared\Infrastructure\Tenant\TenantContext;
|
||||
|
||||
use function in_array;
|
||||
use function is_string;
|
||||
|
||||
use Prometheus\CollectorRegistry;
|
||||
use Prometheus\Counter;
|
||||
use Prometheus\Histogram;
|
||||
use Symfony\Component\EventDispatcher\Attribute\AsEventListener;
|
||||
use Symfony\Component\HttpKernel\Event\RequestEvent;
|
||||
use Symfony\Component\HttpKernel\Event\TerminateEvent;
|
||||
use Symfony\Component\HttpKernel\KernelEvents;
|
||||
|
||||
/**
|
||||
* Collects HTTP request metrics for Prometheus.
|
||||
*
|
||||
* Tracks request latency (P50, P95, P99), error rates, and requests per second.
|
||||
* Metrics are labeled by tenant for multi-tenant analysis.
|
||||
*
|
||||
* @see Story 1.8 - T3.4: Custom metrics (requests_total, request_duration_seconds)
|
||||
*/
|
||||
final class MetricsCollector
|
||||
{
|
||||
private const string NAMESPACE = 'classeo';
|
||||
|
||||
private Counter $requestsTotal;
|
||||
private Histogram $requestDuration;
|
||||
private Counter $loginFailures;
|
||||
private ?float $requestStartTime = null;
|
||||
|
||||
public function __construct(
|
||||
private readonly CollectorRegistry $registry,
|
||||
private readonly TenantContext $tenantContext,
|
||||
) {
|
||||
$this->initializeMetrics();
|
||||
}
|
||||
|
||||
private function initializeMetrics(): void
|
||||
{
|
||||
$this->requestsTotal = $this->registry->getOrRegisterCounter(
|
||||
self::NAMESPACE,
|
||||
'http_requests_total',
|
||||
'Total number of HTTP requests',
|
||||
['method', 'route', 'status', 'tenant_id'],
|
||||
);
|
||||
|
||||
$this->requestDuration = $this->registry->getOrRegisterHistogram(
|
||||
self::NAMESPACE,
|
||||
'http_request_duration_seconds',
|
||||
'HTTP request duration in seconds',
|
||||
['method', 'route', 'tenant_id'],
|
||||
// Buckets optimized for SLA monitoring (P95 < 200ms, P99 < 500ms)
|
||||
[0.005, 0.01, 0.025, 0.05, 0.075, 0.1, 0.15, 0.2, 0.25, 0.3, 0.4, 0.5, 0.75, 1.0, 2.5, 5.0, 10.0],
|
||||
);
|
||||
|
||||
$this->loginFailures = $this->registry->getOrRegisterCounter(
|
||||
self::NAMESPACE,
|
||||
'login_failures_total',
|
||||
'Total number of failed login attempts',
|
||||
['tenant_id', 'reason'],
|
||||
);
|
||||
}
|
||||
|
||||
#[AsEventListener(event: KernelEvents::REQUEST, priority: 1024)]
|
||||
public function onKernelRequest(RequestEvent $event): void
|
||||
{
|
||||
if (!$event->isMainRequest()) {
|
||||
return;
|
||||
}
|
||||
|
||||
$this->requestStartTime = microtime(true);
|
||||
}
|
||||
|
||||
#[AsEventListener(event: KernelEvents::TERMINATE, priority: 1024)]
|
||||
public function onKernelTerminate(TerminateEvent $event): void
|
||||
{
|
||||
if ($this->requestStartTime === null) {
|
||||
return;
|
||||
}
|
||||
|
||||
$request = $event->getRequest();
|
||||
$response = $event->getResponse();
|
||||
|
||||
// Skip metrics endpoints to avoid self-referential noise
|
||||
$routeValue = $request->attributes->get('_route', 'unknown');
|
||||
$route = is_string($routeValue) ? $routeValue : 'unknown';
|
||||
if (in_array($route, ['prometheus_metrics', 'health_check'], true)) {
|
||||
$this->requestStartTime = null;
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$method = $request->getMethod();
|
||||
$status = (string) $response->getStatusCode();
|
||||
$tenantId = $this->tenantContext->hasTenant()
|
||||
? $this->tenantContext->getCurrentTenantConfig()->subdomain
|
||||
: 'none';
|
||||
|
||||
$duration = microtime(true) - $this->requestStartTime;
|
||||
|
||||
// Record request count
|
||||
$this->requestsTotal->inc([$method, $route, $status, $tenantId]);
|
||||
|
||||
// Record request duration
|
||||
$this->requestDuration->observe($duration, [$method, $route, $tenantId]);
|
||||
|
||||
$this->requestStartTime = null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Record a failed login attempt.
|
||||
*
|
||||
* Called by the authentication system to track brute force attempts.
|
||||
*/
|
||||
public function recordLoginFailure(string $reason = 'invalid_credentials'): void
|
||||
{
|
||||
$tenantId = $this->tenantContext->hasTenant()
|
||||
? $this->tenantContext->getCurrentTenantConfig()->subdomain
|
||||
: 'none';
|
||||
|
||||
$this->loginFailures->inc([$tenantId, $reason]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Shared\Infrastructure\Monitoring;
|
||||
|
||||
use Prometheus\CollectorRegistry;
|
||||
use Prometheus\RenderTextFormat;
|
||||
use Symfony\Component\HttpFoundation\Request;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
use Symfony\Component\HttpKernel\Exception\AccessDeniedHttpException;
|
||||
use Symfony\Component\Routing\Attribute\Route;
|
||||
|
||||
/**
|
||||
* Exposes Prometheus metrics endpoint.
|
||||
*
|
||||
* Collects and exposes application metrics for Prometheus scraping.
|
||||
* Metrics include request latency, error rates, and custom business metrics.
|
||||
*
|
||||
* Security: In production, this endpoint is restricted to internal Docker network IPs.
|
||||
* For additional security, configure your reverse proxy (nginx/traefik) to block
|
||||
* external access to /metrics.
|
||||
*
|
||||
* @see Story 1.8 - T3.3: Expose /metrics endpoint in backend
|
||||
*/
|
||||
#[Route('/metrics', name: 'prometheus_metrics', methods: ['GET'])]
|
||||
final readonly class MetricsController
|
||||
{
|
||||
/**
|
||||
* Internal network CIDR ranges allowed to access metrics.
|
||||
*/
|
||||
private const array ALLOWED_NETWORKS = [
|
||||
'10.0.0.0/8',
|
||||
'172.16.0.0/12',
|
||||
'192.168.0.0/16',
|
||||
'127.0.0.0/8',
|
||||
];
|
||||
|
||||
public function __construct(
|
||||
private CollectorRegistry $registry,
|
||||
private HealthMetricsCollectorInterface $healthMetrics,
|
||||
private string $appEnv = 'dev',
|
||||
) {
|
||||
}
|
||||
|
||||
public function __invoke(Request $request): Response
|
||||
{
|
||||
// In production, restrict to internal networks only
|
||||
if ($this->appEnv === 'prod' && !$this->isInternalRequest($request)) {
|
||||
throw new AccessDeniedHttpException('Metrics endpoint is restricted to internal networks.');
|
||||
}
|
||||
|
||||
// Collect fresh health metrics before rendering
|
||||
$this->healthMetrics->collect();
|
||||
|
||||
$renderer = new RenderTextFormat();
|
||||
$metrics = $renderer->render($this->registry->getMetricFamilySamples());
|
||||
|
||||
return new Response(
|
||||
$metrics,
|
||||
Response::HTTP_OK,
|
||||
['Content-Type' => RenderTextFormat::MIME_TYPE],
|
||||
);
|
||||
}
|
||||
|
||||
private function isInternalRequest(Request $request): bool
|
||||
{
|
||||
$clientIp = $request->getClientIp();
|
||||
if ($clientIp === null) {
|
||||
return false;
|
||||
}
|
||||
|
||||
foreach (self::ALLOWED_NETWORKS as $network) {
|
||||
if ($this->ipInRange($clientIp, $network)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
private function ipInRange(string $ip, string $cidr): bool
|
||||
{
|
||||
[$subnet, $bits] = explode('/', $cidr);
|
||||
$ip = ip2long($ip);
|
||||
$subnet = ip2long($subnet);
|
||||
$mask = -1 << (32 - (int) $bits);
|
||||
$subnet &= $mask;
|
||||
|
||||
return ($ip & $mask) === $subnet;
|
||||
}
|
||||
}
|
||||
95
backend/src/Shared/Infrastructure/Monitoring/PiiPatterns.php
Normal file
95
backend/src/Shared/Infrastructure/Monitoring/PiiPatterns.php
Normal file
@@ -0,0 +1,95 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Shared\Infrastructure\Monitoring;
|
||||
|
||||
/**
|
||||
* Centralized PII (Personally Identifiable Information) patterns.
|
||||
*
|
||||
* Used by both log processors and error tracking to ensure consistent
|
||||
* PII filtering across all observability systems (RGPD compliance).
|
||||
*
|
||||
* @see Story 1.8 - NFR-OB3: RGPD compliance in logs and error reports
|
||||
*/
|
||||
final class PiiPatterns
|
||||
{
|
||||
/**
|
||||
* Keys that may contain PII and should be redacted.
|
||||
*/
|
||||
public const array SENSITIVE_KEYS = [
|
||||
// Authentication
|
||||
'password',
|
||||
'token',
|
||||
'secret',
|
||||
'key',
|
||||
'authorization',
|
||||
'cookie',
|
||||
'session',
|
||||
'refresh_token',
|
||||
'access_token',
|
||||
|
||||
// Personal data
|
||||
'email',
|
||||
'phone',
|
||||
'address',
|
||||
'ip',
|
||||
'user_agent',
|
||||
|
||||
// French field names
|
||||
'nom',
|
||||
'prenom',
|
||||
'adresse',
|
||||
'telephone',
|
||||
'nir',
|
||||
'numero_securite_sociale',
|
||||
'securite_sociale',
|
||||
|
||||
// English field names
|
||||
'name',
|
||||
'firstname',
|
||||
'lastname',
|
||||
'first_name',
|
||||
'last_name',
|
||||
];
|
||||
|
||||
/**
|
||||
* Regex patterns that indicate PII in values.
|
||||
*/
|
||||
public const array VALUE_PATTERNS = [
|
||||
'/[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}/', // Email
|
||||
'/\b\d{10,}\b/', // Phone numbers
|
||||
'/\b\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3}\b/', // IPv4
|
||||
'/\b[12]\d{2}(0[1-9]|1[0-2])\d{2}\d{3}\d{3}\d{2}\b/', // French NIR (numéro de sécurité sociale)
|
||||
];
|
||||
|
||||
/**
|
||||
* Check if a key name suggests it contains PII.
|
||||
*/
|
||||
public static function isSensitiveKey(string $key): bool
|
||||
{
|
||||
$normalizedKey = strtolower($key);
|
||||
|
||||
foreach (self::SENSITIVE_KEYS as $pattern) {
|
||||
if (str_contains($normalizedKey, $pattern)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a value matches PII patterns.
|
||||
*/
|
||||
public static function containsPii(string $value): bool
|
||||
{
|
||||
foreach (self::VALUE_PATTERNS as $pattern) {
|
||||
if (preg_match($pattern, $value)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Shared\Infrastructure\Monitoring;
|
||||
|
||||
use const FILTER_VALIDATE_EMAIL;
|
||||
|
||||
use function is_array;
|
||||
use function is_string;
|
||||
|
||||
use Monolog\LogRecord;
|
||||
use Monolog\Processor\ProcessorInterface;
|
||||
|
||||
/**
|
||||
* Monolog processor that scrubs PII from log entries before sending to Loki.
|
||||
*
|
||||
* Critical for RGPD compliance (NFR-S3): No personal data in centralized logs.
|
||||
*
|
||||
* @see Story 1.8 - T5.4: Filter PII from logs (scrubber processor)
|
||||
*/
|
||||
final class PiiScrubberLogProcessor implements ProcessorInterface
|
||||
{
|
||||
public function __invoke(LogRecord $record): LogRecord
|
||||
{
|
||||
$context = $this->scrubArray($record->context);
|
||||
$extra = $this->scrubArray($record->extra);
|
||||
|
||||
return $record->with(context: $context, extra: $extra);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<array-key, mixed> $data
|
||||
*
|
||||
* @return array<array-key, mixed>
|
||||
*/
|
||||
private function scrubArray(array $data): array
|
||||
{
|
||||
$result = [];
|
||||
|
||||
foreach ($data as $key => $value) {
|
||||
if (is_string($key) && $this->isPiiKey($key)) {
|
||||
$result[$key] = '[REDACTED]';
|
||||
} elseif (is_array($value)) {
|
||||
$result[$key] = $this->scrubArray($value);
|
||||
} elseif (is_string($value) && $this->looksLikePii($value)) {
|
||||
$result[$key] = '[REDACTED]';
|
||||
} else {
|
||||
$result[$key] = $value;
|
||||
}
|
||||
}
|
||||
|
||||
return $result;
|
||||
}
|
||||
|
||||
private function isPiiKey(string $key): bool
|
||||
{
|
||||
return PiiPatterns::isSensitiveKey($key);
|
||||
}
|
||||
|
||||
private function looksLikePii(string $value): bool
|
||||
{
|
||||
// Filter email addresses
|
||||
if (filter_var($value, FILTER_VALIDATE_EMAIL) !== false) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Filter JWT tokens
|
||||
if (preg_match('/^eyJ[a-zA-Z0-9_-]+\.eyJ[a-zA-Z0-9_-]+\.[a-zA-Z0-9_-]+$/', $value)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Filter French phone numbers
|
||||
if (preg_match('/^(?:\+33|0)[1-9](?:[0-9]{2}){4}$/', preg_replace('/\s/', '', $value) ?? '')) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Filter French NIR (numéro de sécurité sociale) - RGPD critical
|
||||
$cleanValue = preg_replace('/[\s.-]/', '', $value) ?? '';
|
||||
if (preg_match('/^[12]\d{2}(0[1-9]|1[0-2])\d{2}\d{3}\d{3}\d{2}$/', $cleanValue)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Shared\Infrastructure\Monitoring;
|
||||
|
||||
use Prometheus\Storage\Redis;
|
||||
|
||||
/**
|
||||
* Factory to create Prometheus Redis storage from REDIS_URL.
|
||||
*/
|
||||
final readonly class PrometheusStorageFactory
|
||||
{
|
||||
public static function createRedisStorage(string $redisUrl): Redis
|
||||
{
|
||||
$parsed = parse_url($redisUrl);
|
||||
|
||||
return new Redis([
|
||||
'host' => $parsed['host'] ?? 'redis',
|
||||
'port' => $parsed['port'] ?? 6379,
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,106 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Shared\Infrastructure\Monitoring;
|
||||
|
||||
use const FILTER_VALIDATE_EMAIL;
|
||||
|
||||
use function is_array;
|
||||
use function is_string;
|
||||
|
||||
use Sentry\Event;
|
||||
use Sentry\EventHint;
|
||||
|
||||
/**
|
||||
* Scrubs PII from Sentry events before sending to GlitchTip.
|
||||
*
|
||||
* Critical for RGPD compliance (NFR-S3): No personal data in error reports.
|
||||
* This callback runs as the last step before sending to the error tracking service.
|
||||
*
|
||||
* @see Story 1.8 - T1.4: Filter PII before send (scrubber)
|
||||
*/
|
||||
final class SentryBeforeSendCallback
|
||||
{
|
||||
public function __invoke(Event $event, ?EventHint $hint): Event
|
||||
{
|
||||
// Scrub request data
|
||||
$request = $event->getRequest();
|
||||
if (!empty($request)) {
|
||||
$this->scrubArray($request);
|
||||
$event->setRequest($request);
|
||||
}
|
||||
|
||||
// Scrub extra context
|
||||
$extra = $event->getExtra();
|
||||
if (!empty($extra)) {
|
||||
$this->scrubArray($extra);
|
||||
$event->setExtra($extra);
|
||||
}
|
||||
|
||||
// Scrub tags that might contain PII
|
||||
$tags = $event->getTags();
|
||||
if (!empty($tags)) {
|
||||
$this->scrubStringArray($tags);
|
||||
$event->setTags($tags);
|
||||
}
|
||||
|
||||
// Never drop the event - we want all errors tracked
|
||||
return $event;
|
||||
}
|
||||
|
||||
/**
|
||||
* Recursively scrub PII from an array.
|
||||
*
|
||||
* @param array<array-key, mixed> $data
|
||||
*/
|
||||
private function scrubArray(array &$data): void
|
||||
{
|
||||
foreach ($data as $key => &$value) {
|
||||
if (is_string($key) && $this->isPiiKey($key)) {
|
||||
$value = '[FILTERED]';
|
||||
} elseif (is_array($value)) {
|
||||
$this->scrubArray($value);
|
||||
} elseif (is_string($value) && $this->looksLikePii($value)) {
|
||||
$value = '[FILTERED]';
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Scrub PII from a string-only array (tags).
|
||||
*
|
||||
* @param array<string, string> $data
|
||||
*/
|
||||
private function scrubStringArray(array &$data): void
|
||||
{
|
||||
foreach ($data as $key => &$value) {
|
||||
if ($this->isPiiKey($key) || $this->looksLikePii($value)) {
|
||||
$value = '[FILTERED]';
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private function isPiiKey(string $key): bool
|
||||
{
|
||||
return PiiPatterns::isSensitiveKey($key);
|
||||
}
|
||||
|
||||
private function looksLikePii(string $value): bool
|
||||
{
|
||||
// Filter email-like patterns
|
||||
if (filter_var($value, FILTER_VALIDATE_EMAIL) !== false) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Filter JWT tokens
|
||||
if (preg_match('/^eyJ[a-zA-Z0-9_-]+\.eyJ[a-zA-Z0-9_-]+\.[a-zA-Z0-9_-]+$/', $value)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Filter UUIDs in specific contexts (but not all - some are legitimate IDs)
|
||||
// We keep UUIDs as they're often needed for debugging
|
||||
|
||||
return false;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Shared\Infrastructure\Monitoring;
|
||||
|
||||
use App\Shared\Infrastructure\Audit\CorrelationIdHolder;
|
||||
use App\Shared\Infrastructure\Tenant\TenantContext;
|
||||
use Sentry\State\HubInterface;
|
||||
use Sentry\State\Scope;
|
||||
use Sentry\UserDataBag;
|
||||
use Symfony\Bundle\SecurityBundle\Security;
|
||||
use Symfony\Component\EventDispatcher\Attribute\AsEventListener;
|
||||
use Symfony\Component\HttpKernel\Event\ControllerEvent;
|
||||
use Symfony\Component\HttpKernel\KernelEvents;
|
||||
|
||||
/**
|
||||
* Enriches Sentry error reports with tenant, user, and correlation context.
|
||||
*
|
||||
* Runs after authentication so user context is available.
|
||||
* Critical: Filters PII before sending to GlitchTip (NFR-S3 compliance).
|
||||
*/
|
||||
final readonly class SentryContextEnricher
|
||||
{
|
||||
public function __construct(
|
||||
private HubInterface $sentryHub,
|
||||
private TenantContext $tenantContext,
|
||||
private Security $security,
|
||||
) {
|
||||
}
|
||||
|
||||
/**
|
||||
* Enrich Sentry scope with request context.
|
||||
*
|
||||
* Uses CONTROLLER event (after firewall) so user context is available.
|
||||
* Correlation ID and tenant are already resolved by their respective middlewares.
|
||||
*/
|
||||
#[AsEventListener(event: KernelEvents::CONTROLLER, priority: -100)]
|
||||
public function onKernelController(ControllerEvent $event): void
|
||||
{
|
||||
if (!$event->isMainRequest()) {
|
||||
return;
|
||||
}
|
||||
|
||||
$this->sentryHub->configureScope(function (Scope $scope): void {
|
||||
// Add correlation ID for distributed tracing
|
||||
$correlationId = CorrelationIdHolder::get();
|
||||
if ($correlationId !== null) {
|
||||
$scope->setTag('correlation_id', $correlationId->value());
|
||||
}
|
||||
|
||||
// Add tenant context (use subdomain for consistency with metrics)
|
||||
if ($this->tenantContext->hasTenant()) {
|
||||
$scope->setTag('tenant_id', $this->tenantContext->getCurrentTenantConfig()->subdomain);
|
||||
}
|
||||
|
||||
// Add user context (ID only - no PII)
|
||||
$user = $this->security->getUser();
|
||||
if ($user !== null) {
|
||||
// Only send user ID, never email or username (RGPD compliance)
|
||||
$scope->setUser(new UserDataBag(
|
||||
id: method_exists($user, 'getId') ? (string) $user->getId() : null,
|
||||
));
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -39,6 +39,8 @@ final readonly class TenantMiddleware implements EventSubscriberInterface
|
||||
'/_profiler',
|
||||
'/_wdt',
|
||||
'/_error',
|
||||
'/health',
|
||||
'/metrics',
|
||||
];
|
||||
|
||||
public function onKernelRequest(RequestEvent $event): void
|
||||
@@ -49,16 +51,17 @@ final readonly class TenantMiddleware implements EventSubscriberInterface
|
||||
|
||||
$request = $event->getRequest();
|
||||
$path = $request->getPathInfo();
|
||||
$host = $request->getHost();
|
||||
|
||||
// Skip tenant resolution for public paths (docs, profiler, etc.)
|
||||
// Check if this is a public path (docs, profiler, login, etc.)
|
||||
$isPublicPath = false;
|
||||
foreach (self::PUBLIC_PATHS as $publicPath) {
|
||||
if (str_starts_with($path, $publicPath)) {
|
||||
return;
|
||||
$isPublicPath = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
$host = $request->getHost();
|
||||
|
||||
try {
|
||||
$config = $this->resolver->resolve($host);
|
||||
$this->context->setCurrentTenant($config);
|
||||
@@ -66,7 +69,12 @@ final readonly class TenantMiddleware implements EventSubscriberInterface
|
||||
// Store tenant config in request for easy access
|
||||
$request->attributes->set('_tenant', $config);
|
||||
} catch (TenantNotFoundException) {
|
||||
// Return 404 with generic message - DO NOT reveal tenant existence
|
||||
// For public paths, allow requests without tenant context (metrics will show "none")
|
||||
if ($isPublicPath) {
|
||||
return;
|
||||
}
|
||||
|
||||
// For protected paths, return 404 with generic message - DO NOT reveal tenant existence
|
||||
$response = new JsonResponse(
|
||||
[
|
||||
'status' => Response::HTTP_NOT_FOUND,
|
||||
|
||||
Reference in New Issue
Block a user