Skip to content

Commit 8ee8683

Browse files
authored
Merge pull request #457 from moov-io/add-struct-context
Add struct context
2 parents ae481cf + cfedbb2 commit 8ee8683

File tree

3 files changed

+579
-0
lines changed

3 files changed

+579
-0
lines changed

log/README.md

Lines changed: 145 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,145 @@
1+
# Log Package
2+
3+
The log package provides structured logging capabilities for Moov applications.
4+
5+
## Usage
6+
7+
### Basic Logging
8+
9+
```go
10+
import "github.com/moov-io/base/log"
11+
12+
// Create a new logger
13+
logger := log.NewDefaultLogger()
14+
15+
// Log a message with different levels
16+
logger.Info().Log("Application started")
17+
logger.Debug().Log("Debug information")
18+
logger.Warn().Log("Warning message")
19+
logger.Error().Log("Error occurred")
20+
21+
// Log with key-value pairs
22+
logger.Info().Set("request_id", log.String("12345")).Log("Processing request")
23+
24+
// Log formatted messages
25+
logger.Infof("Processing request %s", "12345")
26+
27+
// Log errors
28+
err := someFunction()
29+
if err != nil {
30+
logger.LogError(err)
31+
}
32+
```
33+
34+
### Using Fields
35+
36+
```go
37+
import "github.com/moov-io/base/log"
38+
39+
// Create a map of fields
40+
fields := log.Fields{
41+
"request_id": log.String("12345"),
42+
"user_id": log.Int(42),
43+
"timestamp": log.Time(time.Now()),
44+
}
45+
46+
// Log with fields
47+
logger.With(fields).Info().Log("Request processed")
48+
```
49+
50+
### Using StructContext
51+
52+
The `StructContext` function allows you to log struct fields automatically by using tags.
53+
54+
```go
55+
import "github.com/moov-io/base/log"
56+
57+
// Define a struct with log tags
58+
type User struct {
59+
ID int `log:"id"`
60+
Username string `log:"username"`
61+
Email string `log:"email,omitempty"` // won't be logged if empty
62+
Address Address `log:"address"` // nested struct must have log tag
63+
Hidden string // no log tag, won't be logged
64+
}
65+
66+
type Address struct {
67+
Street string `log:"street"`
68+
City string `log:"city"`
69+
Country string `log:"country"`
70+
}
71+
72+
// Create a user
73+
user := User{
74+
ID: 1,
75+
Username: "johndoe",
76+
77+
Address: Address{
78+
Street: "123 Main St",
79+
City: "New York",
80+
Country: "USA",
81+
},
82+
Hidden: "secret",
83+
}
84+
85+
// Log with struct context
86+
logger.With(log.StructContext(user)).Info().Log("User logged in")
87+
88+
// Log with struct context and prefix
89+
logger.With(log.StructContext(user, log.WithPrefix("user"))).Info().Log("User details")
90+
91+
// Using custom tag other than "log"
92+
type Product struct {
93+
ID int `otel:"product_id"`
94+
Name string `otel:"product_name"`
95+
Price float64 `otel:"price,omitempty"`
96+
}
97+
98+
product := Product{
99+
ID: 42,
100+
Name: "Widget",
101+
Price: 19.99,
102+
}
103+
104+
// Use otel tags instead of log tags
105+
logger.With(log.StructContext(product, log.WithTag("otel"))).Info().Log("Product details")
106+
```
107+
108+
The above will produce log entries with the following fields:
109+
- `id=1`
110+
- `username=johndoe`
111+
112+
- `address.street=123 Main St`
113+
- `address.city=New York`
114+
- `address.country=USA`
115+
116+
With the prefix option, the fields will be:
117+
- `user.id=1`
118+
- `user.username=johndoe`
119+
120+
- `user.address.street=123 Main St`
121+
- `user.address.city=New York`
122+
- `user.address.country=USA`
123+
124+
With the custom tag option, the fields will be extracted from the tag you specify (such as `otel`):
125+
- `product_id=42`
126+
- `product_name=Widget`
127+
- `price=19.99`
128+
129+
Note that nested structs or pointers to structs must have the specified tag to be included in the context.
130+
131+
## Features
132+
133+
- Structured logging with key-value pairs
134+
- Multiple log levels (Debug, Info, Warn, Error, Fatal)
135+
- JSON and LogFmt output formats
136+
- Context-based logging
137+
- Automatic struct field logging with StructContext
138+
- Support for various value types (string, int, float, bool, time, etc.)
139+
140+
## Configuration
141+
142+
The default logger format is determined by the `MOOV_LOG_FORMAT` environment variable:
143+
- `json`: JSON format
144+
- `logfmt`: LogFmt format (default)
145+
- `nop` or `noop`: No-op logger that discards all logs

log/struct_context.go

Lines changed: 182 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,182 @@
1+
package log
2+
3+
import (
4+
"fmt"
5+
"reflect"
6+
"slices"
7+
"strings"
8+
"time"
9+
)
10+
11+
// StructContextOption defines options for StructContext
12+
type StructContextOption func(*structContext)
13+
14+
// WithPrefix adds a prefix to all struct field names
15+
func WithPrefix(prefix string) StructContextOption {
16+
return func(sc *structContext) {
17+
sc.prefix = prefix
18+
}
19+
}
20+
21+
// WithTag adds a custom tag to look for in struct fields
22+
func WithTag(tag string) StructContextOption {
23+
return func(sc *structContext) {
24+
sc.tag = tag
25+
}
26+
}
27+
28+
// structContext implements the Context interface for struct fields
29+
type structContext struct {
30+
fields map[string]Valuer
31+
prefix string
32+
tag string
33+
}
34+
35+
// Context returns a map of field names to Valuer implementations
36+
func (sc *structContext) Context() map[string]Valuer {
37+
return sc.fields
38+
}
39+
40+
// StructContext creates a Context from a struct, extracting fields tagged with `log`
41+
// It supports nested structs and respects omitempty directive
42+
func StructContext(v interface{}, opts ...StructContextOption) Context {
43+
sc := &structContext{
44+
fields: make(map[string]Valuer),
45+
prefix: "",
46+
tag: "log",
47+
}
48+
49+
// Apply options
50+
for _, opt := range opts {
51+
opt(sc)
52+
}
53+
54+
if v == nil {
55+
return sc
56+
}
57+
58+
value := reflect.ValueOf(v)
59+
extractFields(value, sc, "")
60+
61+
return sc
62+
}
63+
64+
// extractFields recursively extracts fields from a struct value
65+
func extractFields(value reflect.Value, sc *structContext, path string) {
66+
// If it's a pointer, dereference it
67+
if value.Kind() == reflect.Ptr {
68+
if value.IsNil() {
69+
return
70+
}
71+
value = value.Elem()
72+
}
73+
74+
// Only process structs
75+
if value.Kind() != reflect.Struct {
76+
return
77+
}
78+
79+
typ := value.Type()
80+
for i := range typ.NumField() {
81+
field := typ.Field(i)
82+
fieldValue := value.Field(i)
83+
84+
// Skip unexported fields
85+
if !field.IsExported() {
86+
continue
87+
}
88+
89+
// Get the log tag
90+
tag := field.Tag.Get(sc.tag)
91+
if tag == "" {
92+
// Skip fields without log tag
93+
continue
94+
}
95+
96+
// Parse the tag
97+
tagParts := strings.Split(tag, ",")
98+
fieldName := tagParts[0]
99+
if fieldName == "" {
100+
fieldName = field.Name
101+
}
102+
103+
// Handle omitempty
104+
omitEmpty := slices.Contains(tagParts, "omitempty")
105+
106+
// Build the full field name with path and prefix
107+
fullName := fieldName
108+
if path != "" {
109+
fullName = path + "." + fieldName
110+
}
111+
112+
// we add prefis only once, for the field on the first level
113+
if path == "" && sc.prefix != "" {
114+
fullName = sc.prefix + "." + fullName
115+
}
116+
117+
// Check if field should be omitted due to empty value
118+
if omitEmpty && fieldValue.IsZero() {
119+
continue
120+
}
121+
122+
// Store the field value
123+
valuer := valueToValuer(fieldValue)
124+
if valuer != nil {
125+
sc.fields[fullName] = valuer
126+
}
127+
128+
// If it's a struct, recursively extract its fields only if it has a log tag
129+
if fieldValue.Kind() == reflect.Struct ||
130+
(fieldValue.Kind() == reflect.Ptr && !fieldValue.IsNil() && fieldValue.Elem().Kind() == reflect.Struct) {
131+
extractFields(fieldValue, sc, fullName)
132+
}
133+
}
134+
}
135+
136+
// valueToValuer converts a reflect.Value to a Valuer
137+
func valueToValuer(v reflect.Value) Valuer {
138+
if !v.IsValid() {
139+
return nil
140+
}
141+
142+
//nolint:exhaustive
143+
switch v.Kind() {
144+
case reflect.Bool:
145+
return Bool(v.Bool())
146+
case reflect.Int, reflect.Int8, reflect.Int16, reflect.Int32, reflect.Int64:
147+
return Int64(v.Int())
148+
case reflect.Uint, reflect.Uint8, reflect.Uint16, reflect.Uint32, reflect.Uint64:
149+
return Uint64(v.Uint())
150+
case reflect.Float32:
151+
return Float32(float32(v.Float()))
152+
case reflect.Float64:
153+
return Float64(v.Float())
154+
case reflect.String:
155+
return String(v.String())
156+
case reflect.Ptr:
157+
if v.IsNil() {
158+
return &any{nil}
159+
}
160+
return valueToValuer(v.Elem())
161+
case reflect.Struct:
162+
// Check if it's a time.Time
163+
if v.Type().String() == "time.Time" {
164+
if v.CanInterface() {
165+
t, ok := v.Interface().(time.Time)
166+
if ok {
167+
return Time(t)
168+
}
169+
}
170+
}
171+
}
172+
173+
// Try to use Stringer for complex types
174+
if v.CanInterface() {
175+
if stringer, ok := v.Interface().(fmt.Stringer); ok {
176+
return Stringer(stringer)
177+
}
178+
}
179+
180+
// Return as string representation for other types
181+
return String(fmt.Sprintf("%v", v.Interface()))
182+
}

0 commit comments

Comments
 (0)