Skip to content

Observe Typed Config Sections

Use config.ObserveSection when a long-lived component needs settings from GTB config but the reusable package should not depend on pkg/config.

The pattern is:

  1. Define a package-owned settings struct.
  2. Let the package accept that struct, or a tiny settings-source interface.
  3. In GTB adapter code, bind the config section with ObserveSection.
  4. Reconfigure from SectionChange.Current only when the typed struct changes.

Define Package Settings

type ServerSettings struct {
    Port           int `mapstructure:"port"`
    MaxHeaderBytes int `mapstructure:"max_header_bytes"`
}

type SettingsSource interface {
    Current() *ServerSettings
    Version() uint64
}

The package can accept SettingsSource without importing pkg/config. *config.ObservedSection[ServerSettings] satisfies that shape from the GTB adapter layer.

Bind the Config Section

settings, err := config.ObserveSection[ServerSettings](
    props.Config,
    "server.http",
    config.WithSectionDefaults(defaultServerSettings(), mergeServerSettings),
    config.WithSectionValidator(func(next ServerSettings) error {
        return next.Validate()
    }),
    config.WithSectionApply(func(change config.SectionChange[ServerSettings]) error {
        return server.Reconfigure(&change.Current.Value)
    }),
)
if err != nil {
    return err
}

server.SetSettingsSource(settings)

ObserveSection performs the initial unmarshal, registers an observer, validates fresh snapshots on successful reloads, and publishes immutable snapshots through Current.

React Only to Real Changes

The binding compares the previous and current typed section as whole structs. Unrelated config reloads do not increment Version and do not call WithSectionApply.

When the section changes, the callback receives:

Field Meaning
Previous Last valid typed section snapshot.
Current New validated typed section snapshot.
Changed True when the binding published a new version.
Version Monotonic version after the change was published.

Packages should reconfigure from change.Current.Value instead of watching individual config keys.

Custom Equality

Use WithSectionEqual when a package needs to ignore derived or operationally irrelevant fields:

config.WithSectionEqual(func(previous, current config.Section[ServerSettings]) bool {
    return previous.Exists == current.Exists &&
        previous.Value.Port == current.Value.Port &&
        previous.Value.MaxHeaderBytes == current.Value.MaxHeaderBytes
})

Keep the equality function package-owned and based on complete settings semantics, not scattered call-site field checks.

Dynamic Defaults

Use WithSectionDefaultFunc when defaults come from another config location and must rehydrate on reload too. For example, a transport-specific port may fall back to a shared server.port key:

config.WithSectionDefaultFunc(func(cfg config.Observed) ServerSettings {
    return ServerSettings{Port: cfg.GetInt("server.port")}
}, mergeServerSettings)

The default function runs during the initial bind and on every successful reload before equality is checked.

HTTP Server Settings

pkg/http provides a package-specific helper for its server settings:

settings, err := gtbhttp.ObserveServerSettingsFromConfig(
    cfg,
    "server.http",
    config.WithSectionApply(func(change config.SectionChange[gtbhttp.ServerSettings]) error {
        log.Info("http settings changed", "version", change.Version)

        return nil
    }),
)
if err != nil {
    return err
}

var source gtbhttp.ServerSettingsSource = settings
_ = source.Current()

The helper keeps the existing GTB config shape intact. It reads server.http.*, keeps server.port as the shared port fallback, and rehydrates that fallback on reload. The returned source exposes Version() so code can quickly tell whether the whole typed settings snapshot has changed.

gRPC Server Settings

pkg/grpc provides the same observer pattern for its server settings:

settings, err := gtbgrpc.ObserveServerSettingsFromConfig(
    cfg,
    "server.grpc",
    config.WithSectionApply(func(change config.SectionChange[gtbgrpc.ServerSettings]) error {
        log.Info("grpc settings changed", "version", change.Version)

        return nil
    }),
)
if err != nil {
    return err
}

var source gtbgrpc.ServerSettingsSource = settings
_ = source.Current()

The helper reads server.grpc.*, keeps server.port as the shared port fallback, and rehydrates that fallback on reload. A version change tells the owning package that the full typed snapshot changed; existing listeners still need explicit restart or reconfigure logic before a port change takes effect.

Gateway Settings

pkg/gateway observes a composed settings shape because it owns an HTTP server and dials the local gRPC server:

settings, err := gateway.ObserveSettingsFromConfig(
    cfg,
    config.WithSectionApply(func(change config.SectionChange[gateway.Settings]) error {
        log.Info("gateway settings changed", "version", change.Version)

        return nil
    }),
)
if err != nil {
    return err
}

var source gateway.SettingsSource = settings
_ = source.Current()

The helper composes server.gateway.* and server.gateway.tls.* with server.grpc.* and server.grpc.tls.*. Existing gateway servers and client connections still need explicit restart or redial logic before changed ports or TLS paths affect live traffic.

OTel Signal Settings

pkg/telemetry observes resolved settings for one OTel signal at a time, over the typed Settings from the standalone go/observability/otelcore module:

import (
    "gitlab.com/phpboyscout/go-tool-base/pkg/telemetry"
    "gitlab.com/phpboyscout/go/observability/otelcore"
)

settings, err := telemetry.ObserveSettingsFromConfig(
    cfg,
    otelcore.SignalTracing,
    config.WithSectionApply(func(change config.SectionChange[otelcore.Settings]) error {
        log.Info("otel tracing settings changed", "version", change.Version)

        return nil
    }),
)
if err != nil {
    return err
}

var source otelcore.SettingsSource = settings
_ = source.Current()

The helper preserves the existing telemetry.* shape. Shared keys such as telemetry.endpoint, telemetry.headers and telemetry.insecure are resolved first, then telemetry.<signal>.* overrides are applied. On reload, a version change means the full resolved signal snapshot changed. Existing exporters do not rebuild automatically; the owning package must decide whether to rebuild, restart, or keep using the current provider.

Failure Behaviour

If a reload cannot be decoded or validation fails, the binding keeps the last valid snapshot:

  • Current continues to return the previous settings.
  • Version does not increment.
  • WithSectionApply is not called.
  • The observer returns the decode or validation error so the container logs it.

This mirrors GTB's fail-closed config reload behaviour while keeping package constructors independent of the framework config container.