Skip to content

feat: tag-driven struct field logging with omit/redact modifiers#11

Merged
ungerik merged 1 commit into
masterfrom
struct-field-modifiers
Apr 14, 2026
Merged

feat: tag-driven struct field logging with omit/redact modifiers#11
ungerik merged 1 commit into
masterfrom
struct-field-modifiers

Conversation

@ungerik

@ungerik ungerik commented Apr 14, 2026

Copy link
Copy Markdown
Contributor

Summary

  • Unified Message.StructFields and Message.TaggedStructFields onto a single variadic structFields(v, keyTags ...string) core. StructFields now resolves tags in the order golog, log, json (first match wins). TaggedStructFields is a single-tag shortcut.
  • Recognized modifiers on any tag value: omitempty, omitzero, omitnull, and redact (also spelled redacted). Suppression modifiers win over redact (matches encoding/json.Marshal's omitempty precedence).
  • omitempty matches encoding/json.Marshal exactly; omitzero is a strict superset of encoding/json's Go 1.24 omitzero (unions the IsZero() bool method result with reflect.Value.IsZero); omitnull calls IsNull() bool and falls back to omitzero semantics when the type has no such method. Both value-receiver and pointer-receiver IsNull / IsZero methods are honored, even on non-addressable struct fields (the helper allocates a one-time addressable copy when needed).
  • Wildcard escape hatch: TaggedStructFields(s, "") now logs every exported field by its Go name with no rename and no modifier pickup. This restores the pre-rewrite "log every exported field" behavior as an explicit opt-in. StructFields(s) deliberately stays strict tag-driven.
  • Unknown modifier tokens are ignored silently so that json:"name,omitnull" is a valid tag for both encoding/json.Marshal (ignores it) and golog (honors it). A new test, TestEncodingJSONIgnoresCustomModifiers, pins that assumption against the stdlib — if a future Go release starts validating these, CI will flag it.

Tag value grammar

Raw tag value Log key Notes
"field_name" field_name explicit name
"" Go field name empty → fall back to Go name
",omitempty" Go field name empty name + modifier
"-" (bare) skipped only the bare form skips
"-,omitempty" - literal field name - + modifiers

Whitespace around the name and each modifier is trimmed. Modifiers from other tags on the same field are ignored — only the first-matching tag contributes both the name and the modifiers.

Breaking changes

  1. golog:"redact" (bare, single token) no longer triggers redaction. Under the unified parser it names the field "redact". Migrate to golog:",redact" (or combine: golog:",redact,omitempty").
  2. StructFields(s) on an untagged struct now logs nothing. Previously every exported field was logged by its Go name. The cleanest replacement is the new TaggedStructFields(s, "") wildcard. Per-field, you can also opt in by adding an empty tag like json:"" or golog:"".
  3. TaggedStructFields(s, "json") with json:"" now logs the field (under its Go name) instead of skipping it. This is encoding/json.Marshal parity.

Test plan

  • go test ./... passes — full coverage adds ~60 subtests across TestMessage_StructFields, TestMessage_TaggedStructFields, TestParseStructFieldDirectives (21-case table-driven parser test), TestShouldOmitStructField_NilPointer (5 subtests, regression guard for the panic fix), TestShouldOmitStructField_PointerReceiver (8 subtests, value-receiver + pointer-receiver methods on addressable + non-addressable fields), and TestEncodingJSONIgnoresCustomModifiers.
  • go vet ./... clean.
  • go build ./... clean.
  • staticcheck -checks SA5008 ./... clean (custom modifiers in test json tags live in reflect.StructOf-built types via the buildTestStruct helper, so no AST-level struct tag literals trigger the check).
  • Existing golog:"redact" tests migrated to golog:",redact" + json:"name,redact" and still assert the "***REDACTED***" substitution.
  • omitnull verified on golog.Timestamp (uses IsNull) and time.Time (falls back to omitzero).
  • omitzero verified on time.Time{} and on zero structs without an IsZero method.
  • Precedence guard: ,redact,omitempty on an empty string suppresses the field rather than emitting the redaction marker.
  • Nil-pointer regression guard: *time.Time with ,omitzero and *golog.Timestamp with ,omitnull (both nil) are suppressed instead of panicking on the value-receiver method auto-dereference. End-to-end test through Message.StructFields plus direct predicate tests.
  • Pointer-receiver method coverage: custom types with func (p *T) IsNull() bool and func (p *T) IsZero() bool are detected as value fields in non-addressable structs (allocates an addressable copy) and via the fast Addr() path on addressable structs.
  • Empty keyTag wildcard: TaggedStructFields(s, "") logs every exported field by Go name, ignores struct tags entirely, skips unexported fields.
  • encoding/json.Marshal stdlib assumption pinned by a test.

Documentation

Latest doc-release sweep on this branch:

  • README.md ### Struct Field Logging: Tags and Modifiers — full grammar, modifier table, complete example struct, breaking-changes callout, and an omitnull vs omitzero note using sql.NullString as the canonical "null is not zero" example.
  • README.md "Tag resolution" subsection — adds the wildcard escape-hatch paragraph documenting TaggedStructFields(s, "").
  • README.md "Modifiers" tableomitzero and omitnull rows now note that both value-receiver and pointer-receiver methods are honored, matching the addressable-copy fallback in invokeIsNull / invokeIsZero.
  • README.md "Breaking changes" calloutStructFields(s) migration tip now points at TaggedStructFields(s, "") as the cleanest restore path.
  • README.md feature bullet list — mentions tag-driven struct logging with the four modifiers.
  • Message.StructFields and Message.TaggedStructFields godoc — expanded with the new grammar, modifier semantics, the wildcard TaggedStructFields(s, "") opt-in, and a pointer to the breaking change.
  • Subpackage READMEs (benchmarks/, goslog/, logfile/, logsentry/) — audited, no changes required (none reference the renamed/repurposed methods).

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

This PR updates golog’s struct field logging to be entirely tag-driven, unifying Message.StructFields and Message.TaggedStructFields behind a single parser that supports omit/redact modifiers and encoding/json-compatible tag grammar where applicable.

Changes:

  • Refactored struct-field reflection logging to resolve tags by priority (gologlogjson) and parse tag modifiers (omitempty, omitzero, omitnull, redact/redacted).
  • Expanded test coverage to pin the new grammar, precedence rules, and confirm encoding/json ignores golog-specific modifiers.
  • Updated README docs to describe the new tag-driven behavior, modifiers, and breaking changes.

Reviewed changes

Copilot reviewed 3 out of 3 changed files in this pull request and generated 3 comments.

File Description
message.go Implements unified tag lookup + directive parsing and omit/redact behavior; updates public godoc accordingly.
message_test.go Updates existing struct field tests for tag-driven behavior and adds extensive new cases for grammar/modifiers/stdlib assumptions.
README.md Replaces the redaction section with a full “tags and modifiers” spec + examples and breaking-change notes.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment thread message.go
Comment thread message_test.go Outdated
Comment thread message.go
Unify Message.StructFields and Message.TaggedStructFields onto a
single variadic structFields(v, keyTags ...string) core. StructFields
now resolves tags in the order golog, log, json (first match wins for
both naming and modifiers). TaggedStructFields is a single-tag
shortcut. Public method signatures are unchanged.

Recognized modifiers in any tag value (parsed once by the new
parseStructFieldDirectives helper in struct.go):

  - omitempty (encoding/json.Marshal parity: false, 0, nil pointer
    or interface, empty array, slice, map, string)
  - omitzero  (strict superset of encoding/json Go 1.24 omitzero:
    union of an IsZero bool method on the type and the Go zero
    value via reflect.Value.IsZero; both value-receiver and
    pointer-receiver IsZero methods are honored)
  - omitnull  (calls IsNull bool when the type implements it,
    falls back to omitzero semantics otherwise; both value- and
    pointer-receiver IsNull methods are honored; covers
    golog.Timestamp and any sql.NullString-shaped wrapper)
  - redact (also spelled redacted; replaces the value with
    ***REDACTED*** before it reaches the writer)

Suppression modifiers win over redact: a tag like
  json:",redact,omitempty"
on an empty string emits nothing, not the marker. Matches
encoding/json.Marshal omitempty precedence.

Tag value grammar:

  -            bare "-" skips the field
  -,...        literal field name "-" with modifiers
  ""           empty value -> fall back to Go field name
  ",mod"       empty name -> Go field name + modifiers
  "name,mod"   explicit name + modifiers

Whitespace around the name and each modifier is trimmed. Unknown
modifier tokens are ignored silently so that json:"name,omitnull"
is a valid tag for both golog (honors omitnull) and
encoding/json.Marshal (ignores it). A new test pins this stdlib
assumption against future Go releases.

Wildcard escape hatch:

  TaggedStructFields(s, "")

logs every exported field by its Go name with no rename and no
modifier pickup. This restores the pre-rewrite "log every exported
field" behavior as an explicit opt-in. StructFields(s) deliberately
stays strict tag-driven.

Pointer-receiver detection:

The invokeIsNull and invokeIsZero helpers try the value-receiver
fast path first (zero allocation), then fall back to a
pointer-receiver path. If the field is reached via a pointer to
the enclosing struct it is already addressable and we use fv.Addr
directly. Otherwise we allocate one addressable copy via
reflect.New + Set so a value-typed field with a pointer-receiver
method does not silently fall through.

Nil-pointer safety:

shouldOmitStructField and isZeroValueForOmitzero short-circuit on
fv.IsNil for Ptr / Interface kinds before any interface assertion.
Without this, a nil *time.Time with ,omitzero or a nil
*golog.Timestamp with ,omitnull panicked at log time when the
value-receiver IsZero / IsNull method auto-dereferenced the nil
pointer. A nil pointer or interface is now treated as definitively
null and zero, which is both safe and semantically correct.

Breaking changes:

  1. golog:"redact" (bare, single token) no longer triggers
     redaction. Under the unified parser it names the field
     "redact" in the log. Migrate to golog:",redact" (or combine
     with other modifiers: golog:",redact,omitempty").
  2. StructFields(s) on a struct where no exported field carries
     any of the default keyTags (golog, log, json) now logs
     nothing. Previously every exported field was logged by its
     Go name. The cleanest replacement is the new wildcard
     TaggedStructFields(s, ""). Per-field, you can also opt in by
     adding an empty tag like json:"" or golog:"".
  3. TaggedStructFields(s, "json") with json:"" on a field now
     logs the field under its Go name instead of skipping it.
     This is encoding/json.Marshal parity.

File layout:

  - struct.go (new): the structFieldDirectives type plus
    parseStructFieldDirectives, shouldOmitStructField,
    invokeIsNull, invokeIsZero, asAddressableValue,
    isZeroValueForOmitzero, isEmptyValueForOmitempty. Pure
    reflect-based helpers with no Message dependency.
  - struct_test.go (new): TestParseStructFieldDirectives (21-case
    table), TestEncodingJSONIgnoresCustomModifiers (stdlib
    assumption pin), TestShouldOmitStructField_NilPointer (5-case
    panic regression guard), TestShouldOmitStructField_Pointer
    Receiver (8-case value- and pointer-receiver coverage on
    addressable and non-addressable fields).
  - message.go: rewritten StructFields, TaggedStructFields, and
    the private structFields walker. Public signatures unchanged.
    Doc comments expanded with the full grammar, modifier
    semantics, the wildcard opt-in, and pointers to the breaking
    changes.
  - message_test.go: existing redact tests migrated from
    golog:"redact" to golog:",redact" and the json+redact tests
    rewritten to use a new buildTestStruct helper that builds the
    struct type at runtime via reflect.StructOf so static
    analyzers (go vet structtag, staticcheck SA5008) never see
    the custom modifiers in a tag literal. New integration
    subtests cover multi-keyTag fallback, winning-tag controls
    modifiers, untagged struct logs nothing, all four modifiers
    across every primitive kind, omitnull fallback, redact plus
    omitempty precedence, json.Marshal parity for omitempty and
    omitzero, the wildcard escape hatch, and nil pointer fields
    through the full Message.StructFields pipeline.
  - README.md: the old "Redacting Sensitive Data" subsection is
    rewritten as "Struct Field Logging: Tags and Modifiers" with
    the full grammar, modifier table (including pointer-receiver
    coverage notes), complete example struct, breaking-changes
    callout, the wildcard escape hatch paragraph, and an
    omitnull vs omitzero note that uses sql.NullString as the
    canonical "null is not zero" example. Feature bullet list
    near the top updated to mention tag-driven struct logging.

Verified clean: go test ./..., go vet ./..., go build ./..., and
staticcheck -checks SA5008 ./... all pass silently. ~60 new
subtests across the four new test functions plus the migrated
existing ones.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
@ungerik ungerik force-pushed the struct-field-modifiers branch from c402c50 to bbd8edd Compare April 14, 2026 11:02
@ungerik ungerik merged commit a4c5840 into master Apr 14, 2026
2 checks passed
@ungerik ungerik deleted the struct-field-modifiers branch April 14, 2026 11:03
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants