Skip to content

Commit

Permalink
feat: Add initial OpenTelemetry support (#537)
Browse files Browse the repository at this point in the history
This PR adds two main integration components (along with all the main supporting functions to enrich Sentry spans with OTel data):
* Span Processor
* Propagator

Co-authored-by: Anton Ovchinnikov <[email protected]>
  • Loading branch information
cleptric and tonyo authored Jan 31, 2023
1 parent f39baef commit d362c36
Show file tree
Hide file tree
Showing 15 changed files with 1,634 additions and 0 deletions.
8 changes: 8 additions & 0 deletions otel/context.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
//go:build go1.18

package sentryotel

// Context keys to be used with context.WithValue(...) and ctx.Value(...)
type dynamicSamplingContextKey struct{}
type sentryTraceHeaderContextKey struct{}
type sentryTraceParentContextKey struct{}
38 changes: 38 additions & 0 deletions otel/event_processor.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
//go:build go1.18

package sentryotel

import (
"github.com/getsentry/sentry-go"
"go.opentelemetry.io/otel/trace"
)

// linkTraceContextToErrorEvent is a Sentry event processor that attaches trace information
// to the error event.
//
// Caveat: hint.Context should contain a valid context populated by OpenTelemetry's span context.
func linkTraceContextToErrorEvent(event *sentry.Event, hint *sentry.EventHint) *sentry.Event {
if hint == nil || hint.Context == nil {
return event
}
otelSpanContext := trace.SpanContextFromContext(hint.Context)
var sentrySpan *sentry.Span
if otelSpanContext.IsValid() {
sentrySpan, _ = sentrySpanMap.Get(otelSpanContext.SpanID())
}
if sentrySpan == nil {
return event
}

traceContext := event.Contexts["trace"]
if len(traceContext) > 0 {
// trace context is already set, not touching it
return event
}
event.Contexts["trace"] = map[string]interface{}{
"trace_id": sentrySpan.TraceID.String(),
"span_id": sentrySpan.SpanID.String(),
"parent_span_id": sentrySpan.ParentSpanID.String(),
}
return event
}
68 changes: 68 additions & 0 deletions otel/event_processor_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
//go:build go1.18

package sentryotel

import (
"errors"
"testing"

"github.com/getsentry/sentry-go"
)

func TestLinkTraceContextToErrorEventWithEmptyTraceContext(t *testing.T) {
_, _, tracer := setupSpanProcessorTest()
ctx, otelSpan := tracer.Start(emptyContextWithSentry(), "spanName")
sentrySpan, _ := sentrySpanMap.Get(otelSpan.SpanContext().SpanID())

hub := sentry.GetHubFromContext(ctx)
client := hub.Client()
client.CaptureException(
errors.New("new sentry exception"),
&sentry.EventHint{Context: ctx},
nil,
)

transport := client.Transport.(*TransportMock)
events := transport.Events()
assertEqual(t, len(events), 1)
err := events[0]
exception := err.Exception[0]
assertEqual(t, exception.Type, "*errors.errorString")
assertEqual(t, exception.Value, "new sentry exception")
assertEqual(t, err.Type, "")
assertEqual(t,
err.Contexts["trace"],
map[string]interface{}{
"trace_id": sentrySpan.TraceID.String(),
"span_id": sentrySpan.SpanID.String(),
"parent_span_id": sentrySpan.ParentSpanID.String(),
},
)
}

func TestLinkTraceContextToErrorEventDoesNotTouchExistingTraceContext(t *testing.T) {
_, _, tracer := setupSpanProcessorTest()
ctx, _ := tracer.Start(emptyContextWithSentry(), "spanName")

hub := sentry.GetHubFromContext(ctx)
hub.Scope().SetContext("trace", map[string]interface{}{"trace_id": "123"})
client := hub.Client()
client.CaptureException(
errors.New("new sentry exception with existing trace context"),
&sentry.EventHint{Context: ctx},
hub.Scope(),
)

transport := client.Transport.(*TransportMock)
events := transport.Events()
assertEqual(t, len(events), 1)
err := events[0]
exception := err.Exception[0]
assertEqual(t, exception.Type, "*errors.errorString")
assertEqual(t, exception.Value, "new sentry exception with existing trace context")
assertEqual(t, err.Type, "")
assertEqual(t,
err.Contexts["trace"],
map[string]interface{}{"trace_id": "123"},
)
}
20 changes: 20 additions & 0 deletions otel/go.mod
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
module github.com/getsentry/sentry-go/otel

go 1.19

require (
github.com/getsentry/sentry-go v0.17.0
github.com/google/go-cmp v0.5.9
go.opentelemetry.io/otel v1.11.2
go.opentelemetry.io/otel/sdk v1.11.2
go.opentelemetry.io/otel/trace v1.11.2
)

replace github.com/getsentry/sentry-go => ../

require (
github.com/go-logr/logr v1.2.3 // indirect
github.com/go-logr/stdr v1.2.2 // indirect
golang.org/x/sys v0.4.0 // indirect
golang.org/x/text v0.6.0 // indirect
)
24 changes: 24 additions & 0 deletions otel/go.sum
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/go-errors/errors v1.4.2 h1:J6MZopCL4uSllY1OfXM374weqZFFItUbrImctkmUxIA=
github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A=
github.com/go-logr/logr v1.2.3 h1:2DntVwHkVopvECVRSlL5PSo9eG+cAkDCuckLubN+rq0=
github.com/go-logr/logr v1.2.3/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A=
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.5.9 h1:O2Tfq5qg4qc4AmwVlvv0oLiVAGB7enBSJ2x2DqQFi38=
github.com/google/go-cmp v0.5.9/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
github.com/pingcap/errors v0.11.4 h1:lFuQV/oaUMGcD2tqt+01ROSmJs75VG1ToEOkZIZ4nE4=
github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/stretchr/testify v1.8.1 h1:w7B6lhMri9wdJUVmEZPGGhZzrYTPvgJArz7wNPgYKsk=
go.opentelemetry.io/otel v1.11.2 h1:YBZcQlsVekzFsFbjygXMOXSs6pialIZxcjfO/mBDmR0=
go.opentelemetry.io/otel v1.11.2/go.mod h1:7p4EUV+AqgdlNV9gL97IgUZiVR3yrFXYo53f9BM3tRI=
go.opentelemetry.io/otel/sdk v1.11.2 h1:GF4JoaEx7iihdMFu30sOyRx52HDHOkl9xQ8SMqNXUiU=
go.opentelemetry.io/otel/sdk v1.11.2/go.mod h1:wZ1WxImwpq+lVRo4vsmSOxdd+xwoUJ6rqyLc3SyX9aU=
go.opentelemetry.io/otel/trace v1.11.2 h1:Xf7hWSF2Glv0DE3MH7fBHvtpSBsjcBUe5MYAmZM/+y0=
go.opentelemetry.io/otel/trace v1.11.2/go.mod h1:4N+yC7QEz7TTsG9BSRLNAa63eg5E06ObSbKPmxQ/pKA=
golang.org/x/sys v0.4.0 h1:Zr2JFtRQNX3BCZ8YtxRE9hNJYC8J6I1MVbMg6owUp18=
golang.org/x/sys v0.4.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/text v0.6.0 h1:3XmdazWV+ubf7QgHSTWeykHOci5oeekaGJBLkrkaw4k=
golang.org/x/text v0.6.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
181 changes: 181 additions & 0 deletions otel/helpers_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,181 @@
//go:build go1.18

package sentryotel

// TODO(anton): This is a copy of helpers_test.go in the repo root.
// We should figure out how to share testing helpers.

import (
"encoding/hex"
"fmt"
"log"
"reflect"
"sort"
"sync"
"testing"
"time"

"github.com/getsentry/sentry-go"
"github.com/google/go-cmp/cmp"
"go.opentelemetry.io/otel/baggage"
"go.opentelemetry.io/otel/propagation"
"go.opentelemetry.io/otel/trace"
)

func assertEqual(t *testing.T, got, want interface{}, userMessage ...interface{}) {
t.Helper()

if !reflect.DeepEqual(got, want) {
logFailedAssertion(t, formatUnequalValues(got, want), userMessage...)
}
}

func assertNotEqual(t *testing.T, got, want interface{}, userMessage ...interface{}) {
t.Helper()

if reflect.DeepEqual(got, want) {
logFailedAssertion(t, formatUnequalValues(got, want), userMessage...)
}
}

func logFailedAssertion(t *testing.T, summary string, userMessage ...interface{}) {
t.Helper()
text := summary

if len(userMessage) > 0 {
if message, ok := userMessage[0].(string); ok {
if message != "" && len(userMessage) > 1 {
text = fmt.Sprintf(message, userMessage[1:]...) + text
} else if message != "" {
text = fmt.Sprint(message) + text
}
}
}

t.Error(text)
}

func formatUnequalValues(got, want interface{}) string {
var a, b string

if reflect.TypeOf(got) != reflect.TypeOf(want) {
a, b = fmt.Sprintf("%T(%#v)", got, got), fmt.Sprintf("%T(%#v)", want, want)
} else {
a, b = fmt.Sprintf("%#v", got), fmt.Sprintf("%#v", want)
}

return fmt.Sprintf("\ngot: %s\nwant: %s", a, b)
}

// assertMapCarrierEqual compares two values of type propagation.MapCarrier and raises an
// assertion error if the values differ.
//
// It is needed because some headers (e.g. "baggage") might contain the same set of values/attributes,
// (and therefore be semantically equal), but serialized in different order.
func assertMapCarrierEqual(t *testing.T, got, want propagation.MapCarrier, userMessage ...interface{}) {
// Make sure that keys are the same
gotKeysSorted := got.Keys()
sort.Strings(gotKeysSorted)
wantKeysSorted := want.Keys()
sort.Strings(wantKeysSorted)

if diff := cmp.Diff(wantKeysSorted, gotKeysSorted); diff != "" {
t.Errorf("Comparing MapCarrier keys (-want +got):\n%s", diff)
}

for _, key := range gotKeysSorted {
gotValue := got.Get(key)
wantValue := want.Get(key)

// Ignore serialization order for baggage values
if key == sentry.SentryBaggageHeader {
gotBaggage, gotErr := baggage.Parse(gotValue)
wantBaggage, wantErr := baggage.Parse(wantValue)

if diff := cmp.Diff(wantErr, gotErr); diff != "" {
t.Errorf("Comparing Baggage parsing errors (-want +got):\n%s", diff)
}

// sortedBaggage = gotBaggage.Members()

if diff := cmp.Diff(
wantBaggage,
gotBaggage,
cmp.AllowUnexported(baggage.Member{}, baggage.Baggage{}),
); diff != "" {
t.Errorf("Comparing Baggage values (-want +got):\n%s", diff)
}
continue
}

// Everything else: do the exact comparison
if diff := cmp.Diff(wantValue, gotValue); diff != "" {
t.Errorf("Comparing MapCarrier values (-want +got):\n%s", diff)
}
}
}

// FIXME: copied from tracing_test.go
func TraceIDFromHex(s string) sentry.TraceID {
var id sentry.TraceID
_, err := hex.Decode(id[:], []byte(s))
if err != nil {
panic(err)
}
return id
}

func SpanIDFromHex(s string) sentry.SpanID {
var id sentry.SpanID
_, err := hex.Decode(id[:], []byte(s))
if err != nil {
panic(err)
}
return id
}

func stringPtr(s string) *string {
return &s
}

func otelTraceIDFromHex(s string) trace.TraceID {
traceID, err := trace.TraceIDFromHex(s)
if err != nil {
log.Fatalf("Cannot make a TraceID from the hex string: '%s'", s)
}
return traceID
}

func otelSpanIDFromHex(s string) trace.SpanID {
spanID, err := trace.SpanIDFromHex(s)
if err != nil {
log.Fatalf("Cannot make a SPanID from the hex string: '%s'", s)
}
return spanID
}

// FIXME(anton): copie from mocks_test.go

type TransportMock struct {
mu sync.Mutex
events []*sentry.Event
lastEvent *sentry.Event
}

func (t *TransportMock) Configure(options sentry.ClientOptions) {}
func (t *TransportMock) SendEvent(event *sentry.Event) {
t.mu.Lock()
defer t.mu.Unlock()
t.events = append(t.events, event)
t.lastEvent = event
}
func (t *TransportMock) Flush(timeout time.Duration) bool {
return true
}
func (t *TransportMock) Events() []*sentry.Event {
t.mu.Lock()
defer t.mu.Unlock()
return t.events
}

//
9 changes: 9 additions & 0 deletions otel/internal/dummy/dummy.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
package dummy

// This package is intentionally left empty.
// Reason: the otel module currenty requires go>=1.18. All files in the module have '//go:build go1.18' guards, so
// with go1.17 "go test" might fail with the error: "go: warning: "./..." matched no packages; no packages to test".
// As a workaround, we added this empty "dummy" package, which is the only package without the compiler version restrictions,
// so at least the compiler doesn't complain that there are no packages to test.
//
// This file and package can be removed when we drop support for 1.17.
Loading

0 comments on commit d362c36

Please sign in to comment.