Skip to main content

vector/sinks/datadog/logs/
config.rs

1use std::{convert::TryFrom, sync::Arc};
2
3use indoc::indoc;
4use tower::ServiceBuilder;
5use vector_lib::{
6    config::proxy::ProxyConfig, configurable::configurable_component, schema::meaning,
7};
8use vrl::value::Kind;
9
10use hyper::{Body, client::connect::Connect};
11
12use super::{service::LogApiRetry, sink::LogSinkBuilder};
13use crate::{
14    common::datadog,
15    http::HttpClient,
16    schema,
17    sinks::{
18        datadog::{DatadogCommonConfig, LocalDatadogCommonConfig, logs::service::LogApiService},
19        prelude::*,
20        util::http::RequestConfig,
21    },
22    tls::{MaybeTlsSettings, TlsEnableableConfig},
23};
24
25// The Datadog API has a hard limit of 5MB for uncompressed payloads. Above this
26// threshold the API will toss results. We previously serialized Events as they
27// came in -- a very CPU intensive process -- and to avoid that we only batch up
28// to 750KB below the max and then build our payloads. This does mean that in
29// some situations we'll kick out over-large payloads -- for instance, a string
30// of escaped double-quotes -- but we believe this should be very rare in
31// practice.
32pub const MAX_PAYLOAD_BYTES: usize = 5_000_000;
33pub const BATCH_GOAL_BYTES: usize = 4_250_000;
34pub const BATCH_MAX_EVENTS: usize = 1_000;
35pub const BATCH_DEFAULT_TIMEOUT_SECS: f64 = 5.0;
36
37#[derive(Clone, Copy, Debug, Default)]
38pub struct DatadogLogsDefaultBatchSettings;
39
40impl SinkBatchSettings for DatadogLogsDefaultBatchSettings {
41    const MAX_EVENTS: Option<usize> = Some(BATCH_MAX_EVENTS);
42    const MAX_BYTES: Option<usize> = Some(BATCH_GOAL_BYTES);
43    const TIMEOUT_SECS: f64 = BATCH_DEFAULT_TIMEOUT_SECS;
44}
45
46/// Configuration for the `datadog_logs` sink.
47#[configurable_component(sink("datadog_logs", "Publish log events to Datadog."))]
48#[derive(Clone, Debug, Derivative)]
49#[derivative(Default)]
50#[serde(deny_unknown_fields)]
51pub struct DatadogLogsConfig {
52    #[serde(flatten)]
53    pub local_dd_common: LocalDatadogCommonConfig,
54
55    #[configurable(derived)]
56    #[derivative(Default(value = "default_compression()"))]
57    #[serde(default = "default_compression")]
58    pub compression: Option<Compression>,
59
60    #[configurable(derived)]
61    #[serde(default, skip_serializing_if = "crate::serde::is_default")]
62    pub encoding: Transformer,
63
64    #[configurable(derived)]
65    #[serde(default)]
66    pub batch: BatchConfig<DatadogLogsDefaultBatchSettings>,
67
68    #[configurable(derived)]
69    #[serde(default)]
70    pub request: RequestConfig,
71
72    /// When enabled this sink will normalize events to conform to the Datadog Agent standard. This
73    /// also sends requests to the logs backend with the `DD-PROTOCOL: agent-json` header. This bool
74    /// will be overridden as `true` if this header has already been set in the request.headers
75    /// configuration setting.
76    #[serde(default)]
77    pub conforms_as_agent: bool,
78}
79
80const fn default_compression() -> Option<Compression> {
81    Some(Compression::zstd_default())
82}
83
84impl GenerateConfig for DatadogLogsConfig {
85    fn generate_config() -> toml::Value {
86        toml::from_str(indoc! {r#"
87            default_api_key = "${DATADOG_API_KEY_ENV_VAR}"
88        "#})
89        .unwrap()
90    }
91}
92
93impl DatadogLogsConfig {
94    // TODO: We should probably hoist this type of base URI generation so that all DD sinks can
95    // utilize it, since it all follows the same pattern.
96    fn get_uri(&self, dd_common: &DatadogCommonConfig) -> http::Uri {
97        let base_url = dd_common
98            .endpoint
99            .clone()
100            .unwrap_or_else(|| format!("https://http-intake.logs.{}", dd_common.site));
101
102        http::Uri::try_from(format!("{base_url}/api/v2/logs")).expect("URI not valid")
103    }
104
105    pub fn get_protocol(&self, dd_common: &DatadogCommonConfig) -> String {
106        self.get_uri(dd_common)
107            .scheme_str()
108            .unwrap_or("http")
109            .to_string()
110    }
111
112    pub fn build_processor<C>(
113        &self,
114        dd_common: &DatadogCommonConfig,
115        client: HttpClient<Body, C>,
116        dd_evp_origin: String,
117    ) -> crate::Result<VectorSink>
118    where
119        C: Connect + Clone + Send + Sync + 'static,
120    {
121        let default_api_key: Arc<str> = Arc::from(dd_common.default_api_key.inner());
122        let request_limits = self.request.tower.into_settings();
123
124        // We forcefully cap the provided batch configuration to the size/log line limits imposed by
125        // the Datadog Logs API, but we still allow them to be lowered if need be.
126        let batch = self
127            .batch
128            .validate()?
129            .limit_max_bytes(BATCH_GOAL_BYTES)?
130            .limit_max_events(BATCH_MAX_EVENTS)?
131            .into_batcher_settings()?;
132
133        let headers = {
134            let mut request_headers = self.request.headers.clone();
135            if self.conforms_as_agent {
136                request_headers.insert(String::from("DD-PROTOCOL"), String::from("agent-json"));
137            }
138            request_headers
139        };
140
141        // conforms_as_agent is true if either the user supplied configuration parameter is enabled
142        // or the DD-PROTOCOL: agent-json header had already been manually set
143        let conforms_as_agent = if let Some(value) = headers.get("DD-PROTOCOL") {
144            value == "agent-json"
145        } else {
146            false
147        };
148
149        let service = ServiceBuilder::new()
150            .settings(request_limits, LogApiRetry)
151            .service(LogApiService::new(
152                client,
153                self.get_uri(dd_common),
154                headers,
155                dd_evp_origin,
156            )?);
157
158        let encoding = self.encoding.clone();
159        let protocol = self.get_protocol(dd_common);
160
161        let sink = LogSinkBuilder::new(
162            encoding,
163            service,
164            default_api_key,
165            batch,
166            protocol,
167            conforms_as_agent,
168        )
169        .compression(self.compression.or_else(default_compression).unwrap())
170        .build();
171
172        Ok(VectorSink::from_event_streamsink(sink))
173    }
174
175    pub fn create_client(&self, proxy: &ProxyConfig) -> crate::Result<HttpClient> {
176        let default_tls_config;
177
178        let tls_settings = MaybeTlsSettings::from_config(
179            Some(match self.local_dd_common.tls.as_ref() {
180                Some(config) => config,
181                None => {
182                    default_tls_config = TlsEnableableConfig::enabled();
183                    &default_tls_config
184                }
185            }),
186            false,
187        )?;
188        Ok(HttpClient::new(tls_settings, proxy)?)
189    }
190}
191
192#[async_trait::async_trait]
193#[typetag::serde(name = "datadog_logs")]
194impl SinkConfig for DatadogLogsConfig {
195    async fn build(&self, cx: SinkContext) -> crate::Result<(VectorSink, Healthcheck)> {
196        let client = self.create_client(&cx.proxy)?;
197        let global = cx.extra_context.get_or_default::<datadog::Options>();
198        let dd_common = self.local_dd_common.with_globals(global)?;
199
200        let healthcheck = dd_common.build_healthcheck(client.clone())?;
201
202        let sink = self.build_processor(&dd_common, client, cx.app_name_slug)?;
203
204        Ok((sink, healthcheck))
205    }
206
207    fn input(&self) -> Input {
208        let requirement = schema::Requirement::empty()
209            .optional_meaning(meaning::MESSAGE, Kind::bytes())
210            .optional_meaning(meaning::TIMESTAMP, Kind::timestamp())
211            .optional_meaning(meaning::HOST, Kind::bytes())
212            .optional_meaning(meaning::SOURCE, Kind::bytes())
213            .optional_meaning(meaning::SEVERITY, Kind::bytes())
214            .optional_meaning(meaning::SERVICE, Kind::bytes())
215            .optional_meaning(meaning::TRACE_ID, Kind::bytes());
216
217        Input::log().with_schema_requirement(requirement)
218    }
219
220    fn acknowledgements(&self) -> &AcknowledgementsConfig {
221        &self.local_dd_common.acknowledgements
222    }
223}
224
225#[cfg(test)]
226mod test {
227    use vector_lib::{
228        codecs::{JsonSerializerConfig, MetricTagValues, encoding::format::JsonSerializerOptions},
229        config::LogNamespace,
230    };
231
232    use super::*;
233    use crate::{codecs::EncodingConfigWithFraming, components::validation::prelude::*};
234
235    #[test]
236    fn generate_config() {
237        crate::test_util::test_generate_config::<DatadogLogsConfig>();
238    }
239
240    #[test]
241    fn compression_config_field() {
242        // Verify the default compression function returns zstd
243        assert_eq!(default_compression(), Some(Compression::zstd_default()));
244
245        // Test 1: Config deserialized without compression field gets zstd default
246        // (due to #[serde(default = "default_compression")])
247        let config_yaml = indoc! {r#"
248            default_api_key: "test_key"
249        "#};
250
251        let config: DatadogLogsConfig = serde_yaml::from_str(config_yaml).unwrap();
252        // The serde default applies immediately during deserialization
253        assert!(matches!(config.compression, Some(Compression::Zstd(_))));
254
255        // Test 2: When explicitly set to "none", it should be Some(Compression::None)
256        let config_yaml_with_none = indoc! {r#"
257            default_api_key: "test_key"
258            compression: "none"
259        "#};
260
261        let config_no_compression: DatadogLogsConfig =
262            serde_yaml::from_str(config_yaml_with_none).unwrap();
263        assert_eq!(config_no_compression.compression, Some(Compression::None));
264
265        // Test 3: When explicitly set to "zstd", it should be Some(Compression::Zstd)
266        let config_yaml_with_zstd = indoc! {r#"
267            default_api_key: "test_key"
268            compression: "zstd"
269        "#};
270
271        let config_zstd: DatadogLogsConfig = serde_yaml::from_str(config_yaml_with_zstd).unwrap();
272        assert!(matches!(
273            config_zstd.compression,
274            Some(Compression::Zstd(_))
275        ));
276
277        // Test 4: When explicitly set to "gzip", it should be Some(Compression::Gzip)
278        let config_yaml_with_gzip = indoc! {r#"
279            default_api_key: "test_key"
280            compression: "gzip"
281        "#};
282
283        let config_gzip: DatadogLogsConfig = serde_yaml::from_str(config_yaml_with_gzip).unwrap();
284        assert!(matches!(
285            config_gzip.compression,
286            Some(Compression::Gzip(_))
287        ));
288    }
289
290    impl ValidatableComponent for DatadogLogsConfig {
291        fn validation_configuration() -> ValidationConfiguration {
292            let endpoint = "http://127.0.0.1:9005".to_string();
293            let config = Self {
294                local_dd_common: LocalDatadogCommonConfig {
295                    endpoint: Some(endpoint.clone()),
296                    default_api_key: Some("unused".to_string().into()),
297                    ..Default::default()
298                },
299                // Disable compression for validation tests to ensure byte counting is accurate
300                compression: Some(Compression::None),
301                ..Default::default()
302            };
303
304            let encoding = EncodingConfigWithFraming::new(
305                None,
306                JsonSerializerConfig::new(MetricTagValues::Full, JsonSerializerOptions::default())
307                    .into(),
308                config.encoding.clone(),
309            );
310
311            let logs_endpoint = format!("{endpoint}/api/v2/logs");
312
313            let external_resource = ExternalResource::new(
314                ResourceDirection::Push,
315                HttpResourceConfig::from_parts(
316                    http::Uri::try_from(&logs_endpoint).expect("should not fail to parse URI"),
317                    None,
318                ),
319                encoding,
320            );
321
322            ValidationConfiguration::from_sink(
323                Self::NAME,
324                LogNamespace::Legacy,
325                vec![ComponentTestCaseConfig::from_sink(
326                    config,
327                    None,
328                    Some(external_resource),
329                )],
330            )
331        }
332    }
333
334    register_validatable_component!(DatadogLogsConfig);
335}