================================================================================
[OK] ELK STACK EN ÉQUIPE DE 3 DÉVELOPPEURS - APPLICATION JAVA SPRING BOOT
                        (GUIDE ULTRA-DÉTAILLÉ)
================================================================================

Ce guide couvre un cas réel et concret: une équipe de 3 développeurs Java
qui travaillent ensemble sur une application Spring Boot et utilisent la stack
ELK (Elasticsearch + Logstash + Kibana) pour centraliser, analyser et visualiser
les logs de leur application collectivement.

━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
QU'EST-CE QUE LA STACK ELK EN JAVA?
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

En Java/Spring Boot, les logs passent par SLF4J + Logback (le duo standard).
On ajoute des appenders Logback pour envoyer les logs vers ELK:

  ┌────────────────────────────────────────────────────────────────────────┐
  │  Application Spring Boot                                               │
  │                                                                        │
  │  Code Java -> SLF4J (façade) -> Logback (implémentation)               │
  │                                        │                               │
  │                    ┌───────────────────┼────────────────┐             │
  │                    [BLACK_DOWN-POINTING_TRIANGLE]                   [BLACK_DOWN-POINTING_TRIANGLE]                [BLACK_DOWN-POINTING_TRIANGLE]             │
  │              Console             Fichier JSON       Logstash TCP      │
  │              (dev)              (logs/*.log)       (logback-logstash) │
  │                                       │                │              │
  │                               Filebeat (lit)           │              │
  │                                       │                │              │
  │                                       └───────┬────────┘              │
  │                                               [BLACK_DOWN-POINTING_TRIANGLE]                       │
  │                                     Logstash (pipeline)               │
  │                                               │                       │
  │                                               [BLACK_DOWN-POINTING_TRIANGLE]                       │
  │                                     Elasticsearch (stockage)          │
  │                                               │                       │
  │                                               [BLACK_DOWN-POINTING_TRIANGLE]                       │
  │                                     Kibana (dashboards)               │
  └────────────────────────────────────────────────────────────────────────┘

  Bibliothèques Java utilisées dans ce guide:
  - SLF4J              : Façade de logging (API standard)
  - Logback            : Implémentation SLF4J (par défaut dans Spring Boot)
  - logstash-logback-encoder : Appender Logback -> JSON -> Logstash/fichier
  - spring-boot-starter-web  : Pour l'API REST
  - spring-data-jpa          : Pour l'accès base de données

━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
PRÉSENTATION DE L'ÉQUIPE ET DU PROJET
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

L'équipe:
  - Alice  -> Lead développeuse (aussi admin ELK)
  - Bob    -> Développeur backend
  - Claire -> Développeuse backend + tests

Le projet:
  - Nom: "TaskManager API"
  - Stack: Java 17 + Spring Boot 3.2 + Spring Data JPA + H2/PostgreSQL
  - Build: Maven
  - Versionning: Git + GitHub
  - Dépôt: github.com/equipe/taskmanager-java

Application Spring Boot simple:
  - GET    /api/tasks          -> lister les tâches
  - POST   /api/tasks          -> créer une tâche
  - GET    /api/tasks/{id}     -> récupérer une tâche
  - PUT    /api/tasks/{id}     -> modifier une tâche
  - DELETE /api/tasks/{id}     -> supprimer une tâche
  - GET    /api/tasks/search   -> rechercher des tâches

Structure du projet Maven:
  taskmanager-java/
  ├── src/
  │   ├── main/
  │   │   ├── java/com/equipe/taskmanager/
  │   │   │   ├── TaskManagerApplication.java
  │   │   │   ├── controller/
  │   │   │   │   └── TaskController.java
  │   │   │   ├── service/
  │   │   │   │   └── TaskService.java
  │   │   │   ├── repository/
  │   │   │   │   └── TaskRepository.java
  │   │   │   ├── model/
  │   │   │   │   └── Task.java
  │   │   │   ├── dto/
  │   │   │   │   ├── TaskRequest.java
  │   │   │   │   └── TaskResponse.java
  │   │   │   ├── exception/
  │   │   │   │   ├── TaskNotFoundException.java
  │   │   │   │   └── GlobalExceptionHandler.java
  │   │   │   └── logging/
  │   │   │       ├── RequestLoggingFilter.java    <- NOUVEAU
  │   │   │       └── LoggingAspect.java           <- NOUVEAU
  │   │   └── resources/
  │   │       ├── application.yml
  │   │       ├── application-dev.yml
  │   │       ├── application-prod.yml
  │   │       └── logback-spring.xml               <- NOUVEAU: config Logback
  │   └── test/
  │       └── java/com/equipe/taskmanager/
  │           ├── controller/
  │           │   └── TaskControllerTest.java
  │           ├── service/
  │           │   └── TaskServiceTest.java
  │           └── logging/
  │               └── LoggingTest.java              <- NOUVEAU
  ├── docker/
  │   ├── docker-compose.elk.yml                   <- NOUVEAU
  │   ├── logstash/
  │   │   ├── pipeline/
  │   │   │   └── taskmanager.conf                 <- NOUVEAU
  │   │   └── logstash.yml
  │   └── filebeat/
  │       └── filebeat.yml                         <- NOUVEAU
  ├── logs/                                        <- NOUVEAU
  │   └── .gitkeep
  ├── pom.xml
  └── .env


━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
PARTIE 1 - MISE EN PLACE PAR ALICE (ADMIN / LEAD DEV)
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

Alice est chargée de mettre en place la stack ELK pour toute l'équipe.
Elle fait cela une seule fois. Bob et Claire n'ont qu'à suivre la PARTIE 2.

────────────────────────────────────────────────────────────────────────────────
ÉTAPE 1.1 - Alice crée le fichier docker-compose pour la stack ELK
────────────────────────────────────────────────────────────────────────────────

  COMMANDES TERMINAL:
  ───────────────────
  cd taskmanager-java/
  mkdir -p docker/logstash/pipeline docker/filebeat logs
  touch logs/.gitkeep

  nano docker/docker-compose.elk.yml

  CONTENU COMPLET DU FICHIER docker/docker-compose.elk.yml:
  ──────────────────────────────────────────────────────────

  version: '3.8'

  services:

    # ─────────────────────────────────────────────────────
    # ELASTICSEARCH - Moteur de recherche et stockage
    # ─────────────────────────────────────────────────────
    elasticsearch:
      image: docker.elastic.co/elasticsearch/elasticsearch:8.11.0
      container_name: elasticsearch
      environment:
        - node.name=elasticsearch
        - cluster.name=taskmanager-java-cluster
        - discovery.type=single-node
        - bootstrap.memory_lock=true
        - xpack.security.enabled=false       # Désactivé pour le dev
        - "ES_JAVA_OPTS=-Xms512m -Xmx512m"
      ulimits:
        memlock:
          soft: -1
          hard: -1
      volumes:
        - elasticsearch_data:/usr/share/elasticsearch/data
      ports:
        - "9200:9200"
        - "9300:9300"
      networks:
        - elk
      healthcheck:
        test: ["CMD-SHELL", "curl -sf http://localhost:9200/_cluster/health || exit 1"]
        interval: 30s
        timeout: 10s
        retries: 5
        start_period: 60s

    # ─────────────────────────────────────────────────────
    # LOGSTASH - Pipeline d'ingestion et transformation
    # ─────────────────────────────────────────────────────
    logstash:
      image: docker.elastic.co/logstash/logstash:8.11.0
      container_name: logstash
      volumes:
        - ./logstash/pipeline:/usr/share/logstash/pipeline:ro
        - ./logstash/logstash.yml:/usr/share/logstash/config/logstash.yml:ro
        - ../logs:/logs:ro
      ports:
        - "5000:5000/tcp"     # Entrée TCP (logback-logstash-encoder)
        - "5000:5000/udp"     # Entrée UDP
        - "5044:5044"         # Entrée Beats (Filebeat)
        - "9600:9600"         # API monitoring Logstash
      environment:
        LS_JAVA_OPTS: "-Xmx256m -Xms256m"
      networks:
        - elk
      depends_on:
        elasticsearch:
          condition: service_healthy

    # ─────────────────────────────────────────────────────
    # KIBANA - Interface de visualisation
    # ─────────────────────────────────────────────────────
    kibana:
      image: docker.elastic.co/kibana/kibana:8.11.0
      container_name: kibana
      ports:
        - "5601:5601"
      environment:
        ELASTICSEARCH_URL: http://elasticsearch:9200
        ELASTICSEARCH_HOSTS: '["http://elasticsearch:9200"]'
        SERVER_NAME: kibana
      networks:
        - elk
      depends_on:
        elasticsearch:
          condition: service_healthy

    # ─────────────────────────────────────────────────────
    # FILEBEAT - Agent de collecte de fichiers de logs
    # ─────────────────────────────────────────────────────
    filebeat:
      image: docker.elastic.co/beats/filebeat:8.11.0
      container_name: filebeat
      user: root
      volumes:
        - ./filebeat/filebeat.yml:/usr/share/filebeat/filebeat.yml:ro
        - ../logs:/logs:ro
      command: filebeat -e -strict.perms=false
      networks:
        - elk
      depends_on:
        - logstash

  volumes:
    elasticsearch_data:
      driver: local

  networks:
    elk:
      driver: bridge

────────────────────────────────────────────────────────────────────────────────
ÉTAPE 1.2 - Alice crée la configuration Logstash
────────────────────────────────────────────────────────────────────────────────

  COMMANDE:
  ─────────
  nano docker/logstash/logstash.yml

  CONTENU:
  ────────
  http.host: "0.0.0.0"
  xpack.monitoring.elasticsearch.hosts: ["http://elasticsearch:9200"]
  log.level: warn

  ──────────────────────────────────────────────────────────────────────────────

  COMMANDE:
  ─────────
  nano docker/logstash/pipeline/taskmanager.conf

  CONTENU COMPLET DU PIPELINE taskmanager.conf:
  ──────────────────────────────────────────────

  # ══════════════════════════════════════════════════════════════════
  # PIPELINE LOGSTASH - TaskManager Java (Spring Boot)
  # ══════════════════════════════════════════════════════════════════

  # ──────────────────────────────────────────────────────────────────
  # INPUT: Sources d'entrée des logs
  # ──────────────────────────────────────────────────────────────────
  input {

    # Source 1: Logback via logstash-logback-encoder (TCP JSON)
    # Spring Boot envoie directement les logs en JSON sur ce port
    tcp {
      port  => 5000
      codec => json_lines
      tags  => ["spring-boot", "tcp-direct"]
    }

    # Source 2: Filebeat lit les fichiers de log JSON
    beats {
      port => 5044
      tags => ["filebeat", "file-based"]
    }

    # Source 3: Lecture directe du fichier JSON dans le conteneur
    file {
      path           => "/logs/taskmanager-*.json"
      start_position => "beginning"
      sincedb_path   => "/dev/null"
      codec          => json
      tags           => ["file-direct"]
    }
  }

  # ──────────────────────────────────────────────────────────────────
  # FILTER: Transformation et enrichissement des logs
  # ──────────────────────────────────────────────────────────────────
  filter {

    # Supprimer les champs internes non utiles
    mutate {
      remove_field => ["@version", "port", "host"]
    }

    # Normaliser le niveau de log en majuscule
    if [level] {
      mutate { uppercase => ["level"] }
    }

    # logback-logstash-encoder envoie le niveau dans "level"
    # mais aussi parfois dans "levelValue" (entier)
    # On normalise vers un champ "log_level"
    if [level] {
      mutate { add_field => { "log_level" => "%{level}" } }
    }

    # Parser le timestamp Logback (format ISO8601)
    date {
      match    => ["@timestamp", "ISO8601"]
      target   => "@timestamp"
      timezone => "UTC"
    }

    # Enrichissement: Convertir la durée si présente
    if [duration_ms] {
      mutate { convert => { "duration_ms" => "float" } }
    }

    # Enrichissement: Marquer les requêtes lentes (>500ms)
    if [duration_ms] and [duration_ms] > 500 {
      mutate {
        add_field => { "slow_request" => "true" }
        add_tag   => ["slow"]
      }
    }

    # Enrichissement: Normaliser le statut HTTP
    if [http_status] {
      mutate { convert => { "http_status" => "integer" } }
    }

    # Enrichissement: Tagger les erreurs HTTP
    if [http_status] and [http_status] >= 400 {
      mutate { add_tag => ["http_error"] }
    }
    if [http_status] and [http_status] >= 500 {
      mutate { add_tag => ["server_error"] }
    }

    # Enrichissement: Ajouter métadonnées applicatives
    mutate {
      add_field => {
        "app_name"    => "taskmanager-java"
        "tech_stack"  => "spring-boot"
        "environment" => "${APP_ENV:development}"
      }
    }

    # Enrichissement: Extraire le thread name Logback
    # logback-logstash-encoder inclut "thread_name"
    if [thread_name] {
      mutate {
        rename => { "thread_name" => "thread" }
      }
    }

    # Enrichissement: Parser les stack traces d'exceptions Java
    if [stack_trace] {
      # Extraire la première ligne (type d'exception)
      grok {
        match => {
          "stack_trace" => "^(?<exception_class>[a-zA-Z0-9_.]+Exception[^\n]*)"
        }
        tag_on_failure => []
      }
    }
  }

  # ──────────────────────────────────────────────────────────────────
  # OUTPUT: Destination des logs transformés
  # ──────────────────────────────────────────────────────────────────
  output {

    # Elasticsearch avec index journalier
    elasticsearch {
      hosts => ["http://elasticsearch:9200"]
      index => "taskmanager-java-logs-%{+YYYY.MM.dd}"
    }

    # Debug console (décommenter temporairement si besoin)
    # stdout { codec => rubydebug }
  }

────────────────────────────────────────────────────────────────────────────────
ÉTAPE 1.3 - Alice crée la configuration Filebeat
────────────────────────────────────────────────────────────────────────────────

  COMMANDE:
  ─────────
  nano docker/filebeat/filebeat.yml

  CONTENU:
  ────────

  filebeat.inputs:
    - type: log
      enabled: true
      paths:
        - /logs/taskmanager-*.json
      # Les logs Spring Boot sont en JSON grâce à logback-logstash-encoder
      json.keys_under_root: true
      json.add_error_key: true
      json.message_key: message
      # Gérer les stack traces Java sur plusieurs lignes
      multiline:
        type: pattern
        pattern: '^[[:space:]]+(at|\.{3})\b|^Caused by:'
        negate: false
        match: after
      fields:
        app: taskmanager-java
        env: development
      fields_under_root: true

  # Envoyer vers Logstash (et non directement vers Elasticsearch)
  output.logstash:
    hosts: ["logstash:5044"]
    loadbalance: true

  # Gestion des erreurs: réessayer si Logstash est indisponible
  queue.mem:
    events: 4096
    flush.min_events: 512
    flush.timeout: 5s

  logging.level: warning

────────────────────────────────────────────────────────────────────────────────
ÉTAPE 1.4 - Alice lance la stack ELK
────────────────────────────────────────────────────────────────────────────────

  COMMANDES TERMINAL:
  ───────────────────
  cd taskmanager-java/docker/

  # Lancer tous les services:
  docker-compose -f docker-compose.elk.yml up -d

  # Attendre ~2 minutes, puis vérifier:
  docker-compose -f docker-compose.elk.yml ps

  SORTIE ATTENDUE:
  ────────────────
  Name              Command                State                  Ports
  ──────────────────────────────────────────────────────────────────────────────
  elasticsearch     /bin/tini -- /usr/loca...   Up    0.0.0.0:9200->9200/tcp
  kibana            /bin/tini -- /usr/loca...   Up    0.0.0.0:5601->5601/tcp
  logstash          /usr/local/bin/docker-...   Up    0.0.0.0:5000->5000/tcp
  filebeat          /usr/local/bin/docker-...   Up

  # Test de connectivité Elasticsearch:
  curl http://localhost:9200

  RÉSULTAT ATTENDU:
  ─────────────────
  {
    "name" : "elasticsearch",
    "cluster_name" : "taskmanager-java-cluster",
    "version" : { "number" : "8.11.0", ... },
    "tagline" : "You Know, for Search"
  }

  # Santé du cluster:
  curl http://localhost:9200/_cluster/health?pretty

  RÉSULTAT ATTENDU:
  ─────────────────
  {
    "cluster_name" : "taskmanager-java-cluster",
    "status" : "yellow",   <- Normal en single-node (pas de réplicas)
    "number_of_nodes" : 1,
    ...
  }

────────────────────────────────────────────────────────────────────────────────
ÉTAPE 1.5 - Alice ajoute les dépendances Maven
────────────────────────────────────────────────────────────────────────────────

Alice modifie le fichier pom.xml du projet:

  SECTION À AJOUTER DANS pom.xml (dans <dependencies>):
  ───────────────────────────────────────────────────────

  <!-- ══════════════════════════════════════════════════ -->
  <!-- DÉPENDANCES ELK / LOGGING                         -->
  <!-- ══════════════════════════════════════════════════ -->

  <!-- logstash-logback-encoder: Formate les logs en JSON
       et permet d'envoyer vers Logstash via TCP.
       C'est LA bibliothèque clé pour Spring Boot + ELK -->
  <dependency>
      <groupId>net.logstash.logback</groupId>
      <artifactId>logstash-logback-encoder</artifactId>
      <version>7.4</version>
  </dependency>

  <!-- Spring AOP: Pour le logging aspect-oriented (LoggingAspect) -->
  <dependency>
      <groupId>org.springframework.boot</groupId>
      <artifactId>spring-boot-starter-aop</artifactId>
  </dependency>

  <!-- Lombok: Génère @Slf4j et autres annotations boilerplate -->
  <dependency>
      <groupId>org.projectlombok</groupId>
      <artifactId>lombok</artifactId>
      <optional>true</optional>
  </dependency>

  <!-- ══════════════════════════════════════════════════ -->
  <!-- DÉPENDANCES APPLICATIVES (si pas encore présentes) -->
  <!-- ══════════════════════════════════════════════════ -->

  <dependency>
      <groupId>org.springframework.boot</groupId>
      <artifactId>spring-boot-starter-web</artifactId>
  </dependency>

  <dependency>
      <groupId>org.springframework.boot</groupId>
      <artifactId>spring-boot-starter-data-jpa</artifactId>
  </dependency>

  <dependency>
      <groupId>org.springframework.boot</groupId>
      <artifactId>spring-boot-starter-validation</artifactId>
  </dependency>

  <!-- H2 pour les tests et le dev local -->
  <dependency>
      <groupId>com.h2database</groupId>
      <artifactId>h2</artifactId>
      <scope>runtime</scope>
  </dependency>

  <!-- PostgreSQL pour la production -->
  <dependency>
      <groupId>org.postgresql</groupId>
      <artifactId>postgresql</artifactId>
      <scope>runtime</scope>
  </dependency>

  <!-- Tests -->
  <dependency>
      <groupId>org.springframework.boot</groupId>
      <artifactId>spring-boot-starter-test</artifactId>
      <scope>test</scope>
  </dependency>

  PROPRIÉTÉ DE VERSION À AJOUTER dans <properties>:
  ─────────────────────────────────────────────────
  <java.version>17</java.version>

────────────────────────────────────────────────────────────────────────────────
ÉTAPE 1.6 - Alice configure Logback via logback-spring.xml
────────────────────────────────────────────────────────────────────────────────

Ce fichier est le cœur de l'intégration ELK côté Java.
Il remplace le logback.xml par défaut de Spring Boot.

  COMMANDE:
  ─────────
  nano src/main/resources/logback-spring.xml

  CONTENU COMPLET:
  ────────────────

  <?xml version="1.0" encoding="UTF-8"?>
  <!--
    logback-spring.xml - Configuration Logback pour TaskManager Java
    Utilise springProfile pour adapter la config selon l'environnement.
    logback-SPRING.xml (avec "spring") permet d'utiliser <springProfile>.
  -->
  <configuration>

    <!-- ═══════════════════════════════════════════════════════════ -->
    <!-- PROPRIÉTÉS GLOBALES                                         -->
    <!-- ═══════════════════════════════════════════════════════════ -->
    <springProperty scope="context" name="appName"
                    source="spring.application.name"
                    defaultValue="taskmanager-java"/>
    <springProperty scope="context" name="logstashHost"
                    source="logstash.host"
                    defaultValue="localhost"/>
    <springProperty scope="context" name="logstashPort"
                    source="logstash.port"
                    defaultValue="5000"/>
    <springProperty scope="context" name="appEnv"
                    source="app.environment"
                    defaultValue="development"/>

    <!-- Dossier et nom des fichiers de log -->
    <property name="LOG_DIR"  value="logs"/>
    <property name="LOG_FILE" value="${LOG_DIR}/taskmanager-${appEnv}"/>

    <!-- ═══════════════════════════════════════════════════════════ -->
    <!-- APPENDER 1: CONSOLE (format lisible humain - dev seulement) -->
    <!-- ═══════════════════════════════════════════════════════════ -->
    <appender name="CONSOLE" class="ch.qos.logback.core.ConsoleAppender">
      <encoder>
        <pattern>
          %d{yyyy-MM-dd HH:mm:ss.SSS} [%thread] %highlight(%-5level) %cyan(%logger{36}) - %msg%n%throwable
        </pattern>
        <charset>UTF-8</charset>
      </encoder>
    </appender>

    <!-- ═══════════════════════════════════════════════════════════ -->
    <!-- APPENDER 2: FICHIER JSON LOCAL (pour Filebeat)             -->
    <!-- ═══════════════════════════════════════════════════════════ -->
    <appender name="FILE_JSON"
              class="ch.qos.logback.core.rolling.RollingFileAppender">
      <file>${LOG_FILE}.json</file>

      <!-- Rotation: nouveau fichier chaque jour, max 30 jours, max 500MB -->
      <rollingPolicy class="ch.qos.logback.core.rolling.TimeBasedRollingPolicy">
        <fileNamePattern>${LOG_FILE}.%d{yyyy-MM-dd}.json.gz</fileNamePattern>
        <maxHistory>30</maxHistory>
        <totalSizeCap>500MB</totalSizeCap>
      </rollingPolicy>

      <!-- Encoder JSON via logstash-logback-encoder -->
      <encoder class="net.logstash.logback.encoder.LogstashEncoder">
        <!-- Champs personnalisés ajoutés à CHAQUE log -->
        <customFields>{"app":"${appName}","env":"${appEnv}","tech":"spring-boot"}</customFields>

        <!-- Inclure les champs MDC (Mapped Diagnostic Context) -->
        <includeMdc>true</includeMdc>

        <!-- Inclure le contexte (classe, méthode, ligne) -->
        <includeCallerData>false</includeCallerData>

        <!-- Format du timestamp -->
        <timestampPattern>yyyy-MM-dd'T'HH:mm:ss.SSS'Z'</timestampPattern>
        <timeZone>UTC</timeZone>

        <!-- Renommer certains champs pour cohérence avec notre pipeline -->
        <fieldNames>
          <timestamp>@timestamp</timestamp>
          <message>message</message>
          <logger>logger_name</logger>
          <thread>thread_name</thread>
          <level>level</level>
          <levelValue>level_value</levelValue>
          <stackTrace>stack_trace</stackTrace>
        </fieldNames>
      </encoder>
    </appender>

    <!-- ═══════════════════════════════════════════════════════════ -->
    <!-- APPENDER 3: LOGSTASH TCP (envoi direct en temps réel)      -->
    <!-- ═══════════════════════════════════════════════════════════ -->
    <appender name="LOGSTASH"
              class="net.logstash.logback.appender.LogstashTcpSocketAppender">

      <!-- Adresse du serveur Logstash -->
      <destination>${logstashHost}:${logstashPort}</destination>

      <!-- Reconnexion automatique si Logstash est temporairement indisponible -->
      <reconnectionDelay>10 seconds</reconnectionDelay>

      <!-- Queue interne: si Logstash est lent, on ne bloque pas l'app -->
      <queueSize>512</queueSize>
      <keepAliveDuration>5 minutes</keepAliveDuration>

      <!-- Même encodeur JSON que pour le fichier -->
      <encoder class="net.logstash.logback.encoder.LogstashEncoder">
        <customFields>{"app":"${appName}","env":"${appEnv}","tech":"spring-boot"}</customFields>
        <includeMdc>true</includeMdc>
        <fieldNames>
          <timestamp>@timestamp</timestamp>
          <message>message</message>
          <logger>logger_name</logger>
          <thread>thread_name</thread>
          <level>level</level>
          <stackTrace>stack_trace</stackTrace>
        </fieldNames>
      </encoder>
    </appender>

    <!-- ═══════════════════════════════════════════════════════════ -->
    <!-- APPENDER ASYNC: Wrapper asynchrone pour ne pas bloquer     -->
    <!-- ═══════════════════════════════════════════════════════════ -->
    <appender name="ASYNC_LOGSTASH"
              class="ch.qos.logback.classic.AsyncAppender">
      <appender-ref ref="LOGSTASH"/>
      <queueSize>256</queueSize>
      <discardingThreshold>0</discardingThreshold>
      <!-- false = ne pas perdre de logs si la queue est pleine -->
      <neverBlock>false</neverBlock>
    </appender>

    <appender name="ASYNC_FILE"
              class="ch.qos.logback.classic.AsyncAppender">
      <appender-ref ref="FILE_JSON"/>
      <queueSize>256</queueSize>
      <discardingThreshold>0</discardingThreshold>
    </appender>

    <!-- ═══════════════════════════════════════════════════════════ -->
    <!-- PROFIL: DÉVELOPPEMENT (console + fichier + logstash)       -->
    <!-- ═══════════════════════════════════════════════════════════ -->
    <springProfile name="dev,default">
      <!-- Réduire le bruit des frameworks -->
      <logger name="org.hibernate.SQL" level="DEBUG"/>
      <logger name="org.hibernate.type.descriptor.sql" level="TRACE"/>
      <logger name="org.springframework.web" level="DEBUG"/>
      <logger name="com.equipe.taskmanager" level="DEBUG"/>

      <root level="INFO">
        <appender-ref ref="CONSOLE"/>
        <appender-ref ref="ASYNC_FILE"/>
        <appender-ref ref="ASYNC_LOGSTASH"/>
      </root>
    </springProfile>

    <!-- ═══════════════════════════════════════════════════════════ -->
    <!-- PROFIL: TEST (console seulement, pas de fichier ni ELK)    -->
    <!-- ═══════════════════════════════════════════════════════════ -->
    <springProfile name="test">
      <logger name="com.equipe.taskmanager" level="DEBUG"/>
      <logger name="org.springframework" level="WARN"/>
      <logger name="org.hibernate" level="WARN"/>

      <root level="WARN">
        <appender-ref ref="CONSOLE"/>
        <!-- PAS de ASYNC_FILE ni ASYNC_LOGSTASH en test! -->
      </root>
    </springProfile>

    <!-- ═══════════════════════════════════════════════════════════ -->
    <!-- PROFIL: PRODUCTION (fichier + logstash, pas de console)    -->
    <!-- ═══════════════════════════════════════════════════════════ -->
    <springProfile name="prod">
      <!-- En prod, pas de SQL debug -->
      <logger name="org.hibernate" level="WARN"/>
      <logger name="org.springframework" level="WARN"/>
      <logger name="com.equipe.taskmanager" level="INFO"/>

      <root level="INFO">
        <!-- PAS de CONSOLE en production (inutile et lent) -->
        <appender-ref ref="ASYNC_FILE"/>
        <appender-ref ref="ASYNC_LOGSTASH"/>
      </root>
    </springProfile>

  </configuration>

────────────────────────────────────────────────────────────────────────────────
ÉTAPE 1.7 - Alice configure application.yml avec les paramètres ELK
────────────────────────────────────────────────────────────────────────────────

  COMMANDE:
  ─────────
  nano src/main/resources/application.yml

  CONTENU:
  ────────

  spring:
    application:
      name: taskmanager-java
    datasource:
      url: jdbc:h2:mem:taskmanager
      driver-class-name: org.h2.Driver
      username: sa
      password:
    jpa:
      hibernate:
        ddl-auto: create-drop
      show-sql: false
      properties:
        hibernate:
          format_sql: true

  server:
    port: 8080

  # ── Configuration ELK / Logstash ──────────────────────────────────
  logstash:
    host: ${LOGSTASH_HOST:localhost}
    port: ${LOGSTASH_PORT:5000}

  app:
    environment: ${APP_ENV:development}

  # ── Logging général ──────────────────────────────────────────────
  logging:
    level:
      root: INFO
      com.equipe.taskmanager: DEBUG
      org.springframework.web: INFO
    file:
      name: logs/taskmanager-development.json

  ──────────────────────────────────────────────────────────────────────────────

  COMMANDE:
  ─────────
  nano src/main/resources/application-dev.yml

  CONTENU:
  ────────

  # Profil DEV: configuration locale avec ELK
  spring:
    profiles:
      active: dev
    h2:
      console:
        enabled: true    # Console H2 accessible sur /h2-console

  logstash:
    host: localhost      # ELK tourne en local (ou IP d'Alice)
    port: 5000

  app:
    environment: development

  logging:
    level:
      com.equipe.taskmanager: DEBUG
      org.hibernate.SQL: DEBUG

  ──────────────────────────────────────────────────────────────────────────────

  COMMANDE:
  ─────────
  nano src/main/resources/application-test.yml

  CONTENU:
  ────────

  # Profil TEST: pas de Logstash, logs console seulement
  spring:
    datasource:
      url: jdbc:h2:mem:testdb;DB_CLOSE_DELAY=-1
    jpa:
      hibernate:
        ddl-auto: create-drop

  # Désactiver l'envoi vers Logstash en test
  logstash:
    host: 127.0.0.1
    port: 1    # Port invalide = pas d'envoi

  app:
    environment: test

  logging:
    level:
      com.equipe.taskmanager: DEBUG
      org.springframework: WARN
      org.hibernate: WARN

────────────────────────────────────────────────────────────────────────────────
ÉTAPE 1.8 - Alice crée le code Java principal de l'application
────────────────────────────────────────────────────────────────────────────────

  ── TaskManagerApplication.java ─────────────────────────────────────────────

  package com.equipe.taskmanager;

  import org.slf4j.Logger;
  import org.slf4j.LoggerFactory;
  import org.springframework.boot.SpringApplication;
  import org.springframework.boot.autoconfigure.SpringBootApplication;

  @SpringBootApplication
  public class TaskManagerApplication {

      private static final Logger log =
          LoggerFactory.getLogger(TaskManagerApplication.class);

      public static void main(String[] args) {
          SpringApplication.run(TaskManagerApplication.class, args);
          log.info("TaskManager API démarrée avec succès",
              // structured argument via logstash-logback-encoder:
              // on peut passer des paires clé/valeur avec StructuredArguments
          );
      }
  }

  ── model/Task.java ──────────────────────────────────────────────────────────

  package com.equipe.taskmanager.model;

  import jakarta.persistence.*;
  import lombok.Data;
  import lombok.NoArgsConstructor;
  import lombok.AllArgsConstructor;
  import lombok.Builder;
  import java.time.LocalDateTime;

  @Entity
  @Table(name = "tasks")
  @Data
  @NoArgsConstructor
  @AllArgsConstructor
  @Builder
  public class Task {

      @Id
      @GeneratedValue(strategy = GenerationType.IDENTITY)
      private Long id;

      @Column(nullable = false, length = 200)
      private String title;

      @Column(columnDefinition = "TEXT")
      private String description;

      @Builder.Default
      private Boolean done = false;

      @Builder.Default
      @Column(name = "created_at")
      private LocalDateTime createdAt = LocalDateTime.now();
  }

  ── dto/TaskRequest.java ─────────────────────────────────────────────────────

  package com.equipe.taskmanager.dto;

  import jakarta.validation.constraints.NotBlank;
  import jakarta.validation.constraints.Size;
  import lombok.Data;

  @Data
  public class TaskRequest {

      @NotBlank(message = "Le titre est obligatoire")
      @Size(max = 200, message = "Le titre ne peut pas dépasser 200 caractères")
      private String title;

      private String description;

      private Boolean done;
  }

  ── dto/TaskResponse.java ────────────────────────────────────────────────────

  package com.equipe.taskmanager.dto;

  import com.equipe.taskmanager.model.Task;
  import lombok.Data;
  import java.time.LocalDateTime;

  @Data
  public class TaskResponse {
      private Long          id;
      private String        title;
      private String        description;
      private Boolean       done;
      private LocalDateTime createdAt;

      public static TaskResponse fromTask(Task task) {
          TaskResponse response = new TaskResponse();
          response.setId(task.getId());
          response.setTitle(task.getTitle());
          response.setDescription(task.getDescription());
          response.setDone(task.getDone());
          response.setCreatedAt(task.getCreatedAt());
          return response;
      }
  }

  ── repository/TaskRepository.java ──────────────────────────────────────────

  package com.equipe.taskmanager.repository;

  import com.equipe.taskmanager.model.Task;
  import org.springframework.data.jpa.repository.JpaRepository;
  import org.springframework.data.jpa.repository.Query;
  import org.springframework.data.repository.query.Param;
  import org.springframework.stereotype.Repository;
  import java.util.List;

  @Repository
  public interface TaskRepository extends JpaRepository<Task, Long> {

      List<Task> findByDone(Boolean done);

      // Recherche insensible à la casse
      @Query("SELECT t FROM Task t WHERE LOWER(t.title) LIKE LOWER(CONCAT('%', :query, '%'))")
      List<Task> searchByTitle(@Param("query") String query);
  }

  ── exception/TaskNotFoundException.java ────────────────────────────────────

  package com.equipe.taskmanager.exception;

  public class TaskNotFoundException extends RuntimeException {
      public TaskNotFoundException(Long id) {
          super("Tâche non trouvée avec l'id: " + id);
      }
  }

────────────────────────────────────────────────────────────────────────────────
ÉTAPE 1.9 - Alice crée le Service avec logging métier structuré
────────────────────────────────────────────────────────────────────────────────

  ── service/TaskService.java ─────────────────────────────────────────────────

  package com.equipe.taskmanager.service;

  import com.equipe.taskmanager.dto.TaskRequest;
  import com.equipe.taskmanager.dto.TaskResponse;
  import com.equipe.taskmanager.exception.TaskNotFoundException;
  import com.equipe.taskmanager.model.Task;
  import com.equipe.taskmanager.repository.TaskRepository;
  import lombok.RequiredArgsConstructor;
  import net.logstash.logback.argument.StructuredArguments;
  import org.slf4j.Logger;
  import org.slf4j.LoggerFactory;
  import org.springframework.stereotype.Service;
  import org.springframework.transaction.annotation.Transactional;
  import java.util.List;
  import java.util.stream.Collectors;

  @Service
  @RequiredArgsConstructor
  public class TaskService {

      // Logger SLF4J standard (Logback l'implémente)
      private static final Logger log = LoggerFactory.getLogger(TaskService.class);

      private final TaskRepository taskRepository;

      // StructuredArguments permet d'ajouter des champs JSON structurés
      // dans les logs (au lieu de concaténer dans le message)
      // Ces champs sont indexés séparément dans Elasticsearch!

      public List<TaskResponse> getAllTasks() {
          List<Task> tasks = taskRepository.findAll();

          // StructuredArguments.kv("clé", valeur) -> champ JSON dans le log
          log.info("Liste des tâches récupérée",
              StructuredArguments.kv("event_type", "task_list"),
              StructuredArguments.kv("task_count", tasks.size())
          );

          return tasks.stream()
              .map(TaskResponse::fromTask)
              .collect(Collectors.toList());
      }

      @Transactional
      public TaskResponse createTask(TaskRequest request) {
          Task task = Task.builder()
              .title(request.getTitle())
              .description(request.getDescription())
              .build();

          task = taskRepository.save(task);

          log.info("Tâche créée avec succès",
              StructuredArguments.kv("event_type",  "task_created"),
              StructuredArguments.kv("task_id",     task.getId()),
              StructuredArguments.kv("task_title",  task.getTitle())
          );

          return TaskResponse.fromTask(task);
      }

      public TaskResponse getTaskById(Long id) {
          Task task = taskRepository.findById(id)
              .orElseThrow(() -> {
                  log.warn("Tâche introuvable lors de la consultation",
                      StructuredArguments.kv("event_type", "task_not_found"),
                      StructuredArguments.kv("task_id",    id)
                  );
                  return new TaskNotFoundException(id);
              });

          log.debug("Tâche consultée",
              StructuredArguments.kv("event_type", "task_read"),
              StructuredArguments.kv("task_id",    id)
          );

          return TaskResponse.fromTask(task);
      }

      @Transactional
      public TaskResponse updateTask(Long id, TaskRequest request) {
          Task task = taskRepository.findById(id)
              .orElseThrow(() -> {
                  log.warn("Tâche introuvable lors de la mise à jour",
                      StructuredArguments.kv("event_type", "task_update_not_found"),
                      StructuredArguments.kv("task_id",    id)
                  );
                  return new TaskNotFoundException(id);
              });

          boolean wasCompleted = Boolean.TRUE.equals(task.getDone());

          if (request.getTitle() != null) task.setTitle(request.getTitle());
          if (request.getDescription() != null) task.setDescription(request.getDescription());
          if (request.getDone() != null) task.setDone(request.getDone());

          task = taskRepository.save(task);

          // Log spécifique si la tâche vient d'être marquée comme terminée
          if (!wasCompleted && Boolean.TRUE.equals(task.getDone())) {
              log.info("Tâche marquée comme terminée",
                  StructuredArguments.kv("event_type",  "task_completed"),
                  StructuredArguments.kv("task_id",     task.getId()),
                  StructuredArguments.kv("task_title",  task.getTitle())
              );
          } else {
              log.info("Tâche mise à jour",
                  StructuredArguments.kv("event_type", "task_updated"),
                  StructuredArguments.kv("task_id",    task.getId())
              );
          }

          return TaskResponse.fromTask(task);
      }

      @Transactional
      public void deleteTask(Long id) {
          Task task = taskRepository.findById(id)
              .orElseThrow(() -> {
                  log.warn("Tâche introuvable lors de la suppression",
                      StructuredArguments.kv("event_type", "task_delete_not_found"),
                      StructuredArguments.kv("task_id",    id)
                  );
                  return new TaskNotFoundException(id);
              });

          String title = task.getTitle();
          taskRepository.delete(task);

          log.info("Tâche supprimée",
              StructuredArguments.kv("event_type",  "task_deleted"),
              StructuredArguments.kv("task_id",     id),
              StructuredArguments.kv("task_title",  title)
          );
      }

      public List<TaskResponse> searchTasks(String query) {
          if (query == null || query.trim().length() < 2) {
              log.warn("Recherche trop courte refusée",
                  StructuredArguments.kv("event_type", "search_rejected"),
                  StructuredArguments.kv("query_length", query == null ? 0 : query.length())
              );
              throw new IllegalArgumentException("La requête doit faire au moins 2 caractères");
          }

          List<Task> results = taskRepository.searchByTitle(query.trim());

          log.info("Recherche de tâches effectuée",
              StructuredArguments.kv("event_type",    "task_search"),
              StructuredArguments.kv("query",         query.trim()),
              StructuredArguments.kv("result_count",  results.size())
          );

          return results.stream()
              .map(TaskResponse::fromTask)
              .collect(Collectors.toList());
      }

      public List<TaskResponse> getTasksByStatus(Boolean done) {
          List<Task> tasks = taskRepository.findByDone(done);

          log.info("Filtrage des tâches par statut",
              StructuredArguments.kv("event_type",  "task_filter"),
              StructuredArguments.kv("done",        done),
              StructuredArguments.kv("task_count",  tasks.size())
          );

          return tasks.stream()
              .map(TaskResponse::fromTask)
              .collect(Collectors.toList());
      }
  }

────────────────────────────────────────────────────────────────────────────────
ÉTAPE 1.10 - Alice crée le Controller REST
────────────────────────────────────────────────────────────────────────────────

  ── controller/TaskController.java ──────────────────────────────────────────

  package com.equipe.taskmanager.controller;

  import com.equipe.taskmanager.dto.TaskRequest;
  import com.equipe.taskmanager.dto.TaskResponse;
  import com.equipe.taskmanager.service.TaskService;
  import jakarta.validation.Valid;
  import lombok.RequiredArgsConstructor;
  import org.springframework.http.HttpStatus;
  import org.springframework.http.ResponseEntity;
  import org.springframework.web.bind.annotation.*;
  import java.util.List;

  @RestController
  @RequestMapping("/api/tasks")
  @RequiredArgsConstructor
  public class TaskController {

      private final TaskService taskService;

      @GetMapping
      public ResponseEntity<List<TaskResponse>> getAllTasks() {
          return ResponseEntity.ok(taskService.getAllTasks());
      }

      @PostMapping
      public ResponseEntity<TaskResponse> createTask(
              @Valid @RequestBody TaskRequest request) {
          return ResponseEntity.status(HttpStatus.CREATED)
              .body(taskService.createTask(request));
      }

      @GetMapping("/{id}")
      public ResponseEntity<TaskResponse> getTaskById(@PathVariable Long id) {
          return ResponseEntity.ok(taskService.getTaskById(id));
      }

      @PutMapping("/{id}")
      public ResponseEntity<TaskResponse> updateTask(
              @PathVariable Long id,
              @Valid @RequestBody TaskRequest request) {
          return ResponseEntity.ok(taskService.updateTask(id, request));
      }

      @DeleteMapping("/{id}")
      public ResponseEntity<Void> deleteTask(@PathVariable Long id) {
          taskService.deleteTask(id);
          return ResponseEntity.noContent().build();
      }

      @GetMapping("/search")
      public ResponseEntity<List<TaskResponse>> searchTasks(
              @RequestParam String q) {
          return ResponseEntity.ok(taskService.searchTasks(q));
      }

      @GetMapping("/filter")
      public ResponseEntity<List<TaskResponse>> filterByStatus(
              @RequestParam Boolean done) {
          return ResponseEntity.ok(taskService.getTasksByStatus(done));
      }
  }

────────────────────────────────────────────────────────────────────────────────
ÉTAPE 1.11 - Alice crée le filtre HTTP pour logger toutes les requêtes
────────────────────────────────────────────────────────────────────────────────

  ── logging/RequestLoggingFilter.java ───────────────────────────────────────

  package com.equipe.taskmanager.logging;

  import jakarta.servlet.FilterChain;
  import jakarta.servlet.ServletException;
  import jakarta.servlet.http.HttpServletRequest;
  import jakarta.servlet.http.HttpServletResponse;
  import net.logstash.logback.argument.StructuredArguments;
  import org.slf4j.Logger;
  import org.slf4j.LoggerFactory;
  import org.slf4j.MDC;
  import org.springframework.stereotype.Component;
  import org.springframework.web.filter.OncePerRequestFilter;
  import java.io.IOException;
  import java.util.UUID;

  /**
   * Filtre HTTP Spring qui logue automatiquement chaque requête entrante.
   * S'exécute une seule fois par requête (OncePerRequestFilter).
   *
   * Ce filtre enregistre dans Elasticsearch (via Logstash):
   * - La méthode HTTP (GET, POST, etc.)
   * - L'URI de la requête
   * - Le code de statut de la réponse
   * - La durée de traitement en millisecondes
   * - L'adresse IP du client
   * - Un identifiant unique de requête (request_id) pour le tracing
   *
   * Le request_id est injecté dans le MDC (Mapped Diagnostic Context)
   * ce qui signifie qu'il apparaîtra AUTOMATIQUEMENT dans TOUS les logs
   * générés pendant le traitement de cette requête, même dans le Service.
   */
  @Component
  public class RequestLoggingFilter extends OncePerRequestFilter {

      private static final Logger log =
          LoggerFactory.getLogger(RequestLoggingFilter.class);

      @Override
      protected void doFilterInternal(HttpServletRequest request,
                                      HttpServletResponse response,
                                      FilterChain filterChain)
              throws ServletException, IOException {

          // Générer un identifiant unique pour cette requête
          // Permet de retrouver TOUS les logs d'une requête dans Kibana
          String requestId = UUID.randomUUID().toString().substring(0, 8);

          // Injecter dans le MDC: tous les logs suivants auront ce champ
          MDC.put("request_id",  requestId);
          MDC.put("http_method", request.getMethod());
          MDC.put("http_path",   request.getRequestURI());
          MDC.put("remote_ip",   getClientIp(request));

          long startTime = System.currentTimeMillis();

          try {
              // Laisser passer la requête vers le contrôleur
              filterChain.doFilter(request, response);

          } finally {
              // Ce bloc s'exécute TOUJOURS, même en cas d'exception
              long durationMs = System.currentTimeMillis() - startTime;

              log.info("Requête HTTP traitée",
                  StructuredArguments.kv("event_type",  "http_request"),
                  StructuredArguments.kv("http_method", request.getMethod()),
                  StructuredArguments.kv("http_path",   request.getRequestURI()),
                  StructuredArguments.kv("http_status", response.getStatus()),
                  StructuredArguments.kv("duration_ms", durationMs),
                  StructuredArguments.kv("remote_ip",   getClientIp(request)),
                  StructuredArguments.kv("request_id",  requestId),
                  StructuredArguments.kv("user_agent",
                      truncate(request.getHeader("User-Agent"), 200))
              );

              // TRÈS IMPORTANT: Nettoyer le MDC après chaque requête
              // Sinon les valeurs contaminent les requêtes suivantes
              // (Spring Boot réutilise les threads!)
              MDC.clear();
          }
      }

      /**
       * Récupère l'IP réelle du client, même derrière un proxy/load balancer.
       */
      private String getClientIp(HttpServletRequest request) {
          String xForwardedFor = request.getHeader("X-Forwarded-For");
          if (xForwardedFor != null && !xForwardedFor.isEmpty()) {
              // X-Forwarded-For peut contenir une liste: "ip1, ip2, ip3"
              return xForwardedFor.split(",")[0].trim();
          }
          return request.getRemoteAddr();
      }

      private String truncate(String value, int maxLength) {
          if (value == null) return "";
          return value.length() <= maxLength ? value : value.substring(0, maxLength);
      }
  }

────────────────────────────────────────────────────────────────────────────────
ÉTAPE 1.12 - Alice crée le GlobalExceptionHandler avec logging
────────────────────────────────────────────────────────────────────────────────

  ── exception/GlobalExceptionHandler.java ───────────────────────────────────

  package com.equipe.taskmanager.exception;

  import net.logstash.logback.argument.StructuredArguments;
  import org.slf4j.Logger;
  import org.slf4j.LoggerFactory;
  import org.springframework.http.HttpStatus;
  import org.springframework.http.ResponseEntity;
  import org.springframework.validation.FieldError;
  import org.springframework.web.bind.MethodArgumentNotValidException;
  import org.springframework.web.bind.annotation.ExceptionHandler;
  import org.springframework.web.bind.annotation.RestControllerAdvice;
  import java.time.LocalDateTime;
  import java.util.HashMap;
  import java.util.Map;

  @RestControllerAdvice
  public class GlobalExceptionHandler {

      private static final Logger log =
          LoggerFactory.getLogger(GlobalExceptionHandler.class);

      // ── 404: Tâche non trouvée ─────────────────────────────────────────
      @ExceptionHandler(TaskNotFoundException.class)
      public ResponseEntity<Map<String, Object>> handleTaskNotFound(
              TaskNotFoundException ex) {

          log.warn("Ressource non trouvée",
              StructuredArguments.kv("event_type",     "resource_not_found"),
              StructuredArguments.kv("exception_msg",  ex.getMessage()),
              StructuredArguments.kv("http_status",    404)
          );

          return ResponseEntity.status(HttpStatus.NOT_FOUND)
              .body(buildErrorBody(404, ex.getMessage()));
      }

      // ── 400: Validation échouée ────────────────────────────────────────
      @ExceptionHandler(MethodArgumentNotValidException.class)
      public ResponseEntity<Map<String, Object>> handleValidationErrors(
              MethodArgumentNotValidException ex) {

          Map<String, String> errors = new HashMap<>();
          ex.getBindingResult().getAllErrors().forEach(error -> {
              String field   = ((FieldError) error).getField();
              String message = error.getDefaultMessage();
              errors.put(field, message);
          });

          log.warn("Validation échouée sur la requête",
              StructuredArguments.kv("event_type",       "validation_error"),
              StructuredArguments.kv("validation_errors", errors.toString()),
              StructuredArguments.kv("http_status",       400)
          );

          Map<String, Object> body = buildErrorBody(400, "Validation échouée");
          body.put("errors", errors);
          return ResponseEntity.badRequest().body(body);
      }

      // ── 400: Argument illégal ──────────────────────────────────────────
      @ExceptionHandler(IllegalArgumentException.class)
      public ResponseEntity<Map<String, Object>> handleIllegalArgument(
              IllegalArgumentException ex) {

          log.warn("Argument invalide",
              StructuredArguments.kv("event_type",    "invalid_argument"),
              StructuredArguments.kv("exception_msg", ex.getMessage()),
              StructuredArguments.kv("http_status",   400)
          );

          return ResponseEntity.badRequest()
              .body(buildErrorBody(400, ex.getMessage()));
      }

      // ── 500: Erreur interne inattendue ─────────────────────────────────
      @ExceptionHandler(Exception.class)
      public ResponseEntity<Map<String, Object>> handleGenericException(
              Exception ex) {

          // exc_info=true équivalent en Java: logger.error(..., ex)
          // -> Logback inclut la stack trace dans le log
          log.error("Erreur interne non gérée",
              StructuredArguments.kv("event_type",    "internal_error"),
              StructuredArguments.kv("exception_type", ex.getClass().getName()),
              StructuredArguments.kv("http_status",   500),
              ex   // <- Passer l'exception pour inclure la stack trace
          );

          return ResponseEntity.internalServerError()
              .body(buildErrorBody(500, "Erreur interne du serveur"));
      }

      private Map<String, Object> buildErrorBody(int status, String message) {
          Map<String, Object> body = new HashMap<>();
          body.put("status",    status);
          body.put("message",   message);
          body.put("timestamp", LocalDateTime.now().toString());
          return body;
      }
  }

────────────────────────────────────────────────────────────────────────────────
ÉTAPE 1.13 - Alice crée le LoggingAspect (AOP) pour le logging automatique
────────────────────────────────────────────────────────────────────────────────

L'AOP (Aspect-Oriented Programming) permet de logger l'entrée/sortie de
méthodes automatiquement sans polluer le code métier.

  ── logging/LoggingAspect.java ───────────────────────────────────────────────

  package com.equipe.taskmanager.logging;

  import net.logstash.logback.argument.StructuredArguments;
  import org.aspectj.lang.ProceedingJoinPoint;
  import org.aspectj.lang.annotation.Around;
  import org.aspectj.lang.annotation.Aspect;
  import org.aspectj.lang.reflect.MethodSignature;
  import org.slf4j.Logger;
  import org.slf4j.LoggerFactory;
  import org.springframework.stereotype.Component;

  /**
   * Aspect de logging automatique pour tous les services.
   *
   * Toute méthode dans com.equipe.taskmanager.service est automatiquement
   * loggée avec sa durée d'exécution, sans modifier le code du service.
   *
   * Exemple de log généré:
   * {
   *   "message": "Appel service: TaskService.createTask",
   *   "event_type": "service_call",
   *   "class": "TaskService",
   *   "method": "createTask",
   *   "duration_ms": 23.5,
   *   "success": true
   * }
   */
  @Aspect
  @Component
  public class LoggingAspect {

      private static final Logger log =
          LoggerFactory.getLogger(LoggingAspect.class);

      /**
       * Intercepte TOUTES les méthodes publiques des classes @Service.
       * "execution(* com.equipe.taskmanager.service.*.*(..))" signifie:
       * - Toute méthode (*)
       * - De n'importe quelle classe dans le package service
       * - Avec n'importe quels paramètres (..)
       */
      @Around("execution(* com.equipe.taskmanager.service.*.*(..))")
      public Object logServiceCall(ProceedingJoinPoint joinPoint) throws Throwable {

          MethodSignature signature  = (MethodSignature) joinPoint.getSignature();
          String          className  = joinPoint.getTarget().getClass().getSimpleName();
          String          methodName = signature.getName();
          long            startTime  = System.currentTimeMillis();

          try {
              // Exécuter la méthode interceptée
              Object result = joinPoint.proceed();

              long durationMs = System.currentTimeMillis() - startTime;

              log.debug("Appel service réussi: {}.{}",
                  className, methodName,
                  StructuredArguments.kv("event_type",  "service_call"),
                  StructuredArguments.kv("class",       className),
                  StructuredArguments.kv("method",      methodName),
                  StructuredArguments.kv("duration_ms", durationMs),
                  StructuredArguments.kv("success",     true)
              );

              return result;

          } catch (Exception ex) {
              long durationMs = System.currentTimeMillis() - startTime;

              log.error("Erreur dans le service: {}.{}",
                  className, methodName,
                  StructuredArguments.kv("event_type",     "service_error"),
                  StructuredArguments.kv("class",          className),
                  StructuredArguments.kv("method",         methodName),
                  StructuredArguments.kv("duration_ms",    durationMs),
                  StructuredArguments.kv("success",        false),
                  StructuredArguments.kv("exception_type", ex.getClass().getName())
              );

              // Relancer l'exception pour ne pas bloquer le traitement normal
              throw ex;
          }
      }
  }

────────────────────────────────────────────────────────────────────────────────
ÉTAPE 1.14 - Alice configure Kibana: index pattern et dashboards
────────────────────────────────────────────────────────────────────────────────

Alice lance l'application pour générer des logs de test:

  COMMANDES TERMINAL:
  ───────────────────
  cd taskmanager-java/
  export LOGSTASH_HOST=localhost
  export LOGSTASH_PORT=5000
  export APP_ENV=development

  # Compiler et lancer:
  mvn spring-boot:run -Dspring-boot.run.profiles=dev

  # Dans un autre terminal, générer des requêtes:
  curl http://localhost:8080/api/tasks
  curl -X POST http://localhost:8080/api/tasks \
       -H "Content-Type: application/json" \
       -d '{"title":"Test ELK Java","description":"Premier log"}'
  curl http://localhost:8080/api/tasks/1
  curl http://localhost:8080/api/tasks/9999    # -> 404

INTERFACE WEB Kibana (http://localhost:5601) - Créer l'index pattern:

  1. Menu ≡ > Management > Stack Management > Kibana > Data Views
  2. Cliquer "+ Create data view"
  3. Remplir:
     ┌────────────────────────────────────────────────────────────────┐
     │  Create data view                                              │
     │                                                                │
     │  Name*:           [TaskManager Java Logs              ]        │
     │  Index pattern*:  [taskmanager-java-logs-*            ]        │
     │  Timestamp field: [@timestamp                      [BLACK_DOWN-POINTING_TRIANGLE]]         │
     │                                                                │
     │  Aperçu: [OK] taskmanager-java-logs-2024.01.15 (352 documents) │
     │                                                                │
     │  [    Save data view to Kibana    ]                            │
     └────────────────────────────────────────────────────────────────┘
  4. Cliquer "Save data view to Kibana"

INTERFACE WEB Kibana - Créer le Dashboard "Vue Générale":

  1. Menu ≡ > Analytics > Dashboards > "+ Create dashboard"
  2. "+ Create visualization"

  Visualisation 1: Histogramme des requêtes par heure
  - Type: Bar chart
  - X: @timestamp (date histogram, 1 heure)
  - Y: Count
  - Titre: "Requêtes par heure"

  Visualisation 2: Camembert des codes HTTP
  - Type: Pie chart
  - Slice: Terms on http_status (top 10)
  - Titre: "Codes HTTP"

  Visualisation 3: Durée médiane par route
  - Type: Data table
  - Rows: Terms on http_path.keyword
  - Metric: Median of duration_ms
  - Titre: "Durée par route (médiane ms)"

  Visualisation 4: Taux d'erreur (gauge)
  - Type: Gauge
  - Filtre: http_status >= 500
  - Seuils: vert 0-2%, jaune 2-5%, rouge >5%
  - Titre: "Taux erreur 5xx (%)"

  3. Sauvegarder sous: "TaskManager Java - Vue Générale"

INTERFACE WEB Kibana - Créer le Dashboard "Erreurs Java":

  1. Nouveau dashboard: "TaskManager Java - Erreurs"
  2. Visualisations:

  Visualisation A: Timeline des exceptions Java
  - Filtre KQL: level: "ERROR" OR stack_trace: *
  - Type: Bar chart par heure
  - Titre: "Exceptions Java dans le temps"

  Visualisation B: Top des types d'exception
  - Filtre: exception_class: *
  - Type: Terms on exception_class.keyword
  - Titre: "Types d'exceptions les plus fréquents"

  Visualisation C: Tableau des dernières erreurs
  - Filtre: level: "ERROR"
  - Type: Data table
  - Colonnes: @timestamp, message, exception_class, http_path
  - Titre: "10 dernières erreurs"


━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
PARTIE 2 - CONFIGURATION POSTE DE BOB ET CLAIRE
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

────────────────────────────────────────────────────────────────────────────────
ÉTAPE 2.1 - Bob et Claire clonent le projet et créent leur .env
────────────────────────────────────────────────────────────────────────────────

BOB dans son terminal:

  COMMANDES:
  ──────────
  git clone https://github.com/equipe/taskmanager-java.git
  cd taskmanager-java

  # Créer le fichier .env (JAMAIS dans Git):
  nano .env

  CONTENU DU .env DE BOB:
  ───────────────────────
  # ── ELK Stack ─────────────────────────────────────────────────
  LOGSTASH_HOST=192.168.1.10      # IP de la machine d'Alice
  LOGSTASH_PORT=5000
  KIBANA_URL=http://192.168.1.10:5601
  APP_ENV=development

  # ── Spring profiles ───────────────────────────────────────────
  SPRING_PROFILES_ACTIVE=dev

  # S'assurer que .env est dans .gitignore:
  echo ".env" >> .gitignore
  echo "logs/" >> .gitignore

  # Vérifier le .gitignore:
  cat .gitignore

────────────────────────────────────────────────────────────────────────────────
ÉTAPE 2.2 - Bob et Claire créent leur script de lancement
────────────────────────────────────────────────────────────────────────────────

BOB crée run-dev.sh à la racine:

  CONTENU:
  ────────
  #!/bin/bash
  # run-dev.sh - Lance Spring Boot avec variables ELK

  set -e

  echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
  echo "  TaskManager Java - Démarrage avec ELK"
  echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"

  # Charger les variables
  if [ -f .env ]; then export $(grep -v '^#' .env | xargs); fi

  # Vérifier Elasticsearch
  ES_URL="http://${LOGSTASH_HOST:-localhost}:9200"
  echo "[RECHERCHE] Vérification ELK..."
  if curl -s --connect-timeout 3 "$ES_URL/_cluster/health" > /dev/null 2>&1; then
      echo "[OK] Elasticsearch accessible: $ES_URL"
  else
      echo "[ATTENTION]  Elasticsearch non accessible. Logs en fichier local uniquement."
  fi

  # Vérifier Logstash
  if nc -z "${LOGSTASH_HOST:-localhost}" "${LOGSTASH_PORT:-5000}" 2>/dev/null; then
      echo "[OK] Logstash accessible: ${LOGSTASH_HOST}:${LOGSTASH_PORT}"
  else
      echo "[ATTENTION]  Logstash non accessible."
  fi

  echo ""
  echo "[RAPIDE] Lancement Spring Boot (profil: dev)..."
  echo "[GRAPHIQUE] Kibana: ${KIBANA_URL:-http://localhost:5601}"
  echo ""

  mvn spring-boot:run \
    -Dspring-boot.run.profiles=dev \
    -Dlogstash.host="${LOGSTASH_HOST:-localhost}" \
    -Dlogstash.port="${LOGSTASH_PORT:-5000}" \
    -Dapp.environment="${APP_ENV:-development}"

  COMMANDE:
  ─────────
  chmod +x run-dev.sh

────────────────────────────────────────────────────────────────────────────────
ÉTAPE 2.3 - Bob et Claire vérifient que leurs logs arrivent dans Kibana
────────────────────────────────────────────────────────────────────────────────

BOB dans son terminal:

  COMMANDES:
  ──────────
  ./run-dev.sh

  SORTIE ATTENDUE:
  ────────────────
  [OK] Elasticsearch accessible: http://192.168.1.10:9200
  [OK] Logstash accessible: 192.168.1.10:5000
  [RAPIDE] Lancement Spring Boot (profil: dev)...

  ...
  INFO  c.e.t.TaskManagerApplication - TaskManager API démarrée avec succès
  INFO  c.e.t.logging.RequestLoggingFilter - Logback-Logstash handler actif

  # Dans un autre terminal:
  curl -X POST http://localhost:8080/api/tasks \
       -H "Content-Type: application/json" \
       -d '{"title":"Tâche de Bob"}'

BOB dans Kibana (http://192.168.1.10:5601):

  1. Se connecter
  2. Menu ≡ > Analytics > Discover
  3. Data view: "TaskManager Java Logs"
  4. Plage: "Last 15 minutes"
  5. Bob voit:
     ┌──────────────────────────────────────────────────────────────────────┐
     │  5 hits                                                              │
     │                                                                      │
     │  10:30:05  INFO   Tâche créée avec succès                           │
     │    event_type: task_created | task_id: 1 | task_title: Tâche de Bob │
     │    request_id: a1b2c3d4  <- Injecté par le MDC!                     │
     │                                                                      │
     │  10:30:05  DEBUG  Appel service réussi: TaskService.createTask      │
     │    event_type: service_call | duration_ms: 24.5 | success: true     │
     │    request_id: a1b2c3d4  <- Même request_id!                        │
     │                                                                      │
     │  10:30:05  INFO   Requête HTTP traitée                              │
     │    event_type: http_request | http_method: POST | http_status: 201  │
     │    duration_ms: 31.2 | request_id: a1b2c3d4                        │
     └──────────────────────────────────────────────────────────────────────┘

  Le request_id permet de tracer TOUS les logs d'une seule requête!


━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
PARTIE 3 - WORKFLOW QUOTIDIEN DE L'ÉQUIPE (CAS CONCRETS JAVA)
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

════════════════════════════════════════════════════════════════════════════════
SCÉNARIO A - Bob ajoute une fonctionnalité et cause une NullPointerException
════════════════════════════════════════════════════════════════════════════════

Bob doit ajouter une route pour afficher les statistiques des tâches.
Il fait une erreur classique Java: NullPointerException.

──────────────────────────────────────────────────────────────────────────────
A.1 - Bob écrit le code bugué
──────────────────────────────────────────────────────────────────────────────

Bob ajoute dans TaskService.java:

  ── CODE BUGUÉ DE BOB ────────────────────────────────────────────────────────

  public Map<String, Object> getStats() {
      List<Task> allTasks = taskRepository.findAll();

      // [X] BUG: Si allTasks est vide, stream().max() retourne Optional.empty()
      // et .get() lance NoSuchElementException!
      Task mostRecentTask = allTasks.stream()
          .max(Comparator.comparing(Task::getCreatedAt))
          .get();  // <- DANGEREUX sans vérification!

      // [X] BUG: mostRecentTask.getTitle() peut lancer NullPointerException
      // si le titre a été sauvegardé null (ne devrait pas arriver mais quand même)
      String mostRecentTitle = mostRecentTask.getTitle().toUpperCase();

      long doneTasks    = allTasks.stream().filter(Task::getDone).count();
      long pendingTasks = allTasks.size() - doneTasks;

      Map<String, Object> stats = new HashMap<>();
      stats.put("total",      allTasks.size());
      stats.put("done",       doneTasks);
      stats.put("pending",    pendingTasks);
      stats.put("mostRecent", mostRecentTitle);
      return stats;
  }

  Et dans TaskController.java:

  @GetMapping("/stats")
  public ResponseEntity<Map<String, Object>> getStats() {
      return ResponseEntity.ok(taskService.getStats());
  }

──────────────────────────────────────────────────────────────────────────────
A.2 - Bob lance et teste, l'erreur se produit
──────────────────────────────────────────────────────────────────────────────

  COMMANDES:
  ──────────
  ./run-dev.sh

  # Dans un autre terminal (base de données VIDE):
  curl http://localhost:8080/api/tasks/stats

  SORTIE CONSOLE SPRING BOOT:
  ─────────────────────────────
  ERROR c.e.t.exception.GlobalExceptionHandler - Erreur interne non gérée
  java.util.NoSuchElementException: No value present
      at java.base/java.util.Optional.get(Optional.java:143)
      at com.equipe.taskmanager.service.TaskService.getStats(TaskService.java:98)
      at com.equipe.taskmanager.service.TaskService$$SpringCGLIB$$0.getStats(...)
      at com.equipe.taskmanager.controller.TaskController.getStats(TaskController.java:52)
      ...

──────────────────────────────────────────────────────────────────────────────
A.3 - Bob retrouve l'erreur dans Kibana avec tous ses détails
──────────────────────────────────────────────────────────────────────────────

INTERFACE WEB Kibana:

  1. Bob va dans Discover
  2. Filtre KQL: level: "ERROR"
  3. Il voit l'erreur:
     ┌──────────────────────────────────────────────────────────────────────┐
     │  Jan 15, 10:45:02  ERROR  Erreur interne non gérée                  │
     │                                                                      │
     │  Champs du document:                                                 │
     │  ─────────────────────────────────────────────────                  │
     │  event_type:     internal_error                                      │
     │  exception_type: java.util.NoSuchElementException                   │
     │  exception_class: java.util.NoSuchElementException                  │
     │  http_status:    500                                                 │
     │  http_method:    GET                                                 │
     │  http_path:      /api/tasks/stats                                   │
     │  request_id:     f7e3a1b2                                           │
     │  duration_ms:    8.2                                                 │
     │                                                                      │
     │  stack_trace:                                                        │
     │  java.util.NoSuchElementException: No value present                 │
     │    at java.base/java.util.Optional.get(Optional.java:143)           │
     │    at com.equipe.taskmanager.service.TaskService.getStats(           │
     │       TaskService.java:98)                                           │
     │    at com.equipe.taskmanager.controller.TaskController.getStats(    │
     │       TaskController.java:52)                                        │
     └──────────────────────────────────────────────────────────────────────┘

  4. Bob clique sur request_id: "f7e3a1b2" pour filtrer
     et voir TOUS les logs de cette requête:

     Filtre automatique: request_id: "f7e3a1b2"

     ┌──────────────────────────────────────────────────────────────────────┐
     │  3 logs pour la requête f7e3a1b2 (trace complète):                  │
     │                                                                      │
     │  10:45:02.001  DEBUG  Appel service: TaskService.getStats           │
     │    event_type: service_call | success: false | duration_ms: 7.1    │
     │                                                                      │
     │  10:45:02.003  ERROR  Erreur dans le service: TaskService.getStats  │
     │    exception_type: java.util.NoSuchElementException                 │
     │                                                                      │
     │  10:45:02.008  ERROR  Erreur interne non gérée                      │
     │    (avec stack_trace complète)                                      │
     └──────────────────────────────────────────────────────────────────────┘

──────────────────────────────────────────────────────────────────────────────
A.4 - Bob corrige le code
──────────────────────────────────────────────────────────────────────────────

  ── CODE CORRIGÉ ─────────────────────────────────────────────────────────────

  public Map<String, Object> getStats() {
      List<Task> allTasks = taskRepository.findAll();

      log.info("Calcul des statistiques",
          StructuredArguments.kv("event_type",  "stats_computed"),
          StructuredArguments.kv("task_count",  allTasks.size())
      );

      long doneTasks    = allTasks.stream().filter(Task::getDone).count();
      long pendingTasks = allTasks.size() - doneTasks;

      // [OK] CORRECTION: Utiliser orElse pour gérer la liste vide
      String mostRecentTitle = allTasks.stream()
          .max(Comparator.comparing(Task::getCreatedAt))
          .map(Task::getTitle)           // Optional.map: ne s'exécute que si présent
          .orElse("Aucune tâche");       // Valeur par défaut si liste vide

      Map<String, Object> stats = new HashMap<>();
      stats.put("total",      allTasks.size());
      stats.put("done",       doneTasks);
      stats.put("pending",    pendingTasks);
      stats.put("mostRecent", mostRecentTitle);
      return stats;
  }

──────────────────────────────────────────────────────────────────────────────
A.5 - Bob vérifie dans Kibana que l'erreur a disparu
──────────────────────────────────────────────────────────────────────────────

INTERFACE WEB Kibana:

  # Filtre KQL après re-déploiement:
  level: "ERROR" AND event_type: "internal_error" AND @timestamp > "now-30m"

  Résultat: 0 hits [OK] L'erreur ne se reproduit plus.

  # Test avec base vide:
  curl http://localhost:8080/api/tasks/stats
  # Réponse: {"total":0,"done":0,"pending":0,"mostRecent":"Aucune tâche"}


════════════════════════════════════════════════════════════════════════════════
SCÉNARIO B - Claire utilise le MDC pour tracer les requêtes multi-services
════════════════════════════════════════════════════════════════════════════════

Claire ajoute une nouvelle fonctionnalité: quand une tâche est créée,
l'application notifie un service externe (simulé). Elle veut tracer
l'opération de bout en bout dans les logs.

──────────────────────────────────────────────────────────────────────────────
B.1 - Claire enrichit le MDC avec des informations métier
──────────────────────────────────────────────────────────────────────────────

Claire met à jour RequestLoggingFilter.java pour enrichir le MDC:

  ── AJOUT DANS RequestLoggingFilter (dans doFilterInternal) ─────────────────

  // Extraire un correlation ID depuis les headers HTTP
  // (utile si un autre service appelle notre API)
  String correlationId = request.getHeader("X-Correlation-ID");
  if (correlationId == null || correlationId.isEmpty()) {
      correlationId = UUID.randomUUID().toString().substring(0, 8);
  }

  // Injecter dans le MDC: propagé à tous les logs de cette requête
  MDC.put("correlation_id", correlationId);
  MDC.put("request_id",     UUID.randomUUID().toString().substring(0, 8));
  MDC.put("http_method",    request.getMethod());
  MDC.put("http_path",      request.getRequestURI());
  MDC.put("remote_ip",      getClientIp(request));

  // Propager le correlation ID dans la réponse
  response.setHeader("X-Correlation-ID", correlationId);

Claire crée un nouveau service de notification simulé:

  ── service/NotificationService.java ────────────────────────────────────────

  package com.equipe.taskmanager.service;

  import net.logstash.logback.argument.StructuredArguments;
  import org.slf4j.Logger;
  import org.slf4j.LoggerFactory;
  import org.slf4j.MDC;
  import org.springframework.stereotype.Service;

  @Service
  public class NotificationService {

      private static final Logger log =
          LoggerFactory.getLogger(NotificationService.class);

      /**
       * Notifie un système externe qu'une tâche a été créée.
       * Le MDC est automatiquement propagé depuis le thread appelant,
       * donc correlation_id et request_id sont déjà présents dans les logs.
       */
      public void notifyTaskCreated(Long taskId, String taskTitle) {
          try {
              // Simuler un appel HTTP vers un service externe (100ms)
              Thread.sleep(50 + (long)(Math.random() * 100));

              log.info("Notification envoyée pour la nouvelle tâche",
                  StructuredArguments.kv("event_type",       "notification_sent"),
                  StructuredArguments.kv("notification_type","task_created"),
                  StructuredArguments.kv("task_id",          taskId),
                  StructuredArguments.kv("task_title",       taskTitle),
                  StructuredArguments.kv("channel",          "email")
                  // correlation_id est dans le MDC -> ajouté automatiquement!
              );

          } catch (InterruptedException e) {
              Thread.currentThread().interrupt();
              log.error("Notification interrompue",
                  StructuredArguments.kv("event_type", "notification_failed"),
                  StructuredArguments.kv("task_id",    taskId)
              );
          } catch (Exception e) {
              // L'échec de la notification ne doit pas faire échouer la création
              log.warn("Impossible d'envoyer la notification (service externe indisponible)",
                  StructuredArguments.kv("event_type", "notification_warning"),
                  StructuredArguments.kv("task_id",    taskId),
                  e
              );
          }
      }
  }

──────────────────────────────────────────────────────────────────────────────
B.2 - Claire teste la traçabilité dans Kibana
──────────────────────────────────────────────────────────────────────────────

  COMMANDES:
  ──────────
  curl -X POST http://localhost:8080/api/tasks \
       -H "Content-Type: application/json" \
       -H "X-Correlation-ID: DEMO-2024-001" \
       -d '{"title":"Tâche avec notification"}'

  # La réponse contient l'en-tête:
  # X-Correlation-ID: DEMO-2024-001

INTERFACE WEB Kibana:

  1. Discover > Filtre: correlation_id: "DEMO-2024-001"

  2. Claire voit la trace complète de la requête:
     ┌──────────────────────────────────────────────────────────────────────┐
     │  5 logs pour correlation_id: DEMO-2024-001                          │
     │                                                                      │
     │  10:52:01.010  DEBUG  Appel service: TaskService.createTask         │
     │    class: TaskService | method: createTask | correlation_id: DEMO-  │
     │                                                                      │
     │  10:52:01.035  INFO   Tâche créée avec succès                       │
     │    task_id: 2 | task_title: Tâche avec notification                 │
     │    correlation_id: DEMO-2024-001                                    │
     │                                                                      │
     │  10:52:01.037  INFO   Notification envoyée pour la nouvelle tâche   │
     │    notification_type: task_created | channel: email                 │
     │    correlation_id: DEMO-2024-001 <- Même correlation ID!            │
     │                                                                      │
     │  10:52:01.140  DEBUG  Appel service réussi: TaskService.createTask  │
     │    duration_ms: 130.5 | success: true                               │
     │    correlation_id: DEMO-2024-001                                    │
     │                                                                      │
     │  10:52:01.143  INFO   Requête HTTP traitée                          │
     │    http_status: 201 | duration_ms: 143.2                           │
     │    correlation_id: DEMO-2024-001                                    │
     └──────────────────────────────────────────────────────────────────────┘

  Claire peut maintenant tracer une opération à travers tous les composants!


════════════════════════════════════════════════════════════════════════════════
SCÉNARIO C - Alice fait la revue hebdomadaire avec analyse des performances
════════════════════════════════════════════════════════════════════════════════

──────────────────────────────────────────────────────────────────────────────
C.1 - Alice consulte le dashboard global
──────────────────────────────────────────────────────────────────────────────

INTERFACE WEB Kibana:

  1. Dashboard "TaskManager Java - Vue Générale"
  2. Plage: "Last 7 days"

  ┌───────────────────────────────────────────────────────────────────────┐
  │  TaskManager Java - Vue Générale │ 7 derniers jours                   │
  │                                                                       │
  │  Total requêtes:  23,412                                             │
  │  Durée médiane:   47ms [OK]                                             │
  │  Taux erreur 5xx: 0.8% [OK]                                             │
  │  Taux erreur 4xx: 3.2% [ATTENTION]                                            │
  │                                                                       │
  │  Top routes par durée médiane (ms):                                  │
  │  GET  /api/tasks/search  -> 312ms  <- LA PLUS LENTE!                  │
  │  GET  /api/tasks         -> 54ms                                      │
  │  POST /api/tasks         -> 38ms                                      │
  │  GET  /api/tasks/{id}    -> 22ms                                      │
  │  DELETE /api/tasks/{id}  -> 18ms                                      │
  └───────────────────────────────────────────────────────────────────────┘

──────────────────────────────────────────────────────────────────────────────
C.2 - Alice investigate la route de recherche lente via Dev Tools
──────────────────────────────────────────────────────────────────────────────

INTERFACE WEB Kibana - Dev Tools:

  # Analyser les percentiles de /api/tasks/search:
  GET /taskmanager-java-logs-*/_search
  {
    "size": 0,
    "query": {
      "bool": {
        "filter": [
          { "term":  { "http_path.keyword": "/api/tasks/search" } },
          { "range": { "@timestamp": { "gte": "now-7d" } } }
        ]
      }
    },
    "aggs": {
      "durée_stats": {
        "extended_stats": { "field": "duration_ms" }
      },
      "percentiles_durée": {
        "percentiles": {
          "field": "duration_ms",
          "percents": [50, 75, 90, 95, 99]
        }
      },
      "par_heure": {
        "date_histogram": {
          "field": "@timestamp",
          "calendar_interval": "1h"
        },
        "aggs": {
          "durée_moy": { "avg": { "field": "duration_ms" } }
        }
      }
    }
  }

  RÉSULTAT:
  ─────────
  {
    "aggregations": {
      "durée_stats": {
        "count": 1203,
        "min": 12.0,
        "max": 2450.0,
        "avg": 318.7,
        "std_deviation": 412.3    <- Grande variance = comportement instable
      },
      "percentiles_durée": {
        "50.0": 148.0,
        "75.0": 512.0,
        "90.0": 1200.0,           <- 10% des requêtes prennent >1.2 secondes!
        "95.0": 1850.0,
        "99.0": 2400.0
      }
    }
  }

──────────────────────────────────────────────────────────────────────────────
C.3 - Alice assigne les optimisations dans Kibana via commentaires
──────────────────────────────────────────────────────────────────────────────

Alice crée un "saved search" dans Kibana et le partage avec Bob:

INTERFACE WEB Kibana - Sauvegarder une recherche:

  1. Discover > Filtre: http_path: "/api/tasks/search" AND duration_ms > 500
  2. Cliquer sur "Save" (icône disquette en haut)
  3. Nom: "Recherches lentes - à optimiser"
  4. Cliquer "Save"
  5. Cliquer "Share" > Copier l'URL
  6. Envoyer à Bob: "Bob, voir les recherches lentes: [URL]
                     P90 = 1.2 secondes, il faut un index sur task.title"

Bob reçoit le lien, voit les requêtes lentes en contexte, et ajoute l'index:

  ── Ajout d'un index dans Task.java ─────────────────────────────────────────

  @Entity
  @Table(name = "tasks",
         indexes = {
             // [OK] Index ajouté par Bob pour accélérer la recherche par titre
             @Index(name = "idx_task_title", columnList = "title")
         })
  public class Task {
      // ... reste inchangé
  }

  Et dans TaskRepository.java, Bob utilise la recherche full-text:

  // [OK] Version optimisée avec index
  @Query("SELECT t FROM Task t WHERE LOWER(t.title) LIKE LOWER(CONCAT('%', :query, '%'))")
  List<Task> searchByTitle(@Param("query") String query);

Après déploiement, Alice vérifie dans Kibana:

  INTERFACE WEB Kibana - Requête Dev Tools:
  ──────────────────────────────────────────
  Même requête que C.2, mais filtrée sur les 2 dernières heures.

  NOUVEAU RÉSULTAT:
  ─────────────────
  "percentiles_durée": {
    "50.0": 14.0,    <- 148ms -> 14ms: 10x plus rapide!
    "75.0": 22.0,
    "90.0": 38.0,    <- 1200ms -> 38ms: 32x plus rapide!
    "95.0": 55.0,
    "99.0": 120.0
  }

  L'index a drastiquement amélioré les performances. Visible dans Kibana!


━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
PARTIE 4 - TESTS JAVA AVEC ELK (ISOLER LES TESTS DE LA STACK ELK)
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

Claire s'occupe de tous les tests. Le profil "test" désactive Logstash,
mais il faut vérifier que les logs sont bien générés.

────────────────────────────────────────────────────────────────────────────────
ÉTAPE 4.1 - Tests du service avec vérification des logs
────────────────────────────────────────────────────────────────────────────────

  ── test/service/TaskServiceTest.java ───────────────────────────────────────

  package com.equipe.taskmanager.service;

  import com.equipe.taskmanager.dto.TaskRequest;
  import com.equipe.taskmanager.dto.TaskResponse;
  import com.equipe.taskmanager.exception.TaskNotFoundException;
  import com.equipe.taskmanager.model.Task;
  import com.equipe.taskmanager.repository.TaskRepository;
  import ch.qos.logback.classic.Level;
  import ch.qos.logback.classic.Logger;
  import ch.qos.logback.classic.spi.ILoggingEvent;
  import ch.qos.logback.core.read.ListAppender;
  import org.junit.jupiter.api.BeforeEach;
  import org.junit.jupiter.api.Test;
  import org.junit.jupiter.api.extension.ExtendWith;
  import org.mockito.InjectMocks;
  import org.mockito.Mock;
  import org.mockito.junit.jupiter.MockitoExtension;
  import org.slf4j.LoggerFactory;
  import java.util.List;
  import java.util.Optional;
  import static org.assertj.core.api.Assertions.*;
  import static org.mockito.Mockito.*;

  @ExtendWith(MockitoExtension.class)
  class TaskServiceTest {

      @Mock
      private TaskRepository taskRepository;

      @Mock
      private NotificationService notificationService;

      @InjectMocks
      private TaskService taskService;

      // ── Helper pour capturer les logs Logback pendant les tests ──────────
      private ListAppender<ILoggingEvent> captureLogsFor(Class<?> loggerClass) {
          Logger logger = (Logger) LoggerFactory.getLogger(loggerClass);
          ListAppender<ILoggingEvent> listAppender = new ListAppender<>();
          listAppender.start();
          logger.addAppender(listAppender);
          return listAppender;
      }

      // ── Test 1: Création d'une tâche ──────────────────────────────────────
      @Test
      void createTask_shouldSaveAndLogEvent() {
          // GIVEN
          TaskRequest request = new TaskRequest();
          request.setTitle("Nouvelle tâche");
          request.setDescription("Description test");

          Task savedTask = Task.builder()
              .id(1L)
              .title("Nouvelle tâche")
              .description("Description test")
              .done(false)
              .build();

          when(taskRepository.save(any(Task.class))).thenReturn(savedTask);

          // Capturer les logs du service
          ListAppender<ILoggingEvent> logAppender = captureLogsFor(TaskService.class);

          // WHEN
          TaskResponse response = taskService.createTask(request);

          // THEN - Vérifier la réponse
          assertThat(response).isNotNull();
          assertThat(response.getId()).isEqualTo(1L);
          assertThat(response.getTitle()).isEqualTo("Nouvelle tâche");

          // THEN - Vérifier que le log de création a bien été émis
          List<ILoggingEvent> logs = logAppender.list;
          assertThat(logs).isNotEmpty();

          ILoggingEvent creationLog = logs.stream()
              .filter(e -> e.getLevel() == Level.INFO)
              .filter(e -> e.getMessage().contains("Tâche créée"))
              .findFirst()
              .orElseThrow(() -> new AssertionError("Log 'Tâche créée' attendu!"));

          // Vérifier les arguments structurés (StructuredArguments)
          assertThat(creationLog.getArgumentArray()).isNotNull();
          // Le message formaté doit contenir task_id et task_title
          String formattedMessage = creationLog.getFormattedMessage();
          // Note: avec logstash-logback-encoder, les StructuredArguments
          // sont dans les KeyValuePairs, pas dans getArgumentArray()
          assertThat(creationLog.getKeyValuePairs()).anyMatch(
              kvp -> kvp.key.equals("event_type") && kvp.value.equals("task_created")
          );
      }

      // ── Test 2: Tâche introuvable -> warning loggé ─────────────────────────
      @Test
      void getTaskById_whenNotFound_shouldLogWarningAndThrow() {
          // GIVEN
          when(taskRepository.findById(999L)).thenReturn(Optional.empty());
          ListAppender<ILoggingEvent> logAppender = captureLogsFor(TaskService.class);

          // WHEN + THEN
          assertThatThrownBy(() -> taskService.getTaskById(999L))
              .isInstanceOf(TaskNotFoundException.class)
              .hasMessageContaining("999");

          // Vérifier le log WARNING
          assertThat(logAppender.list)
              .anyMatch(e -> e.getLevel() == Level.WARN
                          && e.getMessage().contains("introuvable"));
      }

      // ── Test 3: Recherche trop courte -> IllegalArgumentException ──────────
      @Test
      void searchTasks_withShortQuery_shouldThrowAndLog() {
          // GIVEN
          ListAppender<ILoggingEvent> logAppender = captureLogsFor(TaskService.class);

          // WHEN + THEN
          assertThatThrownBy(() -> taskService.searchTasks("a"))
              .isInstanceOf(IllegalArgumentException.class);

          assertThat(logAppender.list)
              .anyMatch(e -> e.getLevel() == Level.WARN
                          && e.getMessage().contains("trop courte"));
      }

      // ── Test 4: Marquer une tâche comme terminée -> log spécifique ─────────
      @Test
      void updateTask_whenMarkingAsDone_shouldLogCompletionEvent() {
          // GIVEN
          Task existingTask = Task.builder()
              .id(1L).title("Ma tâche").done(false).build();

          Task updatedTask = Task.builder()
              .id(1L).title("Ma tâche").done(true).build();

          when(taskRepository.findById(1L)).thenReturn(Optional.of(existingTask));
          when(taskRepository.save(any())).thenReturn(updatedTask);

          TaskRequest request = new TaskRequest();
          request.setDone(true);

          ListAppender<ILoggingEvent> logAppender = captureLogsFor(TaskService.class);

          // WHEN
          taskService.updateTask(1L, request);

          // THEN - Log spécifique pour completion
          assertThat(logAppender.list)
              .anyMatch(e -> e.getMessage().contains("terminée")
                          && e.getLevel() == Level.INFO);
      }
  }

────────────────────────────────────────────────────────────────────────────────
ÉTAPE 4.2 - Tests d'intégration du controller avec MockMvc
────────────────────────────────────────────────────────────────────────────────

  ── test/controller/TaskControllerTest.java ─────────────────────────────────

  package com.equipe.taskmanager.controller;

  import com.equipe.taskmanager.model.Task;
  import com.equipe.taskmanager.repository.TaskRepository;
  import com.fasterxml.jackson.databind.ObjectMapper;
  import org.junit.jupiter.api.BeforeEach;
  import org.junit.jupiter.api.Test;
  import org.springframework.beans.factory.annotation.Autowired;
  import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc;
  import org.springframework.boot.test.context.SpringBootTest;
  import org.springframework.http.MediaType;
  import org.springframework.test.context.ActiveProfiles;
  import org.springframework.test.web.servlet.MockMvc;
  import java.util.Map;
  import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.*;
  import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*;
  import static org.hamcrest.Matchers.*;

  @SpringBootTest
  @AutoConfigureMockMvc
  @ActiveProfiles("test")    // <- Profile "test": Logstash désactivé automatiquement!
  class TaskControllerTest {

      @Autowired MockMvc         mockMvc;
      @Autowired TaskRepository  taskRepository;
      @Autowired ObjectMapper    objectMapper;

      @BeforeEach
      void setUp() {
          taskRepository.deleteAll();
      }

      @Test
      void GET_tasks_empty_shouldReturn200WithEmptyList() throws Exception {
          mockMvc.perform(get("/api/tasks"))
              .andExpect(status().isOk())
              .andExpect(content().contentType(MediaType.APPLICATION_JSON))
              .andExpect(jsonPath("$", hasSize(0)));
      }

      @Test
      void POST_task_valid_shouldReturn201WithTask() throws Exception {
          Map<String, String> request = Map.of(
              "title",       "Test task",
              "description", "Test description"
          );

          mockMvc.perform(post("/api/tasks")
                  .contentType(MediaType.APPLICATION_JSON)
                  .content(objectMapper.writeValueAsString(request)))
              .andExpect(status().isCreated())
              .andExpect(jsonPath("$.id",    notNullValue()))
              .andExpect(jsonPath("$.title", is("Test task")))
              .andExpect(jsonPath("$.done",  is(false)));
      }

      @Test
      void POST_task_missingTitle_shouldReturn400() throws Exception {
          Map<String, String> request = Map.of("description", "No title here");

          mockMvc.perform(post("/api/tasks")
                  .contentType(MediaType.APPLICATION_JSON)
                  .content(objectMapper.writeValueAsString(request)))
              .andExpect(status().isBadRequest())
              .andExpect(jsonPath("$.errors.title", notNullValue()));
      }

      @Test
      void GET_task_byId_notFound_shouldReturn404() throws Exception {
          mockMvc.perform(get("/api/tasks/9999"))
              .andExpect(status().isNotFound())
              .andExpect(jsonPath("$.status", is(404)));
      }

      @Test
      void PUT_task_markAsDone_shouldReturn200() throws Exception {
          // Créer une tâche
          Task task = taskRepository.save(Task.builder().title("À faire").build());

          Map<String, Object> updateRequest = Map.of("done", true);

          mockMvc.perform(put("/api/tasks/" + task.getId())
                  .contentType(MediaType.APPLICATION_JSON)
                  .content(objectMapper.writeValueAsString(updateRequest)))
              .andExpect(status().isOk())
              .andExpect(jsonPath("$.done", is(true)));
      }

      @Test
      void DELETE_task_shouldReturn204() throws Exception {
          Task task = taskRepository.save(Task.builder().title("À supprimer").build());

          mockMvc.perform(delete("/api/tasks/" + task.getId()))
              .andExpect(status().isNoContent());

          mockMvc.perform(get("/api/tasks/" + task.getId()))
              .andExpect(status().isNotFound());
      }

      @Test
      void GET_search_shortQuery_shouldReturn400() throws Exception {
          mockMvc.perform(get("/api/tasks/search").param("q", "a"))
              .andExpect(status().isBadRequest());
      }

      @Test
      void GET_search_validQuery_shouldReturnResults() throws Exception {
          taskRepository.save(Task.builder().title("Acheter du lait").build());
          taskRepository.save(Task.builder().title("Aller à la gym").build());

          mockMvc.perform(get("/api/tasks/search").param("q", "lait"))
              .andExpect(status().isOk())
              .andExpect(jsonPath("$", hasSize(1)))
              .andExpect(jsonPath("$[0].title", containsString("lait")));
      }

      @Test
      void POST_task_requestId_shouldBeInResponseHeader() throws Exception {
          // Le filtre RequestLoggingFilter ajoute X-Correlation-ID
          mockMvc.perform(post("/api/tasks")
                  .contentType(MediaType.APPLICATION_JSON)
                  .header("X-Correlation-ID", "TEST-001")
                  .content(objectMapper.writeValueAsString(Map.of("title", "Test"))))
              .andExpect(status().isCreated())
              .andExpect(header().string("X-Correlation-ID", "TEST-001"));
      }
  }

────────────────────────────────────────────────────────────────────────────────
ÉTAPE 4.3 - Test dédié au logging ELK (sans Logstash)
────────────────────────────────────────────────────────────────────────────────

  ── test/logging/LoggingTest.java ────────────────────────────────────────────

  package com.equipe.taskmanager.logging;

  import ch.qos.logback.classic.Level;
  import ch.qos.logback.classic.Logger;
  import ch.qos.logback.classic.spi.ILoggingEvent;
  import ch.qos.logback.core.read.ListAppender;
  import com.equipe.taskmanager.model.Task;
  import com.equipe.taskmanager.repository.TaskRepository;
  import org.junit.jupiter.api.BeforeEach;
  import org.junit.jupiter.api.Test;
  import org.slf4j.LoggerFactory;
  import org.springframework.beans.factory.annotation.Autowired;
  import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc;
  import org.springframework.boot.test.context.SpringBootTest;
  import org.springframework.http.MediaType;
  import org.springframework.test.context.ActiveProfiles;
  import org.springframework.test.web.servlet.MockMvc;
  import java.util.List;
  import static org.assertj.core.api.Assertions.*;
  import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.*;
  import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*;

  /**
   * Tests dédiés à la vérification du comportement de logging.
   * Vérifie que les bons logs sont émis sans avoir besoin de Logstash.
   * Utilise ListAppender pour capturer les logs en mémoire.
   */
  @SpringBootTest
  @AutoConfigureMockMvc
  @ActiveProfiles("test")
  class LoggingTest {

      @Autowired MockMvc        mockMvc;
      @Autowired TaskRepository taskRepository;

      @BeforeEach
      void setUp() {
          taskRepository.deleteAll();
      }

      @Test
      void httpRequest_shouldAlwaysLogHttpEvent() throws Exception {
          // Capturer les logs du filtre HTTP
          Logger filterLogger = (Logger) LoggerFactory
              .getLogger(RequestLoggingFilter.class);
          ListAppender<ILoggingEvent> appender = new ListAppender<>();
          appender.start();
          filterLogger.addAppender(appender);

          // WHEN: Effectuer une requête quelconque
          mockMvc.perform(get("/api/tasks"))
              .andExpect(status().isOk());

          // THEN: Vérifier qu'un log http_request a été émis
          List<ILoggingEvent> httpLogs = appender.list.stream()
              .filter(e -> e.getLevel() == Level.INFO)
              .toList();

          assertThat(httpLogs).isNotEmpty();
          assertThat(httpLogs).anyMatch(e ->
              e.getKeyValuePairs().stream().anyMatch(
                  kv -> "event_type".equals(kv.key) && "http_request".equals(kv.value)
              )
          );

          // Nettoyer
          filterLogger.detachAppender(appender);
      }

      @Test
      void notFoundRequest_shouldLogWarning() throws Exception {
          // Capturer les logs du handler d'exception
          Logger exceptionLogger = (Logger) LoggerFactory
              .getLogger("com.equipe.taskmanager.exception.GlobalExceptionHandler");
          ListAppender<ILoggingEvent> appender = new ListAppender<>();
          appender.start();
          exceptionLogger.addAppender(appender);

          // WHEN
          mockMvc.perform(get("/api/tasks/99999"))
              .andExpect(status().isNotFound());

          // THEN: WARNING émis
          assertThat(appender.list)
              .anyMatch(e -> e.getLevel() == Level.WARN);

          exceptionLogger.detachAppender(appender);
      }

      @Test
      void correlationId_shouldPropagateToAllLogs() throws Exception {
          // Capturer les logs de tous les composants
          Logger rootLogger = (Logger) LoggerFactory
              .getLogger("com.equipe.taskmanager");
          ListAppender<ILoggingEvent> appender = new ListAppender<>();
          appender.start();
          rootLogger.addAppender(appender);

          // WHEN: Requête avec X-Correlation-ID personnalisé
          mockMvc.perform(post("/api/tasks")
                  .contentType(MediaType.APPLICATION_JSON)
                  .header("X-Correlation-ID", "TEST-TRACE-001")
                  .content("{\"title\":\"Test trace\"}"))
              .andExpect(status().isCreated());

          // THEN: Tous les logs doivent avoir le correlation_id dans leur MDC
          assertThat(appender.list).isNotEmpty();
          assertThat(appender.list).allMatch(e ->
              "TEST-TRACE-001".equals(e.getMDCPropertyMap().get("correlation_id"))
          );

          rootLogger.detachAppender(appender);
      }
  }


━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
PARTIE 5 - INTÉGRATION CI/CD AVEC GITHUB ACTIONS
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

Alice configure le pipeline CI/CD pour tester et scanner avec ELK en CI.

────────────────────────────────────────────────────────────────────────────────
ÉTAPE 5.1 - Fichier GitHub Actions complet
────────────────────────────────────────────────────────────────────────────────

  COMMANDE:
  ─────────
  mkdir -p .github/workflows
  nano .github/workflows/ci.yml

  CONTENU COMPLET:
  ────────────────

  name: CI - Build, Tests, ELK + SonarQube

  on:
    push:
      branches: [main, develop, 'feature/**']
    pull_request:
      branches: [main]

  jobs:
    build-and-test:
      name: Build Java + Tests + ELK check
      runs-on: ubuntu-latest

      # ── Service Elasticsearch pour les tests d'intégration ELK ──────────
      services:
        elasticsearch:
          image: docker.elastic.co/elasticsearch/elasticsearch:8.11.0
          env:
            discovery.type: single-node
            xpack.security.enabled: "false"
            ES_JAVA_OPTS: "-Xms256m -Xmx256m"
          ports:
            - 9200:9200
          options: >-
            --health-cmd "curl -sf http://localhost:9200/_cluster/health"
            --health-interval 15s
            --health-timeout 10s
            --health-retries 10
            --health-start-period 60s

      steps:
        - name: Checkout code
          uses: actions/checkout@v4
          with:
            fetch-depth: 0    # Requis pour SonarQube

        - name: Set up JDK 17
          uses: actions/setup-java@v4
          with:
            java-version: '17'
            distribution: 'temurin'
            cache: maven

        # ── Vérification Elasticsearch ────────────────────────────────────
        - name: Wait for Elasticsearch
          run: |
            echo "[HOURGLASS_WITH_FLOWING_SAND] Attente d'Elasticsearch..."
            for i in $(seq 1 20); do
              STATUS=$(curl -s http://localhost:9200/_cluster/health \
                | python3 -c "import sys,json; print(json.load(sys.stdin).get('status','error'))" \
                2>/dev/null || echo "error")
              if [ "$STATUS" = "green" ] || [ "$STATUS" = "yellow" ]; then
                echo "[OK] Elasticsearch prêt (status: $STATUS)"
                break
              fi
              echo "  Tentative $i/20 (status: $STATUS)..."
              sleep 10
            done

        # ── Tests Maven (profil test: Logstash désactivé) ─────────────────
        - name: Run tests
          run: |
            mvn verify \
              -Dspring.profiles.active=test \
              -Dlogstash.host=127.0.0.1 \
              -Dlogstash.port=1 \
              -Dapp.environment=ci \
              -B   # Batch mode (pas d'interactivité)
          env:
            APP_ENV: ci

        # ── Test d'indexation Elasticsearch ───────────────────────────────
        - name: Verify Elasticsearch indexing capability
          run: |
            echo "[RECHERCHE] Test indexation Elasticsearch..."
            RESULT=$(curl -sf -X POST "http://localhost:9200/ci-test-index/_doc/" \
              -H "Content-Type: application/json" \
              -d '{"app":"taskmanager-java","env":"ci","test":true,"@timestamp":"'$(date -u +%Y-%m-%dT%H:%M:%SZ)'"}'
            )
            echo "Résultat: $RESULT"
            # Vérifier que l'indexation a réussi
            echo $RESULT | python3 -c "
            import sys, json
            r = json.load(sys.stdin)
            assert r['result'] == 'created', f'Indexation échouée: {r}'
            print('[OK] Elasticsearch indexation OK')
            "
            # Nettoyer l'index de test
            curl -s -X DELETE "http://localhost:9200/ci-test-index" > /dev/null

        # ── Vérifier les rapports de test ─────────────────────────────────
        - name: Upload test reports
          uses: actions/upload-artifact@v3
          if: always()
          with:
            name: test-reports
            path: target/surefire-reports/

        # ── SonarQube ─────────────────────────────────────────────────────
        - name: Cache SonarQube
          uses: actions/cache@v3
          with:
            path: ~/.sonar/cache
            key: ${{ runner.os }}-sonar
            restore-keys: ${{ runner.os }}-sonar

        - name: SonarQube Scan
          run: |
            mvn sonar:sonar \
              -Dsonar.projectKey=taskmanager-java \
              -Dsonar.host.url=${{ secrets.SONAR_HOST_URL }} \
              -Dsonar.login=${{ secrets.SONAR_TOKEN }} \
              -B
          env:
            GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}

        - name: Check SonarQube Quality Gate
          uses: sonarsource/sonarqube-quality-gate-action@master
          timeout-minutes: 5
          env:
            SONAR_TOKEN: ${{ secrets.SONAR_TOKEN }}


━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
PARTIE 6 - REQUÊTES KQL ET ELASTICSEARCH UTILES POUR L'ÉQUIPE JAVA
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

────────────────────────────────────────────────────────────────────────────────
REQUÊTES KQL SPÉCIFIQUES JAVA/SPRING BOOT
────────────────────────────────────────────────────────────────────────────────

  # Toutes les exceptions Java:
  stack_trace: *

  # NullPointerException spécifiquement:
  exception_class: "java.lang.NullPointerException"

  # Toutes les exceptions NoSuchElement:
  exception_class: *NoSuchElementException*

  # Logs du service TaskService uniquement:
  logger_name: "com.equipe.taskmanager.service.TaskService"

  # Logs d'un thread spécifique (utile pour les problèmes de concurrence):
  thread: "http-nio-8080-exec-3"

  # Recherche par correlation ID (tracer une requête complète):
  correlation_id: "DEMO-2024-001"

  # Requêtes avec la même request_id (trace d'une requête):
  request_id: "a1b2c3d4"

  # Erreurs de validation Bean Validation:
  event_type: "validation_error"

  # Erreurs de service:
  event_type: "service_error"

  # Appels de service (AOP):
  event_type: "service_call"

  # Appels lents (>500ms) issus de l'AOP:
  event_type: "service_call" AND duration_ms > 500

  # Logs Hibernate (requêtes SQL):
  logger_name: "org.hibernate.SQL"

  # Tâches complétées aujourd'hui:
  event_type: "task_completed" AND @timestamp > "now/d"

  # Logs de sécurité:
  event_type: "suspicious_activity"

────────────────────────────────────────────────────────────────────────────────
REQUÊTES API ELASTICSEARCH UTILES (via Kibana Dev Tools)
────────────────────────────────────────────────────────────────────────────────

  ── Requête 1: Top des exceptions Java les plus fréquentes ───────────────────

  GET /taskmanager-java-logs-*/_search
  {
    "size": 0,
    "query": {
      "exists": { "field": "exception_class" }
    },
    "aggs": {
      "top_exceptions": {
        "terms": {
          "field": "exception_class.keyword",
          "size": 10
        },
        "aggs": {
          "premiere_occurrence": {
            "min": { "field": "@timestamp" }
          },
          "derniere_occurrence": {
            "max": { "field": "@timestamp" }
          }
        }
      }
    }
  }

  ── Requête 2: Performances par méthode de service (AOP) ─────────────────────

  GET /taskmanager-java-logs-*/_search
  {
    "size": 0,
    "query": {
      "bool": {
        "filter": [
          { "term":  { "event_type.keyword": "service_call" } },
          { "range": { "@timestamp": { "gte": "now-24h" } } }
        ]
      }
    },
    "aggs": {
      "par_methode": {
        "terms": {
          "script": {
            "source": "doc['class.keyword'].value + '.' + doc['method.keyword'].value"
          },
          "size": 20
        },
        "aggs": {
          "duree_moy": { "avg":    { "field": "duration_ms" } },
          "duree_max": { "max":    { "field": "duration_ms" } },
          "p99":       { "percentiles": { "field": "duration_ms", "percents": [99] } },
          "nb_erreurs": {
            "filter": { "term": { "success": false } }
          }
        }
      }
    }
  }

  ── Requête 3: Trace complète d'une requête via correlation_id ────────────────

  GET /taskmanager-java-logs-*/_search
  {
    "query": {
      "term": {
        "correlation_id.keyword": "DEMO-2024-001"
      }
    },
    "sort": [
      { "@timestamp": { "order": "asc" } }
    ],
    "_source": ["@timestamp", "level", "message", "event_type",
                "logger_name", "method", "duration_ms"]
  }

  ── Requête 4: Activité par heure et par développeur (via env) ────────────────

  GET /taskmanager-java-logs-*/_search
  {
    "size": 0,
    "query": {
      "range": { "@timestamp": { "gte": "now-7d" } }
    },
    "aggs": {
      "par_env": {
        "terms": { "field": "env.keyword" },
        "aggs": {
          "par_jour": {
            "date_histogram": {
              "field": "@timestamp",
              "calendar_interval": "1d"
            },
            "aggs": {
              "total_requetes": { "value_count": { "field": "http_status" } },
              "erreurs":        { "filter": { "range": { "http_status": { "gte": 500 } } } }
            }
          }
        }
      }
    }
  }

  ── Requête 5: Vérifier la santé de l'indexation (monitoring) ────────────────

  # Nombre total de documents:
  GET /taskmanager-java-logs-*/_count

  # Taille des index:
  GET /_cat/indices/taskmanager-java-logs-*?v&s=index&h=index,docs.count,store.size,creation.date.string

  # Vérifier l'index template:
  GET /_index_template/taskmanager-java-logs-template


━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
PARTIE 7 - BONNES PRATIQUES DE L'ÉQUIPE (RÈGLES D'OR JAVA + ELK)
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

────────────────────────────────────────────────────────────────────────────────
RÈGLE 1 - Utiliser StructuredArguments, jamais la concaténation
────────────────────────────────────────────────────────────────────────────────

  // [X] À NE JAMAIS FAIRE: concaténation dans le message
  log.info("Tâche créée avec id=" + task.getId() + " et titre=" + task.getTitle());
  // -> Non structuré, impossible à filtrer dans Elasticsearch

  // [OK] TOUJOURS FAIRE: champs séparés via StructuredArguments
  import static net.logstash.logback.argument.StructuredArguments.*;

  log.info("Tâche créée",
      kv("event_type",  "task_created"),   // keyValue abrégé en kv
      kv("task_id",     task.getId()),
      kv("task_title",  task.getTitle())
  );
  // -> Chaque champ est indexé séparément dans Elasticsearch!
  // -> Filtrable dans Kibana: task_id: 42

────────────────────────────────────────────────────────────────────────────────
RÈGLE 2 - Toujours utiliser @ActiveProfiles("test") dans les tests
────────────────────────────────────────────────────────────────────────────────

  // [OK] Tous les tests doivent avoir cette annotation:
  @SpringBootTest
  @ActiveProfiles("test")    // <- Active application-test.yml
  class MonTest {            //    -> Désactive Logstash (port 1 invalide)
      ...                    //    -> Logs console uniquement
  }

  // Rappel: application-test.yml contient:
  // logstash.host: 127.0.0.1
  // logstash.port: 1   <- Connexion impossible = pas d'envoi

────────────────────────────────────────────────────────────────────────────────
RÈGLE 3 - Toujours nettoyer le MDC dans un bloc finally
────────────────────────────────────────────────────────────────────────────────

  // [OK] PATTERN CORRECT avec try-finally:
  MDC.put("user_id", String.valueOf(userId));
  try {
      // Code qui utilise le MDC
      taskService.createTask(request);
  } finally {
      MDC.remove("user_id");   // ou MDC.clear() si on a mis plusieurs valeurs
  }

  // [X] DANGEREUX sans finally:
  MDC.put("user_id", "42");
  taskService.createTask(request);   // Si exception: MDC non nettoyé!
  MDC.remove("user_id");            // Thread pool contaminé!

  // NOTE: Le RequestLoggingFilter appelle MDC.clear() dans son finally,
  // ce qui nettoie TOUT le MDC après chaque requête HTTP. C'est voulu.

────────────────────────────────────────────────────────────────────────────────
RÈGLE 4 - Passer l'exception comme dernier argument pour la stack trace
────────────────────────────────────────────────────────────────────────────────

  // [OK] Inclure la stack trace: passer l'exception comme DERNIER argument
  try {
      riskyOperation();
  } catch (Exception e) {
      log.error("Opération échouée",
          kv("event_type", "operation_failed"),
          kv("operation",  "riskyOperation"),
          e    // <- DERNIER argument: Logback détecte l'exception et inclut la stack trace
      );
  }

  // [X] Sans stack trace (perte d'information pour le débogage):
  log.error("Opération échouée: " + e.getMessage());

────────────────────────────────────────────────────────────────────────────────
RÈGLE 5 - Niveaux de log: respecter la convention de l'équipe
────────────────────────────────────────────────────────────────────────────────

  TRACE  -> Très détaillé, pour debug interne (désactivé sauf investigation)
           log.trace("Entrée dans la méthode X, paramètre: {}", param);

  DEBUG  -> Informations utiles pour le débogage développeur
           log.debug("Requête SQL générée", kv("sql", query));

  INFO   -> Événements normaux importants de l'application
           log.info("Tâche créée", kv("task_id", task.getId()));

  WARN   -> Situation anormale, l'application continue de fonctionner
           log.warn("Ressource non trouvée", kv("id", id));

  ERROR  -> Erreur qui a empêché une opération (avec exception)
           log.error("Erreur DB lors de la sauvegarde", kv("table", "tasks"), e);

────────────────────────────────────────────────────────────────────────────────
RÈGLE 6 - Réunion hebdomadaire: Kibana + SonarQube ensemble (20 minutes)
────────────────────────────────────────────────────────────────────────────────

  Agenda du lundi matin:

  PARTIE A - SonarQube (10 minutes):
  1. Ouvrir http://sonar.equipe.local:9000 > TaskManager Java
  2. Coverage OK? Nouveaux bugs? Vulnérabilités?
  3. Assigner les issues ouvertes

  PARTIE B - Kibana (10 minutes):
  1. Ouvrir http://sonar.equipe.local:5601
  2. Dashboard "Vue Générale" > Last 7 days
  3. Taux d'erreur normal? Performances stables?
  4. Nouvelles exceptions Java cette semaine?
  5. Routes les plus lentes -> assigner l'optimisation

  OBJECTIF COMBINÉ:
  "Le code doit être propre (SonarQube) ET l'application doit tourner
   correctement en production (Kibana)"


━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
PARTIE 8 - RÉSUMÉ VISUEL - QUI FAIT QUOI ET QUAND (JAVA + ELK)
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

  ALICE (Lead / Admin ELK + SonarQube)
  ──────────────────────────────────────
  [OK] Déploie la stack ELK avec docker-compose (une fois)
  [OK] Configure logback-spring.xml (une fois, évolutif)
  [OK] Configure l'index template Elasticsearch (une fois)
  [OK] Crée les dashboards Kibana (Vue Générale, Erreurs, Performances)
  [OK] Configure les alertes Kibana (email sur erreurs critiques)
  [OK] Crée les comptes Kibana pour Bob et Claire
  [OK] Fait la revue hebdomadaire SonarQube + Kibana
  [OK] Assigne les optimisations de performance basées sur les métriques

  BOB (Développeur - features + performance)
  ──────────────────────────────────────────
  [OK] Ajoute des StructuredArguments dans tout nouveau code
  [OK] Vérifie ses logs dans Kibana Discover après chaque feature
  [OK] Utilise correlation_id pour tracer les requêtes complexes
  [OK] Diagnostique les NullPointerException via Kibana (stack trace complète)
  [OK] Optimise les routes lentes identifiées dans les dashboards

  CLAIRE (Développeuse - tests + qualité logs)
  ──────────────────────────────────────────────
  [OK] Écrit les tests avec @ActiveProfiles("test") (Logstash désactivé)
  [OK] Teste que les bons logs sont émis (ListAppender)
  [OK] Vérifie que le MDC se propage correctement
  [OK] Monitore les patterns de sécurité dans les logs 404
  [OK] Configure les alertes Kibana pour les anomalies de sécurité


  FLUX DE DONNÉES JAVA -> ELK:
  ────────────────────────────

  Code Java                        Infrastructure (Alice)
  ─────────                        ──────────────────────
  log.info("...",                  ┌────────────────────┐
    kv("event_type", "x"),   TCP   │  Logstash :5000    │
    kv("task_id", 42))  ──────────->│  (pipeline .conf)  │
                                   └────────┬───────────┘
  logs/taskmanager-dev.json               │
    (écrit par logback-spring.xml)         │
         │                                 │
  Filebeat (lit le fichier)  ─────────────┘
         │                         ┌────────────────────┐
         └──────────────────────->  │  Elasticsearch     │
                         Beats:5044│  :9200             │
                                   │  (indexation JSON) │
                                   └────────┬───────────┘
                                            │
                                   ┌────────[BLACK_DOWN-POINTING_TRIANGLE]───────────┐
                                   │  Kibana :5601       │
                                   │  (dashboards,       │
                                   │   alertes, Discover)│
                                   └────────────────────┘
                                            │
                                   Alice / Bob / Claire
                                   (navigateur web)


━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
PARTIE 9 - CHEATSHEET JAVA + ELK (RÉFÉRENCE RAPIDE ÉQUIPE)
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

  COMMANDES MAVEN:
  ─────────────────

  # Compiler et lancer les tests (profil test: ELK désactivé):
  mvn test -Dspring.profiles.active=test

  # Lancer avec couverture (Jacoco):
  mvn verify -Dspring.profiles.active=test

  # Lancer l'app en dev (avec ELK):
  mvn spring-boot:run -Dspring-boot.run.profiles=dev

  # Lancer avec paramètres ELK explicites:
  mvn spring-boot:run \
    -Dspring-boot.run.profiles=dev \
    -Dspring-boot.run.jvmArguments="\
      -Dlogstash.host=192.168.1.10 \
      -Dlogstash.port=5000 \
      -Dapp.environment=development"

  # Build sans tests (déploiement rapide):
  mvn package -DskipTests

  # Nettoyer et reconstruire:
  mvn clean verify -Dspring.profiles.active=test

  COMMANDES DOCKER ELK:
  ─────────────────────

  # Démarrer ELK:
  cd docker/ && docker-compose -f docker-compose.elk.yml up -d

  # Arrêter ELK:
  cd docker/ && docker-compose -f docker-compose.elk.yml down

  # Voir les logs Logstash (debug pipeline):
  docker logs logstash -f --tail 100

  # Redémarrer seulement Logstash:
  docker restart logstash

  # Voir tous les services:
  docker-compose -f docker-compose.elk.yml ps

  IMPORT JAVA À METTRE EN HAUT DES FICHIERS:
  ────────────────────────────────────────────

  // Logger SLF4J standard:
  import org.slf4j.Logger;
  import org.slf4j.LoggerFactory;

  // StructuredArguments pour les champs JSON:
  import net.logstash.logback.argument.StructuredArguments;
  import static net.logstash.logback.argument.StructuredArguments.kv;

  // MDC pour le contexte de requête:
  import org.slf4j.MDC;

  // Déclaration du logger dans la classe:
  private static final Logger log = LoggerFactory.getLogger(MaClasse.class);

  // Avec Lombok (plus concis):
  // Ajouter @Slf4j sur la classe -> accès via: log.info("...")
  import lombok.extern.slf4j.Slf4j;
  @Slf4j
  public class MaClasse { ... }

  URLS UTILES:
  ────────────

  # Kibana (interface principale):
  http://sonar.equipe.local:5601

  # Discover (recherche de logs):
  http://sonar.equipe.local:5601/app/discover

  # Dashboards:
  http://sonar.equipe.local:5601/app/dashboards

  # Dev Tools (API Elasticsearch):
  http://sonar.equipe.local:5601/app/dev_tools

  # Elasticsearch API directe:
  http://sonar.equipe.local:9200

  # Santé du cluster:
  http://sonar.equipe.local:9200/_cluster/health?pretty

  # Index existants:
  http://sonar.equipe.local:9200/_cat/indices/taskmanager-java-logs-*?v

  # API Spring Boot (local dev):
  http://localhost:8080/api/tasks

  REQUÊTES KQL LES PLUS UTILISÉES:
  ─────────────────────────────────

  # Exceptions Java:
  stack_trace: *

  # Type d'exception spécifique:
  exception_class: "java.lang.NullPointerException"

  # Logs d'une classe spécifique:
  logger_name: "com.equipe.taskmanager.service.TaskService"

  # Tracer une requête complète:
  correlation_id: "MON-ID-ICI"

  # Erreurs 5xx:
  http_status >= 500

  # Requêtes lentes:
  duration_ms > 500

  # Appels de service lents:
  event_type: "service_call" AND duration_ms > 200

  # Tâches créées aujourd'hui:
  event_type: "task_created" AND @timestamp > "now/d"

  ALIAS PRATIQUES (bash/zsh):
  ────────────────────────────

  # Dans ~/.bashrc ou ~/.zshrc:
  alias elk-up='cd ~/taskmanager-java/docker && docker-compose -f docker-compose.elk.yml up -d'
  alias elk-down='cd ~/taskmanager-java/docker && docker-compose -f docker-compose.elk.yml down'
  alias kibana='open http://localhost:5601'
  alias es-health='curl -s http://localhost:9200/_cluster/health | python3 -m json.tool'
  alias es-count='curl -s "http://localhost:9200/_cat/indices/taskmanager-java-logs-*?v"'
  alias dev='cd ~/taskmanager-java && ./run-dev.sh'
  alias test='cd ~/taskmanager-java && mvn test -Dspring.profiles.active=test'

  VARIABLES D'ENVIRONNEMENT (.env):
  ──────────────────────────────────

  LOGSTASH_HOST=192.168.1.10
  LOGSTASH_PORT=5000
  KIBANA_URL=http://192.168.1.10:5601
  APP_ENV=development
  SPRING_PROFILES_ACTIVE=dev

================================================================================
FIN DE LA SECTION - ELK STACK EN ÉQUIPE DE 3 DÉVELOPPEURS (JAVA SPRING BOOT)
================================================================================