diff --git a/ddtrace/opentelemetry/span.go b/ddtrace/opentelemetry/span.go index 8087e5728c..b78f5dfbf1 100644 --- a/ddtrace/opentelemetry/span.go +++ b/ddtrace/opentelemetry/span.go @@ -100,8 +100,18 @@ func (s *span) End(options ...oteltrace.SpanEndOption) { var finishCfg = oteltrace.NewSpanEndConfig(options...) var opts []tracer.FinishOption if s.statusInfo.code == otelcodes.Error { + // Set unconditionally: this feeds the OTLP status message (see convertSpanStatus) + // even under semantics, where error.msg is otherwise suppressed as a raw attribute. s.DD.SetTag(ext.ErrorMsg, s.statusInfo.description) - opts = append(opts, tracer.WithError(errors.New(s.statusInfo.description))) + if s.otelSemanticsEnabled { + // Under OTel semantics, mark the span errored for the OTLP status without + // injecting DD's error.* tags: WithError would set error.type to the reflect + // type of the status wrapper ("*errors.errorString"), clobbering the stable + // OTel error.type that instrumentation sets. + s.DD.SetTag(ext.Error, true) + } else { + opts = append(opts, tracer.WithError(errors.New(s.statusInfo.description))) + } } if len(s.finishOpts) != 0 { opts = append(opts, s.finishOpts...) diff --git a/ddtrace/opentelemetry/span_test.go b/ddtrace/opentelemetry/span_test.go index d047e1177d..036f8afd30 100644 --- a/ddtrace/opentelemetry/span_test.go +++ b/ddtrace/opentelemetry/span_test.go @@ -265,6 +265,8 @@ func TestSpanEnd(t *testing.T) { assert.Equal(1.0, p[0]["error"]) // this should be an error span meta := fmt.Sprintf("%v", p[0]["meta"]) assert.Contains(meta, msg) + // Non-semantics error path: WithError injects error.type as the wrapper's reflect type. + assert.Contains(meta, "error.type:*errors.errorString") for k, v := range attributes { assert.Contains(meta, fmt.Sprintf("%s:%s", k, v)) } @@ -1040,3 +1042,56 @@ func TestRemapStatusCodeOtelSemantics(t *testing.T) { assert.NotContains(meta, "http.status_code") assert.Contains(metrics, "http.response.status_code:200") } + +// TestSpanEndErrorTypeOtelSemantics verifies that finishing an error-status span under +// OTel semantics marks it errored without injecting Datadog's error.type/error.stack: an +// instrumentation-set error.type survives, and when none is set no reflect-type value is +// injected. (The non-semantics WithError path is covered by TestSpanEnd.) +func TestSpanEndErrorTypeOtelSemantics(t *testing.T) { + // finish an error-status span under semantics, optionally with an + // instrumentation-set error.type, and return the exported span. + run := func(t *testing.T, instrumentationErrorType string) map[string]any { + // Reload global config on cleanup so the flag doesn't leak; LIFO order runs this + // after t.Setenv restores the env. + t.Cleanup(func() { internalconfig.CreateNew() }) + t.Setenv("DD_TRACE_OTEL_SEMANTICS_ENABLED", "true") + internalconfig.CreateNew() + + _, payloads, cleanup := mockTracerProvider(t) + tr := otel.Tracer("") + defer cleanup() + + _, sp := tr.Start(context.Background(), "op") + if instrumentationErrorType != "" { + sp.SetAttributes(attribute.String(ext.ErrorType, instrumentationErrorType)) + } + sp.SetStatus(codes.Error, "boom") + sp.End() + + tracer.Flush() + traces, err := waitForPayload(payloads) + if err != nil { + t.Fatal(err.Error()) + } + return traces[0][0] + } + + t.Run("preserves instrumentation error.type", func(t *testing.T) { + assert := assert.New(t) + span := run(t, "*net.OpError") + assert.Equal(1.0, span["error"]) // span still flagged errored + meta := fmt.Sprintf("%v", span["meta"]) + assert.Contains(meta, "error.type:*net.OpError") // instrumentation value survives + assert.NotContains(meta, "*errors.errorString") // WithError was skipped + assert.NotContains(meta, "error.stack") // no DD stack injected + }) + + t.Run("injects no error.type when instrumentation sets none", func(t *testing.T) { + assert := assert.New(t) + span := run(t, "") + assert.Equal(1.0, span["error"]) // still errored via the error flag / OTLP status + meta := fmt.Sprintf("%v", span["meta"]) + assert.NotContains(meta, "error.type") // no reflect-type value injected + assert.NotContains(meta, "error.stack") + }) +} diff --git a/ddtrace/tracer/span_to_otlp.go b/ddtrace/tracer/span_to_otlp.go index a5c7fa332b..84be5365d6 100644 --- a/ddtrace/tracer/span_to_otlp.go +++ b/ddtrace/tracer/span_to_otlp.go @@ -187,9 +187,10 @@ var otelIntMetricKeys = map[string]struct{}{ // ddOnlyMetaKeys are Datadog-specific span tags omitted under OTelSemanticsEnabled; // they have no OTel equivalent. span.kind is already carried by the OTLP SpanKind field. +// error.type is intentionally excluded: it is a stable OTel attribute that instrumentation +// sets, so it passes through. var ddOnlyMetaKeys = map[string]struct{}{ ext.ErrorMsg: {}, - ext.ErrorType: {}, ext.ErrorStack: {}, ext.ErrorHandlingStack: {}, ext.SpanKind: {}, diff --git a/ddtrace/tracer/span_to_otlp_test.go b/ddtrace/tracer/span_to_otlp_test.go index 3a75032804..5795657bcb 100644 --- a/ddtrace/tracer/span_to_otlp_test.go +++ b/ddtrace/tracer/span_to_otlp_test.go @@ -246,29 +246,32 @@ func TestConvertSpanAttributesIntEncoding(t *testing.T) { } // TestConvertSpanAttributesOtelSemantics verifies that under OTel semantics the OTLP -// exporter omits Datadog-specific attributes (span identity, error.*, span.kind). +// exporter omits Datadog-specific attributes (span identity, error.message/stack, +// span.kind) but preserves error.type, which is a stable OpenTelemetry attribute. func TestConvertSpanAttributesOtelSemantics(t *testing.T) { s := newBasicSpan("op") s.meta = tinternal.NewSpanMetaFromMap(map[string]string{ "http.request.method": "GET", ext.ErrorMsg: "boom", - ext.ErrorType: "*errors.errorString", + ext.ErrorType: "*net.OpError", ext.ErrorStack: "goroutine 1 ...", ext.ErrorHandlingStack: "goroutine 1 ...", ext.SpanKind: ext.SpanKindServer, }) m := keyValuesToMap(convertSpanAttributes(s, "", true)) - // DD-only span-identity, error, and span.kind attributes are suppressed. + // DD-only span-identity, error message/stack, and span.kind attributes are suppressed. for _, k := range []string{ "operation.name", "resource.name", "span.type", - ext.ErrorMsg, ext.ErrorType, ext.ErrorStack, ext.ErrorHandlingStack, ext.SpanKind, + ext.ErrorMsg, ext.ErrorStack, ext.ErrorHandlingStack, ext.SpanKind, } { _, ok := m[k] assert.Falsef(t, ok, "%q should be suppressed when OTel semantics is enabled", k) } - // Non-DD-only meta is preserved unchanged. + // error.type is a stable OTel attribute and passes through unchanged, as does + // non-DD-only meta. + assert.Equal(t, "*net.OpError", m[ext.ErrorType]) assert.Equal(t, "GET", m["http.request.method"]) }