Skip to content

Commit 4e8e6cc

Browse files
authored
feat(data-pipeline): OTLP HTTP/protobuf trace export (#2115)
# What does this PR do? Adds OTLP HTTP/protobuf as a trace-export encoding alongside HTTP/JSON, selectable via the OTel-standard `OTEL_EXPORTER_OTLP_TRACES_PROTOCOL` (`http/json` default, `http/protobuf`). The generated `prost` OTLP types are the single intermediate representation: the mapper builds them directly from native spans, protobuf is `prost::encode_to_vec`, and JSON is a serde serializer over the same types. # Motivation libdatadog's OTLP trace export only spoke HTTP/JSON. SDKs that honor `OTEL_EXPORTER_OTLP_TRACES_PROTOCOL` need `http/protobuf` to match the OTel default and to talk to collectors that expect protobuf. # Additional Notes - Prost is the single IR: no parallel hand-rolled JSON model, no string round-trip. `encode_otlp_protobuf` is `encode_to_vec`; `encode_otlp_json` is a serde serializer (`json_serializer.rs`) emitting OTLP-spec JSON (hex ids, base64 bytes, int64-as-string, lowerCamelCase, proto3 defaults omitted). - `OtlpProtocol` is `{HttpJson, HttpProtobuf}`; `grpc` is rejected at the parse boundary (`FromStr`) rather than carried as an unsupported variant. `content_type()`/`encode()` live on the type. - Integrates with the OTLP metrics exporter already on main: the shared low-level sender takes the content-type per request, so traces use the configured protocol while metrics stay JSON. - Carries through `otel_trace_semantics_enabled` (OTel attribute compatibility, #2091): when set, the prost mapper omits the DD-specific span attributes and promotes the error message to the OTLP `Status`. - Span-link W3C trace flags are carried through to OTLP `Link.flags`. - Benchmarks for the encoder hot paths, plus allocation tuning (pre-sized Vecs, allocation-free id/timestamp/int serialization). # How to test the change? - `cargo test -p libdd-trace-utils -p libdd-data-pipeline -p libdd-data-pipeline-ffi`, `cargo test --doc`, clippy, fmt, and `cargo ffi-test` pass. A parity test asserts the JSON and protobuf encodings carry the same span from the one prost IR; a protobuf round-trip test asserts the encoding is lossless. - End-to-end through dd-trace-py (DataDog/dd-trace-py#18609): emitted protobuf-only OTLP traces through a local Agent to the backend. Wire was `application/x-protobuf` (HTTP 200), spans ingested with correct service/resource and a preserved 128-bit trace id. - Benchmarks: `cargo bench -p libdd-trace-utils --bench main -- otlp/`. BREAKING CHANGE: removes the previously public `libdd_trace_utils::otlp_encoder::json_types` module (the hand-rolled OTLP JSON model). OTLP encoding now builds prost-generated types as the single IR. libdatadog consumers pin by version, so they pick this up on the next release. Co-authored-by: brian.marks <brian.marks@datadoghq.com>
1 parent 9697e87 commit 4e8e6cc

24 files changed

Lines changed: 2804 additions & 839 deletions

File tree

Cargo.lock

Lines changed: 2 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

libdd-data-pipeline-ffi/src/trace_exporter.rs

Lines changed: 136 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ use libdd_data_pipeline::trace_exporter::{
1313
TelemetryConfig, TelemetryInstrumentationSessions, TraceExporter as GenericTraceExporter,
1414
TraceExporterInputFormat, TraceExporterOutputFormat,
1515
};
16+
use libdd_data_pipeline::OtlpProtocol;
1617

1718
pub(crate) type TraceExporter = GenericTraceExporter<NativeCapabilities>;
1819

@@ -83,6 +84,7 @@ pub struct TraceExporterConfig {
8384
connection_timeout: Option<u64>,
8485
shared_runtime: Option<Arc<SharedRuntime>>,
8586
otlp_endpoint: Option<String>,
87+
otlp_protocol: Option<OtlpProtocol>,
8688
}
8789

8890
#[no_mangle]
@@ -498,12 +500,50 @@ pub unsafe extern "C" fn ddog_trace_exporter_config_set_otlp_endpoint(
498500
)
499501
}
500502

