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

@@ -89,3 +89,11 @@ TURNSTILE_FAIL_OPEN=true
# postgresql+advisory://db_user:db_password@localhost/db_name
LOCK_DSN=flock
###< symfony/lock ###
###> sentry/sentry-symfony ###
# GlitchTip DSN for error tracking (Sentry-compatible)
# Set this after creating a project in GlitchTip UI at http://localhost:8081
SENTRY_DSN=
# Environment label for error reports
SENTRY_ENVIRONMENT=development
###< sentry/sentry-symfony ###

View File

@@ -17,7 +17,9 @@
"doctrine/orm": "^3.3",
"lexik/jwt-authentication-bundle": "^3.2",
"nelmio/cors-bundle": "^2.6",
"promphp/prometheus_client_php": "^2.14",
"ramsey/uuid": "^4.7",
"sentry/sentry-symfony": "^5.8",
"symfony/amqp-messenger": "^8.0",
"symfony/asset": "^8.0",
"symfony/console": "^8.0",

675
backend/composer.lock generated
View File

@@ -4,7 +4,7 @@
"Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
"This file is @generated automatically"
],
"content-hash": "ff0834d39a673e5aea0d0d8fde04c9b0",
"content-hash": "fb9fd4887621a91ef8635fd6092e53b2",
"packages": [
{
"name": "api-platform/core",
@@ -1457,6 +1457,182 @@
],
"time": "2025-03-06T22:45:56+00:00"
},
{
"name": "guzzlehttp/psr7",
"version": "2.8.0",
"source": {
"type": "git",
"url": "https://github.com/guzzle/psr7.git",
"reference": "21dc724a0583619cd1652f673303492272778051"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/guzzle/psr7/zipball/21dc724a0583619cd1652f673303492272778051",
"reference": "21dc724a0583619cd1652f673303492272778051",
"shasum": ""
},
"require": {
"php": "^7.2.5 || ^8.0",
"psr/http-factory": "^1.0",
"psr/http-message": "^1.1 || ^2.0",
"ralouphie/getallheaders": "^3.0"
},
"provide": {
"psr/http-factory-implementation": "1.0",
"psr/http-message-implementation": "1.0"
},
"require-dev": {
"bamarni/composer-bin-plugin": "^1.8.2",
"http-interop/http-factory-tests": "0.9.0",
"phpunit/phpunit": "^8.5.44 || ^9.6.25"
},
"suggest": {
"laminas/laminas-httphandlerrunner": "Emit PSR-7 responses"
},
"type": "library",
"extra": {
"bamarni-bin": {
"bin-links": true,
"forward-command": false
}
},
"autoload": {
"psr-4": {
"GuzzleHttp\\Psr7\\": "src/"
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"authors": [
{
"name": "Graham Campbell",
"email": "hello@gjcampbell.co.uk",
"homepage": "https://github.com/GrahamCampbell"
},
{
"name": "Michael Dowling",
"email": "mtdowling@gmail.com",
"homepage": "https://github.com/mtdowling"
},
{
"name": "George Mponos",
"email": "gmponos@gmail.com",
"homepage": "https://github.com/gmponos"
},
{
"name": "Tobias Nyholm",
"email": "tobias.nyholm@gmail.com",
"homepage": "https://github.com/Nyholm"
},
{
"name": "Márk Sági-Kazár",
"email": "mark.sagikazar@gmail.com",
"homepage": "https://github.com/sagikazarmark"
},
{
"name": "Tobias Schultze",
"email": "webmaster@tubo-world.de",
"homepage": "https://github.com/Tobion"
},
{
"name": "Márk Sági-Kazár",
"email": "mark.sagikazar@gmail.com",
"homepage": "https://sagikazarmark.hu"
}
],
"description": "PSR-7 message implementation that also provides common utility methods",
"keywords": [
"http",
"message",
"psr-7",
"request",
"response",
"stream",
"uri",
"url"
],
"support": {
"issues": "https://github.com/guzzle/psr7/issues",
"source": "https://github.com/guzzle/psr7/tree/2.8.0"
},
"funding": [
{
"url": "https://github.com/GrahamCampbell",
"type": "github"
},
{
"url": "https://github.com/Nyholm",
"type": "github"
},
{
"url": "https://tidelift.com/funding/github/packagist/guzzlehttp/psr7",
"type": "tidelift"
}
],
"time": "2025-08-23T21:21:41+00:00"
},
{
"name": "jean85/pretty-package-versions",
"version": "2.1.1",
"source": {
"type": "git",
"url": "https://github.com/Jean85/pretty-package-versions.git",
"reference": "4d7aa5dab42e2a76d99559706022885de0e18e1a"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/Jean85/pretty-package-versions/zipball/4d7aa5dab42e2a76d99559706022885de0e18e1a",
"reference": "4d7aa5dab42e2a76d99559706022885de0e18e1a",
"shasum": ""
},
"require": {
"composer-runtime-api": "^2.1.0",
"php": "^7.4|^8.0"
},
"require-dev": {
"friendsofphp/php-cs-fixer": "^3.2",
"jean85/composer-provided-replaced-stub-package": "^1.0",
"phpstan/phpstan": "^2.0",
"phpunit/phpunit": "^7.5|^8.5|^9.6",
"rector/rector": "^2.0",
"vimeo/psalm": "^4.3 || ^5.0"
},
"type": "library",
"extra": {
"branch-alias": {
"dev-master": "1.x-dev"
}
},
"autoload": {
"psr-4": {
"Jean85\\": "src/"
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"authors": [
{
"name": "Alessandro Lai",
"email": "alessandro.lai85@gmail.com"
}
],
"description": "A library to get pretty versions strings of installed dependencies",
"keywords": [
"composer",
"package",
"release",
"versions"
],
"support": {
"issues": "https://github.com/Jean85/pretty-package-versions/issues",
"source": "https://github.com/Jean85/pretty-package-versions/tree/2.1.1"
},
"time": "2025-03-19T14:43:43+00:00"
},
{
"name": "lcobucci/jwt",
"version": "5.6.0",
@@ -1814,6 +1990,74 @@
},
"time": "2026-01-12T15:59:08+00:00"
},
{
"name": "promphp/prometheus_client_php",
"version": "v2.14.1",
"source": {
"type": "git",
"url": "https://github.com/PromPHP/prometheus_client_php.git",
"reference": "a283aea8269287dc35313a0055480d950c59ac1f"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/PromPHP/prometheus_client_php/zipball/a283aea8269287dc35313a0055480d950c59ac1f",
"reference": "a283aea8269287dc35313a0055480d950c59ac1f",
"shasum": ""
},
"require": {
"ext-json": "*",
"php": "^7.4|^8.0"
},
"replace": {
"endclothing/prometheus_client_php": "*",
"jimdo/prometheus_client_php": "*",
"lkaemmerling/prometheus_client_php": "*"
},
"require-dev": {
"guzzlehttp/guzzle": "^6.3|^7.0",
"phpstan/extension-installer": "^1.0",
"phpstan/phpstan": "^1.5.4",
"phpstan/phpstan-phpunit": "^1.1.0",
"phpstan/phpstan-strict-rules": "^1.1.0",
"phpunit/phpunit": "^9.4",
"squizlabs/php_codesniffer": "^3.6",
"symfony/polyfill-apcu": "^1.6"
},
"suggest": {
"ext-apc": "Required if using APCu.",
"ext-pdo": "Required if using PDO.",
"ext-redis": "Required if using Redis.",
"promphp/prometheus_push_gateway_php": "An easy client for using Prometheus PushGateway.",
"symfony/polyfill-apcu": "Required if you use APCu on PHP8.0+"
},
"type": "library",
"extra": {
"branch-alias": {
"dev-master": "1.0-dev"
}
},
"autoload": {
"psr-4": {
"Prometheus\\": "src/Prometheus/"
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"Apache-2.0"
],
"authors": [
{
"name": "Lukas Kämmerling",
"email": "kontakt@lukas-kaemmerling.de"
}
],
"description": "Prometheus instrumentation library for PHP applications.",
"support": {
"issues": "https://github.com/PromPHP/prometheus_client_php/issues",
"source": "https://github.com/PromPHP/prometheus_client_php/tree/v2.14.1"
},
"time": "2025-04-14T07:59:43+00:00"
},
{
"name": "psr/cache",
"version": "3.0.0",
@@ -2014,6 +2258,114 @@
},
"time": "2019-01-08T18:20:26+00:00"
},
{
"name": "psr/http-factory",
"version": "1.1.0",
"source": {
"type": "git",
"url": "https://github.com/php-fig/http-factory.git",
"reference": "2b4765fddfe3b508ac62f829e852b1501d3f6e8a"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/php-fig/http-factory/zipball/2b4765fddfe3b508ac62f829e852b1501d3f6e8a",
"reference": "2b4765fddfe3b508ac62f829e852b1501d3f6e8a",
"shasum": ""
},
"require": {
"php": ">=7.1",
"psr/http-message": "^1.0 || ^2.0"
},
"type": "library",
"extra": {
"branch-alias": {
"dev-master": "1.0.x-dev"
}
},
"autoload": {
"psr-4": {
"Psr\\Http\\Message\\": "src/"
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"authors": [
{
"name": "PHP-FIG",
"homepage": "https://www.php-fig.org/"
}
],
"description": "PSR-17: Common interfaces for PSR-7 HTTP message factories",
"keywords": [
"factory",
"http",
"message",
"psr",
"psr-17",
"psr-7",
"request",
"response"
],
"support": {
"source": "https://github.com/php-fig/http-factory"
},
"time": "2024-04-15T12:06:14+00:00"
},
{
"name": "psr/http-message",
"version": "2.0",
"source": {
"type": "git",
"url": "https://github.com/php-fig/http-message.git",
"reference": "402d35bcb92c70c026d1a6a9883f06b2ead23d71"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/php-fig/http-message/zipball/402d35bcb92c70c026d1a6a9883f06b2ead23d71",
"reference": "402d35bcb92c70c026d1a6a9883f06b2ead23d71",
"shasum": ""
},
"require": {
"php": "^7.2 || ^8.0"
},
"type": "library",
"extra": {
"branch-alias": {
"dev-master": "2.0.x-dev"
}
},
"autoload": {
"psr-4": {
"Psr\\Http\\Message\\": "src/"
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"authors": [
{
"name": "PHP-FIG",
"homepage": "https://www.php-fig.org/"
}
],
"description": "Common interface for HTTP messages",
"homepage": "https://github.com/php-fig/http-message",
"keywords": [
"http",
"http-message",
"psr",
"psr-7",
"request",
"response"
],
"support": {
"source": "https://github.com/php-fig/http-message/tree/2.0"
},
"time": "2023-04-04T09:54:51+00:00"
},
{
"name": "psr/link",
"version": "2.0.1",
@@ -2120,6 +2472,50 @@
},
"time": "2024-09-11T13:17:53+00:00"
},
{
"name": "ralouphie/getallheaders",
"version": "3.0.3",
"source": {
"type": "git",
"url": "https://github.com/ralouphie/getallheaders.git",
"reference": "120b605dfeb996808c31b6477290a714d356e822"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/ralouphie/getallheaders/zipball/120b605dfeb996808c31b6477290a714d356e822",
"reference": "120b605dfeb996808c31b6477290a714d356e822",
"shasum": ""
},
"require": {
"php": ">=5.6"
},
"require-dev": {
"php-coveralls/php-coveralls": "^2.1",
"phpunit/phpunit": "^5 || ^6.5"
},
"type": "library",
"autoload": {
"files": [
"src/getallheaders.php"
]
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"authors": [
{
"name": "Ralph Khattar",
"email": "ralph.khattar@gmail.com"
}
],
"description": "A polyfill for getallheaders.",
"support": {
"issues": "https://github.com/ralouphie/getallheaders/issues",
"source": "https://github.com/ralouphie/getallheaders/tree/develop"
},
"time": "2019-03-08T08:55:37+00:00"
},
{
"name": "ramsey/collection",
"version": "2.1.1",
@@ -2274,6 +2670,196 @@
},
"time": "2025-12-14T04:43:48+00:00"
},
{
"name": "sentry/sentry",
"version": "4.19.1",
"source": {
"type": "git",
"url": "https://github.com/getsentry/sentry-php.git",
"reference": "1c21d60bebe67c0122335bd3fe977990435af0a3"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/getsentry/sentry-php/zipball/1c21d60bebe67c0122335bd3fe977990435af0a3",
"reference": "1c21d60bebe67c0122335bd3fe977990435af0a3",
"shasum": ""
},
"require": {
"ext-curl": "*",
"ext-json": "*",
"ext-mbstring": "*",
"guzzlehttp/psr7": "^1.8.4|^2.1.1",
"jean85/pretty-package-versions": "^1.5|^2.0.4",
"php": "^7.2|^8.0",
"psr/log": "^1.0|^2.0|^3.0",
"symfony/options-resolver": "^4.4.30|^5.0.11|^6.0|^7.0|^8.0"
},
"conflict": {
"raven/raven": "*"
},
"require-dev": {
"friendsofphp/php-cs-fixer": "^3.4",
"guzzlehttp/promises": "^2.0.3",
"guzzlehttp/psr7": "^1.8.4|^2.1.1",
"monolog/monolog": "^1.6|^2.0|^3.0",
"phpbench/phpbench": "^1.0",
"phpstan/phpstan": "^1.3",
"phpunit/phpunit": "^8.5|^9.6",
"vimeo/psalm": "^4.17"
},
"suggest": {
"monolog/monolog": "Allow sending log messages to Sentry by using the included Monolog handler."
},
"type": "library",
"autoload": {
"files": [
"src/functions.php"
],
"psr-4": {
"Sentry\\": "src/"
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"authors": [
{
"name": "Sentry",
"email": "accounts@sentry.io"
}
],
"description": "PHP SDK for Sentry (http://sentry.io)",
"homepage": "http://sentry.io",
"keywords": [
"crash-reporting",
"crash-reports",
"error-handler",
"error-monitoring",
"log",
"logging",
"profiling",
"sentry",
"tracing"
],
"support": {
"issues": "https://github.com/getsentry/sentry-php/issues",
"source": "https://github.com/getsentry/sentry-php/tree/4.19.1"
},
"funding": [
{
"url": "https://sentry.io/",
"type": "custom"
},
{
"url": "https://sentry.io/pricing/",
"type": "custom"
}
],
"time": "2025-12-02T15:57:41+00:00"
},
{
"name": "sentry/sentry-symfony",
"version": "5.8.3",
"source": {
"type": "git",
"url": "https://github.com/getsentry/sentry-symfony.git",
"reference": "e82559a078b26c8f8592289e98a25b203527a9c6"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/getsentry/sentry-symfony/zipball/e82559a078b26c8f8592289e98a25b203527a9c6",
"reference": "e82559a078b26c8f8592289e98a25b203527a9c6",
"shasum": ""
},
"require": {
"guzzlehttp/psr7": "^2.1.1",
"jean85/pretty-package-versions": "^1.5||^2.0",
"php": "^7.2||^8.0",
"sentry/sentry": "^4.19.1",
"symfony/cache-contracts": "^1.1||^2.4||^3.0",
"symfony/config": "^4.4.20||^5.0.11||^6.0||^7.0||^8.0",
"symfony/console": "^4.4.20||^5.0.11||^6.0||^7.0||^8.0",
"symfony/dependency-injection": "^4.4.20||^5.0.11||^6.0||^7.0||^8.0",
"symfony/event-dispatcher": "^4.4.20||^5.0.11||^6.0||^7.0||^8.0",
"symfony/http-kernel": "^4.4.20||^5.0.11||^6.0||^7.0||^8.0",
"symfony/polyfill-php80": "^1.22",
"symfony/psr-http-message-bridge": "^1.2||^2.0||^6.4||^7.0||^8.0",
"symfony/yaml": "^4.4.20||^5.0.11||^6.0||^7.0||^8.0"
},
"require-dev": {
"doctrine/dbal": "^2.13||^3.3||^4.0",
"doctrine/doctrine-bundle": "^2.6||^3.0",
"friendsofphp/php-cs-fixer": "^2.19||^3.40",
"masterminds/html5": "^2.8",
"phpstan/extension-installer": "^1.0",
"phpstan/phpstan": "1.12.5",
"phpstan/phpstan-phpunit": "1.4.0",
"phpstan/phpstan-symfony": "1.4.10",
"phpunit/phpunit": "^8.5.40||^9.6.21",
"symfony/browser-kit": "^4.4.20||^5.0.11||^6.0||^7.0||^8.0",
"symfony/cache": "^4.4.20||^5.0.11||^6.0||^7.0||^8.0",
"symfony/dom-crawler": "^4.4.20||^5.0.11||^6.0||^7.0||^8.0",
"symfony/framework-bundle": "^4.4.20||^5.0.11||^6.0||^7.0||^8.0",
"symfony/http-client": "^4.4.20||^5.0.11||^6.0||^7.0||^8.0",
"symfony/messenger": "^4.4.20||^5.0.11||^6.0||^7.0||^8.0",
"symfony/monolog-bundle": "^3.4||^4.0",
"symfony/phpunit-bridge": "^5.2.6||^6.0||^7.0||^8.0",
"symfony/process": "^4.4.20||^5.0.11||^6.0||^7.0||^8.0",
"symfony/security-core": "^4.4.20||^5.0.11||^6.0||^7.0||^8.0",
"symfony/security-http": "^4.4.20||^5.0.11||^6.0||^7.0||^8.0",
"symfony/twig-bundle": "^4.4.20||^5.0.11||^6.0||^7.0||^8.0",
"vimeo/psalm": "^4.3||^5.16.0"
},
"suggest": {
"doctrine/doctrine-bundle": "Allow distributed tracing of database queries using Sentry.",
"monolog/monolog": "Allow sending log messages to Sentry by using the included Monolog handler.",
"symfony/cache": "Allow distributed tracing of cache pools using Sentry.",
"symfony/twig-bundle": "Allow distributed tracing of Twig template rendering using Sentry."
},
"type": "symfony-bundle",
"autoload": {
"files": [
"src/aliases.php"
],
"psr-4": {
"Sentry\\SentryBundle\\": "src/"
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"authors": [
{
"name": "Sentry",
"email": "accounts@sentry.io"
}
],
"description": "Symfony integration for Sentry (http://getsentry.com)",
"homepage": "http://getsentry.com",
"keywords": [
"errors",
"logging",
"sentry",
"symfony"
],
"support": {
"issues": "https://github.com/getsentry/sentry-symfony/issues",
"source": "https://github.com/getsentry/sentry-symfony/tree/5.8.3"
},
"funding": [
{
"url": "https://sentry.io/",
"type": "custom"
},
{
"url": "https://sentry.io/pricing/",
"type": "custom"
}
],
"time": "2025-12-18T09:26:49+00:00"
},
{
"name": "symfony/amqp-messenger",
"version": "v8.0.4",
@@ -5496,6 +6082,93 @@
],
"time": "2026-01-27T16:18:07+00:00"
},
{
"name": "symfony/psr-http-message-bridge",
"version": "v8.0.4",
"source": {
"type": "git",
"url": "https://github.com/symfony/psr-http-message-bridge.git",
"reference": "d6edf266746dd0b8e81e754a79da77b08dc00531"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/symfony/psr-http-message-bridge/zipball/d6edf266746dd0b8e81e754a79da77b08dc00531",
"reference": "d6edf266746dd0b8e81e754a79da77b08dc00531",
"shasum": ""
},
"require": {
"php": ">=8.4",
"psr/http-message": "^1.0|^2.0",
"symfony/http-foundation": "^7.4|^8.0"
},
"conflict": {
"php-http/discovery": "<1.15"
},
"require-dev": {
"nyholm/psr7": "^1.1",
"php-http/discovery": "^1.15",
"psr/log": "^1.1.4|^2|^3",
"symfony/browser-kit": "^7.4|^8.0",
"symfony/config": "^7.4|^8.0",
"symfony/event-dispatcher": "^7.4|^8.0",
"symfony/framework-bundle": "^7.4|^8.0",
"symfony/http-kernel": "^7.4|^8.0",
"symfony/runtime": "^7.4|^8.0"
},
"type": "symfony-bridge",
"autoload": {
"psr-4": {
"Symfony\\Bridge\\PsrHttpMessage\\": ""
},
"exclude-from-classmap": [
"/Tests/"
]
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"authors": [
{
"name": "Fabien Potencier",
"email": "fabien@symfony.com"
},
{
"name": "Symfony Community",
"homepage": "https://symfony.com/contributors"
}
],
"description": "PSR HTTP message bridge",
"homepage": "https://symfony.com",
"keywords": [
"http",
"http-message",
"psr-17",
"psr-7"
],
"support": {
"source": "https://github.com/symfony/psr-http-message-bridge/tree/v8.0.4"
},
"funding": [
{
"url": "https://symfony.com/sponsor",
"type": "custom"
},
{
"url": "https://github.com/fabpot",
"type": "github"
},
{
"url": "https://github.com/nicolas-grekas",
"type": "github"
},
{
"url": "https://tidelift.com/funding/github/packagist/symfony/symfony",
"type": "tidelift"
}
],
"time": "2026-01-03T23:40:55+00:00"
},
{
"name": "symfony/rate-limiter",
"version": "v8.0.5",

View File

@@ -14,4 +14,5 @@ return [
Symfony\Bundle\MakerBundle\MakerBundle::class => ['dev' => true],
Doctrine\Bundle\FixturesBundle\DoctrineFixturesBundle::class => ['dev' => true, 'test' => true],
Nelmio\CorsBundle\NelmioCorsBundle::class => ['all' => true],
Sentry\SentryBundle\SentryBundle::class => ['all' => true],
];

View File

@@ -16,6 +16,11 @@ security:
dev:
pattern: ^/(_(profiler|wdt)|css|images|js)/
security: false
# Monitoring endpoints - no authentication, restricted by IP in production
monitoring:
pattern: ^/(health|metrics)$
stateless: true
security: false
api_login:
pattern: ^/api/login$
stateless: true

View File

@@ -0,0 +1,24 @@
# Sentry/GlitchTip Configuration
# Error tracking with automatic context enrichment
#
# To enable error tracking:
# 1. Set up GlitchTip at http://localhost:8081 (via make up-full)
# 2. Create a project and get the DSN
# 3. Add SENTRY_DSN to .env.local
sentry:
dsn: '%env(default::SENTRY_DSN)%'
register_error_handler: false # Disable when DSN is empty
options:
environment: '%env(SENTRY_ENVIRONMENT)%'
send_default_pii: false # CRITICAL: No PII in error reports (RGPD)
when@prod:
sentry:
register_error_handler: true # Enable in production
options:
before_send: 'App\Shared\Infrastructure\Monitoring\SentryBeforeSendCallback'
when@test:
sentry:
dsn: ''

View File

@@ -167,6 +167,62 @@ services:
App\Shared\Infrastructure\Captcha\TurnstileValidatorInterface:
alias: App\Shared\Infrastructure\Captcha\TurnstileValidator
# =============================================================================
# Monitoring & Observability (Story 1.8)
# =============================================================================
# Prometheus CollectorRegistry - uses Redis for persistence between requests
Prometheus\Storage\Redis:
factory: ['App\Shared\Infrastructure\Monitoring\PrometheusStorageFactory', 'createRedisStorage']
arguments:
$redisUrl: '%env(REDIS_URL)%'
Prometheus\CollectorRegistry:
arguments:
$storageAdapter: '@Prometheus\Storage\Redis'
# Sentry/GlitchTip PII scrubber callback
App\Shared\Infrastructure\Monitoring\SentryBeforeSendCallback: ~
# Infrastructure Health Checker - shared service for health checks (DRY)
App\Shared\Infrastructure\Monitoring\InfrastructureHealthChecker:
arguments:
$redisUrl: '%env(REDIS_URL)%'
# Interface alias for InfrastructureHealthChecker (allows testing with stubs)
App\Shared\Infrastructure\Monitoring\InfrastructureHealthCheckerInterface:
alias: App\Shared\Infrastructure\Monitoring\InfrastructureHealthChecker
# Health Check Controller - uses shared InfrastructureHealthChecker
App\Shared\Infrastructure\Monitoring\HealthCheckController: ~
# Metrics Controller - restricted to internal networks in production
App\Shared\Infrastructure\Monitoring\MetricsController:
arguments:
$appEnv: '%kernel.environment%'
# Health Metrics Collector - exposes health_check_status gauge
App\Shared\Infrastructure\Monitoring\HealthMetricsCollector: ~
# Interface alias for HealthMetricsCollector (allows testing with stubs)
App\Shared\Infrastructure\Monitoring\HealthMetricsCollectorInterface:
alias: App\Shared\Infrastructure\Monitoring\HealthMetricsCollector
# Sentry context enricher - adds tenant/user/correlation_id to error reports
# Explicitly registered to ensure HubInterface dependency is resolved
App\Shared\Infrastructure\Monitoring\SentryContextEnricher:
arguments:
$sentryHub: '@Sentry\State\HubInterface'
# Monolog processors for structured logging
App\Shared\Infrastructure\Monitoring\CorrelationIdLogProcessor:
tags:
- { name: monolog.processor }
App\Shared\Infrastructure\Monitoring\PiiScrubberLogProcessor:
tags:
- { name: monolog.processor }
# =============================================================================
# Test environment overrides
# =============================================================================

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,

View File

@@ -121,6 +121,18 @@
"bin/phpunit"
]
},
"sentry/sentry-symfony": {
"version": "5.8",
"recipe": {
"repo": "github.com/symfony/recipes-contrib",
"branch": "main",
"version": "5.0",
"ref": "12f504985eb24e3b20a9e41e0ec7e398798d18f0"
},
"files": [
"config/packages/sentry.yaml"
]
},
"symfony/console": {
"version": "8.0",
"recipe": {

View File

@@ -0,0 +1,138 @@
<?php
declare(strict_types=1);
namespace App\Tests\Unit\Shared\Infrastructure\Monitoring;
use App\Shared\Domain\CorrelationId;
use App\Shared\Infrastructure\Audit\CorrelationIdHolder;
use App\Shared\Infrastructure\Monitoring\CorrelationIdLogProcessor;
use App\Shared\Infrastructure\Tenant\TenantConfig;
use App\Shared\Infrastructure\Tenant\TenantContext;
use App\Shared\Infrastructure\Tenant\TenantId;
use DateTimeImmutable;
use Monolog\Level;
use Monolog\LogRecord;
use PHPUnit\Framework\Attributes\CoversClass;
use PHPUnit\Framework\Attributes\Test;
use PHPUnit\Framework\TestCase;
/**
* @see Story 1.8 - T5.2: Processor to add correlation_id automatically
* @see Story 1.8 - T5.3: Processor to add tenant_id automatically
*/
#[CoversClass(CorrelationIdLogProcessor::class)]
final class CorrelationIdLogProcessorTest extends TestCase
{
private TenantContext $tenantContext;
private CorrelationIdLogProcessor $processor;
protected function setUp(): void
{
CorrelationIdHolder::clear();
$this->tenantContext = new TenantContext();
$this->processor = new CorrelationIdLogProcessor($this->tenantContext);
}
protected function tearDown(): void
{
CorrelationIdHolder::clear();
$this->tenantContext->clear();
}
#[Test]
public function itAddsCorrelationIdToLogRecord(): void
{
$correlationId = CorrelationId::fromString('01234567-89ab-cdef-0123-456789abcdef');
CorrelationIdHolder::set($correlationId);
$record = $this->createLogRecord();
$result = ($this->processor)($record);
self::assertArrayHasKey('correlation_id', $result->extra);
self::assertSame('01234567-89ab-cdef-0123-456789abcdef', $result->extra['correlation_id']);
}
#[Test]
public function itAddsTenantIdToLogRecord(): void
{
$tenantConfig = new TenantConfig(
tenantId: TenantId::fromString('a1b2c3d4-e5f6-7890-abcd-ef1234567890'),
subdomain: 'test-school',
databaseUrl: 'postgresql://test@localhost/test',
);
$this->tenantContext->setCurrentTenant($tenantConfig);
$record = $this->createLogRecord();
$result = ($this->processor)($record);
self::assertArrayHasKey('tenant_id', $result->extra);
self::assertSame('test-school', $result->extra['tenant_id']);
}
#[Test]
public function itAddsBothCorrelationIdAndTenantId(): void
{
$correlationId = CorrelationId::fromString('11111111-2222-3333-4444-555555555555');
CorrelationIdHolder::set($correlationId);
$tenantConfig = new TenantConfig(
tenantId: TenantId::fromString('66666666-7777-8888-9999-aaaaaaaaaaaa'),
subdomain: 'school',
databaseUrl: 'postgresql://test@localhost/school',
);
$this->tenantContext->setCurrentTenant($tenantConfig);
$record = $this->createLogRecord();
$result = ($this->processor)($record);
self::assertSame('11111111-2222-3333-4444-555555555555', $result->extra['correlation_id']);
self::assertSame('school', $result->extra['tenant_id']);
}
#[Test]
public function itDoesNotAddCorrelationIdWhenNotSet(): void
{
$record = $this->createLogRecord();
$result = ($this->processor)($record);
self::assertArrayNotHasKey('correlation_id', $result->extra);
}
#[Test]
public function itDoesNotAddTenantIdWhenNoTenant(): void
{
$record = $this->createLogRecord();
$result = ($this->processor)($record);
self::assertArrayNotHasKey('tenant_id', $result->extra);
}
#[Test]
public function itPreservesExistingExtraData(): void
{
$correlationId = CorrelationId::fromString('abcdef12-3456-7890-abcd-ef1234567890');
CorrelationIdHolder::set($correlationId);
$record = $this->createLogRecord(extra: ['existing_key' => 'existing_value']);
$result = ($this->processor)($record);
self::assertSame('existing_value', $result->extra['existing_key']);
self::assertSame('abcdef12-3456-7890-abcd-ef1234567890', $result->extra['correlation_id']);
}
/**
* @param array<string, mixed> $extra
*/
private function createLogRecord(array $extra = []): LogRecord
{
return new LogRecord(
datetime: new DateTimeImmutable(),
channel: 'test',
level: Level::Info,
message: 'Test message',
context: [],
extra: $extra,
);
}
}

View File

@@ -0,0 +1,160 @@
<?php
declare(strict_types=1);
namespace App\Tests\Unit\Shared\Infrastructure\Monitoring;
use App\Shared\Infrastructure\Monitoring\HealthCheckController;
use App\Shared\Infrastructure\Monitoring\InfrastructureHealthCheckerInterface;
use PHPUnit\Framework\Attributes\CoversClass;
use PHPUnit\Framework\Attributes\Test;
use PHPUnit\Framework\TestCase;
use Symfony\Component\HttpFoundation\Response;
/**
* Stub for InfrastructureHealthCheckerInterface.
*/
final class InfrastructureHealthCheckerStub implements InfrastructureHealthCheckerInterface
{
/**
* @param array{postgres: bool, redis: bool, rabbitmq: bool} $checks
*/
public function __construct(
private readonly array $checks = ['postgres' => true, 'redis' => true, 'rabbitmq' => true],
) {
}
public function checkPostgres(): bool
{
return $this->checks['postgres'];
}
public function checkRedis(): bool
{
return $this->checks['redis'];
}
public function checkRabbitMQ(): bool
{
return $this->checks['rabbitmq'];
}
public function checkAll(): array
{
return $this->checks;
}
}
/**
* @see Story 1.8 - T7: Health Check Endpoint
*/
#[CoversClass(HealthCheckController::class)]
final class HealthCheckControllerTest extends TestCase
{
private function createController(
?InfrastructureHealthCheckerInterface $healthChecker = null,
): HealthCheckController {
$healthChecker ??= new InfrastructureHealthCheckerStub();
return new HealthCheckController($healthChecker);
}
#[Test]
public function itReturnsHealthyWhenAllServicesAreUp(): void
{
$controller = $this->createController();
$response = $controller();
self::assertSame(Response::HTTP_OK, $response->getStatusCode());
$data = json_decode($response->getContent(), true);
self::assertSame('healthy', $data['status']);
self::assertTrue($data['checks']['postgres']);
self::assertTrue($data['checks']['redis']);
self::assertTrue($data['checks']['rabbitmq']);
}
#[Test]
public function itReturnsUnhealthyWhenPostgresIsDown(): void
{
$checker = new InfrastructureHealthCheckerStub([
'postgres' => false,
'redis' => true,
'rabbitmq' => true,
]);
$controller = $this->createController($checker);
$response = $controller();
self::assertSame(Response::HTTP_SERVICE_UNAVAILABLE, $response->getStatusCode());
$data = json_decode($response->getContent(), true);
self::assertSame('unhealthy', $data['status']);
self::assertFalse($data['checks']['postgres']);
}
#[Test]
public function itIncludesTimestampInResponse(): void
{
$controller = $this->createController();
$response = $controller();
$data = json_decode($response->getContent(), true);
self::assertArrayHasKey('timestamp', $data);
self::assertMatchesRegularExpression(
'/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d+[+-]\d{2}:\d{2}$/',
$data['timestamp'],
);
}
#[Test]
public function itReturnsAllServiceChecks(): void
{
$controller = $this->createController();
$response = $controller();
$data = json_decode($response->getContent(), true);
self::assertArrayHasKey('postgres', $data['checks']);
self::assertArrayHasKey('redis', $data['checks']);
self::assertArrayHasKey('rabbitmq', $data['checks']);
}
#[Test]
public function itReturnsUnhealthyWhenRabbitmqIsDown(): void
{
$checker = new InfrastructureHealthCheckerStub([
'postgres' => true,
'redis' => true,
'rabbitmq' => false,
]);
$controller = $this->createController($checker);
$response = $controller();
self::assertSame(Response::HTTP_SERVICE_UNAVAILABLE, $response->getStatusCode());
$data = json_decode($response->getContent(), true);
self::assertFalse($data['checks']['rabbitmq']);
}
#[Test]
public function itReturnsUnhealthyWhenRedisIsDown(): void
{
$checker = new InfrastructureHealthCheckerStub([
'postgres' => true,
'redis' => false,
'rabbitmq' => true,
]);
$controller = $this->createController($checker);
$response = $controller();
self::assertSame(Response::HTTP_SERVICE_UNAVAILABLE, $response->getStatusCode());
$data = json_decode($response->getContent(), true);
self::assertFalse($data['checks']['redis']);
}
}

View File

@@ -0,0 +1,199 @@
<?php
declare(strict_types=1);
namespace App\Tests\Unit\Shared\Infrastructure\Monitoring;
use App\Shared\Infrastructure\Monitoring\MetricsCollector;
use App\Shared\Infrastructure\Tenant\TenantConfig;
use App\Shared\Infrastructure\Tenant\TenantContext;
use App\Shared\Infrastructure\Tenant\TenantId;
use PHPUnit\Framework\Attributes\CoversClass;
use PHPUnit\Framework\Attributes\Test;
use PHPUnit\Framework\TestCase;
use Prometheus\CollectorRegistry;
use Prometheus\Counter;
use Prometheus\Histogram;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\HttpKernel\Event\RequestEvent;
use Symfony\Component\HttpKernel\Event\TerminateEvent;
use Symfony\Component\HttpKernel\HttpKernelInterface;
/**
* @see Story 1.8 - T3.4: Custom metrics (requests_total, request_duration_seconds)
*/
#[CoversClass(MetricsCollector::class)]
final class MetricsCollectorTest extends TestCase
{
private CollectorRegistry $registry;
private TenantContext $tenantContext;
private MetricsCollector $collector;
private Counter $requestsCounter;
private Histogram $durationHistogram;
private Counter $loginFailuresCounter;
protected function setUp(): void
{
$this->requestsCounter = $this->createMock(Counter::class);
$this->durationHistogram = $this->createMock(Histogram::class);
$this->loginFailuresCounter = $this->createMock(Counter::class);
$this->registry = $this->createMock(CollectorRegistry::class);
$this->registry->method('getOrRegisterCounter')
->willReturnCallback(fn (string $ns, string $name) => match ($name) {
'http_requests_total' => $this->requestsCounter,
'login_failures_total' => $this->loginFailuresCounter,
default => $this->createMock(Counter::class),
});
$this->registry->method('getOrRegisterHistogram')
->willReturn($this->durationHistogram);
$this->tenantContext = new TenantContext();
$this->collector = new MetricsCollector($this->registry, $this->tenantContext);
}
protected function tearDown(): void
{
$this->tenantContext->clear();
}
#[Test]
public function itRecordsRequestMetricsWithoutTenant(): void
{
$request = Request::create('/api/users', 'GET');
$request->attributes->set('_route', 'get_users');
$response = new Response('', 200);
$kernel = $this->createMock(HttpKernelInterface::class);
// Simulate request start
$requestEvent = new RequestEvent($kernel, $request, HttpKernelInterface::MAIN_REQUEST);
$this->collector->onKernelRequest($requestEvent);
// Expect metrics to be recorded with tenant_id="none"
$this->requestsCounter->expects(self::once())
->method('inc')
->with(['GET', 'get_users', '200', 'none']);
$this->durationHistogram->expects(self::once())
->method('observe')
->with(
self::greaterThan(0),
['GET', 'get_users', 'none'],
);
// Simulate request end
$terminateEvent = new TerminateEvent($kernel, $request, $response);
$this->collector->onKernelTerminate($terminateEvent);
}
#[Test]
public function itRecordsRequestMetricsWithTenant(): void
{
$tenantConfig = new TenantConfig(
tenantId: TenantId::fromString('a1b2c3d4-e5f6-7890-abcd-ef1234567890'),
subdomain: 'ecole-alpha',
databaseUrl: 'postgresql://test@localhost/test',
);
$this->tenantContext->setCurrentTenant($tenantConfig);
$request = Request::create('/api/users', 'POST');
$request->attributes->set('_route', 'create_user');
$response = new Response('', 201);
$kernel = $this->createMock(HttpKernelInterface::class);
$requestEvent = new RequestEvent($kernel, $request, HttpKernelInterface::MAIN_REQUEST);
$this->collector->onKernelRequest($requestEvent);
$this->requestsCounter->expects(self::once())
->method('inc')
->with(['POST', 'create_user', '201', 'ecole-alpha']);
$terminateEvent = new TerminateEvent($kernel, $request, $response);
$this->collector->onKernelTerminate($terminateEvent);
}
#[Test]
public function itSkipsMetricsEndpoint(): void
{
$request = Request::create('/metrics', 'GET');
$request->attributes->set('_route', 'prometheus_metrics');
$response = new Response('', 200);
$kernel = $this->createMock(HttpKernelInterface::class);
$requestEvent = new RequestEvent($kernel, $request, HttpKernelInterface::MAIN_REQUEST);
$this->collector->onKernelRequest($requestEvent);
// Should NOT record metrics for /metrics endpoint
$this->requestsCounter->expects(self::never())->method('inc');
$this->durationHistogram->expects(self::never())->method('observe');
$terminateEvent = new TerminateEvent($kernel, $request, $response);
$this->collector->onKernelTerminate($terminateEvent);
}
#[Test]
public function itSkipsHealthEndpoint(): void
{
$request = Request::create('/health', 'GET');
$request->attributes->set('_route', 'health_check');
$response = new Response('', 200);
$kernel = $this->createMock(HttpKernelInterface::class);
$requestEvent = new RequestEvent($kernel, $request, HttpKernelInterface::MAIN_REQUEST);
$this->collector->onKernelRequest($requestEvent);
$this->requestsCounter->expects(self::never())->method('inc');
$terminateEvent = new TerminateEvent($kernel, $request, $response);
$this->collector->onKernelTerminate($terminateEvent);
}
#[Test]
public function itRecordsLoginFailureWithoutTenant(): void
{
$this->loginFailuresCounter->expects(self::once())
->method('inc')
->with(['none', 'invalid_credentials']);
$this->collector->recordLoginFailure();
}
#[Test]
public function itRecordsLoginFailureWithTenant(): void
{
$tenantConfig = new TenantConfig(
tenantId: TenantId::fromString('a1b2c3d4-e5f6-7890-abcd-ef1234567890'),
subdomain: 'ecole-beta',
databaseUrl: 'postgresql://test@localhost/test',
);
$this->tenantContext->setCurrentTenant($tenantConfig);
$this->loginFailuresCounter->expects(self::once())
->method('inc')
->with(['ecole-beta', 'rate_limited']);
$this->collector->recordLoginFailure('rate_limited');
}
#[Test]
public function itIgnoresSubrequests(): void
{
$request = Request::create('/api/test', 'GET');
$kernel = $this->createMock(HttpKernelInterface::class);
// Subrequest should be ignored
$requestEvent = new RequestEvent($kernel, $request, HttpKernelInterface::SUB_REQUEST);
$this->collector->onKernelRequest($requestEvent);
$this->requestsCounter->expects(self::never())->method('inc');
$response = new Response('', 200);
$terminateEvent = new TerminateEvent($kernel, $request, $response);
$this->collector->onKernelTerminate($terminateEvent);
}
}

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

View File

@@ -0,0 +1,186 @@
<?php
declare(strict_types=1);
namespace App\Tests\Unit\Shared\Infrastructure\Monitoring;
use App\Shared\Infrastructure\Monitoring\PiiScrubberLogProcessor;
use DateTimeImmutable;
use Monolog\Level;
use Monolog\LogRecord;
use PHPUnit\Framework\Attributes\CoversClass;
use PHPUnit\Framework\Attributes\DataProvider;
use PHPUnit\Framework\Attributes\Test;
use PHPUnit\Framework\TestCase;
/**
* @see Story 1.8 - T5.4: Filter PII from logs (scrubber processor)
*/
#[CoversClass(PiiScrubberLogProcessor::class)]
final class PiiScrubberLogProcessorTest extends TestCase
{
private PiiScrubberLogProcessor $processor;
protected function setUp(): void
{
$this->processor = new PiiScrubberLogProcessor();
}
#[Test]
public function itRedactsEmailInContext(): void
{
$record = $this->createLogRecord(
context: ['email' => 'user@example.com'],
);
$result = ($this->processor)($record);
self::assertSame('[REDACTED]', $result->context['email']);
}
#[Test]
public function itRedactsPasswordInContext(): void
{
$record = $this->createLogRecord(
context: ['password' => 'secret123'],
);
$result = ($this->processor)($record);
self::assertSame('[REDACTED]', $result->context['password']);
}
#[Test]
public function itRedactsNestedPii(): void
{
$record = $this->createLogRecord(
context: [
'user' => [
'id' => 'uuid-123',
'email' => 'user@example.com',
'name' => 'John Doe',
],
],
);
$result = ($this->processor)($record);
self::assertSame('uuid-123', $result->context['user']['id']);
self::assertSame('[REDACTED]', $result->context['user']['email']);
self::assertSame('[REDACTED]', $result->context['user']['name']);
}
#[Test]
public function itRedactsJwtTokensByValue(): void
{
$jwt = 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIn0.dozjgNryP4J3jVmNHl0w5N_XgL0n3I9PlFUP0THsR8U';
$record = $this->createLogRecord(
context: ['auth_header' => $jwt],
);
$result = ($this->processor)($record);
self::assertSame('[REDACTED]', $result->context['auth_header']);
}
#[Test]
public function itRedactsEmailValues(): void
{
$record = $this->createLogRecord(
context: ['some_field' => 'contact@school.fr'],
);
$result = ($this->processor)($record);
self::assertSame('[REDACTED]', $result->context['some_field']);
}
#[Test]
public function itPreservesSafeValues(): void
{
$record = $this->createLogRecord(
context: [
'correlation_id' => '01234567-89ab-cdef-0123-456789abcdef',
'tenant_id' => 'tenant-uuid',
'event_type' => 'UserCreated',
'count' => 42,
],
);
$result = ($this->processor)($record);
self::assertSame('01234567-89ab-cdef-0123-456789abcdef', $result->context['correlation_id']);
self::assertSame('tenant-uuid', $result->context['tenant_id']);
self::assertSame('UserCreated', $result->context['event_type']);
self::assertSame(42, $result->context['count']);
}
#[Test]
public function itRedactsPiiInExtra(): void
{
$record = $this->createLogRecord(
extra: ['user_email' => 'admin@classeo.fr'],
);
$result = ($this->processor)($record);
self::assertSame('[REDACTED]', $result->extra['user_email']);
}
#[Test]
#[DataProvider('piiKeyProvider')]
public function itRedactsVariousPiiKeys(string $key): void
{
$record = $this->createLogRecord(
context: [$key => 'sensitive_value'],
);
$result = ($this->processor)($record);
self::assertSame('[REDACTED]', $result->context[$key]);
}
/**
* @return iterable<string, array{string}>
*/
public static function piiKeyProvider(): iterable
{
yield 'email' => ['email'];
yield 'password' => ['password'];
yield 'token' => ['token'];
yield 'secret' => ['secret'];
yield 'authorization' => ['authorization'];
yield 'cookie' => ['cookie'];
yield 'phone' => ['phone'];
yield 'address' => ['address'];
yield 'nom' => ['nom'];
yield 'prenom' => ['prenom'];
yield 'name' => ['name'];
yield 'firstname' => ['firstname'];
yield 'lastname' => ['lastname'];
yield 'ip' => ['ip'];
yield 'user_agent' => ['user_agent'];
yield 'user_email' => ['user_email'];
yield 'auth_token' => ['auth_token'];
}
/**
* @param array<string, mixed> $context
* @param array<string, mixed> $extra
*/
private function createLogRecord(
string $message = 'Test message',
array $context = [],
array $extra = [],
): LogRecord {
return new LogRecord(
datetime: new DateTimeImmutable(),
channel: 'test',
level: Level::Info,
message: $message,
context: $context,
extra: $extra,
);
}
}

View File

@@ -0,0 +1,192 @@
<?php
declare(strict_types=1);
namespace App\Tests\Unit\Shared\Infrastructure\Monitoring;
use App\Shared\Infrastructure\Monitoring\SentryBeforeSendCallback;
use PHPUnit\Framework\Attributes\CoversClass;
use PHPUnit\Framework\Attributes\DataProvider;
use PHPUnit\Framework\Attributes\Test;
use PHPUnit\Framework\TestCase;
use Sentry\Event;
/**
* Tests for PII scrubbing in Sentry events.
*
* Critical for RGPD compliance: ensures no personal data is sent to error tracking.
*
* @see Story 1.8 - T1.4: Filter PII before send (scrubber)
*/
#[CoversClass(SentryBeforeSendCallback::class)]
final class SentryBeforeSendCallbackTest extends TestCase
{
private SentryBeforeSendCallback $callback;
protected function setUp(): void
{
$this->callback = new SentryBeforeSendCallback();
}
#[Test]
public function itNeverDropsEvents(): void
{
$event = Event::createEvent();
$result = ($this->callback)($event, null);
self::assertNotNull($result);
self::assertSame($event, $result);
}
#[Test]
public function itFiltersEmailFromExtra(): void
{
$event = Event::createEvent();
$event->setExtra(['user_email' => 'john@example.com', 'action' => 'login']);
$result = ($this->callback)($event, null);
$extra = $result->getExtra();
self::assertSame('[FILTERED]', $extra['user_email']);
self::assertSame('login', $extra['action']);
}
#[Test]
public function itFiltersPasswordFromExtra(): void
{
$event = Event::createEvent();
$event->setExtra(['password' => 'secret123', 'user_id' => 'john']);
$result = ($this->callback)($event, null);
$extra = $result->getExtra();
self::assertSame('[FILTERED]', $extra['password']);
self::assertSame('john', $extra['user_id']);
}
#[Test]
public function itFiltersTokenFromExtra(): void
{
$event = Event::createEvent();
$event->setExtra(['auth_token' => 'abc123xyz', 'status' => 'active']);
$result = ($this->callback)($event, null);
$extra = $result->getExtra();
self::assertSame('[FILTERED]', $extra['auth_token']);
self::assertSame('active', $extra['status']);
}
#[Test]
public function itFiltersJwtTokensByValue(): void
{
$jwt = 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIn0.dozjgNryP4J3jVmNHl0w5N_XgL0n3I9PlFUP0THsR8U';
$event = Event::createEvent();
$event->setExtra(['data' => $jwt]);
$result = ($this->callback)($event, null);
$extra = $result->getExtra();
self::assertSame('[FILTERED]', $extra['data']);
}
#[Test]
public function itFiltersEmailValues(): void
{
$event = Event::createEvent();
$event->setExtra(['contact' => 'user@example.com']);
$result = ($this->callback)($event, null);
$extra = $result->getExtra();
self::assertSame('[FILTERED]', $extra['contact']);
}
#[Test]
public function itFiltersNestedPii(): void
{
$event = Event::createEvent();
$event->setExtra([
'user' => [
'email' => 'nested@example.com',
'id' => 123,
],
]);
$result = ($this->callback)($event, null);
$extra = $result->getExtra();
self::assertSame('[FILTERED]', $extra['user']['email']);
self::assertSame(123, $extra['user']['id']);
}
#[Test]
public function itFiltersPiiFromTags(): void
{
$event = Event::createEvent();
$event->setTags(['user_email' => 'tagged@example.com', 'environment' => 'prod']);
$result = ($this->callback)($event, null);
$tags = $result->getTags();
self::assertSame('[FILTERED]', $tags['user_email']);
self::assertSame('prod', $tags['environment']);
}
#[Test]
#[DataProvider('piiKeysProvider')]
public function itFiltersVariousPiiKeys(string $key): void
{
$event = Event::createEvent();
$event->setExtra([$key => 'sensitive_value']);
$result = ($this->callback)($event, null);
$extra = $result->getExtra();
self::assertSame('[FILTERED]', $extra[$key], "Key '$key' should be filtered");
}
/**
* @return iterable<string, array{string}>
*/
public static function piiKeysProvider(): iterable
{
yield 'email' => ['email'];
yield 'password' => ['password'];
yield 'token' => ['token'];
yield 'secret' => ['secret'];
yield 'key' => ['api_key'];
yield 'authorization' => ['authorization'];
yield 'cookie' => ['cookie'];
yield 'session' => ['session_id'];
yield 'phone' => ['phone'];
yield 'address' => ['address'];
yield 'ip' => ['client_ip'];
yield 'nom' => ['nom'];
yield 'prenom' => ['prenom'];
yield 'name' => ['name'];
yield 'firstname' => ['firstname'];
yield 'lastname' => ['lastname'];
}
#[Test]
public function itPreservesSafeValues(): void
{
$event = Event::createEvent();
$event->setExtra([
'error_code' => 'E001',
'count' => 42,
'enabled' => true,
'data' => ['a', 'b', 'c'],
]);
$result = ($this->callback)($event, null);
$extra = $result->getExtra();
self::assertSame('E001', $extra['error_code']);
self::assertSame(42, $extra['count']);
self::assertTrue($extra['enabled']);
self::assertSame(['a', 'b', 'c'], $extra['data']);
}
}