Skip to main content

vector/sinks/kafka/
config.rs

1use std::{collections::HashMap, time::Duration};
2
3use futures::FutureExt;
4use rdkafka::ClientConfig;
5use serde_with::serde_as;
6use vector_lib::{
7    codecs::JsonSerializerConfig, configurable::configurable_component,
8    lookup::lookup_v2::ConfigTargetPath,
9};
10use vrl::value::Kind;
11
12use crate::{
13    config::{DynValidatedSink, ValidatedSink},
14    kafka::{KafkaAuthConfig, KafkaCompression},
15    serde::json::to_string,
16    sinks::{
17        kafka::sink::{KafkaSink, healthcheck},
18        prelude::*,
19    },
20    template::ConfinementConfig,
21};
22
23/// Configuration for the `kafka` sink.
24#[serde_as]
25#[configurable_component(sink(
26    "kafka",
27    "Publish observability event data to Apache Kafka topics."
28))]
29#[derive(Clone, Debug)]
30#[serde(deny_unknown_fields)]
31pub struct KafkaSinkConfig {
32    /// A comma-separated list of Kafka bootstrap servers.
33    ///
34    /// These are the servers in a Kafka cluster that a client should use to bootstrap its
35    /// connection to the cluster, allowing discovery of all the other hosts in the cluster.
36    ///
37    /// Must be in the form of `host:port`, and comma-separated.
38    #[configurable(metadata(docs::examples = "10.14.22.123:9092,10.14.23.332:9092"))]
39    pub bootstrap_servers: String,
40
41    /// The Kafka topic name to write events to.
42    #[configurable(metadata(docs::templateable))]
43    #[configurable(metadata(
44        docs::examples = "topic-1234",
45        docs::examples = "logs-{{unit}}-%Y-%m-%d"
46    ))]
47    pub topic: Template,
48
49    /// The topic name to use for healthcheck. If omitted, `topic` is used.
50    /// This option helps prevent healthcheck warnings when `topic` is templated.
51    ///
52    /// It is ignored when healthcheck is disabled.
53    pub healthcheck_topic: Option<String>,
54
55    /// The log field name or tag key to use for the topic key.
56    ///
57    /// If the field does not exist in the log or in the tags, a blank value is used. If
58    /// unspecified, the key is not sent.
59    ///
60    /// Kafka uses a hash of the key to choose the partition or uses round-robin if the record has
61    /// no key.
62    #[configurable(metadata(docs::examples = "user_id"))]
63    #[configurable(metadata(docs::examples = ".my_topic"))]
64    #[configurable(metadata(docs::examples = "%my_topic"))]
65    pub key_field: Option<ConfigTargetPath>,
66
67    #[configurable(derived)]
68    pub encoding: EncodingConfig,
69
70    // These batching options will **not** override librdkafka_options values.
71    #[configurable(derived)]
72    #[serde(default)]
73    pub batch: BatchConfig<NoDefaultsBatchSettings>,
74
75    #[configurable(derived)]
76    #[serde(default)]
77    pub compression: KafkaCompression,
78
79    #[configurable(derived)]
80    #[serde(flatten)]
81    pub auth: KafkaAuthConfig,
82
83    /// Default timeout, in milliseconds, for network requests.
84    #[serde_as(as = "serde_with::DurationMilliSeconds<u64>")]
85    #[serde(default = "default_socket_timeout_ms")]
86    #[configurable(metadata(docs::examples = 30000, docs::examples = 60000))]
87    #[configurable(metadata(docs::human_name = "Socket Timeout"))]
88    pub socket_timeout_ms: Duration,
89
90    /// Local message timeout, in milliseconds.
91    #[serde_as(as = "serde_with::DurationMilliSeconds<u64>")]
92    #[configurable(metadata(docs::examples = 150000, docs::examples = 450000))]
93    #[serde(default = "default_message_timeout_ms")]
94    #[configurable(metadata(docs::human_name = "Message Timeout"))]
95    pub message_timeout_ms: Duration,
96
97    /// The time window used for the `rate_limit_num` option.
98    #[configurable(metadata(docs::type_unit = "seconds"))]
99    #[configurable(metadata(docs::human_name = "Rate Limit Duration"))]
100    #[serde(default = "default_rate_limit_duration_secs")]
101    pub rate_limit_duration_secs: u64,
102
103    /// The maximum number of requests allowed within the `rate_limit_duration_secs` time window.
104    #[configurable(metadata(docs::type_unit = "requests"))]
105    #[configurable(metadata(docs::human_name = "Rate Limit Number"))]
106    #[serde(default = "default_rate_limit_num")]
107    pub rate_limit_num: u64,
108
109    /// A map of advanced options to pass directly to the underlying `librdkafka` client.
110    ///
111    /// For more information on configuration options, see [Configuration properties][config_props_docs].
112    ///
113    /// [config_props_docs]: https://github.com/edenhill/librdkafka/blob/master/CONFIGURATION.md
114    #[serde(default)]
115    #[configurable(metadata(docs::examples = "example_librdkafka_options()"))]
116    #[configurable(metadata(
117        docs::additional_props_description = "A librdkafka configuration option."
118    ))]
119    pub librdkafka_options: HashMap<String, String>,
120
121    /// The log field name to use for the Kafka headers.
122    ///
123    /// If omitted, no headers are written.
124    #[serde(alias = "headers_field")] // accidentally released as `headers_field` in 0.18
125    #[configurable(metadata(docs::examples = "headers"))]
126    pub headers_key: Option<ConfigTargetPath>,
127
128    #[configurable(derived)]
129    #[serde(
130        default,
131        deserialize_with = "crate::serde::bool_or_struct",
132        skip_serializing_if = "crate::serde::is_default"
133    )]
134    pub acknowledgements: AcknowledgementsConfig,
135
136    #[configurable(derived)]
137    #[serde(flatten)]
138    pub confinement: ConfinementConfig,
139}
140
141const fn default_socket_timeout_ms() -> Duration {
142    Duration::from_millis(60000) // default in librdkafka
143}
144
145const fn default_message_timeout_ms() -> Duration {
146    Duration::from_millis(300000) // default in librdkafka
147}
148
149const fn default_rate_limit_duration_secs() -> u64 {
150    1
151}
152
153const fn default_rate_limit_num() -> u64 {
154    i64::MAX as u64 // i64 avoids TOML deserialize issue
155}
156
157fn example_librdkafka_options() -> HashMap<String, String> {
158    HashMap::<_, _>::from_iter([
159        ("client.id".to_string(), "${ENV_VAR}".to_string()),
160        ("fetch.error.backoff.ms".to_string(), "1000".to_string()),
161        ("socket.send.buffer.bytes".to_string(), "100".to_string()),
162    ])
163}
164
165impl KafkaSinkConfig {
166    pub(crate) fn to_rdkafka(&self) -> crate::Result<ClientConfig> {
167        self.validate_batch_librdkafka_conflicts()?;
168
169        let mut client_config = ClientConfig::new();
170        client_config
171            .set("bootstrap.servers", &self.bootstrap_servers)
172            .set(
173                "socket.timeout.ms",
174                self.socket_timeout_ms.as_millis().to_string(),
175            )
176            .set("statistics.interval.ms", "1000");
177
178        self.auth.apply(&mut client_config)?;
179
180        // All batch options are producer only.
181        client_config
182            .set("compression.codec", to_string(self.compression))
183            .set(
184                "message.timeout.ms",
185                self.message_timeout_ms.as_millis().to_string(),
186            );
187
188        if let Some(value) = self.batch.timeout_secs {
189            // Delay in milliseconds to wait for messages in the producer queue to accumulate before
190            // constructing message batches (MessageSets) to transmit to brokers. A higher value
191            // allows larger and more effective (less overhead, improved compression) batches of
192            // messages to accumulate at the expense of increased message delivery latency.
193            // Type: float
194            let key = "queue.buffering.max.ms";
195            debug!(
196                librdkafka_option = key,
197                batch_option = "timeout_secs",
198                value,
199                "Applying batch option as librdkafka option."
200            );
201            client_config.set(key, (value * 1000.0).round().to_string());
202        }
203        if let Some(value) = self.batch.max_events {
204            // Maximum number of messages batched in one MessageSet. The total MessageSet size is
205            // also limited by batch.size and message.max.bytes.
206            // Type: integer
207            let key = "batch.num.messages";
208            debug!(
209                librdkafka_option = key,
210                batch_option = "max_events",
211                value,
212                "Applying batch option as librdkafka option."
213            );
214            client_config.set(key, value.to_string());
215        }
216        if let Some(value) = self.batch.max_bytes {
217            // Maximum size (in bytes) of all messages batched in one MessageSet, including protocol
218            // framing overhead. This limit is applied after the first message has been added to the
219            // batch, regardless of the first message's size, this is to ensure that messages that
220            // exceed batch.size are produced. The total MessageSet size is also limited by
221            // batch.num.messages and message.max.bytes.
222            // Type: integer
223            let key = "batch.size";
224            debug!(
225                librdkafka_option = key,
226                batch_option = "max_bytes",
227                value,
228                "Applying batch option as librdkafka option."
229            );
230            client_config.set(key, value.to_string());
231        }
232
233        for (key, value) in self.librdkafka_options.iter() {
234            debug!(option = %key, value = %value, "Setting librdkafka option.");
235            client_config.set(key.as_str(), value.as_str());
236        }
237
238        Ok(client_config)
239    }
240
241    /// Validate that no Vector batch option conflicts with a corresponding
242    /// `librdkafka_options` key.
243    ///
244    /// `to_rdkafka` maps each batch option to a specific librdkafka option and
245    /// refuses to set both. This is a pure configuration error, so it is checked
246    /// here (and reused by `to_rdkafka`) without building a producer.
247    fn validate_batch_librdkafka_conflicts(&self) -> crate::Result<()> {
248        if let Some(value) = self.batch.timeout_secs {
249            Self::ensure_no_librdkafka_conflict(
250                "batch.timeout_secs",
251                "queue.buffering.max.ms",
252                value,
253                &self.librdkafka_options,
254            )?;
255        }
256        if let Some(value) = self.batch.max_events {
257            Self::ensure_no_librdkafka_conflict(
258                "batch.max_events",
259                "batch.num.messages",
260                value,
261                &self.librdkafka_options,
262            )?;
263        }
264        if let Some(value) = self.batch.max_bytes {
265            Self::ensure_no_librdkafka_conflict(
266                "batch.max_bytes",
267                "batch.size",
268                value,
269                &self.librdkafka_options,
270            )?;
271        }
272        Ok(())
273    }
274
275    /// Reject a Vector batch option that would overwrite a corresponding
276    /// `librdkafka_options` key.
277    fn ensure_no_librdkafka_conflict(
278        batch_option: &str,
279        key: &str,
280        value: impl std::fmt::Display,
281        librdkafka_options: &HashMap<String, String>,
282    ) -> crate::Result<()> {
283        if let Some(val) = librdkafka_options.get(key) {
284            return Err(format!(
285                "Batching setting `{batch_option}` sets `librdkafka_options.{key}={value}`.\
286                The config already sets this as `librdkafka_options.{key}={val}`.\
287                Please delete one."
288            )
289            .into());
290        }
291        Ok(())
292    }
293}
294
295impl GenerateConfig for KafkaSinkConfig {
296    fn generate_config() -> serde_json::Value {
297        serde_json::to_value(Self {
298            bootstrap_servers: "10.14.22.123:9092,10.14.23.332:9092".to_owned(),
299            topic: Template::try_from("topic-1234".to_owned()).unwrap(),
300            healthcheck_topic: None,
301            key_field: Some(ConfigTargetPath::try_from("user_id".to_owned()).unwrap()),
302            encoding: JsonSerializerConfig::default().into(),
303            batch: Default::default(),
304            compression: KafkaCompression::None,
305            auth: Default::default(),
306            socket_timeout_ms: default_socket_timeout_ms(),
307            message_timeout_ms: default_message_timeout_ms(),
308            rate_limit_duration_secs: default_rate_limit_duration_secs(),
309            rate_limit_num: default_rate_limit_num(),
310            librdkafka_options: Default::default(),
311            headers_key: None,
312            acknowledgements: Default::default(),
313            confinement: ConfinementConfig::default(),
314        })
315        .unwrap()
316    }
317}
318
319#[async_trait::async_trait]
320#[typetag::serde(name = "kafka")]
321impl SinkConfig for KafkaSinkConfig {
322    fn confinement_config(&self) -> Option<&crate::template::ConfinementConfig> {
323        Some(&self.confinement)
324    }
325
326    fn input(&self) -> Input {
327        let requirements = Requirement::empty().optional_meaning("timestamp", Kind::timestamp());
328
329        Input::new(self.encoding.config().input_type()).with_schema_requirement(requirements)
330    }
331
332    fn acknowledgements(&self) -> &AcknowledgementsConfig {
333        &self.acknowledgements
334    }
335
336    fn as_dyn_validated(&self) -> Option<&dyn DynValidatedSink> {
337        Some(self)
338    }
339}
340
341#[derive(Clone, Debug)]
342pub struct ValidatedKafkaSink {
343    topic: ConfinedTemplate,
344}
345
346#[async_trait::async_trait]
347impl ValidatedSink for KafkaSinkConfig {
348    type Validated = ValidatedKafkaSink;
349    fn validate(&self) -> crate::Result<ValidatedKafkaSink> {
350        // Build the librdkafka ClientConfig (pure: just key-value pairs) to
351        // surface batch/librdkafka conflicts. Native config creation — which
352        // can load `plugin.library.paths` and run plugin initialization — is
353        // deferred to `build()` per the split-component-build-lifecycle RFC.
354        let _ = self.to_rdkafka()?;
355        let topic = self
356            .topic
357            .clone()
358            .confine(&self.confinement, Self::NAME, "topic")?;
359        Ok(ValidatedKafkaSink { topic })
360    }
361
362    async fn build(
363        &self,
364        validated: &ValidatedKafkaSink,
365        cx: SinkContext,
366    ) -> crate::Result<(VectorSink, Healthcheck)> {
367        let ValidatedKafkaSink { topic } = validated;
368        let sink = KafkaSink::new(self.clone(), topic.clone())?;
369        let hc = healthcheck(self.clone(), topic.clone(), cx.healthcheck.clone()).boxed();
370        Ok((VectorSink::from_event_streamsink(sink), hc))
371    }
372}
373
374#[cfg(test)]
375mod tests {
376    use super::*;
377    use crate::config::ValidatedSink;
378    use crate::template::{ConfinementConfig, Template};
379
380    #[test]
381    fn generate_config() {
382        KafkaSinkConfig::generate_config();
383    }
384
385    #[test]
386    fn validate_returns_confined_topic() {
387        let config: KafkaSinkConfig = serde_yaml::from_str(
388            r#"
389            bootstrap_servers: "localhost:9092"
390            topic: "test-topic"
391            encoding:
392                codec: "json"
393            "#,
394        )
395        .unwrap();
396        let validated = config.validate().expect("validation should succeed");
397        assert_eq!(validated.topic.to_string(), "test-topic");
398    }
399
400    #[test]
401    fn confinement_rejects_unconfined_topic() {
402        let template = Template::try_from("{{ topic }}").unwrap();
403        let config = ConfinementConfig::default();
404        let result = template.confine(&config, "kafka", "topic");
405        assert!(result.is_err());
406    }
407
408    #[test]
409    fn confinement_opt_out_allows_unconfined_topic() {
410        let template = Template::try_from("{{ topic }}").unwrap();
411        let config = ConfinementConfig {
412            dangerously_allow_unconfined_template_resolution: true,
413        };
414        let result = template.confine(&config, "kafka", "topic");
415        assert!(result.is_ok());
416    }
417
418    #[test]
419    fn confinement_allows_prefixed_topic() {
420        let template = Template::try_from("events-{{ env }}").unwrap();
421        let config = ConfinementConfig::default();
422        let result = template.confine(&config, "kafka", "topic");
423        assert!(result.is_ok());
424    }
425
426    #[test]
427    fn validate_rejects_batch_timeout_secs_conflicting_with_librdkafka_option() {
428        let config: KafkaSinkConfig = serde_yaml::from_str(
429            r#"
430            bootstrap_servers: "localhost:9092"
431            topic: "test-topic"
432            encoding:
433                codec: "json"
434            batch:
435                timeout_secs: 1.0
436            librdkafka_options:
437                queue.buffering.max.ms: "1000"
438            "#,
439        )
440        .unwrap();
441        assert!(
442            config.validate().is_err(),
443            "batch.timeout_secs conflicting with librdkafka_options.queue.buffering.max.ms should fail validation"
444        );
445    }
446
447    #[test]
448    fn validate_rejects_batch_max_events_conflicting_with_librdkafka_option() {
449        let config: KafkaSinkConfig = serde_yaml::from_str(
450            r#"
451            bootstrap_servers: "localhost:9092"
452            topic: "test-topic"
453            encoding:
454                codec: "json"
455            batch:
456                max_events: 1000
457            librdkafka_options:
458                batch.num.messages: "1000"
459            "#,
460        )
461        .unwrap();
462        assert!(
463            config.validate().is_err(),
464            "batch.max_events conflicting with librdkafka_options.batch.num.messages should fail validation"
465        );
466    }
467
468    #[test]
469    fn validate_rejects_batch_max_bytes_conflicting_with_librdkafka_option() {
470        let config: KafkaSinkConfig = serde_yaml::from_str(
471            r#"
472            bootstrap_servers: "localhost:9092"
473            topic: "test-topic"
474            encoding:
475                codec: "json"
476            batch:
477                max_bytes: 1000000
478            librdkafka_options:
479                batch.size: "1000000"
480            "#,
481        )
482        .unwrap();
483        assert!(
484            config.validate().is_err(),
485            "batch.max_bytes conflicting with librdkafka_options.batch.size should fail validation"
486        );
487    }
488
489    #[test]
490    fn validate_accepts_batch_options_without_conflicting_librdkafka_options() {
491        let config: KafkaSinkConfig = serde_yaml::from_str(
492            r#"
493            bootstrap_servers: "localhost:9092"
494            topic: "test-topic"
495            encoding:
496                codec: "json"
497            batch:
498                timeout_secs: 1.0
499                max_events: 1000
500                max_bytes: 1000000
501            librdkafka_options:
502                client.id: "vector"
503            "#,
504        )
505        .unwrap();
506        assert!(
507            config.validate().is_ok(),
508            "batch options without conflicting librdkafka options should pass validation"
509        );
510    }
511
512    #[tokio::test]
513    async fn build_rejects_unknown_librdkafka_option() {
514        let config: KafkaSinkConfig = serde_yaml::from_str(
515            r#"
516            bootstrap_servers: "localhost:9092"
517            topic: "test-topic"
518            encoding:
519                codec: "json"
520            librdkafka_options:
521                definitely.not.an.option: "x"
522            "#,
523        )
524        .unwrap();
525        let validated = config
526            .validate()
527            .expect("validation is pure and should succeed");
528        assert!(
529            ValidatedSink::build(&config, &validated, SinkContext::default())
530                .await
531                .is_err(),
532            "an unknown librdkafka option should fail build"
533        );
534    }
535
536    #[tokio::test]
537    async fn build_rejects_invalid_librdkafka_option_value() {
538        let config: KafkaSinkConfig = serde_yaml::from_str(
539            r#"
540            bootstrap_servers: "localhost:9092"
541            topic: "test-topic"
542            encoding:
543                codec: "json"
544            librdkafka_options:
545                queue.buffering.max.ms: "not-a-number"
546            "#,
547        )
548        .unwrap();
549        let validated = config
550            .validate()
551            .expect("validation is pure and should succeed");
552        assert!(
553            ValidatedSink::build(&config, &validated, SinkContext::default())
554                .await
555                .is_err(),
556            "an invalid value for a known librdkafka option should fail build"
557        );
558    }
559}