Skip to content

Latest commit

History

5 Commits

Folders and files

NameName
Last commit message
Last commit date

Repository files navigation

ordinary/log

Structured logging library for PHP 8.5+.

Installation

composer require ordinary/log

Basic Usage

Logger is the ready-to-use implementation. Add one or more drivers and start logging immediately.

useOrdinary\Log\Logger;
useOrdinary\Log\Driver\StreamDriver;
useOrdinary\Log\JsonFormatter;
$logger = newLogger();
$logger->add(newStreamDriver(STDOUT, newJsonFormatter()));
$logger->info('User signed in', ['user_id' => 42]);
$logger->warning('Cache miss on key {key}', ['key' => 'user:42:prefs']);
$logger->error('Payment failed', ['order_id' => 'ORD-999', 'exception' => $e]);

Message strings support {key} placeholder interpolation using context values.

Log levels (lowest → highest severity)

DebugInfoNoticeWarningErrorCriticalAlertEmergency


PSR-3 Compatibility

If any library or framework in your stack type-hints \Psr\Log\LoggerInterface, use Logger::toPsr() to get a drop-in adapter — one method call, no extra setup:

useOrdinary\Log\Logger;
useOrdinary\Log\Driver\StreamDriver;
useOrdinary\Log\JsonFormatter;
$logger = newLogger();
$logger->add(newStreamDriver(STDOUT, newJsonFormatter()));
// Framework integration — one call produces a PSR-3 adapter$container->bind(\Psr\Log\LoggerInterface::class, fn() => $logger->toPsr());

PSR-3 specifies that Throwables in context must be passed under the key "exception". LogEntryInterface::RESERVED_EXCEPTION is also "exception", so no translation is required — the same key works in both the native API and the PSR-3 adapter:

// Native API$logger->error('Charge failed', ['exception' => $e, 'order_id' => 'ORD-1']);
// PSR-3 API — identical behavior$logger->toPsr()->error('Charge failed', ['exception' => $e, 'order_id' => 'ORD-1']);

If you need to create a PsrLoggerAdapter directly (for example, to wrap a custom LoggerInterface implementation):

useOrdinary\Log\Psr\PsrLoggerAdapter;
$psrLogger = newPsrLoggerAdapter($myCustomLogger);

Formatters

Formatters control how log items are serialized to strings. Two are provided out of the box.

TextFormatter

Produces a human-readable log line by interpolating {key} placeholders in the message string. The keys {date} and {level} are injected automatically, so they are always available regardless of what context you pass.

Default output (using StreamDriver's defaults — date as ISO-8601, level lowercase):

[2024-06-01T12:00:00Z] [error] Something failed order_id=ORD-999

Any context keys that are not used as {key} placeholders in the message are appended as key=value pairs.

useOrdinary\Log\TextFormatter;
useOrdinary\Log\DateTimeFormatter;
useOrdinary\Log\LevelFormatter;
useOrdinary\Log\ExceptionFormatter;
// Default — ISO-8601 date, lowercase level, no exception stack traces$formatter = newTextFormatter();
// Custom date format — daily granularity, uppercase level$formatter = newTextFormatter(
dateTimeFormatter: newDateTimeFormatter('Y-m-d', 'America/New_York'),
levelFormatter: newLevelFormatter(uppercase: true),
);
// With stack traces$formatter = newTextFormatter(
exceptionFormatter: newExceptionFormatter(includeTrace: true),
);

Template-driven messages — any {key} from context is substituted inline:

// Message: "User john logged in from 192.0.2.1"$logger->info('User {username} logged in from {ip}', [
'username' => 'john',
'ip' => '192.0.2.1',
]);

StreamDriver, SyslogDriver, and RotatingStreamDriver all default to new TextFormatter().

JsonFormatter

Produces structured JSON — one object per log item. Top-level fields: channel (if set), level, date, message, exception (if a Throwable was attached), context.

useOrdinary\Log\JsonFormatter;
// {"level":"error","date":"2024-06-01T12:00:00+00:00","message":"Charge failed","exception":"RuntimeException: ...","context":{"order_id":"ORD-999"}}$formatter = newJsonFormatter();
// Custom date format$formatter = newJsonFormatter(
dateTimeFormatter: newDateTimeFormatter('Y-m-d', 'UTC'),
);

