From 7332acb4bdae4275f852ac7ce9da4450bc7dd406 Mon Sep 17 00:00:00 2001 From: Zach Montoya Date: Thu, 4 Jun 2026 13:06:16 -0700 Subject: [PATCH 1/8] feat(otel): Update the trace exporter with a OTel trace compatbility mode. This is implemented by adding a new bool field enable_otel_trace_compatibility to TraceExporterBuilder with a public setter, propagating it to the OtlpTraceConfig, and using it in the OTLP trace export immediately to omit Datadog-specific attributes like "operation.name" --- libdd-data-pipeline/src/otlp/config.rs | 3 + .../src/trace_exporter/builder.rs | 12 +++ libdd-data-pipeline/src/trace_exporter/mod.rs | 2 +- libdd-trace-utils/src/otlp_encoder/mapper.rs | 102 +++++++++++++----- 4 files changed, 94 insertions(+), 25 deletions(-) diff --git a/libdd-data-pipeline/src/otlp/config.rs b/libdd-data-pipeline/src/otlp/config.rs index 02d7a45f80..3092e11164 100644 --- a/libdd-data-pipeline/src/otlp/config.rs +++ b/libdd-data-pipeline/src/otlp/config.rs @@ -35,4 +35,7 @@ pub struct OtlpTraceConfig { /// Protocol (for future use; currently only HttpJson is supported). #[allow(dead_code)] pub(crate) protocol: OtlpProtocol, + /// When `true`, omit DD-specific per-span attributes (`service.name`, `operation.name`, + /// `resource.name`, `span.type`) from the OTLP payload. + pub enable_otel_trace_compatibility: bool, } diff --git a/libdd-data-pipeline/src/trace_exporter/builder.rs b/libdd-data-pipeline/src/trace_exporter/builder.rs index 3c0e1f14b5..1f8924cecd 100644 --- a/libdd-data-pipeline/src/trace_exporter/builder.rs +++ b/libdd-data-pipeline/src/trace_exporter/builder.rs @@ -65,6 +65,7 @@ pub struct TraceExporterBuilder { connection_timeout: Option, otlp_endpoint: Option, otlp_headers: Vec<(String, String)>, + enable_otel_trace_compatibility: bool, } impl TraceExporterBuilder { @@ -296,6 +297,16 @@ impl TraceExporterBuilder { self } + /// Enable OTel trace compatibility mode: omit DD-specific per-span attributes + /// (`service.name`, `operation.name`, `resource.name`, `span.type`) from the OTLP payload. + /// + /// Use this when exporting to a native OTel backend that does not expect Datadog semantics, + /// for example when `DD_TRACE_OTEL_COMPATIBILITY_ENABLED=true`. + pub fn enable_otel_trace_compatibility(&mut self) -> &mut Self { + self.enable_otel_trace_compatibility = true; + self + } + /// Build the [`TraceExporter`] synchronously. /// /// Sync facade over [`Self::build_async`]; panics inside an existing tokio context. @@ -452,6 +463,7 @@ impl TraceExporterBuilder { .map(Duration::from_millis) .unwrap_or(DEFAULT_OTLP_TIMEOUT), protocol: OtlpProtocol::HttpJson, + enable_otel_trace_compatibility: self.enable_otel_trace_compatibility, } }); diff --git a/libdd-data-pipeline/src/trace_exporter/mod.rs b/libdd-data-pipeline/src/trace_exporter/mod.rs index 3850d46215..33fd15471d 100644 --- a/libdd-data-pipeline/src/trace_exporter/mod.rs +++ b/libdd-data-pipeline/src/trace_exporter/mod.rs @@ -545,7 +545,7 @@ impl Tra r.runtime_id = self.metadata.runtime_id.clone(); r }; - let request = map_traces_to_otlp(traces, &resource_info); + let request = map_traces_to_otlp(traces, &resource_info, config.enable_otel_trace_compatibility); let json_body = serde_json::to_vec(&request).map_err(|e| { error!("OTLP JSON serialization error: {e}"); TraceExporterError::Internal(InternalErrorKind::InvalidWorkerState(e.to_string())) diff --git a/libdd-trace-utils/src/otlp_encoder/mapper.rs b/libdd-trace-utils/src/otlp_encoder/mapper.rs index 0575c20ccf..6eba97e341 100644 --- a/libdd-trace-utils/src/otlp_encoder/mapper.rs +++ b/libdd-trace-utils/src/otlp_encoder/mapper.rs @@ -30,6 +30,7 @@ const MAX_ATTRIBUTES_PER_SPAN: usize = 128; pub fn map_traces_to_otlp( trace_chunks: Vec>>, resource_info: &OtlpResourceInfo, + enable_otel_trace_compatibility: bool, ) -> ExportTraceServiceRequest { let resource = build_resource(resource_info); let mut all_spans: Vec = Vec::new(); @@ -50,7 +51,12 @@ pub fn map_traces_to_otlp( }) .unwrap_or(0); for span in chunk { - all_spans.push(map_span(span, &resource_info.service, chunk_trace_id_high)); + all_spans.push(map_span( + span, + &resource_info.service, + chunk_trace_id_high, + enable_otel_trace_compatibility, + )); } } let scope_spans = ScopeSpans { @@ -116,6 +122,7 @@ fn map_span( span: &Span, resource_service: &str, chunk_trace_id_high: u64, + enable_otel_trace_compatibility: bool, ) -> OtlpSpan { // Reconstruct the full 128-bit trace ID. The caller resolves the high 64 bits once per // chunk (from either the native u128 `trace_id` field or the "_dd.p.tid" meta tag). @@ -139,7 +146,8 @@ fn map_span( .get("span.kind") .map(|v| tag_to_otlp_kind(v.borrow())) .unwrap_or_else(|| dd_type_to_otlp_kind(span.r#type.borrow())); - let (attributes, dropped_attributes_count) = map_attributes(span, resource_service); + let (attributes, dropped_attributes_count) = + map_attributes(span, resource_service, enable_otel_trace_compatibility); let error_msg = span.meta.get("error.msg").map(|v| v.borrow().to_string()); let status = if span.error != 0 { Status { @@ -291,12 +299,16 @@ fn dd_type_to_otlp_kind(t: &str) -> i32 { } } -fn map_attributes(span: &Span, resource_service: &str) -> (Vec, usize) { +fn map_attributes( + span: &Span, + resource_service: &str, + enable_otel_trace_compatibility: bool, +) -> (Vec, usize) { let mut attrs: Vec = Vec::new(); // Add service.name when the span's service differs from the resource-level service. let span_service = span.service.borrow(); let has_per_span_service = !span_service.is_empty() && span_service != resource_service; - if has_per_span_service { + if has_per_span_service && !enable_otel_trace_compatibility { attrs.push(KeyValue { key: "service.name".to_string(), value: AnyValue::StringValue(span_service.to_string()), @@ -304,7 +316,7 @@ fn map_attributes(span: &Span, resource_service: &str) -> (Vec< } let operation_name = span.name.borrow(); let has_operation_name = !operation_name.is_empty(); - if has_operation_name { + if has_operation_name && !enable_otel_trace_compatibility { attrs.push(KeyValue { key: "operation.name".to_string(), value: AnyValue::StringValue(operation_name.to_string()), @@ -312,7 +324,7 @@ fn map_attributes(span: &Span, resource_service: &str) -> (Vec< } let span_type = span.r#type.borrow(); let has_span_type = !span_type.is_empty(); - if has_span_type { + if has_span_type && !enable_otel_trace_compatibility { attrs.push(KeyValue { key: "span.type".to_string(), value: AnyValue::StringValue(span_type.to_string()), @@ -320,7 +332,7 @@ fn map_attributes(span: &Span, resource_service: &str) -> (Vec< } let resource_name = span.resource.borrow(); let has_resource_name = !resource_name.is_empty(); - if has_resource_name { + if has_resource_name && !enable_otel_trace_compatibility { attrs.push(KeyValue { key: "resource.name".to_string(), value: AnyValue::StringValue(resource_name.to_string()), @@ -391,7 +403,7 @@ mod tests { error: 0, ..Default::default() }; - let req = map_traces_to_otlp(vec![vec![span]], &resource_info); + let req = map_traces_to_otlp(vec![vec![span]], &resource_info, false); let rs = &req.resource_spans[0]; let otlp_span = &rs.scope_spans[0].spans[0]; assert_eq!(otlp_span.trace_id, "0000000000000000d269b633813fc60c"); @@ -422,7 +434,7 @@ mod tests { libdd_tinybytes::BytesString::from_static("error.msg"), libdd_tinybytes::BytesString::from_static("something broke"), ); - let req = map_traces_to_otlp(vec![vec![span]], &resource_info); + let req = map_traces_to_otlp(vec![vec![span]], &resource_info, false); let otlp_span = &req.resource_spans[0].scope_spans[0].spans[0]; let status = &otlp_span.status; assert_eq!(status.code, json_types::status_code::ERROR); @@ -446,7 +458,7 @@ mod tests { libdd_tinybytes::BytesString::from_static("rate"), std::f64::consts::PI, ); - let req = map_traces_to_otlp(vec![vec![span]], &resource_info); + let req = map_traces_to_otlp(vec![vec![span]], &resource_info, false); let json = serde_json::to_value(&req).unwrap(); let attrs = &json["resourceSpans"][0]["scopeSpans"][0]["spans"][0]["attributes"]; let count_kv = attrs @@ -483,7 +495,7 @@ mod tests { "_dd.p.tid".into(), libdd_tinybytes::BytesString::from_static("5b8efff798038103"), ); - let req = map_traces_to_otlp(vec![vec![span]], &resource_info); + let req = map_traces_to_otlp(vec![vec![span]], &resource_info, false); let otlp_span = &req.resource_spans[0].scope_spans[0].spans[0]; assert_eq!(otlp_span.trace_id, "5b8efff798038103d269b633813fc60c"); } @@ -513,7 +525,7 @@ mod tests { duration: 1, ..Default::default() }; - let req = map_traces_to_otlp(vec![vec![root, child]], &resource_info); + let req = map_traces_to_otlp(vec![vec![root, child]], &resource_info, false); let spans = &req.resource_spans[0].scope_spans[0].spans; let expected = "5b8efff798038103d269b633813fc60c"; assert_eq!(spans[0].trace_id, expected); @@ -533,7 +545,7 @@ mod tests { duration: 1, ..Default::default() }; - let req = map_traces_to_otlp(vec![vec![span]], &resource_info); + let req = map_traces_to_otlp(vec![vec![span]], &resource_info, false); let otlp_span = &req.resource_spans[0].scope_spans[0].spans[0]; assert_eq!(otlp_span.trace_id, "0000000000000000d269b633813fc60c"); } @@ -575,7 +587,7 @@ mod tests { duration: 1, ..Default::default() }; - let req = map_traces_to_otlp(vec![vec![root, child_a, child_b]], &resource_info); + let req = map_traces_to_otlp(vec![vec![root, child_a, child_b]], &resource_info, false); let spans = &req.resource_spans[0].scope_spans[0].spans; assert_eq!(spans.len(), 3); let expected = "5b8efff798038103d269b633813fc60c"; @@ -636,6 +648,7 @@ mod tests { let req = map_traces_to_otlp( vec![vec![root_a, child_a], vec![root_b, child_b]], &resource_info, + false, ); let spans = &req.resource_spans[0].scope_spans[0].spans; assert_eq!(spans.len(), 4); @@ -689,7 +702,7 @@ mod tests { "_dd.p.tid".into(), libdd_tinybytes::BytesString::from_static("dddddddddddddddd"), ); - let req = map_traces_to_otlp(vec![vec![root, child_no_tag, child_valid]], &resource_info); + let req = map_traces_to_otlp(vec![vec![root, child_no_tag, child_valid]], &resource_info, false); let spans = &req.resource_spans[0].scope_spans[0].spans; // The chunk-level scan skips the malformed root and picks up child_valid's tag, // which is then applied to every span in the chunk. @@ -704,7 +717,7 @@ mod tests { // Defensive: an empty chunk should produce no spans and not panic. let resource_info = OtlpResourceInfo::default(); let empty: Vec>> = vec![vec![]]; - let req = map_traces_to_otlp(empty, &resource_info); + let req = map_traces_to_otlp(empty, &resource_info, false); let spans = &req.resource_spans[0].scope_spans[0].spans; assert!(spans.is_empty()); } @@ -724,7 +737,7 @@ mod tests { "tracestate".into(), libdd_tinybytes::BytesString::from_static("vendor1=abc,rojo=00f067"), ); - let req = map_traces_to_otlp(vec![vec![span]], &resource_info); + let req = map_traces_to_otlp(vec![vec![span]], &resource_info, false); let otlp_span = &req.resource_spans[0].scope_spans[0].spans[0]; assert_eq!( otlp_span.trace_state.as_deref(), @@ -746,7 +759,7 @@ mod tests { }; span.meta_struct .insert("my_key".into(), Bytes::from(vec![1u8, 2, 3])); - let req = map_traces_to_otlp(vec![vec![span]], &resource_info); + let req = map_traces_to_otlp(vec![vec![span]], &resource_info, false); let json = serde_json::to_value(&req).unwrap(); let attrs = &json["resourceSpans"][0]["scopeSpans"][0]["spans"][0]["attributes"]; let kv = attrs @@ -770,7 +783,7 @@ mod tests { duration: 1, ..Default::default() }; - let req = map_traces_to_otlp(vec![vec![span]], &resource_info); + let req = map_traces_to_otlp(vec![vec![span]], &resource_info, false); let json = serde_json::to_value(&req).unwrap(); let attrs = &json["resourceSpans"][0]["scopeSpans"][0]["spans"][0]["attributes"]; let kv = attrs @@ -794,7 +807,7 @@ mod tests { duration: 1, ..Default::default() }; - let req = map_traces_to_otlp(vec![vec![span]], &resource_info); + let req = map_traces_to_otlp(vec![vec![span]], &resource_info, false); let json = serde_json::to_value(&req).unwrap(); let attrs = &json["resourceSpans"][0]["scopeSpans"][0]["spans"][0]["attributes"]; let kv = attrs @@ -818,7 +831,7 @@ mod tests { duration: 1, ..Default::default() }; - let req = map_traces_to_otlp(vec![vec![span]], &resource_info); + let req = map_traces_to_otlp(vec![vec![span]], &resource_info, false); let json = serde_json::to_value(&req).unwrap(); let otlp_span = &json["resourceSpans"][0]["scopeSpans"][0]["spans"][0]; // resource maps to the OTLP span name @@ -848,7 +861,7 @@ mod tests { duration: 1, ..Default::default() }; - let req = map_traces_to_otlp(vec![vec![span]], &resource_info); + let req = map_traces_to_otlp(vec![vec![span]], &resource_info, false); let json = serde_json::to_value(&req).unwrap(); let attrs = json["resourceSpans"][0]["scopeSpans"][0]["spans"][0]["attributes"] .as_array() @@ -876,7 +889,7 @@ mod tests { duration: 1, ..Default::default() }; - let req = map_traces_to_otlp(vec![vec![span]], &resource_info); + let req = map_traces_to_otlp(vec![vec![span]], &resource_info, false); let json = serde_json::to_value(&req).unwrap(); let attrs = &json["resourceSpans"][0]["scopeSpans"][0]["spans"][0]["attributes"]; let kv = attrs @@ -888,6 +901,47 @@ mod tests { assert_eq!(kv["value"]["stringValue"], "span-svc"); } + #[test] + fn test_enable_otel_trace_compatibility() { + let resource_info = OtlpResourceInfo { + service: "resource-svc".to_string(), + ..Default::default() + }; + let span: Span = Span { + trace_id: 1, + span_id: 2, + name: libdd_tinybytes::BytesString::from_static("http.request"), + service: libdd_tinybytes::BytesString::from_static("span-svc"), + resource: libdd_tinybytes::BytesString::from_static("GET /api/users"), + r#type: libdd_tinybytes::BytesString::from_static("web"), + start: 0, + duration: 1, + ..Default::default() + }; + let req = map_traces_to_otlp(vec![vec![span]], &resource_info, true); + let json = serde_json::to_value(&req).unwrap(); + let attrs = json["resourceSpans"][0]["scopeSpans"][0]["spans"][0]["attributes"] + .as_array() + .unwrap(); + let keys: Vec<&str> = attrs.iter().map(|a| a["key"].as_str().unwrap()).collect(); + assert!( + !keys.contains(&"service.name"), + "attributes should not contain service.name" + ); + assert!( + !keys.contains(&"operation.name"), + "attributes should not contain operation.name" + ); + assert!( + !keys.contains(&"resource.name"), + "attributes should not contain resource.name" + ); + assert!( + !keys.contains(&"span.type"), + "attributes should not contain span.type" + ); + } + #[test] fn test_unsampled_span_flags_zero() { // _sampling_priority_v1 = 0 means explicitly dropped; flags field must be 0. @@ -901,7 +955,7 @@ mod tests { ..Default::default() }; span.metrics.insert("_sampling_priority_v1".into(), 0.0); - let req = map_traces_to_otlp(vec![vec![span]], &resource_info); + let req = map_traces_to_otlp(vec![vec![span]], &resource_info, false); let otlp_span = &req.resource_spans[0].scope_spans[0].spans[0]; assert_eq!(otlp_span.flags, Some(0)); } From 110a9ef8f80709f0f6e5fd0ac534e2ca5f064082 Mon Sep 17 00:00:00 2001 From: Zach Montoya Date: Thu, 4 Jun 2026 14:56:49 -0700 Subject: [PATCH 2/8] Fix dropped attributes count calculation toaccount for OTel Trace Compatibility --- libdd-trace-utils/src/otlp_encoder/mapper.rs | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/libdd-trace-utils/src/otlp_encoder/mapper.rs b/libdd-trace-utils/src/otlp_encoder/mapper.rs index 6eba97e341..00cf1156e3 100644 --- a/libdd-trace-utils/src/otlp_encoder/mapper.rs +++ b/libdd-trace-utils/src/otlp_encoder/mapper.rs @@ -370,10 +370,10 @@ fn map_attributes( value: AnyValue::BytesValue(v.borrow().to_vec()), }); } - let total = (if has_per_span_service { 1 } else { 0 }) - + (if has_operation_name { 1 } else { 0 }) - + (if has_span_type { 1 } else { 0 }) - + (if has_resource_name { 1 } else { 0 }) + let total = (if has_per_span_service && !enable_otel_trace_compatibility { 1 } else { 0 }) + + (if has_operation_name && !enable_otel_trace_compatibility { 1 } else { 0 }) + + (if has_span_type && !enable_otel_trace_compatibility { 1 } else { 0 }) + + (if has_resource_name && !enable_otel_trace_compatibility { 1 } else { 0 }) + span.meta.len() + span.metrics.len() + span.meta_struct.len(); From 2d0d23408fea28614b64c96712f0ba7f236114bd Mon Sep 17 00:00:00 2001 From: Zach Montoya Date: Fri, 5 Jun 2026 11:37:35 -0700 Subject: [PATCH 3/8] Update the DD span => OTLP span mapping to remove the 'error.msg' and 'error.message' span tags on the output OTLP span when OTel Trace Compatibility is enabled --- libdd-trace-utils/src/otlp_encoder/mapper.rs | 119 +++++++++++++++++-- 1 file changed, 112 insertions(+), 7 deletions(-) diff --git a/libdd-trace-utils/src/otlp_encoder/mapper.rs b/libdd-trace-utils/src/otlp_encoder/mapper.rs index 00cf1156e3..4e3839886f 100644 --- a/libdd-trace-utils/src/otlp_encoder/mapper.rs +++ b/libdd-trace-utils/src/otlp_encoder/mapper.rs @@ -21,7 +21,7 @@ const MAX_ATTRIBUTES_PER_SPAN: usize = 128; /// runtime-id). InstrumentationScope: present but empty (DD SDKs don't have a scope concept). /// All analogous DD span fields are mapped; meta→attributes (string), metrics→attributes /// (int/double), links and events mapped to OTLP links and events. Status from span.error and -/// meta["error.msg"]. +/// meta["error.msg"] or meta["error.message"]. /// /// The high 64 bits of a 128-bit trace ID are carried in the trace_id field itself or (if not /// present) as the `_dd.p.tid` meta tag, which per RFC #85 is set on the chunk root only. @@ -148,7 +148,13 @@ fn map_span( .unwrap_or_else(|| dd_type_to_otlp_kind(span.r#type.borrow())); let (attributes, dropped_attributes_count) = map_attributes(span, resource_service, enable_otel_trace_compatibility); - let error_msg = span.meta.get("error.msg").map(|v| v.borrow().to_string()); + // DD tracers use either "error.msg" or "error.message". + // A span should only carry one of these, so the order of preference is arbitrary. + let error_msg = span + .meta + .get("error.msg") + .or_else(|| span.meta.get("error.message")) + .map(|v| v.borrow().to_string()); let status = if span.error != 0 { Status { message: error_msg, @@ -342,8 +348,12 @@ fn map_attributes( if attrs.len() >= MAX_ATTRIBUTES_PER_SPAN { break; } + let key = k.borrow(); + if enable_otel_trace_compatibility && (key == "error.msg" || key == "error.message") { + continue; + } attrs.push(KeyValue { - key: k.borrow().to_string(), + key: key.to_string(), value: AnyValue::StringValue(v.borrow().to_string()), }); } @@ -370,11 +380,18 @@ fn map_attributes( value: AnyValue::BytesValue(v.borrow().to_vec()), }); } + let excluded_error_tags = if enable_otel_trace_compatibility { + span.meta.contains_key("error.msg") as usize + + span.meta.contains_key("error.message") as usize + } else { + 0 + }; let total = (if has_per_span_service && !enable_otel_trace_compatibility { 1 } else { 0 }) + (if has_operation_name && !enable_otel_trace_compatibility { 1 } else { 0 }) + (if has_span_type && !enable_otel_trace_compatibility { 1 } else { 0 }) + (if has_resource_name && !enable_otel_trace_compatibility { 1 } else { 0 }) + span.meta.len() + - excluded_error_tags + span.metrics.len() + span.meta_struct.len(); let dropped = total.saturating_sub(attrs.len()); @@ -419,7 +436,7 @@ mod tests { } #[test] - fn test_status_error_message_from_meta() { + fn test_status_error_msg_from_meta() { let resource_info = OtlpResourceInfo::default(); let mut span: Span = Span { trace_id: 1, @@ -439,6 +456,37 @@ mod tests { let status = &otlp_span.status; assert_eq!(status.code, json_types::status_code::ERROR); assert_eq!(status.message.as_deref(), Some("something broke")); + assert!( + otlp_span.attributes.iter().any(|kv| kv.key == "error.msg"), + "error.msg should still appear in attributes when otel compat mode is disabled" + ); + } + + #[test] + fn test_status_error_message_from_meta() { + let resource_info = OtlpResourceInfo::default(); + let mut span: Span = Span { + trace_id: 1, + span_id: 2, + name: libdd_tinybytes::BytesString::from_static("err_span"), + start: 0, + duration: 1, + error: 1, + ..Default::default() + }; + span.meta.insert( + libdd_tinybytes::BytesString::from_static("error.message"), + libdd_tinybytes::BytesString::from_static("something broke"), + ); + let req = map_traces_to_otlp(vec![vec![span]], &resource_info, false); + let otlp_span = &req.resource_spans[0].scope_spans[0].spans[0]; + let status = &otlp_span.status; + assert_eq!(status.code, json_types::status_code::ERROR); + assert_eq!(status.message.as_deref(), Some("something broke")); + assert!( + otlp_span.attributes.iter().any(|kv| kv.key == "error.message"), + "error.message should still appear in attributes when otel compat mode is disabled" + ); } #[test] @@ -920,10 +968,15 @@ mod tests { }; let req = map_traces_to_otlp(vec![vec![span]], &resource_info, true); let json = serde_json::to_value(&req).unwrap(); - let attrs = json["resourceSpans"][0]["scopeSpans"][0]["spans"][0]["attributes"] + let attrs_val = &json["resourceSpans"][0]["scopeSpans"][0]["spans"][0]["attributes"]; + let keys: Vec<&str> = attrs_val .as_array() - .unwrap(); - let keys: Vec<&str> = attrs.iter().map(|a| a["key"].as_str().unwrap()).collect(); + .map(|arr| { + arr.iter() + .map(|a| a["key"].as_str().unwrap()) + .collect() + }) + .unwrap_or_default(); assert!( !keys.contains(&"service.name"), "attributes should not contain service.name" @@ -942,6 +995,58 @@ mod tests { ); } + #[test] + fn test_otel_compat_error_msg_excluded_from_attributes() { + let resource_info = OtlpResourceInfo::default(); + let mut span: Span = Span { + trace_id: 1, + span_id: 2, + name: libdd_tinybytes::BytesString::from_static("err_span"), + start: 0, + duration: 1, + error: 1, + ..Default::default() + }; + span.meta.insert( + libdd_tinybytes::BytesString::from_static("error.msg"), + libdd_tinybytes::BytesString::from_static("something broke"), + ); + let req = map_traces_to_otlp(vec![vec![span]], &resource_info, true); + let otlp_span = &req.resource_spans[0].scope_spans[0].spans[0]; + assert_eq!(otlp_span.status.code, json_types::status_code::ERROR); + assert_eq!(otlp_span.status.message.as_deref(), Some("something broke")); + assert!( + !otlp_span.attributes.iter().any(|kv| kv.key == "error.msg"), + "error.msg should not appear in attributes when otel compat mode is enabled" + ); + } + + #[test] + fn test_otel_compat_error_message_excluded_from_attributes() { + let resource_info = OtlpResourceInfo::default(); + let mut span: Span = Span { + trace_id: 1, + span_id: 2, + name: libdd_tinybytes::BytesString::from_static("err_span"), + start: 0, + duration: 1, + error: 1, + ..Default::default() + }; + span.meta.insert( + libdd_tinybytes::BytesString::from_static("error.message"), + libdd_tinybytes::BytesString::from_static("something broke"), + ); + let req = map_traces_to_otlp(vec![vec![span]], &resource_info, true); + let otlp_span = &req.resource_spans[0].scope_spans[0].spans[0]; + assert_eq!(otlp_span.status.code, json_types::status_code::ERROR); + assert_eq!(otlp_span.status.message.as_deref(), Some("something broke")); + assert!( + !otlp_span.attributes.iter().any(|kv| kv.key == "error.message"), + "error.message should not appear in attributes when otel compat mode is enabled" + ); + } + #[test] fn test_unsampled_span_flags_zero() { // _sampling_priority_v1 = 0 means explicitly dropped; flags field must be 0. From 2efa686c8e2c3b953eda9d0c58e4ed5a6cc0930a Mon Sep 17 00:00:00 2001 From: Zach Montoya Date: Mon, 8 Jun 2026 17:55:37 -0700 Subject: [PATCH 4/8] Run the linter --- libdd-data-pipeline/src/trace_exporter/mod.rs | 6 ++- libdd-trace-utils/src/otlp_encoder/mapper.rs | 44 +++++++++++++------ 2 files changed, 36 insertions(+), 14 deletions(-) diff --git a/libdd-data-pipeline/src/trace_exporter/mod.rs b/libdd-data-pipeline/src/trace_exporter/mod.rs index 33fd15471d..ce927067c5 100644 --- a/libdd-data-pipeline/src/trace_exporter/mod.rs +++ b/libdd-data-pipeline/src/trace_exporter/mod.rs @@ -545,7 +545,11 @@ impl Tra r.runtime_id = self.metadata.runtime_id.clone(); r }; - let request = map_traces_to_otlp(traces, &resource_info, config.enable_otel_trace_compatibility); + let request = map_traces_to_otlp( + traces, + &resource_info, + config.enable_otel_trace_compatibility, + ); let json_body = serde_json::to_vec(&request).map_err(|e| { error!("OTLP JSON serialization error: {e}"); TraceExporterError::Internal(InternalErrorKind::InvalidWorkerState(e.to_string())) diff --git a/libdd-trace-utils/src/otlp_encoder/mapper.rs b/libdd-trace-utils/src/otlp_encoder/mapper.rs index 4e3839886f..3f5ec500de 100644 --- a/libdd-trace-utils/src/otlp_encoder/mapper.rs +++ b/libdd-trace-utils/src/otlp_encoder/mapper.rs @@ -386,11 +386,23 @@ fn map_attributes( } else { 0 }; - let total = (if has_per_span_service && !enable_otel_trace_compatibility { 1 } else { 0 }) - + (if has_operation_name && !enable_otel_trace_compatibility { 1 } else { 0 }) - + (if has_span_type && !enable_otel_trace_compatibility { 1 } else { 0 }) - + (if has_resource_name && !enable_otel_trace_compatibility { 1 } else { 0 }) - + span.meta.len() + let total = (if has_per_span_service && !enable_otel_trace_compatibility { + 1 + } else { + 0 + }) + (if has_operation_name && !enable_otel_trace_compatibility { + 1 + } else { + 0 + }) + (if has_span_type && !enable_otel_trace_compatibility { + 1 + } else { + 0 + }) + (if has_resource_name && !enable_otel_trace_compatibility { + 1 + } else { + 0 + }) + span.meta.len() - excluded_error_tags + span.metrics.len() + span.meta_struct.len(); @@ -484,7 +496,10 @@ mod tests { assert_eq!(status.code, json_types::status_code::ERROR); assert_eq!(status.message.as_deref(), Some("something broke")); assert!( - otlp_span.attributes.iter().any(|kv| kv.key == "error.message"), + otlp_span + .attributes + .iter() + .any(|kv| kv.key == "error.message"), "error.message should still appear in attributes when otel compat mode is disabled" ); } @@ -750,7 +765,11 @@ mod tests { "_dd.p.tid".into(), libdd_tinybytes::BytesString::from_static("dddddddddddddddd"), ); - let req = map_traces_to_otlp(vec![vec![root, child_no_tag, child_valid]], &resource_info, false); + let req = map_traces_to_otlp( + vec![vec![root, child_no_tag, child_valid]], + &resource_info, + false, + ); let spans = &req.resource_spans[0].scope_spans[0].spans; // The chunk-level scan skips the malformed root and picks up child_valid's tag, // which is then applied to every span in the chunk. @@ -971,11 +990,7 @@ mod tests { let attrs_val = &json["resourceSpans"][0]["scopeSpans"][0]["spans"][0]["attributes"]; let keys: Vec<&str> = attrs_val .as_array() - .map(|arr| { - arr.iter() - .map(|a| a["key"].as_str().unwrap()) - .collect() - }) + .map(|arr| arr.iter().map(|a| a["key"].as_str().unwrap()).collect()) .unwrap_or_default(); assert!( !keys.contains(&"service.name"), @@ -1042,7 +1057,10 @@ mod tests { assert_eq!(otlp_span.status.code, json_types::status_code::ERROR); assert_eq!(otlp_span.status.message.as_deref(), Some("something broke")); assert!( - !otlp_span.attributes.iter().any(|kv| kv.key == "error.message"), + !otlp_span + .attributes + .iter() + .any(|kv| kv.key == "error.message"), "error.message should not appear in attributes when otel compat mode is enabled" ); } From e86566e697b2958177d7dac9f6491d56031de050 Mon Sep 17 00:00:00 2001 From: Zach Montoya Date: Tue, 9 Jun 2026 11:39:14 -0700 Subject: [PATCH 5/8] Exclude the "span.kind" span attribute from being emitted on OTLP spans in the OTel compat mode. It is already present as a first-class SpanKind field --- libdd-trace-utils/src/otlp_encoder/mapper.rs | 64 +++++++++++++++++++- 1 file changed, 61 insertions(+), 3 deletions(-) diff --git a/libdd-trace-utils/src/otlp_encoder/mapper.rs b/libdd-trace-utils/src/otlp_encoder/mapper.rs index 3f5ec500de..a53401b7ca 100644 --- a/libdd-trace-utils/src/otlp_encoder/mapper.rs +++ b/libdd-trace-utils/src/otlp_encoder/mapper.rs @@ -349,7 +349,9 @@ fn map_attributes( break; } let key = k.borrow(); - if enable_otel_trace_compatibility && (key == "error.msg" || key == "error.message") { + if enable_otel_trace_compatibility + && (key == "error.msg" || key == "error.message" || key == "span.kind") + { continue; } attrs.push(KeyValue { @@ -380,9 +382,10 @@ fn map_attributes( value: AnyValue::BytesValue(v.borrow().to_vec()), }); } - let excluded_error_tags = if enable_otel_trace_compatibility { + let excluded_compat_tags = if enable_otel_trace_compatibility { span.meta.contains_key("error.msg") as usize + span.meta.contains_key("error.message") as usize + + span.meta.contains_key("span.kind") as usize } else { 0 }; @@ -403,7 +406,7 @@ fn map_attributes( } else { 0 }) + span.meta.len() - - excluded_error_tags + - excluded_compat_tags + span.metrics.len() + span.meta_struct.len(); let dropped = total.saturating_sub(attrs.len()); @@ -1065,6 +1068,61 @@ mod tests { ); } + #[test] + fn test_otel_compat_span_kind_excluded_from_attributes() { + // span.kind is promoted to the OTLP kind field; it must not appear as an attribute + // when OTel Trace Compatibility is enabled, and must not inflate dropped_attributes_count. + let resource_info = OtlpResourceInfo::default(); + let mut span: Span = Span { + trace_id: 1, + span_id: 2, + name: libdd_tinybytes::BytesString::from_static("s"), + start: 0, + duration: 1, + ..Default::default() + }; + span.meta.insert( + libdd_tinybytes::BytesString::from_static("span.kind"), + libdd_tinybytes::BytesString::from_static("server"), + ); + let req = map_traces_to_otlp(vec![vec![span]], &resource_info, true); + let otlp_span = &req.resource_spans[0].scope_spans[0].spans[0]; + assert_eq!(otlp_span.kind, json_types::span_kind::SERVER); + assert!( + !otlp_span.attributes.iter().any(|kv| kv.key == "span.kind"), + "span.kind should not appear in attributes when otel compat mode is enabled" + ); + assert_eq!( + otlp_span.dropped_attributes_count, None, + "span.kind should not be counted as a dropped attribute when intentionally excluded by compat mode" + ); + } + + #[test] + fn test_span_kind_present_in_attributes_when_compat_disabled() { + // When OTel Trace Compatibility is disabled, span.kind appears as a regular attribute. + let resource_info = OtlpResourceInfo::default(); + let mut span: Span = Span { + trace_id: 1, + span_id: 2, + name: libdd_tinybytes::BytesString::from_static("s"), + start: 0, + duration: 1, + ..Default::default() + }; + span.meta.insert( + libdd_tinybytes::BytesString::from_static("span.kind"), + libdd_tinybytes::BytesString::from_static("client"), + ); + let req = map_traces_to_otlp(vec![vec![span]], &resource_info, false); + let otlp_span = &req.resource_spans[0].scope_spans[0].spans[0]; + assert_eq!(otlp_span.kind, json_types::span_kind::CLIENT); + assert!( + otlp_span.attributes.iter().any(|kv| kv.key == "span.kind"), + "span.kind should appear in attributes when otel compat mode is disabled" + ); + } + #[test] fn test_unsampled_span_flags_zero() { // _sampling_priority_v1 = 0 means explicitly dropped; flags field must be 0. From 06a721229e96d8c344a355ffcd510b0c2b623223 Mon Sep 17 00:00:00 2001 From: Zach Montoya Date: Thu, 11 Jun 2026 17:16:52 -0700 Subject: [PATCH 6/8] Rename the config to `otel_trace_semantics_enabled` --- libdd-data-pipeline/src/otlp/config.rs | 6 ++-- .../src/trace_exporter/builder.rs | 10 +++--- libdd-data-pipeline/src/trace_exporter/mod.rs | 2 +- libdd-trace-utils/src/otlp_encoder/mapper.rs | 32 +++++++++---------- 4 files changed, 25 insertions(+), 25 deletions(-) diff --git a/libdd-data-pipeline/src/otlp/config.rs b/libdd-data-pipeline/src/otlp/config.rs index 3092e11164..be8dbcbfce 100644 --- a/libdd-data-pipeline/src/otlp/config.rs +++ b/libdd-data-pipeline/src/otlp/config.rs @@ -35,7 +35,7 @@ pub struct OtlpTraceConfig { /// Protocol (for future use; currently only HttpJson is supported). #[allow(dead_code)] pub(crate) protocol: OtlpProtocol, - /// When `true`, omit DD-specific per-span attributes (`service.name`, `operation.name`, - /// `resource.name`, `span.type`) from the OTLP payload. - pub enable_otel_trace_compatibility: bool, + /// When `true`, does not add DD-specific per-span attributes (`service.name`, `operation.name`, + /// `resource.name`, `span.type`) to the OTLP payload. + pub otel_trace_semantics_enabled: bool, } diff --git a/libdd-data-pipeline/src/trace_exporter/builder.rs b/libdd-data-pipeline/src/trace_exporter/builder.rs index 1f8924cecd..7f3e0bd8cc 100644 --- a/libdd-data-pipeline/src/trace_exporter/builder.rs +++ b/libdd-data-pipeline/src/trace_exporter/builder.rs @@ -65,7 +65,7 @@ pub struct TraceExporterBuilder { connection_timeout: Option, otlp_endpoint: Option, otlp_headers: Vec<(String, String)>, - enable_otel_trace_compatibility: bool, + otel_trace_semantics_enabled: bool, } impl TraceExporterBuilder { @@ -301,9 +301,9 @@ impl TraceExporterBuilder { /// (`service.name`, `operation.name`, `resource.name`, `span.type`) from the OTLP payload. /// /// Use this when exporting to a native OTel backend that does not expect Datadog semantics, - /// for example when `DD_TRACE_OTEL_COMPATIBILITY_ENABLED=true`. - pub fn enable_otel_trace_compatibility(&mut self) -> &mut Self { - self.enable_otel_trace_compatibility = true; + /// for example when `DD_TRACE_OTEL_SEMANTICS_ENABLED=true`. + pub fn enable_otel_trace_semantics(&mut self) -> &mut Self { + self.otel_trace_semantics_enabled = true; self } @@ -463,7 +463,7 @@ impl TraceExporterBuilder { .map(Duration::from_millis) .unwrap_or(DEFAULT_OTLP_TIMEOUT), protocol: OtlpProtocol::HttpJson, - enable_otel_trace_compatibility: self.enable_otel_trace_compatibility, + otel_trace_semantics_enabled: self.otel_trace_semantics_enabled, } }); diff --git a/libdd-data-pipeline/src/trace_exporter/mod.rs b/libdd-data-pipeline/src/trace_exporter/mod.rs index ce927067c5..6d389750a4 100644 --- a/libdd-data-pipeline/src/trace_exporter/mod.rs +++ b/libdd-data-pipeline/src/trace_exporter/mod.rs @@ -548,7 +548,7 @@ impl Tra let request = map_traces_to_otlp( traces, &resource_info, - config.enable_otel_trace_compatibility, + config.otel_trace_semantics_enabled, ); let json_body = serde_json::to_vec(&request).map_err(|e| { error!("OTLP JSON serialization error: {e}"); diff --git a/libdd-trace-utils/src/otlp_encoder/mapper.rs b/libdd-trace-utils/src/otlp_encoder/mapper.rs index a53401b7ca..3a4f375d4d 100644 --- a/libdd-trace-utils/src/otlp_encoder/mapper.rs +++ b/libdd-trace-utils/src/otlp_encoder/mapper.rs @@ -30,7 +30,7 @@ const MAX_ATTRIBUTES_PER_SPAN: usize = 128; pub fn map_traces_to_otlp( trace_chunks: Vec>>, resource_info: &OtlpResourceInfo, - enable_otel_trace_compatibility: bool, + otel_trace_semantics_enabled: bool, ) -> ExportTraceServiceRequest { let resource = build_resource(resource_info); let mut all_spans: Vec = Vec::new(); @@ -55,7 +55,7 @@ pub fn map_traces_to_otlp( span, &resource_info.service, chunk_trace_id_high, - enable_otel_trace_compatibility, + otel_trace_semantics_enabled, )); } } @@ -122,7 +122,7 @@ fn map_span( span: &Span, resource_service: &str, chunk_trace_id_high: u64, - enable_otel_trace_compatibility: bool, + otel_trace_semantics_enabled: bool, ) -> OtlpSpan { // Reconstruct the full 128-bit trace ID. The caller resolves the high 64 bits once per // chunk (from either the native u128 `trace_id` field or the "_dd.p.tid" meta tag). @@ -147,7 +147,7 @@ fn map_span( .map(|v| tag_to_otlp_kind(v.borrow())) .unwrap_or_else(|| dd_type_to_otlp_kind(span.r#type.borrow())); let (attributes, dropped_attributes_count) = - map_attributes(span, resource_service, enable_otel_trace_compatibility); + map_attributes(span, resource_service, otel_trace_semantics_enabled); // DD tracers use either "error.msg" or "error.message". // A span should only carry one of these, so the order of preference is arbitrary. let error_msg = span @@ -308,13 +308,13 @@ fn dd_type_to_otlp_kind(t: &str) -> i32 { fn map_attributes( span: &Span, resource_service: &str, - enable_otel_trace_compatibility: bool, + otel_trace_semantics_enabled: bool, ) -> (Vec, usize) { let mut attrs: Vec = Vec::new(); // Add service.name when the span's service differs from the resource-level service. let span_service = span.service.borrow(); let has_per_span_service = !span_service.is_empty() && span_service != resource_service; - if has_per_span_service && !enable_otel_trace_compatibility { + if has_per_span_service && !otel_trace_semantics_enabled { attrs.push(KeyValue { key: "service.name".to_string(), value: AnyValue::StringValue(span_service.to_string()), @@ -322,7 +322,7 @@ fn map_attributes( } let operation_name = span.name.borrow(); let has_operation_name = !operation_name.is_empty(); - if has_operation_name && !enable_otel_trace_compatibility { + if has_operation_name && !otel_trace_semantics_enabled { attrs.push(KeyValue { key: "operation.name".to_string(), value: AnyValue::StringValue(operation_name.to_string()), @@ -330,7 +330,7 @@ fn map_attributes( } let span_type = span.r#type.borrow(); let has_span_type = !span_type.is_empty(); - if has_span_type && !enable_otel_trace_compatibility { + if has_span_type && !otel_trace_semantics_enabled { attrs.push(KeyValue { key: "span.type".to_string(), value: AnyValue::StringValue(span_type.to_string()), @@ -338,7 +338,7 @@ fn map_attributes( } let resource_name = span.resource.borrow(); let has_resource_name = !resource_name.is_empty(); - if has_resource_name && !enable_otel_trace_compatibility { + if has_resource_name && !otel_trace_semantics_enabled { attrs.push(KeyValue { key: "resource.name".to_string(), value: AnyValue::StringValue(resource_name.to_string()), @@ -349,7 +349,7 @@ fn map_attributes( break; } let key = k.borrow(); - if enable_otel_trace_compatibility + if otel_trace_semantics_enabled && (key == "error.msg" || key == "error.message" || key == "span.kind") { continue; @@ -382,26 +382,26 @@ fn map_attributes( value: AnyValue::BytesValue(v.borrow().to_vec()), }); } - let excluded_compat_tags = if enable_otel_trace_compatibility { + let excluded_compat_tags = if otel_trace_semantics_enabled { span.meta.contains_key("error.msg") as usize + span.meta.contains_key("error.message") as usize + span.meta.contains_key("span.kind") as usize } else { 0 }; - let total = (if has_per_span_service && !enable_otel_trace_compatibility { + let total = (if has_per_span_service && !otel_trace_semantics_enabled { 1 } else { 0 - }) + (if has_operation_name && !enable_otel_trace_compatibility { + }) + (if has_operation_name && !otel_trace_semantics_enabled { 1 } else { 0 - }) + (if has_span_type && !enable_otel_trace_compatibility { + }) + (if has_span_type && !otel_trace_semantics_enabled { 1 } else { 0 - }) + (if has_resource_name && !enable_otel_trace_compatibility { + }) + (if has_resource_name && !otel_trace_semantics_enabled { 1 } else { 0 @@ -972,7 +972,7 @@ mod tests { } #[test] - fn test_enable_otel_trace_compatibility() { + fn test_otel_trace_semantics_enabled() { let resource_info = OtlpResourceInfo { service: "resource-svc".to_string(), ..Default::default() From 91c86087d8dcd291ede25531533c5cfd7ee0ae0b Mon Sep 17 00:00:00 2001 From: Zach Montoya Date: Thu, 11 Jun 2026 17:20:19 -0700 Subject: [PATCH 7/8] Fix linter issues --- libdd-data-pipeline/src/otlp/config.rs | 4 ++-- libdd-data-pipeline/src/trace_exporter/mod.rs | 7 ++----- 2 files changed, 4 insertions(+), 7 deletions(-) diff --git a/libdd-data-pipeline/src/otlp/config.rs b/libdd-data-pipeline/src/otlp/config.rs index be8dbcbfce..d4d2dc3c8f 100644 --- a/libdd-data-pipeline/src/otlp/config.rs +++ b/libdd-data-pipeline/src/otlp/config.rs @@ -35,7 +35,7 @@ pub struct OtlpTraceConfig { /// Protocol (for future use; currently only HttpJson is supported). #[allow(dead_code)] pub(crate) protocol: OtlpProtocol, - /// When `true`, does not add DD-specific per-span attributes (`service.name`, `operation.name`, - /// `resource.name`, `span.type`) to the OTLP payload. + /// When `true`, does not add DD-specific per-span attributes (`service.name`, + /// `operation.name`, `resource.name`, `span.type`) to the OTLP payload. pub otel_trace_semantics_enabled: bool, } diff --git a/libdd-data-pipeline/src/trace_exporter/mod.rs b/libdd-data-pipeline/src/trace_exporter/mod.rs index 6d389750a4..38d8b837d0 100644 --- a/libdd-data-pipeline/src/trace_exporter/mod.rs +++ b/libdd-data-pipeline/src/trace_exporter/mod.rs @@ -545,11 +545,8 @@ impl Tra r.runtime_id = self.metadata.runtime_id.clone(); r }; - let request = map_traces_to_otlp( - traces, - &resource_info, - config.otel_trace_semantics_enabled, - ); + let request = + map_traces_to_otlp(traces, &resource_info, config.otel_trace_semantics_enabled); let json_body = serde_json::to_vec(&request).map_err(|e| { error!("OTLP JSON serialization error: {e}"); TraceExporterError::Internal(InternalErrorKind::InvalidWorkerState(e.to_string())) From 61a273f9fc5075ed8e7892478568c2a3217c83ed Mon Sep 17 00:00:00 2001 From: Zach Montoya Date: Thu, 11 Jun 2026 17:27:58 -0700 Subject: [PATCH 8/8] Small comment update --- libdd-data-pipeline/src/trace_exporter/builder.rs | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/libdd-data-pipeline/src/trace_exporter/builder.rs b/libdd-data-pipeline/src/trace_exporter/builder.rs index 7f3e0bd8cc..0694405115 100644 --- a/libdd-data-pipeline/src/trace_exporter/builder.rs +++ b/libdd-data-pipeline/src/trace_exporter/builder.rs @@ -297,11 +297,11 @@ impl TraceExporterBuilder { self } - /// Enable OTel trace compatibility mode: omit DD-specific per-span attributes - /// (`service.name`, `operation.name`, `resource.name`, `span.type`) from the OTLP payload. - /// - /// Use this when exporting to a native OTel backend that does not expect Datadog semantics, - /// for example when `DD_TRACE_OTEL_SEMANTICS_ENABLED=true`. + /// Enables OTel trace semantics, which does not add DD-specific per-span attributes + /// (`service.name`, `operation.name`, `resource.name`, `span.type`) to the OTLP payload. + /// This is useful when exporting to a native OTel backend that does not expect Datadog + /// semantics. The host language tracer is expected to observe this behavior by setting the + /// `DD_TRACE_OTEL_SEMANTICS_ENABLED` environment variable to `true`. pub fn enable_otel_trace_semantics(&mut self) -> &mut Self { self.otel_trace_semantics_enabled = true; self