gobridge

Programmatic API: Delivery Hooks, Builder and Lifecycle

Embedding GoBridge in Go code: delivery hooks, the programmatic builder, and runtime lifecycle notes. Split out of Routes, Runtime & Validation Reference, which is the declarative configuration reference.

Delivery Hooks (Programmatic API)

Delivery hooks are registered programmatically via the builder or runtime options – they are not configured in YAML. A hook observes message lifecycle events; it cannot modify the message or change the settlement outcome (the callbacks have no return value the runtime acts on). It is not free: hooks run synchronously on the delivery goroutine, so a slow or blocking hook directly adds delivery latency and can stall the route. A panic in OnAttempt/OnSettled is contained by an internal recover (counted on the delivery-panic metric with reason=hook and logged) so it never alters settlement or produces a duplicate – but keep hooks fast and non-blocking rather than relying on that.

Registration

hook := &myAuditHook{}

rt, err := bridge.NewBuilder(cfg, bridge.WithLogger(logger)).
    RegisterTransportFactory("mqtt", paho.NewFactory(logger)).
    RegisterStoreFactory("memory", nativestore.NewMemoryStoreFactory()).
    RegisterDeliveryHook(hook).
    Build(ctx)

Or at the runtime level:

rt := runtime.New(
    runtime.WithDeliveryHook(hook),
    // ... other options
)

Interface

type DeliveryHook interface {
    OnAttempt(ctx context.Context, evt DeliveryAttempt)
    OnSettled(ctx context.Context, evt DeliveryOutcome)
}

When hooks fire

Event Direction When Fields
OnAttempt ingress Every time a message is received from a source transport RouteID, Envelope, Attempt=1
OnAttempt egress Every send attempt (DirectHold) or drain attempt (SharedOutbox) RouteID, BindingID, Envelope, Attempt, MaxAttempts, Err
OnSettled egress Delivered successfully (DirectHold send or SharedOutbox drain) Err=nil, Terminal=true
OnSettled egress DirectHold send or SharedOutbox drain failed permanently – DLQ/drop Err set, Terminal=true
OnSettled egress DirectHold send or SharedOutbox drain hit the replay cap (poison) – DLQ/drop Err set, Terminal=true
OnSettled ingress Permanent processor/resolve failure – DLQ/drop Err set, Terminal=true
OnSettled ingress Replay cap reached on the processor/resolve/outbox-build path (poison) – DLQ/drop Err set, Terminal=true
OnSettled ingress Message filtered by a processor – drop/DLQ Err=ErrMessageFiltered, Terminal=true
OnSettled ingress Message dropped (retry unsupported, no DLQ) Err set, Terminal=true
OnSettled ingress Message expired before send Err=ErrMessageExpired, Terminal=true

Terminal Direction reflects where the message settled. Outcomes on the send path – a successful send, or a DirectHold send or SharedOutbox drain that failed permanently or hit the replay cap – are stamped egress. Outcomes that settle at the source boundary before or without a successful egress hop – expired, filtered, a permanent processor/resolve error, a retry-unsupported drop, or a replay-cap poison on the processor/resolve/outbox-build path – converge through the runtime’s settleTerminal and are stamped ingress. Dashboards and audit rules that key on Direction must expect ingress for these, not egress.

OnAttempt fires on every attempt including retries. OnSettled fires after the message reaches a terminal state — for the SharedOutbox path, after the terminal store transition Completes. A failed Complete re-claims the record and defers the hook to the successful retry, so OnSettled never double-fires; conversely a crash in the window between a durable Complete and the hook can skip it for that one record (the settlement itself stays durable). Treat it as at-most-once per completed record, not exactly once.

Event structs

Thread safety

Hook methods may be called concurrently from multiple delivery goroutines. Implementations must be safe for concurrent use. Hooks are called synchronously on the delivery goroutine – a slow hook directly increases delivery latency.

Hooks vs Processors

Hooks and processors serve different purposes:

Concern Processor Hook
Can mutate the envelope Yes No
Can short-circuit the pipeline Yes No
Called per attempt or per message Per message (before send) Per attempt and on final outcome
Registration Config YAML (processors:) Programmatic (RegisterDeliveryHook)
Use case Filtering, transformation, enrichment Audit logging, observability, external notification

Example: audit logging hook

type auditHook struct {
    logger *slog.Logger
}

func (h *auditHook) OnAttempt(ctx context.Context, evt ports.DeliveryAttempt) {
    if evt.Direction == ports.DirectionEgress && evt.Err != nil {
        h.logger.Warn("egress attempt failed",
            "route", evt.RouteID,
            "binding", evt.BindingID,
            "envelope_id", evt.Envelope.ID,
            "attempt", evt.Attempt,
            "max_attempts", evt.MaxAttempts,
            "error", evt.Err,
        )
    }
}

func (h *auditHook) OnSettled(ctx context.Context, evt ports.DeliveryOutcome) {
    level := slog.LevelInfo
    if evt.Err != nil {
        level = slog.LevelError
    }
    h.logger.Log(ctx, level, "delivery settled",
        "route", evt.RouteID,
        "binding", evt.BindingID,
        "envelope_id", evt.Envelope.ID,
        "attempts", evt.Attempt,
        "error", evt.Err,
    )
}

Programmatic Builder & Lifecycle Notes

These affect the Go composition root (bridge.Builder / bridge.Supervisor), not the YAML shape, but they change when and how config errors surface: