stats

package
v0.12.0 Latest Latest
Warning

This package is not in the latest version of its module.

Go to latest
Published: Jul 29, 2026 License: MIT Imports: 6 Imported by: 0

Documentation

Overview

Package stats defines the Observer interface for codec and adapter lifecycle events.

go-codex exposes two levels of observability:

Codec-level (ValidationObserver)

Use ValidationObserver when you call codecs directly without an adapter — for example, validating config files, parsing binary protocols, or any non-HTTP/MQTT use case. Implement just ValidationObserver.RecordValidationError and call ReportErrors after each codex.Codec.Decode:

val, err := appConfigCodec.Decode(rawData)
stats.ReportErrors(obs, "config", err)

Adapter-level (Observer)

Use the full Observer interface when wiring to an adapter. It embeds ValidationObserver and adds transport-specific hooks for HTTP and MQTT:

nethttp.Register(mux, route, handler, nethttp.Options{Observer: obs})
adaptermqtt.SubscribeHandler(ctx, ch, fn, adaptermqtt.SubscribeOptions{Observer: obs})

Composing metrics and logging

Use NewLoggingObserver and NewFanout to separate the metrics concern from the logging concern — no mixing of slog and counters in one struct:

metrics := &MyMetricsObserver{}  // pure counters — swap for Prometheus

obs := stats.NewFanout(
    metrics,
    stats.NewLoggingObserver(slog.Default().With("component", "api")),
)

nethttp.Register(mux, route, handler, nethttp.Options{Observer: obs})

LoggingObserver implements all five observer interfaces and logs every event via slog. Configure the logger's handler for your environment:

NewFanout fans out to all provided observers and also implements the optional FileObserver, SecurityObserver, and PipelineObserver interfaces — delegating each to the inner observers that satisfy those interfaces.

NoopObserver satisfies all five interfaces at zero cost; it is the default when no observer is configured.

Index

Examples

Constants

This section is empty.

Variables

This section is empty.

Functions

func ConstraintName

func ConstraintName(err error) string

ConstraintName extracts the constraint identifier from a field-level error:

func ReportErrors

func ReportErrors(obs ValidationObserver, location string, err error)

ReportErrors walks err and calls obs.RecordValidationError for every codec validation failure it finds. location identifies the data source (e.g. "body", "query", "payload", "config"). A no-op if err is nil or contains no recognisable validation errors.

Handled error types:

For all three, the walker recurses into the wrapped cause so nested errors (e.g. a KeyError whose cause is a ValidationErrors) are fully reported. Any other wrapped error is unwrapped and recursed into silently.

Example
package main

import (
	"fmt"

	"github.com/DaniDeer/go-codex/codex"
	"github.com/DaniDeer/go-codex/stats"
	"github.com/DaniDeer/go-codex/validate"
)

func main() {
	// ValidationObserver: implement to receive per-field validation events.
	type simpleObserver struct{ count int }
	obs := &simpleObserver{}

	recordFn := func(location, constraintName, field string) {
		obs.count++
	}

	// Wrap in a ValidationObserver for ReportErrors.
	type wrapObs struct{ fn func(string, string, string) }
	w := &wrapObs{fn: recordFn}

	// Example: decode a struct with two failing fields.
	type Config struct {
		Port  int
		Level string
	}
	portCodec := codex.Int().Refine(validate.RangeInt(1, 65535))
	levelCodec := codex.String().Refine(validate.OneOf("debug", "info", "warn", "error"))
	configCodec := codex.Struct[Config](
		codex.RequiredField("port", portCodec,
			func(c Config) int { return c.Port },
			func(c *Config, v int) { c.Port = v },
		),
		codex.OptionalField("level", levelCodec,
			func(c Config) string { return c.Level },
			func(c *Config, v string) { c.Level = v },
		),
	)

	_, err := configCodec.Decode(map[string]any{"port": float64(99999), "level": "verbose"})
	_ = w // suppress unused warning
	stats.ReportErrors(stats.NoopObserver{}, "config", err)

	// err is a structured codex.ValidationErrors — all fields collected at once.
	fmt.Println(err != nil)
}
Output:
true

func WithObserver added in v0.11.0

func WithObserver(ctx context.Context, obs Observer) context.Context

WithObserver returns a copy of ctx carrying obs as the default observer. Adapters, stream bridges, and [ports.File] operations that receive this context will use obs automatically when no explicit Observer is set in their options struct — without passing Observer: obs on every call site.