Context values are normalized before encoding: DateTimeInterface → ISO-8601 string, \Stringable → string, NaN"NaN", INF"Infinity", booleans and nulls preserved.


Context Keys

LogEntryInterface defines reserved context keys. Understanding which ones you may set versus which are injected helps avoid subtle bugs.

KeyConstantWho sets itNotes
exceptionRESERVED_EXCEPTIONYouPass any \Throwable; formatters render it
dateRESERVED_DATEFormatterOverwriting silently has no effect
levelRESERVED_LEVELFormatterSame
channelRESERVED_CHANNELLogger (from $channel param)Same
exception.messageRESERVED_EXCEPTION_MESSAGEFormatterSame
exception.lineRESERVED_EXCEPTION_LINEFormatterSame
exception.codeRESERVED_EXCEPTION_CODEFormatterSame

RESERVED_EXCEPTION is the only reserved key you should set yourself. All others are injected by the Logger or formatters and will be overwritten even if you supply them.


Log Drivers

Drivers implement LogDriverInterface with a single handleLog() method. They are pure I/O — matching, dispatching, and failure handling are all managed by Logger.

StreamDriver

Writes a formatted line to any writable stream resource.

useOrdinary\Log\Driver\StreamDriver;
useOrdinary\Log\JsonFormatter;
// Write JSON lines to a file$logger->add(newStreamDriver(
stream: fopen('/var/log/app.log', 'a'),
formatter: newJsonFormatter(),
));
// Write errors and above to STDERR$logger->add(
newStreamDriver(STDERR, newJsonFormatter()),
matcher: newIsLevelOrHigher(LogLevel::Error),
);

CloudWatchDriver

Sends log events to AWS CloudWatch Logs. Requires aws/aws-sdk-php:

composer require aws/aws-sdk-php
useAws\CloudWatchLogs\CloudWatchLogsClient;
useOrdinary\Log\Driver\CloudWatchDriver;
$logger->add(newCloudWatchDriver(
client: newCloudWatchLogsClient(['region' => 'us-east-1', 'version' => 'latest']),
logGroupName: '/my-app/production',
logStreamName: 'web-01',
formatter: newJsonFormatter(),
));

BufferingDriver

Accumulates log items in memory and dispatches them in bulk when flushed. Use this to batch writes to high-latency backends like CloudWatch or a database. Call Logger::flush() at request end to drain the buffer. The buffer is also drained automatically when the driver is destroyed.

useOrdinary\Log\Driver\BufferingDriver;
useOrdinary\Log\Driver\CloudWatchDriver;
$logger->add(newBufferingDriver(
inner: newCloudWatchDriver($client, '/app/prod', 'web'),
flushAfter: 100, // auto-flush at 100 items; omit or set 0 for explicit-only
));
// ... handle request ...$logger->flush(); // sends all buffered items

When the inner driver implements LogBatchDriverInterface, flush() calls handleLogBatch() with the entire buffer in a single operation instead of issuing individual handleLog() calls.

DeduplicatingDriver

Suppresses repeated log items within a sliding time window and re-dispatches a summary when the window closes. Two items are considered duplicates when their fingerprints match; the default fingerprint is "{level}:{message}".

Behavior:

  • The first occurrence of a fingerprint is forwarded immediately as a normal log item.
  • Subsequent occurrences within the window replace the stored pending item (the latest context is always preserved) and their timestamps are accumulated.
  • When the window expires (detected on the next handleLog call) or when flush() is called, any pending item that received at least one duplicate is re-dispatched with two extra context keys added:
    • dedup_count — the number of suppressed duplicates (int)
    • dedup_times — ISO-8601 strings of each suppressed occurrence in JsonFormatter output
  • Pending items that received no duplicates are silently discarded on flush.
  • flush() clears all pending state — fingerprints are treated as fresh after a flush cycle.
  • The driver flushes automatically on destruction, so pending summaries are never lost even without an explicit flush() call.
