Skip to content

Commit db4a1ea

Browse files
committed
feat(otel): add option to continue from otel
1 parent dde4d36 commit db4a1ea

File tree

4 files changed

+170
-8
lines changed

4 files changed

+170
-8
lines changed

http/sentryhttp.go

+6-2
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ type Handler struct {
1717
repanic bool
1818
waitForDelivery bool
1919
timeout time.Duration
20+
spanOptions []sentry.SpanOption
2021
}
2122

2223
// Options configure a Handler.
@@ -44,6 +45,8 @@ type Options struct {
4445
// If the timeout is reached, the current goroutine is no longer blocked
4546
// waiting, but the delivery is not canceled.
4647
Timeout time.Duration
48+
// SpanOptions are options to be passed to the span created by the handler.
49+
SpanOptions []sentry.SpanOption
4750
}
4851

4952
// New returns a new Handler. Use the Handle and HandleFunc methods to wrap
@@ -57,6 +60,7 @@ func New(options Options) *Handler {
5760
repanic: options.Repanic,
5861
timeout: timeout,
5962
waitForDelivery: options.WaitForDelivery,
63+
spanOptions: options.SpanOptions,
6064
}
6165
}
6266

@@ -86,11 +90,11 @@ func (h *Handler) handle(handler http.Handler) http.HandlerFunc {
8690
hub = sentry.CurrentHub().Clone()
8791
ctx = sentry.SetHubOnContext(ctx, hub)
8892
}
89-
options := []sentry.SpanOption{
93+
options := append([]sentry.SpanOption{
9094
sentry.WithOpName("http.server"),
9195
sentry.ContinueFromRequest(r),
9296
sentry.WithTransactionSource(sentry.SourceURL),
93-
}
97+
}, h.spanOptions...)
9498
// We don't mind getting an existing transaction back so we don't need to
9599
// check if it is.
96100
transaction := sentry.StartTransaction(ctx,

otel/middleware_test.go

+125
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,125 @@
1+
package sentryotel
2+
3+
import (
4+
"context"
5+
"net/http"
6+
"net/http/httptest"
7+
"testing"
8+
9+
"github.com/getsentry/sentry-go"
10+
sentryhttp "github.com/getsentry/sentry-go/http"
11+
"go.opentelemetry.io/otel"
12+
otelSdkTrace "go.opentelemetry.io/otel/sdk/trace"
13+
"go.opentelemetry.io/otel/trace"
14+
)
15+
16+
func emptyContextWithSentryAndTracing(t *testing.T) (context.Context, <-chan *sentry.Event) {
17+
t.Helper()
18+
19+
events := make(chan *sentry.Event, 1)
20+
21+
client, err := sentry.NewClient(sentry.ClientOptions{
22+
Debug: true,
23+
Dsn: "https://[email protected]/123",
24+
Environment: "testing",
25+
Release: "1.2.3",
26+
EnableTracing: true,
27+
BeforeSendTransaction: func(event *sentry.Event, _ *sentry.EventHint) *sentry.Event {
28+
events <- event
29+
return event
30+
},
31+
})
32+
if err != nil {
33+
t.Fatalf("failed to create sentry client: %v", err)
34+
}
35+
36+
hub := sentry.NewHub(client, sentry.NewScope())
37+
return sentry.SetHubOnContext(context.Background(), hub), events
38+
}
39+
40+
func TestRespectOtelSampling(t *testing.T) {
41+
spanProcessor := NewSentrySpanProcessor()
42+
43+
simulateOtelAndSentry := func(ctx context.Context) (root, inner trace.Span) {
44+
handler := sentryhttp.New(sentryhttp.Options{
45+
SpanOptions: []sentry.SpanOption{ContinueFromOtel()},
46+
}).Handle(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
47+
_, inner = otel.Tracer("").Start(ctx, "test-inner-span")
48+
}))
49+
50+
tracer := otel.Tracer("")
51+
// simulate an otel middleware creating the root span before sentry
52+
ctx, root = tracer.Start(ctx, "test-root-span")
53+
54+
handler.ServeHTTP(
55+
httptest.NewRecorder(),
56+
httptest.NewRequest(http.MethodGet, "/", nil).WithContext(ctx),
57+
)
58+
59+
return root, inner
60+
}
61+
62+
t.Run("always sample", func(t *testing.T) {
63+
sentrySpanMap.Clear()
64+
65+
tp := otelSdkTrace.NewTracerProvider(
66+
otelSdkTrace.WithSpanProcessor(spanProcessor),
67+
otelSdkTrace.WithSampler(otelSdkTrace.AlwaysSample()),
68+
)
69+
otel.SetTracerProvider(tp)
70+
71+
ctx, events := emptyContextWithSentryAndTracing(t)
72+
73+
root, inner := simulateOtelAndSentry(ctx)
74+
75+
if root.SpanContext().TraceID() != inner.SpanContext().TraceID() {
76+
t.Errorf("otel root span and inner span should have the same trace id")
77+
}
78+
79+
if len(events) != 1 {
80+
t.Errorf("got unexpected number of events sent to sentry: %d != 1", len(events))
81+
}
82+
83+
for _, span := range []trace.Span{root, inner} {
84+
if !span.SpanContext().IsSampled() {
85+
t.Errorf("otel span should be sampled")
86+
}
87+
88+
spanID := span.SpanContext().SpanID()
89+
90+
sentrySpan, ok := sentrySpanMap.Get(spanID)
91+
if !ok {
92+
t.Fatalf("sentry span could not be found from otel span %d", spanID)
93+
}
94+
95+
if sentrySpan.Sampled != sentry.SampledTrue {
96+
t.Errorf("sentry span should be sampled, not %v", sentrySpan.Sampled)
97+
}
98+
}
99+
100+
})
101+
102+
t.Run("never sample", func(t *testing.T) {
103+
sentrySpanMap.Clear()
104+
105+
tp := otelSdkTrace.NewTracerProvider(
106+
otelSdkTrace.WithSpanProcessor(spanProcessor),
107+
otelSdkTrace.WithSampler(otelSdkTrace.NeverSample()),
108+
)
109+
otel.SetTracerProvider(tp)
110+
111+
ctx, events := emptyContextWithSentryAndTracing(t)
112+
113+
if len(events) != 0 {
114+
t.Fatalf("sentry span should not have been sent to sentry")
115+
}
116+
117+
root, inner := simulateOtelAndSentry(ctx)
118+
119+
for _, span := range []trace.Span{root, inner} {
120+
if span.SpanContext().IsSampled() {
121+
t.Errorf("otel span should not be sampled")
122+
}
123+
}
124+
})
125+
}