Use WithObserver at application startup to wire one observer everywhere:

obs := stats.NewFanout(metricsObserver, stats.NewLoggingObserver(slog.Default()))
ctx := stats.WithObserver(context.Background(), obs)

// All adapters use obs when Options.Observer is nil:
nethttp.Handler(handle, fn, nethttp.Options{})
mqtt.Subscribe(ctx, client, handle, 1, fn, mqtt.SubscribeOptions{})
stream.Apply(ctx, s, fn, stream.ApplyOptions{})

The context-provided observer has **lower** priority than an explicitly set opts.Observer — explicit always wins:

nethttp.Handler(handle, fn, nethttp.Options{Observer: auditObs}) // auditObs used, ctx obs ignored

The observer is scoped to the context: a child context from context.WithValue inherits the parent's observer unless overridden. Use WithObserver again on a child context to swap the observer for a sub-tree.

Example
package main

import (
	"context"

	"github.com/DaniDeer/go-codex/stats"
)

func main() {
	obs := stats.NewLoggingObserver(nil) // nil logger is replaced by slog.Default() in real code
	ctx := stats.WithObserver(context.Background(), obs)

	// Retrieve anywhere downstream — adapters do this internally.
	retrieved := stats.ObserverFromContext(ctx)
	_ = retrieved
}

Types

type CacheObserver added in v0.12.0

type CacheObserver interface {
	// RecordCacheHit is called for every cache lookup that found and decoded
	// a value. key is the expanded cache key (e.g. "user:42").
	RecordCacheHit(key string, duration time.Duration)

	// RecordCacheMiss is called for every cache lookup that found no value.
	RecordCacheMiss(key string, duration time.Duration)

	// RecordCacheWrite is called for every cache write or delete, success or
	// failure. op is "set" or "del".
	RecordCacheWrite(key, op string, success bool, duration time.Duration)
}

CacheObserver is an optional extension to Observer for cache adapter lifecycle events (adapters/redis). Cache adapters type-assert the configured Observer to CacheObserver before calling its methods — existing Observer implementations need not change.

type MyObserver struct{ ... }
func (o *MyObserver) RecordCacheHit(key string, d time.Duration)  { hits.Inc() }
func (o *MyObserver) RecordCacheMiss(key string, d time.Duration) { misses.Inc() }
func (o *MyObserver) RecordCacheWrite(key, op string, success bool, d time.Duration) { ... }

type FileObserver added in v0.11.0

type FileObserver interface {
	// RecordFileRead is called after every [ports.File.Read], [ports.File.Update],
	// or [ports.File.Patch] attempt (read phase). path is the concrete file path
	// (after template substitution), success is false on any error including
	// decode/validation failures.
	RecordFileRead(path string, success bool, duration time.Duration)

	// RecordFileWrite is called after every [ports.File.Write], [ports.File.Update],
	// or [ports.File.Patch] attempt (write phase). success is false on any
	// encode or filesystem error.
	RecordFileWrite(path string, success bool, duration time.Duration)
}

FileObserver is an optional extension to Observer for file I/O lifecycle events. [ports.File] type-asserts the configured observer to FileObserver before calling its methods, so implementing this interface is purely additive — existing Observer implementations need not change.

type MyObserver struct{ ... }
func (o *MyObserver) RecordFileRead(path string, success bool, d time.Duration) {
    // increment a Prometheus counter, emit a log line, etc.
}
func (o *MyObserver) RecordFileWrite(path string, success bool, d time.Duration) { ... }

type LoggingObserver added in v0.11.0

type LoggingObserver struct {
	// contains filtered or unexported fields
}

LoggingObserver logs every observer event as a structured slog message. It implements all observer interfaces except TraceObserver: Observer (embeds ValidationObserver), PipelineObserver, SecurityObserver, FileObserver, SQLObserver, StreamObserver, and CacheObserver.

TraceObserver is intentionally not implemented — slog has no concept of distributed trace spans. Use stats.NewFanout to combine a LoggingObserver with a separate TraceObserver implementation (e.g. OpenTelemetry).

Configure the logger's handler for your environment:

Combine with a metrics observer via NewFanout for both metrics and logging:

obs := stats.NewFanout(
    metricsObserver,
    stats.NewLoggingObserver(slog.Default().With("component", "api")),
)

func NewLoggingObserver added in v0.11.0

func NewLoggingObserver(logger *slog.Logger) *LoggingObserver

NewLoggingObserver returns a LoggingObserver backed by logger.

func (*LoggingObserver) RecordApply added in v0.11.0

func (o *LoggingObserver) RecordApply(name, version string, success bool, d time.Duration)

func (*LoggingObserver) RecordCacheHit added in v0.12.0

func (o *LoggingObserver) RecordCacheHit(key string, d time.Duration)

func (*LoggingObserver) RecordCacheMiss added in v0.12.0

func (o *LoggingObserver) RecordCacheMiss(key string, d time.Duration)

func (*LoggingObserver) RecordCacheWrite added in v0.12.0

func (o *LoggingObserver) RecordCacheWrite(key, op string, success bool, d time.Duration)

func (*LoggingObserver) RecordFileRead added in v0.11.0

func (o *LoggingObserver) RecordFileRead(path string, success bool, d time.Duration)

func (*LoggingObserver) RecordFileWrite added in v0.11.0

func (o *LoggingObserver) RecordFileWrite(path string, success bool, d time.Duration)

func (*LoggingObserver) RecordMigration added in v0.11.0

func (o *LoggingObserver) RecordMigration(op, name string, version int64, d time.Duration, err error)

func (*LoggingObserver) RecordPublish added in v0.11.0

func (o *LoggingObserver) RecordPublish(topic string, success bool, d time.Duration)

func (*LoggingObserver) RecordRequest added in v0.11.0

func (o *LoggingObserver) RecordRequest(method, path string, statusCode int, d time.Duration)

func (*LoggingObserver) RecordSecurityRejection added in v0.11.0

func (o *LoggingObserver) RecordSecurityRejection(location, scheme string)

func (*LoggingObserver) RecordStreamItem added in v0.11.0

func (o *LoggingObserver) RecordStreamItem(function string, success bool, d time.Duration)

func (*LoggingObserver) RecordSubscribe added in v0.11.0

func (o *LoggingObserver) RecordSubscribe(topic string, success bool, d time.Duration)

func (*LoggingObserver) RecordValidation added in v0.11.0

func (o *LoggingObserver) RecordValidation(table, op string, d time.Duration, err error)

func (*LoggingObserver) RecordValidationError added in v0.11.0

func (o *LoggingObserver) RecordValidationError(location, constraint, field string)

type NoopObserver

type NoopObserver struct{}

NoopObserver discards all events. It satisfies all observer interfaces — Observer (embeds ValidationObserver), PipelineObserver, SecurityObserver, FileObserver, SQLObserver, StreamObserver, CacheObserver, and TraceObserver — and is the zero-cost default used when no observer is configured.

func (NoopObserver) EndSpan added in v0.11.0

func (NoopObserver) EndSpan(_ context.Context, _ error)

func (NoopObserver) RecordApply

func (NoopObserver) RecordApply(_, _ string, _ bool, _ time.Duration)

func (NoopObserver) RecordCacheHit added in v0.12.0

func (NoopObserver) RecordCacheHit(_ string, _ time.Duration)

func (NoopObserver) RecordCacheMiss added in v0.12.0

func (NoopObserver) RecordCacheMiss(_ string, _ time.Duration)

func (NoopObserver) RecordCacheWrite added in v0.12.0

func (NoopObserver) RecordCacheWrite(_, _ string, _ bool, _ time.Duration)

func (NoopObserver) RecordFileRead added in v0.11.0

func (NoopObserver) RecordFileRead(_ string, _ bool, _ time.Duration)

func (NoopObserver) RecordFileWrite added in v0.11.0

func (NoopObserver) RecordFileWrite(_ string, _ bool, _ time.Duration)

func (NoopObserver) RecordMigration added in v0.11.0

func (NoopObserver) RecordMigration(_, _ string, _ int64, _ time.Duration, _ error)

func (NoopObserver) RecordPublish

func (NoopObserver) RecordPublish(_ string, _ bool, _ time.Duration)

func (NoopObserver) RecordRequest

func (NoopObserver) RecordRequest(_, _ string, _ int, _ time.Duration)

func (NoopObserver) RecordSecurityRejection

func (NoopObserver) RecordSecurityRejection(_, _ string)

func (NoopObserver) RecordStreamItem added in v0.11.0

func (NoopObserver) RecordStreamItem(_ string, _ bool, _ time.Duration)

func (NoopObserver) RecordSubscribe

func (NoopObserver) RecordSubscribe(_ string, _ bool, _ time.Duration)

func (NoopObserver) RecordValidation added in v0.11.0

func (NoopObserver) RecordValidation(_, _ string, _ time.Duration, _ error)

