Logger
Strukturiertes Logging mit 20+ Handlern, fluenter Konfiguration und eingebauter Fehlerresilienz.
Einführung
Logging klingt trivial: error_log() und fertig. In der Praxis braucht eine Unternehmensanwendung aber Logs in Dateien, Alerts in Slack, Metriken in Grafana Loki, und das alles mit unterschiedlichen Schwellenwerten, strukturierten Daten und ohne dass ein fehlerhafter Handler die gesamte Anwendung blockiert.
jardisadapter/logger ist eine PSR-3 kompatible Logging-Pipeline, die genau das liefert:
- 20+ Handler — Datei, Console, Syslog, Slack, Teams, Loki, Database, Redis, Kafka, RabbitMQ, E-Mail, Logstash, Browser Console und mehr
- Fluenter LoggerBuilder — ein Builder konfiguriert alle Handler, das Ergebnis ist ein immutabler Logger
- Fehlerresilienz — ein fehlerhafter Handler bricht nie die Pipeline. Die anderen Handler laufen weiter, Fehler werden an einen optionalen Error-Handler gemeldet
- Strukturierte Log-Records — Enricher fügen Timestamp, UUID, Client-IP, Memory-Usage und beliebige Custom-Felder hinzu
- Smart Handler — FingersCrossed (Buffer bis Error), Sampling (Volume-Reduktion) und Conditional (Routing per Callback)
- Bounded-Context-Scoping — jeder Logger trägt einen Context-Namen, der in jedem Log-Record erscheint
- 7 Formatter — Line, JSON, Human-Readable, Slack, Teams, Loki, Browser Console
Installation
composer require jardisadapter/loggerGitHub: jardisAdapter/logger
Optionale PHP-Extensions:
| Extension | Für |
|---|---|
ext-redis | LogRedis, LogRedisMq |
ext-amqp | LogRabbitMq |
ext-rdkafka | LogKafkaMq |
Grundlegende Nutzung
Zwei Schritte: Builder konfigurieren, Logger verwenden.
use JardisAdapter\Logger\LoggerBuilder;
use JardisAdapter\Logger\Data\LogLevel;
$logger = (new LoggerBuilder('OrderService'))
->addConsole(LogLevel::DEBUG)
->addFile(LogLevel::INFO, '/var/log/orders.log')
->getLogger();
// PSR-3 API
$logger->info('Order created', ['order_id' => 4711]);
$logger->error('Payment failed', ['order_id' => 4711, 'reason' => 'timeout']);Jeder Handler hat einen minimalen Log-Level. addFile(LogLevel::INFO, ...) ignoriert debug-Nachrichten: nur info und höher landen in der Datei. Die Console bekommt alles ab debug.
Error-Handler für resiliente Pipelines
$logger = (new LoggerBuilder('PaymentService'))
->setErrorHandler(function (\Exception $e, string $handlerId, string $level, string $message, array $context) {
// Handler-Fehler loggen oder alerting triggern
error_log("Logger handler {$handlerId} failed: " . $e->getMessage());
})
->addSlack(LogLevel::ERROR, 'https://hooks.slack.com/...')
->addFile(LogLevel::DEBUG, '/var/log/app.log')
->getLogger();
// Wenn Slack ausfällt, läuft die Datei-Ausgabe trotzdem weiter
$logger->error('Critical failure');Log-Level-Hierarchie
Die PSR-3 Log-Levels sind numerisch geordnet. Ein Handler mit Level warning ignoriert alles darunter:
| Level | Schwere | Typischer Einsatz |
|---|---|---|
emergency | 7 | System unbenutzbar |
alert | 6 | Sofortige Aktion nötig |
critical | 5 | Kritische Fehler |
error | 4 | Laufzeitfehler |
warning | 3 | Ungewöhnliche Zustände |
notice | 2 | Normale, aber bemerkenswerte Ereignisse |
info | 1 | Informativ |
debug | 0 | Debug-Informationen |
Handler-Übersicht
Stream-basierte Handler
| Handler | Ziel | Builder-Methode |
|---|---|---|
LogFile | Datei (Lazy Open) | addFile(level, path) |
LogConsole | STDOUT | addConsole(level) |
LogErrorLog | STDERR | addErrorLog(level) |
LogSyslog | Syslog | addSyslog(level) |
LogBrowserConsole | ChromeLogger HTTP-Header | addBrowserConsole(level) |
LogNull | Nirgendwo (Test/Graceful Degradation) | addNull(level) |
Webhook-Handler
| Handler | Ziel | Builder-Methode |
|---|---|---|
LogSlack | Slack Incoming Webhook | addSlack(level, url) |
LogTeams | MS Teams MessageCard | addTeams(level, url) |
LogLoki | Grafana Loki Push API | addLoki(level, url, staticLabels) |
LogWebhook | Beliebiger HTTP-Endpoint | addWebhook(level, url) |
Persistenz-Handler
| Handler | Ziel | Builder-Methode |
|---|---|---|
LogDatabase | PDO INSERT (MySQL/PgSQL/SQLite) | addDatabase(level, pdo) |
LogRedis | Redis SETEX mit TTL | addRedis(level, redis) |
LogEmail | Direkte SMTP-Zustellung | addEmail(level, to, from, ...) |
LogStash | Logstash TCP | addStash(level, host, port) |
Message-Queue-Handler
| Handler | Protokoll | Builder-Methode |
|---|---|---|
LogRedisMq | Redis PUBLISH (Pub/Sub) | addRedisMq(redis, channel) |
LogRabbitMq | AMQP Fanout Exchange | addRabbitMq(connection, exchange) |
LogKafkaMq | Kafka Topic Producer | addKafkaMq(producer, topic) |
Strukturierte Log-Records
LogData — Enricher Pipeline
Jeder Handler baut seinen Log-Record über ein LogData-Objekt. Enricher sind Callables, die erst zum Zeitpunkt des Loggings ausgewertet werden: Zero-Cost bis zur tatsächlichen Nutzung.
use JardisAdapter\Logger\Data\LogData;
use JardisAdapter\Logger\Enricher\LogDateTime;
use JardisAdapter\Logger\Enricher\LogUuid;
use JardisAdapter\Logger\Enricher\LogClientIp;
use JardisAdapter\Logger\Enricher\LogMemoryUsage;
$logData = (new LogData())
->addField('timestamp', new LogDateTime())
->addField('hostname', fn() => gethostname())
->addExtra('request_id', new LogUuid())
->addExtra('client_ip', new LogClientIp())
->addExtra('memory', new LogMemoryUsage());addField() fügt Felder auf Root-Ebene hinzu. addExtra() fügt Felder im verschachtelten data-Abschnitt hinzu.
Resultierender Log-Record
{
"context": "OrderService",
"level": "error",
"message": "Payment failed for order 4711",
"timestamp": "2025-11-30 10:00:00",
"hostname": "server-01",
"data": {
"order_id": 4711,
"request_id": "a3f8b2c1-...",
"client_ip": "192.168.1.42",
"memory": "12.34 MB (12935168 Bytes)."
}
}Eingebaute Enricher
| Enricher | Liefert |
|---|---|
LogDateTime | Aktuelles Datum/Uhrzeit (Y-m-d H:i:s) |
LogUuid | UUID v4 |
LogClientIp | Client-IP (Proxy-aware: X-Forwarded-For, HTTP_CLIENT_IP) |
LogWebRequest | Array mit IP, URL, Method, User-Agent, GET/POST-Daten |
LogMemoryUsage | Aktueller Speicherverbrauch |
LogMemoryPeak | Peak-Speicherverbrauch |
PSR-3 Message-Interpolation
$logger->info('Order {order_id} shipped to {city}', [
'order_id' => 4711,
'city' => 'Berlin',
]);
// Message: "Order 4711 shipped to Berlin"Platzhalter im {key}-Format werden aus dem Context-Array aufgelöst. Arrays werden JSON-encodiert, Callables ausgeführt.
Custom LogData pro Handler
use JardisAdapter\Logger\Handler\LogFile;
$handler = new LogFile(LogLevel::DEBUG, '/var/log/app.log');
$handler->setLogData($logData); // Custom Enricher nur für diesen HandlerFormatter
Jeder Handler hat einen Standard-Formatter, der per setFormat() oder Builder-Parameter überschrieben werden kann.
| Formatter | Format | Standard für |
|---|---|---|
LogLineFormat | { "key": "value", ... }\n | Datei, Console, STDERR |
LogJsonFormat | Pure JSON (json_encode) | Logstash, Webhooks |
LogHumanFormat | Mehrzeilig: KEY: value\n | Debug-Ausgabe |
LogSlackFormat | Slack Webhook Payload mit Emoji und Farben | LogSlack |
LogTeamsFormat | MS Teams MessageCard mit ThemeColor | LogTeams |
LogLokiFormat | Loki Push API Streams mit Nanosekunden-Timestamps | LogLoki |
LogBrowserConsoleFormat | ChromeLogger v4.1.0 Protokoll | LogBrowserConsole |
use JardisAdapter\Logger\Formatter\LogJsonFormat;
$logger = (new LoggerBuilder('ApiService'))
->addFile(LogLevel::INFO, '/var/log/structured.log', format: new LogJsonFormat())
->getLogger();Smart Handler
Drei spezialisierte Handler für fortgeschrittene Logging-Strategien.
FingersCrossed — Buffer bis Error
Sammelt alle Log-Nachrichten in einem Puffer. Erst wenn eine Nachricht den Aktivierungs-Level erreicht (Standard: error), wird der gesamte Puffer auf einmal geflushed. So bekommt man den vollständigen Kontext vor einem Fehler, ohne im Normalbetrieb die Festplatte zu fluten.
use JardisAdapter\Logger\Handler\LogFile;
use JardisAdapter\Logger\Handler\LogFingersCrossed;
$fileHandler = new LogFile(LogLevel::DEBUG, '/var/log/app.log');
$logger = (new LoggerBuilder('PaymentService'))
->addFingersCrossed(
wrappedHandler: $fileHandler,
activationLevel: LogLevel::ERROR,
bufferSize: 200,
stopBufferingAfterActivation: true
)
->getLogger();
$logger->debug('Starting payment process'); // gepuffert
$logger->info('Validating card'); // gepuffert
$logger->error('Card declined'); // → Flush: alle 3 Nachrichten auf einmal
$logger->info('Retrying...'); // direkt geschrieben (nach Aktivierung)| Parameter | Standard | Beschreibung |
|---|---|---|
activationLevel | error | Level, der den Flush auslöst |
bufferSize | 100 | Max. gepufferte Nachrichten (FIFO) |
stopBufferingAfterActivation | true | Nach Flush direkt durchreichen |
Sampling — Volume-Reduktion
Reduziert das Log-Volumen durch verschiedene Sampling-Strategien: ideal für hochfrequente Endpoints, bei denen nicht jeder Request geloggt werden muss.
$logger = (new LoggerBuilder('ApiService'))
->addSampling(
wrappedHandler: $fileHandler,
strategy: 'smart',
config: [
'alwaysLogLevels' => ['error', 'critical', 'alert', 'emergency'],
'samplePercentage' => 10,
]
)
->getLogger();| Strategie | Beschreibung |
|---|---|
rate | Max. N Nachrichten pro Sekunde |
percentage | Nur X% der Nachrichten durchlassen |
smart | Errors immer, Rest nach Prozentsatz |
fingerprint | Deduplizierung: gleiche Nachricht nur einmal pro Zeitfenster |
Conditional — Routing per Callback
Leitet Nachrichten basierend auf Callables an unterschiedliche Handler weiter.
use JardisAdapter\Logger\Handler\LogConditional;
$conditional = new LogConditional([
[fn($level, $msg, $ctx) => isset($ctx['payment_id']), $paymentFileHandler],
[fn($level, $msg, $ctx) => isset($ctx['api_request']), $apiLokiHandler],
], fallbackHandler: $defaultFileHandler);
$logger = (new LoggerBuilder('AppService'))
->addHandler($conditional)
->getLogger();Die erste Bedingung, die true zurückgibt, gewinnt. Trifft keine zu und kein Fallback ist gesetzt, wird die Nachricht stillschweigend verworfen.
Benannte Handler
Jeder Handler kann einen Namen erhalten und zur Laufzeit abgefragt werden:
$logger = (new LoggerBuilder('OrderService'))
->addFile(LogLevel::DEBUG, '/var/log/app.log', name: 'app_log')
->addFile(LogLevel::ERROR, '/var/log/errors.log', name: 'error_log')
->addSlack(LogLevel::CRITICAL, $webhookUrl, name: 'slack_alerts')
->getLogger();
// Handler zur Laufzeit abrufen
$appHandler = $logger->getHandler('app_log');
$allHandlers = $logger->getHandlers();
$fileHandlers = $logger->getHandlersByClass(LogFile::class);Architektur
Unter der Haube folgt der Logger dem Closure-Orchestrator-Pattern, dem architektonischen Grundprinzip aller Jardis-Packages.
LoggerBuilder ← Builder (Fluent API)
└── Logger ← Orchestrator (PSR-3, immutabel)
├── LogFile ← Handler (extends LogCommand)
│ ├── LogData ← Record Builder + Enricher
│ │ ├── LogDateTime ← Enricher (Callable)
│ │ └── LogUuid ← Enricher (Callable)
│ └── LogLineFormat ← Formatter
├── LogSlack ← Handler
│ ├── LogSlackFormat ← Formatter
│ └── HttpTransport ← HTTP-Transport (Retry)
└── LogFingersCrossed ← Smart Handler (Decorator)
└── LogFile ← Gewrappter HandlerVerzeichnisstruktur
src/
├── Logger.php ← PSR-3 Orchestrator
├── LoggerBuilder.php ← Fluenter Builder
├── Contract/
│ ├── LogCommandInterface.php
│ ├── StreamableLogCommandInterface.php
│ ├── LogDataInterface.php
│ └── LogFormatInterface.php
├── Data/
│ ├── LogData.php ← Record Builder
│ └── LogLevel.php ← Level → Severity Map
├── Enricher/
│ ├── LogDateTime.php
│ ├── LogUuid.php
│ ├── LogClientIp.php
│ ├── LogWebRequest.php
│ ├── LogMemoryUsage.php
│ └── LogMemoryPeak.php
├── Formatter/
│ ├── LogLineFormat.php
│ ├── LogJsonFormat.php
│ ├── LogHumanFormat.php
│ ├── LogSlackFormat.php
│ ├── LogTeamsFormat.php
│ ├── LogLokiFormat.php
│ └── LogBrowserConsoleFormat.php
└── Handler/
├── LogCommand.php ← Abstrakte Basis
├── LogFile.php
├── LogConsole.php
├── LogErrorLog.php
├── LogSyslog.php
├── LogBrowserConsole.php
├── LogNull.php
├── LogSlack.php
├── LogTeams.php
├── LogLoki.php
├── LogWebhook.php
├── LogDatabase.php
├── LogEmail.php
├── LogRedis.php
├── LogRedisMq.php
├── LogRabbitMq.php
├── LogKafkaMq.php
├── LogStash.php
├── HttpTransport.php
├── LogFingersCrossed.php
├── LogSampling.php
└── LogConditional.phpAPI-Referenz
LoggerBuilder
| Methode | Signatur | Beschreibung |
|---|---|---|
__construct | __construct(string $context) | Bounded-Context-Name für alle Records |
addHandler | addHandler(LogCommandInterface $handler): self | Beliebigen Handler registrieren |
setErrorHandler | setErrorHandler(callable $handler): self | Fehler-Callback für Handler-Exceptions |
getLogger | getLogger(): Logger | Finalisiert — Logger ist immutabel |
Convenience-Methoden (alle return self):
| Methode | Parameter |
|---|---|
addConsole | (string $level, ?string $name, ?LogFormatInterface $format) |
addFile | (string $level, string $path, ?string $name, ?LogFormatInterface $format) |
addErrorLog | (string $level, ?string $name) |
addSyslog | (string $level, ?string $name) |
addBrowserConsole | (string $level, ?string $name) |
addNull | (string $level, ?string $name) |
addDatabase | (string $level, \PDO $pdo, ?string $table, ?string $name) |
addSlack | (string $level, string $url, ?string $name, int $timeout, int $retryAttempts) |
addTeams | (string $level, string $url, ?string $name, int $timeout, int $retryAttempts) |
addLoki | (string $level, string $url, array $staticLabels, ?string $name, int $timeout, int $retryAttempts) |
addWebhook | (string $level, string $url, ?string $name, string $method, array $headers, ...) |
addEmail | (string $level, string $to, string $from, string $subject, string $smtpHost, ...) |
addRedis | (string $level, \Redis $redis, ?string $name, int $ttl) |
addRedisMq | (\Redis $redis, string $channel, ?string $name) |
addRabbitMq | (\AMQPConnection $connection, string $exchange, ?string $name) |
addKafkaMq | (\RdKafka\Producer $producer, string $topic, ?string $name) |
addStash | (string $level, string $host, int $port, ?array $bindTo, ?string $name) |
addFingersCrossed | (StreamableLogCommandInterface $wrappedHandler, string $activationLevel, int $bufferSize, bool $stopBufferingAfterActivation, ?string $name) |
addSampling | (StreamableLogCommandInterface $wrappedHandler, string $strategy, array $config, ?string $name) |
addConditional | (array $conditionalHandlers, ?StreamableLogCommandInterface $fallbackHandler, ?string $name) |
Logger
| Methode | Signatur | Beschreibung |
|---|---|---|
log | log($level, \Stringable|string $message, array $context = []): void | PSR-3 Log-Methode |
debug ... emergency | (Stringable|string $message, array $context = []): void | PSR-3 Convenience |
getHandler | getHandler(string $name): ?LogCommandInterface | Handler per Name abrufen |
getHandlers | getHandlers(): array | Alle Handler (keyed by ID) |
getHandlersByClass | getHandlersByClass(string $class): array | Handler per Klasse filtern |
Vollständiges Beispiel
Ein produktionsreifes Setup mit File-Logging, Slack-Alerts, Loki-Metriken und FingersCrossed-Buffering:
use JardisAdapter\Logger\LoggerBuilder;
use JardisAdapter\Logger\Data\LogData;
use JardisAdapter\Logger\Data\LogLevel;
use JardisAdapter\Logger\Enricher\LogDateTime;
use JardisAdapter\Logger\Enricher\LogUuid;
use JardisAdapter\Logger\Enricher\LogClientIp;
use JardisAdapter\Logger\Formatter\LogJsonFormat;
use JardisAdapter\Logger\Handler\LogFile;
use JardisAdapter\Logger\Handler\LogFingersCrossed;
// Enricher konfigurieren
$logData = (new LogData())
->addField('timestamp', new LogDateTime())
->addField('hostname', fn() => gethostname())
->addExtra('request_id', new LogUuid())
->addExtra('client_ip', new LogClientIp());
// Debug-Handler mit FingersCrossed (Buffer bis Error)
$debugFile = new LogFile(LogLevel::DEBUG, '/var/log/debug.log');
$debugFile->setLogData($logData);
$debugFile->setFormat(new LogJsonFormat());
$logger = (new LoggerBuilder('OrderService'))
// Strukturiertes File-Logging ab INFO
->addFile(LogLevel::INFO, '/var/log/orders.log', name: 'main_log',
format: new LogJsonFormat())
// Debug-Context nur bei Fehlern (FingersCrossed)
->addFingersCrossed(
wrappedHandler: $debugFile,
activationLevel: LogLevel::ERROR,
bufferSize: 200,
name: 'debug_buffer'
)
// Slack für kritische Fehler
->addSlack(LogLevel::CRITICAL, 'https://hooks.slack.com/services/T.../B.../xxx',
name: 'slack_alerts')
// Grafana Loki für alle Levels
->addLoki(LogLevel::DEBUG, 'http://loki:3100/loki/api/v1/push',
staticLabels: ['app' => 'orders', 'env' => 'production'],
name: 'loki')
// Error-Handler für Handler-Fehler
->setErrorHandler(function (\Exception $e, string $handlerId) {
error_log("Logger handler {$handlerId} failed: " . $e->getMessage());
})
->getLogger();
// Normalbetrieb — Info-Logs gehen nur in Datei und Loki
$logger->info('Order {order_id} created', ['order_id' => 4711]);
// Fehler — FingersCrossed flushed Debug-Buffer, Slack alertet
$logger->error('Payment timeout for order {order_id}', [
'order_id' => 4711,
'gateway' => 'stripe',
'timeout_ms' => 30000,
]);