Skip to content

Commit

Permalink
adds alpha support for otel (#57)
Browse files Browse the repository at this point in the history
Adds otel hookins for clues.  This adds the following:

1 - an optional initializer for users who want to run otel support.
```go
clues.Initialize(ctx)
````

2 - Span creation.
```go
ctx := clues.AddSpan(ctx, "spanName", "with_foo", "bar")
defer clues.CloseSpan(ctx)
````

3 - Automatic otel span attribute addition when Adding to clues.
4 - Automatic otel span logging when using Clog.
  • Loading branch information
ryanfkeepers authored Nov 5, 2024
1 parent b39b934 commit d786361
Show file tree
Hide file tree
Showing 6 changed files with 467 additions and 71 deletions.
84 changes: 63 additions & 21 deletions clues.go
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,46 @@ import (
"github.com/alcionai/clues/internal/stringify"
)

// ---------------------------------------------------------------------------
// persistent client initialization
// ---------------------------------------------------------------------------

// Initialize will spin up any persistent clients that are held by clues,
// such as OTEL communication. Clues will use these optimistically in the
// background to provide additional telemetry hook-ins.
//
// Clues will operate as expected in the event of an error, or if initialization
// is not called. This is a purely optional step.
func Initialize(
ctx context.Context,
serviceName string,
config OTELConfig,
) (context.Context, error) {
nc := nodeFromCtx(ctx)

err := nc.init(ctx, serviceName, config)
if err != nil {
return ctx, err
}

return setNodeInCtx(ctx, nc), nil
}

// Close will flush all buffered data waiting to be read. If Initialize was not
// called, this call is a no-op. Should be called in a defer after initializing.
func Close(ctx context.Context) error {
nc := nodeFromCtx(ctx)

if nc.otel != nil {
err := nc.otel.close(ctx)
if err != nil {
return Wrap(err, "closing otel client")
}
}

return nil
}

// ---------------------------------------------------------------------------
// key-value metadata
// ---------------------------------------------------------------------------
Expand Down Expand Up @@ -35,40 +75,42 @@ func AddMap[K comparable, V any](
// traces
// ---------------------------------------------------------------------------

// AddTrace stacks a clues node onto this context. Adding a node ensures
// that this point in code is identified by an ID, which can later be
// used to correlate and isolate logs to certain trace branches.
// AddTrace is only needed for layers that don't otherwise call Add() or
// similar functions, since those funcs already attach a new node.
func AddTrace(
ctx context.Context,
traceID string,
) context.Context {
nc := nodeFromCtx(ctx)
return setNodeInCtx(ctx, nc.trace(traceID))
}

// AddTraceWith stacks a clues node onto this context and uses the provided
// name for the trace id, instead of a randomly generated hash. AddTraceWith
// can be called without additional values if you only want to add a trace marker.
func AddTraceWith(
// AddSpan stacks a clues node onto this context and uses the provided
// name for the trace id, instead of a randomly generated hash. AddSpan
// can be called without additional values if you only want to add a trace
// marker. The assumption is that an otel span is generated and attached
// to the node. Callers should always follow this addition with a closing
// `defer clues.CloseSpan(ctx)`.
func AddSpan(
ctx context.Context,
traceID string,
name string,
kvs ...any,
) context.Context {
nc := nodeFromCtx(ctx)

var node *dataNode

if len(kvs) > 0 {
node = nc.addValues(stringify.Normalize(kvs...))
node.id = traceID
ctx, node = nc.addSpan(ctx, name)
node.id = name
node = node.addValues(stringify.Normalize(kvs...))
} else {
node = nc.trace(traceID)
ctx, node = nc.addSpan(ctx, name)
node = node.trace(name)
}

return setNodeInCtx(ctx, node)
}

// CloseSpan closes the current span in the clues node. Should only be called
// following a `clues.AddSpan()` call.
func CloseSpan(ctx context.Context) context.Context {
nc := nodeFromCtx(ctx)
node := nc.closeSpan(ctx)

return setNodeInCtx(ctx, node)
}

// ---------------------------------------------------------------------------
// comments
// ---------------------------------------------------------------------------
Expand Down
83 changes: 41 additions & 42 deletions clues_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -247,33 +247,7 @@ func TestAddMap(t *testing.T) {
}
}

func TestAddTrace(t *testing.T) {
table := []struct {
name string
expectM msa
expectS sa
}{
{"single", msa{}, sa{}},
{"multiple", msa{}, sa{}},
{"duplicates", msa{}, sa{}},
}
for _, test := range table {
t.Run(test.name, func(t *testing.T) {
ctx := context.WithValue(context.Background(), testCtx{}, "instance")
check := msa{}
mustEquals(t, check, clues.In(ctx).Map(), false)

ctx = clues.AddTrace(ctx, "")

assert(
t, ctx, "",
test.expectM, msa{},
test.expectS, sa{})
})
}
}

func TestAddTraceName(t *testing.T) {
func TestAddSpan(t *testing.T) {
table := []struct {
name string
names []string
Expand All @@ -290,24 +264,49 @@ func TestAddTraceName(t *testing.T) {
{"duplicates with kvs", []string{"single", "multiple", "multiple"}, "single,multiple,multiple", sa{"k", "v"}, msa{"k": "v"}, sa{"k", "v"}},
}
for _, test := range table {
t.Run(test.name, func(t *testing.T) {
ctx := context.WithValue(context.Background(), testCtx{}, "instance")
mustEquals(t, msa{}, clues.In(ctx).Map(), false)
for _, init := range []bool{true, false} {
t.Run(test.name, func(t *testing.T) {
ctx := context.Background()

if init {
ictx, err := clues.Initialize(ctx, test.name, clues.OTELConfig{
GRPCEndpoint: "localhost:4317",
})
if err != nil {
t.Error("initializing clues", err)
return
}

defer func() {
err := clues.Close(ictx)
if err != nil {
t.Error("closing clues:", err)
return
}
}()

ctx = ictx
}

for _, name := range test.names {
ctx = clues.AddTraceWith(ctx, name, test.kvs...)
}
ctx = context.WithValue(ctx, testCtx{}, "instance")
mustEquals(t, msa{}, clues.In(ctx).Map(), false)

assert(
t, ctx, "",
test.expectM, msa{},
test.expectS, sa{})
for _, name := range test.names {
ctx = clues.AddSpan(ctx, name, test.kvs...)
defer clues.CloseSpan(ctx)
}

c := clues.In(ctx).Map()
if c["clues_trace"] != test.expectTrace {
t.Errorf("expected clues_trace to equal %q, got %q", test.expectTrace, c["clues_trace"])
}
})
assert(
t, ctx, "",
test.expectM, msa{},
test.expectS, sa{})

c := clues.In(ctx).Map()
if c["clues_trace"] != test.expectTrace {
t.Errorf("expected clues_trace to equal %q, got %q", test.expectTrace, c["clues_trace"])
}
})
}
}
}

Expand Down
114 changes: 114 additions & 0 deletions datanode.go
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,11 @@ import (
"runtime"
"strings"

"github.com/alcionai/clues/internal/stringify"
"github.com/google/uuid"
"go.opentelemetry.io/otel/attribute"
otellog "go.opentelemetry.io/otel/log"
"go.opentelemetry.io/otel/trace"
"golang.org/x/exp/maps"
"golang.org/x/exp/slices"
)
Expand All @@ -34,6 +38,16 @@ type Adder interface {
type dataNode struct {
parent *dataNode

// otel contains the client instance for the in memory otel runtime. It is only
// present if the end user calls the clues initialization step.
otel *otelClient

// span is the current otel Span.
// Spans are kept separately from the otelClient because we want the client to
// maintain a consistent reference to otel initialization, while the span can
// get replaced at arbitrary points.
span trace.Span

// ids are optional and are used primarily as tracing markers.
// if empty, the trace for that node will get skipped when building the
// full trace along the node's ancestry path in the tree.
Expand Down Expand Up @@ -71,6 +85,8 @@ func (dn *dataNode) spawnDescendant() *dataNode {

return &dataNode{
parent: dn,
otel: dn.otel,
span: dn.span,
agents: agents,
}
}
Expand All @@ -80,13 +96,15 @@ func (dn *dataNode) spawnDescendant() *dataNode {
// ---------------------------------------------------------------------------

// addValues adds all entries in the map to the dataNode's values.
// automatically propagates values onto the current span.
func (dn *dataNode) addValues(m map[string]any) *dataNode {
if m == nil {
m = map[string]any{}
}

spawn := dn.spawnDescendant()
spawn.setValues(m)
spawn.addSpanAttributes(m)

return spawn
}
Expand Down Expand Up @@ -192,6 +210,37 @@ func (dn *dataNode) Slice() []any {
return s
}

// ---------------------------------------------------------------------------
// initialization
// ---------------------------------------------------------------------------

// init sets up persistent clients in the clues ecosystem such as otel.
// Initialization is NOT required. It is an optional step that end
// users can take if and when they want those clients running in their
// clues instance.
//
// Multiple initializations will no-op.
func (dn *dataNode) init(
ctx context.Context,
name string,
config OTELConfig,
) error {
if dn == nil {
return nil
}

// if any of these already exist, initialization was previously called.
if dn.otel != nil {
return nil
}

cli, err := newOTELClient(ctx, name, config)

dn.otel = cli

return Stack(err).OrNil()
}

// ---------------------------------------------------------------------------
// comments
// ---------------------------------------------------------------------------
Expand Down Expand Up @@ -350,6 +399,71 @@ func setNodeInCtx(ctx context.Context, dn *dataNode) context.Context {
return context.WithValue(ctx, defaultCtxKey, dn)
}

// ------------------------------------------------------------
// span handling
// ------------------------------------------------------------

// addSpan adds a new otel span. If the otel client is nil, no-ops.
// Attrs can be added to the span with addSpanAttrs. This span will
// continue to be used for that purpose until replaced with another
// span, which will appear in a separate context (and thus a separate,
// dataNode).
func (dn *dataNode) addSpan(
ctx context.Context,
name string,
) (context.Context, *dataNode) {
if dn == nil || dn.otel == nil {
return ctx, dn
}

ctx, span := dn.otel.tracer.Start(ctx, name)

spawn := dn.spawnDescendant()
spawn.span = span

return ctx, spawn
}

// closeSpan closes the otel span and removes it span from the data node.
// If no span is present, no ops.
func (dn *dataNode) closeSpan(ctx context.Context) *dataNode {
if dn == nil || dn.span == nil {
return dn
}

dn.span.End()

spawn := dn.spawnDescendant()
spawn.span = nil

return spawn
}

// addSpanAttributes adds the values to the current span. If the span
// is nil (such as if otel wasn't initialized or no span has been generated),
// this call no-ops.
func (dn *dataNode) addSpanAttributes(
values map[string]any,
) {
if dn == nil || dn.span == nil {
return
}

for k, v := range values {
dn.span.SetAttributes(attribute.String(k, stringify.Marshal(v, false)))
}
}

// OTELLogger gets the otel logger instance from the otel client.
// Returns nil if otel wasn't initialized.
func (dn *dataNode) OTELLogger() otellog.Logger {
if dn == nil || dn.otel == nil {
return nil
}

return dn.otel.logger
}

// ---------------------------------------------------------------------------
// helpers
// ---------------------------------------------------------------------------
Expand Down
Loading

0 comments on commit d786361

Please sign in to comment.