Aller au contenu principal

TP10.2 - Système de Logging Professionnel

Objectifs du TP​

Dans ce TP, vous allez:

  1. ✅ Configurer Logback avec logback-spring.xml
  2. ✅ Créer des configurations par environnement
  3. ✅ Implémenter le MDC pour la traçabilité
  4. ✅ Configurer la rotation des fichiers logs
  5. ✅ Ajouter des logs structurés dans l'application

Durée estimée: 1h30


Contexte​

Vous reprenez l'application Bookstore du TP10.1 et ajoutez un système de logging professionnel pour le monitoring et le debugging en production.


Partie 1: Configuration Logback​

Créer logback-spring.xml​

Créez src/main/resources/logback-spring.xml:

<?xml version="1.0" encoding="UTF-8"?>
<configuration scan="true" scanPeriod="30 seconds">

<!-- Import des defaults Spring Boot -->
<include resource="org/springframework/boot/logging/logback/defaults.xml"/>

<!-- Propriétés -->
<springProperty scope="context" name="APP_NAME" source="spring.application.name" defaultValue="bookstore"/>
<property name="LOG_PATH" value="${LOG_PATH:-logs}"/>
<property name="LOG_PATTERN" value="%d{yyyy-MM-dd HH:mm:ss.SSS} [%thread] [%X{requestId:-N/A}] [%X{userId:-anonymous}] %-5level %logger{36} - %msg%n"/>

<!-- ===== CONSOLE APPENDER ===== -->
<appender name="CONSOLE" class="ch.qos.logback.core.ConsoleAppender">
<encoder>
<pattern>%d{HH:mm:ss.SSS} [%X{requestId:-}] %highlight(%-5level) %cyan(%logger{36}) - %msg%n</pattern>
</encoder>
</appender>

<!-- ===== FILE APPENDER ===== -->
<appender name="FILE" class="ch.qos.logback.core.rolling.RollingFileAppender">
<file>${LOG_PATH}/${APP_NAME}.log</file>
<rollingPolicy class="ch.qos.logback.core.rolling.SizeAndTimeBasedRollingPolicy">
<fileNamePattern>${LOG_PATH}/archived/${APP_NAME}.%d{yyyy-MM-dd}.%i.log.gz</fileNamePattern>
<maxFileSize>10MB</maxFileSize>
<maxHistory>30</maxHistory>
<totalSizeCap>1GB</totalSizeCap>
</rollingPolicy>
<encoder>
<pattern>${LOG_PATTERN}</pattern>
</encoder>
</appender>

<!-- ===== ERROR FILE APPENDER ===== -->
<appender name="ERROR_FILE" class="ch.qos.logback.core.rolling.RollingFileAppender">
<file>${LOG_PATH}/${APP_NAME}-error.log</file>
<filter class="ch.qos.logback.classic.filter.ThresholdFilter">
<level>ERROR</level>
</filter>
<rollingPolicy class="ch.qos.logback.core.rolling.SizeAndTimeBasedRollingPolicy">
<fileNamePattern>${LOG_PATH}/archived/${APP_NAME}-error.%d{yyyy-MM-dd}.%i.log.gz</fileNamePattern>
<maxFileSize>10MB</maxFileSize>
<maxHistory>90</maxHistory>
</rollingPolicy>
<encoder>
<pattern>${LOG_PATTERN}</pattern>
</encoder>
</appender>

<!-- TODO: Ajouter les profils dev, prod, test -->

</configuration>

Partie 2: Profils de Logging​

Ajouter les profils dans logback-spring.xml​

    <!-- ===== PROFIL DEV ===== -->
<springProfile name="dev,local">
<root level="INFO">
<appender-ref ref="CONSOLE"/>
</root>

<!-- Application en DEBUG -->
<logger name="com.bookstore" level="DEBUG"/>

<!-- SQL Queries -->
<logger name="org.hibernate.SQL" level="DEBUG"/>
<logger name="org.hibernate.type.descriptor.sql.BasicBinder" level="TRACE"/>

<!-- Spring Web -->
<logger name="org.springframework.web" level="DEBUG"/>
</springProfile>

<!-- ===== PROFIL PROD ===== -->
<springProfile name="prod">
<root level="WARN">
<appender-ref ref="FILE"/>
<appender-ref ref="ERROR_FILE"/>
</root>

<logger name="com.bookstore" level="INFO"/>
</springProfile>

<!-- ===== PROFIL TEST ===== -->
<springProfile name="test">
<root level="WARN">
<appender-ref ref="CONSOLE"/>
</root>
<logger name="com.bookstore" level="INFO"/>
</springProfile>

<!-- ===== DEFAULT ===== -->
<springProfile name="default">
<root level="INFO">
<appender-ref ref="CONSOLE"/>
<appender-ref ref="FILE"/>
</root>
<logger name="com.bookstore" level="DEBUG"/>
</springProfile>

Partie 3: Filtre MDC​

MdcLoggingFilter​

package com.bookstore.filter;

import jakarta.servlet.FilterChain;
import jakarta.servlet.ServletException;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import org.slf4j.MDC;
import org.springframework.core.Ordered;
import org.springframework.core.annotation.Order;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.stereotype.Component;
import org.springframework.web.filter.OncePerRequestFilter;

