Search by

meyercomputerinc / log-client

knuge

Buffered, batch-shipping Monolog handler for Axiom. One HTTP request per execution; can never slow down or break the host application.

Package info

bitbucket.org/meyercomputerinc/log-client

pkg:composer/meyercomputerinc/log-client

Statistics

Installs: 89

Dependents: 0

Suggesters: 0

v0.4.0 2026-10-27 18:41 UTC

This package is auto-updated.

Last update: 2026-10-27 18:41:54 UTC


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 via error_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

  1. Create a dataset (one per app is a good default, e.g. my-app and my-app-dev). Datasets are not auto-created on ingest.
  2. Create an API token with ingest permission on that dataset.
  3. Optional: run php artisan vendor:publish --tag=log-client-config to 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 with parse_json(context).
  • map: a nested object. Create context and extra as 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

ExecutionFlush point
HTTP requestend of the kernel's terminate(), after terminable middleware
Artisan commandend 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 processMasterSupervisorLooped / 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 taskafter each task
Any long-running processwhen 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:

linethreshold (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 msSLOW_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 a failed_jobs row).
  • The Redis count and time come from Laravel's CommandExecuted event, which is off until RedisManager::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: boot runs from LARAVEL_START (or the kernel's start when that constant is missing or stale) to the app's booted callback, middleware to RouteMatched (global middleware and routing), handler to RequestHandled (route middleware and the controller), terminate to 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 in slow_queries.
  • slow.skip_commands (default horizon, horizon:*, queue:*, schedule:*, tinker) are never reported: their "finish" is a restart, and the scheduler's commands run as their own php artisan process 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