503+
/// Sets the OTLP export protocol. Accepts the OTel-standard values `http/json` (default) or
504+
/// `http/protobuf`; `grpc` is rejected as not yet supported. The host language resolves the value
505+
/// (e.g. from `OTEL_EXPORTER_OTLP_TRACES_PROTOCOL`).
506+
///
507+
/// Has no effect unless an OTLP endpoint is also configured via
508+
/// `ddog_trace_exporter_config_set_otlp_endpoint`; without one, traces are sent to the
509+
/// Datadog agent and this protocol selection is ignored.
510+
///
511+
/// Returns `None` on success, `ErrorCode::InvalidArgument` for a null config or an unaccepted
512+
/// value, and `ErrorCode::InvalidInput` for a non-UTF-8 string.
513+
#[no_mangle]
514+
pub unsafe extern "C" fn ddog_trace_exporter_config_set_otlp_protocol(
515+
config: Option<&mut TraceExporterConfig>,
516+
protocol: CharSlice,
517+
) -> Option<Box<ExporterError>> {
518+
catch_panic!(
519+
if let Some(handle) = config {
520+
let value = match sanitize_string(protocol) {
521+
Ok(s) => s,
522+
Err(e) => return Some(e),
523+
};
524+
// `FromStr` is the single source of truth for string -> OtlpProtocol. It accepts only
525+
// the supported HTTP encodings (`http/json`, `http/protobuf`); `grpc` and any unknown
526+
// value are rejected with an error, so an unsupported protocol can never be stored.
527+
match value.parse::<OtlpProtocol>() {
528+
Ok(p) => {
529+
handle.otlp_protocol = Some(p);
530+
None
531+
}
532+
Err(_) => gen_error!(ErrorCode::InvalidArgument),
533+
}
534+
} else {
535+
gen_error!(ErrorCode::InvalidArgument)
536+
},
537+
gen_error!(ErrorCode::Panic)
538+
)
539+
}
540+
501541
/// Create a new TraceExporter instance.
502542
///
503-
/// When an OTLP endpoint is configured via `TraceExporterConfig`, the exporter sends traces in
504-
/// OTLP HTTP/JSON to that endpoint instead of the Datadog agent. The same payload (e.g.
505-
/// MessagePack) is passed to `ddog_trace_exporter_send`; the library decodes and converts to
506-
/// OTLP when OTLP is enabled.
543+
/// When an OTLP endpoint is configured via `TraceExporterConfig`, the exporter sends traces to
544+
/// that endpoint in OTLP over HTTP — JSON or protobuf per the configured protocol — instead of
545+
/// to the Datadog agent. The same payload (e.g. MessagePack) is passed to
546+
/// `ddog_trace_exporter_send`; the library decodes and converts it to OTLP when OTLP is enabled.
507547
///
508548
/// # Arguments
509549
///
@@ -565,6 +605,9 @@ pub unsafe extern "C" fn ddog_trace_exporter_new(
565605

566606
if let Some(ref url) = config.otlp_endpoint {
567607
builder.set_otlp_endpoint(url);
608+
if let Some(protocol) = config.otlp_protocol {
609+
builder.set_otlp_protocol(protocol);
610+
}
568611
}
569612

570613
match builder.build() {
@@ -1283,6 +1326,95 @@ mod tests {
12831326
}
12841327
}
12851328

1329+
#[test]
1330+
fn config_otlp_protocol_test() {
1331+
unsafe {
1332+
// Null config → InvalidArgument
1333+
let error =
1334+
ddog_trace_exporter_config_set_otlp_protocol(None, CharSlice::from("http/json"));
1335+
assert_eq!(error.as_ref().unwrap().code, ErrorCode::InvalidArgument);
1336+
ddog_trace_exporter_error_free(error);
1337+
1338+
// "http/json" → success, stored
1339+
let mut config = Some(TraceExporterConfig::default());
1340+
let error = ddog_trace_exporter_config_set_otlp_protocol(
1341+
config.as_mut(),
1342+
CharSlice::from("http/json"),
1343+
);
1344+
assert_eq!(error, None);
1345+
assert_eq!(
1346+
config.as_ref().unwrap().otlp_protocol,
1347+
Some(OtlpProtocol::HttpJson)
1348+
);
1349+
1350+
// "http/protobuf" → success, stored
1351+
let mut config = Some(TraceExporterConfig::default());
1352+
let error = ddog_trace_exporter_config_set_otlp_protocol(
1353+
config.as_mut(),
1354+
CharSlice::from("http/protobuf"),
1355+
);
1356+
assert_eq!(error, None);
1357+
assert_eq!(
1358+
config.as_ref().unwrap().otlp_protocol,
1359+
Some(OtlpProtocol::HttpProtobuf)
1360+
);
1361+
1362+
// "grpc" → InvalidArgument
1363+
let mut config = Some(TraceExporterConfig::default());
1364+
let error = ddog_trace_exporter_config_set_otlp_protocol(
1365+
config.as_mut(),
1366+
CharSlice::from("grpc"),
1367+
);
1368+
assert_eq!(error.as_ref().unwrap().code, ErrorCode::InvalidArgument);
1369+
ddog_trace_exporter_error_free(error);
1370+
1371+
// Garbage value → InvalidArgument
1372+
let mut config = Some(TraceExporterConfig::default());
1373+
let error = ddog_trace_exporter_config_set_otlp_protocol(
1374+
config.as_mut(),
1375+
CharSlice::from("nonsense"),
1376+
);
1377+
assert_eq!(error.as_ref().unwrap().code, ErrorCode::InvalidArgument);
1378+
ddog_trace_exporter_error_free(error);
1379+
1380+
// Non-UTF-8 input → InvalidInput
1381+
let mut config = Some(TraceExporterConfig::default());
1382+
let invalid: [u8; 2] = [0x80u8, 0xFFu8];
1383+
let error = ddog_trace_exporter_config_set_otlp_protocol(
1384+
config.as_mut(),
1385+
CharSlice::from_bytes(&invalid),
1386+
);
1387+
assert_eq!(error.as_ref().unwrap().code, ErrorCode::InvalidInput);
1388+
ddog_trace_exporter_error_free(error);
1389+
}
1390+
}
1391+
1392+
#[test]
1393+
fn set_otlp_protocol_stores_parsed_enum() {
1394+
use libdd_data_pipeline::OtlpProtocol;
1395+
let mut cfg = TraceExporterConfig::default();
1396+
let err = unsafe {
1397+
ddog_trace_exporter_config_set_otlp_protocol(
1398+
Some(&mut cfg),
1399+
CharSlice::from("http/protobuf"),
1400+
)
1401+
};
1402+
assert!(err.is_none());
1403+
assert_eq!(cfg.otlp_protocol, Some(OtlpProtocol::HttpProtobuf));
1404+
}
1405+
1406+
#[test]
1407+
fn set_otlp_protocol_rejects_grpc_and_unknown() {
1408+
let mut cfg = TraceExporterConfig::default();
1409+
for bad in ["grpc", "nonsense"] {
1410+
let err = unsafe {
1411+
ddog_trace_exporter_config_set_otlp_protocol(Some(&mut cfg), CharSlice::from(bad))
1412+
};
1413+
assert!(err.is_some(), "expected error for {bad}");
1414+
assert_eq!(cfg.otlp_protocol, None, "{bad} must not be stored");
1415+
}
1416+
}
1417+
12861418
#[cfg(all(feature = "catch_panic", panic = "unwind"))]
12871419
#[test]
12881420
fn catch_panic_test() {

libdd-data-pipeline/Cargo.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -74,6 +74,7 @@ libdd-trace-utils = { path = "../libdd-trace-utils", features = [
7474
"test-utils",
7575
] }
7676
httpmock = "0.8.0-alpha.1"
77+
prost = "0.14.1"
7778
rand = "0.8.5"
7879
tempfile = "3.3.0"
7980
tokio = { version = "1.23", features = [

libdd-data-pipeline/src/lib.rs

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,9 @@
1414
pub mod agent_info;
1515
mod health_metrics;
1616
pub(crate) mod otlp;
17+
// `OtlpProtocol` (documented on the enum itself) is the only public symbol from the otherwise
18+
// crate-internal `otlp` module; re-exported here so the FFI crate can name it.
19+
pub use otlp::OtlpProtocol;
1720
#[cfg(feature = "telemetry")]
1821
pub(crate) mod telemetry;
1922
#[cfg(not(target_arch = "wasm32"))]

libdd-data-pipeline/src/otlp/config.rs

Lines changed: 91 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -6,18 +6,60 @@
66
use http::HeaderMap;
77
use std::time::Duration;
88

9-
/// OTLP trace export protocol. HTTP/JSON is currently supported.
9+
/// OTLP trace export protocol — selects the HTTP body encoding and `Content-Type`.
10+
///
11+
/// Only the HTTP encodings libdatadog actually supports are representable. A `grpc` value (e.g.
12+
/// resolved from the OTel-default `OTEL_EXPORTER_OTLP_PROTOCOL`) is rejected by
13+
/// [`FromStr`](std::str::FromStr) rather than represented here, so an unsupported protocol can
14+
/// never be constructed and silently mishandled downstream.
1015
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
11-
pub(crate) enum OtlpProtocol {
12-
/// HTTP with JSON body (Content-Type: application/json). Default for HTTP.
16+
pub enum OtlpProtocol {
17+
/// HTTP with a JSON body (`Content-Type: application/json`). The default.
1318
#[default]
1419
HttpJson,
15-
/// HTTP with protobuf body. (Not supported yet)
16-
#[allow(dead_code)]
20+
/// HTTP with a protobuf body (`Content-Type: application/x-protobuf`).
1721
HttpProtobuf,
18-
/// gRPC. (Not supported yet)
19-
#[allow(dead_code)]
20-
Grpc,
22+
}
23+
24+
impl std::str::FromStr for OtlpProtocol {
25+
type Err = String;
26+
fn from_str(s: &str) -> Result<Self, Self::Err> {
27+
match s {
28+
"http/json" => Ok(OtlpProtocol::HttpJson),
29+
"http/protobuf" => Ok(OtlpProtocol::HttpProtobuf),
30+
// gRPC is a valid OTLP protocol in the OTel spec but is not implemented in
31+
// libdatadog. Reject it explicitly so callers get a clean error at the parse
32+
// boundary, rather than constructing an unsupported value that has to be guarded
33+
// against everywhere downstream.
34+
"grpc" => Err("OTLP gRPC export is not supported".to_string()),
35+
other => Err(format!("unknown OTLP protocol: {other}")),
36+
}
37+
}
38+
}
39+
40+
impl OtlpProtocol {
41+
/// The HTTP `Content-Type` for this protocol's body encoding. Crate-internal: the public type
42+
/// is only constructed/selected by callers; encoding is the exporter's job.
43+
pub(crate) fn content_type(&self) -> http::HeaderValue {
44+
match self {
45+
OtlpProtocol::HttpJson => libdd_common::header::APPLICATION_JSON,
46+
OtlpProtocol::HttpProtobuf => libdd_common::header::APPLICATION_PROTOBUF,
47+
}
48+
}
49+
50+
/// Encode the prost OTLP request to this protocol's wire format. Crate-internal so the
51+
/// third-party `serde_json::Error` does not leak into the public API.
52+
pub(crate) fn encode(
53+
&self,
54+
req: &libdd_trace_utils::otlp_encoder::ProtoExportTraceServiceRequest,
55+
) -> Result<Vec<u8>, serde_json::Error> {
56+
match self {
57+
OtlpProtocol::HttpJson => libdd_trace_utils::otlp_encoder::encode_otlp_json(req),
58+
OtlpProtocol::HttpProtobuf => {
59+
Ok(libdd_trace_utils::otlp_encoder::encode_otlp_protobuf(req))
60+
}
61+
}
62+
}
2163
}
2264

2365
/// Default timeout for OTLP export requests.
@@ -32,15 +74,50 @@ pub struct OtlpTraceConfig {
3274
pub headers: HeaderMap,
3375
/// Request timeout.
3476
pub timeout: Duration,
35-
/// Protocol (for future use; currently only HttpJson is supported).
36-
#[allow(dead_code)]
37-
pub(crate) protocol: OtlpProtocol,
38-
/// When `true`, does not add DD-specific per-span attributes to the OTLP payload.
39-
// These attributes are: (`service.name`, `operation.name`, `resource.name`,
40-
// `span.type`, `error.msg`, `error.message`, `span.kind`)
77+
/// OTLP export protocol (selects body encoding and content-type).
78+
pub protocol: OtlpProtocol,
79+
/// When `true`, omit DD-specific per-span attributes (`service.name`, `operation.name`,
80+
/// `resource.name`, `span.type`, `error.*`, `span.kind`) from the OTLP payload.
4181
pub otel_trace_semantics_enabled: bool,
4282
}
4383

84+
#[cfg(test)]
85+
mod tests {
86+
use super::*;
87+
use std::str::FromStr;
88+
#[test]
89+
fn protocol_from_str() {
90+
assert_eq!(
91+
OtlpProtocol::from_str("http/json").unwrap(),
92+
OtlpProtocol::HttpJson
93+
);
94+
assert_eq!(
95+
OtlpProtocol::from_str("http/protobuf").unwrap(),
96+
OtlpProtocol::HttpProtobuf
97+
);
98+
assert!(OtlpProtocol::from_str("nonsense").is_err());
99+
}
100+
101+
#[test]
102+
fn grpc_is_rejected_at_parse() {
103+
// gRPC is unsupported, so it must not parse into a protocol: an unsupported value can
104+
// never be constructed.
105+
assert!(OtlpProtocol::from_str("grpc").is_err());
106+
}
107+
108+
#[test]
109+
fn protocol_content_types() {
110+
assert_eq!(
111+
OtlpProtocol::HttpJson.content_type(),
112+
libdd_common::header::APPLICATION_JSON
113+
);
114+
assert_eq!(
115+
OtlpProtocol::HttpProtobuf.content_type(),
116+
libdd_common::header::APPLICATION_PROTOBUF
117+
);
118+
}
119+
}
120+
44121
/// Parsed OTLP trace-metrics exporter configuration.
45122
#[derive(Clone, Debug)]
46123
pub struct OtlpMetricsConfig {

0 commit comments

Comments
 (0)