useOrdinary\Log\Driver\DeduplicatingDriver;
$logger->add(newDeduplicatingDriver(
inner: newStreamDriver(STDERR),
windowSeconds: 300, // suppress repeats for 5 minutes
));
// Custom fingerprint — deduplicate by event code rather than full message$logger->add(newDeduplicatingDriver(
inner: newSlackDriver($webhookUrl),
windowSeconds: 3600,
fingerprint: fn(LogEntryInterface$item) => $item->level->name . ':' . ($item->context['event'] ?? $item->message),
));

The $clock parameter (Psr\Clock\ClockInterface, defaults to UtcClock) is used for all window tracking. Inject a test double to control time in unit tests.

A typical output sequence for three occurrences of the same log within the window — two dispatches total:

[error] Payment failed ← dispatched immediately (first occurrence)
... 2 more suppressed ...
[error] Payment failed dedup_count=2 dedup_times=[...] ← flushed at window close

RotatingStreamDriver

Writes to a date-rotated file. The file path is built by substituting {date} in the path pattern with the formatted date of each log item. A new file is opened automatically whenever the date changes:

useOrdinary\Log\Driver\RotatingStreamDriver;
useOrdinary\Log\JsonFormatter;
$logger->add(newRotatingStreamDriver(
pathPattern: '/var/log/app-{date}.log',
formatter: newJsonFormatter(),
));
// Produces: /var/log/app-2024-06-01.log, /var/log/app-2024-06-02.log, …

Use $dateFormat to control rotation granularity — 'Y-m-d' (default) for daily, 'Y-m-d-H' for hourly.

FingersCrossedDriver

Buffers all log items silently until one at or above the activation level arrives, then flushes the entire buffer to the inner driver. This gives full diagnostic context around errors without log noise during normal operation:

useOrdinary\Log\Driver\FingersCrossedDriver;
useOrdinary\Log\Driver\StreamDriver;
useOrdinary\Log\LogLevel;
$logger->add(newFingersCrossedDriver(
inner: newStreamDriver(fopen('/var/log/app.log', 'a')),
activationLevel: LogLevel::Error,
));

After activation all subsequent items go directly to the inner driver. If the threshold is never reached, flush() discards the buffer silently.

Options:

  • $maxBuffer — cap buffer size (drops oldest item when full); 0 = unlimited.
  • $resetOnFlush — return to buffering state after each flush(), for request-scoped use.

NullDriver

Silently discards every log item. Useful as a placeholder or to explicitly disable a group during tests:

useOrdinary\Log\Driver\NullDriver;
$logger->add(newNullDriver());

TestDriver

Collects all log items in memory. Use it in tests to assert on what was logged without touching real I/O:

useOrdinary\Log\Driver\TestDriver;
useOrdinary\Log\LogLevel;
$driver = newTestDriver();
$logger->add($driver);
$service->processPayment($order); // triggers $logger->error(...)$this->assertTrue($driver->hasRecordThatContains('Payment failed', LogLevel::Error));
$this->assertTrue($driver->hasRecord(LogLevel::Error, 'Payment failed'));
$this->assertTrue($driver->hasRecordAtLevel(LogLevel::Error));
$errors = $driver->getRecordsAtLevel(LogLevel::Error); // list<LogEntryInterface>$driver->reset(); // clear between test cases

Composing decorators

Decorators compose: wrap one inside another and call Logger::flush() once — the cascade propagates automatically.

$logger->add(
newDeduplicatingDriver(
inner: newBufferingDriver(
inner: newCloudWatchDriver($client, '/app/prod', 'web'),
flushAfter: 50,
),
windowSeconds: 60,
),
);
$logger->flush(); // DeduplicatingDriver → BufferingDriver → CloudWatchDriver

Log Groups

Drivers are organised into named groups. Groups let you apply a shared matcher to multiple drivers without repeating it, and add or remove entire sets of drivers at runtime.

A default group is always present with no matcher. Drivers added without specifying a group land there.

// All drivers in this group only receive Warning and above$logger->addGroup('prod', matcher: newIsLevelOrHigher(LogLevel::Warning));
$logger->add(newCloudWatchDriver($client, '/app/prod', 'web', $formatter), group: 'prod');
$logger->add(newStreamDriver(fopen('/var/log/app.log', 'a'), $formatter), group: 'prod');
// Default group — receives everything$logger->add(newStreamDriver(STDOUT, $formatter));

