From 5211e25e093683ba3c7c27babf77d0e626909eb3 Mon Sep 17 00:00:00 2001 From: yasun Date: Wed, 14 Jan 2026 11:58:08 +0800 Subject: [PATCH] feat(logger): Integrate database logging with LOG_LEVEL and fix mixed output format update update --- cmd/hyperfleet-api/main.go | 32 ++++++- cmd/hyperfleet-api/migrate/cmd.go | 5 +- cmd/hyperfleet-api/servecmd/cmd.go | 17 +++- docs/logging.md | 131 +++++++++++++++++++++++++++++ pkg/config/db.go | 34 ++++++++ pkg/dao/generic.go | 2 +- pkg/db/db_session/db_session.go | 11 ++- pkg/db/db_session/default.go | 27 ++++-- pkg/db/db_session/test.go | 27 ++++-- pkg/db/db_session/testcontainer.go | 30 ++++--- pkg/logger/gorm_logger.go | 88 +++++++++++++++++++ pkg/logger/text_handler.go | 21 ++++- 12 files changed, 386 insertions(+), 39 deletions(-) create mode 100644 pkg/logger/gorm_logger.go diff --git a/cmd/hyperfleet-api/main.go b/cmd/hyperfleet-api/main.go index 75d5446..26a898d 100755 --- a/cmd/hyperfleet-api/main.go +++ b/cmd/hyperfleet-api/main.go @@ -2,6 +2,7 @@ package main import ( "context" + "io" "log/slog" "os" @@ -46,11 +47,36 @@ func main() { // initDefaultLogger initializes a default logger with INFO level // This ensures logging works before environment/config is loaded +// Reads LOG_LEVEL and LOG_FORMAT from environment variables if set func initDefaultLogger() { + // Read log level from environment with default fallback + level := slog.LevelInfo + if levelStr := os.Getenv("LOG_LEVEL"); levelStr != "" { + if parsed, err := logger.ParseLogLevel(levelStr); err == nil { + level = parsed + } + } + + // Read log format from environment with default fallback + format := logger.FormatJSON + if formatStr := os.Getenv("LOG_FORMAT"); formatStr != "" { + if parsed, err := logger.ParseLogFormat(formatStr); err == nil { + format = parsed + } + } + + // Read log output from environment with default fallback + var output io.Writer = os.Stdout + if outputStr := os.Getenv("LOG_OUTPUT"); outputStr != "" { + if parsed, err := logger.ParseLogOutput(outputStr); err == nil { + output = parsed + } + } + cfg := &logger.LogConfig{ - Level: slog.LevelInfo, - Format: logger.FormatJSON, - Output: os.Stdout, + Level: level, + Format: format, + Output: output, Component: "hyperfleet-api", Version: "unknown", Hostname: getHostname(), diff --git a/cmd/hyperfleet-api/migrate/cmd.go b/cmd/hyperfleet-api/migrate/cmd.go index 8842d41..89b8d34 100755 --- a/cmd/hyperfleet-api/migrate/cmd.go +++ b/cmd/hyperfleet-api/migrate/cmd.go @@ -29,7 +29,10 @@ func NewMigrateCommand() *cobra.Command { return cmd } -func runMigrate(_ *cobra.Command, _ []string) { +func runMigrate(cmd *cobra.Command, _ []string) { + // Bind environment variables + dbConfig.BindEnv(cmd.PersistentFlags()) + if err := runMigrateWithError(); err != nil { os.Exit(1) } diff --git a/cmd/hyperfleet-api/servecmd/cmd.go b/cmd/hyperfleet-api/servecmd/cmd.go index 6835d3f..f19d3ff 100755 --- a/cmd/hyperfleet-api/servecmd/cmd.go +++ b/cmd/hyperfleet-api/servecmd/cmd.go @@ -13,6 +13,7 @@ import ( "github.com/openshift-hyperfleet/hyperfleet-api/cmd/hyperfleet-api/environments" "github.com/openshift-hyperfleet/hyperfleet-api/cmd/hyperfleet-api/server" "github.com/openshift-hyperfleet/hyperfleet-api/pkg/api" + "github.com/openshift-hyperfleet/hyperfleet-api/pkg/db/db_session" "github.com/openshift-hyperfleet/hyperfleet-api/pkg/logger" "github.com/openshift-hyperfleet/hyperfleet-api/pkg/telemetry" ) @@ -37,13 +38,16 @@ func NewServeCommand() *cobra.Command { func runServe(cmd *cobra.Command, args []string) { ctx := context.Background() + // Bind database environment variables BEFORE Initialize (database is initialized inside) + environments.Environment().Config.Database.BindEnv(cmd.PersistentFlags()) + err := environments.Environment().Initialize() if err != nil { logger.WithError(ctx, err).Error("Unable to initialize environment") os.Exit(1) } - // Bind environment variables for advanced configuration (OTel, Masking) + // Bind logging environment variables AFTER Initialize (logger is reconfigured later in initLogger) environments.Environment().Config.Logging.BindEnv(cmd.PersistentFlags()) initLogger() @@ -140,4 +144,15 @@ func initLogger() { // Use ReconfigureGlobalLogger instead of InitGlobalLogger because // InitGlobalLogger was already called in main() with default config logger.ReconfigureGlobalLogger(logConfig) + + // Reconfigure database logger to follow LOG_LEVEL + dbSessionFactory := environments.Environment().Database.SessionFactory + if dbSessionFactory != nil { + gormLevel := environments.Environment().Config.Database.GetGormLogLevel( + environments.Environment().Config.Logging.Level, + ) + if reconfigurable, ok := dbSessionFactory.(db_session.LoggerReconfigurable); ok { + reconfigurable.ReconfigureLogger(gormLevel) + } + } } diff --git a/docs/logging.md b/docs/logging.md index 1bbec4d..064df66 100644 --- a/docs/logging.md +++ b/docs/logging.md @@ -67,6 +67,10 @@ export MASKING_HEADERS="Authorization,Cookie,X-API-Key" # JSON body fields to mask (comma-separated) export MASKING_FIELDS="password,token,secret,api_key" + +# Database debug mode (true/false) +# When true, logs all SQL queries regardless of LOG_LEVEL +export DB_DEBUG=false ``` ### Configuration Struct @@ -251,6 +255,97 @@ export LOG_LEVEL=info No code changes required - the logger automatically adapts output format based on configuration. +## Database Logging + +HyperFleet API automatically integrates database (GORM) logging with the application's `LOG_LEVEL` configuration while providing a `DB_DEBUG` override for database-specific debugging. + +### LOG_LEVEL Integration + +Database logs follow the application log level by default: + +| LOG_LEVEL | GORM Behavior | What Gets Logged | +|-----------|---------------|------------------| +| `debug` | Info level | All SQL queries with parameters, duration, and row counts | +| `info` | Warn level | Only slow queries (>200ms) and errors | +| `warn` | Warn level | Only slow queries (>200ms) and errors | +| `error` | Silent | Nothing (database logging disabled) | + +### DB_DEBUG Override + +The `DB_DEBUG` environment variable provides database-specific debugging without changing the global `LOG_LEVEL`: + +```bash +# Production environment with database debugging +export LOG_LEVEL=info # Application logs remain at INFO +export LOG_FORMAT=json # Production format +export DB_DEBUG=true # Force all SQL queries to be logged +./bin/hyperfleet-api serve +``` + +**Priority:** +1. If `DB_DEBUG=true`, all SQL queries are logged (GORM Info level) +2. Otherwise, follow `LOG_LEVEL` mapping (see table above) + +### Database Log Examples + +**Fast query (LOG_LEVEL=debug or DB_DEBUG=true):** + +JSON format: +```json +{ + "timestamp": "2026-01-14T11:31:29.788683+08:00", + "level": "info", + "message": "GORM query", + "duration_ms": 9.052167, + "rows": 1, + "sql": "INSERT INTO \"clusters\" (\"id\",\"created_time\",...) VALUES (...)", + "component": "api", + "version": "0120ac6-modified", + "hostname": "yasun-mac", + "request_id": "38EOuujxBDUduP0hYLxVGMm69Dq", + "transaction_id": 1157 +} +``` + +Text format: +```text +2026-01-14T11:34:23+08:00 INFO [api] [0120ac6-modified] [yasun-mac] GORM query request_id=38EPGnassU9SLNZ82XiXZLiWS4i duration_ms=10.135875 rows=1 sql="INSERT INTO \"clusters\" (\"id\",\"created_time\",...) VALUES (...)" +``` + +**Slow query (>200ms, visible at all log levels except error):** + +```json +{ + "timestamp": "2026-01-14T12:00:00Z", + "level": "warn", + "message": "GORM query", + "duration_ms": 250.5, + "rows": 1000, + "sql": "SELECT * FROM clusters WHERE ...", + "request_id": "...", + "transaction_id": 1234 +} +``` + +**Database error (visible at all log levels):** + +```json +{ + "timestamp": "2026-01-14T12:00:00Z", + "level": "error", + "message": "GORM query error", + "error": "pq: duplicate key value violates unique constraint \"idx_clusters_name\"", + "duration_ms": 10.5, + "rows": 0, + "sql": "INSERT INTO \"clusters\" ...", + "request_id": "..." +} +``` + +### Backward Compatibility + +The existing `--enable-db-debug` CLI flag and `DB_DEBUG` environment variable continue to work exactly as before. The new functionality only adds automatic integration with `LOG_LEVEL` when `DB_DEBUG` is not explicitly set. + ## Log Output Examples ### Error Logs with Stack Traces @@ -354,6 +449,8 @@ env().Config.Logging.Masking.Fields = append( ## Best Practices +### Application Logging + 1. **Always use context**: `logger.Info(ctx, "msg")` not `slog.Info("msg")` 2. **Use WithError for errors**: `logger.WithError(ctx, err).Error(...)` not `"error", err` 3. **Use field constants**: `logger.FieldEnvironment` not `"environment"` @@ -362,6 +459,15 @@ env().Config.Logging.Masking.Fields = append( 6. **Never log sensitive data**: Always sanitize passwords, tokens, connection strings 7. **Choose appropriate levels**: DEBUG (dev), INFO (normal), WARN (client error), ERROR (server error) +### Database Logging + +1. **Use LOG_LEVEL for database logs**: Don't set `DB_DEBUG` unless specifically debugging database issues +2. **Production default**: `LOG_LEVEL=info` hides fast queries, shows slow queries (>200ms) +3. **Temporary debugging**: Use `DB_DEBUG=true` for production database troubleshooting, then disable it +4. **Development**: Use `LOG_LEVEL=debug` to see all SQL queries during development +5. **High-traffic systems**: Consider `LOG_LEVEL=warn` to minimize database log volume +6. **Monitor slow queries**: Review WARN-level GORM logs for queries exceeding 200ms threshold + ## Troubleshooting ### Logs Not Appearing @@ -392,6 +498,31 @@ mainRouter.Use(logging.RequestLoggingMiddleware) 2. Verify field names match configuration (case-insensitive) 3. Check JSON structure: Masking only works on top-level fields +### SQL Queries Not Appearing + +1. Check log level: `export LOG_LEVEL=debug` (to see all SQL queries) +2. Check DB_DEBUG: `export DB_DEBUG=true` (to force SQL logging at any log level) +3. Verify queries are executing: Check if API operations complete successfully +4. Check log format: Use `LOG_FORMAT=text` for easier debugging + +### Too Many SQL Queries in Logs + +1. Production mode: `export LOG_LEVEL=info` (hides fast queries < 200ms) +2. Disable DB_DEBUG: `export DB_DEBUG=false` or unset it +3. Minimal mode: `export LOG_LEVEL=warn` (only slow queries and errors) +4. Silent mode: `export LOG_LEVEL=error` (no SQL queries logged) + +### Only Want to See Slow Queries + +Use production default configuration: +```bash +export LOG_LEVEL=info +export LOG_FORMAT=json +export DB_DEBUG=false # or leave unset +``` + +This will only log SQL queries that take longer than 200ms. + ## Testing ### Unit Tests diff --git a/pkg/config/db.go b/pkg/config/db.go index 65c95ae..28926ce 100755 --- a/pkg/config/db.go +++ b/pkg/config/db.go @@ -2,8 +2,12 @@ package config import ( "fmt" + "os" + "strconv" + "strings" "github.com/spf13/pflag" + "gorm.io/gorm/logger" ) type DatabaseConfig struct { @@ -54,6 +58,19 @@ func (c *DatabaseConfig) AddFlags(fs *pflag.FlagSet) { fs.IntVar(&c.MaxOpenConnections, "db-max-open-connections", c.MaxOpenConnections, "Maximum open DB connections for this instance") } +// BindEnv reads configuration from environment variables +// Priority: flags > env vars > defaults +func (c *DatabaseConfig) BindEnv(fs *pflag.FlagSet) { + if val := os.Getenv("DB_DEBUG"); val != "" { + if fs == nil || !fs.Changed("enable-db-debug") { + debug, err := strconv.ParseBool(val) + if err == nil { + c.Debug = debug + } + } + } +} + func (c *DatabaseConfig) ReadFiles() error { err := readFileValueString(c.HostFile, &c.Host) if err != nil { @@ -117,3 +134,20 @@ func (c *DatabaseConfig) LogSafeConnectionStringWithName(name string, withSSL bo ) } } + +// GetGormLogLevel returns the appropriate GORM log level based on DB_DEBUG and LOG_LEVEL. +// DB_DEBUG=true always returns Info level, otherwise follows LOG_LEVEL mapping. +func (c *DatabaseConfig) GetGormLogLevel(logLevel string) logger.LogLevel { + if c.Debug { + return logger.Info + } + + switch strings.ToLower(strings.TrimSpace(logLevel)) { + case "debug": + return logger.Info + case "error": + return logger.Silent + default: + return logger.Warn + } +} diff --git a/pkg/dao/generic.go b/pkg/dao/generic.go index 1038f3f..4a59516 100755 --- a/pkg/dao/generic.go +++ b/pkg/dao/generic.go @@ -66,7 +66,7 @@ func (d *sqlGenericDao) GetInstanceDao(ctx context.Context, model interface{}) G } func (d *sqlGenericDao) Fetch(offset int, limit int, resourceList interface{}) error { - return d.g2.Debug().Offset(offset).Limit(limit).Find(resourceList).Error + return d.g2.Offset(offset).Limit(limit).Find(resourceList).Error } func (d *sqlGenericDao) Preload(preload string) { diff --git a/pkg/db/db_session/db_session.go b/pkg/db/db_session/db_session.go index c05baa8..04c0364 100755 --- a/pkg/db/db_session/db_session.go +++ b/pkg/db/db_session/db_session.go @@ -1,9 +1,18 @@ package db_session -import "sync" +import ( + "sync" + + "gorm.io/gorm/logger" +) const ( disable = "disable" ) var once sync.Once + +// LoggerReconfigurable allows runtime reconfiguration of the database logger +type LoggerReconfigurable interface { + ReconfigureLogger(level logger.LogLevel) +} diff --git a/pkg/db/db_session/default.go b/pkg/db/db_session/default.go index 7968e59..d1d998a 100755 --- a/pkg/db/db_session/default.go +++ b/pkg/db/db_session/default.go @@ -17,6 +17,8 @@ import ( "github.com/openshift-hyperfleet/hyperfleet-api/pkg/logger" ) +const slowQueryThreshold = 200 * time.Millisecond + type Default struct { config *config.DatabaseConfig @@ -64,10 +66,17 @@ func (f *Default) Init(config *config.DatabaseConfig) { } dbx.SetMaxOpenConns(config.MaxOpenConnections) - // Connect GORM to use the same connection + var gormLog gormlogger.Interface + if config.Debug { + gormLog = logger.NewGormLogger(gormlogger.Info, slowQueryThreshold) + } else { + gormLog = logger.NewGormLogger(gormlogger.Warn, slowQueryThreshold) + } + conf := &gorm.Config{ PrepareStmt: false, FullSaveAssociations: false, + Logger: gormLog, } g2, err = gorm.Open(postgres.New(postgres.Config{ Conn: dbx, @@ -140,14 +149,9 @@ func (f *Default) NewListener(ctx context.Context, channel string, callback func } func (f *Default) New(ctx context.Context) *gorm.DB { - conn := f.g2.Session(&gorm.Session{ + return f.g2.Session(&gorm.Session{ Context: ctx, - Logger: f.g2.Logger.LogMode(gormlogger.Silent), }) - if f.config.Debug { - conn = conn.Debug() - } - return conn } func (f *Default) CheckConnection() error { @@ -164,3 +168,12 @@ func (f *Default) Close() error { func (f *Default) ResetDB() { panic("ResetDB is not implemented for non-integration-test env") } + +// ReconfigureLogger changes the GORM logger level at runtime +func (f *Default) ReconfigureLogger(level gormlogger.LogLevel) { + if f.g2 == nil { + return + } + newLogger := logger.NewGormLogger(level, slowQueryThreshold) + f.g2.Logger = newLogger +} diff --git a/pkg/db/db_session/test.go b/pkg/db/db_session/test.go index 1e6e5b6..9e17694 100755 --- a/pkg/db/db_session/test.go +++ b/pkg/db/db_session/test.go @@ -126,12 +126,18 @@ func connect(name string, config *config.DatabaseConfig) (*sql.DB, *gorm.DB, fun } } - // Connect GORM to use the same connection + var gormLog gormlogger.Interface + if config.Debug { + gormLog = logger.NewGormLogger(gormlogger.Info, slowQueryThreshold) + } else { + gormLog = logger.NewGormLogger(gormlogger.Silent, slowQueryThreshold) + } + conf := &gorm.Config{ PrepareStmt: false, FullSaveAssociations: false, SkipDefaultTransaction: true, - Logger: gormlogger.Default.LogMode(gormlogger.Silent), + Logger: gormLog, } g2, err = gorm.Open(postgres.New(postgres.Config{ Conn: dbx, @@ -188,19 +194,13 @@ func (f *Test) DirectDB() *sql.DB { func (f *Test) New(ctx context.Context) *gorm.DB { if f.wasDisconnected { - // Connection was killed in order to reset DB f.db, f.g2 = connectFactory(f.config) f.wasDisconnected = false } - conn := f.g2.Session(&gorm.Session{ + return f.g2.Session(&gorm.Session{ Context: ctx, - Logger: f.g2.Logger.LogMode(gormlogger.Silent), }) - if f.config.Debug { - conn = conn.Debug() - } - return conn } // CheckConnection checks to ensure a connection is present @@ -223,3 +223,12 @@ func (f *Test) ResetDB() { func (f *Test) NewListener(ctx context.Context, channel string, callback func(id string)) { newListener(ctx, f.config.ConnectionString(true), channel, callback) } + +// ReconfigureLogger changes the GORM logger level at runtime +func (f *Test) ReconfigureLogger(level gormlogger.LogLevel) { + if f.g2 == nil { + return + } + newLogger := logger.NewGormLogger(level, slowQueryThreshold) + f.g2.Logger = newLogger +} diff --git a/pkg/db/db_session/testcontainer.go b/pkg/db/db_session/testcontainer.go index a3caf45..8ea19c9 100755 --- a/pkg/db/db_session/testcontainer.go +++ b/pkg/db/db_session/testcontainer.go @@ -98,16 +98,18 @@ func (f *Testcontainer) Init(config *config.DatabaseConfig) { // Configure connection pool f.sqlDB.SetMaxOpenConns(config.MaxOpenConnections) - // Connect GORM to use the same connection + var gormLog gormlogger.Interface + if config.Debug { + gormLog = logger.NewGormLogger(gormlogger.Info, slowQueryThreshold) + } else { + gormLog = logger.NewGormLogger(gormlogger.Silent, slowQueryThreshold) + } + conf := &gorm.Config{ PrepareStmt: false, FullSaveAssociations: false, SkipDefaultTransaction: true, - Logger: gormlogger.Default.LogMode(gormlogger.Silent), - } - - if config.Debug { - conf.Logger = gormlogger.Default.LogMode(gormlogger.Info) + Logger: gormLog, } f.g2, err = gorm.Open(gormpostgres.New(gormpostgres.Config{ @@ -134,14 +136,9 @@ func (f *Testcontainer) DirectDB() *sql.DB { } func (f *Testcontainer) New(ctx context.Context) *gorm.DB { - conn := f.g2.Session(&gorm.Session{ + return f.g2.Session(&gorm.Session{ Context: ctx, - Logger: f.g2.Logger.LogMode(gormlogger.Silent), }) - if f.config.Debug { - conn = conn.Debug() - } - return conn } func (f *Testcontainer) CheckConnection() error { @@ -203,3 +200,12 @@ func (f *Testcontainer) NewListener(ctx context.Context, channel string, callbac newListener(ctx, connStr, channel, callback) } + +// ReconfigureLogger changes the GORM logger level at runtime +func (f *Testcontainer) ReconfigureLogger(level gormlogger.LogLevel) { + if f.g2 == nil { + return + } + newLogger := logger.NewGormLogger(level, slowQueryThreshold) + f.g2.Logger = newLogger +} diff --git a/pkg/logger/gorm_logger.go b/pkg/logger/gorm_logger.go new file mode 100644 index 0000000..8e60dc0 --- /dev/null +++ b/pkg/logger/gorm_logger.go @@ -0,0 +1,88 @@ +package logger + +import ( + "context" + "errors" + "fmt" + "time" + + gormlogger "gorm.io/gorm/logger" +) + +type GormLogger struct { + logLevel gormlogger.LogLevel + slowThreshold time.Duration +} + +func NewGormLogger(logLevel gormlogger.LogLevel, slowThreshold time.Duration) *GormLogger { + return &GormLogger{ + logLevel: logLevel, + slowThreshold: slowThreshold, + } +} + +func (l *GormLogger) LogMode(level gormlogger.LogLevel) gormlogger.Interface { + return &GormLogger{ + logLevel: level, + slowThreshold: l.slowThreshold, + } +} + +func (l *GormLogger) Info(ctx context.Context, msg string, data ...interface{}) { + if l.logLevel >= gormlogger.Info { + With(ctx, "gorm_info", formatMessage(msg, data)).Info("GORM info") + } +} + +func (l *GormLogger) Warn(ctx context.Context, msg string, data ...interface{}) { + if l.logLevel >= gormlogger.Warn { + With(ctx, "gorm_warn", formatMessage(msg, data)).Warn("GORM warning") + } +} + +func (l *GormLogger) Error(ctx context.Context, msg string, data ...interface{}) { + if l.logLevel >= gormlogger.Error { + With(ctx, "gorm_error", formatMessage(msg, data)).Error("GORM error") + } +} + +func (l *GormLogger) Trace(ctx context.Context, begin time.Time, fc func() (sql string, rowsAffected int64), err error) { + if l.logLevel <= gormlogger.Silent { + return + } + + elapsed := time.Since(begin) + sql, rows := fc() + + switch { + case err != nil && l.logLevel >= gormlogger.Error && !errors.Is(err, gormlogger.ErrRecordNotFound): + With(ctx, + "error", err.Error(), + "duration_ms", float64(elapsed.Nanoseconds())/1e6, + "rows", rows, + "sql", sql, + ).Error("GORM query error") + + case elapsed > l.slowThreshold && l.slowThreshold != 0 && l.logLevel >= gormlogger.Warn: + With(ctx, + "duration_ms", float64(elapsed.Nanoseconds())/1e6, + "threshold_ms", float64(l.slowThreshold.Nanoseconds())/1e6, + "rows", rows, + "sql", sql, + ).Warn("GORM slow query") + + case l.logLevel >= gormlogger.Info: + With(ctx, + "duration_ms", float64(elapsed.Nanoseconds())/1e6, + "rows", rows, + "sql", sql, + ).Info("GORM query") + } +} + +func formatMessage(msg string, data []interface{}) string { + if len(data) == 0 { + return msg + } + return fmt.Sprintf(msg, data...) +} diff --git a/pkg/logger/text_handler.go b/pkg/logger/text_handler.go index 87fe151..b69fd14 100644 --- a/pkg/logger/text_handler.go +++ b/pkg/logger/text_handler.go @@ -22,6 +22,7 @@ type HyperFleetTextHandler struct { version string hostname string level slog.Level + attrs []slog.Attr mu sync.Mutex } @@ -57,6 +58,10 @@ func (h *HyperFleetTextHandler) Handle(ctx context.Context, r slog.Record) error } } + for _, attr := range h.attrs { + fmt.Fprintf(&buf, " %s=%s", attr.Key, formatValue(attr.Value.Any())) + } + var stackTrace []string r.Attrs(func(a slog.Attr) bool { if a.Key == "stack_trace" { @@ -84,11 +89,19 @@ func (h *HyperFleetTextHandler) Handle(ctx context.Context, r slog.Record) error return err } -// WithAttrs returns a new handler with additional attributes func (h *HyperFleetTextHandler) WithAttrs(attrs []slog.Attr) slog.Handler { - // For simplicity, return self (attributes are handled in Handle method) - // In a production system, you might want to store attrs and include them in every log - return h + newAttrs := make([]slog.Attr, len(h.attrs)+len(attrs)) + copy(newAttrs, h.attrs) + copy(newAttrs[len(h.attrs):], attrs) + + return &HyperFleetTextHandler{ + w: h.w, + component: h.component, + version: h.version, + hostname: h.hostname, + level: h.level, + attrs: newAttrs, + } } // WithGroup returns a new handler with a group name