Skip to content

Monolog

TL;DR — Monolog est le standard de fait du logging PHP. Architecture : un logger par channel → liste de handlers (où écrire) → chaîne de processors (enrichir le record) → formatter (format final). En prod, configure un fingers_crossed qui ne crache vers stderr/Sentry que si un log error apparaît dans la requête : tu ne perds pas les détails sans noyer les logs. Structure tes logs en JSON pour qu'ils soient parsables par Loki/ELK/Datadog.

🧠 Mental model — ASCII diagram + analogy

   Logger::error('User failed login', ['user_id' => 42])


   ┌────────────────────────────────────────┐
   │  Channel "security" (a Logger instance)│
   │                                        │
   │  Handlers (executed in order, bubble)  │
   │  ┌──────────────────────────────────┐  │
   │  │ FingersCrossedHandler            │  │
   │  │  ├ wrapped: StreamHandler stderr │  │
   │  │  └ trigger: action_level=error   │  │
   │  ├──────────────────────────────────┤  │
   │  │ SlackHandler (only critical+)    │  │
   │  ├──────────────────────────────────┤  │
   │  │ SentryHandler (only error+)      │  │
   │  └──────────────────────────────────┘  │
   │                                        │
   │  Each record passes through:           │
   │   Processors → Formatter → write       │
   └────────────────────────────────────────┘

Levels (RFC 5424): DEBUG < INFO < NOTICE < WARNING < ERROR < CRITICAL < ALERT < EMERGENCY

Analogie : pense plomberie. Le logger est un robinet (par channel). L'eau (= records) traverse une succession de vannes (handlers) qui décident de laisser passer ou bloquer selon le niveau. Avant de sortir, l'eau est analysée et étiquetée (processors ajoutent IP, user, request_id) puis mise en bouteille (formatter = JSON/line).

fingers_crossed est un buffer : il garde tout silencieusement, et libère le flot uniquement si un log d'un certain niveau (action_level) apparaît. Idéal en prod : tu obtiens le contexte complet d'une requête qui a échoué, sans logger les 99% qui réussissent.

🛠️ Code minimal — realistic snippet (PHP 8.2+)

yaml
# config/packages/monolog.yaml — base
monolog:
    channels: ['app', 'security_audit', 'doctrine_slow']

when@dev:
    monolog:
        handlers:
            main:
                type: stream
                path: '%kernel.logs_dir%/%kernel.environment%.log'
                level: debug
                channels: ['!event']
            console:
                type: console
                process_psr_3_messages: false
                channels: ['!event', '!doctrine', '!console']

when@prod:
    monolog:
        handlers:
            # Buffer everything; flush only on error
            main:
                type: fingers_crossed
                action_level: error
                handler: nested
                excluded_http_codes: [404, 405]
                buffer_size: 50 # avoid memory leak with massive bursts
            nested:
                type: stream
                path: php://stderr
                level: debug
                formatter: monolog.formatter.json
            # Critical alerts to Slack
            slack:
                type: slack
                token: '%env(SLACK_TOKEN)%'
                channel: '#alerts-prod'
                level: critical
                bubble: false # stop here for critical
                channels: ['!event', '!doctrine']
            # Sentry for exceptions
            sentry:
                type: sentry
                dsn: '%env(SENTRY_DSN)%'
                level: error
                hub_id: Sentry\State\HubInterface
            # Audit trail — append-only, never rotated
            security_audit:
                type: stream
                path: /var/log/app/audit.jsonl
                level: info
                channels: ['security_audit']
                formatter: monolog.formatter.json

services:
    monolog.formatter.json:
        class: Monolog\Formatter\JsonFormatter
php
<?php
// src/Service/AuthService.php — using a channel
namespace App\Service;

use Psr\Log\LoggerInterface;

final class AuthService
{
    public function __construct(
        private LoggerInterface $logger,             // 'app' (default)
        private LoggerInterface $securityAuditLogger, // injected by name (channel)
    ) {}

    public function login(string $email, string $password): void
    {
        $this->logger->info('Login attempt', ['email' => $email]);

        if (!$this->verify($email, $password)) {
            $this->securityAuditLogger->warning('login.failed', [
                'email' => $email,
                'ip'    => $_SERVER['REMOTE_ADDR'] ?? null,
            ]);
            throw new \DomainException('Invalid credentials');
        }

        $this->securityAuditLogger->info('login.success', ['email' => $email]);
    }
}

