Skip to content

Commit a4c5840

Browse files
authored
Merge pull request #11 from domonda/struct-field-modifiers
feat: tag-driven struct field logging with omit/redact modifiers
2 parents 37d75c6 + bbd8edd commit a4c5840

5 files changed

Lines changed: 1184 additions & 91 deletions

File tree

README.md

Lines changed: 80 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -34,7 +34,7 @@ Fast and feature-rich structured logging library for Go
3434
- [Advanced Features](#advanced-features)
3535
- [Custom Colorizers](#custom-colorizers)
3636
- [Call Stack Logging](#call-stack-logging)
37-
- [Redacting Sensitive Data](#redacting-sensitive-data)
37+
- [Struct Field Logging: Tags and Modifiers](#struct-field-logging-tags-and-modifiers)
3838
- [Custom Levels](#custom-levels)
3939
- [Level Filtering](#level-filtering)
4040
- [Parsing Log Timestamps](#parsing-log-timestamps)
@@ -55,6 +55,7 @@ Fast and feature-rich structured logging library for Go
5555

5656
- **High Performance**: Zero-allocation logging for JSON, text, and complex fields (error, time.Time)
5757
- **Structured Logging**: Type-safe field methods for all Go primitives including native time.Time and UUID
58+
- **Tag-driven Struct Logging**: `StructFields` and `TaggedStructFields` honor `golog`, `log`, and `json` tags with `omitempty`, `omitzero`, `omitnull`, and `redact` modifiers. `encoding/json`-compatible where applicable
5859
- **Multiple Output Formats**: JSON and human-readable text output
5960
- **Terminal Auto-Detection**: Automatically switches between colored text (TTY) and JSON (non-TTY)
6061
- **Configurable Log Levels**: TRACE, DEBUG, INFO, WARN, ERROR, FATAL with flexible filtering
@@ -497,30 +498,95 @@ defer func() {
497498
}()
498499
```
499500

500-
### Redacting Sensitive Data
501+
### Struct Field Logging: Tags and Modifiers
501502

502-
Use the `golog:"redact"` struct tag to automatically redact sensitive fields when logging structs:
503+
`StructFields` and `TaggedStructFields` walk a struct via reflection and log
504+
each matching field as an attribute. They are driven entirely by struct tags,
505+
with modifiers that mirror `encoding/json.Marshal` semantics plus a handful of
506+
golog-specific additions.
507+
508+
#### Tag resolution
509+
510+
`StructFields(s)` looks for tags in this order — **first match wins**:
511+
512+
1. `golog:"..."`
513+
2. `log:"..."`
514+
3. `json:"..."`
515+
516+
Only the first tag present on a field is consulted. Other tags on the same field
517+
are ignored. Fields with **none** of those tags are skipped entirely (this is a
518+
deliberate difference from `encoding/json.Marshal`, which would use the Go field
519+
name: golog errs on the side of not leaking untagged internal fields into logs).
520+
521+
`TaggedStructFields(s, "json")` does the same with a single caller-chosen tag.
522+
523+
**Wildcard escape hatch:** passing the empty string as the key tag,
524+
`TaggedStructFields(s, "")`, matches every exported field with no rename and no
525+
modifiers — every field is logged under its Go name, struct tags ignored. Use
526+
this when you want to dump a struct wholesale and you don't care about per-field
527+
tags. (`StructFields(s)` deliberately stays strict tag-driven; the wildcard is
528+
opt-in.)
529+
530+
#### Tag value grammar
531+
532+
Each tag value is `name,mod1,mod2,...`:
533+
534+
| Tag value | Log key | Notes |
535+
|------------------------------|---------------|-------------------------------------------|
536+
| `"field_name"` | `field_name` | explicit name |
537+
| `""` | Go field name | empty → fall back to field name |
538+
| `",omitempty"` | Go field name | empty name with modifier → fall back |
539+
| `"-"` (bare, no comma) | *skipped* | only the bare form skips |
540+
| `"-,..."` (comma follows) | `-` | literal field name `-` + modifiers |
541+
542+
Whitespace around the name and each modifier is trimmed. Unknown modifier
543+
tokens are ignored silently, so `json:"name,omitnull"` is a valid tag for both
544+
`encoding/json.Marshal` (which ignores `omitnull`) and golog (which honors it).
545+
546+
#### Modifiers
547+
548+
| Modifier | Suppress when… |
549+
|-------------|---------------------------------------------------------------------------------|
550+
| `omitempty` | `false`, `0`, nil pointer/interface, empty array/slice/map/string. Exactly matches `encoding/json.Marshal`. Does **not** catch zero structs or `time.Time{}`. |
551+
| `omitzero` | The type's `IsZero() bool` method returns true, **or** the value is the Go zero value (via `reflect.Value.IsZero`). Catches `time.Time{}` and any zero struct. Strict superset of `encoding/json`'s Go 1.24 `omitzero`. Both value-receiver and pointer-receiver `IsZero` methods are honored. |
552+
| `omitnull` | The type's `IsNull() bool` method returns true. Falls back to `omitzero` semantics when the type has no `IsNull` method. Use for nullable wrappers (`golog.Timestamp`, `sql.NullString`, `uu.NullableID`) where "null" is richer than "all bytes zero". Both value-receiver and pointer-receiver `IsNull` methods are honored. |
553+
| `redact` | (not a suppression modifier) — replaces the value with `"***REDACTED***"` before it reaches the writer. Also spelled `redacted`. Suppression modifiers win over redact: `json:",redact,omitempty"` on an empty string emits nothing, not the marker. |
554+
555+
Modifiers OR together — any passing check suppresses the field.
556+
557+
#### Complete example
503558

504559
```go
505560
type User struct {
506-
ID int `json:"id"`
507-
Username string `json:"username"`
508-
Password string `json:"password" golog:"redact"`
509-
APIKey string `json:"api_key" golog:"redact"`
561+
ID int `json:"id"`
562+
Name string `json:"name,omitempty"`
563+
APIKey string `json:",redact"`
564+
CreatedAt time.Time `json:"created_at,omitzero"`
565+
DeletedAt golog.Timestamp `json:"deleted_at,omitnull"`
566+
Internal string // no json/log/golog tag → never logged
510567
}
511568

512569
user := User{
513-
ID: 123,
514-
Username: "john_doe",
515-
Password: "secret123",
516-
APIKey: "sk-abc123",
570+
ID: 123,
571+
Name: "john_doe",
572+
APIKey: "sk-abc123",
573+
CreatedAt: time.Now(),
574+
// DeletedAt left as zero Timestamp
575+
Internal: "debug-only",
517576
}
518577

519-
log.Info("User logged in").StructFields(user).Log()
520-
// Output: ... id=123 username="john_doe" password="***REDACTED***" api_key="***REDACTED***"
578+
log.Info("user").TaggedStructFields(user, "json").Log()
579+
// Output: ... id=123 name="john_doe" APIKey="***REDACTED***" created_at="2026-04-14T..."
580+
// (DeletedAt suppressed by omitnull, Internal never considered.)
521581
```
522582

523-
The `golog:"redact"` tag works with both `StructFields()` and `TaggedStructFields()` methods.
583+
> **Breaking changes in this release**
584+
>
585+
> - `golog:"redact"` (bare, single token) **no longer triggers redaction** — under the unified parser it names the field `"redact"`. Migrate to `golog:",redact"` (or combine with other modifiers: `golog:",redact,omitempty"`).
586+
> - `StructFields(s)` on an untagged struct now logs **nothing**. Previously it logged every exported field by its Go name. The cleanest replacement is `TaggedStructFields(s, "")`, the wildcard escape hatch documented above. Per-field, you can also add an empty tag like `json:""` or `golog:""` to opt in.
587+
> - `TaggedStructFields(s, "json")` with `json:""` now logs the field (Go field name), previously it skipped. This is `encoding/json.Marshal` parity.
588+
589+
`omitnull` vs `omitzero`, concretely: `sql.NullString{Valid: false, String: ""}` and `sql.NullString{Valid: true, String: ""}` are both the reflect zero value, but only the first is actually null. `omitnull` with a proper `IsNull` method distinguishes the two; `omitzero` cannot.
524590

525591
### Custom Levels
526592

message.go

Lines changed: 98 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,6 @@ import (
1010
"go/token"
1111
"net/http"
1212
"reflect"
13-
"strings"
1413
"time"
1514
"unicode/utf8"
1615
)
@@ -385,24 +384,56 @@ func (m *Message) tryWriteInterface(w Writer, val reflect.Value) (written bool)
385384
return false
386385
}
387386

388-
// StructFields calls Any(fieldName, fieldValue) for every exported struct field.
389-
// Fields with the tag `golog:"redact"` will have their value replaced with "***REDACTED***".
387+
// StructFields logs each exported field of strct whose struct tag matches,
388+
// in order, one of "golog", "log", "json". The first tag present on a field
389+
// determines both the log key and which modifiers apply; other tags on the
390+
// same field are ignored.
391+
//
392+
// Fields with no tag from that list are not logged. To log a field under its
393+
// Go field name, give it an empty tag value such as `json:""` or `golog:""`.
394+
//
395+
// Supported modifiers (comma-separated after the name):
396+
// - omitempty — suppress the field when its value is empty per
397+
// encoding/json semantics (false, 0, nil pointer/interface, empty
398+
// array/slice/map/string).
399+
// - omitzero — suppress when the value is zero. Calls IsZero() bool if
400+
// the type implements it (so time.Time{} is suppressed), else falls
401+
// back to reflect.Value.IsZero().
402+
// - omitnull — suppress when the value is null. Calls IsNull() bool if
403+
// the type implements it (so zero golog.Timestamp is suppressed),
404+
// else falls back to omitzero semantics.
405+
// - redact (also spelled "redacted") — replace the value with
406+
// "***REDACTED***" in the log output. Suppression modifiers win over
407+
// redact: `json:",redact,omitempty"` on an empty string emits nothing,
408+
// not the redaction marker.
409+
//
410+
// Unknown modifier tokens are ignored silently, matching encoding/json's
411+
// forward-compat posture.
412+
//
413+
// Breaking change vs. earlier versions: the bare form `golog:"redact"`
414+
// no longer triggers redaction — under the new unified parser it would
415+
// name the field "redact" in the log. Migrate to `golog:",redact"`.
390416
func (m *Message) StructFields(strct any) *Message {
391417
if m == nil || strct == nil {
392418
return m
393419
}
394-
m.structFields(reflect.ValueOf(strct), "")
420+
m.structFields(reflect.ValueOf(strct), "golog", "log", "json")
395421
return m
396422
}
397423

398-
// TaggedStructFields calls Any(fieldTag, fieldValue) for every exported struct field
399-
// that has the passed keyTag with the tag value not being empty or "-".
400-
// Tag values are only considered until the first comma character,
401-
// so `tag:"hello_world,omitempty"` will result in the fieldTag "hello_world".
402-
// Fields with the following tags will be ignored: `keyTag:"-"`, `keyTag:""` `keyTag:",xxx"`
403-
// where keyTag is the passed keyTag parameter.
404-
// Fields with the tag `golog:"redact"` will have their value replaced with "***REDACTED***"
405-
// (unless keyTag is "golog", in which case the tag value is used as the field key).
424+
// TaggedStructFields logs each exported field of strct that carries the
425+
// given keyTag. It follows the same tag-value grammar and modifier set as
426+
// [Message.StructFields]; see that method's documentation for details.
427+
//
428+
// Fields without the keyTag are not logged. Fields where the keyTag value is
429+
// exactly "-" are skipped. A tag value with an empty name segment (e.g.
430+
// `json:""` or `json:",omitempty"`) falls back to the Go field name, matching
431+
// encoding/json.Marshal.
432+
//
433+
// Passing the empty string as keyTag is a wildcard: every exported field is
434+
// logged under its Go field name, regardless of whether it carries any
435+
// struct tag. This restores the pre-tag-driven "log every exported field"
436+
// behavior for callers that want it — `TaggedStructFields(s, "")`.
406437
func (m *Message) TaggedStructFields(strct any, keyTag string) *Message {
407438
if m == nil || strct == nil {
408439
return m
@@ -411,7 +442,7 @@ func (m *Message) TaggedStructFields(strct any, keyTag string) *Message {
411442
return m
412443
}
413444

414-
func (m *Message) structFields(v reflect.Value, keyTag string) {
445+
func (m *Message) structFields(v reflect.Value, keyTags ...string) {
415446
for v.Kind() == reflect.Pointer {
416447
if v.IsNil() {
417448
return
@@ -421,27 +452,63 @@ func (m *Message) structFields(v reflect.Value, keyTag string) {
421452
t := v.Type()
422453
for i := 0; i < t.NumField(); i++ {
423454
field := t.Field(i)
424-
switch {
425-
case field.Anonymous:
426-
m.structFields(v.Field(i), keyTag)
427-
case token.IsExported(field.Name):
428-
var (
429-
key = field.Name
430-
value = v.Field(i).Interface()
431-
)
432-
if keyTag != "golog" && strings.TrimSpace(field.Tag.Get("golog")) == "redact" {
433-
value = "***REDACTED***"
455+
456+
if field.Anonymous {
457+
m.structFields(v.Field(i), keyTags...)
458+
continue
459+
}
460+
if !token.IsExported(field.Name) {
461+
continue
462+
}
463+
464+
// Find the first keyTag that "matches" this field. Lookup (not
465+
// Get) so that an explicit empty tag value like `json:""` counts
466+
// as present.
467+
//
468+
// An empty string in keyTags is a wildcard: it matches every
469+
// exported field with an empty raw tag value (which the caller
470+
// later substitutes with the Go field name). This gives callers
471+
// a way to dump a struct wholesale under Go field names, e.g.
472+
// `TaggedStructFields(s, "")` for log-everything, or
473+
// `structFields(v, "json", "")` for "prefer json tag, fall back
474+
// to Go field name".
475+
var rawTag string
476+
var found bool
477+
for _, kt := range keyTags {
478+
if kt == "" {
479+
found = true
480+
rawTag = ""
481+
break
434482
}
435-
if keyTag != "" {
436-
key = field.Tag.Get(keyTag)
437-
key, _, _ = strings.Cut(key, ",")
438-
key = strings.TrimSpace(key)
439-
if key == "" || key == "-" {
440-
continue
441-
}
483+
if tv, ok := field.Tag.Lookup(kt); ok {
484+
rawTag = tv
485+
found = true
486+
break
442487
}
443-
m.Any(key, value)
444488
}
489+
if !found {
490+
continue
491+
}
492+
493+
d := parseStructFieldDirectives(rawTag)
494+
if d.skip {
495+
continue
496+
}
497+
if d.key == "" {
498+
d.key = field.Name
499+
}
500+
501+
fv := v.Field(i)
502+
if shouldOmitStructField(fv, d) {
503+
continue
504+
}
505+
506+
if d.redact {
507+
m.Str(d.key, "***REDACTED***")
508+
continue
509+
}
510+
511+
m.Any(d.key, fv.Interface())
445512
}
446513
}
447514

0 commit comments

Comments
 (0)