import java.io.IOException;
import java.util.UUID;

@Component
@Order(Ordered.HIGHEST_PRECEDENCE)
public class MdcLoggingFilter extends OncePerRequestFilter {

private static final String REQUEST_ID = "requestId";
private static final String USER_ID = "userId";
private static final String PATH = "path";
private static final String METHOD = "method";

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

long startTime = System.currentTimeMillis();

try {
// Générer ou récupérer le Request ID
String requestId = request.getHeader("X-Request-ID");
if (requestId == null || requestId.isBlank()) {
requestId = UUID.randomUUID().toString().substring(0, 8);
}

// Ajouter au MDC
MDC.put(REQUEST_ID, requestId);
MDC.put(PATH, request.getRequestURI());
MDC.put(METHOD, request.getMethod());

// Ajouter le User ID si authentifié
Authentication auth = SecurityContextHolder.getContext().getAuthentication();
if (auth != null && auth.isAuthenticated() && !"anonymousUser".equals(auth.getPrincipal())) {
MDC.put(USER_ID, auth.getName());
}

// Ajouter le Request ID dans la réponse
response.setHeader("X-Request-ID", requestId);

// Continuer la chaîne
filterChain.doFilter(request, response);

} finally {
// Log de la requête terminée
long duration = System.currentTimeMillis() - startTime;
logger.info("Request completed: {} {} - Status: {} - Duration: {}ms",
MDC.get(METHOD), MDC.get(PATH), response.getStatus(), duration);

// IMPORTANT: Toujours nettoyer le MDC!
MDC.clear();
}
}

@Override
protected boolean shouldNotFilter(HttpServletRequest request) {
// Ne pas filtrer les ressources statiques
String path = request.getRequestURI();
return path.startsWith("/css/")
|| path.startsWith("/js/")
|| path.startsWith("/images/")
|| path.equals("/favicon.ico");
}
}

Partie 4: Ajouter des Logs dans le Code​

BookService avec Logging​

package com.bookstore.service;

import com.bookstore.entity.Book;
import com.bookstore.exception.*;
import com.bookstore.repository.BookRepository;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;

import java.util.List;

@Service
@RequiredArgsConstructor
@Slf4j
public class BookService {

private final BookRepository bookRepository;

public List<Book> findAll() {
log.debug("Fetching all books");
List<Book> books = bookRepository.findAll();
log.debug("Found {} books", books.size());
return books;
}

public Book findById(Long id) {
log.debug("Finding book with ID: {}", id);
return bookRepository.findById(id)
.orElseThrow(() -> {
log.warn("Book not found with ID: {}", id);
return new ResourceNotFoundException("Book", id);
});
}

@Transactional
public Book create(Book book) {
log.info("Creating new book: title='{}', author='{}', isbn='{}'",
book.getTitle(), book.getAuthor(), book.getIsbn());

if (bookRepository.existsByIsbn(book.getIsbn())) {
log.warn("Attempt to create book with duplicate ISBN: {}", book.getIsbn());
throw new ConflictException("ISBN already exists: " + book.getIsbn());
}

Book saved = bookRepository.save(book);
log.info("Book created successfully with ID: {}", saved.getId());
return saved;
}

@Transactional
public Book update(Long id, Book updated) {
log.info("Updating book with ID: {}", id);
log.debug("New values: title='{}', price={}", updated.getTitle(), updated.getPrice());

Book existing = findById(id);

if (!existing.getIsbn().equals(updated.getIsbn())
&& bookRepository.existsByIsbn(updated.getIsbn())) {
log.warn("Attempt to change ISBN to existing value: {}", updated.getIsbn());
throw new ConflictException("ISBN already exists: " + updated.getIsbn());
}

existing.setTitle(updated.getTitle());
existing.setAuthor(updated.getAuthor());
existing.setIsbn(updated.getIsbn());
existing.setPrice(updated.getPrice());
existing.setStock(updated.getStock());

Book saved = bookRepository.save(existing);
log.info("Book {} updated successfully", id);
return saved;
}

@Transactional
public void delete(Long id) {
log.info("Deleting book with ID: {}", id);
Book book = findById(id);
bookRepository.delete(book);
log.info("Book {} deleted successfully", id);
}

@Transactional
public void decreaseStock(Long id, int quantity) {
log.info("Decreasing stock for book {}: quantity={}", id, quantity);

Book book = findById(id);
log.debug("Current stock: {}", book.getStock());

if (book.getStock() < quantity) {
log.warn("Insufficient stock for book {}: available={}, requested={}",
id, book.getStock(), quantity);
throw new BusinessRuleException(
"INSUFFICIENT_STOCK",
String.format("Not enough stock. Available: %d, Requested: %d",
book.getStock(), quantity)
);
}

book.setStock(book.getStock() - quantity);
bookRepository.save(book);
log.info("Stock decreased for book {}: new stock={}", id, book.getStock());
}
}

GlobalExceptionHandler avec Logging​

Mettez à jour le handler pour inclure des logs appropriés:

