diff --git a/echo/sentryecho.go b/echo/sentryecho.go index 84f6adc35..e6fa88231 100644 --- a/echo/sentryecho.go +++ b/echo/sentryecho.go @@ -77,7 +77,7 @@ func (h *handler) handle(next echo.HandlerFunc) echo.HandlerFunc { } options := []sentry.SpanOption{ - sentry.ContinueTrace(hub, r.Header.Get(sentry.SentryTraceHeader), r.Header.Get(sentry.SentryBaggageHeader)), + sentry.ContinueTrace(sentry.SetHubOnContext(r.Context(), hub), r.Header.Get(sentry.SentryTraceHeader), r.Header.Get(sentry.SentryBaggageHeader)), sentry.WithOpName("http.server"), sentry.WithTransactionSource(transactionSource), sentry.WithSpanOrigin(sentry.SpanOriginEcho), diff --git a/fasthttp/sentryfasthttp.go b/fasthttp/sentryfasthttp.go index da84ad970..dcef1b2d0 100644 --- a/fasthttp/sentryfasthttp.go +++ b/fasthttp/sentryfasthttp.go @@ -68,15 +68,16 @@ func (h *Handler) Handle(handler fasthttp.RequestHandler) fasthttp.RequestHandle r := convert(ctx) + hubCtx := sentry.SetHubOnContext(ctx, hub) options := []sentry.SpanOption{ - sentry.ContinueTrace(hub, r.Header.Get(sentry.SentryTraceHeader), r.Header.Get(sentry.SentryBaggageHeader)), + sentry.ContinueTrace(hubCtx, r.Header.Get(sentry.SentryTraceHeader), r.Header.Get(sentry.SentryBaggageHeader)), sentry.WithOpName("http.server"), sentry.WithTransactionSource(sentry.SourceURL), sentry.WithSpanOrigin(sentry.SpanOriginFastHTTP), } transaction := sentry.StartTransaction( - sentry.SetHubOnContext(ctx, hub), + hubCtx, fmt.Sprintf("%s %s", r.Method, string(ctx.Path())), options..., ) diff --git a/fiber/sentryfiber.go b/fiber/sentryfiber.go index fbce623e0..5373c75db 100644 --- a/fiber/sentryfiber.go +++ b/fiber/sentryfiber.go @@ -70,20 +70,21 @@ func (h *handler) handle(ctx *fiber.Ctx) error { transactionName := ctx.Path() transactionSource := sentry.SourceURL + savedCtx := ctx.UserContext() + requestCtx, cancel := context.WithCancel(savedCtx) + defer cancel() + defer func() { ctx.SetUserContext(savedCtx) }() + + hubCtx := sentry.SetHubOnContext(requestCtx, hub) options := []sentry.SpanOption{ - sentry.ContinueTrace(hub, r.Header.Get(sentry.SentryTraceHeader), r.Header.Get(sentry.SentryBaggageHeader)), + sentry.ContinueTrace(hubCtx, r.Header.Get(sentry.SentryTraceHeader), r.Header.Get(sentry.SentryBaggageHeader)), sentry.WithOpName("http.server"), sentry.WithTransactionSource(transactionSource), sentry.WithSpanOrigin(sentry.SpanOriginFiber), } - savedCtx := ctx.UserContext() - requestCtx, cancel := context.WithCancel(savedCtx) - defer cancel() - defer func() { ctx.SetUserContext(savedCtx) }() - transaction := sentry.StartTransaction( - sentry.SetHubOnContext(requestCtx, hub), + hubCtx, fmt.Sprintf("%s %s", r.Method, transactionName), options..., ) diff --git a/fiberv3/sentryfiber.go b/fiberv3/sentryfiber.go index 944eea37a..9c3ae451c 100644 --- a/fiberv3/sentryfiber.go +++ b/fiberv3/sentryfiber.go @@ -70,20 +70,21 @@ func (h *handler) handle(ctx fiber.Ctx) error { transactionName := ctx.Path() transactionSource := sentry.SourceURL + savedCtx := ctx.Context() + requestCtx, cancel := context.WithCancel(savedCtx) + defer cancel() + defer ctx.SetContext(savedCtx) + + hubCtx := sentry.SetHubOnContext(requestCtx, hub) options := []sentry.SpanOption{ - sentry.ContinueTrace(hub, r.Header.Get(sentry.SentryTraceHeader), r.Header.Get(sentry.SentryBaggageHeader)), + sentry.ContinueTrace(hubCtx, r.Header.Get(sentry.SentryTraceHeader), r.Header.Get(sentry.SentryBaggageHeader)), sentry.WithOpName("http.server"), sentry.WithTransactionSource(transactionSource), sentry.WithSpanOrigin(sentry.SpanOriginFiber), } - savedCtx := ctx.Context() - requestCtx, cancel := context.WithCancel(savedCtx) - defer cancel() - defer ctx.SetContext(savedCtx) - transaction := sentry.StartTransaction( - sentry.SetHubOnContext(requestCtx, hub), + hubCtx, fmt.Sprintf("%s %s", r.Method, transactionName), options..., ) diff --git a/gin/sentrygin.go b/gin/sentrygin.go index 3b8d88cec..2587d62b8 100644 --- a/gin/sentrygin.go +++ b/gin/sentrygin.go @@ -74,7 +74,7 @@ func (h *handler) handle(c *gin.Context) { } options := []sentry.SpanOption{ - sentry.ContinueTrace(hub, c.GetHeader(sentry.SentryTraceHeader), c.GetHeader(sentry.SentryBaggageHeader)), + sentry.ContinueTrace(sentry.SetHubOnContext(ctx, hub), c.GetHeader(sentry.SentryTraceHeader), c.GetHeader(sentry.SentryBaggageHeader)), sentry.WithOpName("http.server"), sentry.WithTransactionSource(transactionSource), sentry.WithSpanOrigin(sentry.SpanOriginGin), diff --git a/grpc/server.go b/grpc/server.go index 862c385b0..7edbda980 100644 --- a/grpc/server.go +++ b/grpc/server.go @@ -82,7 +82,7 @@ func startServerTransaction(ctx context.Context, fullMethod string) (context.Con transaction := sentry.StartTransaction( sentry.SetHubOnContext(ctx, hub), name, - sentry.ContinueTrace(hub, sentryTraceHeader, sentryBaggageHeader), + sentry.ContinueTrace(sentry.SetHubOnContext(ctx, hub), sentryTraceHeader, sentryBaggageHeader), sentry.WithOpName(defaultServerOperationName), sentry.WithDescription(name), sentry.WithTransactionSource(sentry.SourceRoute), diff --git a/http/sentryhttp.go b/http/sentryhttp.go index cc510a5e5..15ef66c0a 100644 --- a/http/sentryhttp.go +++ b/http/sentryhttp.go @@ -94,7 +94,7 @@ func (h *Handler) handle(handler http.Handler) http.HandlerFunc { hub.Client().SetSDKIdentifier(sdkIdentifier) options := []sentry.SpanOption{ - sentry.ContinueTrace(hub, r.Header.Get(sentry.SentryTraceHeader), r.Header.Get(sentry.SentryBaggageHeader)), + sentry.ContinueTrace(ctx, r.Header.Get(sentry.SentryTraceHeader), r.Header.Get(sentry.SentryBaggageHeader)), sentry.WithOpName("http.server"), sentry.WithTransactionSource(sentry.SourceURL), sentry.WithSpanOrigin(sentry.SpanOriginStdLib), diff --git a/httpclient/sentryhttpclient.go b/httpclient/sentryhttpclient.go index 041a1f5da..6cb6b4e7b 100644 --- a/httpclient/sentryhttpclient.go +++ b/httpclient/sentryhttpclient.go @@ -103,12 +103,21 @@ func (s *SentryRoundTripper) RoundTrip(request *http.Request) (*http.Response, e // Only create the `http.client` span only if there is a parent span. parentSpan := sentry.SpanFromContext(request.Context()) if parentSpan == nil { - if hub := sentry.GetHubFromContext(request.Context()); hub != nil { - request = request.Clone(request.Context()) - request.Header.Add(sentry.SentryBaggageHeader, hub.GetBaggage()) - request.Header.Add(sentry.SentryTraceHeader, hub.GetTraceparent()) + ctx := request.Context() + baggage := sentry.GetBaggage(ctx) + traceparent := sentry.GetTraceparent(ctx) + if baggage != "" || traceparent != "" { + request = request.Clone(ctx) + if baggage != "" { + request.Header.Add(sentry.SentryBaggageHeader, baggage) + } + if traceparent != "" { + request.Header.Add(sentry.SentryTraceHeader, traceparent) + } if s.propagateTraceparent { - request.Header.Add(sentry.TraceparentHeader, hub.GetTraceparentW3C()) + if w3c := sentry.GetTraceparentW3C(ctx); w3c != "" { + request.Header.Add(sentry.TraceparentHeader, w3c) + } } } diff --git a/iris/sentryiris.go b/iris/sentryiris.go index ade474208..f121f6619 100644 --- a/iris/sentryiris.go +++ b/iris/sentryiris.go @@ -65,7 +65,7 @@ func (h *handler) handle(ctx iris.Context) { r := ctx.Request() options := []sentry.SpanOption{ - sentry.ContinueTrace(hub, r.Header.Get(sentry.SentryTraceHeader), r.Header.Get(sentry.SentryBaggageHeader)), + sentry.ContinueTrace(sentry.SetHubOnContext(ctx, hub), r.Header.Get(sentry.SentryTraceHeader), r.Header.Get(sentry.SentryBaggageHeader)), sentry.WithOpName("http.server"), sentry.WithTransactionSource(sentry.SourceRoute), sentry.WithSpanOrigin(sentry.SpanOriginIris), diff --git a/negroni/sentrynegroni.go b/negroni/sentrynegroni.go index 2599379bb..bd4d36f67 100644 --- a/negroni/sentrynegroni.go +++ b/negroni/sentrynegroni.go @@ -55,7 +55,7 @@ func (h *handler) ServeHTTP(w http.ResponseWriter, r *http.Request, next http.Ha hub.Client().SetSDKIdentifier(sdkIdentifier) options := []sentry.SpanOption{ - sentry.ContinueTrace(hub, r.Header.Get(sentry.SentryTraceHeader), r.Header.Get(sentry.SentryBaggageHeader)), + sentry.ContinueTrace(sentry.SetHubOnContext(r.Context(), hub), r.Header.Get(sentry.SentryTraceHeader), r.Header.Get(sentry.SentryBaggageHeader)), sentry.WithOpName("http.server"), sentry.WithTransactionSource(sentry.SourceURL), sentry.WithSpanOrigin(sentry.SpanOriginNegroni), diff --git a/scope.go b/scope.go index 13da9e371..571288d29 100644 --- a/scope.go +++ b/scope.go @@ -434,14 +434,15 @@ func (snapshot *scopeSnapshot) mergeLocked(scope *Scope) { } snapshot.propagationContext = scope.propagationContext - if scope.span != nil { - snapshot.span = scope.span - } } // ApplyToEvent takes the data from the current scope and attaches it to the event. func (scope *Scope) ApplyToEvent(event *Event, hint *EventHint, client Client) *Event { - return snapshotScopes(client, scope).applyToEvent(event, hint, client) + snapshot := snapshotScopes(client, scope) + // Preserve Scope.ApplyToEvent and legacy Hub behavior while context capture + // deliberately excludes the legacy scope span from merged snapshots. + snapshot.span = scope.GetSpan() + return snapshot.applyToEvent(event, hint, client) } // ApplyToEvent implements [EventModifier] for a pre-built snapshot. @@ -485,11 +486,30 @@ func (snapshot scopeSnapshot) applyToEvent(event *Event, hint *EventHint, client event.Contexts = make(map[string]Context) } - if snapshot.span != nil { + var ctx context.Context + if hint != nil { + ctx = hint.Context + } + if ctx == nil && snapshot.request != nil { + ctx = snapshot.request.Context() + } + + // Context is the source of truth for active spans. snapshot.span is retained + // only for legacy Hub captures, whose capture options build a Hub-only + // snapshot instead of the global/isolation snapshot used by context APIs. + var activeSpan *Span + if ctx != nil { + activeSpan = SpanFromContext(ctx) + } + if activeSpan == nil { + activeSpan = snapshot.span + } + + if activeSpan != nil { if _, ok := event.Contexts["trace"]; !ok { - event.Contexts["trace"] = snapshot.span.traceContext().Map() + event.Contexts["trace"] = activeSpan.traceContext().Map() } - if transaction := snapshot.span.GetTransaction(); transaction != nil { + if transaction := activeSpan.GetTransaction(); transaction != nil { event.sdkMetaData.dsc = DynamicSamplingContextFromTransaction(transaction) } } else { @@ -503,17 +523,12 @@ func (snapshot scopeSnapshot) applyToEvent(event *Event, hint *EventHint, client event.sdkMetaData.dsc = dsc } - var ctx context.Context - if hint != nil { - ctx = hint.Context - } - if ctx == nil && snapshot.request != nil { - ctx = snapshot.request.Context() - } - if traceID, spanID, ok := client.externalTraceContextFromContext(ctx); event.Type != transactionType && ok { - traceCtx := event.Contexts["trace"] - traceCtx["trace_id"] = traceID.String() - traceCtx["span_id"] = spanID.String() + if event.Type != transactionType && ctx != nil { + if tid, sid, ok := client.externalTraceContextFromContext(ctx); ok { + traceCtx := event.Contexts["trace"] + traceCtx["trace_id"] = tid.String() + traceCtx["span_id"] = sid.String() + } } if event.User.IsEmpty() { @@ -618,46 +633,36 @@ func hubFromContexts(ctxs ...context.Context) *Hub { // resolveTrace resolves trace ID and span ID from the given scope and contexts. // // The resolution order follows a most-specific-to-least-specific pattern: -// 1. If an external trace resolver was registered (eg. OTel), we prioritise trace context -// information from that -// 2. Check for span directly in contexts (SpanFromContext) - this is the most specific -// source as it represents a span explicitly attached to the current operation's context -// 3. Check scope's span - provides access to span set on the hub's scope -// 4. Fall back to scope's propagation context trace ID -// -// This ordering ensures we always use the most contextually relevant tracing information. -// For example, if a specific span is active for an operation, we use that span's trace/span IDs -// rather than accidentally using a different span that might be set on the hub's scope. +// 1. external trace resolver (eg. OTel), when configured +// 2. active Sentry span from context (SpanFromContext) +// 3. isolation/propagation context carried by the scope // -// TODO: this should be removed when the span API is introduced. Currently kept for compatibility. The span -// and trace should only be resolved through context. +// Active spans are not read from Scope. func resolveTrace(scope *Scope, client Client, ctxs ...context.Context) (traceID TraceID, spanID SpanID) { client = normalizeClient(client) - var span *Span + // Check every context for an external resolver result before considering + // Sentry spans from any context. for _, ctx := range ctxs { if ctx == nil { continue } - if traceID, spanID, ok := client.externalTraceContextFromContext(ctx); ok { - return traceID, spanID + if tid, sid, ok := client.externalTraceContextFromContext(ctx); ok { + return tid, sid } - if span = SpanFromContext(ctx); span != nil { - break + } + for _, ctx := range ctxs { + if ctx == nil { + continue + } + if span := SpanFromContext(ctx); span != nil { + return span.TraceID, span.SpanID } } if scope != nil { scope.mu.RLock() - if span == nil { - span = scope.span - } - if span != nil { - traceID = span.TraceID - spanID = span.SpanID - } else { - traceID = scope.propagationContext.TraceID - } + traceID = scope.propagationContext.TraceID scope.mu.RUnlock() } diff --git a/tracing.go b/tracing.go index c11d1bf9a..4ea39469c 100644 --- a/tracing.go +++ b/tracing.go @@ -204,12 +204,6 @@ func StartSpan(ctx context.Context, operation string, options ...SpanOption) *Sp span.recorder.record(&span) - clientOptions := span.clientOptions() - if clientOptions.EnableTracing { - hub := hubFromContext(ctx) - hub.Scope().SetSpan(&span) - } - return &span } @@ -468,24 +462,17 @@ func (s *Span) doFinish() { s.EndTime = monotonicTimeSince(s.StartTime) } - hub := hubFromContext(s.ctx) - if !s.IsTransaction() { - if s.parent != nil { - hub.Scope().SetSpan(s.parent) - } - } - if s.shouldIgnoreStatusCode() { return } + client := spanClient(s.ctx) if !s.Sampled.Bool() { if !s.IsTransaction() { // we count the sampled spans from the transaction root. it is guaranteed that the whole transaction // would be sampled return } - client := hub.Client() children := s.recorder.children() client.recordDiscard(report.ReasonSampleRate, ratelimit.CategoryTransaction, 1) client.recordDiscard(report.ReasonSampleRate, ratelimit.CategorySpan, int64(len(children)+1)) @@ -499,7 +486,14 @@ func (s *Span) doFinish() { // TODO(tracing): add breadcrumbs // (see https://github.com/getsentry/sentry-python/blob/f6f3525f8812f609/sentry_sdk/tracing.py#L372) - hub.CaptureEvent(event) + // Prefer Hub capture while integrations still bind request state via Hub. + // Always pass the span context so active-span resolution uses context only. + hint := &EventHint{Context: s.ctx} + if hub := GetHubFromContext(s.ctx); hub != nil { + hub.CaptureEventWithHint(event, hint) + return + } + client.CaptureEvent(s.ctx, event, WithEventHint(hint)) } // sentryTracePattern matches either @@ -548,7 +542,17 @@ func (s *Span) updateFromBaggage(header []byte) { } func (s *Span) clientOptions() *ClientOptions { - return hubFromContext(s.ctx).Client().clientOptions() + return spanClient(s.ctx).clientOptions() +} + +// spanClient resolves the client for span sampling and finish. Prefer a Hub +// client when present so legacy Hub-bound integrations keep working until the +// isolation migration lands; otherwise use context/global scope binding. +func spanClient(ctx context.Context) Client { + if hub := GetHubFromContext(ctx); hub != nil { + return hub.Client() + } + return GetClient(ctx) } func (s *Span) sample() Sampled { @@ -1039,11 +1043,11 @@ func WithSpanOrigin(origin SpanOrigin) SpanOption { // ContinueTrace continues a trace based on traceparent and baggage values. // If the SDK is configured with tracing enabled, // this function returns populated SpanOption. -// In any other cases, it populates the propagation context on the scope. -func ContinueTrace(hub *Hub, traceparent, baggage string) SpanOption { - scope := hub.Scope() +// In any other cases, it populates the propagation context on the isolation +// scope carried by ctx (or on a Hub scope when only a Hub is bound). +func ContinueTrace(ctx context.Context, traceparent, baggage string) SpanOption { propagationContext, _ := PropagationContextFromHeaders(traceparent, baggage) - client := hub.Client() + client := spanClient(ctx) if !shouldContinueTrace(client, propagationContext.DynamicSamplingContext) { propagationContext = NewPropagationContext() @@ -1051,10 +1055,62 @@ func ContinueTrace(hub *Hub, traceparent, baggage string) SpanOption { baggage = "" } - scope.SetPropagationContext(propagationContext) + if scope := scopeFromContext(ctx); scope != nil { + scope.SetPropagationContext(propagationContext) + } else if hub := GetHubFromContext(ctx); hub != nil { + if scope := hub.Scope(); scope != nil { + scope.SetPropagationContext(propagationContext) + } + } return ContinueFromHeaders(traceparent, baggage) } +// GetTraceparent returns the current Sentry traceparent string from ctx, to be +// used as an HTTP header value or HTML meta tag value. An active span on ctx +// takes precedence over isolation propagation context. +func GetTraceparent(ctx context.Context) string { + if span := SpanFromContext(ctx); span != nil { + return span.ToSentryTrace() + } + if scope := scopeFromContext(ctx); scope != nil { + propagationContext := scope.propagationContextSnapshot() + return fmt.Sprintf("%s-%s", propagationContext.TraceID, propagationContext.SpanID) + } + if hub := GetHubFromContext(ctx); hub != nil { + return hub.GetTraceparent() + } + return "" +} + +// GetTraceparentW3C returns the current W3C traceparent string from ctx. +func GetTraceparentW3C(ctx context.Context) string { + if span := SpanFromContext(ctx); span != nil { + return span.ToTraceparent() + } + if scope := scopeFromContext(ctx); scope != nil { + propagationContext := scope.propagationContextSnapshot() + return fmt.Sprintf("00-%s-%s-00", propagationContext.TraceID, propagationContext.SpanID) + } + if hub := GetHubFromContext(ctx); hub != nil { + return hub.GetTraceparentW3C() + } + return "" +} + +// GetBaggage returns the current Sentry baggage string from ctx. +func GetBaggage(ctx context.Context) string { + if span := SpanFromContext(ctx); span != nil { + return span.ToBaggage() + } + if scope := scopeFromContext(ctx); scope != nil { + return scope.propagationContextSnapshot().DynamicSamplingContext.String() + } + if hub := GetHubFromContext(ctx); hub != nil { + return hub.GetBaggage() + } + return "" +} + // ContinueFromRequest returns a span option that updates the span to continue // an existing trace. If it cannot detect an existing trace in the request, the // span will be left unchanged. @@ -1083,7 +1139,7 @@ func ContinueFromHeaders(trace, baggage string) SpanOption { } } - client := hubFromContext(s.ctx).Client() + client := spanClient(s.ctx) if !shouldContinueTrace(client, dsc) { return // leave span unchanged → behaves as head of trace } @@ -1111,7 +1167,7 @@ func ContinueFromTrace(trace string) SpanOption { if trace == "" { return } - client := hubFromContext(s.ctx).Client() + client := spanClient(s.ctx) if !shouldContinueTrace(client, DynamicSamplingContext{}) { return } diff --git a/tracing_test.go b/tracing_test.go index 5f08e6f8d..de4234880 100644 --- a/tracing_test.go +++ b/tracing_test.go @@ -905,9 +905,8 @@ func TestSampleRatePropagation(t *testing.T) { Transport: transport, }) - hub := GetHubFromContext(ctx) options := []SpanOption{ - ContinueTrace(hub, tt.traceHeader, tt.baggageHeader), + ContinueTrace(ctx, tt.traceHeader, tt.baggageHeader), } transaction := StartTransaction(ctx, "test-transaction", options...) transaction.Finish() @@ -981,8 +980,7 @@ func TestTracesSamplerReceivesRemoteParent(t *testing.T) { }, }) - hub := GetHubFromContext(ctx) - txn := StartTransaction(ctx, "test-txn", ContinueTrace(hub, tt.traceHeader, tt.baggageHeader)) + txn := StartTransaction(ctx, "test-txn", ContinueTrace(ctx, tt.traceHeader, tt.baggageHeader)) txn.Finish() assert.Nil(t, gotCtx.Parent, "SamplingContext.Parent should be nil for remote parent") @@ -1291,21 +1289,22 @@ func TestSpanFinishConcurrentlyWithoutRaces(_ *testing.T) { } func TestSpanScopeManagement(t *testing.T) { - // Initialize a test hub and client + // Active span ownership is context-based: finishing a nested span must not + // change which span is active on a parent context held by the caller. transport := &MockTransport{} client, err := newClient(ClientOptions{ EnableTracing: true, TracesSampleRate: 1.0, Transport: transport, + Dsn: "https://key@example.com/1", }) if err != nil { t.Fatal(err) } - hub := NewHub(client, NewScope()) - // Set the hub on the context - ctx := context.Background() - ctx = SetHubOnContext(ctx, hub) + ctx := WithIsolation(context.Background()) + ctx, scope := ScopeFromContext(ctx) + scope.SetClient(client) // Start a parent span (transaction) transaction := StartTransaction(ctx, "parent-operation") @@ -1313,25 +1312,29 @@ func TestSpanScopeManagement(t *testing.T) { // Start a child span childSpan := StartSpan(transaction.Context(), "child-operation") - // Finish the child span - defer childSpan.Finish() subChildSpan := StartSpan(childSpan.Context(), "sub_child-operation") subChildSpan.Finish() - // Capture an event after finishing the child span - // This event should be associated with the first child span - hub.CaptureMessage("Test event") + // After finishing the nested span, the child context remains active. + // Capture through the child context, not ambient Hub scope state. + _ = CaptureMessage(childSpan.Context(), "Test event") // Flush to ensure the event is sent transport.Flush(time.Second) // Verify that the event has the correct trace data events := transport.Events() - if len(events) != 1 { - t.Fatalf("expected 2 event, got %d", len(events)) + var event *Event + for _, e := range events { + if e.Message == "Test event" { + event = e + break + } + } + if event == nil { + t.Fatalf("expected message event, got %d events", len(events)) } - event := events[0] // Extract the trace context from the event traceCtx, ok := event.Contexts["trace"] @@ -1342,19 +1345,24 @@ func TestSpanScopeManagement(t *testing.T) { // Extract TraceID and SpanID from the trace context traceID, ok := traceCtx["trace_id"].(TraceID) if !ok { - t.Fatalf("trace_id not found") + t.Fatalf("trace_id not found: %#v", traceCtx["trace_id"]) } spanID, ok := traceCtx["span_id"].(SpanID) if !ok { - t.Fatalf("span_id not found") + t.Fatalf("span_id not found: %#v", traceCtx["span_id"]) } // Verify that the IDs match the first child span IDs if traceID != childSpan.TraceID { - t.Errorf("expected TraceID %s, got %s", transaction.TraceID, traceID) + t.Errorf("expected TraceID %s, got %s", childSpan.TraceID, traceID) } if spanID != childSpan.SpanID { - t.Errorf("expected SpanID %s, got %s", transaction.SpanID, spanID) + t.Errorf("expected SpanID %s, got %s", childSpan.SpanID, spanID) + } + + // Parent contextactivity is unaffected by nested finish. + if SpanFromContext(transaction.Context()) != transaction { + t.Errorf("parent context lost active transaction after nested finish") } } @@ -1404,9 +1412,8 @@ func TestStrictTraceContinuation(t *testing.T) { baggage = baggageWithOrg(tt.baggageOrgID) } - hub := GetHubFromContext(ctx) transaction := StartTransaction(ctx, "test", - ContinueTrace(hub, sentryTrace, baggage), + ContinueTrace(ctx, sentryTrace, baggage), ) transaction.Finish() @@ -1422,3 +1429,234 @@ func TestStrictTraceContinuation(t *testing.T) { }) } } + +func TestContextIsActiveSpanSourceOfTruth(t *testing.T) { + transport := &MockTransport{} + client, err := newClient(ClientOptions{ + EnableTracing: true, + TracesSampleRate: 1.0, + Transport: transport, + Dsn: "https://key@example.com/1", + }) + if err != nil { + t.Fatal(err) + } + + base := WithIsolation(context.Background()) + base, isolation := ScopeFromContext(base) + isolation.SetClient(client) + + parent := StartSpan(base, "parent") + // Starting a span must not write the active span onto Scope. + assert.Nil(t, isolation.GetSpan()) + assert.Equal(t, parent, SpanFromContext(parent.Context())) + assert.Nil(t, SpanFromContext(base)) + + child := StartSpan(parent.Context(), "child") + assert.Nil(t, isolation.GetSpan()) + assert.Equal(t, child, SpanFromContext(child.Context())) + assert.Equal(t, parent, SpanFromContext(parent.Context())) + + // Scope mutations under the child remain visible at the isolation boundary. + _, childScope := ScopeFromContext(child.Context()) + childScope.SetTag("from", "child") + assert.Same(t, isolation, childScope) + + // Capture with child context links to the child span. + before := len(transport.Events()) + _ = CaptureMessage(child.Context(), "child-msg") + events := transport.Events() + if assert.Greater(t, len(events), before) { + ev := events[len(events)-1] + assert.Equal(t, "child", ev.Tags["from"]) + trace := ev.Contexts["trace"] + assert.Equal(t, child.TraceID, trace["trace_id"]) + assert.Equal(t, child.SpanID, trace["span_id"]) + } + + // Ending the child must not mutate parent activity on context or Scope. + child.Finish() + assert.Equal(t, parent, SpanFromContext(parent.Context())) + assert.Nil(t, isolation.GetSpan()) + + // Capture with parent context still links to the parent. + before = len(transport.Events()) + _ = CaptureMessage(parent.Context(), "parent-msg") + events = transport.Events() + if assert.Greater(t, len(events), before) { + ev := events[len(events)-1] + assert.Equal(t, "parent-msg", ev.Message) + trace := ev.Contexts["trace"] + assert.Equal(t, parent.TraceID, trace["trace_id"]) + assert.Equal(t, parent.SpanID, trace["span_id"]) + } +} + +func TestTraceResolutionPrecedence(t *testing.T) { + transport := &MockTransport{} + client, err := newClient(ClientOptions{ + EnableTracing: true, + TracesSampleRate: 1.0, + Transport: transport, + Dsn: "https://key@example.com/1", + }) + if err != nil { + t.Fatal(err) + } + + propTrace := TraceIDFromHex("aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa") + propSpan := SpanIDFromHex("bbbbbbbbbbbbbbbb") + spanTrace := TraceIDFromHex("cccccccccccccccccccccccccccccccc") + spanSpanID := SpanIDFromHex("dddddddddddddddd") + otelTrace := TraceIDFromHex("eeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee") + otelSpan := SpanIDFromHex("ffffffffffffffff") + + t.Run("propagation when no active span", func(t *testing.T) { + ctx := WithIsolation(context.Background()) + ctx, scope := ScopeFromContext(ctx) + scope.SetClient(client) + scope.SetPropagationContext(PropagationContext{ + TraceID: propTrace, + SpanID: propSpan, + }) + // A legacy span stored on Scope must not participate in context capture. + scope.SetSpan(&Span{TraceID: spanTrace, SpanID: spanSpanID}) + + before := len(transport.Events()) + _ = CaptureMessage(ctx, "prop-only") + events := transport.Events() + if assert.Greater(t, len(events), before) { + trace := events[len(events)-1].Contexts["trace"] + assert.Equal(t, propTrace, trace["trace_id"]) + assert.Equal(t, propSpan, trace["span_id"]) + } + }) + + t.Run("active span wins over propagation", func(t *testing.T) { + ctx := WithIsolation(context.Background()) + ctx, scope := ScopeFromContext(ctx) + scope.SetClient(client) + scope.SetPropagationContext(PropagationContext{ + TraceID: propTrace, + SpanID: propSpan, + }) + span := StartSpan(ctx, "op") + span.TraceID = spanTrace + span.SpanID = spanSpanID + + before := len(transport.Events()) + _ = CaptureMessage(span.Context(), "span-wins") + events := transport.Events() + if assert.Greater(t, len(events), before) { + trace := events[len(events)-1].Contexts["trace"] + assert.Equal(t, spanTrace, trace["trace_id"]) + assert.Equal(t, spanSpanID, trace["span_id"]) + } + }) + + t.Run("otel wins over active span and propagation", func(t *testing.T) { + client.SetExternalContextTraceResolver(func(context.Context) (TraceID, SpanID, bool) { + return otelTrace, otelSpan, true + }) + t.Cleanup(func() { client.SetExternalContextTraceResolver(nil) }) + + ctx := WithIsolation(context.Background()) + ctx, scope := ScopeFromContext(ctx) + scope.SetClient(client) + scope.SetPropagationContext(PropagationContext{ + TraceID: propTrace, + SpanID: propSpan, + }) + span := StartSpan(ctx, "op") + span.TraceID = spanTrace + span.SpanID = spanSpanID + + before := len(transport.Events()) + _ = CaptureMessage(span.Context(), "otel-wins") + events := transport.Events() + if assert.Greater(t, len(events), before) { + trace := events[len(events)-1].Contexts["trace"] + // external resolver overwrites as strings in applyToEvent + assert.Equal(t, otelTrace.String(), trace["trace_id"]) + assert.Equal(t, otelSpan.String(), trace["span_id"]) + } + }) +} + +func TestResolveTraceExternalResolverPrecedesLaterContextSpan(t *testing.T) { + client, err := newClient(ClientOptions{}) + if err != nil { + t.Fatal(err) + } + type testContextKey struct{} + externalTrace := TraceIDFromHex("eeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee") + externalSpan := SpanIDFromHex("ffffffffffffffff") + externalCtx := context.WithValue(context.Background(), testContextKey{}, "external") + client.SetExternalContextTraceResolver(func(ctx context.Context) (TraceID, SpanID, bool) { + if ctx == externalCtx { + return externalTrace, externalSpan, true + } + return TraceID{}, SpanID{}, false + }) + + sentrySpan := &Span{ + TraceID: TraceIDFromHex("cccccccccccccccccccccccccccccccc"), + SpanID: SpanIDFromHex("dddddddddddddddd"), + } + spanCtx := context.WithValue(context.Background(), spanContextKey{}, sentrySpan) + + traceID, spanID := resolveTrace(nil, client, spanCtx, externalCtx) + assert.Equal(t, externalTrace, traceID) + assert.Equal(t, externalSpan, spanID) +} + +func TestContinueTraceSetsIsolationPropagation(t *testing.T) { + client, err := newClient(ClientOptions{ + EnableTracing: true, + TracesSampleRate: 1.0, + Transport: &MockTransport{}, + Dsn: "https://key@example.com/1", + }) + if err != nil { + t.Fatal(err) + } + + trace := "d49d9bf66f13450b81f65bc51cf49c03-a9f442f9330b4e09-1" + ctx := WithIsolation(context.Background()) + ctx, scope := ScopeFromContext(ctx) + scope.SetClient(client) + opt := ContinueTrace(ctx, trace, "") + txn := StartTransaction(ctx, "continued", opt) + pc := scope.propagationContextSnapshot() + assert.Equal(t, TraceIDFromHex("d49d9bf66f13450b81f65bc51cf49c03"), pc.TraceID) + assert.Equal(t, TraceIDFromHex("d49d9bf66f13450b81f65bc51cf49c03"), txn.TraceID) +} + +func TestGetTraceparentFromContext(t *testing.T) { + client, err := newClient(ClientOptions{ + EnableTracing: true, + TracesSampleRate: 1.0, + Transport: &MockTransport{}, + Dsn: "https://key@example.com/1", + }) + if err != nil { + t.Fatal(err) + } + + ctx := WithIsolation(context.Background()) + ctx, scope := ScopeFromContext(ctx) + scope.SetClient(client) + scope.SetPropagationContext(PropagationContext{ + TraceID: TraceIDFromHex("d49d9bf66f13450b81f65bc51cf49c03"), + SpanID: SpanIDFromHex("a9f442f9330b4e09"), + }) + assert.Equal(t, "d49d9bf66f13450b81f65bc51cf49c03-a9f442f9330b4e09", GetTraceparent(ctx)) + assert.Equal(t, "00-d49d9bf66f13450b81f65bc51cf49c03-a9f442f9330b4e09-00", GetTraceparentW3C(ctx)) + + span := StartSpan(ctx, "op") + span.TraceID = TraceIDFromHex("cccccccccccccccccccccccccccccccc") + span.SpanID = SpanIDFromHex("dddddddddddddddd") + span.Sampled = SampledTrue + assert.Equal(t, span.ToSentryTrace(), GetTraceparent(span.Context())) + assert.Equal(t, span.ToTraceparent(), GetTraceparentW3C(span.Context())) +}