cswni / lumina
Non-blocking Laravel observability instrumentation (HTTP, DB queries, queue jobs, exceptions, outbound HTTP, cache) backed by native PostgreSQL partitioning, JSONB, and full-text search.
Requires
- php: ^8.3
- illuminate/console: ^11.0|^12.0|^13.0
- illuminate/contracts: ^11.0|^12.0|^13.0
- illuminate/database: ^11.0|^12.0|^13.0
- illuminate/http: ^11.0|^12.0|^13.0
- illuminate/support: ^11.0|^12.0|^13.0
Requires (Dev)
- laravel/pint: ^1.27
- orchestra/testbench: ^9.0|^10.0
- phpunit/phpunit: ^11.0|^12.0
This package is auto-updated.
Last update: 2026-08-10 21:24:06 UTC
README
Non-blocking Laravel observability instrumentation — HTTP requests, DB queries (with N+1 detection), queue jobs, exceptions (grouped, Sentry-style), outbound HTTP calls, and cache operations — stored in PostgreSQL using native partitioning, JSONB, BRIN indexes, and generated tsvector full-text search columns.
This package is the extracted instrumentation layer from Laravel Lumina, the standalone observability platform (Filament dashboard + Go MCP server). Install this package into any Laravel app to start collecting telemetry.
Deployment modes
There are two ways to run this package, controlled by LUMINA_MODE:
| Mode | What it does | When to use it |
|---|---|---|
local (default) |
Watchers write telemetry directly into this app's own Postgres via the bundled telemetry_* migrations. |
Standalone use, no separate dashboard — you'll query the tables yourself or run your own Lumina instance against this same database. |
remote |
Watchers buffer the same data and ship it over HTTP to a central Lumina dashboard's /api/v1/telemetry/ingest endpoint. No local telemetry_* tables are created — this app doesn't need Postgres at all for telemetry. |
The common case: N separate Laravel apps all pointing at one shared Lumina dashboard, each app kept visibly separate as its own "Project" (tenant) in the dashboard. |
For remote mode:
LUMINA_MODE=remote
LUMINA_REMOTE_URL=https://lumina.your-domain.test
LUMINA_REMOTE_API_KEY=lumina_live_... # from Filament → Projects → (your project) → API Key
The API key is generated once per Project in the dashboard's Filament panel (Administration → Projects) and identifies which tenant this app's data belongs to — it is never configured or referenced on this side beyond that env var. Skip the "Migrate" step below entirely in remote mode; there's nothing local to migrate.
Requirements
- PHP 8.3+
- Laravel 11, 12, or 13
- PostgreSQL 14+ — local mode only. The bundled schema uses
PARTITION BY RANGE,JSONB, generated columns, andDETACH PARTITION CONCURRENTLY, and does not support MySQL/SQLite. Remote mode needs no local telemetry storage at all — any DB Laravel supports is fine for the app's own unrelated needs. - Redis (or another real queue driver) recommended in both modes — the buffer/flush (and remote push) design assumes an async queue. It works on
sync, but then flushing/pushing happens inline on the request thread, defeating the point.
Installation
composer require cswni/lumina
The service provider auto-registers via Laravel package discovery. Then:
1. Configure
php artisan vendor:publish --tag=lumina-config
This publishes config/observability.php and config/telemetry.php. Set the relevant env vars (see Configuration below) — most have sensible defaults.
2. Migrate — local mode only
Skip this step entirely if LUMINA_MODE=remote (see Deployment modes above) — there are no local tables to create. Otherwise, the package auto-loads its migrations (no publish step required) — just run:
php artisan migrate php artisan telemetry:partition:create
telemetry:partition:create pre-creates the next 7 days of partitions so writes never fall through to the DEFAULT partition in normal operation. Schedule it (and telemetry:prune, telemetry:rollup) — see Scheduling.
If you'd rather own the migration files directly (e.g. to add your own indexes), publish instead of relying on auto-loading:
php artisan vendor:publish --tag=lumina-migrations
3. Wire the terminate-phase middleware — required manual step
Laravel 11+ removed app/Http/Kernel.php, so a package cannot append itself to your global middleware stack automatically. Add this one line to your own bootstrap/app.php:
use Cswni\Lumina\Http\Middleware\ObservabilityTerminate; ->withMiddleware(function (Middleware $middleware): void { $middleware->append(ObservabilityTerminate::class); })
This is what captures the root HTTP span and flushes the buffer after the response is sent — without it, request/query/N+1 telemetry won't be recorded (job, exception, outbound HTTP, and cache watchers still work without this step, since they hook into events directly).
That's it. Everything else — query watching, job watching, exception grouping, outbound HTTP capture, cache hit/miss tracking — registers itself automatically.
What gets captured
| Source | Table | Notes |
|---|---|---|
| HTTP requests | telemetry_spans (kind='http') |
Root span per request: method, route, status, duration, memory peak, DB query count, IP, user agent, user ID |
| DB queries | telemetry_spans (kind='db') |
SQL, normalized pattern, binding count, slow flag (>100ms default) |
| N+1 patterns | telemetry_events (n_plus_one_detected) |
Same normalized query executed >5 times (default) in one request |
| Queue jobs | telemetry_events (job_dispatched/job_completed/job_failed/job_exception_occurred) |
Attempts, duration, exception details |
| Exceptions | telemetry_logs |
Grouped by `sha256(class |
| Outbound HTTP | telemetry_spans (kind='external_http') |
Method, host, path, status — query strings deliberately redacted |
| Cache ops | telemetry_events (cache_hit/cache_miss/cache_write/cache_forget) |
Key + store name |
All spans/logs/events sharing one request or job carry the same correlation_id, so you can join across tables to reconstruct a full request timeline.
Configuration
Key env vars (see the published config files for the full list):
OBSERVABILITY_ENABLED=true # 'local' (default) or 'remote' — see Deployment modes above LUMINA_MODE=local # Only used when LUMINA_MODE=remote LUMINA_REMOTE_URL= LUMINA_REMOTE_API_KEY= LUMINA_REMOTE_TIMEOUT=5 TELEMETRY_QUEUE=telemetry TELEMETRY_SLOW_QUERY_MS=100 TELEMETRY_N_PLUS_ONE_THRESHOLD=5 TELEMETRY_RETENTION_SPANS_DAYS=30 TELEMETRY_RETENTION_LOGS_DAYS=90 TELEMETRY_RETENTION_METRICS_DAYS=180 TELEMETRY_RETENTION_EVENTS_DAYS=30 # Only needed for this app's own inbound ingest endpoint (local mode only, see below) TELEMETRY_API_KEY= # Toggle individual watchers OBSERVABILITY_WATCH_QUERY=true OBSERVABILITY_WATCH_JOB=true OBSERVABILITY_WATCH_EXCEPTION=true OBSERVABILITY_WATCH_HTTP_CLIENT=true OBSERVABILITY_WATCH_CACHE=true
Scheduling
Add to routes/console.php:
use Illuminate\Support\Facades\Schedule; Schedule::command('telemetry:partition:create')->dailyAt('00:05')->withoutOverlapping()->onOneServer(); Schedule::command('telemetry:prune')->dailyAt('00:20')->withoutOverlapping()->onOneServer(); Schedule::command('telemetry:rollup --view=telemetry_metrics_rollup_1m')->everyMinute()->withoutOverlapping()->onOneServer(); Schedule::command('telemetry:rollup --view=telemetry_metrics_rollup_1h')->everyTenMinutes()->withoutOverlapping()->onOneServer(); Schedule::command('telemetry:rollup --view=telemetry_metrics_rollup_1d')->hourly()->withoutOverlapping()->onOneServer();
This app's own inbound ingest endpoint (OTLP-adapter path, local mode only)
Not to be confused with LUMINA_MODE=remote above (which is about this app sending telemetry out) — this section is about this app receiving telemetry from something else, when running in local mode.
POST /api/v1/telemetry/ingest accepts a batch JSON payload (spans/logs/metrics/events arrays) gated by an X-Telemetry-Key header matching TELEMETRY_API_KEY. This exists for out-of-process senders (a separate service, a CLI script, a future real-OTLP adapter) — in-process watchers never call it; they write directly via the buffer/flush path.
curl -X POST https://your-app.test/api/v1/telemetry/ingest \ -H "X-Telemetry-Key: $TELEMETRY_API_KEY" \ -H "Content-Type: application/json" \ -d '{"logs":[{"level":"error","message":"hello from ingest","timestamp":"2026-01-01T00:00:00Z"}]}'
Artisan commands
| Command | Purpose |
|---|---|
telemetry:partition:create [--days-ahead=7] |
Pre-create upcoming partitions |
telemetry:prune [--dry-run] |
Drop partitions past their retention window |
telemetry:rollup [--view=...] |
Refresh telemetry_metrics_rollup_{1m,1h,1d} materialized views |
Testing
composer install vendor/bin/phpunit
The suite runs against Orchestra Testbench and needs no external services — it covers the pure logic (query normalization, N+1 detection, exception hashing, the DTO/buffer layer). It does not cover the Postgres-specific migrations/schema; test those against a real Postgres instance in your own app.
Design notes / known simplifications
- No real OTLP/gRPC — the ingest endpoint accepts a simple internal JSON batch schema with OTel-inspired field names, not protobuf OTLP.
- Spans have no natural idempotency key — retried flush jobs can produce duplicate spans (accepted tradeoff; logs are retry-safe via a client-generated ULID and
INSERT ... ON CONFLICT DO NOTHING). - No global uniqueness of
(trace_id, span_id)across partitions — a Postgres partitioning characteristic (per-partition IDENTITY sequences), not a bug. - Exception grouping via a live
GROUP BYovertelemetry_logs, not a maintained summary table — fine at moderate volume; revisit if it becomes a bottleneck.
License
MIT