konzume / splunk-observability-sdk
Production-grade asynchronous observability SDK for Laravel that ships HTTP, database, exception, queue and custom telemetry to Splunk HEC.
Package info
github.com/bhullarap/splunk-observability-sdk
pkg:composer/konzume/splunk-observability-sdk
Requires
- php: ^8.3
- ext-json: *
- ext-zlib: *
- guzzlehttp/guzzle: ^7.8
- illuminate/contracts: ^13.0
- illuminate/database: ^13.0
- illuminate/http: ^13.0
- illuminate/queue: ^13.0
- illuminate/support: ^13.0
- ramsey/uuid: ^4.7
Requires (Dev)
- mockery/mockery: ^1.6
- orchestra/testbench: ^10.0
- pestphp/pest: ^4.0
- pestphp/pest-plugin-laravel: ^4.0
README
A production-grade, fully asynchronous observability SDK for Laravel 13. Captures HTTP requests, database queries, exceptions, queue jobs and custom application events, batches them in-memory, and ships them to Splunk's HTTP Event Collector (HEC) from a queue worker — never on the request thread.
Designed to be a drop-in alternative to Sentry Performance / Datadog APM / New Relic / Bugsnag for teams that already operate Splunk.
Why an async pipeline
Telemetry SDKs that synchronously call out to a SaaS during a request can spike p99 latency by hundreds of milliseconds whenever the SaaS slows down or drops a packet. This package guarantees zero impact on response time by:
- Collecting every signal in an in-memory request-scoped context.
- Running the heavy serialization + queue dispatch in Laravel's
terminate()phase — i.e. after the response has been sent to the client. - Doing all Splunk HTTP I/O on a separate queue worker, with retries and exponential backoff handled by Laravel's queue layer.
Features
- HTTP request lifecycle: method, URL, route, headers, payload, response status/headers/body, IP, user agent, tenant id, authenticated user, duration, memory.
- Database queries via Laravel's
QueryExecutedevent: SQL, optional bindings, execution time, connection, slow-query detection. - Exceptions: stack traces, exception metadata, request + query context.
- Queue jobs: per-job lifecycle, attempts, duration, failures.
- Application logs: a
splunklog channel that correlates every line to the request'strace_id. - Outbound HTTP: every call made through Laravel's HTTP client (method, URL, status, duration, sizes).
- Mail: every email sent (subject, recipients, mailer, attachment count).
- Custom events:
Splunk::event(),Splunk::metric(),Splunk::trace(). - Sampling (head-based, deterministic on trace id).
- Sanitization of passwords, tokens, authorization headers, card data.
- Configurable payload truncation, query caps, batch sizes.
- Splunk HEC client with gzip compression, batching, retry classification.
- Pluggable exporter — Splunk HEC today, swap in Datadog, Elastic, Loki, OpenTelemetry or Kafka tomorrow.
- Redis-backed queue with dedicated queue name so observability traffic is isolated from app workloads.
Installation
composer require konzume/splunk-observability-sdk
The package auto-discovers — no manual provider registration needed.
Publish the config:
php artisan vendor:publish --tag=splunk-observability-config
This creates config/splunk-observability.php.
Register the middleware (required, Laravel 11+)
The SDK ships a single global middleware that owns the request lifecycle. You must register it in bootstrap/app.php — auto-discovery alone is not enough on the Laravel 11+ skeleton, and the middleware should run first so it wraps every other middleware (including auth, CORS, and exception handling). Use prepend:
// bootstrap/app.php use Konzume\SplunkObservability\Middleware\RequestLifecycleMiddleware; return Application::configure(basePath: dirname(__DIR__)) ->withRouting(/* ... */) ->withMiddleware(function (Middleware $middleware): void { $middleware->prepend(RequestLifecycleMiddleware::class); }) ->withExceptions(/* ... */) ->create();
Why prepend and not append?
prependputs the middleware first in the global stack — itshandle()runs before every other middleware, so the captured duration spans the entire request (auth, body parsing, route dispatch, the controller, everything).appendwould only measure from the SDK middleware onwards and miss most of the request.
This mirrors how Sentry's tracing, Datadog APM and New Relic install their request span — first in, last out.
Environment variables
Minimum config:
SPLUNK_OBS_ENABLED=true SPLUNK_OBS_EXPORTER=splunk_hec SPLUNK_OBS_QUEUE_CONNECTION=redis SPLUNK_OBS_QUEUE_NAME=splunk SPLUNK_HEC_URL=https://splunk.example.com:8088/services/collector SPLUNK_HEC_TOKEN=00000000-0000-0000-0000-000000000000 SPLUNK_HEC_INDEX=main SPLUNK_HEC_SOURCE=laravel SPLUNK_HEC_SOURCETYPE=_json SPLUNK_HEC_COMPRESSION=true SPLUNK_HEC_RETRIES=3
For local dev, set SPLUNK_OBS_EXPORTER=null to discard everything.
Architecture
HTTP request
↓
RequestLifecycleMiddleware (handle)
├─ ContextRepository.reset() / bootFromHeaders()
↓
Application controllers
├─ QueryCollector listens to QueryExecuted
├─ ExceptionCollector hooks Laravel exception handler
├─ Splunk::event() / Splunk::metric() / Splunk::trace()
↓
RequestLifecycleMiddleware (terminate, post-response)
├─ Sanitizer + PayloadTruncator
├─ Sampler decision
├─ SplunkManager.flush() → Event[]
↓
Queue (Redis, "splunk")
↓
ExportObservabilityBatchJob (worker)
↓
SplunkHecExporter → SplunkHecClient → Splunk HEC
Usage
Custom events:
use Konzume\SplunkObservability\Facades\Splunk; Splunk::event('payment_completed', [ 'tenant_id' => $tenantId, 'amount' => $amount, 'currency' => $currency, ]); Splunk::metric('checkout_time', $durationMs, ['provider' => 'stripe']); $result = Splunk::trace('payment_flow', function () use ($order) { return $paymentGateway->charge($order); });
Setting the tenant / user context manually if you don't use auth:
Splunk::setTenantId((string) $request->header('X-Tenant-Id')); Splunk::setUserId((string) $apiKey->user_id);
Correlated logs (the splunk channel)
The SDK auto-registers a splunk log channel. Anything you write to it is buffered into the current request and shipped — correlated by trace_id — alongside the request, its queries, HTTP calls and mail. Search one trace_id in Splunk and you see the whole story.
use Illuminate\Support\Facades\Log; Log::channel('splunk')->info('order placed', ['order_id' => $order->id]); Log::channel('splunk')->error('payment declined', ['gateway' => 'stripe', 'code' => $e->getCode()]);
To mirror every application log into Splunk, add splunk to your stack in config/logging.php:
'channels' => [ 'stack' => [ 'driver' => 'stack', 'channels' => ['single', 'splunk'], ], ],
Log context arrays are run through the same sanitizer as request payloads (so password, token, etc. are redacted). Levels below SPLUNK_OBS_LOG_LEVEL (default debug) are dropped.
Logs are buffered and flushed at the end of the request. A hard fatal (OOM, segfault) before
terminate()loses the buffer — for guaranteed delivery of a critical line, also write it to a durable channel (file/stderr) via a stack.
Outbound HTTP capture
Every call made with Laravel's HTTP client is captured automatically — no code changes:
use Illuminate\Support\Facades\Http; Http::withToken($token)->post('https://api.partner.com/charge', $payload); // → captured as an http_client event: method, URL (sanitized), status, duration, sizes
URLs are always sanitized (userinfo stripped, sensitive query params filtered). Headers and bodies are off by default — enable with SPLUNK_OBS_HTTP_CAPTURE_HEADERS=true / SPLUNK_OBS_HTTP_CAPTURE_BODIES=true only on trusted indexes. The SDK's own export to Splunk uses Guzzle directly, so it is never captured (no recursion).
Mail capture
Every email the app sends is captured (subject, recipients, mailer, attachment count — never the body):
Mail::to($user)->send(new OrderShipped($order)); // → captured as a mail event
Performance impact
The middleware's handle() phase only resets the context and reads request headers — measured at <0.5ms on a small Laravel API. All heavy work (sanitization, JSON serialization, queue push) happens in terminate(), after the client has the response. There is no synchronous HTTP I/O on the request thread.
Sampling
'sampling' => [ 'requests' => 1.0, // 100% of requests 'queries' => 1.0, 'exceptions' => 1.0, // never sample exceptions out 'queues' => 1.0, 'force_sample_on_error' => true, 'force_sample_when_slow' => true, ],
Sampling is deterministic on the trace id, so a trace that gets sampled in at the request level keeps all its child events.
Documentation
- Installation
- Configuration
- Kubernetes deployment
- Redis queue setup
- Horizon setup
- Splunk dashboards
- Splunk alerts
Testing
composer install vendor/bin/pest
Suite layout:
tests/Unit/— sampler, sanitizer, truncator, context, HEC client framingtests/Feature/— middleware lifecycle, custom events, query collectiontests/Integration/— Splunk downtime, retry classificationtests/Queue/— export job retry semanticstests/Performance/— throughput smoke tests
Extending — custom exporter
Implement src/Contracts/Exporter.php and bind it:
$this->app->singleton(\Konzume\SplunkObservability\Contracts\Exporter::class, MyDatadogExporter::class);
The export pipeline is exporter-agnostic — no other code changes are needed.