diff --git a/clues.go b/clues.go index 922e4ab..2501f28 100644 --- a/clues.go +++ b/clues.go @@ -194,7 +194,53 @@ func AddCommentTo( } // --------------------------------------------------------------------------- -// hooks +// agents +// --------------------------------------------------------------------------- + +// AddAgent adds an agent with a given name to the context. What's an agent? +// It's a special case info gatherer that you can spawn to collect clues for +// you. Unlike standard clues additions, you have to tell the agent exactly +// what data you want it to Gather() for you. +// +// Agents are recorded in the current clues node and all of its descendants. +// Data gathered by the agent will appear as part of the standard data map, +// namespaced by each agent. +// +// Agents are specifically handy in a certain set of uncommon cases where +// retrieving clues is otherwise difficult to do, such as working with +// middleware that doesn't allow control over error creation. In these cases +// your only option is to relay that data back to some prior clues node. +func AddAgent( + ctx context.Context, + name string, +) context.Context { + nc := nodeFromCtx(ctx, defaultNamespace) + nn := nc.addAgent(name) + + return setDefaultNodeInCtx(ctx, nn) +} + +// Gather adds all key-value pairs to the provided agent. The agent will +// record those values to the dataNode in which it was created. All gathered +// values are namespaced to the owning agent. +func Gather( + ctx context.Context, + agent string, + vs ...any, +) { + nc := nodeFromCtx(ctx, defaultNamespace) + ag, ok := nc.agents[agent] + + if !ok { + return + } + + // set values, not add. We don't want agents to own a full clues tree. + ag.data.setValues(normalize(vs...)) +} + +// --------------------------------------------------------------------------- +// error label counter // --------------------------------------------------------------------------- // AddLabelCounter embeds an Adder interface into this context. Any already diff --git a/clues_test.go b/clues_test.go index a46b9c9..506f38c 100644 --- a/clues_test.go +++ b/clues_test.go @@ -12,6 +12,19 @@ import ( "golang.org/x/exp/slices" ) +func mapEquals( + t *testing.T, + ctx context.Context, + expect msa, + expectCluesTrace bool, +) { + mustEquals( + t, + expect, + clues.In(ctx).Map(), + expectCluesTrace) +} + func mustEquals[K comparable, V any]( t *testing.T, expect, got map[K]V, @@ -21,7 +34,9 @@ func mustEquals[K comparable, V any]( if hasCluesTrace && len(g) > 0 { if _, ok := g["clues_trace"]; !ok { - t.Error("expected map to contain key [clues_trace]") + t.Errorf( + "expected map to contain key [clues_trace]\ngot: %+v", + g) } delete(g, "clues_trace") } @@ -683,3 +698,87 @@ func TestAddComment_trace(t *testing.T) { commentMatches(t, expected, stack) } + +func TestAddAgent(t *testing.T) { + ctx := context.Background() + ctx = clues.Add(ctx, "one", 1) + + mapEquals(t, ctx, msa{ + "one": 1, + }, true) + + ctxWithWit := clues.AddAgent(ctx, "wit") + clues.Gather(ctx, "wit", "zero", 0) + clues.Gather(ctxWithWit, "wit", "two", 2) + + mapEquals(t, ctx, msa{ + "one": 1, + }, true) + + mapEquals(t, ctxWithWit, msa{ + "one": 1, + "agents": map[string]map[string]any{ + "wit": { + "two": 2, + }, + }, + }, true) + + ctxWithTim := clues.AddAgent(ctxWithWit, "tim") + clues.Gather(ctxWithTim, "tim", "three", 3) + + mapEquals(t, ctx, msa{ + "one": 1, + }, true) + + mapEquals(t, ctxWithTim, msa{ + "one": 1, + "agents": map[string]map[string]any{ + "wit": { + "two": 2, + }, + "tim": { + "three": 3, + }, + }, + }, true) + + ctxWithBob := clues.AddAgent(ctx, "bob") + clues.Gather(ctxWithBob, "bob", "four", 4) + + mapEquals(t, ctx, msa{ + "one": 1, + }, true) + + // should not have changed since its first usage + mapEquals(t, ctxWithWit, msa{ + "one": 1, + "agents": map[string]map[string]any{ + "wit": { + "two": 2, + }, + }, + }, true) + + // should not have changed since its first usage + mapEquals(t, ctxWithTim, msa{ + "one": 1, + "agents": map[string]map[string]any{ + "wit": { + "two": 2, + }, + "tim": { + "three": 3, + }, + }, + }, true) + + mapEquals(t, ctxWithBob, msa{ + "one": 1, + "agents": map[string]map[string]any{ + "bob": { + "four": 4, + }, + }, + }, true) +} diff --git a/datanode.go b/datanode.go index e89d7e8..4081816 100644 --- a/datanode.go +++ b/datanode.go @@ -60,6 +60,30 @@ type dataNode struct { // Errors will only utilize the first labelCounter they find. The tree is searched // from leaf to root when looking for populated labelCounters. labelCounter Adder + + // agents act as proxy dataNodes that can gather specific, intentional data + // additions. They're namespaced so that additions to the agents don't accidentally + // clobber other values in the dataNode. This also allows agents to protect + // variations of data from each other, in case users need to compare differences + // on the same keys. That's not the goal for agents, exactly, but it is capable. + agents map[string]*agent +} + +// spawnDescendant generates a new dataNode that is a descendant of the current +// node. A descendant maintains a pointer to its parent, and carries any genetic +// necessities (ie, copies of fields) that must be present for continued functionality. +func (dn *dataNode) spawnDescendant() *dataNode { + agents := maps.Clone(dn.agents) + + if agents == nil && dn.agents != nil { + agents = map[string]*agent{} + } + + return &dataNode{ + parent: dn, + labelCounter: dn.labelCounter, + agents: agents, + } } // --------------------------------------------------------------------------- @@ -126,12 +150,24 @@ func (dn *dataNode) addValues(m map[string]any) *dataNode { m = map[string]any{} } - return &dataNode{ - parent: dn, - id: makeNodeID(), - values: maps.Clone(m), - labelCounter: dn.labelCounter, + spawn := dn.spawnDescendant() + spawn.id = makeNodeID() + spawn.setValues(m) + + return spawn +} + +// setValues is a helper called by addValues. +func (dn *dataNode) setValues(m map[string]any) { + if len(m) == 0 { + return + } + + if len(dn.values) == 0 { + dn.values = map[string]any{} } + + maps.Copy(dn.values, m) } // trace adds a new leaf containing a trace ID and no other values. @@ -140,12 +176,10 @@ func (dn *dataNode) trace(name string) *dataNode { name = makeNodeID() } - return &dataNode{ - parent: dn, - id: name, - values: map[string]any{}, - labelCounter: dn.labelCounter, - } + spawn := dn.spawnDescendant() + spawn.id = name + + return spawn } // --------------------------------------------------------------------------- @@ -184,13 +218,13 @@ func InNamespace(ctx context.Context, namespace string) *dataNode { // take priority over ancestors in cases of collision. func (dn *dataNode) Map() map[string]any { var ( - m = map[string]any{} - idsl = []string{} + m = map[string]any{} + nodeIDs = []string{} ) dn.lineage(func(id string, vs map[string]any) { if len(id) > 0 { - idsl = append(idsl, id) + nodeIDs = append(nodeIDs, id) } for k, v := range vs { @@ -198,10 +232,22 @@ func (dn *dataNode) Map() map[string]any { } }) - if len(idsl) > 0 { - m["clues_trace"] = strings.Join(idsl, ",") + if len(nodeIDs) > 0 { + m["clues_trace"] = strings.Join(nodeIDs, ",") } + if len(dn.agents) == 0 { + return m + } + + agentVals := map[string]map[string]any{} + + for _, agent := range dn.agents { + agentVals[agent.id] = agent.data.Map() + } + + m["agents"] = agentVals + return m } @@ -266,11 +312,11 @@ func (dn *dataNode) addComment( return dn } - return &dataNode{ - parent: dn, - labelCounter: dn.labelCounter, - comment: newComment(depth+1, msg, vs...), - } + spawn := dn.spawnDescendant() + spawn.id = makeNodeID() + spawn.comment = newComment(depth+1, msg, vs...) + + return spawn } // comments allows us to put a stringer on a slice of comments. @@ -317,6 +363,38 @@ func (dn *dataNode) Comments() comments { return result } +// --------------------------------------------------------------------------- +// agents +// --------------------------------------------------------------------------- + +type agent struct { + // the name of the agent + id string + + // dataNode is used here instead of a basic value map so that + // we can extend the usage of agents in the future by allowing + // the full set of dataNode behavior. We'll need a builder for that, + // but we'll get there eventually. + data *dataNode +} + +// addAgent adds a new named agent to the dataNode. +func (dn *dataNode) addAgent(name string) *dataNode { + spawn := dn.spawnDescendant() + + if len(spawn.agents) == 0 { + spawn.agents = map[string]*agent{} + } + + spawn.agents[name] = &agent{ + id: name, + // no spawn here, this needs an isolated node + data: &dataNode{}, + } + + return spawn +} + // --------------------------------------------------------------------------- // ctx handling // --------------------------------------------------------------------------- diff --git a/err.go b/err.go index 7b03e63..926b49e 100644 --- a/err.go +++ b/err.go @@ -63,10 +63,8 @@ func newErr( file: file, caller: getCaller(traceDepth + 1), msg: msg, - data: &dataNode{ - id: makeNodeID(), - values: m, - }, + // no ID needed for err data nodes + data: &dataNode{values: m}, } } @@ -106,10 +104,8 @@ func toStack( file: file, caller: getCaller(traceDepth + 1), stack: stack, - data: &dataNode{ - id: makeNodeID(), - values: map[string]any{}, - }, + // no ID needed for err dataNodes + data: &dataNode{}, } } @@ -224,7 +220,7 @@ func stackAncestorsOntoSelf(err error) []error { // comply with. func InErr(err error) *dataNode { if isNilErrIface(err) { - return &dataNode{values: map[string]any{}} + return &dataNode{} } return &dataNode{values: inErr(err)} @@ -252,7 +248,7 @@ func inErr(err error) map[string]any { // data take least priority. func (err *Err) Values() *dataNode { if isNilErrIface(err) { - return &dataNode{values: map[string]any{}} + return &dataNode{} } return &dataNode{values: err.values()} @@ -1026,7 +1022,7 @@ func WithSkipCaller(err error, depth int) *Err { // appearance and prefixed by the file and line in which they appeared. This // means comments are always added to the error and never clobber each other, // regardless of their location. -func (err *Err) WithComment(msg string, vs ...any) *Err { +func (err *Err) Comment(msg string, vs ...any) *Err { if isNilErrIface(err) { return nil } @@ -1052,7 +1048,7 @@ func (err *Err) WithComment(msg string, vs ...any) *Err { // appearance and prefixed by the file and line in which they appeared. This // means comments are always added to the error and never clobber each other, // regardless of their location. -func WithComment(err error, msg string, vs ...any) *Err { +func Comment(err error, msg string, vs ...any) *Err { if isNilErrIface(err) { return nil } diff --git a/err_fmt_test.go b/err_fmt_test.go index c4ca1c9..85cf8f0 100644 --- a/err_fmt_test.go +++ b/err_fmt_test.go @@ -1530,7 +1530,7 @@ func TestWithTrace(t *testing.T) { } } -func TestWithComment(t *testing.T) { +func TestComment(t *testing.T) { table := []struct { name string commenter func(err error) error @@ -1667,10 +1667,11 @@ func TestWithComment(t *testing.T) { func TestErrCore_String(t *testing.T) { table := []struct { - name string - core *clues.ErrCore - expectS string - expectVPlus string + name string + core *clues.ErrCore + expectS string + expectVPlus string + expectCluesTrace bool }{ { name: "nil", @@ -1685,16 +1686,18 @@ func TestErrCore_String(t *testing.T) { With("key", "value"). Label("label"). Core(), - expectS: `{"message", [label], {key:value}}`, - expectVPlus: `{msg:"message", labels:[label], values:{key:value}, comments:[]}`, + expectS: `{"message", [label], {key:value}}`, + expectVPlus: `{msg:"message", labels:[label], values:{key:value}, comments:[]}`, + expectCluesTrace: true, }, { name: "message only", core: clues. New("message"). Core(), - expectS: `{"message"}`, - expectVPlus: `{msg:"message", labels:[], values:{}, comments:[]}`, + expectS: `{"message"}`, + expectVPlus: `{msg:"message", labels:[], values:{}, comments:[]}`, + expectCluesTrace: false, }, { name: "labels only", @@ -1702,8 +1705,9 @@ func TestErrCore_String(t *testing.T) { New(""). Label("label"). Core(), - expectS: `{[label]}`, - expectVPlus: `{msg:"", labels:[label], values:{}, comments:[]}`, + expectS: `{[label]}`, + expectVPlus: `{msg:"", labels:[label], values:{}, comments:[]}`, + expectCluesTrace: false, }, { name: "values only", @@ -1711,16 +1715,19 @@ func TestErrCore_String(t *testing.T) { New(""). With("key", "value"). Core(), - expectS: `{{key:value}}`, - expectVPlus: `{msg:"", labels:[], values:{key:value}, comments:[]}`, + expectS: `{{key:value}}`, + expectVPlus: `{msg:"", labels:[], values:{key:value}, comments:[]}`, + expectCluesTrace: true, }, } for _, test := range table { t.Run(test.name, func(t *testing.T) { tc := test.core if tc != nil { - if _, ok := tc.Values["clues_trace"]; !ok { - t.Error("expected core values to contain key [clues_trace]") + if _, ok := tc.Values["clues_trace"]; ok != test.expectCluesTrace { + t.Errorf( + "expected core values to contain key [clues_trace]\ngot: %+v", + tc.Values) } delete(tc.Values, "clues_trace") } diff --git a/err_test.go b/err_test.go index 4ce1637..8e0ccce 100644 --- a/err_test.go +++ b/err_test.go @@ -1368,8 +1368,8 @@ func withCommentWrapper( ) error { // always add two comments to test that both are saved return clues. - WithComment(err, msg, vs...). - WithComment(msg+" - repeat", vs...) + Comment(err, msg, vs...). + Comment(msg+" - repeat", vs...) } func cluesWithCommentWrapper( @@ -1379,6 +1379,6 @@ func cluesWithCommentWrapper( ) error { // always add two comments to test that both are saved return err. - WithComment(msg, vs...). - WithComment(msg+" - repeat", vs...) + Comment(msg, vs...). + Comment(msg+" - repeat", vs...) }