From dca42127044e86ef325cd0e20bb98be03eaa00ef Mon Sep 17 00:00:00 2001 From: Keepers Date: Fri, 2 Aug 2024 15:15:20 -0600 Subject: [PATCH 1/2] add witnesses witnesses add a targeted producer/consumer system to clues that allow downstream producers to relay data back up to upstream consumer contexts. They're not generally useful, but in certain cases where it becomes difficult to maintain delivery and receipt of clues otherwise they become a sort of necessary evil. --- clues.go | 44 ++++++++++++++++++- clues_test.go | 78 ++++++++++++++++++++++++++++++++- datanode.go | 114 +++++++++++++++++++++++++++++++++++++++--------- err.go | 20 ++++----- err_fmt_test.go | 37 +++++++++------- err_test.go | 8 ++-- 6 files changed, 247 insertions(+), 54 deletions(-) diff --git a/clues.go b/clues.go index 922e4ab..c57f108 100644 --- a/clues.go +++ b/clues.go @@ -194,7 +194,49 @@ func AddCommentTo( } // --------------------------------------------------------------------------- -// hooks +// witness +// --------------------------------------------------------------------------- + +// AddWitness adds a witness with a given name to the contex. The caller can +// pass the witness clues directly in a downstream instance to add those clues +// to the current clues node. This can be 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. +// +// When retreiving clues from a context, each witness will produce its own +// namespaced set of values +func AddWitness( + ctx context.Context, + name string, +) context.Context { + nc := nodeFromCtx(ctx, defaultNamespace) + nn := nc.addWitness(name) + + return setDefaultNodeInCtx(ctx, nn) +} + +// Relay adds all key-value pairs to the provided witness. The witness will +// record those values to the dataNode in which it was created. All relayed +// values are namespaced to the owning witness. +func Relay( + ctx context.Context, + witness string, + vs ...any, +) { + nc := nodeFromCtx(ctx, defaultNamespace) + wit, ok := nc.witnesses[witness] + + if !ok { + return + } + + // set values, not add. We don't want witnesses + // to own a full clues tree. + wit.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..78233d0 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,64 @@ func TestAddComment_trace(t *testing.T) { commentMatches(t, expected, stack) } + +func TestAddWitness(t *testing.T) { + ctx := context.Background() + ctx = clues.Add(ctx, "one", 1) + + mapEquals(t, ctx, msa{ + "one": 1, + }, true) + + ctxWithWit := clues.AddWitness(ctx, "wit") + clues.Relay(ctx, "wit", "zero", 0) + clues.Relay(ctxWithWit, "wit", "two", 2) + + mapEquals(t, ctx, msa{ + "one": 1, + }, true) + + mapEquals(t, ctxWithWit, msa{ + "one": 1, + "witnessed": map[string]map[string]any{ + "wit": { + "two": 2, + }, + }, + }, true) + + ctxWithTim := clues.AddWitness(ctxWithWit, "tim") + clues.Relay(ctxWithTim, "tim", "three", 3) + + mapEquals(t, ctx, msa{ + "one": 1, + }, true) + + mapEquals(t, ctxWithTim, msa{ + "one": 1, + "witnessed": map[string]map[string]any{ + "wit": { + "two": 2, + }, + "tim": { + "three": 3, + }, + }, + }, true) + + ctxWithBob := clues.AddWitness(ctx, "bob") + clues.Relay(ctxWithBob, "bob", "four", 4) + + mapEquals(t, ctx, msa{ + "one": 1, + }, true) + + mapEquals(t, ctxWithBob, msa{ + "one": 1, + "witnessed": map[string]map[string]any{ + "bob": { + "four": 4, + }, + }, + }, true) +} diff --git a/datanode.go b/datanode.go index e89d7e8..f7f711f 100644 --- a/datanode.go +++ b/datanode.go @@ -60,6 +60,24 @@ 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 + + // witnesses act as proxy dataNodes that can gather specific, intentional data + // additions. They're namespaced so that additions to the witness don't accidentally + // clobber other values in the dataNode. This also allows witnesses 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 witnesses, exactly, but it is capable. + witnesses map[string]*witness +} + +// 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 { + return &dataNode{ + parent: dn, + labelCounter: dn.labelCounter, + witnesses: maps.Clone(dn.witnesses), + } } // --------------------------------------------------------------------------- @@ -126,12 +144,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 +170,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 +212,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 +226,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.witnesses) == 0 { + return m } + witnessVals := map[string]map[string]any{} + + for _, witness := range dn.witnesses { + witnessVals[witness.id] = witness.data.Map() + } + + m["witnessed"] = witnessVals + return m } @@ -266,11 +306,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 +357,38 @@ func (dn *dataNode) Comments() comments { return result } +// --------------------------------------------------------------------------- +// witnesses +// --------------------------------------------------------------------------- + +type witness struct { + // the name of the witness + id string + + // dataNode is used here instead of a basic value map so that + // we can extend the usage of witnesses 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 +} + +// addWitness adds a new named witness to the dataNode. +func (dn *dataNode) addWitness(name string) *dataNode { + spawn := dn.spawnDescendant() + + if len(spawn.witnesses) == 0 { + spawn.witnesses = map[string]*witness{} + } + + spawn.witnesses[name] = &witness{ + 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...) } From 60d95df101d69b19e68efc9aea61346480123a6c Mon Sep 17 00:00:00 2001 From: Keepers Date: Fri, 16 Aug 2024 10:22:11 -0600 Subject: [PATCH 2/2] witness -> agent, better explanation of agent usage --- clues.go | 42 +++++++++++++++++++++++------------------- clues_test.go | 45 ++++++++++++++++++++++++++++++++++----------- datanode.go | 46 ++++++++++++++++++++++++++-------------------- 3 files changed, 83 insertions(+), 50 deletions(-) diff --git a/clues.go b/clues.go index c57f108..2501f28 100644 --- a/clues.go +++ b/clues.go @@ -194,45 +194,49 @@ func AddCommentTo( } // --------------------------------------------------------------------------- -// witness +// agents // --------------------------------------------------------------------------- -// AddWitness adds a witness with a given name to the contex. The caller can -// pass the witness clues directly in a downstream instance to add those clues -// to the current clues node. This can be 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. +// 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. // -// When retreiving clues from a context, each witness will produce its own -// namespaced set of values -func AddWitness( +// 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.addWitness(name) + nn := nc.addAgent(name) return setDefaultNodeInCtx(ctx, nn) } -// Relay adds all key-value pairs to the provided witness. The witness will -// record those values to the dataNode in which it was created. All relayed -// values are namespaced to the owning witness. -func Relay( +// 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, - witness string, + agent string, vs ...any, ) { nc := nodeFromCtx(ctx, defaultNamespace) - wit, ok := nc.witnesses[witness] + ag, ok := nc.agents[agent] if !ok { return } - // set values, not add. We don't want witnesses - // to own a full clues tree. - wit.data.setValues(normalize(vs...)) + // set values, not add. We don't want agents to own a full clues tree. + ag.data.setValues(normalize(vs...)) } // --------------------------------------------------------------------------- diff --git a/clues_test.go b/clues_test.go index 78233d0..506f38c 100644 --- a/clues_test.go +++ b/clues_test.go @@ -699,7 +699,7 @@ func TestAddComment_trace(t *testing.T) { commentMatches(t, expected, stack) } -func TestAddWitness(t *testing.T) { +func TestAddAgent(t *testing.T) { ctx := context.Background() ctx = clues.Add(ctx, "one", 1) @@ -707,9 +707,9 @@ func TestAddWitness(t *testing.T) { "one": 1, }, true) - ctxWithWit := clues.AddWitness(ctx, "wit") - clues.Relay(ctx, "wit", "zero", 0) - clues.Relay(ctxWithWit, "wit", "two", 2) + ctxWithWit := clues.AddAgent(ctx, "wit") + clues.Gather(ctx, "wit", "zero", 0) + clues.Gather(ctxWithWit, "wit", "two", 2) mapEquals(t, ctx, msa{ "one": 1, @@ -717,15 +717,15 @@ func TestAddWitness(t *testing.T) { mapEquals(t, ctxWithWit, msa{ "one": 1, - "witnessed": map[string]map[string]any{ + "agents": map[string]map[string]any{ "wit": { "two": 2, }, }, }, true) - ctxWithTim := clues.AddWitness(ctxWithWit, "tim") - clues.Relay(ctxWithTim, "tim", "three", 3) + ctxWithTim := clues.AddAgent(ctxWithWit, "tim") + clues.Gather(ctxWithTim, "tim", "three", 3) mapEquals(t, ctx, msa{ "one": 1, @@ -733,7 +733,7 @@ func TestAddWitness(t *testing.T) { mapEquals(t, ctxWithTim, msa{ "one": 1, - "witnessed": map[string]map[string]any{ + "agents": map[string]map[string]any{ "wit": { "two": 2, }, @@ -743,16 +743,39 @@ func TestAddWitness(t *testing.T) { }, }, true) - ctxWithBob := clues.AddWitness(ctx, "bob") - clues.Relay(ctxWithBob, "bob", "four", 4) + 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, - "witnessed": map[string]map[string]any{ + "agents": map[string]map[string]any{ "bob": { "four": 4, }, diff --git a/datanode.go b/datanode.go index f7f711f..4081816 100644 --- a/datanode.go +++ b/datanode.go @@ -61,22 +61,28 @@ type dataNode struct { // from leaf to root when looking for populated labelCounters. labelCounter Adder - // witnesses act as proxy dataNodes that can gather specific, intentional data - // additions. They're namespaced so that additions to the witness don't accidentally - // clobber other values in the dataNode. This also allows witnesses to protect + // 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 witnesses, exactly, but it is capable. - witnesses map[string]*witness + // 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, - witnesses: maps.Clone(dn.witnesses), + agents: agents, } } @@ -230,17 +236,17 @@ func (dn *dataNode) Map() map[string]any { m["clues_trace"] = strings.Join(nodeIDs, ",") } - if len(dn.witnesses) == 0 { + if len(dn.agents) == 0 { return m } - witnessVals := map[string]map[string]any{} + agentVals := map[string]map[string]any{} - for _, witness := range dn.witnesses { - witnessVals[witness.id] = witness.data.Map() + for _, agent := range dn.agents { + agentVals[agent.id] = agent.data.Map() } - m["witnessed"] = witnessVals + m["agents"] = agentVals return m } @@ -358,29 +364,29 @@ func (dn *dataNode) Comments() comments { } // --------------------------------------------------------------------------- -// witnesses +// agents // --------------------------------------------------------------------------- -type witness struct { - // the name of the witness +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 witnesses in the future by allowing + // 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 } -// addWitness adds a new named witness to the dataNode. -func (dn *dataNode) addWitness(name string) *dataNode { +// addAgent adds a new named agent to the dataNode. +func (dn *dataNode) addAgent(name string) *dataNode { spawn := dn.spawnDescendant() - if len(spawn.witnesses) == 0 { - spawn.witnesses = map[string]*witness{} + if len(spawn.agents) == 0 { + spawn.agents = map[string]*agent{} } - spawn.witnesses[name] = &witness{ + spawn.agents[name] = &agent{ id: name, // no spawn here, this needs an isolated node data: &dataNode{},