Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
145 changes: 145 additions & 0 deletions log/README.md
Original file line number Diff line number Diff line change
@@ -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
182 changes: 182 additions & 0 deletions log/struct_context.go
Original file line number Diff line number Diff line change
@@ -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()))
}
Loading
Loading