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.

Maintainers

Package info

github.com/cswni/lumina

pkg:composer/cswni/lumina

Transparency log

Statistics

Installs: 0

Dependents: 0

Suggesters: 0

Stars: 0

Open Issues: 0

dev-main 2026-08-12 21:23 UTC

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, and DETACH 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 BY over telemetry_logs, not a maintained summary table — fine at moderate volume; revisit if it becomes a bottleneck.

License

MIT