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_crossedqui ne crache vers stderr/Sentry que si un logerrorapparaî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 < EMERGENCYAnalogie : 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+)
# 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
// 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:
# config/services.yaml
services:
_defaults:
autowire: true
autoconfigure: true
bind:
$securityAuditLogger: '@monolog.logger.security_audit'<?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);
}
}# Register processor
services:
App\Logger\RequestIdProcessor:
tags:
- { name: monolog.processor }🎯 Patterns courants
Channels par domaine —
app(défaut),security,doctrine,messenger,business_audit. Permet de router : audit business → fichier append-only, doctrine → silence sauf slow queries.fingers_crosseden 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.JSON formatter partout en prod —
JsonFormatterproduit du JSONL parsable par Loki/ELK/Datadog. Jamais de format texte humain en prod : tu perds la structure (tags, extra fields).Processors d'enrichissement —
WebProcessor(URL, method, IP),MemoryUsageProcessor,IntrospectionProcessor(file, line, class). Active globalement via tag.Niveaux disciplinés —
debug= trace fine,info= événement métier (login, order paid),warning= situation suspecte mais récupérable,error= échec,critical= page on-call.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
| Monolog | PHP min | Notes |
|---|---|---|
| 2.x | 7.2 | LogRecord est un array. Used by Symfony 5.4. |
| 3.x | 8.1 | LogRecord devient un value object typé. Breaking. |
| 3.4+ | 8.1 | ProcessorInterface recommandé sur les processors. |
| Symfony / MonologBundle | Monolog |
|---|---|
| Symfony 5.4 / MB 3.x | Monolog 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
fingers_crossedqui bouffe la mémoire — sansbuffer_size, il stocke toute la requête. Sur un long worker Messenger, ça explose. Configurebuffer_size: 50-200.Logs perdus en CLI —
fingers_crossedse vide uniquement à la fin du process. Sur unmessenger:consumequi tourne 24h, tes logsdebugne sortent jamais sauf si erreur. Désactivefingers_crossedpour le channelmessengerou utilise un handler direct.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.bubble: truepar défaut — un record passe par TOUS les handlers. Si Slack ET Sentry sont configurés sansbubble: false, tu reçois une double alerte. Décide qui termine la chaîne.Channels qui ne matchent pas —
channels: ['!event']exclut le channelevent. Mais ton handler custom surchannels: ['business']ne capture que ce channel —$this->logger(app) n'arrive pas. Vérifie viabin/console debug:container monolog.logger.business.PII dans les logs — emails, IP, tokens. RGPD : minimise, hash, ou redacte. Crée un processor
RedactingProcessorqui masque les champs sensibles avant le formatter.JSON tronqué par Docker logging driver —
json-filedriver 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.Variations de schéma JSON — si tu changes les clés
context.user_id→context.userId, tes dashboards Kibana cassent. Documente le schéma de log comme un contrat.
🧪 Testing
<?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\TestLoggerest marqué@deprecateddepuispsr/log3.x (il sera retiré en 4.0). Pour un test unitaire moderne, préfèreMonolog\Handler\TestHandlerbranché sur un vraiMonolog\Logger— il capture leLogRecordcomplet (context + extra), ce queTestLoggerne fait pas :
<?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.
# 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
// 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
// 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]);
}
}# config/services.yaml
services:
App\Logger\IbanMaskingProcessor:
tags: [{ name: monolog.processor }]
App\Logger\RequestIdProcessor:
tags: [{ name: monolog.processor }]<?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_idvia 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 ?
| Transport | Latence | Durabilité | Quand | Piège |
|---|---|---|---|---|
php://stderr (stream) | quasi-nulle | déléguée au runtime (Docker/k8s) | défaut conteneurisé : l'app écrit stdout/stderr, le collecteur (Fluent Bit/Vector) gère le reste | rien ne flush si le pod OOM-kill mid-buffer |
rotating_file | faible | locale, perdue si le node meurt | VM legacy, audit local court terme | rotation = perte si pas backupé ; inode lock sur NFS |
socket (TCP/TLS) | réseau | dépend du backend | push direct vers Splunk/Logstash | bloquant : si le collecteur est down, ta requête HTTP attend le timeout TCP |
LogstashHandler / GelfHandler | réseau | backend | format pré-structuré pour ELK/Graylog | couplage au schéma du backend |
SentryHandler | async (queue interne) | Sentry SaaS | erreurs + exceptions uniquement | n'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 :
- Processus longs (Messenger,
--time-limitélevé, daemons) : le buffer ne se vide qu'à la mort du process ou suraction_level. Solution :passthru_level(laisse passernotice+ immédiatement même sans trigger) ou désactivefingers_crossedsur le channelmessenger. - Mémoire : sans
buffer_size, un endpoint qui logge 10k lignes en debug avant de crasher garde tout en RAM.buffer_size: 50borne le risque (on garde les 50 derniers records). stopBufferingjamais appelé : sur un worker, après le premier flush le handler reste en mode "passthrough" jusqu'au reset. En contexte Messenger, leResetServicesListenerde 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
Processor de redaction PII (implement)Objectif : écrire un
RedactingProcessorqui masque automatiquement les clés sensibles (password,token,authorization,card_number) à n'importe quelle profondeur ducontextet de l'extra, avant le formatter. Indice/Solution : implémenteProcessorInterface, appliquearray_walk_recursivesur une copie de$record->contextET$record->extra, remplace les valeurs dont la clé matche une liste (case-insensitive) par'[REDACTED]', puisreturn $record->with(context: ..., extra: ...). Attention :array_walk_recursivene donne pas la clé de manière fiable pour le matching par clé — itère plutôt récursivement toi-même et testearray_key_exists.Channel
messengerqui ne perd plus ses logs (production-grade)Objectif : sur un consumermessenger:consumelong, garantir que lesdebug/infod'un message réussi sortent quand même (pour debug post-mortem), sans noyer la prod. Indice/Solution : sépare le channelmessengerdufingers_crossedglobal. Option A :passthru_level: infosur lefingers_crosseddu channel messenger. Option B : handlerstreamdirect pourmessengeraveclevel: info. Vérifie queSymfony\Component\Messenger\EventListener\ResetServicesListenerréinitialise bien les services entre messages (sinon le buffer fuit d'un message à l'autre).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-IdoutraceparentW3C) dans tous les logs de la requête ET dans les messages Messenger asynchrones, pour reconstituer une transaction across services. Indice/Solution : unProcessorqui lit le header (ou génère unUuid::v7()), le stocke dansRequestStackattributes. Pour Messenger : unStampInterfacecustom (RequestIdStamp) ajouté au dispatch via middleware, relu côté consumer dans un middleware qui re-pousse l'id dans un serviceRequestIdHolderque le processor lit. Bonus : parsertraceparentpour rester compatible OpenTelemetry.Break-then-fix : le handler socket qui fait tomber l'API (break-then-fix)Objectif : reproduire puis corriger un incident où un
SocketHandlervers un collecteur down fait timeout chaque requête HTTP. Indice/Solution : configure unSocketHandlervers un port fermé (tls://127.0.0.1:9999), observe que chaque requête attend leconnectionTimeout. Fix : (1) baissesetConnectionTimeout()/setTimeout(), (2) wrappe dans unWhatFailureGroupHandler(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).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). UnProcessormaintient l'état du dernier hash (service singleton) et calculehash('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é).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
JsonFormatterou implémenteFormatterInterface, mappelevel_name→log.level,message→message,context.*→labels.*,extra.request_id→trace.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
- Doc Monolog Symfony : https://symfony.com/doc/current/logging.html
- Handlers : https://symfony.com/doc/current/logging/handlers.html
- MonologBundle : https://github.com/symfony/monolog-bundle
- Monolog 2 → 3 upgrade : https://github.com/Seldaek/monolog/blob/main/UPGRADE.md
- Sentry handler : https://docs.sentry.io/platforms/php/guides/symfony/