diff --git a/Cargo.lock b/Cargo.lock index ec71e0a7a5..8842d1db36 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2895,6 +2895,18 @@ version = "0.2.178" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "37c93d8daa9d8a012fd8ab92f088405fb202ea0b6ab73ee2482ae66af4f42091" +[[package]] +name = "libdd-agent-client" +version = "29.0.0" +dependencies = [ + "bytes", + "libdd-http-client", + "serde", + "serde_json", + "thiserror 2.0.17", + "tokio", +] + [[package]] name = "libdd-alloc" version = "1.0.0" diff --git a/Cargo.toml b/Cargo.toml index dc78ca9844..c8bbbd8f16 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -46,6 +46,7 @@ members = [ "libdd-tinybytes", "libdd-dogstatsd-client", "libdd-http-client", + "libdd-agent-client", "libdd-log", "libdd-log-ffi", "libdd-libunwind-sys", ] diff --git a/libdd-agent-client/Cargo.toml b/libdd-agent-client/Cargo.toml new file mode 100644 index 0000000000..f077e1786c --- /dev/null +++ b/libdd-agent-client/Cargo.toml @@ -0,0 +1,27 @@ +# Copyright 2026-Present Datadog, Inc. https://www.datadoghq.com/ +# SPDX-License-Identifier: Apache-2.0 + +[package] +name = "libdd-agent-client" +version.workspace = true +edition.workspace = true +rust-version.workspace = true +license.workspace = true +authors.workspace = true +description = "Datadog-agent-specialized HTTP client: language metadata injection, per-endpoint send methods, retry, and compression" +homepage = "https://github.com/DataDog/libdatadog/tree/main/libdd-agent-client" +repository = "https://github.com/DataDog/libdatadog/tree/main/libdd-agent-client" + +[lib] +bench = false + +[dependencies] +bytes = "1.4" +serde = { version = "1.0", features = ["derive"] } +serde_json = "1.0" +thiserror = "2" +tokio = { version = "1.23", features = ["rt"] } +libdd-http-client = { path = "../libdd-http-client" } + +[dev-dependencies] +tokio = { version = "1.23", features = ["rt", "macros"] } diff --git a/libdd-agent-client/src/agent_info.rs b/libdd-agent-client/src/agent_info.rs new file mode 100644 index 0000000000..8275a41cb9 --- /dev/null +++ b/libdd-agent-client/src/agent_info.rs @@ -0,0 +1,32 @@ +// Copyright 2026-Present Datadog, Inc. https://www.datadoghq.com/ +// SPDX-License-Identifier: Apache-2.0 + +//! Types for [`crate::AgentClient::agent_info`]. + +/// Parsed response from a `GET /info` probe. +/// +/// Returned by [`crate::AgentClient::agent_info`]. Contains agent capabilities and the +/// headers that dd-trace-py currently processes via the side-effectful `process_info_headers` +/// function (`agent.py:17-23`) — here they are explicit typed fields instead. +#[derive(Debug, Clone)] +pub struct AgentInfo { + /// Available agent endpoints, e.g. `["/v0.4/traces", "/v0.5/traces"]`. + pub endpoints: Vec, + /// Whether the agent supports client-side P0 dropping. + pub client_drop_p0s: bool, + /// Raw agent configuration block. + pub config: serde_json::Value, + /// Agent version string, if reported. + pub version: Option, + /// Parsed from the `Datadog-Container-Tags-Hash` response header. + /// + /// Used by dd-trace-py to compute the base tag hash (`agent.py:17-23`). + pub container_tags_hash: Option, + /// Value of the `Datadog-Agent-State` response header from the last `/info` fetch. + /// + /// The agent updates this opaque token whenever its internal state changes (e.g. a + /// configuration reload). Clients that poll `/info` periodically can skip re-parsing + /// the response body by comparing this value to the one returned by the previous call + /// and only acting when it differs. + pub state_hash: Option, +} diff --git a/libdd-agent-client/src/builder.rs b/libdd-agent-client/src/builder.rs new file mode 100644 index 0000000000..76b6efe7fa --- /dev/null +++ b/libdd-agent-client/src/builder.rs @@ -0,0 +1,304 @@ +// Copyright 2026-Present Datadog, Inc. https://www.datadoghq.com/ +// SPDX-License-Identifier: Apache-2.0 + +//! Builder for [`crate::AgentClient`]. + +use std::collections::HashMap; +use std::time::Duration; + +use libdd_http_client::RetryConfig; + +use crate::{error::BuildError, language_metadata::LanguageMetadata, AgentClient}; + +/// Default timeout for agent requests: 2 000 ms. +/// +/// Matches dd-trace-py's `DEFAULT_TIMEOUT = 2.0 s` (`constants.py:97`). +pub const DEFAULT_TIMEOUT_MS: u64 = 2_000; + +/// Default retry configuration: 2 retries (3 total attempts), 100 ms initial delay, +/// exponential backoff with full jitter. +/// +/// This approximates dd-trace-py's `fibonacci_backoff_with_jitter` pattern used in +/// `writer.py:245-249`, `stats.py:123-126`, and `datastreams/processor.py:140-143`. +pub fn default_retry_config() -> RetryConfig { + RetryConfig::new() + .max_retries(2) + .initial_delay(Duration::from_millis(100)) + .with_jitter(true) +} + +/// Transport configuration for the agent client. +/// +/// Determines how the client connects to the Datadog agent (or an intake endpoint). +/// Set via [`AgentClientBuilder::transport`] or the convenience helpers +/// [`AgentClientBuilder::http`], [`AgentClientBuilder::https`], +/// [`AgentClientBuilder::unix_socket`], etc. +#[derive(Debug, Clone)] +pub enum AgentTransport { + /// HTTP over TCP to `http://{host}:{port}`. + Http { + /// Hostname or IP address. + host: String, + /// Port number. + port: u16, + }, + /// HTTPS over TCP to `https://{host}:{port}` (e.g. for intake endpoints). + Https { + /// Hostname or IP address. + host: String, + /// Port number. + port: u16, + }, + /// Unix Domain Socket. + /// + /// HTTP requests are still formed with `Host: localhost`; the socket path + /// governs only the transport layer. + #[cfg(unix)] + UnixSocket { + /// Filesystem path to the socket file. + path: std::path::PathBuf, + }, + /// Windows Named Pipe. + #[cfg(windows)] + NamedPipe { + /// Named pipe path, e.g. `\\.\pipe\DD_APM_DRIVER`. + path: std::ffi::OsString, + }, + /// Probe at build time: use UDS if the socket file exists, otherwise fall back to HTTP. + /// + /// Mirrors the auto-detect logic in dd-trace-py's `_agent.py:32-49`. + #[cfg(unix)] + AutoDetect { + /// UDS path to probe. + uds_path: std::path::PathBuf, + /// Fallback host when the socket is absent. + fallback_host: String, + /// Fallback port when the socket is absent (typically 8126). + fallback_port: u16, + }, +} + +impl Default for AgentTransport { + fn default() -> Self { + AgentTransport::Http { + host: "localhost".to_owned(), + port: 8126, + } + } +} + +/// Connection mode for the underlying HTTP client. +/// +/// # Correctness note +/// +/// The Datadog agent has a low keep-alive timeout that causes "pipe closed" errors on every +/// second connection when connection reuse is enabled. [`ClientMode::Periodic`] (the default) +/// disables connection pooling and is **correct** for all periodic-flush writers (traces, stats, +/// data streams). Only high-frequency continuous senders (e.g. a streaming profiling exporter) +/// should opt into [`ClientMode::Persistent`]. +#[derive(Debug, Clone, Default, PartialEq, Eq)] +pub enum ClientMode { + /// No connection pooling. Correct for periodic flushes to the agent. + #[default] + Periodic, + /// Keep connections alive across requests. + /// + /// Use only for high-frequency continuous senders. + Persistent, +} + +/// Builder for [`AgentClient`]. +/// +/// Obtain via [`AgentClient::builder`]. +/// +/// # Required fields +/// +/// - Transport: set via [`AgentClientBuilder::transport`] or a convenience method +/// ([`AgentClientBuilder::http`], [`AgentClientBuilder::https`], +/// [`AgentClientBuilder::unix_socket`], [`AgentClientBuilder::windows_named_pipe`], +/// [`AgentClientBuilder::auto_detect`]). +/// - [`AgentClientBuilder::language_metadata`]. +/// +/// # Agentless mode +/// +/// Call [`AgentClientBuilder::api_key`] with your Datadog API key and point the transport to +/// the intake endpoint via [`AgentClientBuilder::https`]. The client injects `dd-api-key` on +/// every request. +/// +/// # Testing +/// +/// Call [`AgentClientBuilder::test_token`] to inject `x-datadog-test-session-token` on every +/// request. This replaces dd-trace-py's `AgentWriter.set_test_session_token` (`writer.py:754-755`). +/// +/// # Fork safety +/// +/// The underlying `libdd-http-client` uses `hickory-dns` by default — an in-process, fork-safe +/// DNS resolver that avoids the class of bugs where a forked child inherits open sockets from a +/// parent's DNS thread pool. This is important for host processes that fork (Django, Flask, +/// Celery workers, PHP-FPM, etc.). +#[derive(Debug, Default)] +pub struct AgentClientBuilder { + transport: Option, + api_key: Option, + test_token: Option, + timeout: Option, + language: Option, + retry: Option, + client_mode: ClientMode, + extra_headers: HashMap, +} + +impl AgentClientBuilder { + /// Create a new builder with default settings. + pub fn new() -> Self { + Self::default() + } + + // ── Transport ───────────────────────────────────────────────────────────── + + /// Set the transport configuration. + pub fn transport(mut self, transport: AgentTransport) -> Self { + self.transport = Some(transport); + self + } + + /// Convenience: HTTP over TCP. + pub fn http(self, host: impl Into, port: u16) -> Self { + self.transport(AgentTransport::Http { + host: host.into(), + port, + }) + } + + /// Convenience: HTTPS over TCP. + pub fn https(self, host: impl Into, port: u16) -> Self { + self.transport(AgentTransport::Https { + host: host.into(), + port, + }) + } + + /// Convenience: Unix Domain Socket. + #[cfg(unix)] + pub fn unix_socket(self, path: impl Into) -> Self { + self.transport(AgentTransport::UnixSocket { path: path.into() }) + } + + /// Convenience: Windows Named Pipe. + #[cfg(windows)] + pub fn windows_named_pipe(self, path: impl Into) -> Self { + self.transport(AgentTransport::NamedPipe { path: path.into() }) + } + + /// Convenience: auto-detect transport (UDS if socket file exists, else HTTP). + /// + /// Mirrors the logic in dd-trace-py's `_agent.py:32-49`. + #[cfg(unix)] + pub fn auto_detect( + self, + uds_path: impl Into, + fallback_host: impl Into, + fallback_port: u16, + ) -> Self { + self.transport(AgentTransport::AutoDetect { + uds_path: uds_path.into(), + fallback_host: fallback_host.into(), + fallback_port, + }) + } + + // ── Authentication / routing ────────────────────────────────────────────── + + /// Set the Datadog API key (agentless mode). + /// + /// When set, `dd-api-key: ` is injected on every request. + /// Point the transport to the intake endpoint via [`AgentClientBuilder::https`]. + pub fn api_key(mut self, key: impl Into) -> Self { + self.api_key = Some(key.into()); + self + } + + /// Set the test session token. + /// + /// When set, `x-datadog-test-session-token: ` is injected on every request. + /// Replaces dd-trace-py's `AgentWriter.set_test_session_token` (`writer.py:754-755`). + pub fn test_token(mut self, token: impl Into) -> Self { + self.test_token = Some(token.into()); + self + } + + // ── Timeout / retries ───────────────────────────────────────────────────── + + /// Set the request timeout. + /// + /// Defaults to [`DEFAULT_TIMEOUT_MS`] (2 000 ms) when not set. + pub fn timeout(mut self, timeout: Duration) -> Self { + self.timeout = Some(timeout); + self + } + + /// Read the timeout from `DD_TRACE_AGENT_TIMEOUT_SECONDS`, falling back to + /// [`DEFAULT_TIMEOUT_MS`] if the variable is unset or unparseable. + pub fn timeout_from_env(self) -> Self { + todo!() + } + + /// Override the default retry configuration. + /// + /// Defaults to [`default_retry_config`]: 2 retries, 100 ms initial delay, exponential + /// backoff with full jitter. + pub fn retry(mut self, config: RetryConfig) -> Self { + self.retry = Some(config); + self + } + + // ── Language metadata ───────────────────────────────────────────────────── + + /// Set the language/runtime metadata injected into every request. + /// + /// Required. Drives `Datadog-Meta-Lang`, `Datadog-Meta-Lang-Version`, + /// `Datadog-Meta-Lang-Interpreter`, `Datadog-Meta-Tracer-Version`, and `User-Agent`. + pub fn language_metadata(mut self, meta: LanguageMetadata) -> Self { + self.language = Some(meta); + self + } + + // ── Connection pooling ──────────────────────────────────────────────────── + + /// Set the connection mode. Defaults to [`ClientMode::Periodic`]. + /// + /// See [`ClientMode`] for the correctness rationale behind the default. + pub fn client_mode(mut self, mode: ClientMode) -> Self { + self.client_mode = mode; + self + } + + // ── Compression ─────────────────────────────────────────────────────────── + // + // Not exposed in v1. Gzip compression (level 6, matching dd-trace-py's trace writer at + // `writer.py:490`) will be added in a follow-up once the core send paths are stable. + // Per-method defaults (e.g. unconditional gzip for `send_pipeline_stats`) are already + // baked in; only the opt-in client-level `gzip(level)` builder knob is deferred. + + // ── Extra headers ───────────────────────────────────────────────────────── + + /// Merge additional headers into every request. + /// + /// Intended for `_DD_TRACE_WRITER_ADDITIONAL_HEADERS` in dd-trace-py. + pub fn extra_headers(mut self, headers: HashMap) -> Self { + self.extra_headers = headers; + self + } + + // ── Build ───────────────────────────────────────────────────────────────── + + /// Build the [`AgentClient`]. + /// + /// # Errors + /// + /// - [`BuildError::MissingTransport`] — no transport was configured. + /// - [`BuildError::MissingLanguageMetadata`] — no language metadata was configured. + pub fn build(self) -> Result { + todo!() + } +} diff --git a/libdd-agent-client/src/client.rs b/libdd-agent-client/src/client.rs new file mode 100644 index 0000000000..cdcb8d6c23 --- /dev/null +++ b/libdd-agent-client/src/client.rs @@ -0,0 +1,132 @@ +// Copyright 2026-Present Datadog, Inc. https://www.datadoghq.com/ +// SPDX-License-Identifier: Apache-2.0 + +//! [`AgentClient`] and its send methods. + +use bytes::Bytes; + +use crate::{ + agent_info::AgentInfo, + builder::AgentClientBuilder, + error::SendError, + telemetry::TelemetryRequest, + traces::{AgentResponse, TraceFormat, TraceSendOptions}, +}; + +/// A Datadog-agent-specialized HTTP client. +/// +/// Wraps a configured [`libdd_http_client::HttpClient`] and injects Datadog-specific headers +/// automatically on every request: +/// +/// - Language metadata headers (`Datadog-Meta-Lang`, `Datadog-Meta-Lang-Version`, +/// `Datadog-Meta-Lang-Interpreter`, `Datadog-Meta-Tracer-Version`) from the [`LanguageMetadata`] +/// supplied at build time. +/// - `User-Agent` derived from [`LanguageMetadata::user_agent`]. +/// - Container/entity-ID headers (`Datadog-Container-Id`, `Datadog-Entity-ID`, +/// `Datadog-External-Env`) read from `/proc/self/cgroup` at startup, equivalent to dd-trace-py's +/// `container.update_headers()` (`container.py:157-183`). +/// - `dd-api-key` when an API key was set (agentless mode). +/// - `x-datadog-test-session-token` when a test token was set. +/// - Any extra headers registered via [`AgentClientBuilder::extra_headers`]. +/// +/// Obtain via [`AgentClient::builder`]. +/// +/// [`LanguageMetadata`]: crate::LanguageMetadata +pub struct AgentClient { + // Opaque — fields are an implementation detail. +} + +impl AgentClient { + /// Create a new [`AgentClientBuilder`]. + pub fn builder() -> AgentClientBuilder { + AgentClientBuilder::new() + } + + /// Send a serialised trace payload to the agent. + /// + /// # Automatically injected headers + /// + /// - `X-Datadog-Trace-Count: ` (per-payload — `writer.py:749-752`) + /// - `Datadog-Send-Real-Http-Status: true` — instructs the agent to return 429 when it drops a + /// payload, rather than silently returning 200. dd-trace-py never sets this header, causing + /// silent drops that are invisible to the caller. + /// - `Datadog-Client-Computed-Top-Level: yes` when [`TraceSendOptions::computed_top_level`] is + /// `true`. + /// - Language metadata headers + container headers (see type-level docs). + /// - `Content-Type` and endpoint path derived from `format`. + /// - `Content-Encoding: gzip` when compression is enabled. + /// + /// # Returns + /// + /// An [`AgentResponse`] with the HTTP status and the parsed `rate_by_service` sampling + /// rates from the agent response body (`writer.py:728-734`). + pub async fn send_traces( + &self, + payload: Bytes, + trace_count: usize, + format: TraceFormat, + opts: TraceSendOptions, + ) -> Result { + todo!() + } + + /// Send span stats (APM concentrator buckets) to `/v0.6/stats`. + /// + /// `Content-Type` is always `application/msgpack`. Replaces the manual + /// `get_connection` + raw `PUT` in `SpanStatsProcessor._flush_stats` (`stats.py:204-228`). + pub async fn send_stats(&self, payload: Bytes) -> Result<(), SendError> { + todo!() + } + + /// Send data-streams pipeline stats to `/v0.1/pipeline_stats`. + /// + /// The payload is **always** gzip-compressed regardless of the client-level compression + /// setting. This is a protocol requirement of the data-streams endpoint + /// (`datastreams/processor.py:132`) and must not be a caller responsibility. + pub async fn send_pipeline_stats(&self, payload: Bytes) -> Result<(), SendError> { + todo!() + } + + /// Send a telemetry event. + /// + /// Endpoint routing: + /// - Agent mode → `telemetry/proxy/api/v2/apmtelemetry` + /// - Agentless mode (API key set) → `api/v2/apmtelemetry` on the configured intake host + /// + /// Per-request headers `DD-Telemetry-Request-Type`, `DD-Telemetry-API-Version`, and + /// `DD-Telemetry-Debug-Enabled` are injected automatically from `req`, replacing the + /// manual construction in `_TelemetryClient.get_headers` (`telemetry/writer.py:111-117`). + pub async fn send_telemetry(&self, req: TelemetryRequest) -> Result<(), SendError> { + todo!() + } + + /// Send an event via the agent's EVP (Event Platform) proxy. + /// + /// The agent forwards the request to `.datadoghq.com`. `subdomain` + /// controls the target intake (injected as `X-Datadog-EVP-Subdomain`); `path` is the + /// endpoint on that intake (e.g. `/api/v2/exposures`). + /// + /// In dd-trace-py's openfeature writer both values are hardcoded constants + /// (`openfeature/writer.py:24-27`), but they are independent routing dimensions and + /// must both be supplied by the caller. + pub async fn send_evp_event( + &self, + subdomain: &str, + path: &str, + payload: Bytes, + content_type: &str, + ) -> Result<(), SendError> { + todo!() + } + + /// Probe `GET /info` and return parsed agent capabilities. + /// + /// Processes the `Datadog-Container-Tags-Hash` response header and exposes it as + /// [`AgentInfo::container_tags_hash`] rather than as a side-effect (as in dd-trace-py's + /// `process_info_headers` at `agent.py:17-23`). + /// + /// Returns `Ok(None)` when the agent returns 404 (remote-config / info not supported). + pub async fn agent_info(&self) -> Result, SendError> { + todo!() + } +} diff --git a/libdd-agent-client/src/error.rs b/libdd-agent-client/src/error.rs new file mode 100644 index 0000000000..e9e6dd9355 --- /dev/null +++ b/libdd-agent-client/src/error.rs @@ -0,0 +1,43 @@ +// Copyright 2026-Present Datadog, Inc. https://www.datadoghq.com/ +// SPDX-License-Identifier: Apache-2.0 + +//! Error types for [`crate::AgentClient`]. + +use bytes::Bytes; +use thiserror::Error; + +/// Errors that can occur when building an [`crate::AgentClient`]. +#[derive(Debug, Error)] +pub enum BuildError { + /// No transport was configured. + #[error("transport is required")] + MissingTransport, + /// No language metadata was configured. + #[error("language metadata is required")] + MissingLanguageMetadata, +} + +/// Errors that can occur when sending a request via [`crate::AgentClient`]. +#[derive(Debug, Error)] +pub enum SendError { + /// Connection refused, timeout, or I/O error. + #[error("transport error: {0}")] + Transport(#[source] std::io::Error), + /// The server returned an HTTP error status. Includes the raw status and body. + #[error("HTTP error {status}: {body:?}")] + HttpError { + /// HTTP status code returned by the server. + status: u16, + /// Raw response body. + body: Bytes, + }, + /// All retry attempts exhausted without a successful response. + #[error("retries exhausted: {last_error}")] + RetriesExhausted { + /// The last error encountered before giving up. + last_error: Box, + }, + /// Payload serialisation or compression failure. + #[error("encoding error: {0}")] + Encoding(String), +} diff --git a/libdd-agent-client/src/language_metadata.rs b/libdd-agent-client/src/language_metadata.rs new file mode 100644 index 0000000000..880231d639 --- /dev/null +++ b/libdd-agent-client/src/language_metadata.rs @@ -0,0 +1,59 @@ +// Copyright 2026-Present Datadog, Inc. https://www.datadoghq.com/ +// SPDX-License-Identifier: Apache-2.0 + +//! Language/runtime metadata injected into every outgoing request. + +/// Language and runtime metadata that is automatically injected into every request as +/// `Datadog-Meta-*` headers and drives the `User-Agent` string. +/// +/// | Header | Field | +/// |---------------------------------|--------------------| +/// | `Datadog-Meta-Lang` | `language` | +/// | `Datadog-Meta-Lang-Version` | `language_version` | +/// | `Datadog-Meta-Lang-Interpreter` | `interpreter` | +/// | `Datadog-Meta-Tracer-Version` | `tracer_version` | +/// +/// These four headers are today manually assembled in four separate places in dd-trace-py: +/// `writer.py:638-644`, `writer.py:785-792`, `stats.py:113-117`, and +/// `datastreams/processor.py:128-133`. A single `LanguageMetadata` instance replaces all of them. +/// +/// # `User-Agent` +/// +/// [`LanguageMetadata::user_agent`] produces the string passed to +/// `Endpoint::to_request_builder(user_agent)`, so the `User-Agent` and the `Datadog-Meta-*` +/// headers share a single source of truth. +#[derive(Debug, Clone)] +pub struct LanguageMetadata { + /// Language name, e.g. `"python"`. + pub language: String, + /// Language runtime version, e.g. `"3.12.1"`. + pub language_version: String, + /// Interpreter name, e.g. `"CPython"`. + pub interpreter: String, + /// Tracer library version, e.g. `"2.18.0"`. + pub tracer_version: String, +} + +impl LanguageMetadata { + /// Construct a new `LanguageMetadata`. + pub fn new( + language: impl Into, + language_version: impl Into, + interpreter: impl Into, + tracer_version: impl Into, + ) -> Self { + Self { + language: language.into(), + language_version: language_version.into(), + interpreter: interpreter.into(), + tracer_version: tracer_version.into(), + } + } + + /// Produces the `User-Agent` string passed to `Endpoint::to_request_builder()`. + /// + /// Format: `dd-trace-/`, e.g. `dd-trace-python/2.18.0`. + pub fn user_agent(&self) -> String { + format!("dd-trace-{}/{}", self.language, self.tracer_version) + } +} diff --git a/libdd-agent-client/src/lib.rs b/libdd-agent-client/src/lib.rs new file mode 100644 index 0000000000..f7414343dd --- /dev/null +++ b/libdd-agent-client/src/lib.rs @@ -0,0 +1,99 @@ +// Copyright 2026-Present Datadog, Inc. https://www.datadoghq.com/ +// SPDX-License-Identifier: Apache-2.0 + +//! `libdd-agent-client` — Datadog-agent-specialised HTTP client. +//! +//! This crate is **Milestone 2** of APMSP-2721 (libdd-http-client: Common HTTP Client for +//! Language Clients). It sits on top of the basic `libdd-http-client` primitives (Milestone 1) +//! and encapsulates all Datadog-agent-specific concerns, making them the **default** rather than +//! opt-in boilerplate that every subsystem must repeat. +//! +//! # What it replaces in dd-trace-py +//! +//! | Concern | dd-trace-py location | +//! |---------|----------------------| +//! | Language metadata headers | `writer.py:638-644`, `stats.py:113-117`, `datastreams/processor.py:128-133` | +//! | Container/entity-ID headers | `http.py:32-37`, `container.py:157-183` | +//! | Retry logic (fibonacci backoff) | `writer.py:245-249`, `stats.py:123-126`, `datastreams/processor.py:140-143` | +//! | Trace send with `X-Datadog-Trace-Count` | `writer.py:749-752` | +//! | `rate_by_service` parsing | `writer.py:728-734` | +//! | Stats send | `stats.py:204-228` | +//! | Pipeline stats send (always gzip) | `datastreams/processor.py:204-210` | +//! | Telemetry send with per-request headers | `telemetry/writer.py:111-129` | +//! | EVP event send | `openfeature/writer.py:114-117` | +//! | `GET /info` with typed result | `agent.py:17-46` | +//! +//! # Quick start +//! +//! ```rust,no_run +//! # async fn example() -> Result<(), libdd_agent_client::BuildError> { +//! use libdd_agent_client::{AgentClient, LanguageMetadata}; +//! +//! let client = AgentClient::builder() +//! .http("localhost", 8126) +//! .language_metadata(LanguageMetadata::new( +//! "python", "3.12.1", "CPython", "2.18.0", +//! )) +//! .build()?; +//! # Ok(()) +//! # } +//! ``` +//! +//! # Agentless mode +//! +//! Set an API key via [`builder::AgentClientBuilder::api_key`] and point the transport to the +//! intake endpoint: +//! +//! ```rust,no_run +//! # async fn example() -> Result<(), libdd_agent_client::BuildError> { +//! use libdd_agent_client::{AgentClient, LanguageMetadata}; +//! +//! let client = AgentClient::builder() +//! .https("public-trace-http-intake.logs.datadoghq.com", 443) +//! .api_key("my-api-key") +//! .language_metadata(LanguageMetadata::new( +//! "python", "3.12.1", "CPython", "2.18.0", +//! )) +//! .build()?; +//! # Ok(()) +//! # } +//! ``` +//! +//! # Unix Domain Socket +//! +//! ```rust,no_run +//! # #[cfg(unix)] +//! # async fn example() -> Result<(), libdd_agent_client::BuildError> { +//! use libdd_agent_client::{AgentClient, LanguageMetadata}; +//! +//! let client = AgentClient::builder() +//! .unix_socket("/var/run/datadog/apm.socket") +//! .language_metadata(LanguageMetadata::new( +//! "python", "3.12.1", "CPython", "2.18.0", +//! )) +//! .build()?; +//! # Ok(()) +//! # } +//! ``` +//! +//! # Fork safety +//! +//! The underlying `libdd-http-client` uses `hickory-dns` by default — an in-process, fork-safe +//! DNS resolver. This protects against the class of DNS bugs that can occur in forking processes +//! (Django workers, Celery, PHP-FPM, etc.). + +pub mod agent_info; +pub mod builder; +pub mod client; +pub mod error; +pub mod language_metadata; +pub mod telemetry; +pub mod traces; + +pub use agent_info::AgentInfo; +pub use builder::{AgentClientBuilder, AgentTransport, ClientMode}; +pub use client::AgentClient; +pub use error::{BuildError, SendError}; +pub use language_metadata::LanguageMetadata; +pub use telemetry::TelemetryRequest; +pub use traces::{AgentResponse, TraceFormat, TraceSendOptions}; diff --git a/libdd-agent-client/src/telemetry.rs b/libdd-agent-client/src/telemetry.rs new file mode 100644 index 0000000000..4d2c6403b4 --- /dev/null +++ b/libdd-agent-client/src/telemetry.rs @@ -0,0 +1,28 @@ +// Copyright 2026-Present Datadog, Inc. https://www.datadoghq.com/ +// SPDX-License-Identifier: Apache-2.0 + +//! Types specific to [`crate::AgentClient::send_telemetry`]. + +/// A single telemetry event to send via [`crate::AgentClient::send_telemetry`]. +/// +/// The three per-request headers — `DD-Telemetry-Request-Type`, `DD-Telemetry-API-Version`, and +/// `DD-Telemetry-Debug-Enabled` — are derived automatically from this struct, removing the +/// need for callers to build headers manually (as done in `telemetry/writer.py:111-117`). +/// +/// Endpoint routing (agent proxy vs. agentless intake) is resolved by the client based on +/// whether an API key was set at build time, replacing the ad-hoc logic at +/// `telemetry/writer.py:119-129`. +#[derive(Debug, Clone)] +pub struct TelemetryRequest { + /// Value for the `DD-Telemetry-Request-Type` header, e.g. `"app-started"`. + pub request_type: String, + /// Value for the `DD-Telemetry-API-Version` header, e.g. `"v2"`. + pub api_version: String, + /// When `true`, sets `DD-Telemetry-Debug-Enabled: true`. + pub debug: bool, + /// Pre-serialized JSON payload body. + /// + /// The caller is responsible for serializing the event body to JSON before constructing + /// this struct. The client sends these bytes as-is with `Content-Type: application/json`. + pub body: bytes::Bytes, +} diff --git a/libdd-agent-client/src/traces.rs b/libdd-agent-client/src/traces.rs new file mode 100644 index 0000000000..2db90c6d2b --- /dev/null +++ b/libdd-agent-client/src/traces.rs @@ -0,0 +1,51 @@ +// Copyright 2026-Present Datadog, Inc. https://www.datadoghq.com/ +// SPDX-License-Identifier: Apache-2.0 + +//! Types specific to [`crate::AgentClient::send_traces`]. + +use std::collections::HashMap; + +/// Wire format of the trace payload. +/// +/// Determines both the `Content-Type` header and the target endpoint. +/// +/// # Format selection +/// +/// The caller is currently responsible for choosing the format. In practice this means +/// starting with [`TraceFormat::MsgpackV5`] and downgrading to [`TraceFormat::MsgpackV4`] +/// when the agent returns 404 or 415 (e.g. on Windows, or when AppSec/IAST is active) — +/// the same sticky downgrade that dd-trace-py performs in `AgentWriter` (`writer.py`). +/// +/// In a future version this negotiation may be moved into the client itself so that format +/// selection becomes automatic and callers no longer need to track the downgrade state. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum TraceFormat { + /// `application/msgpack` to `/v0.5/traces`. Preferred format. + MsgpackV5, + /// `application/msgpack` to `/v0.4/traces`. Fallback for Windows / AppSec. + MsgpackV4, + /// `application/json` to `/v1/input`. Used in agentless mode. + JsonV1, +} + +/// Per-request options for [`crate::AgentClient::send_traces`]. +#[derive(Debug, Clone, Default)] +pub struct TraceSendOptions { + /// When `true`, appends `Datadog-Client-Computed-Top-Level: yes`. + /// + /// Signals to the agent that the client has already marked top-level spans, allowing the agent + /// to skip its own top-level computation. In dd-trace-py this header is always set + /// (`writer.py:643`); here it is opt-in so that callers that do not compute top-level spans + /// can omit it. + pub computed_top_level: bool, +} + +/// Parsed response from the agent after a successful trace submission. +#[derive(Debug, Clone)] +pub struct AgentResponse { + /// HTTP status code returned by the agent. + pub status: u16, + /// Per-service sampling rates parsed from the `rate_by_service` field of the agent response + /// body, if present. Mirrors the JSON parsing done in dd-trace-py at `writer.py:728-734`. + pub rate_by_service: Option>, +}