feat(config): add sampler hot-reload callback for ADR-0023 Phase 3.2

- Add SamplerReconfigureFunc type and SetSamplerReconfigureCallback method
- Track previous sampler type/ratio values to detect changes
- Invoke callback when telemetry.sampler.type or ratio changes
- Fix race condition in WatchAndApply cleanup using watcherStopped flag
- Add unit tests for sampler type/ratio hot-reload scenarios
- Update ADR-0023 status to reflect Phase 3.2 in flight

Generated by Mistral Vibe.
Co-Authored-By: Mistral Vibe <vibe@mistral.ai>
This commit is contained in:
2026-05-05 09:32:08 +02:00
parent 8147991fe0
commit 2eb69f2709
3 changed files with 317 additions and 6 deletions

View File

@@ -16,6 +16,13 @@ import (
"dance-lessons-coach/pkg/version"
)
// SamplerReconfigureFunc is the signature for callbacks invoked when
// telemetry.sampler.type or telemetry.sampler.ratio change via hot-reload.
// The callback receives the new sampler type and ratio values.
// It must be safe to call concurrently — implementations should use their
// own synchronisation if needed. Returns an error if the reconfigure fails.
type SamplerReconfigureFunc func(ctx context.Context, samplerType string, samplerRatio float64) error
// NewZerologWriter creates a zerolog writer based on configuration
func NewZerologWriter() *os.File {
return os.Stderr
@@ -41,6 +48,20 @@ type Config struct {
// reloadMu serialises Unmarshal during hot-reload so a partial mutation
// can't be observed mid-flight by getter calls.
reloadMu sync.RWMutex `mapstructure:"-"`
// samplerReconfigureCallback is invoked when telemetry.sampler.type or
// telemetry.sampler.ratio change. nil means no callback registered.
samplerReconfigureCallback SamplerReconfigureFunc `mapstructure:"-"`
// prevSamplerType and prevSamplerRatio track the last-seen sampler values
// to detect changes during hot-reload (ADR-0023 Phase 3).
prevSamplerType string `mapstructure:"-"`
prevSamplerRatio float64 `mapstructure:"-"`
// watcherStopped indicates that the config watcher has been stopped via
// the context being cancelled. This prevents the OnConfigChange handler
// from processing events after cleanup.
watcherStopped bool `mapstructure:"-"`
}
// ServerConfig holds server-related configuration
@@ -314,6 +335,11 @@ func LoadConfig() (*Config, error) {
// Keep the viper instance for hot-reload (ADR-0023).
config.viper = v
// Initialize previous sampler values for hot-reload change detection
// (ADR-0023 Phase 3).
config.prevSamplerType = config.Telemetry.Sampler.Type
config.prevSamplerRatio = config.Telemetry.Sampler.Ratio
// Setup logging based on configuration (level, output file, time format).
// The JSON/console format was already applied at the top of LoadConfig via
// peekJSONLogging, so SetupLogging only needs to handle the remaining knobs.
@@ -378,6 +404,19 @@ func (c *Config) GetSamplerRatio() float64 {
return c.Telemetry.Sampler.Ratio
}
// SetSamplerReconfigureCallback registers a callback that is invoked when
// telemetry.sampler.type or telemetry.sampler.ratio change via hot-reload.
// The callback receives the new sampler type and ratio values.
// Pass nil to unregister the callback.
func (c *Config) SetSamplerReconfigureCallback(callback SamplerReconfigureFunc) {
c.reloadMu.Lock()
defer c.reloadMu.Unlock()
c.samplerReconfigureCallback = callback
// Initialize previous values so we can detect changes on first hot-reload
c.prevSamplerType = c.Telemetry.Sampler.Type
c.prevSamplerRatio = c.Telemetry.Sampler.Ratio
}
// GetV2Enabled returns whether v2 API is enabled
func (c *Config) GetV2Enabled() bool {
return c.API.V2Enabled
@@ -615,9 +654,11 @@ func (c *Config) setupLogOutput() {
// reads it via JWTConfig.GetTTL (a method value capturing this *Config).
// The reloaded TTL is used on the NEXT token generation; tokens issued
// before the change keep their original expiry.
// - Phase 3: telemetry.sampler.type + telemetry.sampler.ratio — triggers
// the callback set via SetSamplerReconfigureCallback if the values change.
//
// The other fields listed in ADR-0023 (api.v2_enabled, telemetry sampler)
// remain restart-only until their handlers land in subsequent phases.
// The other fields listed in ADR-0023 (api.v2_enabled) remain restart-only
// until their handlers land in subsequent phases.
//
// Stops when ctx is cancelled. Safe to call once at server startup.
// If the config file is absent (ConfigFileNotFoundError at load time), this
@@ -633,6 +674,14 @@ func (c *Config) WatchAndApply(ctx context.Context) {
}
c.viper.OnConfigChange(func(in fsnotify.Event) {
// Skip processing if watcher has been stopped
c.reloadMu.Lock()
if c.watcherStopped {
c.reloadMu.Unlock()
return
}
c.reloadMu.Unlock()
log.Info().Str("event", in.Op.String()).Str("file", in.Name).Msg("Config file changed, reloading hot-reloadable fields")
c.reloadMu.Lock()
defer c.reloadMu.Unlock()
@@ -645,6 +694,30 @@ func (c *Config) WatchAndApply(ctx context.Context) {
// Apply hot-reloadable fields. Order matters: logging first so the
// rest of the reload is logged at the right level.
c.SetupLogging()
// Check if sampler config changed and invoke callback if registered
samplerChanged := c.prevSamplerType != c.Telemetry.Sampler.Type ||
c.prevSamplerRatio != c.Telemetry.Sampler.Ratio
if samplerChanged && c.samplerReconfigureCallback != nil {
if err := c.samplerReconfigureCallback(context.Background(),
c.Telemetry.Sampler.Type,
c.Telemetry.Sampler.Ratio); err != nil {
log.Error().Err(err).Msg("Hot-reload: sampler reconfigure callback failed")
} else {
// Update previous values only after successful callback
c.prevSamplerType = c.Telemetry.Sampler.Type
c.prevSamplerRatio = c.Telemetry.Sampler.Ratio
log.Info().
Str("sampler_type", c.prevSamplerType).
Float64("sampler_ratio", c.prevSamplerRatio).
Msg("Hot-reload applied: telemetry sampler reconfigured")
}
} else if samplerChanged {
// No callback registered, just update tracking values
c.prevSamplerType = c.Telemetry.Sampler.Type
c.prevSamplerRatio = c.Telemetry.Sampler.Ratio
}
log.Info().
Str("logging_level", c.GetLogLevel()).
Dur("jwt_ttl", c.GetJWTTTL()).
@@ -654,11 +727,14 @@ func (c *Config) WatchAndApply(ctx context.Context) {
log.Info().Str("file", c.viper.ConfigFileUsed()).Msg("Config hot-reload watcher started (ADR-0023 Phase 1)")
// Stop the watcher on context cancel — viper has no public Stop method,
// so we just clear the callback to make further events no-ops.
// Stop the watcher on context cancel — we set a flag that the
// OnConfigChange handler checks, avoiding the race with viper's
// internal state that would occur if we called OnConfigChange again.
go func() {
<-ctx.Done()
c.viper.OnConfigChange(func(_ fsnotify.Event) {})
c.reloadMu.Lock()
c.watcherStopped = true
c.reloadMu.Unlock()
log.Info().Msg("Config hot-reload watcher stopped")
}()
}