Vom Standard-Setup zur maßgeschneiderten Log-Pipeline
Wenn StreamHandler und RotatingFileHandler nicht mehr ausreichen, zeigen wir, wie du in Symfony eigene Monolog-Handler für Slack-Alerts und Processor für automatischen Log-Kontext schreibst.
Inhaltsverzeichnis
- 1. Warum die Standard-Handler irgendwann nicht mehr reichen
- 2. Monolog-Architektur: Handler, Processor und Formatter im Zusammenspiel
- 3. Einen eigenen Handler schreiben: SlackAlertHandler für kritische Fehler
- 4. Den eigenen Handler in monolog.yaml registrieren
- 5. Channels und Log-Level: Feingranulares Routing
- 6. Einen eigenen Processor schreiben: Request-ID und User-ID anreichern
- 7. Processor global oder pro Handler registrieren
- 8. Performance-Aspekte: Buffering, Deduplizierung und Fingers Crossed
- 9. Eigenen Handler und Processor testen
- 10. Zusammenfassung
- 11. FAQ
1. Warum die Standard-Handler irgendwann nicht mehr reichen
In den meisten Symfony-Projekten reicht am Anfang eine einfache Kombination aus StreamHandler und RotatingFileHandler vollkommen aus. Fehler landen in einer Datei, die Datei wird täglich rotiert, und im Zweifel schaut jemand mit tail oder einem Log-Viewer hinein. Das funktioniert gut, solange die Anwendung klein ist und niemand dringend auf kritische Fehler reagieren muss. Sobald ein Projekt aber produktiv läuft und echte Nutzer betrifft, reicht dieses Modell nicht mehr aus.
Das Team merkt oft erst durch ein Support-Ticket, dass seit Stunden ein Zahlungs-Webhook fehlschlägt, weil niemand aktiv in die Logs schaut. Genau hier setzt die Erweiterbarkeit von Monolog an: Ein eigener Handler kann kritische Einträge sofort an Slack, Teams oder einen Pager senden, während ein Processor jedem Log-Eintrag automatisch Kontextdaten wie Request-ID oder eingeloggten Benutzer mitgibt. Beides zusammen macht aus reinen Textdateien ein aktives Beobachtungssystem.
2. Monolog-Architektur: Handler, Processor und Formatter im Zusammenspiel
Monolog trennt sauber zwischen drei Verantwortlichkeiten. Der Handler entscheidet, wohin ein Log-Eintrag geschrieben wird und ob er überhaupt behandelt wird, basierend auf Log-Level und eigener Logik. Der Processor reichert einen LogRecord an, bevor er den Handler erreicht, etwa mit zusätzlichen Kontextfeldern. Der Formatter schließlich bestimmt, wie der Eintrag am Ende aussieht, als JSON-Zeile, als lesbarer Text oder in einem proprietären Format für ein externes System.
In Symfony wird diese Architektur über das MonologBundle konfiguriert, das jeden Channel mit einer eigenen Liste an Handlern verbindet. Ein Channel ist dabei nichts anderes als ein logischer Namensraum, zum Beispiel app, doctrine oder ein selbst definierter Channel wie payment. Wichtig zu verstehen ist, dass Handler in einer Kette (Stack) verarbeitet werden und über die bubble-Eigenschaft steuern, ob ein Eintrag an nachfolgende Handler weitergereicht wird oder dort stoppt.
3. Einen eigenen Handler schreiben: SlackAlertHandler für kritische Fehler
Ein eigener Handler wird in der Regel von AbstractProcessingHandler abgeleitet, die bereits Level-Filterung und Processor-Aufrufe übernimmt. Die einzige Pflichtmethode ist write(), die den fertig verarbeiteten LogRecord entgegennimmt und an das Zielsystem weiterreicht. Im folgenden Beispiel senden wir kritische Fehler über den symfony/http-client an einen Slack-Webhook, ohne dabei blockierend auf eine Antwort zu warten, was die Performance der Anwendung praktisch nicht beeinträchtigt.
Bewusst wird hier der Level auf Critical gesetzt, damit nicht jede Warnung eine Slack-Nachricht auslöst und das Team abstumpft. Der Konstruktor nimmt den HttpClient und die Webhook-URL per Dependency Injection entgegen, sodass der Handler in Tests einfach durch einen Mock-Client ersetzt werden kann. Das folgende Beispiel zeigt die vollständige Implementierung mit Symfony 7 und PHP 8.4 Typisierung.
<?php
declare(strict_types=1);
namespace App\Monolog\Handler;
use Monolog\Handler\AbstractProcessingHandler;
use Monolog\Level;
use Monolog\LogRecord;
use Symfony\Contracts\HttpClient\HttpClientInterface;
final class SlackAlertHandler extends AbstractProcessingHandler
{
public function __construct(
private readonly HttpClientInterface $httpClient,
private readonly string $webhookUrl,
Level $level = Level::Critical,
) {
parent::__construct($level, bubble: true);
}
protected function write(LogRecord $record): void
{
$this->httpClient->request('POST', $this->webhookUrl, [
'json' => [
'text' => sprintf(
'[%s] %s',
$record->channel,
$record->formatted ?? $record->message,
),
],
]);
}
}
4. Den eigenen Handler in monolog.yaml registrieren
Damit Symfony den neuen Handler tatsächlich verwendet, muss er zunächst als regulärer Service registriert werden, meist automatisch über Autowiring in services.yaml, da die Klasse bereits typisierte Konstruktor-Argumente besitzt. Anschließend wird der Service-Name in monolog.yaml unter dem gewünschten Channel als type: service eingetragen. Die Webhook-URL sollte dabei niemals hartcodiert werden, sondern über ein Environment-Argument wie %env(SLACK_WEBHOOK_URL)% injiziert werden.
In der Praxis bietet es sich an, den SlackAlertHandler nur im Produktions-Environment zu aktivieren und in dev sowie test durch einen NullHandler zu ersetzen, damit lokale Fehler nicht versehentlich Alarme auslösen. Symfony erlaubt genau das über environment-spezifische Konfigurationsdateien wie monolog.yaml unter config/packages/prod. So bleibt die Grundkonfiguration schlank und die produktionsspezifischen Details sind klar getrennt.
5. Channels und Log-Level: Feingranulares Routing
Ein häufiger Fehler ist, alle Log-Einträge durch dieselbe Handler-Kette zu schicken. Sinnvoller ist es, eigene Channels für fachliche Bereiche zu definieren, etwa payment, import oder security, und diese im monolog-Block unter channels aufzulisten. Jeder Channel kann dann eine eigene Kombination aus Handlern erhalten, sodass Zahlungsfehler beispielsweise sowohl in eine dedizierte Datei als auch an Slack gehen, während reine Import-Warnungen nur in der Datei landen.
Die Zuordnung eines LoggerInterface zu einem Channel geschieht in Symfony automatisch über die monolog.logger Tag-Konvention: Wird ein Service mit #[AsMonologChannel('payment')] annotiert, injiziert Symfony automatisch einen Logger, der auf diesen Channel geschrieben ist. Diese saubere Trennung erleichtert später enorm die Fehlersuche, weil man gezielt nach einem fachlichen Bereich filtern kann, statt sich durch einen einzigen riesigen app-Channel zu wühlen.
6. Einen eigenen Processor schreiben: Request-ID und User-ID anreichern
Ein Processor ist im Kern ein aufrufbares Objekt, das einen LogRecord entgegennimmt und einen veränderten LogRecord zurückgibt. Typischerweise implementiert man dafür ProcessorInterface mit der Methode __invoke(LogRecord $record): LogRecord. Ein praxisnahes Beispiel ist ein RequestContextProcessor, der bei jedem Log-Eintrag automatisch die aktuelle Request-ID aus einem RequestStack sowie die ID des eingeloggten Benutzers aus dem Security-TokenStorage in das extra-Array des Records schreibt.
Der große Vorteil gegenüber manuellem context-Array bei jedem Logger-Aufruf ist Konsistenz: Niemand kann vergessen, die User-ID mitzugeben, weil der Processor sie automatisch für jeden einzelnen Log-Eintrag ergänzt. Gerade beim Debugging von produktiven Fehlern spart das enorm viel Zeit, weil man anhand der Request-ID alle zusammengehörigen Log-Zeilen über verschiedene Services hinweg zusammensuchen kann, selbst wenn mehrere Anfragen gleichzeitig verarbeitet werden.
7. Processor global oder pro Handler registrieren
Symfony bietet zwei Wege, einen Processor zu aktivieren. Global gilt ein Processor für alle Handler und Channels, wenn er einfach als Service mit dem Tag monolog.processor registriert wird, ohne weitere Einschränkung. Das ist der richtige Ansatz für Kontextdaten wie Request-ID, die in praktisch jedem Log-Eintrag sinnvoll sind, unabhängig davon, welcher Teil der Anwendung geloggt hat.
Alternativ kann ein Processor gezielt an einen einzelnen Handler oder Channel gebunden werden, indem der Tag mit dem Attribut handler oder channel eingeschränkt wird. Das lohnt sich, wenn ein Processor teure Berechnungen durchführt, etwa einen zusätzlichen API-Aufruf, und deshalb nur für den seltenen, kritischen Slack-Handler laufen soll, nicht aber für jeden einzelnen Debug-Eintrag in der lokalen Log-Datei.
8. Performance-Aspekte: Buffering, Deduplizierung und Fingers Crossed
Eigene Handler und Processor bringen zusätzlichen Overhead mit sich, der bei hohem Traffic spürbar werden kann. Der FingersCrossedHandler ist hier ein wichtiges Werkzeug: Er puffert alle Einträge eines Requests im Speicher und schreibt sie erst dann tatsächlich weg, wenn ein definierter Trigger-Level, meist Error, erreicht wird. Bei einem erfolgreichen Request ohne Fehler fällt so keinerlei I/O-Last an, obwohl während der Verarbeitung durchaus auf Debug-Level geloggt wurde.
Für den eigenen SlackAlertHandler empfiehlt sich zusätzlich der DeduplicationHandler als vorgeschalteter Wrapper, damit nicht bei jedem einzelnen fehlgeschlagenen Request eine neue Slack-Nachricht mit identischem Inhalt verschickt wird. Er merkt sich bereits gesendete Einträge über eine konfigurierbare Zeitspanne und unterdrückt Duplikate. In Kombination mit einem asynchronen HttpClient-Aufruf bleibt die Performance-Auswirkung eines eigenen Alert-Handlers damit minimal.
9. Eigenen Handler und Processor testen
Da write() der einzige Berührungspunkt mit der Außenwelt ist, lässt sich ein eigener Handler in einem PHPUnit-Test isoliert prüfen, indem man dem Konstruktor einen MockHttpClient übergibt und anschließend handle() mit einem manuell erzeugten LogRecord aufruft. So kann man überprüfen, dass genau die erwartete URL mit dem erwarteten JSON-Body aufgerufen wird, ohne echte Netzwerkaufrufe im Testlauf zu benötigen.
Für einen Processor bietet sich ein noch einfacherer Unit-Test an: Man erzeugt einen LogRecord mit leerem extra-Array, ruft den Processor direkt auf und prüft, dass die erwarteten Schlüssel wie request_id und user_id danach vorhanden sind. Da Processor reine Funktionen ohne Seiteneffekte sein sollten, lassen sie sich vollständig ohne Symfony-Kernel testen, was die Testsuite insgesamt deutlich schneller macht.
| Klasse/Interface | Zweck | Wichtigste Methode | Typischer Einsatz |
|---|---|---|---|
| AbstractProcessingHandler | Basisklasse für eigene Handler | write(LogRecord $record) | Slack- oder Teams-Alerts |
| ProcessorInterface | Kontext-Anreicherung vor der Ausgabe | __invoke(LogRecord $record): LogRecord | Request-ID, User-ID einfügen |
| FingersCrossedHandler | Puffert Einträge bis zum Trigger-Level | activate() | I/O nur bei tatsächlichen Fehlern |
| DeduplicationHandler | Unterdrückt wiederholte identische Einträge | write(LogRecord $record) | Verhindert Alert-Flut bei Serienfehlern |
| RotatingFileHandler | Standard-Handler mit täglicher Rotation | write() (intern via Stream) | Lokale Log-Dateien mit Aufbewahrungsfrist |
Mironsoft
Symfony-Architektur, saubere Domänenlogik und Legacy-Modernisierung
Symfony-Anwendungen, die nach zwei Jahren noch wartbar sind?
Wir prüfen bestehende Symfony-Projekte auf aufgeblähte Controller, fehlende Serviceabstraktionen und ungetestete Kernlogik und bauen daraus eine Architektur, die neue Features aufnimmt, ohne bei jedem Release brüchiger zu werden.
Architektur-Review
Bundle-Struktur, Dependency Injection und Serviceabstraktionen auf Wartbarkeit prüfen.
Legacy-Modernisierung
Schrittweise Migration veralteter Symfony-Versionen ohne kompletten Neuschrieb.
Test- und Qualitätssicherung
PHPUnit, PHPStan und CI-Pipelines für nachhaltige Codequalität aufbauen.
10. Zusammenfassung
Monolog Handler & Processor
Handler
Steuert Ziel und Bedingung der Log-Ausgabe, Basisklasse AbstractProcessingHandler
Processor
Reichert LogRecord vor der Ausgabe mit Kontext an, z.B. Request-ID
Konfiguration
Registrierung über monolog.yaml und Service-Tags, environment-spezifisch trennen
Performance
FingersCrossedHandler und DeduplicationHandler halten den Overhead gering