func (NoopObserver) RecordValidationError

func (NoopObserver) RecordValidationError(_, _, _ string)

func (NoopObserver) StartSpan added in v0.11.0

func (NoopObserver) StartSpan(ctx context.Context, _, _ string) context.Context

type Observer

type Observer interface {
	ValidationObserver

	// RecordRequest is called after every request/response cycle completes,
	// regardless of transport. method describes the transport and operation;
	// values vary by adapter:
	//   - HTTP adapters (nethttp, chi): uppercase HTTP method ("GET", "POST", …)
	//   - ZeroMQ adapters: "ZMQ-REP", "ZMQ-REQ", "ZMQ-ROUTER", "ZMQ-DEALER"
	//   - MCP adapter (mcpgo): "tool", "resource", "prompt"
	//   - MQTT5 request/reply: "MQTT5-REQ" (caller), "MQTT5-REP" (server)
	//
	// path is the route pattern or topic template (e.g. "/users/{id}",
	// "sensors/{sensorID}/data"), not the concrete URL or resolved topic.
	// statusCode follows HTTP conventions: 200 success, 400 client error,
	// 500 server/encode error; 0 means no request reached the transport (e.g.
	// pre-flight validation failure or context already cancelled).
	// duration is the total round-trip time including encode/decode.
	RecordRequest(method, path string, statusCode int, duration time.Duration)

	// RecordSubscribe is called after every inbound message or event is fully
	// processed. topic is the concrete incoming value (not a template).
	// success is false when decode or the application handler failed.
	// duration is the total processing time.
	//
	// Used by:
	//   - MQTT adapters (mqtt, mqtt5): called per incoming message
	//   - SSE stream bridges (nethttp, chi): called per emitted SSE event
	//     (success=true on send, success=false on write error or stream error)
	RecordSubscribe(topic string, success bool, duration time.Duration)

	// RecordPublish is called after every outbound message is sent.
	// topic is the resolved publish topic (after template substitution).
	// success is false when encode failed, the broker returned an error, or
	// the context was cancelled. duration covers encode + broker acknowledgement.
	//
	// Note: RecordPublish is not called when topic template substitution itself
	// fails — the message never reached the encode/publish stage in that case.
	//
	// Used by: MQTT adapters (mqtt, mqtt5), ZeroMQ Publish.
	RecordPublish(topic string, success bool, duration time.Duration)
}

Observer receives lifecycle events emitted by codec adapters. It embeds ValidationObserver for per-field validation errors and adds transport hooks for request/response cycles, subscriptions, and publishes. All methods must be safe for concurrent use.

func NewFanout added in v0.11.0

func NewFanout(observers ...Observer) Observer

NewFanout returns an Observer that fans out all calls to each provided observer. The returned value also implements FileObserver, SecurityObserver, PipelineObserver, SQLObserver, StreamObserver, CacheObserver, and TraceObserver — delegating each to the inner observers that satisfy those interfaces, so composing a metrics-only observer with a LoggingObserver works without any type-assertion boilerplate.

obs := stats.NewFanout(
    metricsObserver,
    stats.NewLoggingObserver(slog.Default()),
)

func ObserverFromContext added in v0.11.0

func ObserverFromContext(ctx context.Context) Observer

ObserverFromContext retrieves the observer stored by WithObserver. Returns NoopObserver{} when no observer has been stored in ctx — the same zero-cost default used when opts.Observer is nil and no context observer exists.

type PipelineObserver

type PipelineObserver interface {
	// RecordApply is called after every forge.Function*.Apply completes.
	// name is the function name, version is its version string, success is false
	// when any validation or computation step returned an error, duration is the
	// total Apply time (input validation + computation + output validation).
	RecordApply(name, version string, success bool, duration time.Duration)
}

PipelineObserver receives lifecycle events from forge.Function*.Apply calls. It is a separate interface from Observer because forge is a domain computation layer, not a transport adapter. Implement alongside Observer when you want telemetry for both transport and KPI computation in the same process.

type SQLObserver added in v0.11.0

type SQLObserver interface {
	// RecordValidation is called after every [adapters/sql.Validate] call,
	// success or failure. table and op mirror ValidateOptions.Table/Op.
	// err is nil on success.
	RecordValidation(table, op string, duration time.Duration, err error)

	// RecordMigration is called once per applied or rolled-back migration file
	// during Migrator.Up or Migrator.Down. op is "up" or "down".
	RecordMigration(op, name string, version int64, duration time.Duration, err error)
}

