Skip to content

gqlerror: merge an error tree's slog attributes - #492

Merged
StevenACoffman merged 1 commit into
masterfrom
collect_attrs
Oct 8, 2026
Merged

StevenACoffman merged 1 commit into
masterfrom
collect_attrs

Conversation

@StevenACoffman

@StevenACoffman StevenACoffman commented Oct 8, 2026 •

Copy link
Copy Markdown
Collaborator

gqlerror.CollectAttrs(err) returns the slog attributes of every error in a tree, merged so no key appears twice. Logging middleware can pass it straight to slog.

Collecting Attrs() from each error in a chain gives two gql groups once a cause also reports one, as gqlgen's InvalidNullError will. slog does not merge them, so the JSON handler writes "gql" twice and most readers keep only the last, dropping this package's message and path.

  • Walks Unwrap() error and Unwrap() []error depth first, outermost first.
  • Merges groups that share a key. Any other repeated key keeps the first value, which is the outer *Error's, and so the one the response carried.
  • Follows slog's rules: values are resolved, empty-key groups inlined, zero attributes and empty groups dropped. No error's own attributes are modified.

Mutation testing then guided the rest of gqlerror:

  • New tests for nil inputs and receivers, SetFile, List, WrapIfUnwrapped and an overridden single-location file.
  • Conditions that could not change behaviour are removed, and cloneExtensions is gone. ErrorWithSources.Error drops from 400 to 64 B/op and from 5 to 3 allocs/op with one sourced location.
  • No mutant escapes in gqlerror (9 did before). The 10 reported as not covered sit on case lines, which Go's coverage profile does not record. Run without --coverage, all are killed.
  • mise run mutate now fails below a covered-code score of 75 (currently 76.2).

gqlerror/bench_test.go adds benchmarks for formatting, conversion, constructors, lists, matching, and JSON decoding, raising the package's benchmark statement coverage from 34% to 83%. The benchgate gate, run locally against master plus these benchmarks, reports 0 regressed, 6 improved, and 172 unchanged.

This PR lets people create their own gqlx package with an opt-in exported function that returns an unexported type implementing slog.LogValuer. The type's LogValue method would call CollectAttrs, and slog only calls that method when a handler actually formats the record.

// LogValue returns err's attributes, merged as gqlerror.CollectAttrs merges them, 
// for slog to resolve only when a handler formats the record. A record dropped by
// its level costs nothing more than this call, whereas gqlerror.CollectAttrs passed
// to Logger.LogAttrs runs before the level is checked.
//
// Log it under an empty key to put the "gql" group at the top level, or under a key of its own
// to nest it:
//
//    logger.Error("graphql error", slog.Any("", gqlx.LogValue(err)))
//
// Unlike adding LogValue to *Error, this leaves slog.Any("error", err) printing err.Error() as
// it always has, and works for any error: wrapped, joined, or a List.
func LogValue(err error) slog.LogValuer {
      return logValuer{err: err}
}

// logValuer defers gqlerror.CollectAttrs to slog's resolution of the value.
type logValuer struct {
      err error
}

func (v logValuer) LogValue() slog.Value {
      return slog.GroupValue(gqlerror.CollectAttrs(v.err)...)
}

This skips the work at disabled levels. At a disabled level, CollectAttrs never runs, even if Errors are usually logged at Error or Warn, which are almost always enabled. This provides one value, so it composes. It works with slog.Any, nests under slog.Group, works as an argument to logger.With, and can go inside another error's Attrs(). Its output is merged rather than repeated.

I did not add LogValue in this PR because a slice of merged attributes is the more general building block: the lazy form is five lines on top of it, while recovering the slice from LogValue means calling its method by hand.

It also seemed confusing to provide two ways to do the same thing, so you can just bring your own.

A logger that walks an error chain and logs each error's Attrs gets two
"gql" groups once a cause, such as gqlgen's InvalidNullError, reports
one too. slog does not merge them, so JSON output repeats the key and
most readers keep only the last, losing this package's message and path.

CollectAttrs walks the tree depth first, outermost first, merges groups
that share a key, and keeps the first value of any other repeated key:
the outer *Error's, which is what the response carried. It follows
slog's rules for resolving, inlining and dropping attributes, and never
modifies an error's own.

Mutation testing drove new tests for the rest of the package and the
removal of conditions that could not change behaviour. As a result
ErrorWithSources.Error allocates less: 400 to 64 B/op with one sourced
location. New benchmarks raise gqlerror's benchmark statement coverage
from 34% to 83%, and the mutate task now requires a covered-code score
of 75.

Signed-off-by: Steve Coffman <steve@khanacademy.org>
@StevenACoffman
StevenACoffman merged commit 4354676 into master Oct 8, 2026
8 checks passed
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.

1 participant