Skip to content

Commit 9b18d57

Browse files
committed
feat(otel): add option to continue from otel
1 parent 82a00ab commit 9b18d57

File tree

5 files changed

+177
-15
lines changed

5 files changed

+177
-15
lines changed

http/sentryhttp.go

+12-8
Original file line numberDiff line numberDiff line change
@@ -91,17 +91,21 @@ func (h *Handler) handle(handler http.Handler) http.HandlerFunc {
9191
sentry.ContinueFromRequest(r),
9292
sentry.WithTransactionSource(sentry.SourceURL),
9393
}
94-
// We don't mind getting an existing transaction back so we don't need to
95-
// check if it is.
96-
transaction := sentry.StartTransaction(ctx,
97-
fmt.Sprintf("%s %s", r.Method, r.URL.Path),
98-
options...,
99-
)
100-
defer transaction.Finish()
94+
// If a transaction was already started, whoever started is is responsible for finishing it.
95+
transaction := sentry.TransactionFromContext(ctx)
96+
if transaction == nil {
97+
transaction = sentry.StartTransaction(ctx,
98+
fmt.Sprintf("%s %s", r.Method, r.URL.Path),
99+
options...,
100+
)
101+
defer transaction.Finish()
102+
// We also avoid clobbering the request's context with an older version. If values were added after the
103+
// original transaction's creation, they would be lost by indiscriminately overwriting the context.
104+
r = r.WithContext(transaction.Context())
105+
}
101106
// TODO(tracing): if the next handler.ServeHTTP panics, store
102107
// information on the transaction accordingly (status, tag,
103108
// level?, ...).
104-
r = r.WithContext(transaction.Context())
105109
hub.Scope().SetRequest(r)
106110
defer h.recoverWithSentry(hub, r)
107111
// TODO(tracing): use custom response writer to intercept

otel/middleware_test.go

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

otel/mittleware.go

+23
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
package sentryotel
2+
3+
import (
4+
"net/http"
5+
6+
"github.com/getsentry/sentry-go"
7+
"go.opentelemetry.io/otel/trace"
8+
)
9+
10+
// ContinueFromOtel is a HTTP middleware that can be used with [sentryhttp.Handler] to ensure an existing otel span is
11+
// used as the sentry transaction.
12+
// It should be used whenever the otel tracing is started before the sentry middleware (e.g. to ensure otel sampling
13+
// gets respected across service boundaries)
14+
func ContinueFromOtel(next http.Handler) http.Handler {
15+
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
16+
if otelTrace := trace.SpanFromContext(r.Context()); otelTrace != nil && otelTrace.IsRecording() {
17+
if transaction, ok := sentrySpanMap.Get(otelTrace.SpanContext().SpanID()); ok {
18+
r = r.WithContext(sentry.SpanToContext(r.Context(), transaction))
19+
}
20+
}
21+
next.ServeHTTP(w, r)
22+
})
23+
}

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) {

tracing.go

+7-1
Original file line numberDiff line numberDiff line change
@@ -114,7 +114,7 @@ func StartSpan(ctx context.Context, operation string, options ...SpanOption) *Sp
114114
StartTime: time.Now(),
115115
Sampled: SampledUndefined,
116116

117-
ctx: context.WithValue(ctx, spanContextKey{}, &span),
117+
ctx: SpanToContext(ctx, &span),
118118
parent: parent,
119119
isTransaction: !hasParent,
120120
}
@@ -961,6 +961,12 @@ func SpanFromContext(ctx context.Context) *Span {
961961
return nil
962962
}
963963

964+
// SpanToContext stores a span in the provided context.
965+
// Usually this function does not need to be called directly; use [StartSpan] instead.
966+
func SpanToContext(ctx context.Context, span *Span) context.Context {
967+
return context.WithValue(ctx, spanContextKey{}, span)
968+
}
969+
964970
// StartTransaction will create a transaction (root span) if there's no existing
965971
// transaction in the context otherwise, it will return the existing transaction.
966972
func StartTransaction(ctx context.Context, name string, options ...SpanOption) *Span {

0 commit comments

Comments
 (0)