From 77a790d38ad47b1ea001660dec91a98c32ceca36 Mon Sep 17 00:00:00 2001 From: Simon HUET Date: Tue, 10 Nov 2026 18:15:32 +0100 Subject: [PATCH 1/3] feat(container): introduce BootLoader and two-phase bootstrap architecture Refactor container initialization to separate bootstrapping from IoC resolution, move core bootstrap components into internal/boot, and introduce an internal/services registry to eliminate circular dependencies. - Relocate packages: - Move `internal/config` to `internal/boot/config`. - Move `internal/logger` to `internal/boot/logger`. - Move `LogLevel`, `LogHandler`, `LogColor`, and `LoggingProperties` to `internal/boot/log.go`. - Introduce `internal/boot.BootLoader`: - Implement multi-phase initialization (`Boot()`): - Phase 1: Initialize `Config` quietly at `ERROR` level (halts on failure). - Phase 2: Unmarshal `rays.logging`, configure handlers, format, color, and logger mappings. - Phase 3: Register configured logger in `internal/services`. - Phase 4: Clear compiled config state and re-initialize `Config` at user-configured logging level. - Provide logging helpers (`Logger()`, `LoggerForClass()`, `LoggerForType()`, `SetLoggingHandler()`). - Update `internal/boot/config`: - Separate compiled output into `result HashMap[string, any]` while preserving raw inputs. - Add `Clear()` and `SetLogger()` to support re-initialization. - Update `internal/boot/logger`: - Remove package-level global logger variable and `GetLogger()`. - Enforce instance-based management through `NewLogger()`. - Introduce `internal/services`: - Provide a cycle-free cross-cutting registry holding `*logger.Logger` for `pkg/lang/error.go` and future core services. - Update `internal/container`: - Embed `*boot.BootLoader` and `*Registry` in `Container`. - Delegate bootstrap sequence in `Initialize()` to `this.Boot()`. - Refactor `internal/container/log.go` to lightweight `LoggingConfiguration` bean with type aliases pointing to `boot.*`. - Replace legacy `logger.GetLogger()` references with `services.Get().Logger`. - Testing & Quality: - Add Ginkgo suites and unit tests for `internal/boot` and `internal/services`. - Route logging output to `GinkgoWriter` in test suites (`boot_test.go`, `pkg/testing`). - Maintain 100.0% statement test coverage across all 15 suites. - Pass all `golangci-lint` checks with 0 issues. --- internal/boot/boot.go | 153 ++++++++++++ internal/boot/boot_suite_test.go | 13 + internal/boot/boot_test.go | 176 +++++++++++++ internal/{ => boot}/config/config.go | 49 +++- .../{ => boot}/config/config_suite_test.go | 24 +- internal/{ => boot}/config/convert.go | 0 internal/{ => boot}/config/convert_test.go | 2 +- internal/{ => boot}/config/loader.go | 6 +- .../config/testdata/config/config-test.yaml | 0 .../config/testdata/config/config.yaml | 0 .../config/testdata/config/notread.txt | 0 internal/{ => boot}/config/validate.go | 0 internal/boot/log.go | 183 ++++++++++++++ internal/{ => boot}/logger/buffer.go | 0 internal/{ => boot}/logger/export_test.go | 0 internal/{ => boot}/logger/handler.go | 0 internal/{ => boot}/logger/handler_options.go | 0 .../logger/handler_regression_test.go | 0 internal/{ => boot}/logger/logger.go | 12 +- .../{ => boot}/logger/logger_suite_test.go | 12 +- internal/{ => boot}/logger/pattern.go | 0 internal/{ => boot}/logger/reduce.go | 0 internal/{ => boot}/logger/terminal_other.go | 0 .../{ => boot}/logger/terminal_windows.go | 0 internal/container/conditional.go | 17 +- internal/container/config_prov.go | 14 +- internal/container/container.go | 138 +++-------- internal/container/instance.go | 5 +- internal/container/log.go | 232 +----------------- internal/container/log_test.go | 2 +- internal/container/registry.go | 22 +- internal/services/services.go | 37 +++ internal/services/services_suite_test.go | 13 + internal/services/services_test.go | 47 ++++ pkg/lang/error.go | 12 +- pkg/lang/error_test.go | 5 +- pkg/testing/testing.go | 9 +- 37 files changed, 787 insertions(+), 396 deletions(-) create mode 100644 internal/boot/boot.go create mode 100644 internal/boot/boot_suite_test.go create mode 100644 internal/boot/boot_test.go rename internal/{ => boot}/config/config.go (94%) rename internal/{ => boot}/config/config_suite_test.go (96%) rename internal/{ => boot}/config/convert.go (100%) rename internal/{ => boot}/config/convert_test.go (99%) rename internal/{ => boot}/config/loader.go (82%) rename internal/{ => boot}/config/testdata/config/config-test.yaml (100%) rename internal/{ => boot}/config/testdata/config/config.yaml (100%) rename internal/{ => boot}/config/testdata/config/notread.txt (100%) rename internal/{ => boot}/config/validate.go (100%) create mode 100644 internal/boot/log.go rename internal/{ => boot}/logger/buffer.go (100%) rename internal/{ => boot}/logger/export_test.go (100%) rename internal/{ => boot}/logger/handler.go (100%) rename internal/{ => boot}/logger/handler_options.go (100%) rename internal/{ => boot}/logger/handler_regression_test.go (100%) rename internal/{ => boot}/logger/logger.go (98%) rename internal/{ => boot}/logger/logger_suite_test.go (99%) rename internal/{ => boot}/logger/pattern.go (100%) rename internal/{ => boot}/logger/reduce.go (100%) rename internal/{ => boot}/logger/terminal_other.go (100%) rename internal/{ => boot}/logger/terminal_windows.go (100%) create mode 100644 internal/services/services.go create mode 100644 internal/services/services_suite_test.go create mode 100644 internal/services/services_test.go diff --git a/internal/boot/boot.go b/internal/boot/boot.go new file mode 100644 index 0000000..18d9f2c --- /dev/null +++ b/internal/boot/boot.go @@ -0,0 +1,153 @@ +package boot + +import ( + "io" + "log/slog" + "maps" + "reflect" + "slices" + "sync" + + "github.com/BeamFoundry/rays/internal/boot/config" + "github.com/BeamFoundry/rays/internal/boot/logger" + "github.com/BeamFoundry/rays/internal/services" + . "github.com/BeamFoundry/rays/pkg/core" + . "github.com/BeamFoundry/rays/pkg/lang" +) + +type BootLoader struct { + *config.Config + loggingProperties *LoggingProperties + handler func(io.Writer, *slog.HandlerOptions) slog.Handler + logger *logger.Logger + loggers []string + lock sync.Mutex +} + +func NewBootLoader() *BootLoader { + cfg := config.NewConfig() + loggingDefaults := map[string]any{ + "handler": LogHandler{}.New().DEFAULT.String(), + "level": LogLevel{}.New().INFO.String(), + "source": true, + "format": logger.DefaultFormat, + "color": LogColor{}.New().AUTO.String(), + } + //nolint:errcheck // default config load at path cannot fail + cfg.LoadDefaultConfigAt(loggingDefaults, "rays.logging") + + quietLogger := logger.NewLogger() + quietLogger.SetLogLevel(slog.LevelError) + cfg.SetLogger(quietLogger) + services.Register(&services.Registry{ + Logger: quietLogger, + }) + + return &BootLoader{ + Config: cfg, + logger: quietLogger, + loggers: []string{}, + } +} + +func (this *BootLoader) SetLoggingHandler(handler func(io.Writer, *slog.HandlerOptions) slog.Handler) { + this.lock.Lock() + defer this.lock.Unlock() + this.handler = handler +} + +func (this *BootLoader) LoggingProperties() *LoggingProperties { + return this.loggingProperties +} + +func (this *BootLoader) Boot() Error { + this.lock.Lock() + defer this.lock.Unlock() + + // 1. Initializing configuration in Phase 1 (quiet) + if err := this.InitConfig(); err != nil { + return err + } + + // 2. Initializing logging properties from parsed config + if cfg, err := this.GetSubConfig("rays.logging"); err == nil { + this.loggingProperties = &LoggingProperties{} + if err := cfg.Unmarshal(this.loggingProperties); err != nil { + return err + } + level := this.loggingProperties.LogLevel() + this.logger.SetLogLevel(level.Level()) + if this.loggingProperties.AddSource() { + this.logger.AddSource() + } + if this.handler != nil { + this.logger.SetHandler(this.handler) + } else if handler := this.loggingProperties.LogHandler(); handler.Handler(this.loggingProperties.Format()) != nil { + this.logger.SetHandler(handler.Handler(this.loggingProperties.Format())) + } + this.loggingProperties.LogColor().Apply(this.logger) + this.loggers = slices.Sorted(maps.Keys(this.loggingProperties.MapLevels())) + slices.Reverse(this.loggers) + } + this.logger.SetDefault() + + // 3. Register configured logger into services + services.Register(&services.Registry{ + Logger: this.logger, + }) + + // 4. Primary functionality hooks (future extensions) + + // 5. Reinitialize Config with the correct parameters (Phase 4) + this.Clear() + this.SetLogger(this.logger) + //nolint:errcheck // Phase 1 has already validated configuration loading + _ = this.InitConfig() + + this.logger.Debug("Config & Logging initialized") + return nil +} + +func (this *BootLoader) Logger() Logging { + return &logging{this.logger} +} + +func (this *BootLoader) LoggerForClass(class any) Logging { + rtype := reflect.TypeOf(class) + return this.LoggerForType(rtype) +} + +func (this *BootLoader) LoggerForType(rtype reflect.Type) Logging { + if this.loggingProperties == nil || rtype.Kind() != reflect.Pointer || rtype.Elem().Kind() != reflect.Struct { + return this.Logger() + } + + pkg := rtype.Elem().PkgPath() + name := pkg + "/" + rtype.Elem().Name() + for _, level := range this.loggers { + if (!String(level).EndsWith("/") || !String(pkg).StartsWith(level)) && pkg != level && name != level { + continue + } + this.logger.Trace("Creating specific new logger", "pkg", pkg, "name", name) + lg := this.logger.Clone() + logLevel := this.loggingProperties.MapLevels()[level] + lg.SetLogLevel(logLevel.Level()) + return &logging{lg} + } + return this.Logger() +} + +func (this *BootLoader) Reset() { + this.lock.Lock() + defer this.lock.Unlock() + this.Config.Reset() + this.handler = nil + this.loggingProperties = nil + this.loggers = []string{} + this.logger.Reset() + this.logger.SetLogLevel(slog.LevelError) + this.SetLogger(this.logger) + services.Register(&services.Registry{ + Logger: this.logger, + }) +} diff --git a/internal/boot/boot_suite_test.go b/internal/boot/boot_suite_test.go new file mode 100644 index 0000000..a7dad82 --- /dev/null +++ b/internal/boot/boot_suite_test.go @@ -0,0 +1,13 @@ +package boot_test + +import ( + "testing" + + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +func TestBoot(t *testing.T) { + RegisterFailHandler(Fail) + RunSpecs(t, "Internal/Boot Suite") +} diff --git a/internal/boot/boot_test.go b/internal/boot/boot_test.go new file mode 100644 index 0000000..050e631 --- /dev/null +++ b/internal/boot/boot_test.go @@ -0,0 +1,176 @@ +package boot_test + +import ( + "bytes" + "io" + "log/slog" + "reflect" + + "github.com/BeamFoundry/rays/internal/boot" + "github.com/BeamFoundry/rays/internal/boot/logger" + "github.com/BeamFoundry/rays/internal/services" + . "github.com/BeamFoundry/rays/pkg/core" + "github.com/BeamFoundry/rays/pkg/lang" + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +type DummyStruct struct{} + +var _ = BeforeSuite(func() { + services.Get().Logger.SetOutput(GinkgoWriter) +}) + +var _ = Describe("BootLoader", func() { + var bl *boot.BootLoader + + BeforeEach(func() { + bl = boot.NewBootLoader() + services.Get().Logger.SetOutput(GinkgoWriter) + }) + + AfterEach(func() { + bl.Reset() + }) + + It("creates a BootLoader with default quiet logger and default config", func() { + Expect(bl).NotTo(BeNil()) + Expect(bl.Logger()).NotTo(BeNil()) + Expect(bl.Config).NotTo(BeNil()) + }) + + It("can boot successfully with default settings", func() { + Expect(bl.Boot()).NotTo(HaveOccurred()) + Expect(bl.LoggingProperties()).NotTo(BeNil()) + Expect(bl.LoggingProperties().LogLevel().Enum).To(Equal(boot.LogLevel{}.New().INFO)) + Expect(bl.LoggingProperties().LogHandler().Enum).To(Equal(boot.LogHandler{}.New().DEFAULT)) + Expect(bl.LoggingProperties().LogColor().Enum).To(Equal(boot.LogColor{}.New().AUTO)) + Expect(bl.LoggingProperties().Color().Enum).To(Equal(boot.LogColor{}.New().AUTO)) + Expect(bl.LoggingProperties().AddSource()).To(BeTrue()) + Expect(bl.LoggingProperties().Format()).To(Equal(logger.DefaultFormat)) + Expect(bl.LoggingProperties().MapLevels()).To(BeEmpty()) + }) + + It("can boot with custom logging properties and package-specific loggers", func() { + cfg := map[string]any{ + "level": "DEBUG", + "handler": "JSON", + "color": "ON", + "source": true, + "loggers": map[string]any{ + "github.com/BeamFoundry/rays/internal/boot/": "TRACE", + "github.com/BeamFoundry/rays/internal/boot_test": "DEBUG", + "DummyStruct": "WARN", + }, + } + Expect(bl.LoadConfigAt(cfg, "rays.logging")).NotTo(HaveOccurred()) + Expect(bl.Boot()).NotTo(HaveOccurred()) + + Expect(bl.Logger()).NotTo(BeNil()) + Expect(services.Get().Logger).NotTo(BeNil()) + + // Test LoggerForType and LoggerForClass + lg1 := bl.LoggerForClass(&DummyStruct{}) + Expect(lg1).NotTo(BeNil()) + + lg2 := bl.LoggerForType(reflect.TypeFor[*DummyStruct]()) + Expect(lg2).NotTo(BeNil()) + + // Test non-struct pointer and non-pointer + Expect(bl.LoggerForType(reflect.TypeFor[string]())).NotTo(BeNil()) + Expect(bl.LoggerForClass("not a struct")).NotTo(BeNil()) + + // Test struct that does not match any configured logger + Expect(bl.LoggerForClass(&bytes.Buffer{})).NotTo(BeNil()) + + // Test With and WithGroup on logging wrapper + withLg := lg1.With("key", "val") + Expect(withLg).NotTo(BeNil()) + withGrp := lg1.WithGroup("testgroup") + Expect(withGrp).NotTo(BeNil()) + }) + + It("supports custom logging handler set via SetLoggingHandler", func() { + called := false + bl.SetLoggingHandler(func(w io.Writer, opts *slog.HandlerOptions) slog.Handler { + called = true + return slog.NewTextHandler(w, opts) + }) + Expect(bl.Boot()).NotTo(HaveOccurred()) + Expect(called).To(BeTrue()) + }) + + It("supports CUSTOM log handler with custom format", func() { + cfg := map[string]any{ + "handler": "CUSTOM", + "format": "%msg(%v)", + } + Expect(bl.LoadConfigAt(cfg, "rays.logging")).NotTo(HaveOccurred()) + Expect(bl.Boot()).NotTo(HaveOccurred()) + }) + + It("supports all LogHandler enum values", func() { + h := boot.LogHandler{}.New() + for _, enumVal := range []lang.Enum[boot.LogHandler]{ + h.DEFAULT, + h.JSON, + h.TEXT, + h.CUSTOM, + h.ECS, + h.GELF, + h.LOGSTASH, + } { + handlerObj := boot.LogHandler{Enum: enumVal} + fn := handlerObj.Handler("custom-format") + Expect(fn).NotTo(BeNil()) + handler := fn(io.Discard, nil) + Expect(handler).NotTo(BeNil()) + } + // Default / invalid + invalidHandler := boot.LogHandler{} + Expect(invalidHandler.Handler()).To(BeNil()) + }) + + It("supports all LogLevel enum values", func() { + l := boot.LogLevel{}.New() + Expect((&boot.LogLevel{Enum: l.TRACE}).Level()).To(Equal(LevelTrace)) + Expect((&boot.LogLevel{Enum: l.DEBUG}).Level()).To(Equal(LevelDebug)) + Expect((&boot.LogLevel{Enum: l.WARN}).Level()).To(Equal(LevelWarn)) + Expect((&boot.LogLevel{Enum: l.ERROR}).Level()).To(Equal(LevelError)) + Expect((&boot.LogLevel{Enum: l.FATAL}).Level()).To(Equal(LevelFatal)) + Expect((&boot.LogLevel{Enum: l.INFO}).Level()).To(Equal(LevelInfo)) + invalidLevel := boot.LogLevel{} + Expect(invalidLevel.Level()).To(Equal(LevelInfo)) + }) + + It("supports all LogColor enum values", func() { + lg := logger.NewLogger() + lg.SetOutput(GinkgoWriter) + c := boot.LogColor{}.New() + boot.LogColor{Enum: c.ON}.Apply(lg) + boot.LogColor{Enum: c.OFF}.Apply(lg) + boot.LogColor{Enum: c.AUTO}.Apply(lg) + }) + + It("returns error when phase 1 config initialization fails", func() { + // Calling InitConfig beforehand so Boot()'s InitConfig fails with already initialized + Expect(bl.InitConfig()).NotTo(HaveOccurred()) + err := bl.Boot() + Expect(err).To(HaveOccurred()) + }) + + It("returns error when logging properties unmarshal fails", func() { + cfg := map[string]any{ + "source": "invalid_bool", + } + Expect(bl.LoadConfigAt(cfg, "rays.logging")).NotTo(HaveOccurred()) + err := bl.Boot() + Expect(err).To(HaveOccurred()) + }) + + It("can reset properly", func() { + Expect(bl.Boot()).NotTo(HaveOccurred()) + bl.Reset() + Expect(bl.LoggingProperties()).To(BeNil()) + }) +}) diff --git a/internal/config/config.go b/internal/boot/config/config.go similarity index 94% rename from internal/config/config.go rename to internal/boot/config/config.go index c317111..4078a69 100644 --- a/internal/config/config.go +++ b/internal/boot/config/config.go @@ -11,7 +11,7 @@ import ( "sync" "sync/atomic" - "github.com/BeamFoundry/rays/internal/logger" + "github.com/BeamFoundry/rays/internal/boot/logger" . "github.com/BeamFoundry/rays/pkg/core" . "github.com/BeamFoundry/rays/pkg/lang" . "github.com/BeamFoundry/rays/pkg/util" @@ -42,10 +42,12 @@ type configDuplicator struct { } type Config struct { - defaultConfig HashMap[string, any] + logger *logger.Logger config HashMap[string, any] profileConfig HashMap[string, HashMap[string, any]] overrides HashMap[string, string] + result HashMap[string, any] + defaultConfig HashMap[string, any] profiles []string defaultConfigFS []fs.FS duplicators []configDuplicator @@ -73,7 +75,29 @@ func NewConfig() *Config { overrides: NewHashMap[string, string](), profileConfig: NewHashMap[string, HashMap[string, any]](), profiles: []string{}, + result: NewHashMap[string, any](), + logger: logger.NewLogger(), + } +} + +func (this *Config) SetLogger(l *logger.Logger) { + if l != nil { + this.logger = l + } +} + +func (this *Config) Clear() { + this.lock.Lock() + defer this.lock.Unlock() + this.result.Clear() + atomic.StoreInt32(&this.initialized, 0) +} + +func (this *Config) getActiveConfig() HashMap[string, any] { + if this.result.Size() > 0 { + return this.result } + return this.config } func (this *Config) Reset() { @@ -81,6 +105,7 @@ func (this *Config) Reset() { defer this.lock.Unlock() this.profiles = []string{} this.config.Clear() + this.result.Clear() this.overrides.Clear() this.configFS = []fs.FS{} this.defaultConfig.Remove("test") @@ -284,9 +309,9 @@ func (this *Config) GetConfigValue(key string) (string, Error) { k, _ = strings.CutPrefix(k, "[") k, _ = strings.CutSuffix(k, "]") override := String(k).ToLowerCase().String() - v, ok := this.config.Get(key).Get() + v, ok := this.getActiveConfig().Get(key).Get() if !ok { - logger.GetLogger().Debug("GetConfigValue: last key failed") + this.logger.Debug("GetConfigValue: last key failed") return "", NewKeyNotFoundError(key, keys[0]) } if o, ok := this.overrides.Get(override).Get(); ok { @@ -360,12 +385,15 @@ func (this *Config) GetSubConfig(key string) (*Config, Error) { return nil, NewInvalidTypeKeyError(keys[0], key) } - c, err := getMapValue(this.config.Map(), keys) + c, err := getMapValue(this.getActiveConfig().Map(), keys) if err != nil { return NewConfig(), err } s := NewConfig() s.config = NewHashMapFor(c) + s.result = NewHashMapFor(c) + s.logger = this.logger + s.initialized = 1 if len(keys) == 1 { key, _ = strings.CutPrefix(key, "[") key, _ = strings.CutSuffix(key, "]") @@ -413,7 +441,6 @@ func (this *Config) InitConfig() Error { } } config := NewHashMap[string, any]() - //+ logger.GetLogger().Trace("Config::Pre-Initialize", "default", this.defaultConfig) config.Merge(this.defaultConfig) for _, duplicate := range this.duplicators { err := Try(func() { @@ -429,16 +456,14 @@ func (this *Config) InitConfig() Error { err.LogError() } } - //+ logger.GetLogger().Trace("Config::Pre-Initialize", "config", this.config) config.Merge(this.config) - //+ logger.GetLogger().Trace("Config::Post-Initialize", "default", this.defaultConfig) - this.config = config + this.result = config for _, profile := range this.profiles { if profileConfig, ok := this.profileConfig.Get(profile).Get(); ok { - this.config.Merge(profileConfig) + this.result.Merge(profileConfig) } } - logger.GetLogger().Debug("Config::Post-Initialize", "config", this.config) + this.logger.Debug("Config::Post-Initialize", "config", this.result) atomic.StoreInt32(&this.initialized, 1) return nil } @@ -631,7 +656,7 @@ func (this *Config) unmarshal(obj any) Error { var value reflect.Value override := String(field.Name).ToLowerCase().String() - if v, ok := this.config.Get(field.Name).Get(); ok { + if v, ok := this.getActiveConfig().Get(field.Name).Get(); ok { if o, ok := this.overrides.Get(override).Get(); ok { v = o } diff --git a/internal/config/config_suite_test.go b/internal/boot/config/config_suite_test.go similarity index 96% rename from internal/config/config_suite_test.go rename to internal/boot/config/config_suite_test.go index 1cacbe7..b9ad329 100644 --- a/internal/config/config_suite_test.go +++ b/internal/boot/config/config_suite_test.go @@ -7,7 +7,8 @@ import ( . "github.com/onsi/ginkgo/v2" . "github.com/onsi/gomega" - "github.com/BeamFoundry/rays/internal/config" + "github.com/BeamFoundry/rays/internal/boot/config" + "github.com/BeamFoundry/rays/internal/boot/logger" _ "github.com/BeamFoundry/rays/pkg/testing" ) @@ -698,4 +699,25 @@ var _ = Describe("config", func() { _, err = c.GetConfigValue("favorites.books.released.Germinal") Expect(err).To(HaveOccurred()) }) + + It("can set a custom logger", func() { + c := config.NewConfig() + c.SetLogger(nil) + customLogger := logger.NewLogger() + c.SetLogger(customLogger) + }) + + It("can clear result and allow reinitialization", func() { + c := config.NewConfigFor(favorite) + Expect(c.InitConfig()).NotTo(HaveOccurred()) + v, err := c.GetConfigValue("favorite.book.author") + Expect(err).NotTo(HaveOccurred()) + Expect(v).To(Equal("Victor Hugo")) + + c.Clear() + Expect(c.InitConfig()).NotTo(HaveOccurred()) + v, err = c.GetConfigValue("favorite.book.author") + Expect(err).NotTo(HaveOccurred()) + Expect(v).To(Equal("Victor Hugo")) + }) }) diff --git a/internal/config/convert.go b/internal/boot/config/convert.go similarity index 100% rename from internal/config/convert.go rename to internal/boot/config/convert.go diff --git a/internal/config/convert_test.go b/internal/boot/config/convert_test.go similarity index 99% rename from internal/config/convert_test.go rename to internal/boot/config/convert_test.go index 4ad3492..a2cd0af 100644 --- a/internal/config/convert_test.go +++ b/internal/boot/config/convert_test.go @@ -7,7 +7,7 @@ import ( . "github.com/onsi/ginkgo/v2" . "github.com/onsi/gomega" - . "github.com/BeamFoundry/rays/internal/config" + . "github.com/BeamFoundry/rays/internal/boot/config" . "github.com/BeamFoundry/rays/pkg/lang" ) diff --git a/internal/config/loader.go b/internal/boot/config/loader.go similarity index 82% rename from internal/config/loader.go rename to internal/boot/config/loader.go index b0356f3..80e4ea5 100644 --- a/internal/config/loader.go +++ b/internal/boot/config/loader.go @@ -4,7 +4,7 @@ import ( "io/fs" "regexp" - "github.com/BeamFoundry/rays/internal/logger" + "github.com/BeamFoundry/rays/internal/services" . "github.com/BeamFoundry/rays/pkg/core" "gopkg.in/yaml.v3" ) @@ -29,14 +29,14 @@ func (this *YamlConfigFSLoader) ParseConfigurationFilesystem(fsys fs.FS, loader m := yamlConfigFileRegexp.FindStringSubmatch(entry.Name()) if len(m) == 0 { - logger.GetLogger().Debug("Unable to load configuration file", "name", entry.Name()) + services.Get().Logger.Debug("Unable to load configuration file", "name", entry.Name()) return nil } if data, err := fs.ReadFile(fsys, path); err == nil { config := map[string]any{} if err := yaml.Unmarshal(data, &config); err == nil { - logger.GetLogger().Info("Loading configuration", "path", path, "profile", m[1]) + services.Get().Logger.Info("Loading configuration", "path", path, "profile", m[1]) loader(config, m[1]) } } diff --git a/internal/config/testdata/config/config-test.yaml b/internal/boot/config/testdata/config/config-test.yaml similarity index 100% rename from internal/config/testdata/config/config-test.yaml rename to internal/boot/config/testdata/config/config-test.yaml diff --git a/internal/config/testdata/config/config.yaml b/internal/boot/config/testdata/config/config.yaml similarity index 100% rename from internal/config/testdata/config/config.yaml rename to internal/boot/config/testdata/config/config.yaml diff --git a/internal/config/testdata/config/notread.txt b/internal/boot/config/testdata/config/notread.txt similarity index 100% rename from internal/config/testdata/config/notread.txt rename to internal/boot/config/testdata/config/notread.txt diff --git a/internal/config/validate.go b/internal/boot/config/validate.go similarity index 100% rename from internal/config/validate.go rename to internal/boot/config/validate.go diff --git a/internal/boot/log.go b/internal/boot/log.go new file mode 100644 index 0000000..305a950 --- /dev/null +++ b/internal/boot/log.go @@ -0,0 +1,183 @@ +package boot + +import ( + "io" + "log/slog" + + "github.com/BeamFoundry/rays/internal/boot/logger" + . "github.com/BeamFoundry/rays/pkg/core" + . "github.com/BeamFoundry/rays/pkg/lang" +) + +// LogLevel defines the enumeration of supported logging severity levels. +type LogLevel struct { + Enum[LogLevel] + INFO, // Default level + TRACE, + DEBUG, + WARN, + ERROR, + FATAL Enum[LogLevel] +} + +// Level converts the LogLevel enumeration into the corresponding slog.Level value. +func (this *LogLevel) Level() slog.Level { + switch this.Enum { + case LogLevel{}.New().TRACE: + return LevelTrace + case LogLevel{}.New().DEBUG: + return LevelDebug + case LogLevel{}.New().WARN: + return LevelWarn + case LogLevel{}.New().ERROR: + return LevelError + case LogLevel{}.New().FATAL: + return LevelFatal + default: + return LevelInfo + } +} + +// LogHandler defines the enumeration of supported logging handler implementations. +type LogHandler struct { + Enum[LogHandler] + DEFAULT, + JSON, + TEXT, + ECS, + GELF, + LOGSTASH, + CUSTOM Enum[LogHandler] +} + +// Handler returns a factory function that constructs the slog.Handler corresponding to this LogHandler option. +func (this *LogHandler) Handler(format ...string) func(io.Writer, *slog.HandlerOptions) slog.Handler { + customFormat := logger.DefaultFormat + if len(format) > 0 && format[0] != "" { + customFormat = format[0] + } + switch this.Enum { + case LogHandler{}.New().DEFAULT: + return func(writer io.Writer, options *slog.HandlerOptions) slog.Handler { + return logger.NewHandlerWithFormat(writer, options, logger.DefaultFormat) + } + case LogHandler{}.New().JSON: + return func(writer io.Writer, options *slog.HandlerOptions) slog.Handler { + return logger.NewHandlerWithFormat(writer, options, logger.JSONFormat) + } + case LogHandler{}.New().TEXT: + return func(writer io.Writer, options *slog.HandlerOptions) slog.Handler { + return logger.NewHandlerWithFormat(writer, options, logger.TextFormat) + } + case LogHandler{}.New().CUSTOM: + return func(writer io.Writer, options *slog.HandlerOptions) slog.Handler { + return logger.NewHandlerWithFormat(writer, options, customFormat) + } + case LogHandler{}.New().ECS: + return func(writer io.Writer, options *slog.HandlerOptions) slog.Handler { + return logger.NewHandlerWithFormat(writer, options, logger.ECSFormat) + } + case LogHandler{}.New().GELF: + return func(writer io.Writer, options *slog.HandlerOptions) slog.Handler { + return logger.NewHandlerWithFormat(writer, options, logger.GELFFormat) + } + case LogHandler{}.New().LOGSTASH: + return func(writer io.Writer, options *slog.HandlerOptions) slog.Handler { + return logger.NewHandlerWithFormat(writer, options, logger.LogstashFormat) + } + default: + return nil + } +} + +// LogColor defines the enumeration of terminal ANSI color output options (AUTO, ON, OFF). +type LogColor struct { + Enum[LogColor] + AUTO, + ON, + OFF Enum[LogColor] +} + +// Apply configures the ANSI color output mode on the given logger based on the LogColor setting. +func (this LogColor) Apply(lg *logger.Logger) { + switch this.Enum { + case LogColor{}.New().ON: + lg.SetColor(true) + case LogColor{}.New().OFF: + lg.SetColor(false) + default: + lg.EnableColor() + } +} + +// LoggingProperties holds configuration properties under the "rays.logging" namespace. +type LoggingProperties struct { + ConfigurationProperties `Name:"rays.logging"` + // Loggers + loggers map[string]LogLevel + // Log format for CUSTOM handler + format string + /* configuration keys */ + // Set rays.logging.handler to "DEFAULT", "TEXT", "JSON", "ECS", "GELF", "LOGSTASH" or "CUSTOM" + handler LogHandler + // Set rays.logging.level to "TRACE", "DEBUG", "INFO", "WARN", "ERROR" or "FATAL". + // Defaults to "INFO" + level LogLevel + // Set rays.logging.color to "AUTO", "ON", or "OFF". + // Defaults to "AUTO" + color LogColor + // Add Logging source + source bool +} + +// LogLevel returns the configured logging severity level. +func (this *LoggingProperties) LogLevel() LogLevel { + return this.level +} + +// LogHandler returns the configured log handler type. +func (this *LoggingProperties) LogHandler() LogHandler { + return this.handler +} + +// LogColor returns the configured ANSI color output mode. +func (this *LoggingProperties) LogColor() LogColor { + return this.color +} + +// Color returns the configured ANSI color output mode, mirroring LogColor. +func (this *LoggingProperties) Color() LogColor { + return this.color +} + +// AddSource reports whether caller source file and line information should be added to records. +func (this *LoggingProperties) AddSource() bool { + return this.source +} + +// Format returns the pattern format string configured for custom log handlers. +func (this *LoggingProperties) Format() string { + return this.format +} + +// MapLevels returns the mapping of package paths or logger names to their configured log levels. +func (this *LoggingProperties) MapLevels() map[string]LogLevel { + return this.loggers +} + +// logging wraps a logger.Logger instance to implement the core.Logging interface. +type logging struct { + *logger.Logger +} + +// With returns a new Logging instance with the provided key-value attributes appended. +func (this logging) With(args ...any) Logging { + this.Logger = this.Logger.With(args...) + return &this +} + +// WithGroup returns a new Logging instance that qualifies subsequent attributes with the specified group name. +func (this logging) WithGroup(name string) Logging { + this.Logger = this.Logger.WithGroup(name) + return &this +} diff --git a/internal/logger/buffer.go b/internal/boot/logger/buffer.go similarity index 100% rename from internal/logger/buffer.go rename to internal/boot/logger/buffer.go diff --git a/internal/logger/export_test.go b/internal/boot/logger/export_test.go similarity index 100% rename from internal/logger/export_test.go rename to internal/boot/logger/export_test.go diff --git a/internal/logger/handler.go b/internal/boot/logger/handler.go similarity index 100% rename from internal/logger/handler.go rename to internal/boot/logger/handler.go diff --git a/internal/logger/handler_options.go b/internal/boot/logger/handler_options.go similarity index 100% rename from internal/logger/handler_options.go rename to internal/boot/logger/handler_options.go diff --git a/internal/logger/handler_regression_test.go b/internal/boot/logger/handler_regression_test.go similarity index 100% rename from internal/logger/handler_regression_test.go rename to internal/boot/logger/handler_regression_test.go diff --git a/internal/logger/logger.go b/internal/boot/logger/logger.go similarity index 98% rename from internal/logger/logger.go rename to internal/boot/logger/logger.go index 2c708e0..0f2e139 100644 --- a/internal/logger/logger.go +++ b/internal/boot/logger/logger.go @@ -10,13 +10,6 @@ import ( "time" ) -var logger = Logger{}.New() - -// GetLogger returns the global default Logger instance. -func GetLogger() *Logger { - return logger -} - // Logger wraps slog.Logger with support for custom log levels (Trace, Fatal), // dynamic format patterns, ANSI color modes, and handler configuration. type Logger struct { @@ -29,6 +22,11 @@ type Logger struct { formatSet bool } +// NewLogger initializes and returns a new Logger writing to os.Stderr with default settings. +func NewLogger() *Logger { + return Logger{}.New() +} + // New initializes and returns a new Logger writing to os.Stderr with default settings. func (this Logger) New() *Logger { this.output = os.Stderr diff --git a/internal/logger/logger_suite_test.go b/internal/boot/logger/logger_suite_test.go similarity index 99% rename from internal/logger/logger_suite_test.go rename to internal/boot/logger/logger_suite_test.go index 2a6ec80..3963daf 100644 --- a/internal/logger/logger_suite_test.go +++ b/internal/boot/logger/logger_suite_test.go @@ -20,7 +20,7 @@ import ( . "github.com/onsi/ginkgo/v2" . "github.com/onsi/gomega" - "github.com/BeamFoundry/rays/internal/logger" + "github.com/BeamFoundry/rays/internal/boot/logger" . "github.com/BeamFoundry/rays/pkg/core" . "github.com/BeamFoundry/rays/pkg/lang" . "github.com/BeamFoundry/rays/pkg/testing" @@ -941,7 +941,7 @@ var _ = Describe("PatternHandler", func() { h.SetFormat("%l(%v) %m(%v)") Expect(h.GetFormat()).To(Equal("%l(%v) %m(%v)")) - lg := logger.GetLogger() + lg := logger.NewLogger() lg.SetFormat("%l(%v) - %m(%v)") Expect(lg.GetFormat()).To(Equal("%l(%v) - %m(%v)")) lg.SetColor(true) @@ -963,7 +963,7 @@ var _ = Describe("PatternHandler", func() { Expect(buf.String()).To(Equal("\033[36mINFO\033[0m Forced\n")) // On Logger with non-terminal output (buf) - lg := logger.GetLogger() + lg := logger.NewLogger() lg.SetOutput(buf) lg.EnableColor() Expect(lg.IsColor()).To(BeFalse()) @@ -1086,7 +1086,7 @@ var _ = Describe("Logger and Pattern Coverage Edge Cases", func() { }) It("covers Logger fallback when handler does not implement interfaces", func() { - lg := logger.GetLogger().Clone() + lg := logger.NewLogger().Clone() defer lg.Reset() lg.SetHandler(func(w io.Writer, opts *slog.HandlerOptions) slog.Handler { @@ -1311,7 +1311,7 @@ var _ = Describe("Logger and Pattern Coverage Edge Cases", func() { It("covers additional AST traversal and formatting branches", func() { // Clone with format on Logger - lg := logger.GetLogger().Clone() + lg := logger.NewLogger().Clone() defer lg.Reset() lg.SetFormat("%time(%v) %msg(%v)") clonedWithFormat := lg.Clone() @@ -1716,7 +1716,7 @@ var _ = Describe("Logger and Pattern Coverage Edge Cases", func() { }) It("tracks explicit empty SetFormat independently and reapplies it on clone", func() { - lg := logger.GetLogger().Clone() + lg := logger.NewLogger().Clone() defer lg.Reset() lg.SetFormat("") diff --git a/internal/logger/pattern.go b/internal/boot/logger/pattern.go similarity index 100% rename from internal/logger/pattern.go rename to internal/boot/logger/pattern.go diff --git a/internal/logger/reduce.go b/internal/boot/logger/reduce.go similarity index 100% rename from internal/logger/reduce.go rename to internal/boot/logger/reduce.go diff --git a/internal/logger/terminal_other.go b/internal/boot/logger/terminal_other.go similarity index 100% rename from internal/logger/terminal_other.go rename to internal/boot/logger/terminal_other.go diff --git a/internal/logger/terminal_windows.go b/internal/boot/logger/terminal_windows.go similarity index 100% rename from internal/logger/terminal_windows.go rename to internal/boot/logger/terminal_windows.go diff --git a/internal/container/conditional.go b/internal/container/conditional.go index 257fa42..e6fa358 100644 --- a/internal/container/conditional.go +++ b/internal/container/conditional.go @@ -7,7 +7,8 @@ import ( "strconv" "strings" - "github.com/BeamFoundry/rays/internal/logger" + "github.com/BeamFoundry/rays/internal/boot/logger" + "github.com/BeamFoundry/rays/internal/services" . "github.com/BeamFoundry/rays/pkg/core" ) @@ -30,15 +31,15 @@ func (this *ConditionalOnProfileEvaluator) Profile() string { func (this *ConditionalOnProfileEvaluator) EvaluateBeamCondition(beam Beam, environment InitializingContext) bool { this.reflectType = beam.AnnotatedType() if this.Profile() == "" { - logger.GetLogger().Trace("Empty profile name, assuming true", "type", this.reflectType.Elem().Name()) + services.Get().Logger.Trace("Empty profile name, assuming true", "type", this.reflectType.Elem().Name()) return true } for profile := range strings.SplitSeq(this.Profile(), ",") { if slices.Contains(environment.ActiveProfiles(), profile) { - logger.GetLogger().Trace("Profile found", "type", this.reflectType.Name(), "profile", profile) + services.Get().Logger.Trace("Profile found", "type", this.reflectType.Name(), "profile", profile) return true } - logger.GetLogger().Trace("Profile not found", "type", this.reflectType.Name(), "profile", profile) + services.Get().Logger.Trace("Profile not found", "type", this.reflectType.Name(), "profile", profile) } return false } @@ -90,7 +91,7 @@ func (this *ConditionalOnPropertyEvaluator) EvaluateBeamCondition(beam Beam, con this.reflectType = beam.AnnotatedType() name := this.Name() - this.log = logger.GetLogger().With( + this.log = services.Get().Logger.With( "beam", beam.Type(), "tag", FindFieldWithAnnotation[ConditionalOnProperty](this.reflectType).Tag, ) @@ -138,13 +139,13 @@ func (this *ConditionalOnMissingBeamEvaluator) MissingBeam() any { func (this *ConditionalOnMissingBeamEvaluator) EvaluateBeamCondition(beam Beam, container InitializingContext) bool { this.reflectType = beam.Type() beams := container.FindBeams(this.MissingBeam().(reflect.Type)) - logger.GetLogger().Trace("Evaluating beam", "type", this.reflectType, "quantity", len(beams)) + services.Get().Logger.Trace("Evaluating beam", "type", this.reflectType, "quantity", len(beams)) if len(beams) > 1 { - logger.GetLogger().Trace("Found different beam, returning false", "type", this.reflectType) + services.Get().Logger.Trace("Found different beam, returning false", "type", this.reflectType) return false } - logger.GetLogger().Trace("Returning true", "type", this.reflectType) + services.Get().Logger.Trace("Returning true", "type", this.reflectType) return true } diff --git a/internal/container/config_prov.go b/internal/container/config_prov.go index 0995e76..ae4cf8c 100644 --- a/internal/container/config_prov.go +++ b/internal/container/config_prov.go @@ -4,7 +4,7 @@ import ( "context" "reflect" - "github.com/BeamFoundry/rays/internal/logger" + "github.com/BeamFoundry/rays/internal/services" . "github.com/BeamFoundry/rays/pkg/core" . "github.com/BeamFoundry/rays/pkg/lang" @@ -30,7 +30,7 @@ func (this *ConfigurationBeamProvider) ProvideBeam(reflectType reflect.Type, con // TODO: Force it to be a singleton ??? parent = builder.Build() - logger.GetLogger().Debug("Evaluating registration beam with Configuration Stereotype", "beam", parent.Attributes()[QualifierAttribute], "methods", parent.Type().NumMethod()) + services.Get().Logger.Debug("Evaluating registration beam with Configuration Stereotype", "beam", parent.Attributes()[QualifierAttribute], "methods", parent.Type().NumMethod()) for mt := range parent.Type().Methods() { switch { case mt.Type.NumOut() == 0 || mt.Type.NumOut() > 2: @@ -50,7 +50,7 @@ func (this *ConfigurationBeamProvider) ProvideBeam(reflectType reflect.Type, con continue } } - logger.GetLogger().Trace("Beam with Configuration Stereotype, looking method", "name", mt.Name, "func", mt.Type) + services.Get().Logger.Trace("Beam with Configuration Stereotype, looking method", "name", mt.Name, "func", mt.Type) qualifier := CaseUtils(mt.Name).Uncapitalize().String() annotatedType := parent.Type() @@ -59,20 +59,20 @@ func (this *ConfigurationBeamProvider) ProvideBeam(reflectType reflect.Type, con aType, aValue := container.GetMethodAnnotedType(&mt) if aValue.IsValid() { - logger.GetLogger().Debug("Beam has AnnotedValue", "qualifier", qualifier, "type", mt.Type.Out(0)) + services.Get().Logger.Debug("Beam has AnnotedValue", "qualifier", qualifier, "type", mt.Type.Out(0)) annotatedType = aType shift++ } switch { case mt.Type.NumIn() > shift && mt.Type.In(shift) == reflect.TypeFor[context.Context](): - logger.GetLogger().Debug("Adding Prototype Beam", "qualifier", qualifier, "type", mt.Type.Out(0)) + services.Get().Logger.Debug("Adding Prototype Beam", "qualifier", qualifier, "type", mt.Type.Out(0)) scope = BeamScope{}.New().PROTOTYPE case mt.Type.NumIn() > shift: - logger.GetLogger().Debug("Skipping Beam", "qualifier", qualifier, "type", mt.Type.Out(0)) + services.Get().Logger.Debug("Skipping Beam", "qualifier", qualifier, "type", mt.Type.Out(0)) continue default: - logger.GetLogger().Debug("Adding Singleton Beam", "qualifier", qualifier, "type", mt.Type.Out(0)) + services.Get().Logger.Debug("Adding Singleton Beam", "qualifier", qualifier, "type", mt.Type.Out(0)) } // TODO: Exclude if Prototype and type implements BeamPostConstructWithContext diff --git a/internal/container/container.go b/internal/container/container.go index fb97358..1b66eb0 100644 --- a/internal/container/container.go +++ b/internal/container/container.go @@ -4,40 +4,32 @@ import ( "context" "flag" "fmt" - "io" - "log/slog" - "maps" "reflect" - "slices" "sync" "sync/atomic" - . "github.com/BeamFoundry/rays/internal/config" - "github.com/BeamFoundry/rays/internal/logger" + "github.com/BeamFoundry/rays/internal/boot" + "github.com/BeamFoundry/rays/internal/services" . "github.com/BeamFoundry/rays/pkg/core" . "github.com/BeamFoundry/rays/pkg/lang" ) type Container struct { + *boot.BootLoader *Registry - *Config - loggingProperties *LoggingProperties - scanfor reflect.Type - handler func(io.Writer, *slog.HandlerOptions) slog.Handler - loggers []string - lock sync.Mutex - initialized int32 - scanning bool + scanfor reflect.Type + lock sync.Mutex + initialized int32 + scanning bool // filterScan func(pkgName string) bool } var _ ContainerConfiguration = &Container{} var _container = &Container{ - Registry: NewRegistry(), - Config: NewConfig(), - scanfor: reflect.TypeFor[Stereotype](), - handler: nil, + BootLoader: boot.NewBootLoader(), + Registry: NewRegistry(), + scanfor: reflect.TypeFor[Stereotype](), } func GetContainer() *Container { @@ -73,39 +65,6 @@ func (this *Container) ConfigUnmarshall(path string, object any) Error { return nil } -func (this *Container) SetLoggingHandler(handler func(io.Writer, *slog.HandlerOptions) slog.Handler) { - this.handler = handler -} - -func (this *Container) Logger() Logging { - return &logging{logger.GetLogger()} -} - -func (this *Container) LoggerForClass(class any) Logging { - rtype := reflect.TypeOf(class) - return this.LoggerForType(rtype) -} - -func (this *Container) LoggerForType(rtype reflect.Type) Logging { - if this.loggingProperties == nil || rtype.Kind() != reflect.Pointer || rtype.Elem().Kind() != reflect.Struct { - return this.Logger() - } - - pkg := rtype.Elem().PkgPath() - name := pkg + "/" + rtype.Elem().Name() - for _, level := range this.loggers { - if pkg != level && name != level { - continue - } - logger.GetLogger().Trace("Creating specific new logger", "pkg", pkg, "name", name) - lg := logger.GetLogger().Clone() - logLevel := this.loggingProperties.MapLevels()[level] - lg.SetLogLevel(logLevel.Level()) - return &logging{lg} - } - return this.Logger() -} - func (this *Container) isInitialized() bool { return atomic.CompareAndSwapInt32(&this.initialized, 1, 1) } @@ -172,35 +131,10 @@ func (this *Container) Initialize() Error { return NewError("InitializationError", "Container is already initialized") } - // 1. Initializing configuration & logging - // Initialize configuration - //nolint:errcheck // It should not happen - this.InitConfig() - - // Initializing logging - if cfg, err := this.GetSubConfig("rays.logging"); err == nil { - this.loggingProperties = &LoggingProperties{} - err := cfg.Unmarshal(this.loggingProperties) - if err != nil { - return err - } - level := this.loggingProperties.LogLevel() - logger.GetLogger().SetLogLevel(level.Level()) - if this.loggingProperties.AddSource() { - logger.GetLogger().AddSource() - } - if this.handler != nil { - logger.GetLogger().SetHandler(this.handler) - } else if handler := this.loggingProperties.LogHandler(); handler.Handler(this.loggingProperties.Format()) != nil { - logger.GetLogger().SetHandler(handler.Handler(this.loggingProperties.Format())) - } - this.loggingProperties.LogColor().Apply(logger.GetLogger()) - this.loggers = slices.Sorted(maps.Keys(this.loggingProperties.MapLevels())) - slices.Reverse(this.loggers) + // 1. Initializing configuration & logging via BootLoader + if err := this.Boot(); err != nil { + return err } - logger.GetLogger().SetDefault() - - logger.GetLogger().Debug("Config & Logging initialized") // 2. Scan for Stereotype if this.scanning || flag.Lookup("test.v") == nil { @@ -216,13 +150,13 @@ func (this *Container) Initialize() Error { } for _, t := range FindTypesImplementingInterface(this.scanfor) { if t.Implements(bp) { - logger.GetLogger().Debug("Beam scanning skipping provider beam", "type", t.String()) + services.Get().Logger.Debug("Beam scanning skipping provider beam", "type", t.String()) continue } else if filterScan(t.Elem()) && !filterScan(this.scanfor) { - logger.GetLogger().Trace("Beam scanning skipping filtered beam", "type", t.String()) + services.Get().Logger.Trace("Beam scanning skipping filtered beam", "type", t.String()) continue } - logger.GetLogger().Debug("Beam scanning found beam", "type", t.String()) + services.Get().Logger.Debug("Beam scanning found beam", "type", t.String()) if _, err := this.AddBeamFor(t); err != nil { err.LogError() } @@ -240,7 +174,7 @@ func (this *Container) Initialize() Error { for _, beam := range creatableBeams { if err := this.CreateBeam(beam); err != nil { if err.Name() == "InvalidBeamError" { - logger.GetLogger().Trace("Beam not yet createable", "message", err.Message(), "qualifier", beam.Attributes()[QualifierAttribute]) + services.Get().Logger.Trace("Beam not yet createable", "message", err.Message(), "qualifier", beam.Attributes()[QualifierAttribute]) } else { err.LogError() } @@ -256,7 +190,7 @@ func (this *Container) Initialize() Error { if err := this.InitializeBeam(beam); err != nil { err.LogError() } else { - logger.GetLogger().Debug("Initializing parent beam", "type", beam.Type()) + services.Get().Logger.Debug("Initializing parent beam", "type", beam.Type()) } } } @@ -267,7 +201,7 @@ func (this *Container) Initialize() Error { err := NewError("BeamCreationError", fmt.Sprintf("Unable to create more beams, %d remaining", len(creatableBeams))) err.LogError() for _, beam := range creatableBeams { - logger.GetLogger().Error("BeamCreationError: Beam not created", "qualifier", beam.Attributes()[QualifierAttribute], "type", beam.Type()) + services.Get().Logger.Error("BeamCreationError: Beam not created", "qualifier", beam.Attributes()[QualifierAttribute], "type", beam.Type()) } return err } @@ -286,7 +220,7 @@ func (this *Container) Initialize() Error { } */ - logger.GetLogger().Info("All beam created") + services.Get().Logger.Info("All beam created") injectableBeams := this.GetAllInjectableBeams() injectable := len(injectableBeams) @@ -323,11 +257,11 @@ func (this *Container) Initialize() Error { } } */ - logger.GetLogger().Info("All beam injected") + services.Get().Logger.Info("All beam injected") this.initializeBeams(func(beam Beam) bool { if beam.Type().Kind() != reflect.Interface && FindFieldWithAnnotation[ConfigurationProperties](beam.Type()) != nil { - logger.GetLogger().Debug("Initializing ConfigurationProperties beam", "type", beam.Type()) + services.Get().Logger.Debug("Initializing ConfigurationProperties beam", "type", beam.Type()) return true } return false @@ -344,49 +278,49 @@ func (this *Container) Initialize() Error { } } if init { - logger.GetLogger().Debug("Initializing Configuration beam", "type", beam.AnnotatedType()) + services.Get().Logger.Debug("Initializing Configuration beam", "type", beam.AnnotatedType()) return true } return false }) this.initializeBeams(func(beam Beam) bool { if FindFieldForInterface[Component](beam.Type()) != nil { - logger.GetLogger().Debug("Initializing Component beam", "type", beam.Type()) + services.Get().Logger.Debug("Initializing Component beam", "type", beam.Type()) return true } return false }) this.initializeBeams(func(beam Beam) bool { if FindFieldForInterface[Repository](beam.Type()) != nil { - logger.GetLogger().Debug("Initializing Repository beam", "type", beam.Type()) + services.Get().Logger.Debug("Initializing Repository beam", "type", beam.Type()) return true } return false }) this.initializeBeams(func(beam Beam) bool { if FindFieldForInterface[Service](beam.Type()) != nil { - logger.GetLogger().Debug("Initializing Service beam", "type", beam.Type()) + services.Get().Logger.Debug("Initializing Service beam", "type", beam.Type()) return true } return false }) this.initializeBeams(func(beam Beam) bool { if FindFieldForInterface[Controller](beam.Type()) != nil { - logger.GetLogger().Debug("Initializing Controller beam", "type", beam.Type()) + services.Get().Logger.Debug("Initializing Controller beam", "type", beam.Type()) return true } return false }) this.initializeBeams(func(beam Beam) bool { if FindFieldForInterface[Stereotype](beam.Type()) != nil { - logger.GetLogger().Debug("Initializing Stereotype beam", "type", beam.Type()) + services.Get().Logger.Debug("Initializing Stereotype beam", "type", beam.Type()) return true } return false }) this.initializeBeams(func(beam Beam) bool { - logger.GetLogger().Trace("Initializing remaining beam", "type", beam.Type()) + services.Get().Logger.Trace("Initializing remaining beam", "type", beam.Type()) return true }) @@ -397,7 +331,7 @@ func (this *Container) Initialize() Error { } } - logger.GetLogger().Info("All beam initialized") + services.Get().Logger.Info("All beam initialized") atomic.StoreInt32(&this.initialized, 1) return nil } @@ -410,10 +344,10 @@ func (this *Container) filterConditionalBeams() { continue } if condition := this.GetBeamCondition(beam, includePostEvent); condition != nil && !condition.EvaluateBeamCondition(beam, this) { - logger.GetLogger().Debug("Removing (parent) beam has its condition evaluates to false", "type", beam.Type(), "qualifier", beam.Attributes()[QualifierAttribute]) + services.Get().Logger.Debug("Removing (parent) beam has its condition evaluates to false", "type", beam.Type(), "qualifier", beam.Attributes()[QualifierAttribute]) this.UnregisterBeam(beam) for _, child := range this.GetBeamChildren(beam) { - logger.GetLogger().Debug("Removing (child) beam has its condition evaluates to false", "type", child.Type(), "qualifier", child.Attributes()[QualifierAttribute]) + services.Get().Logger.Debug("Removing (child) beam has its condition evaluates to false", "type", child.Type(), "qualifier", child.Attributes()[QualifierAttribute]) this.UnregisterBeam(child) } } @@ -425,7 +359,7 @@ func (this *Container) filterConditionalBeams() { continue } if condition := this.GetBeamCondition(beam, includePostEvent); condition != nil && !condition.EvaluateBeamCondition(beam, this) { - logger.GetLogger().Debug("Removing child beam has its condition evaluates to false", "type", beam.Type(), "qualifier", beam.Attributes()[QualifierAttribute]) + services.Get().Logger.Debug("Removing child beam has its condition evaluates to false", "type", beam.Type(), "qualifier", beam.Attributes()[QualifierAttribute]) this.UnregisterBeam(beam) } } @@ -449,14 +383,10 @@ func (this *Container) Reset() { this.lock.Lock() defer this.lock.Unlock() this.Registry.Reset() - this.Config.Reset() + this.BootLoader.Reset() this.initialized = 0 this.scanning = false this.scanfor = reflect.TypeFor[Stereotype]() - this.handler = nil - this.loggingProperties = nil - this.loggers = []string{} - logger.GetLogger().Reset() } func (this *Container) Run() { diff --git a/internal/container/instance.go b/internal/container/instance.go index 63b57f8..7778276 100644 --- a/internal/container/instance.go +++ b/internal/container/instance.go @@ -4,7 +4,8 @@ import ( "context" "reflect" - "github.com/BeamFoundry/rays/internal/logger" + "github.com/BeamFoundry/rays/internal/boot/logger" + "github.com/BeamFoundry/rays/internal/services" . "github.com/BeamFoundry/rays/pkg/core" . "github.com/BeamFoundry/rays/pkg/lang" ) @@ -26,7 +27,7 @@ type Instance struct { } func NewInstance(beam Beam) *Instance { - log := logger.GetLogger().WithGroup("beam") + log := services.Get().Logger.WithGroup("beam") instance := &Instance{beam: beam, logger: log} return instance } diff --git a/internal/container/log.go b/internal/container/log.go index ed5a921..776b935 100644 --- a/internal/container/log.go +++ b/internal/container/log.go @@ -2,249 +2,41 @@ package container import ( "context" - "io" - "log/slog" - "maps" "reflect" - "slices" - "github.com/BeamFoundry/rays/internal/logger" + "github.com/BeamFoundry/rays/internal/boot" + "github.com/BeamFoundry/rays/internal/services" . "github.com/BeamFoundry/rays/pkg/core" . "github.com/BeamFoundry/rays/pkg/lang" ) -// LogLevel defines the enumeration of supported logging severity levels. -type LogLevel struct { - Enum[LogLevel] - INFO, // Default level - TRACE, - DEBUG, - WARN, - ERROR, - FATAL Enum[LogLevel] -} - -// Level converts the LogLevel enumeration into the corresponding slog.Level value. -func (this *LogLevel) Level() slog.Level { - switch this.Enum { - case LogLevel{}.New().TRACE: - return LevelTrace - case LogLevel{}.New().DEBUG: - return LevelDebug - case LogLevel{}.New().WARN: - return LevelWarn - case LogLevel{}.New().ERROR: - return LevelError - case LogLevel{}.New().FATAL: - return LevelFatal - default: - return LevelInfo - } -} - -// LogHandler defines the enumeration of supported logging handler implementations. -type LogHandler struct { - Enum[LogHandler] - DEFAULT, - JSON, - TEXT, - ECS, - GELF, - LOGSTASH, - CUSTOM Enum[LogHandler] -} - -// Handler returns a factory function that constructs the slog.Handler corresponding to this LogHandler option. -func (this *LogHandler) Handler(format ...string) func(io.Writer, *slog.HandlerOptions) slog.Handler { - customFormat := logger.DefaultFormat - if len(format) > 0 && format[0] != "" { - customFormat = format[0] - } - switch this.Enum { - case LogHandler{}.New().DEFAULT: - return func(writer io.Writer, options *slog.HandlerOptions) slog.Handler { - return logger.NewHandlerWithFormat(writer, options, logger.DefaultFormat) - } - case LogHandler{}.New().JSON: - return func(writer io.Writer, options *slog.HandlerOptions) slog.Handler { - return logger.NewHandlerWithFormat(writer, options, logger.JSONFormat) - } - case LogHandler{}.New().TEXT: - return func(writer io.Writer, options *slog.HandlerOptions) slog.Handler { - return logger.NewHandlerWithFormat(writer, options, logger.TextFormat) - } - case LogHandler{}.New().CUSTOM: - return func(writer io.Writer, options *slog.HandlerOptions) slog.Handler { - return logger.NewHandlerWithFormat(writer, options, customFormat) - } - case LogHandler{}.New().ECS: - return func(writer io.Writer, options *slog.HandlerOptions) slog.Handler { - return logger.NewHandlerWithFormat(writer, options, logger.ECSFormat) - } - case LogHandler{}.New().GELF: - return func(writer io.Writer, options *slog.HandlerOptions) slog.Handler { - return logger.NewHandlerWithFormat(writer, options, logger.GELFFormat) - } - case LogHandler{}.New().LOGSTASH: - return func(writer io.Writer, options *slog.HandlerOptions) slog.Handler { - return logger.NewHandlerWithFormat(writer, options, logger.LogstashFormat) - } - default: - return nil - } -} - -// LogColor defines the enumeration of terminal ANSI color output options (AUTO, ON, OFF). -type LogColor struct { - Enum[LogColor] - AUTO, - ON, - OFF Enum[LogColor] -} - -// Apply configures the ANSI color output mode on the given logger based on the LogColor setting. -func (this LogColor) Apply(lg *logger.Logger) { - switch this.Enum { - case LogColor{}.New().ON: - lg.SetColor(true) - case LogColor{}.New().OFF: - lg.SetColor(false) - default: - lg.EnableColor() - } -} - -// LoggingProperties holds configuration properties under the "rays.logging" namespace. -type LoggingProperties struct { - ConfigurationProperties `Name:"rays.logging"` - // Loggers - loggers map[string]LogLevel - // Log format for CUSTOM handler - format string - /* configuration keys */ - // Set rays.logging.handler to "DEFAULT", "TEXT", "JSON", "ECS", "GELF", "LOGSTASH" or "CUSTOM" - handler LogHandler - // Set rays.logging.level to "TRACE", "DEBUG", "INFO", "WARN", "ERROR" or "FATAL". - // Defaults to "INFO" - level LogLevel - // Set rays.logging.color to "AUTO", "ON", or "OFF". - // Defaults to "AUTO" - color LogColor - // Add Logging source - source bool -} - -// LogLevel returns the configured logging severity level. -func (this *LoggingProperties) LogLevel() LogLevel { - return this.level -} - -// LogHandler returns the configured log handler type. -func (this *LoggingProperties) LogHandler() LogHandler { - return this.handler -} - -// LogColor returns the configured ANSI color output mode. -func (this *LoggingProperties) LogColor() LogColor { - return this.color -} - -// Color returns the configured ANSI color output mode, mirroring LogColor. -func (this *LoggingProperties) Color() LogColor { - return this.color -} - -// AddSource reports whether caller source file and line information should be added to records. -func (this *LoggingProperties) AddSource() bool { - return this.source -} - -// Format returns the pattern format string configured for custom log handlers. -func (this *LoggingProperties) Format() string { - return this.format -} - -// MapLevels returns the mapping of package paths or logger names to their configured log levels. -func (this *LoggingProperties) MapLevels() map[string]LogLevel { - return this.loggers -} - -// logging wraps a logger.Logger instance to implement the core.Logging interface. -type logging struct { - *logger.Logger -} - -// With returns a new Logging instance with the provided key-value attributes appended. -func (this logging) With(args ...any) Logging { - this.Logger = this.Logger.With(args...) - return &this -} - -// WithGroup returns a new Logging instance that qualifies subsequent attributes with the specified group name. -func (this logging) WithGroup(name string) Logging { - this.Logger = this.Logger.WithGroup(name) - return &this -} +type ( + LogLevel = boot.LogLevel + LogHandler = boot.LogHandler + LogColor = boot.LogColor + LoggingProperties = boot.LoggingProperties +) // LoggingConfiguration is a Stereotype configuration bean that manages logger initialization and injection. type LoggingConfiguration struct { Configuration - properties *LoggingProperties `@:"Inject"` - loggers []string + properties *boot.LoggingProperties `@:"Inject"` } var _, _ = any(&LoggingConfiguration{}).(Stereotype) // PostConstruct initializes the global logger format and color according to loaded logging properties. func (this *LoggingConfiguration) PostConstruct() Error { - this.loggers = slices.Sorted(maps.Keys(this.properties.MapLevels())) - slices.Reverse(this.loggers) - if this.properties.handler.Enum == (LogHandler{}.New().CUSTOM) && this.properties.format != "" { - logger.GetLogger().SetFormat(this.properties.format) - } - this.properties.color.Apply(logger.GetLogger()) - logger.GetLogger().Debug("LoggingConfiguration::PostConstruct()", "loggers", this.loggers) + services.Get().Logger.Debug("LoggingConfiguration::PostConstruct()", "properties", this.properties) return nil } // Logger returns a contextual Logging instance tailored to the injecting bean's package and type. -// -//nolint:contextcheck //TODO: I should remove this check. func (this *LoggingConfiguration) Logger(ctx context.Context) Logging { if ct := ctx.Value(PrototypeContextParentType); ct != nil { if rt, ok := ct.(reflect.Type); ok { - if rt.Kind() == reflect.Pointer { - rt = rt.Elem() - } - pkg := rt.PkgPath() - name := pkg + "/" + rt.Name() - getLogger := func(level string) Logging { - logger.GetLogger().Trace("Creating specific new logger", "pkg", pkg, rt.PkgPath(), rt.Name()) - lg := logger.GetLogger().Clone() - logLevel := this.properties.MapLevels()[level] - lg.SetLogLevel(logLevel.Level()) - return &logging{lg} - } - for _, level := range this.loggers { - if (String(level).EndsWith("/") && String(pkg).StartsWith(level)) || pkg == level || name == level { - return getLogger(level) - } - } + return GetContainer().LoggerForType(rt) } } - return &logging{logger.GetLogger()} -} - -// init registers default logging properties into the container. -func init() { - loggingProperties := map[string]any{ - "handler": LogHandler{}.New().DEFAULT.String(), - "level": LogLevel{}.New().INFO.String(), - "source": true, - "format": logger.DefaultFormat, - "color": LogColor{}.New().AUTO.String(), - } - - //nolint:errcheck // it can not fail - GetContainer().LoadDefaultConfigAt(loggingProperties, "rays.logging") + return GetContainer().Logger() } diff --git a/internal/container/log_test.go b/internal/container/log_test.go index c8e17ab..9d7523f 100644 --- a/internal/container/log_test.go +++ b/internal/container/log_test.go @@ -7,8 +7,8 @@ import ( "strings" _ "unsafe" + "github.com/BeamFoundry/rays/internal/boot/logger" "github.com/BeamFoundry/rays/internal/container" - "github.com/BeamFoundry/rays/internal/logger" . "github.com/BeamFoundry/rays/pkg/core" . "github.com/BeamFoundry/rays/pkg/testing" . "github.com/onsi/ginkgo/v2" diff --git a/internal/container/registry.go b/internal/container/registry.go index 59b67bf..dc63518 100644 --- a/internal/container/registry.go +++ b/internal/container/registry.go @@ -5,7 +5,7 @@ import ( "reflect" "sync" - "github.com/BeamFoundry/rays/internal/logger" + "github.com/BeamFoundry/rays/internal/services" . "github.com/BeamFoundry/rays/pkg/core" . "github.com/BeamFoundry/rays/pkg/lang" ) @@ -39,22 +39,22 @@ func (this *Registry) AddBeamFor(ptr any) (Beam, Error) { if field := FindFieldForInterface[Stereotype](reflectType); field != nil { bpi := reflect.TypeFor[BeamProvider]() if bp, ok := this.providers[field.Type]; ok { - logger.GetLogger().Trace("Cached provider", "stereotype", field.Type.Name(), "provider", bp) + services.Get().Logger.Trace("Cached provider", "stereotype", field.Type.Name(), "provider", bp) if provider, ok := reflect.New(bp.Elem()).Interface().(BeamProvider); ok { return this.registerProvider(reflectType, provider) } } if providers := FindTypesImplementingInterface(bpi, field.Type); len(providers) == 1 { if provider, ok := reflect.New(providers[0].Elem()).Interface().(BeamProvider); ok { - logger.GetLogger().Trace("Found a provider", "stereotype", field.Type.Name(), "provider", providers[0]) + services.Get().Logger.Trace("Found a provider", "stereotype", field.Type.Name(), "provider", providers[0]) this.providers[field.Type] = providers[0] return this.registerProvider(reflectType, provider) } } else { - logger.GetLogger().Trace("Provider not found", "type", field.Type.Name(), "reflectType", reflectType, "len", len(providers)) + services.Get().Logger.Trace("Provider not found", "type", field.Type.Name(), "reflectType", reflectType, "len", len(providers)) } } else { - logger.GetLogger().Trace("No stereotype found", "reflectType", reflectType) + services.Get().Logger.Trace("No stereotype found", "reflectType", reflectType) } builder, err := NewBeamBuilder(ptr) if err != nil { @@ -81,7 +81,7 @@ func (this *Registry) CreateBeam(beam Beam) Error { return NewError("InvalidBeamError", "Child beam and parent not yet initialized") } else if beam.Type() != beam.AnnotatedType() { for field := range beam.AnnotatedType().Elem().Fields() { - logger.GetLogger().Trace("Annotated: Evaluating field to inject", "field.Name", field.Name, "beam", beam.Attributes()[QualifierAttribute]) + services.Get().Logger.Trace("Annotated: Evaluating field to inject", "field.Name", field.Name, "beam", beam.Attributes()[QualifierAttribute]) // TODO: Add parent type to call (instead of recurse) if !this.isFieldInjectable(&field, true) { return NewError("InvalidBeamError", "Annotated Beam not injectable", "field.Name", field.Name, "beam", beam.Attributes()[QualifierAttribute]) @@ -95,7 +95,7 @@ func (this *Registry) CreateBeam(beam Beam) Error { return err } - logger.GetLogger().Debug("Beam instance creation returned a nil pointer, removing beam", "qualifier", beam.Attributes()[QualifierAttribute]) + services.Get().Logger.Debug("Beam instance creation returned a nil pointer, removing beam", "qualifier", beam.Attributes()[QualifierAttribute]) this.unregisterBeam(beam) } return nil @@ -367,10 +367,10 @@ func (this *Registry) InjectBeam(beam Beam) Error { } if beam.Type().Kind() == reflect.Pointer && beam.Type().Elem().Kind() == reflect.Struct { for field := range beam.Type().Elem().Fields() { - logger.GetLogger().Trace("Evaluating field to inject", "field.Name", field.Name, "beam", beam.Attributes()[QualifierAttribute]) + services.Get().Logger.Trace("Evaluating field to inject", "field.Name", field.Name, "beam", beam.Attributes()[QualifierAttribute]) // TODO: Add parent type to call (instead of recurse) if !this.isFieldInjectable(&field, true) { - logger.GetLogger().Trace("Beam not injectable", "field.Name", field.Name, "beam", beam.Attributes()[QualifierAttribute]) + services.Get().Logger.Trace("Beam not injectable", "field.Name", field.Name, "beam", beam.Attributes()[QualifierAttribute]) return NewError("InvalidBeamError", "Beam not injectable", "field.Name", field.Name, "beam", beam.Attributes()[QualifierAttribute]) } } @@ -456,7 +456,7 @@ func (this *Registry) isFieldInjectable(field *reflect.StructField, recurse bool } if beams, err := this.FindBeamsForStructField(field); err != nil { if err.Name() == "InvalidParameter" { - logger.GetLogger().Trace("Field is not meant to be injected") + services.Get().Logger.Trace("Field is not meant to be injected") return true } err.LogError() @@ -464,7 +464,7 @@ func (this *Registry) isFieldInjectable(field *reflect.StructField, recurse bool } else { for _, beam := range beams { if this.GetBeamDefaultInstance(beam).Status().Compare(InstanceStatus{}.New().CREATED) < 0 { - logger.GetLogger().Trace("Found beam but it is not created", "beam", beam.Attributes()[QualifierAttribute]) + services.Get().Logger.Trace("Found beam but it is not created", "beam", beam.Attributes()[QualifierAttribute]) return false } } diff --git a/internal/services/services.go b/internal/services/services.go new file mode 100644 index 0000000..83a2700 --- /dev/null +++ b/internal/services/services.go @@ -0,0 +1,37 @@ +package services + +import ( + "sync/atomic" + + "github.com/BeamFoundry/rays/internal/boot/logger" +) + +// Registry holds references to cross-cutting services initialized during boot. +type Registry struct { + Logger *logger.Logger +} + +var current atomic.Pointer[Registry] + +func init() { + Reset() +} + +// Get returns the currently active service registry. +func Get() *Registry { + return current.Load() +} + +// Register sets the global service registry. If r is nil, it does nothing. +func Register(r *Registry) { + if r != nil { + current.Store(r) + } +} + +// Reset restores the default registry with a fallback Logger. +func Reset() { + current.Store(&Registry{ + Logger: logger.NewLogger(), + }) +} diff --git a/internal/services/services_suite_test.go b/internal/services/services_suite_test.go new file mode 100644 index 0000000..6fd5de6 --- /dev/null +++ b/internal/services/services_suite_test.go @@ -0,0 +1,13 @@ +package services_test + +import ( + "testing" + + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +func TestServices(t *testing.T) { + RegisterFailHandler(Fail) + RunSpecs(t, "Internal/Services Suite") +} diff --git a/internal/services/services_test.go b/internal/services/services_test.go new file mode 100644 index 0000000..68eb2b8 --- /dev/null +++ b/internal/services/services_test.go @@ -0,0 +1,47 @@ +package services_test + +import ( + "github.com/BeamFoundry/rays/internal/boot/logger" + "github.com/BeamFoundry/rays/internal/services" + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +var _ = Describe("Services", func() { + AfterEach(func() { + services.Reset() + }) + + It("returns a non-nil registry by default", func() { + reg := services.Get() + Expect(reg).NotTo(BeNil()) + Expect(reg.Logger).NotTo(BeNil()) + }) + + It("allows registering a custom registry", func() { + customLogger := logger.NewLogger() + customLogger.SetLogLevel(logger.LevelWarn) + services.Register(&services.Registry{ + Logger: customLogger, + }) + Expect(services.Get().Logger).To(Equal(customLogger)) + }) + + It("ignores nil registrations", func() { + regBefore := services.Get() + services.Register(nil) + Expect(services.Get()).To(Equal(regBefore)) + }) + + It("can be reset to defaults", func() { + customLogger := logger.NewLogger() + services.Register(&services.Registry{ + Logger: customLogger, + }) + Expect(services.Get().Logger).To(Equal(customLogger)) + + services.Reset() + Expect(services.Get().Logger).NotTo(Equal(customLogger)) + Expect(services.Get().Logger).NotTo(BeNil()) + }) +}) diff --git a/pkg/lang/error.go b/pkg/lang/error.go index fe08814..3993c65 100644 --- a/pkg/lang/error.go +++ b/pkg/lang/error.go @@ -13,7 +13,7 @@ import ( "runtime" "time" - "github.com/BeamFoundry/rays/internal/logger" + "github.com/BeamFoundry/rays/internal/services" ) // Error is an interface representing an advanced error type, as it implements the [builtin.error] interface. @@ -178,14 +178,14 @@ func (this *DefaultError) Format(state fmt.State, verb rune) { // Log() will log the error at the specified level, with optional arguments func (this *DefaultError) Log(level slog.Level, args ...any) { - if logger.GetLogger().Enabled(context.Background(), logger.LevelError) { + if services.Get().Logger.Enabled(context.Background(), slog.LevelError) { this.logFrames(level, []runtime.Frame{}, args...) } } // LogWithStackTrace() will log error with a stacktrace to its creation, with optional arguments. func (this *DefaultError) LogWithStackTrace(level slog.Level, args ...any) { - if logger.GetLogger().Enabled(context.Background(), logger.LevelError) { + if services.Get().Logger.Enabled(context.Background(), slog.LevelError) { this.logFrames(level, this.frames, args...) } } @@ -193,14 +193,14 @@ func (this *DefaultError) LogWithStackTrace(level slog.Level, args ...any) { // LogError() will log the error at Error level, with optional arguments. func (this *DefaultError) LogError(args ...any) { // TODO: Review why we use logFrames - if logger.GetLogger().Enabled(context.Background(), logger.LevelError) { + if services.Get().Logger.Enabled(context.Background(), slog.LevelError) { this.logFrames(slog.LevelError, []runtime.Frame{}, args...) } } // LogErrorWithStackTrace() will log the error at Error level, wit hstacktrace and optional arguments func (this *DefaultError) LogErrorWithStackTrace(args ...any) { - if logger.GetLogger().Enabled(context.Background(), logger.LevelError) { + if services.Get().Logger.Enabled(context.Background(), slog.LevelError) { this.logFrames(slog.LevelError, this.frames, args...) } } @@ -231,7 +231,7 @@ func (this *DefaultError) logFrames(level slog.Level, frames []runtime.Frame, ar record.Add(this.attributes...) record.Add(args...) //nolint:errcheck // ignoring - logger.GetLogger().Handler().Handle(context.Background(), record) + services.Get().Logger.Handler().Handle(context.Background(), record) } //--- diff --git a/pkg/lang/error_test.go b/pkg/lang/error_test.go index 4f841a7..a96f715 100644 --- a/pkg/lang/error_test.go +++ b/pkg/lang/error_test.go @@ -10,7 +10,6 @@ import ( . "github.com/onsi/ginkgo/v2" . "github.com/onsi/gomega" - "github.com/BeamFoundry/rays/internal/logger" . "github.com/BeamFoundry/rays/pkg/lang" ) @@ -79,10 +78,10 @@ var _ = Describe("NilError", func() { err.AddAttribute("foo", "bar") err.AddAttributes("foo", "bar") err.GomegaString() - err.Log(logger.LevelInfo, "foo", "bar") + err.Log(slog.LevelInfo, "foo", "bar") err.LogError() err.LogErrorWithStackTrace() - err.LogWithStackTrace(logger.LevelDebug, "foo", "bar") + err.LogWithStackTrace(slog.LevelDebug, "foo", "bar") err.PrintStacktrace(os.Stdout) fmt.Fprintf(io.Discard, "err: %+v", err) //nolint:errcheck // Not testing it diff --git a/pkg/testing/testing.go b/pkg/testing/testing.go index 195d847..017fad8 100644 --- a/pkg/testing/testing.go +++ b/pkg/testing/testing.go @@ -6,8 +6,9 @@ import ( . "github.com/onsi/ginkgo/v2" . "github.com/onsi/gomega" + "github.com/BeamFoundry/rays/internal/boot/logger" "github.com/BeamFoundry/rays/internal/container" - "github.com/BeamFoundry/rays/internal/logger" + "github.com/BeamFoundry/rays/internal/services" . "github.com/BeamFoundry/rays/pkg/core" . "github.com/BeamFoundry/rays/pkg/lang" ) @@ -66,7 +67,7 @@ func GetContainer() TestingContext { } func GetLogger() *logger.Logger { - return logger.GetLogger() + return services.Get().Logger } // GetBeam() retrieve a Beam of type T (T must be a struct pointer or interface) @@ -74,10 +75,10 @@ func GetBeam[T any]() Beam { t := reflect.TypeFor[T]() beams := GetContainer().FindBeams(t) if len(beams) > 1 { - logger.GetLogger().Error("Too many Beam found", "type", t.String(), "len", len(beams)) + services.Get().Logger.Error("Too many Beam found", "type", t.String(), "len", len(beams)) return nil // } else if len(beams) == 0 { - // logger.GetLogger().Error("Not one Beam found", "type", t.String(), "len", len(beams)) + // services.Get().Logger.Error("Not one Beam found", "type", t.String(), "len", len(beams)) } Expect(beams).To(HaveLen(1), t.String()) return beams[0] From 5cee9c24caabef54d7d67d22cac47ec2dcc90484 Mon Sep 17 00:00:00 2001 From: Simon HUET Date: Tue, 10 Nov 2026 18:37:56 +0100 Subject: [PATCH 2/3] refactor(config): reuse parent logger in GetSubConfig via internal constructor Introduce an internal constructor `newConfigWithLogger` to allow reusing an existing logger instance when constructing sub-configurations, avoiding the redundant allocation and immediate overwrite of a default logger. - Add internal `newConfigWithLogger(l *logger.Logger) *Config`. - Delegate `NewConfig()` to `newConfigWithLogger(logger.NewLogger())` to preserve independent default logger instantiation for standalone configs. - Update `GetSubConfig()` to construct sub-configs with `newConfigWithLogger(this.logger)`. - Add test in `config_suite_test.go` verifying logger reuse in `GetSubConfig` and logger independence in `NewConfig`. --- internal/boot/config/config.go | 15 +++++++++------ internal/boot/config/config_suite_test.go | 19 +++++++++++++++++++ internal/boot/config/export_test.go | 10 ++++++++++ 3 files changed, 38 insertions(+), 6 deletions(-) create mode 100644 internal/boot/config/export_test.go diff --git a/internal/boot/config/config.go b/internal/boot/config/config.go index 4078a69..285170a 100644 --- a/internal/boot/config/config.go +++ b/internal/boot/config/config.go @@ -64,8 +64,7 @@ func NewConfigFor(m map[string]any) *Config { return c } -// TODO: Define configurable ConfigLoader !!! -func NewConfig() *Config { +func newConfigWithLogger(l *logger.Logger) *Config { return &Config{ configFS: []fs.FS{}, config: NewHashMap[string, any](), @@ -76,10 +75,15 @@ func NewConfig() *Config { profileConfig: NewHashMap[string, HashMap[string, any]](), profiles: []string{}, result: NewHashMap[string, any](), - logger: logger.NewLogger(), + logger: l, } } +// TODO: Define configurable ConfigLoader !!! +func NewConfig() *Config { + return newConfigWithLogger(logger.NewLogger()) +} + func (this *Config) SetLogger(l *logger.Logger) { if l != nil { this.logger = l @@ -387,12 +391,11 @@ func (this *Config) GetSubConfig(key string) (*Config, Error) { c, err := getMapValue(this.getActiveConfig().Map(), keys) if err != nil { - return NewConfig(), err + return newConfigWithLogger(this.logger), err } - s := NewConfig() + s := newConfigWithLogger(this.logger) s.config = NewHashMapFor(c) s.result = NewHashMapFor(c) - s.logger = this.logger s.initialized = 1 if len(keys) == 1 { key, _ = strings.CutPrefix(key, "[") diff --git a/internal/boot/config/config_suite_test.go b/internal/boot/config/config_suite_test.go index b9ad329..c91748b 100644 --- a/internal/boot/config/config_suite_test.go +++ b/internal/boot/config/config_suite_test.go @@ -720,4 +720,23 @@ var _ = Describe("config", func() { Expect(err).NotTo(HaveOccurred()) Expect(v).To(Equal("Victor Hugo")) }) + + It("reuses parent logger in GetSubConfig and creates independent logger in NewConfig", func() { + c1 := config.NewConfig() + c2 := config.NewConfig() + Expect(config.GetConfigLogger(c1)).NotTo(BeNil()) + Expect(config.GetConfigLogger(c2)).NotTo(BeNil()) + Expect(config.GetConfigLogger(c1)).NotTo(BeIdenticalTo(config.GetConfigLogger(c2))) + + customLogger := logger.NewLogger() + c1.SetLogger(customLogger) + Expect(config.GetConfigLogger(c1)).To(BeIdenticalTo(customLogger)) + + Expect(c1.LoadConfig(favorite)).NotTo(HaveOccurred()) + Expect(c1.InitConfig()).NotTo(HaveOccurred()) + + sub, err := c1.GetSubConfig("favorite") + Expect(err).NotTo(HaveOccurred()) + Expect(config.GetConfigLogger(sub)).To(BeIdenticalTo(customLogger)) + }) }) diff --git a/internal/boot/config/export_test.go b/internal/boot/config/export_test.go new file mode 100644 index 0000000..d0d1ad5 --- /dev/null +++ b/internal/boot/config/export_test.go @@ -0,0 +1,10 @@ +package config + +import ( + "github.com/BeamFoundry/rays/internal/boot/logger" +) + +// Exported for internal package tests. +var GetConfigLogger = func(c *Config) *logger.Logger { + return c.logger +} From 87b85979f8663c69a79194d770ad48ef758eb3cf Mon Sep 17 00:00:00 2001 From: Simon HUET Date: Tue, 10 Nov 2026 19:44:28 +0100 Subject: [PATCH 3/3] fix(config): use independent map copies in GetSubConfig Ensure sub-configurations created via `GetSubConfig` instantiate `s.config` and `s.result` using `NewHashMapFrom(c)` instead of `NewHashMapFor(c)`. - Use independent cloned maps for `s.config` and `s.result` so they do not share `c`'s backing map with each other or the parent configuration. - Prevent `sub.Clear()` from mutating the parent's active nested map. - Retain entries in `s.config` across `sub.Clear()` to allow reinitialization via `sub.InitConfig()`. - Add test in `config_suite_test.go` verifying parent map preservation and sub-configuration reinitialization. --- internal/boot/config/config.go | 4 ++-- internal/boot/config/config_suite_test.go | 24 +++++++++++++++++++++++ 2 files changed, 26 insertions(+), 2 deletions(-) diff --git a/internal/boot/config/config.go b/internal/boot/config/config.go index 285170a..b3344f0 100644 --- a/internal/boot/config/config.go +++ b/internal/boot/config/config.go @@ -394,8 +394,8 @@ func (this *Config) GetSubConfig(key string) (*Config, Error) { return newConfigWithLogger(this.logger), err } s := newConfigWithLogger(this.logger) - s.config = NewHashMapFor(c) - s.result = NewHashMapFor(c) + s.config = NewHashMapFrom(c) + s.result = NewHashMapFrom(c) s.initialized = 1 if len(keys) == 1 { key, _ = strings.CutPrefix(key, "[") diff --git a/internal/boot/config/config_suite_test.go b/internal/boot/config/config_suite_test.go index c91748b..facbc01 100644 --- a/internal/boot/config/config_suite_test.go +++ b/internal/boot/config/config_suite_test.go @@ -739,4 +739,28 @@ var _ = Describe("config", func() { Expect(err).NotTo(HaveOccurred()) Expect(config.GetConfigLogger(sub)).To(BeIdenticalTo(customLogger)) }) + + It("ensures subconfiguration uses independent copies and Clear does not mutate parent or prevent reinitialization", func() { + parent := config.NewConfigFor(favorite) + Expect(parent.InitConfig()).NotTo(HaveOccurred()) + + sub, err := parent.GetSubConfig("favorite") + Expect(err).NotTo(HaveOccurred()) + + val, err := sub.GetConfigValue("book.author") + Expect(err).NotTo(HaveOccurred()) + Expect(val).To(Equal("Victor Hugo")) + + // Clearing subconfiguration should not mutate the parent's nested map + sub.Clear() + parentVal, err := parent.GetConfigValue("favorite.book.author") + Expect(err).NotTo(HaveOccurred()) + Expect(parentVal).To(Equal("Victor Hugo")) + + // Clearing subconfiguration should not prevent reinitialization + Expect(sub.InitConfig()).NotTo(HaveOccurred()) + reinitVal, err := sub.GetConfigValue("book.author") + Expect(err).NotTo(HaveOccurred()) + Expect(reinitVal).To(Equal("Victor Hugo")) + }) })