konzume/splunk-observability-sdk

Production-grade asynchronous observability SDK for Laravel that ships HTTP, database, exception, queue and custom telemetry to Splunk HEC.

Maintainers

Package info

github.com/bhullarap/splunk-observability-sdk

pkg:composer/konzume/splunk-observability-sdk

Statistics

Installs: 6

Dependents: 0

Suggesters: 0

Stars: 0

Open Issues: 0

v1.0.2 2026-05-28 20:33 UTC

This package is auto-updated.

Last update: 2026-06-01 16:21:33 UTC


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:

  1. Collecting every signal in an in-memory request-scoped context.
  2. Running the heavy serialization + queue dispatch in Laravel's terminate() phase — i.e. after the response has been sent to the client.
  3. 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 QueryExecuted event: 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 splunk log channel that correlates every line to the request's trace_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?

  • prepend puts the middleware first in the global stack — its handle() runs before every other middleware, so the captured duration spans the entire request (auth, body parsing, route dispatch, the controller, everything).
  • append would 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

Testing

composer install
vendor/bin/pest

Suite layout:

  • tests/Unit/ — sampler, sanitizer, truncator, context, HEC client framing
  • tests/Feature/ — middleware lifecycle, custom events, query collection
  • tests/Integration/ — Splunk downtime, retry classification
  • tests/Queue/ — export job retry semantics
  • tests/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.