Logger
Structured logging with 20+ handlers, fluent configuration and built-in fault resilience.
Introduction
Logging sounds trivial: error_log() and done. In practice, an enterprise application needs logs in files, alerts in Slack, metrics in Grafana Loki, all with different thresholds, structured data and without a failing handler blocking the entire application.
jardisadapter/logger is a PSR-3 compatible logging pipeline that delivers exactly that:
- 20+ handlers — file, console, syslog, Slack, Teams, Loki, database, Redis, Kafka, RabbitMQ, email, Logstash, browser console and more
- Fluent LoggerBuilder — one builder configures all handlers, the result is an immutable logger
- Fault resilience — a failing handler never breaks the pipeline. The other handlers continue, errors are reported to an optional error handler
- Structured log records — enrichers add timestamp, UUID, client IP, memory usage and any custom fields
- Smart handlers — FingersCrossed (buffer until error), Sampling (volume reduction) and Conditional (routing via callback)
- Bounded-context scoping — every logger carries a context name that appears in every log record
- 7 formatters — Line, JSON, Human-readable, Slack, Teams, Loki, Browser Console
Installation
composer require jardisadapter/loggerGitHub: jardisAdapter/logger
Optional PHP extensions:
| Extension | For |
|---|---|
ext-redis | LogRedis, LogRedisMq |
ext-amqp | LogRabbitMq |
ext-rdkafka | LogKafkaMq |
Basic Usage
Two steps: configure the builder, use the logger.
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']);Each handler has a minimum log level. addFile(LogLevel::INFO, ...) ignores debug messages: only info and above are written to the file. The console receives everything from debug upwards.
Error Handler for Resilient Pipelines
$logger = (new LoggerBuilder('PaymentService'))
->setErrorHandler(function (\Exception $e, string $handlerId, string $level, string $message, array $context) {
// Log handler errors or trigger alerting
error_log("Logger handler {$handlerId} failed: " . $e->getMessage());
})
->addSlack(LogLevel::ERROR, 'https://hooks.slack.com/...')
->addFile(LogLevel::DEBUG, '/var/log/app.log')
->getLogger();
// If Slack fails, the file output still runs
$logger->error('Critical failure');Log Level Hierarchy
The PSR-3 log levels are numerically ordered. A handler with level warning ignores everything below it:
| Level | Severity | Typical use |
|---|---|---|
emergency | 7 | System unusable |
alert | 6 | Immediate action required |
critical | 5 | Critical errors |
error | 4 | Runtime errors |
warning | 3 | Unusual conditions |
notice | 2 | Normal but notable events |
info | 1 | Informational |
debug | 0 | Debug information |
Handler Overview
Stream-Based Handlers
| Handler | Target | Builder method |
|---|---|---|
LogFile | File (lazy open) | addFile(level, path) |
LogConsole | STDOUT | addConsole(level) |
LogErrorLog | STDERR | addErrorLog(level) |
LogSyslog | Syslog | addSyslog(level) |
LogBrowserConsole | ChromeLogger HTTP header | addBrowserConsole(level) |
LogNull | Nowhere (test/Graceful Degradation) | addNull(level) |
Webhook Handlers
| Handler | Target | Builder method |
|---|---|---|
LogSlack | Slack Incoming Webhook | addSlack(level, url) |
LogTeams | MS Teams MessageCard | addTeams(level, url) |
LogLoki | Grafana Loki Push API | addLoki(level, url, staticLabels) |
LogWebhook | Any HTTP endpoint | addWebhook(level, url) |
Persistence Handlers
| Handler | Target | Builder method |
|---|---|---|
LogDatabase | PDO INSERT (MySQL/PgSQL/SQLite) | addDatabase(level, pdo) |
LogRedis | Redis SETEX with TTL | addRedis(level, redis) |
LogEmail | Direct SMTP delivery | addEmail(level, to, from, ...) |
LogStash | Logstash TCP | addStash(level, host, port) |
Message Queue Handlers
| Handler | Protocol | Builder method |
|---|---|---|
LogRedisMq | Redis PUBLISH (Pub/Sub) | addRedisMq(redis, channel) |
LogRabbitMq | AMQP Fanout Exchange | addRabbitMq(connection, exchange) |
LogKafkaMq | Kafka Topic Producer | addKafkaMq(producer, topic) |
Structured Log Records
LogData — Enricher Pipeline
Every handler builds its log record via a LogData object. Enrichers are callables that are evaluated only at logging time: zero cost until actually used.
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() adds fields at root level. addExtra() adds fields in the nested data section.
Resulting 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)."
}
}Built-in Enrichers
| Enricher | Provides |
|---|---|
LogDateTime | Current date/time (Y-m-d H:i:s) |
LogUuid | UUID v4 |
LogClientIp | Client IP (proxy-aware: X-Forwarded-For, HTTP_CLIENT_IP) |
LogWebRequest | Array with IP, URL, method, user agent, GET/POST data |
LogMemoryUsage | Current memory usage |
LogMemoryPeak | Peak memory usage |
PSR-3 Message Interpolation
$logger->info('Order {order_id} shipped to {city}', [
'order_id' => 4711,
'city' => 'Berlin',
]);
// Message: "Order 4711 shipped to Berlin"Placeholders in {key} format are resolved from the context array. Arrays are JSON-encoded, callables are executed.
Custom LogData per Handler
use JardisAdapter\Logger\Handler\LogFile;
$handler = new LogFile(LogLevel::DEBUG, '/var/log/app.log');
$handler->setLogData($logData); // Custom enrichers only for this handlerFormatters
Each handler has a default formatter that can be overridden via setFormat() or a builder parameter.
| Formatter | Format | Default for |
|---|---|---|
LogLineFormat | { "key": "value", ... }\n | File, console, STDERR |
LogJsonFormat | Pure JSON (json_encode) | Logstash, webhooks |
LogHumanFormat | Multi-line: KEY: value\n | Debug output |
LogSlackFormat | Slack webhook payload with emoji and colors | LogSlack |
LogTeamsFormat | MS Teams MessageCard with ThemeColor | LogTeams |
LogLokiFormat | Loki Push API streams with nanosecond timestamps | LogLoki |
LogBrowserConsoleFormat | ChromeLogger v4.1.0 protocol | LogBrowserConsole |
use JardisAdapter\Logger\Formatter\LogJsonFormat;
$logger = (new LoggerBuilder('ApiService'))
->addFile(LogLevel::INFO, '/var/log/structured.log', format: new LogJsonFormat())
->getLogger();Smart Handlers
Three specialized handlers for advanced logging strategies.
FingersCrossed — Buffer Until Error
Collects all log messages in a buffer. Only when a message reaches the activation level (default: error) is the entire buffer flushed at once. This provides the full context before an error, without flooding the disk during normal operation.
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'); // buffered
$logger->info('Validating card'); // buffered
$logger->error('Card declined'); // → flush: all 3 messages at once
$logger->info('Retrying...'); // written directly (after activation)| Parameter | Default | Description |
|---|---|---|
activationLevel | error | Level that triggers flush |
bufferSize | 100 | Max buffered messages (FIFO) |
stopBufferingAfterActivation | true | Pass through directly after flush |
Sampling — Volume Reduction
Reduces log volume through various sampling strategies: ideal for high-frequency endpoints where not every request needs to be logged.
$logger = (new LoggerBuilder('ApiService'))
->addSampling(
wrappedHandler: $fileHandler,
strategy: 'smart',
config: [
'alwaysLogLevels' => ['error', 'critical', 'alert', 'emergency'],
'samplePercentage' => 10,
]
)
->getLogger();| Strategy | Description |
|---|---|
rate | Max N messages per second |
percentage | Let through only X% of messages |
smart | Always log errors, rest by percentage |
fingerprint | Deduplication: same message only once per time window |
Conditional — Routing via Callback
Routes messages to different handlers based on callables.
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();The first condition returning true wins. If none matches and no fallback is set, the message is silently discarded.
Named Handlers
Every handler can receive a name and be retrieved at runtime:
$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();
// Retrieve handler at runtime
$appHandler = $logger->getHandler('app_log');
$allHandlers = $logger->getHandlers();
$fileHandlers = $logger->getHandlersByClass(LogFile::class);Architecture
Under the hood, the logger follows the Closure-Orchestrator-Pattern, the architectural core principle of all Jardis packages.
LoggerBuilder ← Builder (fluent API)
└── Logger ← Orchestrator (PSR-3, immutable)
├── LogFile ← Handler (extends LogCommand)
│ ├── LogData ← Record builder + enrichers
│ │ ├── LogDateTime ← Enricher (callable)
│ │ └── LogUuid ← Enricher (callable)
│ └── LogLineFormat ← Formatter
├── LogSlack ← Handler
│ ├── LogSlackFormat ← Formatter
│ └── HttpTransport ← HTTP transport (retry)
└── LogFingersCrossed ← Smart handler (decorator)
└── LogFile ← Wrapped handlerDirectory Structure
src/
├── Logger.php ← PSR-3 orchestrator
├── LoggerBuilder.php ← Fluent 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 ← Abstract base
├── 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 Reference
LoggerBuilder
| Method | Signature | Description |
|---|---|---|
__construct | __construct(string $context) | Bounded-context name for all records |
addHandler | addHandler(LogCommandInterface $handler): self | Register any handler |
setErrorHandler | setErrorHandler(callable $handler): self | Error callback for handler exceptions |
getLogger | getLogger(): Logger | Finalize — logger is immutable |
Convenience methods (all return self):
| Method | Parameters |
|---|---|
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
| Method | Signature | Description |
|---|---|---|
log | log($level, \Stringable|string $message, array $context = []): void | PSR-3 log method |
debug ... emergency | (Stringable|string $message, array $context = []): void | PSR-3 convenience |
getHandler | getHandler(string $name): ?LogCommandInterface | Get handler by name |
getHandlers | getHandlers(): array | All handlers (keyed by ID) |
getHandlersByClass | getHandlersByClass(string $class): array | Filter handlers by class |
Complete Example
A production-ready setup with file logging, Slack alerts, Loki metrics and 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;
// Configure enrichers
$logData = (new LogData())
->addField('timestamp', new LogDateTime())
->addField('hostname', fn() => gethostname())
->addExtra('request_id', new LogUuid())
->addExtra('client_ip', new LogClientIp());
// Debug handler with FingersCrossed (buffer until error)
$debugFile = new LogFile(LogLevel::DEBUG, '/var/log/debug.log');
$debugFile->setLogData($logData);
$debugFile->setFormat(new LogJsonFormat());
$logger = (new LoggerBuilder('OrderService'))
// Structured file logging from INFO
->addFile(LogLevel::INFO, '/var/log/orders.log', name: 'main_log',
format: new LogJsonFormat())
// Debug context only on errors (FingersCrossed)
->addFingersCrossed(
wrappedHandler: $debugFile,
activationLevel: LogLevel::ERROR,
bufferSize: 200,
name: 'debug_buffer'
)
// Slack for critical errors
->addSlack(LogLevel::CRITICAL, 'https://hooks.slack.com/services/T.../B.../xxx',
name: 'slack_alerts')
// Grafana Loki for all levels
->addLoki(LogLevel::DEBUG, 'http://loki:3100/loki/api/v1/push',
staticLabels: ['app' => 'orders', 'env' => 'production'],
name: 'loki')
// Error handler for handler failures
->setErrorHandler(function (\Exception $e, string $handlerId) {
error_log("Logger handler {$handlerId} failed: " . $e->getMessage());
})
->getLogger();
// Normal operation — info logs go only to file and Loki
$logger->info('Order {order_id} created', ['order_id' => 4711]);
// Error — FingersCrossed flushes debug buffer, Slack alerts
$logger->error('Payment timeout for order {order_id}', [
'order_id' => 4711,
'gateway' => 'stripe',
'timeout_ms' => 30000,
]);