jblab/wide-events-bundle
Symfony bundle for wide events: accumulate rich contextual data throughout a request and dispatch it as a single structured event. Cuts event noise, simplifies logging and observability, and keeps handlers clean. Integrates with Symfony's event dispatcher and DI container.
This is a Symfony bundle (jblab/wide-events-bundle), not a Laravel package. There is no service provider, no config/wide-events.php, and no auto-discovery. In a Laravel codebase you consume the framework-agnostic core (WideEvent, WideEventContext, emitters, sampling, normalization, OpenTelemetry correlation) and hand-wire the pieces yourself; the http, messenger, monolog, and bundle components are Symfony-specific and will not run under Laravel's container without adaptation.
First steps:
WideEvent/WideEventContext, mutable context, emitters, normalization, sampling. That is the entire value proposition; everything else is framework glue you will be rewriting anyway.Log::info() calls with a single structured record.Minimum viable Laravel wiring:
// AppServiceProvider::register()
$this->app->scoped(WideEventContext::class, fn () => new WideEventContext());
$this->app->scoped(WideEvent::class, fn ($app) => new WideEvent($app->make(WideEventContext::class)));
// Middleware
public function handle($request, Closure $next)
{
$event = app(WideEvent::class);
$event->context()->set('http.method', $request->method());
$event->context()->set('http.route', $request->route()?->getName());
try {
$response = $next($request);
$event->context()->set('http.status', $response->getStatusCode());
return $response;
} catch (Throwable $e) {
$event->context()->set('exception.type', $e::class);
throw $e;
} finally {
// emit here or in a terminating callback
}
}
Request lifecycle middleware. Bind WideEventContext and WideEvent as scoped so each request (and each Octane request, if applicable) gets a fresh instance. Start the event in middleware, mutate it from controllers/services, finalize and emit in finally or via terminating(). Keep emission in terminating() where possible so streamed responses and post-response work still contribute context.
Context accumulation as the primary API. The mutable context is the day-to-day surface: set('db.queries', 12), increment('cache.misses'), set('user.plan', $user->plan). Treat it like a request-scoped array with a well-known key namespace. Establish conventions early (http.*, db.*, queue.*, app.*) or the emitted payload becomes unqueryable.
Exception deferral. The Symfony side defers exception telemetry rather than emitting immediately. Do the same: record the exception into the context, let the event finalize once, and avoid double-emitting when the exception handler also logs. One request should produce one wide event, not one per error.
Tail-based sampling. Sampling is decided from the finished event, not at start. That means you can guarantee retention of errors, slow requests, and specific users regardless of the base rate. Configure the base rate low (e.g. 1–5%) and add retention rules: any event with an exception, any event above a duration threshold, any event tagged with a business-critical key. This is the single biggest cost lever — the sampling config is where you should spend your tuning effort.
Slow-event retention uses outcome duration. The fix in this release means the duration used for the "keep slow events" decision is the measured outcome duration, not a partial/wall-clock guess. Set your threshold against real p95/p99 latency for the route, not a global number; otherwise you either retain everything or nothing.
Normalization and redaction as a pipeline stage. The package normalizes and truncates payloads and redacts sensitive fields before emission. In Laravel, register your own redaction keys (password, token, authorization, card_number, secret, anything from config('app.key')-adjacent settings) and set explicit limits on string length, array depth, and array item count. Do this in one place — a WideEventFactory or a decorator around the context — rather than scattering substr() calls.
Emitters are an interface, not a destination. The in-memory emitter is for tests and local inspection; the Monolog emitter writes structured records to a channel. In Laravel, prefer binding a custom emitter that writes to a dedicated wide_events log channel with a JSON formatter, so these records do not pollute application logs and can be routed to a collector independently.
Queue jobs. The Symfony Messenger middleware plus correlation stamp is the analog you need to reproduce. On dispatch, capture the current event's correlation/request ID and attach it as a job property or payload stamp; on handle, hydrate a new wide event carrying the same correlation ID, plus queue.job, queue.attempt, queue.connection. Without this, async work is invisible and you end up with orphaned events.
OpenTelemetry correlation. The OTel commit correlates wide events with the active trace. If you already run OTel in Laravel (open-telemetry/ext-open-telemetry or keepsuit/laravel-opentelemetry), pull trace_id/span_id from the current span and set them on the context so you can pivot between the wide event and the trace. If you do not, set a generated request_id and propagate it through headers, logs, and queued jobs — the request-ID propagation feature is independently useful.
Testing. The in-memory emitter makes assertions straightforward: bind the in-memory emitter in a test case, run the request with $this->get(...), then assert on emitted events — presence, key values, redaction of a known sensitive field, and the sampling decision for a forced-slow or forced-error event.
The bundle is not installable as a Laravel package. Do not add it to composer.json expecting auto-discovery. Either depend on the core library directly, or vendor/copy the core classes into your app. Everything in bundle/, http/, and messenger/ assumes Symfony's EventDispatcher, RequestStack, KernelEvents, and DI compiler passes.
Tail sampling forces you to hold the event in memory. The whole record must survive until the response is finalized, which is fine for scalar context but dangerous if you stash large payloads (full request bodies, query result sets). Store IDs and summaries, not objects, and let normalization truncate as a backstop rather than as a primary strategy.
Redaction is not a substitute for discipline. Path-based/field-name redaction misses secrets nested under unexpected keys or embedded in free-text strings. Never put raw request bodies or headers into the context by default; add specific headers to an allowlist.
Normalization silently truncates. Truncation happens at emission, so debugging code that inspects the context before finalization will show data that never reaches your logs. If a field looks empty downstream, check length/depth/item limits before assuming the emitter dropped it.
Sampling decisions happen once, at the end. You cannot "upgrade" to retained mid-request. If an event becomes interesting after a terminal emit (e.g., in a terminating callback that fires late), it is already gone. Ensure emission is the very last step.
Slow-event duration semantics. Because retention now keys off outcome duration, events that failed early may not cross a latency threshold even though they are important. Pair the duration rule with an explicit "always retain on exception" and "always retain on non-2xx/3xx status" rule.
Exception deferral can mask ordering. If you defer telemetry to termination, the context at emit time may include keys set after the exception — sometimes useful, sometimes misleading. Stamp the exception with its own timestamp/duration and record the phase it occurred in.
Scoped bindings, not singletons. Binding WideEvent as a singleton will leak context across requests under Octane and across jobs under the queue worker. Use scoped and reset explicitly in App\Jobs\Middleware or a JobProcessed listener.
Queue correlation must be manual. There is no Laravel equivalent of the Messenger stamp in this release; you own serialization. Attach correlation IDs in the Job's payload via a dispatching helper and rehydrate in the job constructor or handle().
Component isolation is an architectural hint. The isolation-boundaries commit implies the core is meant to stand alone without Symfony. That is good news for Laravel adoption, but it also means no Laravel-side integration layer is coming from this project — budget for it as in-house glue, and keep it thin so you can swap to a first-party Laravel package later.
Extension points worth owning. Emitter interface (swap Monolog for a collector client), normalizer/redactor (add app-specific rules), and sampler (add business-critical retention predicates). These three are the seams most likely to need per-application behavior; wrap each in a Laravel binding rather than forking the core.
How can I help you explore Laravel packages today?