PHP
infinitetoken/lumber is Lumber's PHP client. It encrypts every log with AES-256-GCM before it leaves your server. It's a PSR-3 logger, with a Laravel log channel and a Monolog handler (for Symfony and anything else on Monolog), and it never makes a visitor wait on the network.
Install
composer require infinitetoken/lumber
PHP 8.2 or later, with the curl and OpenSSL extensions. Its one other dependency is psr/log.
Quick start
use InfiniteToken\Lumber\Lumber;
$lumber = new Lumber((string) getenv('LUMBER_API_KEY'));
$lumber->info('Server started', ['port' => 8080]);
$lumber->warning('Rate limit approaching', ['request_id' => 'req-123', 'user_id' => 456]);
$lumber->error('Payment failed', ['trace_id' => 'trace-abc', 'order_id' => 789, 'exception' => $e]);
That's all: the logs are sent when the request (or script) ends. In Laravel, skip this and set up the log channel.
The API key comes from the Lumber app (see Getting Started). Keep it in an environment variable or your secrets manager, never in source control, and never in an app or game you ship to people (why).
How logs are sent
Most PHP runs one request at a time, with nothing left running between requests, so the client can't send from a background thread the way Lumber's other clients do. Instead it holds each request's logs, already encrypted, and sends them as the request ends:
- PHP-FPM (nginx, Caddy, Apache with
proxy_fcgi, most hosting), LiteSpeed and FrankenPHP: the response goes to the visitor first, and then the logs are sent, so nobody waits on Lumber. The PHP worker stays busy while they send, about a tenth of a second a log. - Apache's mod_php and
php -Shave no way to finish a response early, so the visitor's page finishes loading once the logs are sent. - A command-line script sends what it holds as it exits.
Logs are sent one at a time in the order they were logged, over one connection kept open between them. Sending stops after 5 seconds (flushTimeout), and anything left is dropped with a warning; raise it if a request logs more than a few dozen lines. Up to 1,000 logs are held (maxBuffer); one more sends them there and then.
Long-running processes (queue workers, Octane, RoadRunner) never reach the end of a request, so they send at the end of each job or request instead. See Long-running workers.
Laravel
Laravel finds the package by itself. Add your API key to .env, and lumber to the channels the stack channel writes to:
LUMBER_API_KEY=your-api-key
LOG_CHANNEL=stack
LOG_STACK=single,lumber
Everything your app logs now goes to Lumber as well as storage/logs/laravel.log:
use Illuminate\Support\Facades\Log;
Log::info('Order placed', ['order_id' => 789, 'request_id' => $request->header('X-Request-Id')]);
Log::channel('lumber')->warning('Sent to Lumber only');
- Records are sent at the matching level: NOTICE is INFO, and CRITICAL, ALERT and EMERGENCY are FATAL.
{placeholders}in messages are filled from the context, and an exception Laravel reports arrives with its trace.- What you add with Laravel's
Contextgoes into each log's metadata, withtrace_idandrequest_idas the log's own fields. Each log's context carries your app's environment (APP_ENV). - Logs are sent after the response has gone to the visitor, after each queued job, and after each of Octane's requests, tasks and ticks.
Settings, all optional but the key:
.env | Default | |
|---|---|---|
LUMBER_API_KEY | Your app's API key | |
LUMBER_LEVEL | debug | The lowest level the channel sends |
LUMBER_ENVIRONMENT | APP_ENV | Sent as env in every log's context; empty sends none |
LUMBER_SYNC | false | Send each log as it's logged (see Serverless) |
LUMBER_AUTO_CONTEXT | true | Send the process id and hostname |
LUMBER_TIMEOUT | 10 | Seconds to wait for Lumber to answer one log |
LUMBER_FLUSH_TIMEOUT | 5 | Seconds sending may take as a request or job ends |
LUMBER_MAX_BUFFER | 1000 | Logs held before they're sent there and then |
php artisan vendor:publish --tag=lumber-config copies them to config/lumber.php. To change the channel itself, define lumber in config/logging.php ('driver' => 'custom', 'via' => InfiniteToken\Lumber\Laravel\LumberChannel::class, with Laravel's usual level, bubble, replace_placeholders and name). The client is in the container as InfiniteToken\Lumber\Lumber.
Laravel 12 or later. In an app whose config/logging.php predates LOG_STACK, add 'lumber' to the stack channel's channels.
Monolog and Symfony
LumberHandler sends Monolog's records (Monolog 3), so any code that logs through Monolog sends to Lumber:
use InfiniteToken\Lumber\Lumber;
use InfiniteToken\Lumber\Monolog\LumberHandler;
use Monolog\Level;
use Monolog\Logger;
$lumber = new Lumber((string) getenv('LUMBER_API_KEY'));
$logger = new Logger('app');
$logger->pushHandler(new LumberHandler($lumber, Level::Info));
$logger->error('Payment failed', ['order_id' => 789, 'exception' => $e]);
A record's context and extra (what processors add) are the log's data, as below, with the context winning, and its channel goes into the log's context. Add Monolog's PsrLogMessageProcessor to fill {placeholders}. new LumberHandler($apiKey) makes its own client, if you'd rather not share one.
In Symfony, register the client and handler as services in config/services.yaml:
services:
# ...
InfiniteToken\Lumber\Lumber:
arguments:
$apiKey: '%env(LUMBER_API_KEY)%'
$environment: '%kernel.environment%'
InfiniteToken\Lumber\Monolog\LumberHandler:
arguments:
$lumber: '@InfiniteToken\Lumber\Lumber'
$level: info
and give the handler to MonologBundle in config/packages/monolog.yaml:
monolog:
handlers:
lumber:
type: service
id: InfiniteToken\Lumber\Monolog\LumberHandler
Symfony resets its loggers between Messenger's messages, and the handler sends what's held when it's reset.
Log methods
| Method | Level |
|---|---|
debug($message, $context) | DEBUG |
info(...), notice(...) | INFO |
warning(...), warn(...) | WARN |
error(...) | ERROR |
critical(...), alert(...), emergency(...), fatal(...) | FATAL. None of them exit |
log($level, $message, $context) | A PSR-3 level (Psr\Log\LogLevel), a Level, or 'warn' or 'fatal' |
flush(?float $timeout) | Sends what's held now, waiting flushTimeout seconds at most; false if time ran out first |
close(?float $timeout) | Sends what's held and stops; later logs are dropped |
Logging never throws, except for an unknown level, which PSR-3 asks a logger to refuse.
Data
The context array is the log's data:
$lumber->info('User signed in', [
'request_id' => 'req-abc', // the log's request ID
'trace_id' => 'trace-xyz', // the log's trace ID
'context' => ['region' => 'us-east'], // merged into its context
'metadata' => ['plan' => 'pro'], // merged into its metadata
'user_id' => 123, // any other key goes into metadata
'exception' => $e, // a Throwable: its class and message, and its trace
]);
requestId and traceId, the API's own names, work too. An explicit metadata key wins over a colliding key. A Throwable under exception goes into metadata as exception (RuntimeException: Card declined) and trace (PHP's own account of it, with the exceptions that led to it). {placeholders} in the message are filled from the context, as PSR-3 describes, and the values are still sent. Values JSON has no form for are sent as text: a date as ISO 8601, an enum as its value, a JsonSerializable as what it serializes to, another object as its public properties, and a NaN as null. Everything, the message included, is encrypted.
Options
$lumber = new Lumber(
(string) getenv('LUMBER_API_KEY'),
environment: 'production', // sent as `env` in every log's context
autoContext: true, // send the process id and hostname with every log
caller: false, // also send the file, line and function that logged
callerDepth: 0, // frames to skip for `caller`, one per function of yours wrapped around Lumber
sync: false, // send each log as it's logged, waiting for Lumber, rather than as the request ends
afterResponse: true, // finish the response before sending (PHP-FPM, LiteSpeed, FrankenPHP)
timeout: 10.0, // seconds to wait for Lumber to answer one log
flushTimeout: 5.0, // seconds sending may take at a flush, the end of the request included
maxBuffer: 1000, // logs held before a new one sends them there and then
warnings: fn (string $message) => $other->warning($message), // in place of PHP's error log
);
Finishing the response early writes a PHP session first (session_write_close()), or the visitor's next request would wait on the logs for the session's lock. Turn afterResponse off if something after your script's own end still needs to send output.
Delivery
- A network error, a 5xx or no answer within
timeoutcounts as a failed attempt. Each log gets 5 attempts, waiting 1, 2, 4, then 8 seconds between them, and is then dropped with a warning. - A log Lumber refuses (a 4xx, such as an invalid API key) is dropped at once with a warning, since sending it again won't help.
- When your team has sent its plan's logs for the month, Lumber answers 429: the client warns once and drops logs until the month turns.
Every request starts afresh, so a request that finds Lumber unreachable, or at the monthly limit, leaves a note in the temp directory (named by a hash of your token), and the requests after it skip sending until it's over rather than each finding out for itself. While Lumber can't be reached, they hold off for 1 second, then 2, 4 and so on up to 30, until a log gets through. A Lumber outage costs your servers little, and never your visitors.
Warnings go to PHP's error log (error_log()) unless you pass warnings, never through a logger, where they'd be sent back to Lumber.
Long-running workers
A process that serves many requests or jobs sends at the end of each:
- Laravel: queue workers (
queue:work, Horizon) send after each job, and Octane after each request, task and tick, with nothing to set up. - Symfony: Messenger's workers reset loggers between messages, and the Monolog handler sends then.
- RoadRunner, Swoole, FrankenPHP's worker mode, or your own loop: call
flush()after each response or job:
while ($request = $worker->waitRequest()) {
$worker->respond(handle($request));
$lumber->flush(); // after the response, so nobody waits on it
}
Without a flush(), logs still go once 1,000 are held or the process exits, which for a worker could be hours. A forked process (pcntl) starts with an empty buffer and its own connection; the parent sends what it held before the fork.
Serverless
AWS Lambda (Bref, Laravel Vapor) freezes the process as soon as the response is sent, and Google Cloud Run slows it to a crawl unless CPU is always allocated, so logs held for after the response may not go until the next request, or at all. Use sync: true (LUMBER_SYNC=true in Laravel): each log is sent as it's logged, and the request waits for Lumber to answer, about a tenth of a second a log.
Source
The client is open source: Lumber-PHP on GitHub, published as infinitetoken/lumber on Packagist. It's tested against the same known answers for encryption and the wire format as Lumber's other clients.