Skip to content
wiki edited this page Sep 4, 2026 · 1 revision

Events

The event bus lets application code and extensions observe the framework without being wired into it.

import rxevent "github.com/kryovyx/rextension/event"

app.EventBus().Subscribe(rxevent.EventTypeRouterRequestHandled, func(e rxevent.Event) {
	ev, ok := rxevent.As[rxevent.RouterRequestHandledEvent](e)
	if !ok {
		return
	}
	app.Logger().Info("%s %s → %d in %v", ev.Request.Method, ev.RoutePattern, ev.Status, ev.Duration)
})

Subscribe before Run, or from an extension's OnInitialize.

Router events

Constant Type string Fired when
EventTypeRouterInitialized router.initialized a router's trie is built
EventTypeRouterRouteRegistered router.route.registered a route is added
EventTypeRouterRequestIncoming router.request.incoming a request arrives, after matching
EventTypeRouterRequestHandled router.request.handled a request finishes
EventTypeRouterUnresolvedRequest router.request.unresolved no route matched — 404 or 405

Every payload embeds RouterEvent, so ev.Name() gives the router name.

RouterRequestHandledEvent

type RouterRequestHandledEvent struct {
	RouterEvent
	Request        *http.Request
	ResponseWriter http.ResponseWriter
	Duration       time.Duration

	Status       int    // what was actually answered
	BytesWritten int64
	RoutePattern string // "/users/{id}", BaseURL already stripped
}

The router wraps the ResponseWriter for matched routes so Status and BytesWritten are real. Status is 200 when the handler wrote a body without calling WriteHeader, matching net/http.

Use RoutePattern, never Request.URL.Path, as a metric label. Labelling by path makes every distinct URL its own time series, which for a parameterized route is unbounded and attacker-controlled: an unauthenticated client requesting /users/1, /users/2, … grows the registry without limit.

RoutePattern is empty when no route matched. Note that the 404, 405 and OPTIONS paths do not emit a handled event — they emit router.request.unresolved instead.

The delivery contract

The default bus has a 1024-event queue and 4 workers.

Emit never blocks. It is called from the request path, so blocking would let one slow subscriber apply backpressure to every request. When the queue is full, events are dropped.

Dispatch is asynchronous, on worker goroutines, in no guaranteed order relative to the emitting code.

Drops are countable:

if dc, ok := app.EventBus().(rxevent.DropCounter); ok {
	gauge.Set(float64(dc.Dropped()))
}

Type-assert rather than requiring it.

Do not build anything exact on events

This is the rule that matters, and it is why several things in this ecosystem are middleware rather than subscribers:

  • An in-flight request gauge cannot be a subscriber. It can lose an increment and its matching decrement independently, and the gauge drifts permanently. It belongs in middleware.
  • A route index cannot be built from router.route.registered. That event is delivered asynchronously and races the synchronous startup path, so a subscriber sees an arbitrary subset of routes. Extensions use per-route middleware or RouteValidator, both of which hand over the complete table at a defined moment.
  • Anything billed, audited or alerted on wants middleware.

Events are right for observation that tolerates loss: access logging, debug tracing, cache warming, an approximate dashboard counter.

Emitting your own

type CacheInvalidated struct {
	rxevent.BaseEvent
	Key string
}

app.EventBus().Emit(CacheInvalidated{
	BaseEvent: rxevent.NewBaseEvent(ctx, "cache.invalidated", "mycache"),
	Key:       key,
})

Use dot-delimited types prefixed with your own domain, matching the router.* convention.

Lifetime

The bus is created by New and closed at the very end of shutdown — after OnStop and OnShutdown, so a shutdown hook can still emit. Do not close it yourself.

Before v0.3.0 Close was called nowhere in the workspace, so every Rex instance leaked its four worker goroutines for the life of the process.

Clone this wiki locally