Service binding for channel injection:

yaml
# config/services.yaml
services:
    _defaults:
        autowire: true
        autoconfigure: true
        bind:
            $securityAuditLogger: '@monolog.logger.security_audit'
php
<?php
// src/Logger/RequestIdProcessor.php — custom processor
namespace App\Logger;

use Monolog\LogRecord;
use Monolog\Processor\ProcessorInterface;
use Symfony\Component\HttpFoundation\RequestStack;

final class RequestIdProcessor implements ProcessorInterface
{
    public function __construct(private RequestStack $stack) {}

    public function __invoke(LogRecord $record): LogRecord
    {
        $req = $this->stack->getMainRequest();
        // ATTENTION : ne jamais appeler getSession() sans vérifier hasSession() —
        // sur une route stateless (API token) il n'y a PAS de session et l'appel throw.
        // Un processor qui throw = un log perdu silencieusement (et potentiellement une 500).
        $extra = [
            ...$record->extra,
            'request_id' => $req?->headers->get('X-Request-Id'),
            'user_id'    => $req?->hasSession() ? $req->getSession()->get('user_id') : null,
        ];
        // Monolog 3 : LogRecord est immutable, on RETOURNE une copie via ->with().
        return $record->with(extra: $extra);
    }
}
yaml
# Register processor
services:
    App\Logger\RequestIdProcessor:
        tags:
            - { name: monolog.processor }

🎯 Patterns courants

  1. Channels par domaineapp (défaut), security, doctrine, messenger, business_audit. Permet de router : audit business → fichier append-only, doctrine → silence sauf slow queries.

  2. fingers_crossed en prod — règle d'or pour APIs. action_level: error + excluded_http_codes: [404] évite les logs inutiles. Tu gardes la trace complète d'une requête fautive.

  3. JSON formatter partout en prodJsonFormatter produit du JSONL parsable par Loki/ELK/Datadog. Jamais de format texte humain en prod : tu perds la structure (tags, extra fields).

  4. Processors d'enrichissementWebProcessor (URL, method, IP), MemoryUsageProcessor, IntrospectionProcessor (file, line, class). Active globalement via tag.

  5. Niveaux disciplinésdebug = trace fine, info = événement métier (login, order paid), warning = situation suspecte mais récupérable, error = échec, critical = page on-call.

  6. Logging structuré — JAMAIS $logger->info("User $id failed"). Toujours : $logger->info('user.failed', ['user_id' => $id]). Le message reste stable (groupable), les variables vont en contexte.

🔄 Versions

MonologPHP minNotes
2.x7.2LogRecord est un array. Used by Symfony 5.4.
3.x8.1LogRecord devient un value object typé. Breaking.
3.4+8.1ProcessorInterface recommandé sur les processors.
Symfony / MonologBundleMonolog
Symfony 5.4 / MB 3.xMonolog 2.x
Symfony 6.0 / MB 3.7+Monolog 2.x ou 3.x
Symfony 6.4 / MB 3.8+Monolog 3.x recommandé
Symfony 7.0 / MB 3.10+Monolog 3.x requis

Migration 2 → 3 : les processors function($record) qui accédaient à $record['extra'] doivent passer à $record->extra (LogRecord est un objet). Sentry handler : sentry/sentry-symfony 4.x+ compatible Monolog 3.