Per-driver matchers are combined with the group matcher as an AND:

// Group passes Warning+; this driver additionally requires Error+$logger->add(
newCloudWatchDriver($client, '/app/alerts', 'web', $formatter),
matcher: newIsLevelOrHigher(LogLevel::Error),
group: 'prod',
);

Runtime group management

Groups can be added and removed at runtime. All drivers registered to a removed group stop receiving log items immediately.

// On entering a request context$logger->addGroup('request', matcher: newHasContext('request_id'));
$logger->add(newCloudWatchDriver($client, '/app/requests', 'web', $formatter), group: 'request');
// On exit$logger->removeGroup('request');

Constraints:

  • Group IDs must be unique — addGroup() throws if the ID already exists.
  • removeGroup('default') throws — the default group cannot be removed.
  • add() throws if the specified group does not exist.

Processors

Processors transform log items before they are dispatched to drivers. Register them with addProcessor() — they run in registration order after the channel is stamped.

TagProcessor

Stamps a fixed set of key-value pairs on every log item:

useOrdinary\Log\Processor\TagProcessor;
$logger->addProcessor(newTagProcessor([
'env' => 'production',
'release' => 'v2.3.1',
'service' => 'payment-api',
]));

UidProcessor

Generates a unique identifier at construction time and adds it to every log item. Create a new instance per request to correlate all log entries for a single operation:

useOrdinary\Log\Processor\UidProcessor;
$logger->addProcessor(newUidProcessor()); // 'uid' — 7 hex chars$logger->addProcessor(newUidProcessor('request_id', length: 16)); // custom key + length// Bring your own generator — must return a non-empty string$logger->addProcessor(newUidProcessor(generator: fn () => Uuid::v4()->toString()));

Throws \UnexpectedValueException at construction if the generator returns an empty string.

MemoryUsageProcessor

Adds the current memory usage (in bytes) to every log item:

useOrdinary\Log\Processor\MemoryUsageProcessor;
$logger->addProcessor(newMemoryUsageProcessor()); // adds 'memory.usage'$logger->addProcessor(newMemoryUsageProcessor(includePeak: true)); // also 'memory.peak_usage'$logger->addProcessor(newMemoryUsageProcessor(realUsage: true)); // system-allocated bytes

WebProcessor

Adds HTTP request details to every log item. Supply a PSR-7 ServerRequestInterface or a raw server params array — nothing is read from globals:

useOrdinary\Log\Processor\WebProcessor;
// PSR-7 request: native methods used first, server params as fallback$logger->addProcessor(newWebProcessor($psrRequest));
// Adds: request.url, request.ip, request.method, request.server, request.referrer, request.user_agent// Raw server params array (useful in tests or CLI scripts)$logger->addProcessor(newWebProcessor(['REQUEST_URI' => '/test', 'REMOTE_ADDR' => '127.0.0.1']));
// Include additional server param keys$logger->addProcessor(newWebProcessor($psrRequest, extraFields: ['HTTP_X_REQUEST_ID']));
// Also adds: request.http_x_request_id

When a PSR-7 request is provided, URL and method come from getUri() / getMethod(), headers from getHeaderLine(), and IP / extra fields from getServerParams(). Passing null (the default) adds no context.

IntrospectionProcessor

Adds the file, line, class, and function of the actual log call site by inspecting the call stack:

useOrdinary\Log\Processor\IntrospectionProcessor;
$logger->addProcessor(newIntrospectionProcessor());
// Adds: log.file, log.line, log.class, log.function

If your application wraps ordinary/log in its own logger class, exclude that namespace from the walk:

$logger->addProcessor(newIntrospectionProcessor(skipNamespaces: ['App\\Logging\\']));

CallableProcessor

Wraps a closure as a processor for one-off transformations:

useOrdinary\Log\CallableProcessor;
$logger->addProcessor(newCallableProcessor(
fn(ImmutableLogEntryInterface$item) => $item->withContext(['request_id' => $requestId]),
));

