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