⚠️ Pitfalls

  1. fingers_crossed qui bouffe la mémoire — sans buffer_size, il stocke toute la requête. Sur un long worker Messenger, ça explose. Configure buffer_size: 50-200.

  2. Logs perdus en CLIfingers_crossed se vide uniquement à la fin du process. Sur un messenger:consume qui tourne 24h, tes logs debug ne sortent jamais sauf si erreur. Désactive fingers_crossed pour le channel messenger ou utilise un handler direct.

  3. Logger pendant un shutdown — si tu loggues dans __destruct, le container peut être déjà détruit. Le handler crash silencieusement. Évite la logique métier dans destructeurs.

  4. bubble: true par défaut — un record passe par TOUS les handlers. Si Slack ET Sentry sont configurés sans bubble: false, tu reçois une double alerte. Décide qui termine la chaîne.

  5. Channels qui ne matchent paschannels: ['!event'] exclut le channel event. Mais ton handler custom sur channels: ['business'] ne capture que ce channel — $this->logger (app) n'arrive pas. Vérifie via bin/console debug:container monolog.logger.business.

  6. PII dans les logs — emails, IP, tokens. RGPD : minimise, hash, ou redacte. Crée un processor RedactingProcessor qui masque les champs sensibles avant le formatter.

  7. JSON tronqué par Docker logging driverjson-file driver limite ~16k par ligne. Un stack trace énorme se fait couper. Solution : envoyer directement au collecteur (Fluent Bit, Vector) via socket ou stdout sans wrap.

  8. Variations de schéma JSON — si tu changes les clés context.user_idcontext.userId, tes dashboards Kibana cassent. Documente le schéma de log comme un contrat.

🧪 Testing

php
<?php
namespace App\Tests\Service;

use App\Service\AuthService;
use PHPUnit\Framework\TestCase;
use Psr\Log\Test\TestLogger; // psr/log test helper

final class AuthServiceLoggingTest extends TestCase
{
    public function testFailedLoginIsAudited(): void
    {
        $logger      = new TestLogger();
        $auditLogger = new TestLogger();
        $svc         = new AuthService($logger, $auditLogger);

        try { $svc->login('[email protected]', 'wrong'); } catch (\DomainException) {}

        self::assertTrue($auditLogger->hasWarningThatContains('login.failed'));
    }
}

psr/log fournit Psr\Log\Test\TestLogger — pas besoin de mocker LoggerInterface.

Note version (2026) : Psr\Log\Test\TestLogger est marqué @deprecated depuis psr/log 3.x (il sera retiré en 4.0). Pour un test unitaire moderne, préfère Monolog\Handler\TestHandler branché sur un vrai Monolog\Logger — il capture le LogRecord complet (context + extra), ce que TestLogger ne fait pas :

php
<?php
use Monolog\Handler\TestHandler;
use Monolog\Level;
use Monolog\Logger;

$handler = new TestHandler();
$logger  = new Logger('test', [$handler]);

$svc = new AuthService($logger, $logger);
try { $svc->login('[email protected]', 'wrong'); } catch (\DomainException) {}

self::assertTrue($handler->hasRecordThatContains('login.failed', Level::Warning));
// On peut aussi inspecter le context exact, ce qui est le vrai intérêt :
$record = $handler->getRecords()[0];
self::assertSame('[email protected]', $record->context['email']);

Pour un test fonctionnel (WebTestCase), n'assert pas sur les logs : Symfony route déjà les logs du test vers un handler en mémoire. Récupère-les via $client->getProfile()?->getCollector('logger') ou via un TestHandler injecté dans le container de test (self::getContainer()->set('monolog.handler.main', $testHandler)).

🎬 Cas d'usage concrets

Scénario 1 — Logs structured banque compliance (BNP Paribas, Crédit Mutuel)

Une banque retail française doit conserver 5 ans tous les logs liés aux transactions PSD2/AML pour répondre à l'ACPR et Tracfin. Monolog est configuré avec : channel audit (JSONL append-only, output vers s3://bnp-audit-logs/ via LogstashHandler + Filebeat, immuable WORM), channel security (tentatives de login, MFA, IP suspecte, output Splunk via SocketHandler), channel transaction (chaque virement initié, montant, IBAN bénéficiaire masqué via ProcessorInterface). Un processor RequestIdProcessor injecte un UUID dans chaque ligne pour corréler une requête entre les services (API gateway, microservice virement, core banking COBOL). Le processor PsrLogMessageProcessor formate les placeholders. Tracfin reçoit chaque jour un export filtré (montants > 8 000 €, virements internationaux) via une commande app:export-tracfin qui lit les logs JSONL. Les logs debug et info sont en hot storage 30 jours, puis archivés Glacier. Pendant un contrôle ACPR, l'équipe sécurité reconstitue une transaction de 2024 en 4 minutes via le request_id.

Scénario 2 — Logs audit cabinet juridique (Septeo, Cellence DMS)

