From d78636133732cb9a66a21b55a7ee7bb63f7b2690 Mon Sep 17 00:00:00 2001 From: Keepers Date: Tue, 5 Nov 2024 09:53:13 -0700 Subject: [PATCH] adds alpha support for otel (#57) 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. --- clues.go | 84 +++++++++++++++++------ clues_test.go | 83 +++++++++++----------- datanode.go | 114 ++++++++++++++++++++++++++++++ go.mod | 22 +++++- go.sum | 48 +++++++++++-- otel.go | 187 ++++++++++++++++++++++++++++++++++++++++++++++++++ 6 files changed, 467 insertions(+), 71 deletions(-) create mode 100644 otel.go diff --git a/clues.go b/clues.go index 2484ca6..d65d88c 100644 --- a/clues.go +++ b/clues.go @@ -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 // --------------------------------------------------------------------------- @@ -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 // --------------------------------------------------------------------------- diff --git a/clues_test.go b/clues_test.go index 7440141..0bf0b87 100644 --- a/clues_test.go +++ b/clues_test.go @@ -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 @@ -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"]) + } + }) + } } } diff --git a/datanode.go b/datanode.go index 21c0b12..94fc132 100644 --- a/datanode.go +++ b/datanode.go @@ -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" ) @@ -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. @@ -71,6 +85,8 @@ func (dn *dataNode) spawnDescendant() *dataNode { return &dataNode{ parent: dn, + otel: dn.otel, + span: dn.span, agents: agents, } } @@ -80,6 +96,7 @@ 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{} @@ -87,6 +104,7 @@ func (dn *dataNode) addValues(m map[string]any) *dataNode { spawn := dn.spawnDescendant() spawn.setValues(m) + spawn.addSpanAttributes(m) return spawn } @@ -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 // --------------------------------------------------------------------------- @@ -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 // --------------------------------------------------------------------------- diff --git a/go.mod b/go.mod index 8a492c4..1f45e2e 100644 --- a/go.mod +++ b/go.mod @@ -6,16 +6,32 @@ require ( github.com/google/uuid v1.6.0 github.com/pkg/errors v0.9.1 github.com/stretchr/testify v1.9.0 + go.opentelemetry.io/otel v1.31.0 + go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.31.0 + go.opentelemetry.io/otel/log v0.7.0 + go.opentelemetry.io/otel/sdk v1.31.0 + go.opentelemetry.io/otel/trace v1.31.0 go.uber.org/zap v1.27.0 golang.org/x/exp v0.0.0-20240416160154-fe59bbe5cc7f + google.golang.org/grpc v1.67.1 ) require ( + github.com/cenkalti/backoff/v4 v4.3.0 // indirect github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect - github.com/kr/pretty v0.3.1 // indirect + github.com/go-logr/logr v1.4.2 // indirect + github.com/go-logr/stdr v1.2.2 // indirect + github.com/grpc-ecosystem/grpc-gateway/v2 v2.22.0 // indirect github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect - github.com/rogpeppe/go-internal v1.13.1 // indirect + go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.31.0 // indirect + go.opentelemetry.io/otel/metric v1.31.0 // indirect + go.opentelemetry.io/proto/otlp v1.3.1 // indirect go.uber.org/multierr v1.11.0 // indirect - gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15 // indirect + golang.org/x/net v0.30.0 // indirect + golang.org/x/sys v0.26.0 // indirect + golang.org/x/text v0.19.0 // indirect + google.golang.org/genproto/googleapis/api v0.0.0-20241007155032-5fefd90f89a9 // indirect + google.golang.org/genproto/googleapis/rpc v0.0.0-20241007155032-5fefd90f89a9 // indirect + google.golang.org/protobuf v1.35.1 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect ) diff --git a/go.sum b/go.sum index 15dbb04..76d2394 100644 --- a/go.sum +++ b/go.sum @@ -1,22 +1,46 @@ -github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E= +github.com/cenkalti/backoff/v4 v4.3.0 h1:MyRJ/UdXutAwSAT+s3wNd7MfTIcy71VQueUuFK343L8= +github.com/cenkalti/backoff/v4 v4.3.0/go.mod h1:Y3VNntkOUPxTVeUxJ/G5vcM//AlwfmyYozVcomhLiZE= github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM= github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A= +github.com/go-logr/logr v1.4.2 h1:6pFjapn8bFcIbiKo3XT4j/BhANplGihG6tvd+8rYgrY= +github.com/go-logr/logr v1.4.2/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= +github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag= +github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE= +github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI= +github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/grpc-ecosystem/grpc-gateway/v2 v2.22.0 h1:asbCHRVmodnJTuQ3qamDwqVOIjwqUPTYmYuemVOx+Ys= +github.com/grpc-ecosystem/grpc-gateway/v2 v2.22.0/go.mod h1:ggCgvZ2r7uOoQjOyu2Y1NhHmEPPzzuhWgcza5M1Ji1I= github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= -github.com/pkg/diff v0.0.0-20210226163009-20ebb0f2a09e/go.mod h1:pJLUxLENpZxwdsKMEsNbx1VGcRFpLqf3715MtcvvzbA= github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U= github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= -github.com/rogpeppe/go-internal v1.9.0/go.mod h1:WtVeX8xhTBvf0smdhujwtBcq4Qrzq/fJaraNFVN+nFs= github.com/rogpeppe/go-internal v1.13.1 h1:KvO1DLK/DRN07sQ1LQKScxyZJuNnedQ5/wKSR38lUII= github.com/rogpeppe/go-internal v1.13.1/go.mod h1:uMEvuHeurkdAXX61udpOXGD/AzZDWNMNyH2VO9fmH0o= github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg= github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= +go.opentelemetry.io/otel v1.31.0 h1:NsJcKPIW0D0H3NgzPDHmo0WW6SptzPdqg/L1zsIm2hY= +go.opentelemetry.io/otel v1.31.0/go.mod h1:O0C14Yl9FgkjqcCZAsE053C13OaddMYr/hz6clDkEJE= +go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.31.0 h1:K0XaT3DwHAcV4nKLzcQvwAgSyisUghWoY20I7huthMk= +go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.31.0/go.mod h1:B5Ki776z/MBnVha1Nzwp5arlzBbE3+1jk+pGmaP5HME= +go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.31.0 h1:FFeLy03iVTXP6ffeN2iXrxfGsZGCjVx0/4KlizjyBwU= +go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.31.0/go.mod h1:TMu73/k1CP8nBUpDLc71Wj/Kf7ZS9FK5b53VapRsP9o= +go.opentelemetry.io/otel/log v0.7.0 h1:d1abJc0b1QQZADKvfe9JqqrfmPYQCz2tUSO+0XZmuV4= +go.opentelemetry.io/otel/log v0.7.0/go.mod h1:2jf2z7uVfnzDNknKTO9G+ahcOAyWcp1fJmk/wJjULRo= +go.opentelemetry.io/otel/metric v1.31.0 h1:FSErL0ATQAmYHUIzSezZibnyVlft1ybhy4ozRPcF2fE= +go.opentelemetry.io/otel/metric v1.31.0/go.mod h1:C3dEloVbLuYoX41KpmAhOqNriGbA+qqH6PQ5E5mUfnY= +go.opentelemetry.io/otel/sdk v1.31.0 h1:xLY3abVHYZ5HSfOg3l2E5LUj2Cwva5Y7yGxnSW9H5Gk= +go.opentelemetry.io/otel/sdk v1.31.0/go.mod h1:TfRbMdhvxIIr/B2N2LQW2S5v9m3gOQ/08KsbbO5BPT0= +go.opentelemetry.io/otel/trace v1.31.0 h1:ffjsj1aRouKewfr85U2aGagJ46+MvodynlQ1HYdmJys= +go.opentelemetry.io/otel/trace v1.31.0/go.mod h1:TXZkRk7SM2ZQLtR6eoAWQFIHPvzQ06FJAsO1tJg480A= +go.opentelemetry.io/proto/otlp v1.3.1 h1:TrMUixzpM0yuc/znrFTP9MMRh8trP93mkCiDVeXrui0= +go.opentelemetry.io/proto/otlp v1.3.1/go.mod h1:0X1WI4de4ZsLrrJNLAQbFeLCm3T7yBkR0XqQ7niQU+8= go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto= go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE= go.uber.org/multierr v1.11.0 h1:blXXJkSxSSfBVBlC76pxqeO+LN3aDfLQo+309xJstO0= @@ -25,8 +49,22 @@ go.uber.org/zap v1.27.0 h1:aJMhYGrd5QSmlpLMr2MftRKl7t8J8PTZPA732ud/XR8= go.uber.org/zap v1.27.0/go.mod h1:GB2qFLM7cTU87MWRP2mPIjqfIDnGu+VIO4V/SdhGo2E= golang.org/x/exp v0.0.0-20240416160154-fe59bbe5cc7f h1:99ci1mjWVBWwJiEKYY6jWa4d2nTQVIEhZIptnrVb1XY= golang.org/x/exp v0.0.0-20240416160154-fe59bbe5cc7f/go.mod h1:/lliqkxwWAhPjf5oSOIJup2XcqJaw8RGS6k3TGEc7GI= +golang.org/x/net v0.30.0 h1:AcW1SDZMkb8IpzCdQUaIq2sP4sZ4zw+55h6ynffypl4= +golang.org/x/net v0.30.0/go.mod h1:2wGyMJ5iFasEhkwi13ChkO/t1ECNC4X4eBKkVFyYFlU= +golang.org/x/sys v0.26.0 h1:KHjCJyddX0LoSTb3J+vWpupP9p0oznkqVk/IfjymZbo= +golang.org/x/sys v0.26.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +golang.org/x/text v0.19.0 h1:kTxAhCbGbxhK0IwgSKiMO5awPoDQ0RpfiVYBfK860YM= +golang.org/x/text v0.19.0/go.mod h1:BuEKDfySbSR4drPmRPG/7iBdf8hvFMuRexcpahXilzY= +google.golang.org/genproto/googleapis/api v0.0.0-20241007155032-5fefd90f89a9 h1:T6rh4haD3GVYsgEfWExoCZA2o2FmbNyKpTuAxbEFPTg= +google.golang.org/genproto/googleapis/api v0.0.0-20241007155032-5fefd90f89a9/go.mod h1:wp2WsuBYj6j8wUdo3ToZsdxxixbvQNAHqVJrTgi5E5M= +google.golang.org/genproto/googleapis/rpc v0.0.0-20241007155032-5fefd90f89a9 h1:QCqS/PdaHTSWGvupk2F/ehwHtGc0/GYkT+3GAcR1CCc= +google.golang.org/genproto/googleapis/rpc v0.0.0-20241007155032-5fefd90f89a9/go.mod h1:GX3210XPVPUjJbTUbvwI8f2IpZDMZuPJWDzDuebbviI= +google.golang.org/grpc v1.67.1 h1:zWnc1Vrcno+lHZCOofnIMvycFcc0QRGIzm9dhnDX68E= +google.golang.org/grpc v1.67.1/go.mod h1:1gLDyUQU7CTLJI90u3nXZ9ekeghjeM7pTDZlqFNg2AA= +google.golang.org/protobuf v1.35.1 h1:m3LfL6/Ca+fqnjnlqQXNpFPABW1UD7mjh8KO2mKFytA= +google.golang.org/protobuf v1.35.1/go.mod h1:9fA7Ob0pmnwhb644+1+CVWFRbNajQ6iRojtC/QF5bRE= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= -gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15 h1:YR8cESwS4TdDjEe65xsg0ogRM/Nc3DYOhEAlW+xobZo= -gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/otel.go b/otel.go new file mode 100644 index 0000000..d10dfc8 --- /dev/null +++ b/otel.go @@ -0,0 +1,187 @@ +package clues + +import ( + "context" + "fmt" + + "github.com/alcionai/clues/internal/stringify" + "go.opentelemetry.io/otel" + "go.opentelemetry.io/otel/attribute" + "go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc" + otellog "go.opentelemetry.io/otel/log" + "go.opentelemetry.io/otel/log/global" + "go.opentelemetry.io/otel/propagation" + "go.opentelemetry.io/otel/sdk/resource" + sdkTrace "go.opentelemetry.io/otel/sdk/trace" + semconv "go.opentelemetry.io/otel/semconv/v1.26.0" + "go.opentelemetry.io/otel/trace" + "google.golang.org/grpc" + "google.golang.org/grpc/credentials/insecure" +) + +type otelClient struct { + grpcConn *grpc.ClientConn + traceProvider *sdkTrace.TracerProvider + tracer trace.Tracer + logger otellog.Logger +} + +func (cli *otelClient) close(ctx context.Context) error { + if cli == nil { + return nil + } + + if cli.traceProvider != nil { + err := cli.traceProvider.ForceFlush(ctx) + if err != nil { + fmt.Println("forcing trace provider flush:", err) + } + + err = cli.traceProvider.Shutdown(ctx) + if err != nil { + return WrapWC(ctx, err, "shutting down otel trace provider") + } + } + + if cli.grpcConn != nil { + err := cli.grpcConn.Close() + if err != nil { + return WrapWC(ctx, err, "closing grpc connection") + } + } + + return nil +} + +// ------------------------------------------------------------ +// initializers +// ------------------------------------------------------------ + +type OTELConfig struct { + // specify the endpoint location to use for grpc communication. + // If empty, no telemetry exporter will be generated. + // ex: localhost:4317 + // ex: 0.0.0.0:4317 + GRPCEndpoint string +} + +// newOTELClient bootstraps the OpenTelemetry pipeline to run against a +// local server instance. If it does not return an error, make sure +// to call the client.Close() method for proper cleanup. +// The service name is used to match traces across backends. +func newOTELClient( + ctx context.Context, + serviceName string, + config OTELConfig, +) (*otelClient, error) { + // -- Resource + srvResource, err := resource.New(ctx, resource.WithAttributes( + semconv.ServiceNameKey.String(serviceName))) + if err != nil { + return nil, WrapWC(ctx, err, "creating otel resource") + } + + // -- Exporter + + conn, err := grpc.NewClient( + config.GRPCEndpoint, + // Note the use of insecure transport here. TLS is recommended in production. + grpc.WithTransportCredentials(insecure.NewCredentials())) + if err != nil { + return nil, WrapWC(ctx, err, "creating new grpc connection") + } + + exporter, err := otlptracegrpc.New(ctx, otlptracegrpc.WithGRPCConn(conn)) + if err != nil { + return nil, WrapWC(ctx, err, "creating a trace exporter") + } + + // -- TracerProvider + + // Register the trace exporter with a TracerProvider, using a batch + // span processor to aggregate spans before export. + batchSpanProcessor := sdkTrace.NewBatchSpanProcessor(exporter) + + tracerProvider := sdkTrace.NewTracerProvider( + sdkTrace.WithResource(srvResource), + sdkTrace.WithSampler(sdkTrace.AlwaysSample()), + sdkTrace.WithSpanProcessor(batchSpanProcessor), + sdkTrace.WithRawSpanLimits(sdkTrace.SpanLimits{ + AttributeValueLengthLimit: -1, + AttributeCountLimit: -1, + AttributePerEventCountLimit: -1, + AttributePerLinkCountLimit: -1, + EventCountLimit: -1, + LinkCountLimit: -1, + })) + + // set global propagator to traceContext (the default is no-op). + otel.SetTextMapPropagator(propagation.TraceContext{}) + otel.SetTracerProvider(tracerProvider) + + // -- Logger + + // generate a logger provider + logProvider := global.GetLoggerProvider() + + // -- Client + + client := otelClient{ + grpcConn: conn, + traceProvider: tracerProvider, + tracer: tracerProvider.Tracer(serviceName + "/tracer"), + logger: logProvider.Logger(serviceName), + } + + // Shutdown will flush any remaining spans and shut down the exporter. + return &client, nil +} + +// ------------------------------------------------------------ +// annotations. basically otel's version of With() +// Not currently used; we're just mashing everything in as a +// string right now, same as clues does. +// ------------------------------------------------------------ + +type annotation struct { + kind string + k string + v any +} + +func NewAttribute(k string, v any) annotation { + return annotation{ + kind: "attribute", + k: k, + v: v, + } +} + +func (a annotation) IsAttribute() bool { + return a.kind == "attribute" +} + +func (a annotation) KV() otellog.KeyValue { + if a.kind != "attribute" { + return otellog.KeyValue{} + } + + // FIXME: needs extensive type support + switch a.v.(type) { + case int: + return otellog.Int(a.k, a.v.(int)) + case int64: + return otellog.Int64(a.k, a.v.(int64)) + case string: + return otellog.String(a.k, a.v.(string)) + case bool: + return otellog.Bool(a.k, a.v.(bool)) + default: // everything else gets stringified + return otellog.String(a.k, stringify.Marshal(a.v, false)) + } +} + +type Annotationer interface { + IsAttribute() bool + KV() attribute.KeyValue +}