Skip to main content

vector/sinks/datadog/
mod.rs

1use futures_util::FutureExt;
2use http::{Request, StatusCode, Uri};
3use hyper::{body::Body, client::connect::Connect};
4use snafu::Snafu;
5use vector_lib::{
6    config::AcknowledgementsConfig, configurable::configurable_component,
7    sensitive_string::SensitiveString, tls::TlsEnableableConfig,
8};
9
10use super::Healthcheck;
11use crate::{
12    common::datadog,
13    http::{HttpClient, HttpError},
14    sinks::{HealthcheckError, util::HttpEndpoint},
15};
16
17#[cfg(feature = "sinks-datadog_events")]
18pub mod events;
19#[cfg(feature = "sinks-datadog_logs")]
20pub mod logs;
21#[cfg(feature = "sinks-datadog_metrics")]
22pub mod metrics;
23#[cfg(any(
24    all(feature = "sinks-datadog_logs", feature = "test-utils"),
25    all(feature = "sinks-datadog_metrics", feature = "test-utils"),
26    all(feature = "sinks-datadog_logs", test),
27    all(feature = "sinks-datadog_metrics", test)
28))]
29pub mod test_utils;
30#[cfg(feature = "sinks-datadog_traces")]
31pub mod traces;
32
33/// Shared configuration for Datadog sinks.
34/// Contains the maximum set of common settings that applies to all DD sink components.
35#[configurable_component]
36#[derive(Clone, Debug, Default)]
37#[serde(deny_unknown_fields)]
38pub struct LocalDatadogCommonConfig {
39    /// The endpoint to send observability data to.
40    ///
41    /// The endpoint must be an absolute HTTP(S) URL. A missing scheme defaults
42    /// to `https`. The API path should NOT be specified as this is handled by
43    /// the sink.
44    ///
45    /// If set, overrides the `site` option.
46    #[configurable(metadata(docs::examples = "http://127.0.0.1:8080"))]
47    #[configurable(metadata(docs::examples = "http://example.com:12345"))]
48    #[serde(default)]
49    pub endpoint: Option<String>,
50
51    /// The Datadog [site][dd_site] to send observability data to.
52    ///
53    /// This value can also be set by specifying the `DD_SITE` environment variable.
54    /// The value specified here takes precedence over the environment variable.
55    ///
56    /// If not specified by the environment variable, a default value of
57    /// `datadoghq.com` is taken.
58    ///
59    /// [dd_site]: https://docs.datadoghq.com/getting_started/site
60    #[configurable(metadata(docs::examples = "us3.datadoghq.com"))]
61    #[configurable(metadata(docs::examples = "datadoghq.eu"))]
62    pub site: Option<String>,
63
64    /// The default Datadog [API key][api_key] to use in authentication of HTTP requests.
65    ///
66    /// If an event has a Datadog [API key][api_key] set explicitly in its metadata, it takes
67    /// precedence over this setting.
68    ///
69    /// This value can also be set by specifying the `DD_API_KEY` environment variable.
70    /// The value specified here takes precedence over the environment variable.
71    ///
72    /// [api_key]: https://docs.datadoghq.com/api/?lang=bash#authentication
73    /// [global_options]: /docs/reference/configuration/global-options/#datadog
74    #[configurable(metadata(docs::examples = "${DATADOG_API_KEY_ENV_VAR}"))]
75    #[configurable(metadata(docs::examples = "ef8d5de700e7989468166c40fc8a0ccd"))]
76    pub default_api_key: Option<SensitiveString>,
77
78    #[configurable(derived)]
79    #[serde(default)]
80    pub tls: Option<TlsEnableableConfig>,
81
82    #[configurable(derived)]
83    #[serde(
84        default,
85        deserialize_with = "crate::serde::bool_or_struct",
86        skip_serializing_if = "crate::serde::is_default"
87    )]
88    pub acknowledgements: AcknowledgementsConfig,
89}
90
91impl LocalDatadogCommonConfig {
92    pub fn new(
93        endpoint: Option<String>,
94        site: Option<String>,
95        default_api_key: Option<SensitiveString>,
96    ) -> Self {
97        Self {
98            endpoint,
99            site,
100            default_api_key,
101            ..Default::default()
102        }
103    }
104
105    pub fn with_globals(
106        &self,
107        config: datadog::Options,
108    ) -> Result<DatadogCommonConfig, ConfigurationError> {
109        Ok(DatadogCommonConfig {
110            endpoint: self.endpoint.clone(),
111            site: self.site.clone().unwrap_or(config.site),
112            default_api_key: self
113                .default_api_key
114                .clone()
115                .or(config.api_key)
116                .ok_or(ConfigurationError::ApiKeyRequired)?,
117            acknowledgements: self.acknowledgements,
118        })
119    }
120}
121
122#[derive(Debug, Snafu, PartialEq, Eq)]
123pub enum ConfigurationError {
124    #[snafu(display("API Key must be specified."))]
125    ApiKeyRequired,
126}
127
128#[derive(Clone, Debug, Default)]
129pub struct DatadogCommonConfig {
130    pub endpoint: Option<String>,
131    pub site: String,
132    pub default_api_key: SensitiveString,
133    pub acknowledgements: AcknowledgementsConfig,
134}
135
136impl DatadogCommonConfig {
137    /// Returns a `Healthcheck` which is a future that will be used to ensure the
138    /// `<site>/api/v1/validate` endpoint is reachable.
139    pub fn build_healthcheck<C>(&self, client: HttpClient<Body, C>) -> crate::Result<Healthcheck>
140    where
141        C: Connect + Clone + Send + Sync + 'static,
142    {
143        let validate_endpoint = self.get_api_endpoint("/api/v1/validate")?;
144
145        let api_key: String = self.default_api_key.clone().into();
146
147        Ok(build_healthcheck_future(client, validate_endpoint, api_key).boxed())
148    }
149
150    /// Gets the API endpoint with a given suffix path.
151    ///
152    /// If `endpoint` is not specified, we fallback to `site`. A missing scheme
153    /// is defaulted to `https`, so the healthcheck and data endpoints agree on
154    /// the scheme even for a scheme-less custom endpoint.
155    fn get_api_endpoint(&self, path: &str) -> crate::Result<Uri> {
156        let base = datadog::get_api_base_endpoint(self.endpoint.as_deref(), self.site.as_str());
157        let endpoint = HttpEndpoint::parse(&base)?.append_path(path)?;
158        Ok(endpoint.into_uri())
159    }
160}
161
162/// Makes a GET HTTP request to `<site>/api/v1/validate` using the provided client and API key.
163async fn build_healthcheck_future<C>(
164    client: HttpClient<Body, C>,
165    validate_endpoint: Uri,
166    api_key: String,
167) -> crate::Result<()>
168where
169    C: Connect + Clone + Send + Sync + 'static,
170{
171    let request = Request::get(validate_endpoint)
172        .header("DD-API-KEY", api_key)
173        .body(hyper::Body::empty())
174        .map_err(|e| format!("Failed to make HTTP(S) request: {e:?}"))?;
175
176    let response = client.send(request).await?;
177
178    match response.status() {
179        StatusCode::OK => Ok(()),
180        other => Err(HealthcheckError::UnexpectedStatus { status: other }.into()),
181    }
182}
183
184#[derive(Debug, Snafu)]
185pub enum DatadogApiError {
186    #[snafu(display("Failed to make HTTP(S) request: {}", error))]
187    HttpError { error: HttpError },
188    #[snafu(display("Client request was not valid for unknown reasons."))]
189    BadRequest,
190    #[snafu(display("Client request was unauthorized."))]
191    Unauthorized,
192    #[snafu(display("Client request was forbidden."))]
193    Forbidden,
194    #[snafu(display("Client request timed out."))]
195    RequestTimeout,
196    #[snafu(display("Client sent a payload that is too large."))]
197    PayloadTooLarge,
198    #[snafu(display("Client sent too many requests (rate limiting)."))]
199    TooManyRequests,
200    #[snafu(display("Client request was invalid."))]
201    ClientError,
202    #[snafu(display("Server responded with an error."))]
203    ServerError,
204}
205
206impl DatadogApiError {
207    /// Common DatadogApiError handling for HTTP Responses.
208    /// Returns Ok(response) if the response was Ok/Accepted.
209    pub fn from_result(
210        result: Result<http::Response<Body>, HttpError>,
211    ) -> Result<http::Response<Body>, DatadogApiError> {
212        match result {
213            Ok(response) => {
214                match response.status() {
215                    // From https://docs.datadoghq.com/api/latest/logs/:
216                    //
217                    // The status codes answered by the HTTP API are:
218                    // 200: OK (v1)
219                    // 202: Accepted (v2)
220                    // 400: Bad request (likely an issue in the payload
221                    //      formatting)
222                    // 401: Unauthorized (likely a missing API Key))
223                    // 403: Permission issue (likely using an invalid API Key)
224                    // 408: Request Timeout, request should be retried after some
225                    // 413: Payload too large (batch is above 5MB uncompressed)
226                    // 429: Too Many Requests, request should be retried after some time
227                    // 500: Internal Server Error, the server encountered an unexpected condition
228                    //      that prevented it from fulfilling the request, request should be
229                    //      retried after some time
230                    // 503: Service Unavailable, the server is not ready to handle the request
231                    //      probably because it is overloaded, request should be retried after some time
232                    s if s.is_success() => Ok(response),
233                    StatusCode::BAD_REQUEST => Err(DatadogApiError::BadRequest),
234                    StatusCode::UNAUTHORIZED => Err(DatadogApiError::Unauthorized),
235                    StatusCode::FORBIDDEN => Err(DatadogApiError::Forbidden),
236                    StatusCode::REQUEST_TIMEOUT => Err(DatadogApiError::RequestTimeout),
237                    StatusCode::PAYLOAD_TOO_LARGE => Err(DatadogApiError::PayloadTooLarge),
238                    StatusCode::TOO_MANY_REQUESTS => Err(DatadogApiError::TooManyRequests),
239                    s if s.is_client_error() => Err(DatadogApiError::ClientError),
240                    _ => Err(DatadogApiError::ServerError),
241                }
242            }
243            Err(error) => Err(DatadogApiError::HttpError { error }),
244        }
245    }
246
247    pub const fn is_retriable(&self) -> bool {
248        match self {
249            // This retry logic will be expanded further, but specifically retrying unauthorized
250            // requests and lower level HttpErrors for now.
251            // I verified using `curl` that `403` is the response code for this.
252            //
253            // https://github.com/vectordotdev/vector/issues/10870
254            // https://github.com/vectordotdev/vector/issues/12220
255            DatadogApiError::HttpError { error } => error.is_retriable(),
256            DatadogApiError::BadRequest | DatadogApiError::PayloadTooLarge => false,
257            DatadogApiError::ServerError
258            | DatadogApiError::ClientError
259            | DatadogApiError::Unauthorized
260            | DatadogApiError::Forbidden
261            | DatadogApiError::RequestTimeout
262            | DatadogApiError::TooManyRequests => true,
263        }
264    }
265}
266
267#[cfg(test)]
268mod tests {
269    use super::*;
270
271    #[test]
272    fn local_config_with_no_overrides() {
273        let local = LocalDatadogCommonConfig::new(
274            None,
275            Some("potato.com".into()),
276            Some("key".to_string().into()),
277        );
278        let global = datadog::Options {
279            api_key: Some("more key".to_string().into()),
280            site: "tomato.com".into(),
281        };
282
283        let overridden = local.with_globals(global).unwrap();
284
285        assert_eq!(None, overridden.endpoint);
286        assert_eq!("potato.com".to_string(), overridden.site);
287        assert_eq!(
288            SensitiveString::from("key".to_string()),
289            overridden.default_api_key
290        );
291    }
292
293    #[test]
294    fn local_config_with_overrides() {
295        let local = LocalDatadogCommonConfig::new(None, None, None);
296        let global = datadog::Options {
297            api_key: Some("more key".to_string().into()),
298            site: "tomato.com".into(),
299        };
300
301        let overridden = local.with_globals(global).unwrap();
302
303        assert_eq!(None, overridden.endpoint);
304        assert_eq!("tomato.com".to_string(), overridden.site);
305        assert_eq!(
306            SensitiveString::from("more key".to_string()),
307            overridden.default_api_key
308        );
309    }
310
311    #[test]
312    fn no_api_key() {
313        let local = LocalDatadogCommonConfig::new(None, None, None);
314        let global = datadog::Options {
315            api_key: None,
316            site: "tomato.com".into(),
317        };
318
319        let error = local.with_globals(global).unwrap_err();
320        assert_eq!(ConfigurationError::ApiKeyRequired, error);
321    }
322
323    #[test]
324    fn get_api_endpoint_defaults_missing_scheme_to_https() {
325        let config = DatadogCommonConfig {
326            endpoint: Some("localhost:8080".to_string()),
327            site: "datadoghq.com".to_string(),
328            default_api_key: SensitiveString::from("key".to_string()),
329            acknowledgements: Default::default(),
330        };
331        assert_eq!(
332            config
333                .get_api_endpoint("/api/v1/validate")
334                .unwrap()
335                .to_string(),
336            "https://localhost:8080/api/v1/validate"
337        );
338        // The default site-based endpoint keeps its scheme.
339        let default = DatadogCommonConfig {
340            endpoint: None,
341            site: "datadoghq.com".to_string(),
342            default_api_key: SensitiveString::from("key".to_string()),
343            acknowledgements: Default::default(),
344        };
345        assert_eq!(
346            default
347                .get_api_endpoint("/api/v1/validate")
348                .unwrap()
349                .to_string(),
350            "https://api.datadoghq.com/api/v1/validate"
351        );
352    }
353}