SQLObserver is an optional extension to Observer for SQL adapter lifecycle events. adapters/sql.Validate and adapters/sql.Migrator type-assert the configured Observer to SQLObserver before calling its methods — existing Observer implementations need not change.

type MyObserver struct{ ... }
func (o *MyObserver) RecordValidation(table, op string, d time.Duration, err error) {
    // record metrics, emit a log line, etc.
}
func (o *MyObserver) RecordMigration(op, name string, version int64, d time.Duration, err error) { ... }

type SecurityObserver

type SecurityObserver interface {
	// RecordSecurityRejection is called when a security check rejects a request
	// or message. location is the route path (HTTP) or topic (MQTT). scheme is
	// the first declared security scheme name for the operation.
	RecordSecurityRejection(location, scheme string)
}

SecurityObserver is an optional extension to Observer for security rejection events. Adapters type-assert the configured Observer to SecurityObserver before calling RecordSecurityRejection, so implementing this interface is purely additive — existing Observer implementations need not change.

type MyObserver struct{ ... }
func (o *MyObserver) RecordSecurityRejection(location, scheme string) {
    // increment a Prometheus counter, emit a log line, etc.
}

type StreamObserver added in v0.11.0

type StreamObserver interface {
	// RecordStreamItem is called for every item that passes through [stream.Apply],
	// success or failure. function is the forge function name.
	// success is false when forge.Function.Apply returned an error.
	RecordStreamItem(function string, success bool, duration time.Duration)
}

StreamObserver is an optional extension to Observer for stream-level throughput metrics. [stream.Apply] type-asserts the configured Observer to StreamObserver before calling its methods — existing Observer implementations need not change.

type MyObserver struct{ ... }
func (o *MyObserver) RecordStreamItem(function string, success bool, d time.Duration) {
    // record per-item throughput, latency, etc.
}

type TraceObserver added in v0.11.0

type TraceObserver interface {
	// StartSpan starts a new trace span for the named operation and returns
	// a context.Context containing the new span for child propagation.
	// The returned context should be passed to the application's handler
	// function so that business logic can create child spans.
	StartSpan(ctx context.Context, operation, name string) context.Context

	// EndSpan ends the span associated with ctx, recording err as an error
	// event when non-nil.
	EndSpan(ctx context.Context, err error)
}

TraceObserver is an optional extension to Observer for distributed tracing. Adapters type-assert the configured Observer to TraceObserver before calling StartSpan/EndSpan, so implementing this interface is purely additive — existing Observer implementations need not change.

type MyTracer struct{ ... }
func (t *MyTracer) StartSpan(ctx context.Context, operation, name string) context.Context {
    span, ctx := otel.Tracer("go-codex").Start(ctx, operation,
        otel.WithAttributes(attribute.String("name", name)),
    )
    return ctx
}
func (t *MyTracer) EndSpan(ctx context.Context, err error) {
    span := trace.SpanFromContext(ctx)
    if err != nil {
        span.RecordError(err)
        span.SetStatus(codes.Error, err.Error())
    }
    span.End()
}

The pattern at each adapter call site mirrors the SecurityObserver guard:

if to, ok := obs.(TraceObserver); ok {
    ctx = to.StartSpan(ctx, op, name)
    defer func() { to.EndSpan(ctx, err) }()
}

Where err is the eventual operation error (nil on success).

operation values follow a convention: "http.request", "mqtt.subscribe", "mqtt.publish", "forge.apply", "file.read", "file.write", "mcp.tool", "mcp.resource", "mcp.prompt". name is the concrete identifier (route path template, topic, function name, file path).

type ValidationObserver

type ValidationObserver interface {
	// RecordValidationError is called for each field that fails codec
	// validation. location identifies the data source (e.g. "body", "query",
	// "payload", "config"), constraintName is the constraint identifier (e.g.
	// "minLen(3)", "non-negative-int", "email", "type-mismatch", "required"),
	// field is the field or parameter name from the structured error.
	RecordValidationError(location, constraintName, field string)
}

ValidationObserver is the codec-level observability hook. Implement this interface when using codecs directly (without an adapter) to receive per-field validation error events. Use ReportErrors to extract codex.ValidationErrors from a decode error and call [RecordValidationError] for each failing field.

Observer embeds ValidationObserver for use with adapters.

Jump to

Keyboard shortcuts

? : This menu
/ : Search site
f or F : Jump to
y or Y : Canonical URL