feat(data-pipeline): add fork-safe OTLP gRPC trace transport - #2273
feat(data-pipeline): add fork-safe OTLP gRPC trace transport#2273bm1549 wants to merge 2 commits into
Conversation
Adds a plaintext gRPC-over-HTTP/2 (h2c) trace-export transport for OTLP, implemented as a custom tower::Service plugged into tonic's Grpc<T>. It holds no persistent connection or background task: each send opens a fresh connection driven by ephemeral per-request tasks that are torn down when the send completes, so nothing is orphaned across fork(2). Includes a minimal prost codec (tonic 0.14 moved ProstCodec to a separate crate; hand-rolled here to avoid the extra dependency), endpoint validation (plaintext http:// only; https:// rejected), gRPC-status-to-error mapping that recovers nested IO errors, per-request metadata (validated once at build), and unit plus in-process h2 integration tests. This is the transport primitive; the OTLP export path is wired to it in a follow-up change. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
📚 Documentation Check Results📦
|
🔒 Cargo Deny Results📦
|
🎉 All green!🧪 All tests passed 🔄 Datadog auto-retried 1 job - 1 passed on retry 🎯 Code Coverage (details) 🔗 Commit SHA: f5ea26d | Docs | Datadog PR Page | Give us feedback! |
Artifact Size Benchmark Reportaarch64-alpine-linux-musl
aarch64-unknown-linux-gnu
libdatadog-x64-windows
libdatadog-x86-windows
x86_64-alpine-linux-musl
x86_64-unknown-linux-gnu
|
BenchmarksComparisonBenchmark execution time: 2026-07-27 16:22:29 Comparing candidate commit f5ea26d in PR branch Found 9 performance improvements and 10 performance regressions! Performance is the same for 123 metrics, 0 unstable metrics.
|
|
|
||
| /// Per-request OTLP gRPC trace exporter configuration. | ||
| // Not yet wired to the trace exporter's send loop; exercised by tests only. | ||
| #[allow(dead_code)] |
|
|
||
| /// Validate a gRPC endpoint (plaintext `http://` only) and build the transport. | ||
| // Not yet wired to the trace exporter's send loop; exercised by tests only. | ||
| #[allow(dead_code)] |
|
|
||
| /// Send one OTLP trace export request over gRPC. Bounds connect + RPC with a single timeout. | ||
| // Not yet wired to the trace exporter's send loop; exercised by tests only. | ||
| #[allow(dead_code)] |
yannham
left a comment
There was a problem hiding this comment.
Overall LGTM, nothing major blocking.
Though the same caveat as in the original PR applies about the size issue:
A first naive question: could/should this be feature-gated? I assume you'd want all tracers to support otlp out of the box, so the answers might be no, bu asking just > in case.
A good test for the impact of the binary size increase: would you mind trying to create a PR against dd-trace-py pointing to this PR's libdatadog ref, and see if it triggers the size gates for e.g. serverless?
|
|
||
| #[test] | ||
| fn grpc_config_constructs_and_clones() { | ||
| let cfg = OtlpGrpcTraceConfig { | ||
| headers: vec![("k".to_string(), "v".to_string())], | ||
| timeout: Duration::from_secs(3), | ||
| otel_trace_semantics_enabled: true, | ||
| }; | ||
| let clone = cfg.clone(); | ||
| assert_eq!(clone.headers, cfg.headers); | ||
| assert_eq!(clone.timeout, Duration::from_secs(3)); | ||
| assert!(clone.otel_trace_semantics_enabled); | ||
| } |
There was a problem hiding this comment.
We're basically testing the builtin Clone derive implementation.
| #[test] | |
| fn grpc_config_constructs_and_clones() { | |
| let cfg = OtlpGrpcTraceConfig { | |
| headers: vec![("k".to_string(), "v".to_string())], | |
| timeout: Duration::from_secs(3), | |
| otel_trace_semantics_enabled: true, | |
| }; | |
| let clone = cfg.clone(); | |
| assert_eq!(clone.headers, cfg.headers); | |
| assert_eq!(clone.timeout, Duration::from_secs(3)); | |
| assert!(clone.otel_trace_semantics_enabled); | |
| } |
| // private `DecodeBuf` constructor. | ||
| fn decode_from(src: &mut impl bytes::Buf) -> Result<Option<T>, Status> { | ||
| // copy_to_bytes drains the whole buffer even if the backing store is non-contiguous. | ||
| let buf = src.copy_to_bytes(src.remaining()); |
There was a problem hiding this comment.
I'm not sure to understand the point of this line. Sure we get a contiguous buffer out of it, but we need to go through the original one anyways (and copy it). So why not read directly read from the original one?
| let resp = sender.send_request(req).await?; | ||
| let (parts, incoming) = resp.into_parts(); | ||
| let collected = incoming.collect().await?; | ||
| driver.abort(); |
There was a problem hiding this comment.
It seems driver.abort() kinda ends the connection abruptly, instead of a graceful shutdown. On one hand, we already got our response, so we should be good on our side. I just wonder if this is an issue on the intake side.
One alternative is to await the driver after dropping the sender (drop(sender); let _ = driver.await;). This is a graceful shutdown. However this is now subject to network hangs or whatnot, so it probably needs a timeout.
I'm not 100% sure what's the best solution here. abort is not graceful shutdown, but on the other hand it ensures we don't block the calling task for unimportant reasons.
| pub(crate) config: OtlpGrpcTraceConfig, | ||
| origin: http::Uri, | ||
| service: H2Service, | ||
| /// Custom headers parsed to gRPC metadata once at build time; invalid entries are dropped. |
There was a problem hiding this comment.
Nit: "invalid entries are dropped" sounds like it should go on the function that builds metadata_headers, not on the field itself.
| /// Custom headers parsed to gRPC metadata once at build time; invalid entries are dropped. | |
| /// Custom headers parsed to gRPC metadata once at build time. |
| } | ||
|
|
||
| /// A gRPC transport for OTLP trace export: per-request config, request origin, and the dial | ||
| /// service. Holds no live connection (nothing to rebuild across fork). |
There was a problem hiding this comment.
Nit: connections in itself is not necessarily the issue, but background async tasks that we don't control are.
| /// service. Holds no live connection (nothing to rebuild across fork). | |
| /// service. Holds no live connection and thus no background task (nothing to rebuild across fork). |
What does this PR do?
Adds a plaintext gRPC-over-HTTP/2 (h2c) trace-export transport for OTLP to
libdd-data-pipeline. It's a customtower::Service(H2Service) plugged into tonic's genericGrpc<T>client, so the exporter holds no persistent connection or background task: each send opens a fresh connection driven by ephemeral per-request tasks that are torn down when the send completes.This is the transport primitive only. The OTLP export path is wired to it in a follow-up PR that rebases on top of this one.
Motivation
The earlier OTLP gRPC approach built a tonic
Channel, which spawns a persistent background task. Because libdatadog rebuilds its runtime acrossfork(2), that task dies silently after a fork, which forced aWorkerpluswatch-channel to rebuild it. Making the transport per-request and task-free removes that machinery and is fork-safe by construction.Additional Notes
default-features = falseon tonic (notransport/channel/server); a minimal hand-rolled prost codec replacesProstCodec(which moved to the separatetonic-prostcrate) to avoid the extra dependency.https://endpoints are rejected at build time; terminate TLS in a proxy in front of the endpoint if encryption is needed.How to test the change?
cargo nextest run -p libdd-data-pipelinecovers unit tests plus in-processh2integration tests: a real unary send decoded by a mock gRPC server, and connection-refused, timeout, and post-connect-reset cases that all map toTraceExporterError::Io.cargo clippy -p libdd-data-pipeline --all-targets -- -D warnings,cargo +nightly fmt --check, andcargo check -p libdd-data-pipeline --target wasm32-unknown-unknown --no-default-features(the gRPC path is wasm-gated) pass.