diff --git a/log/README.md b/log/README.md new file mode 100644 index 00000000..9c46298f --- /dev/null +++ b/log/README.md @@ -0,0 +1,145 @@ +# Log Package + +The log package provides structured logging capabilities for Moov applications. + +## Usage + +### Basic Logging + +```go +import "github.com/moov-io/base/log" + +// Create a new logger +logger := log.NewDefaultLogger() + +// Log a message with different levels +logger.Info().Log("Application started") +logger.Debug().Log("Debug information") +logger.Warn().Log("Warning message") +logger.Error().Log("Error occurred") + +// Log with key-value pairs +logger.Info().Set("request_id", log.String("12345")).Log("Processing request") + +// Log formatted messages +logger.Infof("Processing request %s", "12345") + +// Log errors +err := someFunction() +if err != nil { + logger.LogError(err) +} +``` + +### Using Fields + +```go +import "github.com/moov-io/base/log" + +// Create a map of fields +fields := log.Fields{ + "request_id": log.String("12345"), + "user_id": log.Int(42), + "timestamp": log.Time(time.Now()), +} + +// Log with fields +logger.With(fields).Info().Log("Request processed") +``` + +### Using StructContext + +The `StructContext` function allows you to log struct fields automatically by using tags. + +```go +import "github.com/moov-io/base/log" + +// Define a struct with log tags +type User struct { + ID int `log:"id"` + Username string `log:"username"` + Email string `log:"email,omitempty"` // won't be logged if empty + Address Address `log:"address"` // nested struct must have log tag + Hidden string // no log tag, won't be logged +} + +type Address struct { + Street string `log:"street"` + City string `log:"city"` + Country string `log:"country"` +} + +// Create a user +user := User{ + ID: 1, + Username: "johndoe", + Email: "john@example.com", + Address: Address{ + Street: "123 Main St", + City: "New York", + Country: "USA", + }, + Hidden: "secret", +} + +// Log with struct context +logger.With(log.StructContext(user)).Info().Log("User logged in") + +// Log with struct context and prefix +logger.With(log.StructContext(user, log.WithPrefix("user"))).Info().Log("User details") + +// Using custom tag other than "log" +type Product struct { + ID int `otel:"product_id"` + Name string `otel:"product_name"` + Price float64 `otel:"price,omitempty"` +} + +product := Product{ + ID: 42, + Name: "Widget", + Price: 19.99, +} + +// Use otel tags instead of log tags +logger.With(log.StructContext(product, log.WithTag("otel"))).Info().Log("Product details") +``` + +The above will produce log entries with the following fields: +- `id=1` +- `username=johndoe` +- `email=john@example.com` +- `address.street=123 Main St` +- `address.city=New York` +- `address.country=USA` + +With the prefix option, the fields will be: +- `user.id=1` +- `user.username=johndoe` +- `user.email=john@example.com` +- `user.address.street=123 Main St` +- `user.address.city=New York` +- `user.address.country=USA` + +With the custom tag option, the fields will be extracted from the tag you specify (such as `otel`): +- `product_id=42` +- `product_name=Widget` +- `price=19.99` + +Note that nested structs or pointers to structs must have the specified tag to be included in the context. + +## Features + +- Structured logging with key-value pairs +- Multiple log levels (Debug, Info, Warn, Error, Fatal) +- JSON and LogFmt output formats +- Context-based logging +- Automatic struct field logging with StructContext +- Support for various value types (string, int, float, bool, time, etc.) + +## Configuration + +The default logger format is determined by the `MOOV_LOG_FORMAT` environment variable: +- `json`: JSON format +- `logfmt`: LogFmt format (default) +- `nop` or `noop`: No-op logger that discards all logs diff --git a/log/struct_context.go b/log/struct_context.go new file mode 100644 index 00000000..4111eabe --- /dev/null +++ b/log/struct_context.go @@ -0,0 +1,182 @@ +package log + +import ( + "fmt" + "reflect" + "slices" + "strings" + "time" +) + +// StructContextOption defines options for StructContext +type StructContextOption func(*structContext) + +// WithPrefix adds a prefix to all struct field names +func WithPrefix(prefix string) StructContextOption { + return func(sc *structContext) { + sc.prefix = prefix + } +} + +// WithTag adds a custom tag to look for in struct fields +func WithTag(tag string) StructContextOption { + return func(sc *structContext) { + sc.tag = tag + } +} + +// structContext implements the Context interface for struct fields +type structContext struct { + fields map[string]Valuer + prefix string + tag string +} + +// Context returns a map of field names to Valuer implementations +func (sc *structContext) Context() map[string]Valuer { + return sc.fields +} + +// StructContext creates a Context from a struct, extracting fields tagged with `log` +// It supports nested structs and respects omitempty directive +func StructContext(v interface{}, opts ...StructContextOption) Context { + sc := &structContext{ + fields: make(map[string]Valuer), + prefix: "", + tag: "log", + } + + // Apply options + for _, opt := range opts { + opt(sc) + } + + if v == nil { + return sc + } + + value := reflect.ValueOf(v) + extractFields(value, sc, "") + + return sc +} + +// extractFields recursively extracts fields from a struct value +func extractFields(value reflect.Value, sc *structContext, path string) { + // If it's a pointer, dereference it + if value.Kind() == reflect.Ptr { + if value.IsNil() { + return + } + value = value.Elem() + } + + // Only process structs + if value.Kind() != reflect.Struct { + return + } + + typ := value.Type() + for i := range typ.NumField() { + field := typ.Field(i) + fieldValue := value.Field(i) + + // Skip unexported fields + if !field.IsExported() { + continue + } + + // Get the log tag + tag := field.Tag.Get(sc.tag) + if tag == "" { + // Skip fields without log tag + continue + } + + // Parse the tag + tagParts := strings.Split(tag, ",") + fieldName := tagParts[0] + if fieldName == "" { + fieldName = field.Name + } + + // Handle omitempty + omitEmpty := slices.Contains(tagParts, "omitempty") + + // Build the full field name with path and prefix + fullName := fieldName + if path != "" { + fullName = path + "." + fieldName + } + + // we add prefis only once, for the field on the first level + if path == "" && sc.prefix != "" { + fullName = sc.prefix + "." + fullName + } + + // Check if field should be omitted due to empty value + if omitEmpty && fieldValue.IsZero() { + continue + } + + // Store the field value + valuer := valueToValuer(fieldValue) + if valuer != nil { + sc.fields[fullName] = valuer + } + + // If it's a struct, recursively extract its fields only if it has a log tag + if fieldValue.Kind() == reflect.Struct || + (fieldValue.Kind() == reflect.Ptr && !fieldValue.IsNil() && fieldValue.Elem().Kind() == reflect.Struct) { + extractFields(fieldValue, sc, fullName) + } + } +} + +// valueToValuer converts a reflect.Value to a Valuer +func valueToValuer(v reflect.Value) Valuer { + if !v.IsValid() { + return nil + } + + //nolint:exhaustive + switch v.Kind() { + case reflect.Bool: + return Bool(v.Bool()) + case reflect.Int, reflect.Int8, reflect.Int16, reflect.Int32, reflect.Int64: + return Int64(v.Int()) + case reflect.Uint, reflect.Uint8, reflect.Uint16, reflect.Uint32, reflect.Uint64: + return Uint64(v.Uint()) + case reflect.Float32: + return Float32(float32(v.Float())) + case reflect.Float64: + return Float64(v.Float()) + case reflect.String: + return String(v.String()) + case reflect.Ptr: + if v.IsNil() { + return &any{nil} + } + return valueToValuer(v.Elem()) + case reflect.Struct: + // Check if it's a time.Time + if v.Type().String() == "time.Time" { + if v.CanInterface() { + t, ok := v.Interface().(time.Time) + if ok { + return Time(t) + } + } + } + } + + // Try to use Stringer for complex types + if v.CanInterface() { + if stringer, ok := v.Interface().(fmt.Stringer); ok { + return Stringer(stringer) + } + } + + // Return as string representation for other types + return String(fmt.Sprintf("%v", v.Interface())) +} diff --git a/log/struct_context_test.go b/log/struct_context_test.go new file mode 100644 index 00000000..76b31b1f --- /dev/null +++ b/log/struct_context_test.go @@ -0,0 +1,252 @@ +package log + +import ( + "testing" + "time" + + "github.com/stretchr/testify/require" +) + +func TestStructContext(t *testing.T) { + type Address struct { + Street string `log:"street"` + City string `log:"city"` + Country string `log:"country"` + ZipCode string `log:"zip_code,omitempty"` + } + + type Person struct { + Name string `log:"name"` + Age int `log:"age"` + Email string `log:"email,omitempty"` + CreatedAt time.Time `log:"created_at"` + Address Address `log:"address"` + Hidden string + } + + now := time.Now() + p := Person{ + Name: "John Doe", + Age: 30, + Email: "john@example.com", + CreatedAt: now, + Address: Address{ + Street: "123 Main St", + City: "New York", + Country: "USA", + }, + Hidden: "should not appear", + } + + // Test basic struct context + ctx := StructContext(p) + fields := ctx.Context() + + require.Equal(t, 8, len(fields)) + require.Equal(t, "John Doe", fields["name"].getValue()) + require.Equal(t, int64(30), fields["age"].getValue()) + require.Equal(t, "john@example.com", fields["email"].getValue()) + require.Equal(t, now.Format(time.RFC3339Nano), fields["created_at"].getValue()) + require.Equal(t, "123 Main St", fields["address.street"].getValue()) + require.Equal(t, "New York", fields["address.city"].getValue()) + require.Equal(t, "USA", fields["address.country"].getValue()) + require.Contains(t, fields, "address") // The struct itself is also included + require.NotContains(t, fields, "Hidden") + require.NotContains(t, fields, "address.zip_code") // Should be omitted as it's empty + + // Test with prefix + ctx = StructContext(p, WithPrefix("user")) + fields = ctx.Context() + + require.Equal(t, 8, len(fields)) + require.Equal(t, "John Doe", fields["user.name"].getValue()) + require.Equal(t, int64(30), fields["user.age"].getValue()) + require.Equal(t, "john@example.com", fields["user.email"].getValue()) + require.Equal(t, now.Format(time.RFC3339Nano), fields["user.created_at"].getValue()) + require.Equal(t, "123 Main St", fields["user.address.street"].getValue()) + require.Equal(t, "New York", fields["user.address.city"].getValue()) + require.Equal(t, "USA", fields["user.address.country"].getValue()) + require.Contains(t, fields, "user.address") // The struct itself is also included + + // Test with nil value + ctx = StructContext(nil) + require.Empty(t, ctx.Context()) + + // Test omitempty behavior + p.Email = "" + ctx = StructContext(p) + fields = ctx.Context() + require.NotContains(t, fields, "email") // Should be omitted as it's empty + + // Test with pointer to struct + ctx = StructContext(&p) + fields = ctx.Context() + require.Equal(t, 7, len(fields)) // email is empty and omitted + require.Equal(t, "John Doe", fields["name"].getValue()) + + // Test nested pointer structs + type Department struct { + Name string `log:"name"` + } + + type Company struct { + Dept *Department `log:"department"` + } + + type Employee struct { + Company *Company `log:"company"` + } + + employee := Employee{ + Company: &Company{ + Dept: &Department{ + Name: "Engineering", + }, + }, + } + + ctx = StructContext(employee) + fields = ctx.Context() + require.Equal(t, 3, len(fields)) + require.Contains(t, fields, "company") + require.Contains(t, fields, "company.department") + require.Equal(t, "Engineering", fields["company.department.name"].getValue()) + + // Test struct without log tag is not included + type TeamMember struct { + Role string `log:"role"` + Employee Employee // No log tag + } + + team := TeamMember{ + Role: "Developer", + Employee: Employee{ + Company: &Company{ + Dept: &Department{ + Name: "Engineering", + }, + }, + }, + } + + ctx = StructContext(team) + fields = ctx.Context() + require.Equal(t, 1, len(fields)) + require.Equal(t, "Developer", fields["role"].getValue()) + require.NotContains(t, fields, "employee.company.department.name") + + // Test with various value types + type AllTypes struct { + BoolVal bool `log:"bool"` + IntVal int `log:"int"` + Int64Val int64 `log:"int64"` + UintVal uint `log:"uint"` + Uint64Val uint64 `log:"uint64"` + Float32Val float32 `log:"float32"` + Float64Val float64 `log:"float64"` + StringVal string `log:"string"` + } + + allTypes := AllTypes{ + BoolVal: true, + IntVal: 42, + Int64Val: int64(9223372036854775807), + UintVal: 42, + Uint64Val: uint64(18446744073709551615), + Float32Val: 3.14, + Float64Val: 2.71828, + StringVal: "hello", + } + + ctx = StructContext(allTypes) + fields = ctx.Context() + require.Equal(t, 8, len(fields)) + require.Equal(t, true, fields["bool"].getValue()) + require.Equal(t, int64(42), fields["int"].getValue()) + require.Equal(t, int64(9223372036854775807), fields["int64"].getValue()) + require.Equal(t, uint64(42), fields["uint"].getValue()) + require.Equal(t, uint64(18446744073709551615), fields["uint64"].getValue()) + require.Equal(t, float32(3.14), fields["float32"].getValue()) + require.Equal(t, float64(2.71828), fields["float64"].getValue()) + require.Equal(t, "hello", fields["string"].getValue()) +} + +func TestStructContextWithTag(t *testing.T) { + // Define a struct with otel tags instead of log tags + type Product struct { + ID int `otel:"product_id"` + Name string `otel:"product_name"` + Price float64 `otel:"price"` + Description string `otel:"description,omitempty"` + CreatedAt time.Time `otel:"created_at"` + } + + now := time.Now() + product := Product{ + ID: 123, + Name: "Test Product", + Price: 29.99, + CreatedAt: now, + } + + // Use StructContext with WithTag option to use otel tags instead of log tags + ctx := StructContext(product, WithTag("otel")) + fields := ctx.Context() + + // Verify that the fields are extracted using otel tags + require.Contains(t, fields, "product_id") + require.Contains(t, fields, "product_name") + require.Contains(t, fields, "price") + require.Contains(t, fields, "created_at") + require.NotContains(t, fields, "description") // Should be omitted as it's empty with omitempty + + // Verify values + require.Equal(t, int64(123), fields["product_id"].getValue()) + require.Equal(t, "Test Product", fields["product_name"].getValue()) + require.Equal(t, float64(29.99), fields["price"].getValue()) + + // Test with both custom tag and prefix + ctx = StructContext(product, WithTag("otel"), WithPrefix("item")) + fields = ctx.Context() + + require.Contains(t, fields, "item.product_id") + require.Contains(t, fields, "item.product_name") + require.Contains(t, fields, "item.price") + require.Contains(t, fields, "item.created_at") + require.NotContains(t, fields, "item.description") +} + +func TestStructContextWithLogger(t *testing.T) { + type User struct { + ID int `log:"id"` + Username string `log:"username"` + Email string `log:"email,omitempty"` + } + + buffer, logger := NewBufferLogger() + user := User{ + ID: 1, + Username: "johndoe", + Email: "john@example.com", + } + + // Log with struct context + logger.With(StructContext(user)).Info().Log("User logged in") + + // Check log output + output := buffer.String() + require.Contains(t, output, "username=johndoe") + require.Contains(t, output, "id=1") + require.Contains(t, output, "email=john@example.com") + require.Contains(t, output, "level=info") + require.Contains(t, output, "msg=\"User logged in\"") + + // Test with prefix + buffer.Reset() + logger.With(StructContext(user, WithPrefix("user"))).Info().Log("User details") + + output = buffer.String() + require.Contains(t, output, "user.username=johndoe") + require.Contains(t, output, "user.id=1") + require.Contains(t, output, "user.email=john@example.com") +}