@ExceptionHandler(ResourceNotFoundException.class)
@ResponseStatus(HttpStatus.NOT_FOUND)
public ErrorResponse handleResourceNotFound(ResourceNotFoundException ex, HttpServletRequest request) {
// WARN car c'est une erreur client, pas serveur
log.warn("Resource not found: {} at {}", ex.getMessage(), request.getRequestURI());
return ErrorResponse.of(ex.getErrorCode(), ex.getMessage(), request.getRequestURI());
}

@ExceptionHandler(Exception.class)
@ResponseStatus(HttpStatus.INTERNAL_SERVER_ERROR)
public ErrorResponse handleGeneric(Exception ex, HttpServletRequest request) {
// ERROR avec stack trace pour les erreurs inattendues
log.error("Unexpected error at {}: {}", request.getRequestURI(), ex.getMessage(), ex);
return ErrorResponse.of("INTERNAL_ERROR", "An unexpected error occurred", request.getRequestURI());
}

Partie 5: Test du Logging​

Démarrer avec le profil dev​

mvn spring-boot:run -Dspring-boot.run.profiles=dev

Test 1: Créer un livre​

curl -X POST http://localhost:8080/api/books \
-H "Content-Type: application/json" \
-H "X-Request-ID: TEST-001" \
-d '{
"title": "Effective Java",
"author": "Joshua Bloch",
"isbn": "9780134685991",
"price": 45.00,
"stock": 15
}'

Logs attendus:

12:00:00.123 [TEST-001] INFO  BookController - Received create book request
12:00:00.125 [TEST-001] INFO BookService - Creating new book: title='Effective Java', author='Joshua Bloch', isbn='9780134685991'
12:00:00.150 [TEST-001] INFO BookService - Book created successfully with ID: 1
12:00:00.155 [TEST-001] INFO MdcLoggingFilter - Request completed: POST /api/books - Status: 201 - Duration: 32ms

Test 2: Livre non trouvé​

curl http://localhost:8080/api/books/999 \
-H "X-Request-ID: TEST-002"

Logs attendus:

12:01:00.100 [TEST-002] DEBUG BookService - Finding book with ID: 999
12:01:00.102 [TEST-002] WARN BookService - Book not found with ID: 999
12:01:00.103 [TEST-002] WARN GlobalExceptionHandler - Resource not found: Book with ID '999' not found at /api/books/999
12:01:00.105 [TEST-002] INFO MdcLoggingFilter - Request completed: GET /api/books/999 - Status: 404 - Duration: 5ms

Test 3: Vérifier les fichiers de log​

# Vérifier le contenu du fichier log
cat logs/bookstore.log

# Vérifier le fichier d'erreurs
cat logs/bookstore-error.log

Partie 6: Configuration Production (Bonus)​

Logs JSON avec Logstash Encoder​

Ajoutez la dépendance:

<dependency>
<groupId>net.logstash.logback</groupId>
<artifactId>logstash-logback-encoder</artifactId>
<version>7.4</version>
</dependency>

Ajoutez l'appender dans logback-spring.xml:

<!-- ===== JSON FILE APPENDER (pour ELK/Datadog) ===== -->
<appender name="JSON_FILE" class="ch.qos.logback.core.rolling.RollingFileAppender">
<file>${LOG_PATH}/${APP_NAME}.json</file>
<rollingPolicy class="ch.qos.logback.core.rolling.SizeAndTimeBasedRollingPolicy">
<fileNamePattern>${LOG_PATH}/archived/${APP_NAME}.%d{yyyy-MM-dd}.%i.json.gz</fileNamePattern>
<maxFileSize>50MB</maxFileSize>
<maxHistory>7</maxHistory>
</rollingPolicy>
<encoder class="net.logstash.logback.encoder.LogstashEncoder">
<includeMdcKeyName>requestId</includeMdcKeyName>
<includeMdcKeyName>userId</includeMdcKeyName>
<includeMdcKeyName>path</includeMdcKeyName>
<customFields>{"app":"bookstore","env":"prod"}</customFields>
</encoder>
</appender>

<springProfile name="prod">
<root level="INFO">
<appender-ref ref="JSON_FILE"/>
<appender-ref ref="ERROR_FILE"/>
</root>
<logger name="com.bookstore" level="INFO"/>
</springProfile>

Checklist​

  • logback-spring.xml créé et fonctionnel
  • Profils dev/prod/test configurés
  • MdcLoggingFilter implémente le requestId
  • Logs structurés dans le service
  • Fichiers de log créés dans /logs
  • Rotation des fichiers configurée
  • X-Request-ID retourné dans les headers de réponse

🎯 Module Terminé!​

Félicitations! Vous avez complété le Module 10 - Gestion des Exceptions et Logging.

Récapitulatif:

  • Chapitre 1: Exceptions personnalisées
  • Chapitre 2: @ControllerAdvice
  • Chapitre 3: SLF4J
  • Chapitre 4: Logback
  • TP10.1: Gestion centralisée des erreurs
  • TP10.2: Système de logging

➡️ Retour au Module 10


⬅️ Retour au Plan de Cours