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

Events

type Event interface {
	Type() string
	Context() context.Context
}

type EventBus interface {
	Subscribe(eventType string, handler EventHandler)
	Emit(event Event)
	SetLogger(logger BusLogger)
	Close()
}

Import github.com/kryovyx/rextension/event directly; do not depend on rex/event.

Subscribing

Subscribe from OnInitialize, and use event.As to recover the concrete type:

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

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

Router events

Constant Type string Emitted when
EventTypeRouterInitialized router.initialized a router's trie is built
EventTypeRouterRouteRegistered router.route.registered a route is added to a router
EventTypeRouterRequestIncoming router.request.incoming an HTTP request arrives
EventTypeRouterRequestHandled router.request.handled a request finishes handling
EventTypeRouterUnresolvedRequest router.request.unresolved no route matched

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

RouterRequestHandledEvent

The richest payload, and the one worth reading carefully:

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

	Status       int    // captured by a wrapper the router installs
	BytesWritten int64
	RoutePattern string // "/users/{id}", not "/users/42" — BaseURL stripped
}

Status is 200 when the handler wrote a body without calling WriteHeader, matching what net/http does.

RoutePattern is what a metric label must use. Labelling by Request.URL.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 metric registry without limit.

It is empty when no route matched.

RouterRouteRegisteredEvent

type RouterRouteRegisteredEvent struct {
	RouterEvent
	Route   Route  // Method + Path
	BaseURL string // normalised: no trailing slash, "" for the root
}

BaseURL is carried because a subscriber that needs the URL a route is served at has to combine Path with the prefix its router mounts it under, and the route does not know that prefix.

The delivery contract

Emit never blocks. Emit is called from the request path, so blocking would let one slow subscriber apply backpressure to every request. The default bus has a bounded queue and a worker pool; when the queue is full, events are dropped.

Dispatch is asynchronous. Handlers run on worker goroutines, in no guaranteed order relative to the code that emitted.

Drops are observable:

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

Type-assert rather than requiring DropCounter — a bus that never drops has no reason to implement it.

The corollary: do not build anything exact on events

Because delivery is lossy and asynchronous:

  • An in-flight request gauge cannot be an event 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 races the synchronous startup path, so an extension collecting routes from it sees an arbitrary subset. Use PerRouteMiddleware or RouteValidator, both of which hand you the complete table.
  • Anything billed, audited or alerted on wants middleware, not a subscriber.

Events are right for observation that tolerates loss: access logging, debug tracing, cache warming, a dashboard counter that is allowed to be approximate.

Emitting your own

type CacheInvalidated struct {
	rxevent.BaseEvent
	Key string
}

func NewCacheInvalidated(ctx context.Context, key string) CacheInvalidated {
	return CacheInvalidated{
		BaseEvent: rxevent.NewBaseEvent(ctx, "cache.invalidated", "mycache"),
		Key:       key,
	}
}

r.EventBus().Emit(NewCacheInvalidated(ctx, key))

Use dot-delimited type strings, prefixed with your extension's domain, matching the router.* convention.

Lifetime

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

Clone this wiki locally