meyercomputerinc / log-client
Buffered, batch-shipping Monolog handler for Axiom. One HTTP request per execution; can never slow down or break the host application.
Requires
- php: ^8.3
- ext-curl: *
- ext-json: *
- laravel/framework: ^12.0|^13.0
- monolog/monolog: ^3.11
Requires (Dev)
- laravel/pint: ^1.29
- orchestra/testbench: ^11.0
- pestphp/pest: ^4.0
- pestphp/pest-plugin-laravel: ^4.0
Suggests
None
Provides
None
Conflicts
None
Replaces
None
README
Buffered, batch-shipping Monolog handler for Axiom. Log records are collected in memory during one execution (HTTP request, artisan command, queued job, scheduled task) and POSTed to Axiom as a single NDJSON batch when that execution ends.
It is built so that logging can never slow down or break the host application:
- one HTTP call per execution, hard-capped at 1 s connect + 2 s total (
AXIOM_CONNECT_TIMEOUT_MS/AXIOM_TIMEOUT_MS; a TLS handshake to Axiom from far away can take 400–700 ms); - a circuit breaker skips shipping for 60 s after 3 consecutive failures;
- every code path catches
Throwable; failures are reported once per process viaerror_log(), never through Laravel's logger; - a bounded 500-record buffer that drops the oldest records under pressure;
- inert during unit tests unless explicitly allowed.
Install
composer require meyercomputerinc/log-client
The service provider is auto-discovered and registers an axiom log channel. Add it to your stack:
LOG_STACK=stderr,axiom # or single,axiom locally
AXIOM_ENABLED=true
AXIOM_TOKEN=xaat-...
AXIOM_DATASET=my-app
Axiom setup
- Create a dataset (one per app is a good default, e.g.
my-appandmy-app-dev). Datasets are not auto-created on ingest. - Create an API token with ingest permission on that dataset.
- Optional: run
php artisan vendor:publish --tag=log-client-configto tune the defaults.
Verify
php artisan log-client:test --dry-run # prints the NDJSON that would be sent
php artisan log-client:test # sends sample records, prints status / ingested / failed / breaker
Then in Axiom: ["my-app"] | where execution_id == "<id printed by the command>".
What gets shipped
Every event carries _time (UTC), level, message, channel, app, environment, host, and execution metadata: execution (request | job | command | schedule | worker), execution_name (route name, job class, command, task, or queue:work <connection>:<queue> for anything a queue worker logs between jobs, such as the driver failing to pop), execution_id (one per execution), trace_id (shared with jobs dispatched from the same request; uses Nightwatch's trace id when Nightwatch is installed), and user_id plus user_type (the model's class basename, e.g. User or Client) when someone is authenticated — through Laravel's Authenticated event or Sanctum's TokenAuthenticated.
Context keys listed in promote (default association, source, client_id, and the slow-execution keys kind, duration_ms, status, queries, query_ms, redis_ms, boot_ms) become top-level fields. Everything else in context — and Laravel Context data, which Laravel places in extra — is shipped according to context_mode:
json(default): one string field. Always safe; the Axiom free plan caps a dataset at 256 field names, and nested keys would otherwise sprawl. Query it withparse_json(context).map: a nested object. Createcontextandextraas map fields in Axiom first so each counts as one field.drop: omit it.
Exceptions in context (['exception' => $e], as Laravel's handler sends them) become exactly exception.class, exception.message, exception.file and a trace capped at exception.trace_frames lines.
When the buffer flushes
| Execution | Flush point |
|---|---|
| HTTP request | end of the kernel's terminate(), after terminable middleware |
| Artisan command | end of the console kernel's terminate() |
| Queue worker (incl. Horizon) | Looping (before the next job) and WorkerStopping — never on the job events, which fire before the worker logs a failed job's exception |
| Horizon master / supervisor process | MasterSupervisorLooped / SupervisorLooped, about once a second; a no-op while nothing is buffered. Without it a lone record (a boot line, a single supervisor error) waited for the next record or the process's end |
| Scheduled task | after each task |
| Any long-running process | when the oldest buffered record is flush_after_seconds old (default 10), or the buffer hits buffer_limit |
Slow executions
The package also writes one log line, through the app's own logger (so it reaches every channel of the stack, not only Axiom), for each execution that takes at least its threshold:
| line | threshold (ms, 0 = off) | fields |
|---|---|---|
Slow request: GET /api/listings 200 in 2340 ms, 41 queries (1900 ms in SQL, 3 slow), 6 redis (38 ms), boot 120 ms, middleware 45 ms, handler 2150 ms, terminate 25 ms | SLOW_REQUEST_MS (2000) | kind=request, duration_ms, status, queries, query_ms, redis_ms, boot_ms; redis, middleware_ms, handler_ms, terminate_ms in context |
Slow query: 812 ms on pgsql: select * from "media" where "listing_id" = ? | SLOW_QUERY_MS (500) | kind=query, duration_ms |
Slow job: App\Jobs\Refill on crons processed in 142.3 s, 1204 queries (…), 310 redis (420 ms) | SLOW_JOB_MS (120000) | kind=job, duration_ms, queries, query_ms, redis_ms; queue, redis in context |
Slow command: images:sweep exited 0 in 612.0 s, 22000 queries (…), 40 redis (…) | SLOW_COMMAND_MS (300000) | kind=command, duration_ms, status, queries, query_ms, redis_ms |
Notes:
- The request and command lines come from the kernels' lifecycle hooks, after the terminable middleware, and ship in the same batch as the rest of the execution. The job line comes from
JobAttempted; a job killed by its timeout never finishes, so it gets no line (it has afailed_jobsrow). - The Redis count and time come from Laravel's
CommandExecutedevent, which is off untilRedisManager::enableEvents(); the package turns it on when the manager resolves. One event dispatch per command, no extra I/O. - A request's phases come from marks Laravel already sets:
bootruns fromLARAVEL_START(or the kernel's start when that constant is missing or stale) to the app'sbootedcallback,middlewaretoRouteMatched(global middleware and routing),handlertoRequestHandled(route middleware and the controller),terminateto the end. A phase whose mark is missing (a 404 before routing, a boot that predates the request under Octane) is left out rather than guessed. - A query line holds the SQL as Laravel ran it: placeholders, never the bindings, cut at 500 chars. At most
slow.query_lines_per_execution(10) query lines per request, job or command; the rest are only counted inslow_queries. slow.skip_commands(defaulthorizon,horizon:*,queue:*,schedule:*,tinker) are never reported: their "finish" is a restart, and the scheduler's commands run as their ownphp artisanprocess and report themselves.- The lines are written at
slow.level(info), so they stay out of a warnings review and get their own:where isnotnull(duration_ms) | summarize n=count(), worst=max(duration_ms) by app, kind, execution_name.
Configuration
See config/log-client.php. Everything is env-driven; the keys you are most likely to touch are AXIOM_LOG_LEVEL, context_mode, promote, flush_after_seconds, and the four SLOW_*_MS thresholds.
Testing your app
The package is forced off while app()->runningUnitTests() is true unless log-client.allow_in_tests is set, so application test suites never ship logs. To make that explicit, add <env name="AXIOM_ENABLED" value="false"/> to phpunit.xml.
License
MIT