skeylup / owlogs-agent
Owlogs agent — ship Laravel logs to an Owlogs server for aggregation and viewing.
Requires
- php: ^8.2
- illuminate/console: ^11.0|^12.0|^13.0
- illuminate/http: ^11.0|^12.0|^13.0
- illuminate/log: ^11.0|^12.0|^13.0
- illuminate/queue: ^11.0|^12.0|^13.0
- illuminate/support: ^11.0|^12.0|^13.0
- monolog/monolog: ^3.0
Requires (Dev)
- laravel/pint: ^1.14
- orchestra/testbench: ^9.0|^10.0
- pestphp/pest: ^3.0|^4.0
- pestphp/pest-plugin-laravel: ^3.0|^4.0
README
Ship Laravel logs — with rich context, tracing IDs, caller location, queue job metadata, and sanitized request body — asynchronously to Owlogs.
Drop-in, zero-config, Octane-safe. Works with Laravel 11, 12 and 13.
Requirements
- PHP 8.2+
- Laravel 11, 12 or 13
- A queue driver (redis, database, sqs, etc.) — required: the ship job runs on the queue, no HTTP call blocks a request
- A persistent cache store (redis, file, database, memcached) — the debounce marker uses
Cache::add; thearraydriver prevents de-duplication across requests - Redis or a writable filesystem — for the cross-process log buffer store (see How it works)
- An Owlogs account (free sign-up at owlogs.com) to get a workspace API key
Features
- Automatic context enrichment on every log entry:
trace_id,span_id,origin,user_id,app_name,app_env,app_url,git_sha,uri,route_name,route_action,ip,user_agent,duration_ms, etc. - Distributed tracing: the same
trace_idflows from an HTTP request into every queue job it dispatches, so you can reconstruct the full lifetime of a request. - Caller location: each log entry carries the
file:lineandClass@methodwhereLog::*()was actually called — not the framework frame. - Queue job metadata: job class, attempt, queue, connection, plus the public (scalar) properties of the job payload.
- Artisan command metadata: command name and arguments for CLI origin logs.
- Sanitized request input: POST / PUT / PATCH bodies are captured with
password,secret,token,authorization,cookie,credit_cardvalues redacted. - Exception stacktraces including up to 3 levels of chained exceptions.
- Performance spans via the
Measurehelper and optional automatic DB query tracking with N+1 detection. - Breadcrumbs for action timelines.
- Opt-in lifecycle auto-logging for auth events, job lifecycle, mail, cache, slow queries, scheduled tasks, model changes, and more.
- Async delivery via a debounced queue job: N flushes (HTTP request + queue jobs it dispatches) in the same window collapse to a single
ShipBufferedLogsJob— no cascade, no blocking. - Runtime-aware buffering: non-Octane accumulates in RAM and flushes once per request/job/command boundary; Octane batches across requests with a 2 s / 20-log window so workers ship fewer, bigger payloads.
- Octane-safe: no container / request injection into singletons, all state is reset between requests.
Quickstart
composer require skeylup/owlogs-agent
Add your workspace API key to .env — grab it from your workspace's API keys page on owlogs.com:
OWLOGS_API_KEY=owl_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
That's it. With LOG_CHANNEL=stack (Laravel's default), any Log::info(...), Log::error(...), etc. is now shipped to Owlogs asynchronously via a queue job — the agent registers the owlogs channel on boot and appends it to your stack channel for you. No edits to config/logging.php or LOG_STACK required.
Prefer remote-only shipping? Set LOG_CHANNEL=owlogs.
If OWLOGS_API_KEY is empty, the queue job is still dispatched but returns silently without hitting the network — handy for local development.
If the project uses Laravel Boost, run php artisan boost:update to pick up the bundled AI instrumentation skill — your AI assistant will then know how to add workflow-level logging across the app automatically.
Customising the config (optional)
Only publish the config file when you actually want to tweak something beyond env vars:
php artisan vendor:publish --tag=owlogs-agent-config
Opt out of auto-registration
If you'd rather wire things manually (custom stack logic, explicit LOG_STACK, or an alternate channel definition), set:
OWLOGS_AUTO_REGISTER_STACK=false
Then declare the channel yourself in config/logging.php:
'owlogs' => [ 'driver' => 'custom', 'via' => \Skeylup\OwlogsAgent\Handlers\RemoteLogChannel::class, 'level' => env('LOG_LEVEL', 'debug'), 'tap' => [\Skeylup\OwlogsAgent\LogContextTap::class], ],
…and add owlogs to your LOG_STACK (e.g. LOG_STACK=single,owlogs).
Upgrading from an earlier version
Earlier versions required you to declare the owlogs channel in config/logging.php and list it in LOG_STACK by hand. Both are now done at boot. After upgrading you can safely:
- Remove the manual
'owlogs' => [...]entry fromconfig/logging.php. - Drop
owlogsfromLOG_STACKin.env(e.g.LOG_STACK=single).
Either is also fine to keep — a pre-existing channel definition is never overwritten, and the stack injection skips owlogs if it's already present.
How it works
┌──────────────┐ ┌───────────────┐ ┌─────────────────┐ ┌─────────────────┐ ┌───────────────────────┐
│ Log::info() │ → │ LogContext │ → │ RemoteHandler │ → │ LogBufferStore │ → │ ShipBufferedLogsJob │
│ │ │ Tap │ │ (RAM, policy) │ │ (Redis / file) │ │ (queued, debounced) │
└──────────────┘ └───────────────┘ └─────────────────┘ └─────────────────┘ └──────────┬────────────┘
▲ ▲ │
│ │ ▼
flush on Cache::add POST /api/owlogs/ingest
boundary debounce X-Api-Key: owl_…
(or Octane (once per
window) 10 s window)
The pipeline has two independent stages — a per-process RAM buffer and a cross-process persistent buffer — both with their own bounded-size / bounded-time guarantees. Together they collapse what used to be one queue job per flush into one ship job per debounce window.
- Context enrichment. Global middleware
AddLogContextpopulates Laravel'sContextfacade on every HTTP request with tracing, routing, user and timing fields. The same context flows into queue jobs (Context::hydrated) and artisan commands (CommandStarting). - Caller trace + formatter.
LogContextTapattaches a Monolog processor that resolves the realfile:line/Class@methodof the caller (skipping framework frames) and sets a JSON formatter. - RAM buffer, runtime-aware.
RemoteHandleraccumulates records in memory. When to drain RAM → store is chosen by the activeFlushPolicy:EndOfRequestPolicy(non-Octane: PHP-FPM / Herd, artisan one-shot,queue:work): no mid-request flush. Drain onapp()->terminating(),Queue::after,CommandFinished, plus aregister_shutdown_functionsafety net.OctaneWindowPolicy(Swoole / RoadRunner / FrankenPHP): drain when the RAM buffer reachesoctane.batch_count(20) oroctane.window_ms(2 000) have elapsed since the first buffered record. Under Swoole,Octane::tickenforces the window even during idle periods; RR / FrankenPHP check on eachRequestTerminated+WorkerStopping.- Runtime is detected from
$_SERVER['LARAVEL_OCTANE'].OWLOGS_FLUSH_STRATEGY=octane|end_of_requestforces one or the other (tests / edge cases). - An unconditional hard ceiling at
2 × batch_size/2 × max_payload_bytesforces a flush no matter the policy — protects against a runaway caller looping onLog::*.
- Cross-process store. On flush, rows are appended to a
LogBufferStore:redis(default) — single Redis list, atomic Lua-scriptedLRANGE+LTRIMdrain. Multiple concurrent processes append safely; a drain always returns distinct rows. RequiresOWLOGS_BUFFER_REDIS_CONNECTION(defaultdefault).file— JSONL underOWLOGS_BUFFER_FILE_PATH(defaultstorage/app/owlogs/buffer.jsonl) with advisoryflock(LOCK_EX). Zero dependencies beyond a writable filesystem.memory— in-process, testing only.
- Debounced dispatch. After appending,
RemoteHandler::flush()tries to dispatch oneShipBufferedLogsJobguarded byCache::add('owlogs:ship:pending', ttl). Any flush fired within the debounce window (ship.debounce_ms, default 10 000) sees the marker and appends without dispatching — so an HTTP request that fans out to 15 queue jobs still queues a single ship job. - Ship job.
ShipBufferedLogsJob::handle()releases the marker, drains up toship.batch_count(256) rows from the store, splits the payload bymax_payload_bytes(each split becomes one HTTPS POST, gzipped, tohttps://www.owlogs.com/api/owlogs/ingest), and self-re-dispatches (no delay) whilestore.size() > 0.RemoteHandler::$suppressedis heldtruefor the duration so nothing the job does can feed back into the buffer. - Retries. Each POST retries 3 times with backoff
[5, 30, 120]seconds on 5xx / network errors. 4xx client errors (bad key, invalid payload),403(no active subscription) and429(quota exhausted) abandon without retry. The rows already drained from the store are serialized into the job payload, so a retry re-ships the same batch (at-least-once, never lost on transient 5xx).
Enriching logs from your models
Implement HasLogContext on any Eloquent model to expose safe, curated metadata in logs — instead of dumping the whole model.
use Skeylup\OwlogsAgent\Contracts\HasLogContext; class User extends Authenticatable implements HasLogContext { public function toLogContext(): array { return [ 'email' => $this->email, 'role' => $this->role, 'plan' => $this->subscription?->plan_name, ]; } public function getLogContextLabel(): string { return trim("{$this->first_name} {$this->last_name}"); } }
When the authenticated user implements this interface, the data is attached to every log entry under extra.user and extra.user_label. When passed explicitly as context (Log::info('...', ['user' => $user])), the same transformation happens — no more accidental serialization of sensitive fields.
Performance measurement
use Skeylup\OwlogsAgent\Measure; // Manual span Measure::start('generate_invoice', ['invoice_id' => 42]); // ... work ... Measure::stop('generate_invoice'); // Closure wrapper $result = Measure::track('stripe.charge', fn () => Stripe::charge($cents)); // Instant checkpoint Measure::checkpoint('cache.hit', ['key' => 'user:42']);
Spans are stored on Laravel's Context and shipped with the last log entry of the flushed batch.
Enable automatic DB query tracking with:
OWLOGS_MEASURE_DB=true OWLOGS_N_PLUS_ONE_THRESHOLD=5
Every query is recorded as a db span, and when the same normalized SQL runs more than N times an n+1 marker is added to the measures array.
Breadcrumbs
use Skeylup\OwlogsAgent\Breadcrumb; Breadcrumb::add('CreateProjectAction::execute'); Breadcrumb::add('ValidateBilling', 'plan=pro'); Breadcrumb::add('NotifyTeam');
Breadcrumbs are persisted as a breadcrumbs JSON array on every log entry of the request/job — so when something fails, you know exactly what led to it.
Auto-logging lifecycle events
Most lifecycle events are captured automatically out of the box. Flip any switch to false in .env to disable a category.
| Env var | Default | Event |
|---|---|---|
OWLOGS_AUTO_JOB_DISPATCHED |
true |
A job is queued |
OWLOGS_AUTO_JOB_STARTED |
true |
A worker picks up a job |
OWLOGS_AUTO_JOB_COMPLETED |
true |
A job completes successfully |
OWLOGS_AUTO_JOB_FAILED |
true |
A job fails (exception + attempt) |
OWLOGS_AUTO_JOB_RETRYING |
true |
A retry is requested |
OWLOGS_AUTO_AUTH_LOGIN |
true |
User logs in (email, IP, UA) |
OWLOGS_AUTO_AUTH_FAILED |
true |
Failed login attempt |
OWLOGS_AUTO_AUTH_LOGOUT |
true |
User logs out |
OWLOGS_AUTO_AUTH_PASSWORD_RESET |
true |
Password reset completed |
OWLOGS_AUTO_AUTH_VERIFIED |
true |
Email verified |
OWLOGS_AUTO_MAIL_SENT |
true |
Mail sending / sent |
OWLOGS_AUTO_NOTIFICATION_SENT |
true |
Notification dispatched |
OWLOGS_AUTO_NOTIFICATION_FAILED |
true |
Notification failed |
OWLOGS_AUTO_SLOW_QUERY |
true |
Queries slower than the threshold below |
OWLOGS_AUTO_SLOW_QUERY_MS |
500 |
Slow-query threshold in ms |
OWLOGS_AUTO_MIGRATION |
false |
Migration ran (opt-in — noisy on deploys) |
OWLOGS_AUTO_CACHE_HIT / OWLOGS_AUTO_CACHE_MISS |
false |
Cache events (opt-in — very high volume) |
OWLOGS_AUTO_HTTP_CLIENT |
true |
Outgoing HTTP client errors (>= 4xx) |
OWLOGS_AUTO_SCHEDULE |
false |
Scheduled task failed (opt-in) |
OWLOGS_AUTO_MODEL_CHANGES |
true |
Eloquent created / updated / deleted (scoped via model_changes_models) |
OWLOGS_AUTO_EVENT_DISPATCH |
true |
App-level events (excluding framework internals) |
Full configuration reference
All of the following can be overridden in config/owlogs.php after publishing.
Core
| Env var | Default | Description |
|---|---|---|
OWLOGS_ENABLED |
true |
Master kill-switch |
OWLOGS_API_KEY |
— | Workspace API key (sent as X-Api-Key) |
OWLOGS_AUTO_REGISTER_STACK |
true |
Auto-define the owlogs channel and append it to stack on boot |
OWLOGS_JSON |
true |
Use JsonFormatter (vs. LineFormatter) |
Transport (HTTP POST to Owlogs)
| Env var | Default | Description |
|---|---|---|
OWLOGS_INGEST_URL |
https://www.owlogs.com/api/owlogs/ingest |
Ingest endpoint |
OWLOGS_COMPRESSION |
true |
Gzip the request body before POSTing |
OWLOGS_QUEUE |
default |
Queue name for ShipBufferedLogsJob |
OWLOGS_QUEUE_CONNECTION |
— | Queue connection (null = app default) |
OWLOGS_TIMEOUT |
30 |
HTTP timeout in seconds |
OWLOGS_MAX_PAYLOAD_BYTES |
524288 (512 KB) |
Hard cap used by the ship job's chunker: any batch larger than this is split into multiple POSTs |
Ship debounce (queue → Owlogs)
Multiple flushes within the debounce window collapse to a single ShipBufferedLogsJob. The job drains up to batch_count rows per run, self-re-dispatches while the store still has rows.
| Env var | Default | Description |
|---|---|---|
OWLOGS_SHIP_DEBOUNCE_MS |
10000 |
Delay applied to each ship dispatch; also the effective de-duplication window |
OWLOGS_SHIP_BATCH_COUNT |
256 |
Max rows drained (and shipped) per ship-job run |
Buffer store (cross-process queue of pending rows)
| Env var | Default | Description |
|---|---|---|
OWLOGS_BUFFER_STORE |
redis |
redis, file, or memory |
OWLOGS_BUFFER_REDIS_CONNECTION |
default |
Redis connection name (from config/database.php) |
OWLOGS_BUFFER_REDIS_KEY |
owlogs:buffer |
List key used in Redis |
OWLOGS_BUFFER_FILE_PATH |
storage/app/owlogs/buffer.jsonl |
JSONL file path when buffer_store=file |
RAM flush policy (per-process)
| Env var | Default | Description |
|---|---|---|
OWLOGS_OCTANE_WINDOW_MS |
2000 |
Octane only: flush RAM when this many ms elapsed since the first buffered record |
OWLOGS_OCTANE_BATCH_COUNT |
20 |
Octane only: flush RAM when this many records are buffered |
OWLOGS_FLUSH_STRATEGY |
— | octane, end_of_request, or unset (auto-detect) |
OWLOGS_BATCH_SIZE |
50 |
Hard ceiling: RAM buffer is force-drained when it hits batch_size * 2 rows (runaway-loop protection) |
OWLOGS_MIN_FLUSH_INTERVAL_MS |
500 |
Deprecated. No longer read; kept to avoid breaking older .env files |
Measurement
| Env var | Default | Description |
|---|---|---|
OWLOGS_MEASURE_DB |
false |
Auto-instrument DB queries |
OWLOGS_MEASURE_MEMORY |
true |
Attach peak memory to each batch |
OWLOGS_N_PLUS_ONE_THRESHOLD |
5 |
Identical-SQL count to flag as N+1 |
Individual context fields can be toggled under config('owlogs.fields') if you want to opt out of e.g. ip or user_agent.
Ignoring noisy URIs
config('owlogs.ignored_uris') is a list of path patterns (matched with Str::is, so * is supported) whose logs are dropped before hitting the transport.
Laravel's broadcasting/auth endpoint fires on every websocket handshake and usually has no signal — it is ignored by default. Set OWLOGS_IGNORE_BROADCASTING=false to forward it again.
Add your own after publishing the config:
'ignored_uris' => [ 'broadcasting/auth', 'horizon/*', 'health', ],
| Env var | Default | Description |
|---|---|---|
OWLOGS_IGNORE_BROADCASTING |
true |
Append broadcasting/auth to ignored_uris |
Payload format
For transparency, here's what each flush POSTs to Owlogs:
{
"logs": [
{
"trace_id": "01JKXZ4…",
"span_id": "01JKXZ4…",
"origin": "http",
"level_name": "ERROR",
"level": 400,
"channel": "owlogs",
"message": "Payment declined",
"stacktrace": "Stripe\\Exception\\CardException: …",
"caller_file": "app/Http/Controllers/BillingController.php",
"caller_line": 87,
"caller_method": "BillingController@charge",
"uri": "POST https://app.example.com/billing/charge",
"route_name": "billing.charge",
"route_action": "App\\Http\\Controllers\\BillingController@charge",
"ip": "10.0.0.1",
"user_agent": "Mozilla/5.0 …",
"request_input": "{\"amount\":1000,\"currency\":\"EUR\"}",
"user_id": 42,
"app_name": "Example App",
"app_env": "production",
"app_url": "https://app.example.com",
"git_sha": "a1b2c3d4",
"job_class": null,
"job_attempt": null,
"queue_name": null,
"connection_name": null,
"duration_ms": 147,
"context": null,
"breadcrumbs": "[\"CreateOrderAction\",\"ChargeCard\"]",
"job_props": null,
"measures": "[{\"label\":\"stripe.charge\",\"duration_ms\":132.1}]",
"memory_peak_mb": 38,
"extra": "{\"user\":{\"email\":\"…\"}}",
"logged_at": "2026-04-16 10:11:12.345"
}
]
}
Expected responses:
2xx→ the agent moves on (server replies{"accepted": <count>})403(no active subscription) or429(quota exhausted) → the job fails immediately, no retry- Any other
4xx→ not retried (fix the key / payload) 5xxor network error → retried up to 3 times with backoff[5, 30, 120]seconds
Troubleshooting
Jobs pile up in the failed_jobs table. Check the exception: if it's 401 / 403, your OWLOGS_API_KEY is wrong or the key was rotated — regenerate it from your workspace and update .env.
Logs never arrive. Run php artisan queue:work — without a worker, the dispatched ShipBufferedLogsJob will never execute and rows pile up in the store. Also verify OWLOGS_API_KEY is set (empty key = silent no-op), and that LOG_CHANNEL=stack (or LOG_CHANNEL=owlogs) — if LOG_CHANNEL points to a non-stack channel (e.g. single), the auto-registered owlogs entry in stack is bypassed.
Too many ShipBufferedLogsJob in the queue. The debounce marker (Cache::add) needs a shared persistent cache. Check CACHE_STORE (Laravel 11+) / CACHE_DRIVER — if it's array, each PHP request has its own marker and no de-duplication happens. Switch to redis, file, database, or memcached.
Rows stuck in the buffer. Inspect the store directly:
# Redis redis-cli LLEN owlogs:buffer redis-cli LRANGE owlogs:buffer 0 0 # File wc -l storage/app/owlogs/buffer.jsonl
A non-zero LLEN with no ship job in the queue usually means the cache marker is stale (e.g. the previous ship job crashed before releasing it). Run php artisan cache:forget owlogs:ship:pending to clear it — the next flush will re-arm dispatch.
Octane complains about bindings. The agent does not use container / request / config injection in singletons. If you see such warnings, they come from elsewhere in your app.
Caller location is wrong. If your logs go through a custom wrapper class, add its path to config('owlogs.caller.ignore_paths') so the frame-walker skips over it.
Security
- Redaction is automatic for request body keys matching
password,password_confirmation,current_password,secret,token,key,authorization,cookie,credit_card. Extend the list inMiddleware/AddLogContext.phpif you need more. - HTTPS: traffic is sent over TLS to
https://www.owlogs.comwith Laravel's default HTTP client verification. - Authentication: every request carries the
X-Api-Keyheader. Rotate the key from your workspace and updateOWLOGS_API_KEYto invalidate. - No global state: all tracing IDs live in Laravel's
Contextwhich is reset between requests / jobs.