otel/mittleware.go

+28
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
package sentryotel
2+
3+
import (
4+
"github.com/getsentry/sentry-go"
5+
"go.opentelemetry.io/otel/trace"
6+
)
7+
8+
// ContinueFromOtel is a [sentry.SpanOption] that can be used with [sentryhttp.New] to ensure sentry uses any
9+
// existing OpenTelemetry trace in the context as the parent of the sentry span.
10+
//
11+
// NOTE: be sure to start the OpenTelemetry span before the sentry one by, e.g. keeping any OpenTelemetry middlewares
12+
// higher in the call chain.
13+
func ContinueFromOtel() sentry.SpanOption {
14+
return func(currentSpan *sentry.Span) {
15+
otelTrace := trace.SpanFromContext(currentSpan.Context())
16+
if otelTrace == nil {
17+
return
18+
}
19+
transaction, ok := sentrySpanMap.Get(otelTrace.SpanContext().SpanID())
20+
if !ok {
21+
return
22+
}
23+
currentSpan.ParentSpanID = transaction.SpanID
24+
// setting this directly because currentSpan.parent is not exported and this currently short-circuits
25+
// span.sample() in the right place.
26+
currentSpan.Sampled = transaction.Sampled
27+
}
28+
}

otel/span_processor.go

+11-6
Original file line numberDiff line numberDiff line change
@@ -46,11 +46,11 @@ func (ssp *sentrySpanProcessor) OnStart(parent context.Context, s otelSdkTrace.R
4646

4747
sentrySpanMap.Set(otelSpanID, span)
4848
} else {
49-
traceParentContext := getTraceParentContext(parent)
49+
sampled := getSampled(parent, s)
5050
transaction := sentry.StartTransaction(
5151
parent,
5252
s.Name(),
53-
sentry.WithSpanSampled(traceParentContext.Sampled),
53+
sentry.WithSpanSampled(sampled),
5454
)
5555
transaction.SpanID = sentry.SpanID(otelSpanID)
5656
transaction.TraceID = sentry.TraceID(otelTraceID)
@@ -108,12 +108,17 @@ func flushSpanProcessor(ctx context.Context) error {
108108
return nil
109109
}
110110

111-
func getTraceParentContext(ctx context.Context) sentry.TraceParentContext {
111+
func getSampled(ctx context.Context, s otelSdkTrace.ReadWriteSpan) sentry.Sampled {
112112
traceParentContext, ok := ctx.Value(sentryTraceParentContextKey{}).(sentry.TraceParentContext)
113-
if !ok {
114-
traceParentContext.Sampled = sentry.SampledUndefined
113+
if ok {
114+
return traceParentContext.Sampled
115115
}
116-
return traceParentContext
116+
117+
if s.SpanContext().IsSampled() {
118+
return sentry.SampledTrue
119+
}
120+
121+
return sentry.SampledFalse
117122
}
118123

119124
func updateTransactionWithOtelData(transaction *sentry.Span, s otelSdkTrace.ReadOnlySpan) {

0 commit comments

Comments
 (0)