Creating a custom processor

Implement LogProcessorInterface with a single process() method:

useOrdinary\Log\ImmutableLogEntryInterface;
useOrdinary\Log\LogEntry;
useOrdinary\Log\LogEntryInterface;
useOrdinary\Log\LogProcessorInterface;
finalclass TenantProcessor implements LogProcessorInterface
{
publicfunction__construct(privatereadonlystring$tenantId) {}
publicfunctionprocess(LogEntryInterface$logItem): LogEntryInterface
{
if ($logIteminstanceof ImmutableLogEntryInterface) {
return$logItem->withContext(['tenant_id' => $this->tenantId]);
}
returnnewLogEntry(
$logItem->level,
$logItem->message,
$logItem->dateTime,
\array_merge($logItem->context, ['tenant_id' => $this->tenantId]),
);
}
}

Creating a Custom Logger

Implement LoggerInterface and use LoggerTrait. The trait provides the eight named methods; you only need to implement log().

useOrdinary\Log\LogEntryInterface;
useOrdinary\Log\LoggerInterface;
useOrdinary\Log\LoggerTrait;
finalclass TenantLogger implements LoggerInterface
{
use LoggerTrait;
publicfunction__construct(
privatereadonlystring$tenantId,
privatereadonlyLogger$logger,
) {}
publicfunctionlog(LogEntryInterface$logItem): void
{
$this->logger->log(
$logItem->withContext(['tenant_id' => $this->tenantId]),
);
}
}

Controlling Timestamps

All log items created via the named helper methods (info(), error(), etc.) are timestamped using Logger's $clock parameter. The default is UtcClock. Inject a Psr\Clock\ClockInterface to control timestamps in tests or to use a different time source:

useOrdinary\Log\Logger;
useOrdinary\Log\UtcClock;
// Production — default UTC clock$logger = newLogger();
// Tests — inject a MutableClock to control time$clock = newMutableClock(newDateTimeImmutable('2024-01-01T00:00:00Z'));
$logger = newLogger(clock: $clock);

The same pattern applies to DeduplicatingDriver — inject a clock to drive the deduplication window in tests without relying on real wall-clock time.


Creating a Custom Driver

Implement LogDriverInterface with a single handleLog() method. Do not add matcher or dispatcher logic — Logger handles that.

useOrdinary\Log\LogDriverInterface;
useOrdinary\Log\LogEntryInterface;
finalclass SlackDriver implements LogDriverInterface
{
publicfunction__construct(privatereadonlystring$webhookUrl) {}
publicfunctionhandleLog(LogEntryInterface$logItem): void
{
// post formatted message to Slack webhook...
}
}

Flushing buffered state

Call Logger::flush() to drain any buffered items held by drivers that implement FlushableInterface. Every registered driver that implements this interface is flushed, in group order, regardless of whether an earlier one throws. The first exception, if any, is re-thrown after all drivers have been attempted.

// Typically called once, at request or job end$logger->flush();

Bufferable drivers

Implement FlushableInterface alongside LogDriverInterface to participate in flush propagation. Wrapping drivers must cascade to the inner driver:

useOrdinary\Log\FlushableInterface;
useOrdinary\Log\LogDriverInterface;
useOrdinary\Log\LogEntryInterface;
finalclass MyBufferingDriver implements LogDriverInterface, FlushableInterface
{
privatearray$buffer = [];
publicfunction__construct(privatereadonlyLogDriverInterface$inner) {}
publicfunctionhandleLog(LogEntryInterface$logItem): void
{
$this->buffer[] = $logItem;
}
publicfunctionflush(): void
{
foreach ($this->bufferas$item) {
$this->inner->handleLog($item);
}
$this->buffer = [];
// Cascade to inner if it is also flushableif ($this->innerinstanceof FlushableInterface) {
$this->inner->flush();
}
}
}

Bulk-write drivers

If your backend supports writing multiple items in a single call, implement LogBatchDriverInterface. BufferingDriver detects this interface during flush and calls handleLogBatch() with the entire buffer at once.

