diff --git a/libdd-data-pipeline/src/otlp/config.rs b/libdd-data-pipeline/src/otlp/config.rs index 02d7a45f80..d4d2dc3c8f 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`, 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 3c0e1f14b5..0694405115 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)>, + otel_trace_semantics_enabled: bool, } impl TraceExporterBuilder { @@ -296,6 +297,16 @@ impl TraceExporterBuilder { self } + /// 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 + } + /// 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, + 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 3850d46215..38d8b837d0 100644 --- a/libdd-data-pipeline/src/trace_exporter/mod.rs +++ b/libdd-data-pipeline/src/trace_exporter/mod.rs @@ -545,7 +545,8 @@ 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.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())) diff --git a/libdd-trace-utils/src/otlp_encoder/mapper.rs b/libdd-trace-utils/src/otlp_encoder/mapper.rs index 0575c20ccf..3a4f375d4d 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. @@ -30,6 +30,7 @@ const MAX_ATTRIBUTES_PER_SPAN: usize = 128; pub fn map_traces_to_otlp( trace_chunks: Vec>>, resource_info: &OtlpResourceInfo, + otel_trace_semantics_enabled: 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, + otel_trace_semantics_enabled, + )); } } let scope_spans = ScopeSpans { @@ -116,6 +122,7 @@ fn map_span( span: &Span, resource_service: &str, chunk_trace_id_high: u64, + 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). @@ -139,8 +146,15 @@ 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 error_msg = span.meta.get("error.msg").map(|v| v.borrow().to_string()); + let (attributes, dropped_attributes_count) = + 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 + .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, @@ -291,12 +305,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, + 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 { + if has_per_span_service && !otel_trace_semantics_enabled { attrs.push(KeyValue { key: "service.name".to_string(), value: AnyValue::StringValue(span_service.to_string()), @@ -304,7 +322,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 && !otel_trace_semantics_enabled { attrs.push(KeyValue { key: "operation.name".to_string(), value: AnyValue::StringValue(operation_name.to_string()), @@ -312,7 +330,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 && !otel_trace_semantics_enabled { attrs.push(KeyValue { key: "span.type".to_string(), value: AnyValue::StringValue(span_type.to_string()), @@ -320,7 +338,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 && !otel_trace_semantics_enabled { attrs.push(KeyValue { key: "resource.name".to_string(), value: AnyValue::StringValue(resource_name.to_string()), @@ -330,8 +348,14 @@ fn map_attributes(span: &Span, resource_service: &str) -> (Vec< if attrs.len() >= MAX_ATTRIBUTES_PER_SPAN { break; } + let key = k.borrow(); + if otel_trace_semantics_enabled + && (key == "error.msg" || key == "error.message" || key == "span.kind") + { + continue; + } attrs.push(KeyValue { - key: k.borrow().to_string(), + key: key.to_string(), value: AnyValue::StringValue(v.borrow().to_string()), }); } @@ -358,11 +382,31 @@ fn map_attributes(span: &Span, resource_service: &str) -> (Vec< 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 }) - + span.meta.len() + 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 && !otel_trace_semantics_enabled { + 1 + } else { + 0 + }) + (if has_operation_name && !otel_trace_semantics_enabled { + 1 + } else { + 0 + }) + (if has_span_type && !otel_trace_semantics_enabled { + 1 + } else { + 0 + }) + (if has_resource_name && !otel_trace_semantics_enabled { + 1 + } else { + 0 + }) + span.meta.len() + - excluded_compat_tags + span.metrics.len() + span.meta_struct.len(); let dropped = total.saturating_sub(attrs.len()); @@ -391,7 +435,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"); @@ -407,7 +451,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, @@ -422,11 +466,45 @@ 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); 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] @@ -446,7 +524,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 +561,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 +591,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 +611,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 +653,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 +714,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 +768,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); + 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 +787,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 +807,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 +829,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 +853,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 +877,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 +901,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 +931,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 +959,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 +971,158 @@ mod tests { assert_eq!(kv["value"]["stringValue"], "span-svc"); } + #[test] + fn test_otel_trace_semantics_enabled() { + 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_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()) + .unwrap_or_default(); + 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_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_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. @@ -901,7 +1136,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)); }