diff --git a/clues.go b/clues.go index 922e4ab..7b8b6f6 100644 --- a/clues.go +++ b/clues.go @@ -194,7 +194,7 @@ func AddCommentTo( } // --------------------------------------------------------------------------- -// hooks +// error label counter // --------------------------------------------------------------------------- // AddLabelCounter embeds an Adder interface into this context. Any already @@ -209,7 +209,7 @@ func AddLabelCounter(ctx context.Context, counter Adder) context.Context { return setDefaultNodeInCtx(ctx, nn) } -// AddLabelCounterTo embeds an Adder interface into this context. Any already +// AddLabelCounterTo embeds an Adder interface within a namespace. Any already // embedded Adder will get replaced. When adding Labels to a clues.Err the // LabelCounter will use the label as the key for the Add call, and increment // the count of that label by one. @@ -224,3 +224,63 @@ func AddLabelCounterTo( return setNodeInCtx(ctx, namespace, nn) } + +// --------------------------------------------------------------------------- +// comments +// --------------------------------------------------------------------------- + +// AddProxy attaches up a new clues proxy in the context. All proxies are +// passed down to all descendants which branch off of this context. Whenever +// a clues is added to the context, it gets added to the proxy as well. As +// a result, any clues added to the context in descendent funcs will appear in +// this context as well. +// +// If the proxyID is an empty string, a random id will be generated. +// +// If isolateProxyValues is true, the proxy will namespace all of its values +// using the proxyID to avoid clobbering any existing values. +// +// Proxy support is specifically useful in a unique situation: when you are +// able to both pass in a ctx and also able to add clues data to it, but also +// unable to later retrieve the ctx or to bind the ctx values into an error. +// This case can manifest when passing ad-hoc functions or middleware to +// callers who limit options to control for error returns. +func AddProxy( + ctx context.Context, + proxyID string, + isolateProxyValues bool, +) context.Context { + nc := nodeFromCtx(ctx, defaultNamespace) + nn := nc.addValues(nil) + nn = nn.addProxy(proxyID, isolateProxyValues) + + return setDefaultNodeInCtx(ctx, nn) +} + +// AddProxyTo attaches up a new clues proxy within the context namespace. +// All proxies are passed down to all descendants which branch off of this +// context. Whenever a clues is added to the context, it gets added to the +// proxy as well. As a result, any clues added to the context in descendent +// funcs will appear in this context as well. +// +// If the proxyID is an empty string, a random id will be generated. +// +// If isolateProxyValues is true, the proxy will namespace all of its values +// using the proxyID to avoid clobbering any existing values. +// +// Proxy support is specifically useful in a unique situation: when you are +// able to both pass in a ctx and also able to add clues data to it, but also +// unable to later retrieve the ctx or to bind the ctx values into an error. +// This case can manifest when passing ad-hoc functions or middleware to +// callers who limit options to control for error returns. +func AddProxyTo( + ctx context.Context, + namespace, proxyID string, + isolateProxyValues bool, +) context.Context { + nc := nodeFromCtx(ctx, ctxKey(namespace)) + nn := nc.addValues(nil) + nn = nn.addProxy(proxyID, isolateProxyValues) + + return setNodeInCtx(ctx, namespace, nn) +} diff --git a/clues_test.go b/clues_test.go index a46b9c9..76fa974 100644 --- a/clues_test.go +++ b/clues_test.go @@ -12,6 +12,14 @@ import ( "golang.org/x/exp/slices" ) +func mapEquals( + t *testing.T, + ctx context.Context, + expect msa, +) { + mustEquals(t, expect, clues.In(ctx).Map(), true) +} + func mustEquals[K comparable, V any]( t *testing.T, expect, got map[K]V, @@ -21,7 +29,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") } @@ -424,6 +434,154 @@ func TestAddTraceNameTo(t *testing.T) { } } +func TestProxy(t *testing.T) { + table := []struct { + name string + kvs [][]string + isolated bool + expectM msa + expectS sa + }{ + { + name: "single", + kvs: [][]string{{"k", "v"}}, + isolated: false, + expectM: msa{"k": "v"}, + expectS: sa{"k", "v"}, + }, + { + name: "multiple", + kvs: [][]string{{"a", "1"}, {"b", "2"}}, + isolated: false, + expectM: msa{"k": "v", "a": "1", "b": "2"}, + expectS: sa{"k", "v", "a", "1", "b", "2"}, + }, + { + name: "duplicates", + kvs: [][]string{{"a", "1"}, {"a", "2"}}, + isolated: false, + expectM: msa{"k": "v", "a": "2"}, + expectS: sa{"k", "v", "a", "2"}, + }, + { + name: "single isolated", + kvs: [][]string{{"k", "v"}}, + isolated: true, + expectM: msa{"k": "v", "proxy_pfx_k": "v"}, + expectS: sa{"k", "v", "proxy_pfx_k", "v"}, + }, + { + name: "multiple isolated", + kvs: [][]string{{"a", "1"}, {"b", "2"}}, + isolated: true, + expectM: msa{"k": "v", "proxy_pfx_a": "1", "proxy_pfx_b": "2"}, + expectS: sa{"k", "v", "proxy_pfx_a", "1", "proxy_pfx_b", "2"}, + }, + { + name: "duplicates isolated", + kvs: [][]string{{"a", "1"}, {"a", "2"}}, + isolated: true, + expectM: msa{"k": "v", "proxy_pfx_a": "2"}, + expectS: sa{"k", "v", "proxy_pfx_a", "2"}, + }, + } + for _, test := range table { + t.Run(test.name, func(t *testing.T) { + ctx := context.WithValue(context.Background(), testCtx{}, "instance") + ctx = clues.Add(ctx, "k", "v") + + check := msa{"k": "v"} + + mustEquals(t, check, clues.In(ctx).Map(), true) + + ctx = clues.AddProxy(ctx, "proxy_pfx", test.isolated) + + for _, kv := range test.kvs { + clues.Add(ctx, kv[0], kv[1]) + + if test.isolated { + check["proxy_pfx_"+kv[0]] = kv[1] + } else { + check[kv[0]] = kv[1] + } + + mustEquals(t, check, clues.In(ctx).Map(), true) + } + + assert( + t, ctx, "", + test.expectM, msa{}, + test.expectS, sa{}) + }) + } +} + +func TestProxy_multipleLayers(t *testing.T) { + ctx := clues.Add(context.Background(), 1, 1) + pxyCtx := clues.AddProxy(ctx, "p1", true) + + t.Run("1", func(t *testing.T) { + mapEquals(t, ctx, msa{"1": 1}) + mapEquals(t, pxyCtx, msa{"1": 1}) + }) + + ctx2 := clues.Add(pxyCtx, 2, 2) + pxyCtx2 := clues.AddProxy(ctx2, "p2", true) + + t.Run("2", func(t *testing.T) { + mapEquals(t, ctx, msa{"1": 1}) + mapEquals(t, pxyCtx, msa{ + "1": 1, + "p1_2": 2, + }) + mapEquals(t, ctx2, msa{ + "1": 1, + "2": 2, + "p1_2": 2, + }) + mapEquals(t, pxyCtx2, msa{ + "1": 1, + "2": 2, + }) + }) + + ctx3 := clues.Add(pxyCtx2, 3, 3) + + t.Run("3", func(t *testing.T) { + mapEquals(t, ctx, msa{"1": 1}) + // proxy 1 is still accumulating all the values added + // to descendant contexts, even after proxy 2 replaces + // it, because proxy 2 should maintain an ancestry ref + // to proxy 1. + mapEquals(t, pxyCtx, msa{ + "1": 1, + "p1_2": 2, + "p1_3": 3, + }) + mapEquals(t, ctx2, msa{ + "1": 1, + "2": 2, + "p1_2": 2, + "p1_3": 3, + }) + // ctxs up to ctx2 only have a reference to proxy 1. + // once we add proxy 2, the contexts from that point + // on maintain only that reference, and no longer + // retrieve proxy 1 values. + mapEquals(t, pxyCtx2, msa{ + "1": 1, + "2": 2, + "p2_3": 3, + }) + mapEquals(t, ctx3, msa{ + "1": 1, + "2": 2, + "3": 3, + "p2_3": 3, + }) + }) +} + func TestImmutableCtx(t *testing.T) { var ( ctx = context.Background() diff --git a/datanode.go b/datanode.go index e89d7e8..cc15a21 100644 --- a/datanode.go +++ b/datanode.go @@ -40,6 +40,11 @@ type dataNode struct { // full trace along the node's ancestry path in the tree. id string + // isolationPrefix is an optional namespacing configuration. When non-empty, + // all values will update their key with the isolationPrefix. + // IE: "foo": "bar" becomes "pfx_foo": "bar" + isolationPrefix string + // values are they arbitrary key:value pairs that appear in clues when callers // use the Add(ctx, k, v) or err.With(k, v) adders. Each key-value pair added // to the node is used to produce the final set of Values() in the dataNode, @@ -60,6 +65,37 @@ 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 + + // proxies contain all proxies (keyed by proxyID) attached to this context. + // Once a proxy has been added to this map, it should be passed down to + // all descendant dataNodes. Therefore, any given child of a data node should + // have an equal or greater number of proxies compared to its parent. + // + // In the hierarchy of data collision priority, proxies have higher priority + // than other context data (eg: last descendant's data wins), but lower + // priority than any error values. + // + // Proxies construct their own data tree. The root of the tree is the first + // proxy added to the context. That proxy will get passed down to all descendants. + // If any descendant of the primary dataNode attaches another proxy, that new + // proxy becomes a descenant of the currently existing proxy. This way, when we + // add a value to a proxy, we are able to walk its ascendancy tree to add the same + // valus to all ancestory proxies, thereby guaranteeing that every aded proxy gets + // the same data. + proxy *dataNode +} + +// 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, + // proxies construct their own data tree, and must be passed + // from parent to child to maintain references. + proxy: dn.proxy, + } } // --------------------------------------------------------------------------- @@ -126,11 +162,38 @@ 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) + + // if a proxy exists, add the same values to the proxy lineage. + if dn.proxy != nil { + for _, ancestor := range dn.proxy.ancestors() { + ancestor.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{} + } + + if len(dn.isolationPrefix) == 0 { + maps.Copy(dn.values, m) + return + } + + for k, v := range m { + k = dn.isolationPrefix + "_" + k + dn.values[k] = v } } @@ -140,30 +203,35 @@ 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 } // --------------------------------------------------------------------------- // getters // --------------------------------------------------------------------------- -// lineage runs the fn on every valueNode in the ancestry tree, -// starting at the root and ending at the dataNode. -func (dn *dataNode) lineage(fn func(id string, vs map[string]any)) { +// ancestors builds an ancestor lineage from this dataNode. +// * the root dataNode is the zeroth, the leaf is last entry. +func (dn *dataNode) ancestors() []*dataNode { + return stackParentOntoSelf(dn) +} + +// a recursive function, purely for building out dn.ancestors. +func stackParentOntoSelf(dn *dataNode) []*dataNode { if dn == nil { - return + return []*dataNode{} } + nodes := []*dataNode{} + if dn.parent != nil { - dn.parent.lineage(fn) + nodes = stackParentOntoSelf(dn.parent) } - fn(dn.id, dn.values) + return append(nodes, dn) } // In returns the default dataNode from the context. @@ -184,24 +252,39 @@ 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{} + traces = []string{} ) - dn.lineage(func(id string, vs map[string]any) { - if len(id) > 0 { - idsl = append(idsl, id) - } + // build a root-to-leaf lineage through the tree. + lineage := dn.ancestors() - for k, v := range vs { - m[k] = v + // copy the values of each ancestor onto the map. + // descendant values will clobber ancestor values. + for _, ancestor := range lineage { + maps.Copy(m, ancestor.values) + + if len(ancestor.id) > 0 { + traces = append(traces, ancestor.id) } - }) + } - if len(idsl) > 0 { - m["clues_trace"] = strings.Join(idsl, ",") + // if a proxy exists, it gets the highest priority + // for representing all unattached descendant data + // beyond the leaf. + if dn.proxy != nil { + // don't call proxy.Values() here, we don't want + // any of the proxy parent data in this result. + maps.Copy(m, dn.proxy.values) + + if len(dn.proxy.id) > 0 { + traces = append(traces, "["+dn.proxy.id+"]") + } } + // construct a clues trace of ids from root to leaf. + m["clues_trace"] = strings.Join(traces, ",") + return m } @@ -266,11 +349,10 @@ func (dn *dataNode) addComment( return dn } - return &dataNode{ - parent: dn, - labelCounter: dn.labelCounter, - comment: newComment(depth+1, msg, vs...), - } + spawn := dn.spawnDescendant() + spawn.comment = newComment(depth+1, msg, vs...) + + return spawn } // comments allows us to put a stringer on a slice of comments. @@ -317,6 +399,42 @@ func (dn *dataNode) Comments() comments { return result } +// --------------------------------------------------------------------------- +// proxies +// --------------------------------------------------------------------------- + +// addProxy creates a new dataNode and adds this proxy to it. +func (dn *dataNode) addProxy( + proxyID string, + isolateValues bool, +) *dataNode { + spawn := dn.spawnDescendant() + + if len(proxyID) == 0 { + proxyID = makeNodeID() + } + + // generate a proxy + proxy := &dataNode{} + + // proxies maintain their own tree, so that we can walk the. + // proxy tree to update all proxies, instead of walking the + // full ancestry tree. + if dn.proxy != nil { + proxy = dn.proxy.spawnDescendant() + } + + proxy.id = proxyID + + if isolateValues { + proxy.isolationPrefix = proxyID + } + + spawn.proxy = proxy + + return spawn +} + // --------------------------------------------------------------------------- // ctx handling // --------------------------------------------------------------------------- diff --git a/err.go b/err.go index 7b03e63..96262a0 100644 --- a/err.go +++ b/err.go @@ -63,10 +63,7 @@ func newErr( file: file, caller: getCaller(traceDepth + 1), msg: msg, - data: &dataNode{ - id: makeNodeID(), - values: m, - }, + data: &dataNode{values: m}, } } @@ -106,10 +103,7 @@ func toStack( file: file, caller: getCaller(traceDepth + 1), stack: stack, - data: &dataNode{ - id: makeNodeID(), - values: map[string]any{}, - }, + data: &dataNode{values: map[string]any{}}, } } diff --git a/err_fmt_test.go b/err_fmt_test.go index c4ca1c9..e1c6b9d 100644 --- a/err_fmt_test.go +++ b/err_fmt_test.go @@ -1720,7 +1720,9 @@ func TestErrCore_String(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]") + t.Errorf( + "expected core values to contain key [clues_trace]\ngot: %+v", + tc.Values) } delete(tc.Values, "clues_trace") }