Observer Pattern¶
See also:
statspackage on pkg.go.dev · Guide: Using the Observer Pattern · http-trace-span-propagation exampleRunnable demos:
examples/stats-observer·examples/rest-api·examples/events-api·examples/flat-key-patch·examples/sensor-service— multi-adapter observer across HTTP, MQTT/zeromq, and SQL in one fanout
The observer pattern is go-codex's unified observability layer across all layers of the library: codecs, adapters, formats (files), forge, and SQL. It provides structured hooks for three observability signals — metrics, logging, and distributed tracing — with no library dependency.
The user decides which signals to use (any, all, or none) and implements the corresponding interfaces. Implementations are fully swappable between development stubs, Prometheus counters, OTel tracing, or any other backend.
The nine observer interfaces¶
| Interface | Signal | Methods | Layer |
|---|---|---|---|
stats.ValidationObserver |
metrics + logging | RecordValidationError(loc, constraint, field) |
Codecs — direct codec calls |
stats.Observer |
metrics + logging | embeds ValidationObserver + RecordRequest, RecordSubscribe, RecordPublish |
Adapters — HTTP, MQTT, MCP |
stats.PipelineObserver |
metrics + logging | RecordApply(name, version, success, dur) |
Forge — computation pipelines |
stats.FileObserver |
metrics + logging | RecordFileRead · RecordFileWrite |
Formats — file I/O |
stats.SecurityObserver |
metrics + logging | RecordSecurityRejection(location, scheme) |
Adapters — security rejection events |
stats.SQLObserver |
metrics + logging | RecordValidation(table, op, dur, err) · RecordMigration(op, name, version, dur, err) |
SQL Adapter — row validation + migrations |
stats.StreamObserver |
metrics + logging | RecordStreamItem(function, success, dur) |
Stream — per-item throughput via stream.Apply |
stats.CacheObserver |
metrics + logging | RecordCacheHit(key, dur) · RecordCacheMiss(key, dur) · RecordCacheWrite(key, op, success, dur) |
Redis Adapter — cache lifecycle events |
stats.TraceObserver |
distributed tracing | StartSpan(ctx, operation, name) ctx · EndSpan(ctx, err) |
Adapters, forge, file, SQL, stream — spans |
Every interface is optional. Implement only what you need.
Built-in types¶
| Type | Implements | Purpose |
|---|---|---|
stats.NoopObserver |
all nine | Zero-cost default — every Option field defaults to this |
stats.LoggingObserver |
all eight except TraceObserver | Logs every event via slog — configure handler for dev/prod/OTel |
stats.NewFanout(observers...) |
all nine | Fans out to multiple observers — compose metrics + logging + tracing |
Composition¶
Pass a single stats.NewFanout value to every layer. No type assertions needed at call sites:
obs := stats.NewFanout(metrics, stats.NewLoggingObserver(logger), tracer)
// Same value — works on every layer:
stats.ReportErrors(obs, "config", err) // codec
route.WithHandler(handler).WithOptions(nethttp.Options{Observer: obs}) // adapter
configFile.Read(nil, ports.FileOptions{Observer: obs}) // format/file
forge.NewRegistry("P", "1.0.0").WithObserver(obs) // forge
Context propagation for trace spans¶
TraceObserver.StartSpan returns a context.Context that carries the active span. Adapters pass this context to the application handler, enabling parent-child span relationships:
HTTP client: client.Call(ctx, ...) → traceparent header
Downstream: handler(ctx, req) → ctx carries incoming span
├─ forge: ApplyContext(ctx, in) → child of HTTP span
└─ file: FileOptions{Context: ctx} → child of HTTP span
Server adapters (nethttp, chi, mqtt) always pass the incoming ctx to StartSpan. They do not detect whether a parent span exists — the TraceObserver implementation decides:
- When a parent span is present (e.g. extracted from an HTTP
traceparentheader by OTel middleware) → child span. - When no parent is present (
context.Background(), direct test call) → root span.
// OTel — parent decision handled automatically by the SDK:
func (t *OTelTracer) StartSpan(ctx context.Context, op, name string) context.Context {
ctx, span := otel.Tracer("go-codex").Start(ctx, op, // parent or nil from ctx
otel.WithAttributes(attribute.String("name", name)),
)
return ctx
}
The library provides the hook; the user's implementation controls span parenting.
All adapter entry points accept context.Context:
- rest.Client.Call(ctx, ...)/rest.CallWithTransport(ctx, ...) — HTTP client, propagates downstream
- rest.Server.Serve(ctx)/nethttp.ServeOne — HTTP server, ctx from *http.Request.Context()
- mqtt.NewPublishTransport[T](...).Publish(ctx, ...) (via events.PublishHandle) — MQTT publish
- mqtt.NewSubscribeTransport[T](...).Subscribe(ctx, ...) (via events.SubscribeHandle) — MQTT subscribe, ctx flows to handler
To propagate into forge and file:
- Use forge.Function.ApplyContext(ctx, in) instead of Apply(in)
- Set ports.FileOptions.Context to the handler's context
Per-layer behavior¶
| Layer | How the observer is injected | Events emitted |
|---|---|---|
| Codec | stats.ReportErrors(obs, location, err) |
RecordValidationError per failing field |
| Adapter (HTTP/MQTT/MCP) | Options{Observer: obs} (or ctx default) |
RecordRequest/RecordSubscribe/RecordPublish, validation errors, security rejections, trace spans |
HTTP client (nethttp.Call, CallHandle) |
CallOptions{Observer: obs} (or ctx default) |
RecordRequest (status 0 = pre-flight validation failure, no HTTP call sent), validation errors, security rejections, trace spans |
| Format (file I/O) | FileOptions{Observer: obs} |
RecordFileRead/RecordFileWrite, validation errors, trace spans |
| Forge | Registry.WithObserver(obs) |
RecordApply per function call, trace spans |
| Stream | ApplyOptions{Observer: obs} (or ctx default) |
RecordStreamItem per item via stream.Apply, trace spans |
| SQL Adapter | ValidateOptions{Observer: obs} (or ctx default) |
RecordValidation, RecordMigration, trace spans |
| Redis Adapter (cache) | ctx default (stats.ObserverFromContext) |
RecordCacheHit/RecordCacheMiss/RecordCacheWrite |
Default observer via context¶
Instead of passing Observer: obs on every call site, attach an observer once to a
context.Context and let every adapter pick it up automatically when no explicit
observer is configured:
obs := stats.NewFanout(metricsObserver, stats.NewLoggingObserver(slog.Default()))
ctx := stats.WithObserver(context.Background(), obs)
// All adapters now use obs when Options.Observer is nil:
events.SubscribeHandle(ctx, sub, mqtt.NewSubscribeTransport[T](client, mqtt.SubscribeOptions{}), fn)
stream.Apply(ctx, s, fn, stream.ApplyOptions{})
route.WithOptions(nethttp.Options{}) // resolved per-request (see below)
API¶
// WithObserver returns a copy of ctx carrying obs as the default observer.
func stats.WithObserver(ctx context.Context, obs Observer) context.Context
// ObserverFromContext retrieves the stored observer, or returns NoopObserver{} if none.
func stats.ObserverFromContext(ctx context.Context) Observer
Precedence¶
opts.Observer != nil → use opts.Observer (explicit — highest priority)
opts.Observer == nil → use stats.ObserverFromContext(ctx) (context default)
→ returns NoopObserver{} if nothing stored
Explicit always wins. Per-component overrides continue to work:
How each layer resolves the context observer¶
| Layer | ctx source | Resolution |
|---|---|---|
HTTP adapters (nethttp.Serve/ServeOne/ServeSSE, chi mirrors) |
r.Context() per-request |
Resolved inside the request closure — a server middleware can inject per-request observers |
HTTP client (Client.Call/Client.Consume, CallWithTransport, CallSSEAdapter) |
ctx passed to function | Resolved at call time — SAME mechanism as MQTT/ZeroMQ below; see the callout after this table for what this means for client wrapper packages |
SSE stream bridges (SSEFromStream, SSEFromHub) |
ctx from each SSE connection | Resolved inside the per-connection closure |
MQTT adapters (Subscribe, Publish) |
ctx passed to function | Resolved at call time |
ZeroMQ adapters (Subscribe, Publish, Serve, Call) |
ctx passed to function | Resolved at call time |
MCP adapters (ToolHandler, ResourceHandler, PromptHandler) |
ctx from each tool/resource/prompt call | Resolved inside the per-call closure |
Stream bridges (stream.Apply, stream.FromCodec, stream.Drain) |
ctx passed to function | Resolved at call time |
ports.File (Read, Write, Update) |
FileOptions.Context |
Resolved from opts.Context when non-nil |
forge.Registry |
not applicable | No context integration — uses explicit .WithObserver(obs) builder |
sql.Validate |
not applicable | No ctx parameter — falls back to NoopObserver{} only |
Client wrapper packages inherit this for free. Any package that builds its own typed client on top of
rest.Client.Call/CallWithTransportinternally (a generated API client, a registry client, an SDK, etc.) automatically supportsstats.WithObserver(ctx, obs)with zero extra code, as long as it doesn't hard-code a non-nil default into its ownCallOptions.Observer. A caller gets fullRecordRequestmetrics for every underlying HTTP call the wrapper makes just by attaching an observer to thectxit passes through — no bespokeWithObserveroption required on the wrapper's own API (though adding one, mirroringnethttp.CallOptions.Observer's own explicit-overrides-context precedence, is still a reasonable convenience for a per-call override). Seeexamples/go-edge-models'sdocker/registrypackage for a concrete example of both: the ctx-based path works out of the box, and it ALSO offers an explicitregistry.WithObserver(obs)option for one-off overrides.
HTTP middleware pattern¶
For HTTP servers, set the observer per-request via a middleware:
// Server-side middleware — injects obs into every request's context:
func ObserverMiddleware(obs stats.Observer) func(http.Handler) http.Handler {
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
ctx := stats.WithObserver(r.Context(), obs)
next.ServeHTTP(w, r.WithContext(ctx))
})
}
}
// Wire:
mux := http.NewServeMux()
route.WithOptions(nethttp.Options{}) // no explicit Observer
http.ListenAndServe(":8080", ObserverMiddleware(obs)(mux))
For per-service (not per-request) wiring, pass nethttp.Options{Observer: obs} directly — this is simpler and slightly cheaper (no per-request context lookup).
For per-adapter code examples, OpenTelemetry tracing, Prometheus wiring, location values, and a full end-to-end walk-through, see the Guide: Using the Observer Pattern.
api/reqreply.Observability — a shipped, declarative observer wrapper¶
api/reqreply ships its own general-purpose observer wrapper —
reqreply.Observability[Req, Resp],
mirroring adapters/nethttp.Observability (REST) and
adapters/zeromq.Observability/adapters/mqtt5.Observability (pub/sub).
It attaches via the existing general-purpose (unpaired)
.HandleMW(nil, fn)/.ClientMW(nil, fn) middleware-implementation
mechanism (see
Feature: Codec-Declared Middleware) —
no new attachment API, just an observability fn attached the same way a
security implementation is:
// Server-side (zeromq): HandleMW(nil, ...) — unpaired, runs
// unconditionally, composes freely alongside a paired security HandleMW
// on the same route.
route.HandleMW(nil, reqreply.Observability[Req, Resp](obs)).WithHandler(fn).Register(server)
// Client-side (zeromq or mqtt5): ClientMW(nil, ...) — the SAME
// implementation, reused unchanged across both adapters.
observedRoute := route.ClientMW(nil, reqreply.Observability[Req, Resp](obs))
Observability lives in the CORE api/reqreply package, not an
adapter — unlike its REST/pub-sub siblings, ONE implementation covers
every attachment point that has a context.Context to work with:
zeromq's server-side AND client-side general decorators, plus mqtt5's
client-side one, all share this exact generic shape. It injects obs
into ctx via stats.WithObserver (so a paired security Fn, or any
downstream code, can resolve the SAME observer via
stats.ObserverFromContext) and drains stats.DiagnosticsFromContext
into RecordValidationError after the wrapped handler returns. It
deliberately does not call RecordRequest itself, nor start a
TraceObserver span — every reqreply adapter transport
(adapters/mqtt5's and adapters/zeromq's Serve/Call dispatch) already
does both, unconditionally, on every code path; calling them again here
would double-count.
mqtt5's server side has no equivalent attachment point: its
server-side general decorator wraps the raw, pre-decode
*pahomqtt5.Publish handler, which has no ctx parameter to inject an
Observer into. reqreply.Server.Serve(ctx) (dispatching through an
attached mqtt5.NewServerTransport) already resolves whichever
Observer is present in the ctx it was called with (via
stats.ObserverFromContext) for its own dispatch-time RecordRequest
calls — injecting obs once into that ctx (e.g. before calling Serve)
already covers the server side with zero additional wiring, so no
mqtt5-specific server helper is shipped.
See
examples/reqreply-api
for the full runnable demonstration (Demo 11,
demo_observer_middleware.go) — including composing Observability
alongside a paired security implementation on the same route, and
examples/reqreply-api/observability's own stats.Observer
implementation shared across every layer.
Declarative routes/channels and middleware declarations themselves
(routes.Route, middleware.Declaration values) stay pure metadata and
need no Observer involvement at all — only the implementation Fn
(HandleMW/ClientMW's second argument) executes real code, so that is
the only place an Observer call belongs.
api/events.Observability/adapter-thinned wrappers — pub/sub's shared observer core¶
api/events ships the adapter-agnostic core of pub/sub's general-purpose
observer wrapper —
events.Observability[T] —
attached via the existing general-purpose (unpaired)
.SubscribeMW(nil, fn)/.PublishMW(nil, fn) mechanism, the pub/sub
analogue of reqreply.Observability above:
It injects obs into ctx via stats.WithObserver and drains
stats.DiagnosticsFromContext into RecordValidationError after the
wrapped handler returns — the exact same shape as reqreply.Observability.
Unlike reqreply, though, pub/sub's 3 adapters do NOT all share one
implementation unchanged:
adapters/zeromqusesevents.Observability[T]directly — its own formerObservability[T]was confirmed to have zero zeromq-specific code, so it moved into the core package unchanged (a breaking removal; callers switch imports with no other code change).adapters/mqtt5andadapters/mqtt(v3) each keep their OWNObservability[T](topic string, obs stats.Observer)wrapper — thinned to delegate the shared ctx-inject/Diagnostics-drain part toevents.Observability[T](obs)internally, then adding the genuinely adapter-specific remainder on top:RecordSubscribe/RecordPublish+ aTraceObserverspan +MessageFromContext-based direction detection (subscribe vs. publish). This mirrorsreqreply.Observability's own "adapter transport already records lifecycle events, so the shared core deliberately does not" rationale — only here the "adapter transport" logic (recordingRecordSubscribe/RecordPublish) lives in the thin wrapper itself, not a separate dispatch function, since pub/sub has no dedicatedServe-style entry point comparable to reqreply's.
See
examples/events-api
for the full runnable demonstration, mirroring reqreply-api's own
observability/ package + demo layout.
Distinct from Observability[T] above: every pub/sub adapter's OWN
subscribe/publish dispatch — unconditionally, with no opt-in attachment
step — already calls RecordValidationError for two topic-shaped error
types that have no Unwrap() reaching one of stats.ReportErrors's
handled cases: events.InvalidTopicError (builder-level topic codec
failure, via events.WithTopicConstraints) and each adapter's own
TopicMismatchError (topic structurally doesn't match the channel's
{varName} template). adapters/mqtt (v3) has always reported both, on
both the subscribe AND publish sides; adapters/mqtt5 and
adapters/zeromq previously reported neither on the SUBSCRIBE side (and
zeromq reported neither on the publish side either) — a silent gap,
since the generic walker alone cannot see either type. Both adapters now
call the same dedicated reportInvalidTopicErrors/
reportTopicMismatchErrors helper pair mqtt v3 already had, closing the
gap — pub/sub Observer coverage for these two error shapes is now
symmetric across all 3 adapters.
api/rest — REST's Diagnostics-ferry mechanism, consolidated into the api layer¶
REST's decode/validation-error reporting uses a DIFFERENT mechanism from
Observability[T]/reqreply.Observability above — it has no
declare-time-attachable general-purpose middleware wrapper, since
adapters/nethttp/adapters/chi's own Observability(obs) already
wraps the WHOLE call (see the interfaces table above) and calls
RecordRequest/starts a TraceObserver span itself. Instead, each
per-field decode/validation failure (body, query, cookie, header,
response header, response cookie, path) is ferried out via
stats.RecordDiagnostic/stats.DiagnosticsFromContext — a ctx-based
sink, since these failures happen INSIDE the request dispatch, before
RecordRequest's own single terminal call.
DiagnosticObserver{Ctx} (adapts ctx-based stats.RecordDiagnostic to
the stats.ValidationObserver interface, so stats.ReportErrors's
existing per-field error-walking logic can be reused unchanged) and its
7 sibling ReportBodyErrors/ReportQueryErrors/ReportCookieErrors/
ReportHeaderErrors/ReportResponseHeaderErrors/
ReportResponseCookieErrors/ReportPathErrors functions now live in
api/rest
— moved there after being confirmed byte-for-byte IDENTICAL between
adapters/nethttp and adapters/chi. Both adapters' own private copies
were deleted entirely; both call the api/rest exports directly, with
zero adapter-owned wrapper remaining (unlike adapters/mqtt5.
Observability's genuinely-adapter-specific remainder — these 8 symbols
have no adapter-specific logic at all, so there is nothing for either
adapter to keep, mirroring api/reqreply.Observability's own "one
shared implementation, zero per-adapter indirection" precedent instead).
ReportPathErrors also unpacks MissingPathVarError/
InvalidPathParamError (previously only PathParamError was handled) —
closing a client-side gap where nethttp.Call's handle.BuildPath(vars)
producing a MissingPathVarError (a caller forgetting a required path
var) reported ZERO stats.Diagnostic, though the correct error was
still returned.
A sibling consolidation of the SAME "thin adapter, thick shared package"
shape: route.FirstSchemeName(reqs []route.SecurityRequirement) string
(used for stats.SecurityObserver.RecordSecurityRejection's scheme-name
argument) moved from 5 byte-identical private per-adapter copies
(adapters/nethttp, adapters/chi, adapters/mqtt, adapters/mqtt5,
adapters/zeromq) into route/security.go, alongside route.Satisfied.