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#[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 #[configurable(metadata(docs::examples = "10.14.22.123:9092,10.14.23.332:9092"))]
39 pub bootstrap_servers: String,
40
41 #[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 pub healthcheck_topic: Option<String>,
54
55 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[serde(alias = "headers_field")] #[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) }
144
145const fn default_message_timeout_ms() -> Duration {
146 Duration::from_millis(300000) }
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 }
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 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 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 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 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 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 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 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}