useOrdinary\Log\LogBatch;
useOrdinary\Log\LogBatchDriverInterface;
useOrdinary\Log\LogEntryInterface;
finalclass ElasticsearchDriver implements LogBatchDriverInterface
{
publicfunctionhandleLog(LogEntryInterface$logItem): void
{
$this->client->index(['body' => $this->format($logItem)]);
}
publicfunctionhandleLogBatch(LogBatch$batch): void
{
$body = [];
foreach ($batch->itemsas$item) {
$body[] = ['index' => ['_index' => 'logs']];
$body[] = $this->format($item);
}
$this->client->bulk(['body' => $body]);
}
}

Synchronous drivers

If your driver must always execute immediately — bypassing the async dispatcher — implement SynchronousDriverInterface instead:

useOrdinary\Log\SynchronousDriverInterface;
finalclass ImmediateStreamDriver implements SynchronousDriverInterface
{
publicfunctionhandleLog(LogEntryInterface$logItem): void
{
// always runs synchronously even when a dispatcher is set
}
}

Matchers

Matchers implement LogMatcherInterface and filter which log items a driver or group processes.

Using matchers

useOrdinary\Log\Matcher\IsAll;
useOrdinary\Log\Matcher\IsAny;
useOrdinary\Log\Matcher\IsLevel;
useOrdinary\Log\Matcher\IsLevelOrHigher;
useOrdinary\Log\Matcher\IsLevelOrLower;
useOrdinary\Log\Matcher\IsNot;
// Severity thresholdsnew IsLevelOrHigher(LogLevel::Error) // Error, Critical, Alert, Emergencynew IsLevelOrLower(LogLevel::Notice) // Debug, Info, Notice// Exact level matching (variadic)new IsLevel(LogLevel::Warning, LogLevel::Error)
// Compositionnew IsNot(newIsLevel(LogLevel::Debug))
new IsAll([newIsLevelOrHigher(LogLevel::Warning), newIsNot(newIsLevel(LogLevel::Warning))])
new IsAny([newIsLevel(LogLevel::Debug), newIsLevelOrHigher(LogLevel::Error)])

Matchers compose without limit. Reuse the same instance across groups and drivers:

$prodMatcher = newIsLevelOrHigher(LogLevel::Warning);
$logger->addGroup('prod', matcher: $prodMatcher);
$logger->add($cloudWatchDriver, group: 'prod');
$logger->add($fileDriver, group: 'prod');
// Dev extends prod — receives everything prod does, plus debug$logger->addGroup('dev', matcher: newIsAny([$prodMatcher, newIsLevel(LogLevel::Debug)]));
$logger->add($localStreamDriver, group: 'dev');

Creating a matcher

Implement LogMatcherInterface with a single matches() method:

useOrdinary\Log\LogEntryInterface;
useOrdinary\Log\LogMatcherInterface;
finalclass HasContext implements LogMatcherInterface
{
publicfunction__construct(privatereadonlystring$key) {}
publicfunctionmatches(LogEntryInterface$logItem): bool
{
returnarray_key_exists($this->key, $logItem->context);
}
}

Error Fallbacks

When a driver throws, the exception is wrapped in a LogFailureException that carries the original exception, the log item, and the failing driver. Logger passes it to the configured failure handler and then continues to the next driver.

By default, Logger uses ErrorLogFailureHandler, which writes the failure to PHP's error log via error_log(). Override it in the constructor:

useOrdinary\Log\FailureHandler\NoOpFailureHandler;
useOrdinary\Log\FailureHandler\SyslogFailureHandler;
useOrdinary\Log\FailureHandler\StderrFailureHandler;
useOrdinary\Log\FailureHandler\ErrorLogFailureHandler;
// Default — writes to PHP error log$logger = newLogger(onFailure: newErrorLogFailureHandler());
// Write to syslog$logger = newLogger(onFailure: newSyslogFailureHandler());
// Write to STDERR$logger = newLogger(onFailure: newStderrFailureHandler());
// Silently discard (explicit no-op)$logger = newLogger(onFailure: newNoOpFailureHandler());

LogFailureException

$e->getMessage(); // "Log dispatch failed for [error] 'msg': <original message>"$e->getPrevious(); // the original \Throwable$e->getLogItem(); // the LogEntryInterface being dispatched$e->getFailingDriver(); // the driver that threw