Un cabinet d'avocats doit prouver l'accès aux dossiers clients pour respecter le secret professionnel + RGPD (qui a vu quoi, quand, depuis où). Monolog est splitté en channel dms_access (chaque ouverture d'un dossier Matter par un avocat, JSONL signé HMAC pour intégrité), channel document_signature (signature électronique d'actes, niveau eIDAS qualifié), channel email (envoi à client/contrepartie). Le WebProcessor ajoute IP, user-agent, session, et un processor custom LawyerProcessor ajoute lawyer_id, bar_id (numéro d'inscription au barreau). Les logs sont chiffrés AES-256 au repos avec une clé tournée tous les 90 jours. Lors d'un incident (avocat qui consulte un dossier sur lequel il n'est pas assigné), une alerte Slack arrive en <30s via SlackWebhookHandler filtré sur level >= WARNING. Le bâtonnier peut demander un export auditable des accès sur un dossier précis via bin/console app:audit-matter --matter=AF-2026-042.

Scénario 3 — Logs e-commerce Sentry (Veepee, Asphalte)

Une boutique e-commerce délègue le tracking d'erreurs à Sentry et les logs business à Loki/Grafana. Monolog est configuré : channel app (handler StreamHandler STDERR pour Docker + handler MonologBundle\SentryHandler à partir de level: error), channel payment (chaque interaction Stripe/PayPal, sanitization automatique des numéros CB via processor regex), channel order (création, annulation, refund). En préprod, le level est debug ; en prod, notice pour éviter le bruit. Le FingersCrossedHandler bufferise les debug/info et les flush uniquement si une erreur >= error survient dans la même requête (gain de stockage 80%). Sentry est enrichi via Hub::configureScope : user_id, order_id, route. Les exceptions 5xx déclenchent une alerte PagerDuty si plus de 10 en 5 min. Sur le Black Friday 2025, Sentry a remonté en 2 minutes une exception Stripe\Exception\ApiConnectionException due à un timeout réseau côté Stripe (incident de leur côté), permettant à l'équipe de basculer le traffic sur PayPal sans attendre les remontées clients.

🛠️ Exemple end-to-end

Cas : configuration multi-channel banque avec audit JSONL, request_id, masquage IBAN, et alerte Slack.

yaml
# config/packages/monolog.yaml
monolog:
    channels: ['audit', 'security', 'transaction', 'payment']

    handlers:
        main:
            type: stream
            path: 'php://stderr'
            level: notice
            channels: ['!audit', '!security', '!transaction']
            formatter: 'monolog.formatter.json'

        audit:
            type: stream
            path: '%kernel.logs_dir%/audit-%kernel.environment%.jsonl'
            level: info
            channels: ['audit']
            formatter: 'monolog.formatter.json'

        security_splunk:
            type: socket
            connection_string: 'tls://splunk.bnp.local:5044'
            level: notice
            channels: ['security']

        transaction:
            type: rotating_file
            path: '%kernel.logs_dir%/transactions.jsonl'
            level: info
            max_files: 1825 # 5 ans réglementaires
            channels: ['transaction']
            formatter: 'monolog.formatter.json'

        slack_critical:
            type: slack_webhook
            webhook_url: '%env(SLACK_AUDIT_WEBHOOK)%'
            channel: '#audit-alerts'
            level: warning
            channels: ['security', 'audit']

when@prod:
    monolog:
        handlers:
            main:
                level: notice
php
<?php
// src/Logger/IbanMaskingProcessor.php
declare(strict_types=1);

namespace App\Logger;

use Monolog\LogRecord;
use Monolog\Processor\ProcessorInterface;

final class IbanMaskingProcessor implements ProcessorInterface
{
    public function __invoke(LogRecord $record): LogRecord
    {
        $context = $record->context;
        array_walk_recursive($context, static function (&$value): void {
            if (is_string($value) && preg_match('/\b[A-Z]{2}\d{2}[A-Z0-9]{10,30}\b/', $value)) {
                $value = preg_replace_callback(
                    '/\b([A-Z]{2}\d{2})([A-Z0-9]+)(\d{4})\b/',
                    static fn (array $m): string => $m[1] . str_repeat('*', strlen($m[2])) . $m[3],
                    $value,
                );
            }
        });
        return $record->with(context: $context);
    }
}
php
<?php
// src/Logger/RequestIdProcessor.php
declare(strict_types=1);

namespace App\Logger;

use Monolog\LogRecord;
use Monolog\Processor\ProcessorInterface;
use Symfony\Component\HttpFoundation\RequestStack;
use Symfony\Component\Uid\Uuid;

final class RequestIdProcessor implements ProcessorInterface
{
    public function __construct(private readonly RequestStack $requestStack) {}

    public function __invoke(LogRecord $record): LogRecord
    {
        $request = $this->requestStack->getMainRequest();
        if (null === $request) {
            return $record;
        }
        $requestId = $request->attributes->get('_request_id');
        if (null === $requestId) {
            $requestId = (string) Uuid::v7();
            $request->attributes->set('_request_id', $requestId);
        }
        return $record->with(extra: [...$record->extra, 'request_id' => $requestId]);
    }
}
yaml
# config/services.yaml
services:
    App\Logger\IbanMaskingProcessor:
        tags: [{ name: monolog.processor }]
    App\Logger\RequestIdProcessor:
        tags: [{ name: monolog.processor }]
php
<?php
// src/Service/TransferService.php
declare(strict_types=1);

namespace App\Service;

use App\Entity\Transfer;
use Psr\Log\LoggerInterface;

final readonly class TransferService
{
    public function __construct(
        private LoggerInterface $transactionLogger,
        private LoggerInterface $auditLogger,
    ) {}

    public function initiateTransfer(string $debtorIban, string $creditorIban, string $amount): Transfer
    {
        $transfer = new Transfer($debtorIban, $creditorIban, $amount);

        $this->transactionLogger->info('transfer.initiated', [
            'transfer_id' => $transfer->id,
            'debtor_iban' => $debtorIban,
            'creditor_iban' => $creditorIban,
            'amount' => $amount,
            'currency' => 'EUR',
        ]);

        if (bccomp($amount, '8000.00', 2) >= 0) {
            $this->auditLogger->notice('aml.threshold.reached', [
                'transfer_id' => $transfer->id,
                'amount' => $amount,
                'tracfin_eligible' => true,
            ]);
        }

        return $transfer;
    }
}

Couverture : channel multi-cible + processor masquage IBAN + processor request_id + emission métier. Les logs transactions.jsonl sont prêts pour ingestion ACPR/Tracfin sans transformation.


🔁 Quand utiliser / éviter

Utiliser :

  • Toujours : un service sans logs = boîte noire en prod.
  • Audit trail réglementaire (login, paiement, suppression) → channel dédié, JSONL append-only, jamais supprimé.
  • Tracing requête : injecter un request_id via processor permet de reconstituer une transaction.

Éviter :

  • Logger des secrets, mots de passe, tokens, CB. JAMAIS.
  • Logger en boucle (1000 logs par requête) → coût de stockage + alertes saturées.
  • Remplacer un APM par des logs : pour le temps de réponse, métriques, percentiles → Prometheus + Grafana, pas Monolog.

🧭 Comment un staff engineer raisonne

Le logging n'est pas un détail d'implémentation : c'est une interface de production consommée par des humains à 3h du matin et par des machines (parsers, alerting, SIEM). Les décisions ci-dessous sont celles qu'on défend en design review.

Choix du transport : où écrivent les logs ?

TransportLatenceDurabilitéQuandPiège
php://stderr (stream)quasi-nulledéléguée au runtime (Docker/k8s)défaut conteneurisé : l'app écrit stdout/stderr, le collecteur (Fluent Bit/Vector) gère le resterien ne flush si le pod OOM-kill mid-buffer
rotating_filefaiblelocale, perdue si le node meurtVM legacy, audit local court termerotation = perte si pas backupé ; inode lock sur NFS
socket (TCP/TLS)réseaudépend du backendpush direct vers Splunk/Logstashbloquant : si le collecteur est down, ta requête HTTP attend le timeout TCP
LogstashHandler / GelfHandlerréseaubackendformat pré-structuré pour ELK/Graylogcouplage au schéma du backend
SentryHandlerasync (queue interne)Sentry SaaSerreurs + exceptions uniquementn'envoie PAS tes info — ce n'est pas un log store

Règle staff : en conteneur, écris sur stdout/stderr en JSON et laisse l'infra router. Un handler socket synchrone dans le chemin de la requête est un SPOF caché : le jour où Splunk tombe, c'est ton API qui ralentit. Si tu dois pousser sur le réseau, fais-le via un sidecar/agent local non bloquant, jamais en direct depuis PHP-FPM.

fingers_crossed : le bon outil, le mauvais réflexe

fingers_crossed est génial pour le modèle requête courte (HTTP) : un buffer par requête, flush sur erreur, mémoire libérée à la fin du worker. Mais il a trois angles morts qu'un senior anticipe :

  1. Processus longs (Messenger, --time-limit élevé, daemons) : le buffer ne se vide qu'à la mort du process ou sur action_level. Solution : passthru_level (laisse passer notice+ immédiatement même sans trigger) ou désactive fingers_crossed sur le channel messenger.
  2. Mémoire : sans buffer_size, un endpoint qui logge 10k lignes en debug avant de crasher garde tout en RAM. buffer_size: 50 borne le risque (on garde les 50 derniers records).
  3. stopBuffering jamais appelé : sur un worker, après le premier flush le handler reste en mode "passthrough" jusqu'au reset. En contexte Messenger, le ResetServicesListener de Symfony remet le compteur à zéro entre deux messages — vérifie qu'il est actif, sinon un message en erreur "salit" les logs des suivants.

Le schéma JSON est un contrat d'API

Tes clés (request_id, context.user_id, level_name) sont consommées par des dashboards, des règles d'alerting et des audits réglementaires. Renommer une clé casse silencieusement un dashboard Kibana sans aucune erreur de compilation. Traite le schéma comme une API versionnée : ajoute une clé schema_version, ne renomme jamais sans migration, et centralise le format via un Processor partagé plutôt que de répéter les clés dans chaque appel.

Coût et cardinalité

Un log info par requête × 5000 req/s × 90 jours = des téraoctets et une facture Datadog à 5 chiffres. Le levier staff n'est pas "logger moins" mais logger au bon niveau et utiliser fingers_crossed pour ne payer le stockage que des requêtes intéressantes. Évite la haute cardinalité dans les labels (Loki/Prometheus) : un user_id en label explose l'index ; il va dans le contenu du log (context), pas dans un label indexé.

Observabilité : logs vs metrics vs traces

Les trois piliers ne sont pas interchangeables. Logs = événements discrets riches (le quoi et le pourquoi). Metrics = agrégats numériques bon marché (le combien, percentiles, taux). Traces = causalité distribuée (le où dans la chaîne). Un staff engineer ne logge pas une latence à chaque requête pour calculer un p99 — c'est le rôle des metrics. Le bon réflexe : propager un trace_id (OpenTelemetry) dans les logs pour pivoter d'une trace lente vers ses logs.

🏋️ Exercices

  1. Processor de redaction PII (implement)Objectif : écrire un RedactingProcessor qui masque automatiquement les clés sensibles (password, token, authorization, card_number) à n'importe quelle profondeur du context et de l'extra, avant le formatter. Indice/Solution : implémente ProcessorInterface, applique array_walk_recursive sur une copie de $record->context ET $record->extra, remplace les valeurs dont la clé matche une liste (case-insensitive) par '[REDACTED]', puis return $record->with(context: ..., extra: ...). Attention : array_walk_recursive ne donne pas la clé de manière fiable pour le matching par clé — itère plutôt récursivement toi-même et teste array_key_exists.

  2. Channel messenger qui ne perd plus ses logs (production-grade)Objectif : sur un consumer messenger:consume long, garantir que les debug/info d'un message réussi sortent quand même (pour debug post-mortem), sans noyer la prod. Indice/Solution : sépare le channel messenger du fingers_crossed global. Option A : passthru_level: info sur le fingers_crossed du channel messenger. Option B : handler stream direct pour messenger avec level: info. Vérifie que Symfony\Component\Messenger\EventListener\ResetServicesListener réinitialise bien les services entre messages (sinon le buffer fuit d'un message à l'autre).

  3. Corrélation distribuée request_id → trace_id (production-grade)Objectif : propager un identifiant de corrélation reçu d'un header amont (X-Request-Id ou traceparent W3C) dans tous les logs de la requête ET dans les messages Messenger asynchrones, pour reconstituer une transaction across services. Indice/Solution : un Processor qui lit le header (ou génère un Uuid::v7()), le stocke dans RequestStack attributes. Pour Messenger : un StampInterface custom (RequestIdStamp) ajouté au dispatch via middleware, relu côté consumer dans un middleware qui re-pousse l'id dans un service RequestIdHolder que le processor lit. Bonus : parser traceparent pour rester compatible OpenTelemetry.

  4. Break-then-fix : le handler socket qui fait tomber l'API (break-then-fix)Objectif : reproduire puis corriger un incident où un SocketHandler vers un collecteur down fait timeout chaque requête HTTP. Indice/Solution : configure un SocketHandler vers un port fermé (tls://127.0.0.1:9999), observe que chaque requête attend le connectionTimeout. Fix : (1) baisse setConnectionTimeout()/setTimeout(), (2) wrappe dans un WhatFailureGroupHandler (avale les exceptions du handler interne pour qu'un collecteur down ne casse jamais la requête), (3) idéalement déplace le push réseau hors du chemin requête (stdout + agent sidecar).

  5. Audit trail inviolable (break-then-fix, hard)Objectif : produire un log d'audit append-only dont on peut prouver qu'aucune ligne n'a été supprimée ou modifiée a posteriori. Indice/Solution : chaîne de hash — chaque record d'audit inclut prev_hash = hash du record précédent (style blockchain léger). Un Processor maintient l'état du dernier hash (service singleton) et calcule hash('sha256', $prevHash . json_encode($record->context)). Casse-le : supprime une ligne au milieu du fichier → la vérification de chaîne détecte la rupture. Discute les limites : concurrence multi-process (le dernier hash devient un point de contention → besoin d'un lock ou d'un seul writer / Kafka partitionné).

  6. Formatter custom ECS / OTel (production-grade)Objectif : émettre des logs conformes à un schéma standardisé (Elastic Common Schema ou OpenTelemetry Logs) pour qu'ils s'intègrent sans mapping custom dans le backend. Indice/Solution : étends JsonFormatter ou implémente FormatterInterface, mappe level_namelog.level, messagemessage, context.*labels.*, extra.request_idtrace.id. Teste que la sortie passe la validation du schéma ECS. Discute pourquoi un formatter standard > un schéma maison : interop, pas de mapping Logstash à maintenir.

🎤 En entretien

Q : Pourquoi fingers_crossed et quel est son principal danger en production ? R : Il bufferise tous les records d'une requête et ne les flush que si un record atteint action_level (ex. error) — on garde le contexte complet d'une requête fautive sans payer le stockage des 99% qui réussissent. Danger : sur un processus long (Messenger, daemon) le buffer ne se vide jamais sans trigger et fuit en mémoire ; on borne avec buffer_size et on isole le channel des workers ou on utilise passthru_level.

Q : bubble: true vs false — concrètement ça change quoi ? R : bubble décide si un record continue vers les handlers suivants après avoir été traité. Avec bubble: false, le handler "termine" la chaîne pour ce record — utile pour éviter une double-alerte (ex. Slack ET Sentry sur la même critical). L'ordre des handlers compte donc : un handler restrictif avec bubble: false peut couper des records aux handlers d'après.

Q : Pourquoi du logging structuré JSON plutôt qu'une string lisible, et où mettre les variables ? R : Le message doit rester stable ('user.login.failed') pour être groupable/alertable ; les variables vont dans le context (['user_id' => 42]). En string interpolée on perd la structure (impossible d'agréger), on crée de la haute cardinalité, et on s'expose à l'injection de logs. JSON = parsable par Loki/ELK/Datadog sans regex fragile, et le schéma de clés devient un contrat versionné.

Q : Tu as un endpoint à 5000 req/s, le coût de logging explose. Comment tu raisonnes ? R : Je distingue d'abord logs/metrics/traces — un p99 de latence n'a rien à faire en log, c'est une metric. Pour les logs restants : fingers_crossed pour ne stocker que les requêtes fautives, niveau notice+ en prod (le debug reste bufferisé), sampling des info à fort volume, et zéro haute cardinalité en label (le user_id va dans le contenu, pas dans un index Loki). Si je dois absolument pousser sur le réseau, c'est via un agent local non bloquant, jamais un handler socket synchrone dans le chemin requête.

🔗 Liens

Bibliothèque tech perso — Achref