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

@@ -8,6 +8,7 @@ use App\Administration\Domain\Event\CompteBloqueTemporairement;
use App\Administration\Domain\Event\ConnexionEchouee;
use App\Shared\Domain\Clock;
use App\Shared\Domain\Tenant\TenantId;
use App\Shared\Infrastructure\Monitoring\MetricsCollector;
use App\Shared\Infrastructure\RateLimit\LoginRateLimiterInterface;
use App\Shared\Infrastructure\RateLimit\LoginRateLimitResult;
use App\Shared\Infrastructure\Tenant\TenantResolver;
@@ -41,6 +42,7 @@ final readonly class LoginFailureHandler implements AuthenticationFailureHandler
private MessageBusInterface $eventBus,
private Clock $clock,
private TenantResolver $tenantResolver,
private MetricsCollector $metricsCollector,
) {
}
@@ -68,6 +70,9 @@ final readonly class LoginFailureHandler implements AuthenticationFailureHandler
occurredOn: $this->clock->now(),
));
// Record metric for Prometheus alerting
$this->metricsCollector->recordLoginFailure('invalid_credentials');
// If the IP was just blocked
if ($result->ipBlocked) {
$this->eventBus->dispatch(new CompteBloqueTemporairement(

View File

@@ -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);
}
}

View File

@@ -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);
}
}

View File

@@ -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]);
}
}
}

View File

@@ -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;
}

View File

@@ -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(),
];
}
}

View File

@@ -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;
}

View File

@@ -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]);
}
}

View File

@@ -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;
}
}

View 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;
}
}

View File

@@ -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;
}
}

View File

@@ -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,
]);
}
}

View File

@@ -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;
}
}

View File

@@ -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,
));
}
});
}
}

View File

@@ -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,