Custom failure handler

Implement LogFailureHandlerInterface:

useOrdinary\Log\LogFailureExceptionInterface;
useOrdinary\Log\LogFailureHandlerInterface;
finalclass SentryFailureHandler implements LogFailureHandlerInterface
{
publicfunctionhandleLogFailure(LogFailureExceptionInterface$e): void
{
\Sentry\captureException($e->getPrevious() ?? $e);
}
}

Async Dispatching

Pass a $dispatcher closure to Logger to defer driver calls. The closure receives each driver invocation as a zero-argument closure.

The shouldLog() / matcher checks always run synchronously before anything is queued, so filtered items are never dispatched.

With revolt/event-loop

revolt/event-loop is the shared backend for Amp 3.x and ReactPHP 3.x.

composer require revolt/event-loop
useRevolt\EventLoop;
useOrdinary\Log\Logger;
$logger = newLogger(
dispatcher: fn(\Closure$fn) => EventLoop::queue($fn),
);
$logger->add(newStreamDriver(fopen('/var/log/app.log', 'a'), $formatter));
$logger->info('Queued — not written yet');
EventLoop::run(); // flushes all queued writes

Each driver call is dispatched as its own unit. Drivers implementing SynchronousDriverInterface are always invoked immediately, even when a dispatcher is set.


Migrating from Monolog

This section maps common Monolog patterns to their ordinary/log equivalents.

Logger setup

// MonologuseMonolog\Logger;
useMonolog\Handler\StreamHandler;
$log = newLogger('app');
$log->pushHandler(newStreamHandler('/var/log/app.log', Logger::WARNING));
// ordinary/loguseOrdinary\Log\Logger;
useOrdinary\Log\Driver\StreamDriver;
useOrdinary\Log\JsonFormatter;
useOrdinary\Log\Matcher\IsLevelOrHigher;
useOrdinary\Log\LogLevel;
$logger = newLogger(channel: 'app');
$logger->add(
newStreamDriver(fopen('/var/log/app.log', 'a'), newJsonFormatter()),
matcher: newIsLevelOrHigher(LogLevel::Warning),
);

PSR-3 / framework integration

// Monolog — already implements Psr\Log\LoggerInterface natively$container->bind(Psr\Log\LoggerInterface::class, fn() => $log);
// ordinary/log — one extra call$container->bind(Psr\Log\LoggerInterface::class, fn() => $logger->toPsr());

Handlers → Drivers

Monolog handlerordinary/log driver
StreamHandlerStreamDriver
RotatingFileHandlerRotatingStreamDriver
SyslogHandlerSyslogDriver
CloudWatchLogsHandlerCloudWatchDriver
BufferHandlerBufferingDriver
FingersCrossedHandlerFingersCrossedDriver
NullHandlerNullDriver
TestHandlerTestDriver

Processors

// Monolog$log->pushProcessor(function (array$record): array {
$record['extra']['request_id'] = $requestId;
return$record;
});
// ordinary/loguseOrdinary\Log\CallableProcessor;
$logger->addProcessor(newCallableProcessor(
fn($item) => $item->withContext(['request_id' => $requestId]),
));

Filtering by level

// Monolog — minimum level on each handler$log->pushHandler(newStreamHandler(STDERR, Logger::ERROR));
// ordinary/log — matcher on each driver (or on the group)useOrdinary\Log\Matcher\IsLevelOrHigher;
useOrdinary\Log\LogLevel;
$logger->add(newStreamDriver(STDERR), matcher: newIsLevelOrHigher(LogLevel::Error));

Channels

// Monolog — separate Logger instance per channel$paymentLog = newLogger('payment');
// ordinary/log — set on the Logger constructor; one logger, one channel$paymentLogger = newLogger(channel: 'payment');
// Appears as a top-level "channel" field in JsonFormatter output

Exception logging

// Monolog$log->error('Charge failed', ['exception' => $e]);
// ordinary/log — identical; RESERVED_EXCEPTION is also "exception"$logger->error('Charge failed', ['exception' => $e]);

About

Structured logging implementation for OrdinaryPHP.

Topics

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages