1use std::sync::Arc;
2
3use vector_lib::{
4 codecs::TextSerializerConfig,
5 lookup::lookup_v2::{ConfigValuePath, OptionalTargetPath},
6 sensitive_string::SensitiveString,
7};
8
9use super::{encoder::HecLogsEncoder, request_builder::HecLogsRequestBuilder, sink::HecLogsSink};
10use crate::{
11 config::{DynValidatedSink, ValidatedSink},
12 http::HttpClient,
13 sinks::{
14 prelude::*,
15 splunk_hec::common::{
16 EndpointTarget, SplunkHecDefaultBatchSettings,
17 acknowledgements::HecClientAcknowledgementsConfig,
18 build_healthcheck, build_http_batch_service, create_client,
19 service::{HecService, HttpRequestBuilder},
20 },
21 util::{HttpEndpoint, http::HttpRetryLogic},
22 },
23 template::ConfinementConfig,
24};
25
26#[configurable_component(sink(
28 "splunk_hec_logs",
29 "Deliver log data to Splunk's HTTP Event Collector."
30))]
31#[derive(Clone, Debug)]
32#[serde(deny_unknown_fields)]
33pub struct HecLogsSinkConfig {
34 #[serde(alias = "token")]
38 pub default_token: SensitiveString,
39
40 #[configurable(metadata(
47 docs::examples = "https://http-inputs-hec.splunkcloud.com",
48 docs::examples = "https://hec.splunk.com:8088",
49 docs::examples = "http://example.com"
50 ))]
51 #[configurable(validation(format = "uri"))]
52 pub endpoint: HttpEndpoint,
53
54 pub host_key: Option<OptionalTargetPath>,
64
65 #[serde(default)]
69 #[configurable(metadata(docs::examples = "field1", docs::examples = "field2"))]
70 pub indexed_fields: Vec<ConfigValuePath>,
71
72 #[configurable(metadata(
76 docs::examples = "index-{{ host }}",
77 docs::examples = "custom_index"
78 ))]
79 pub index: Option<Template>,
80
81 #[configurable(metadata(
85 docs::examples = "sourcetype-{{ sourcetype }}",
86 docs::examples = "_json",
87 ))]
88 pub sourcetype: Option<Template>,
89
90 #[configurable(metadata(
96 docs::examples = "source-{{ file }}",
97 docs::examples = "/var/log/syslog",
98 docs::examples = "UDP:514"
99 ))]
100 pub source: Option<Template>,
101
102 #[configurable(derived)]
103 pub encoding: EncodingConfig,
104
105 #[configurable(derived)]
106 #[serde(default)]
107 pub compression: Compression,
108
109 #[configurable(derived)]
110 #[serde(default)]
111 pub batch: BatchConfig<SplunkHecDefaultBatchSettings>,
112
113 #[configurable(derived)]
114 #[serde(default)]
115 pub request: TowerRequestConfig,
116
117 #[configurable(derived)]
118 pub tls: Option<TlsConfig>,
119
120 #[configurable(derived)]
121 #[serde(default)]
122 pub acknowledgements: HecClientAcknowledgementsConfig,
123
124 #[serde(skip)]
127 pub timestamp_nanos_key: Option<String>,
128
129 #[configurable(metadata(docs::examples = "timestamp", docs::examples = ""))]
137 pub timestamp_key: Option<OptionalTargetPath>,
141
142 #[serde(default)]
151 pub auto_extract_timestamp: Option<bool>,
152
153 #[configurable(derived)]
154 #[serde(default = "default_endpoint_target")]
155 pub endpoint_target: EndpointTarget,
156
157 #[configurable(derived)]
158 #[serde(flatten)]
159 pub confinement: ConfinementConfig,
160}
161
162const fn default_endpoint_target() -> EndpointTarget {
163 EndpointTarget::Event
164}
165
166impl GenerateConfig for HecLogsSinkConfig {
167 fn generate_config() -> serde_json::Value {
168 serde_json::to_value(Self {
169 default_token: "${VECTOR_SPLUNK_HEC_TOKEN}".to_owned().into(),
170 endpoint: HttpEndpoint::parse("http://example.com").unwrap(),
171 host_key: None,
172 indexed_fields: vec![],
173 index: None,
174 sourcetype: None,
175 source: None,
176 encoding: TextSerializerConfig::default().into(),
177 compression: Compression::default(),
178 batch: BatchConfig::default(),
179 request: TowerRequestConfig::default(),
180 tls: None,
181 acknowledgements: Default::default(),
182 timestamp_nanos_key: None,
183 timestamp_key: None,
184 auto_extract_timestamp: None,
185 endpoint_target: EndpointTarget::Event,
186 confinement: ConfinementConfig::default(),
187 })
188 .unwrap()
189 }
190}
191
192impl HecLogsSinkConfig {
193 pub(crate) fn validate_with_component_name(
198 &self,
199 component_name: &'static str,
200 ) -> crate::Result<ValidatedHecLogsSink> {
201 if self.auto_extract_timestamp.is_some() && self.endpoint_target == EndpointTarget::Raw {
202 return Err("`auto_extract_timestamp` cannot be set for the `raw` endpoint.".into());
203 }
204
205 let index = self
208 .index
209 .clone()
210 .map(|t| t.confine(&self.confinement, component_name, "index"))
211 .transpose()?;
212 let source = self
213 .source
214 .clone()
215 .map(|t| t.confine(&self.confinement, component_name, "source"))
216 .transpose()?;
217 let sourcetype = self
218 .sourcetype
219 .clone()
220 .map(|t| t.confine(&self.confinement, component_name, "sourcetype"))
221 .transpose()?;
222
223 let batch_settings = self.batch.into_batcher_settings()?;
224
225 Ok(ValidatedHecLogsSink {
226 index,
227 source,
228 sourcetype,
229 batch_settings,
230 })
231 }
232
233 pub(crate) fn build_from_validated(
237 &self,
238 cx: SinkContext,
239 validated: &ValidatedHecLogsSink,
240 ) -> crate::Result<(VectorSink, Healthcheck)> {
241 let client = create_client(self.tls.as_ref(), cx.proxy())?;
242 let healthcheck = build_healthcheck(
243 self.endpoint.clone().into(),
244 self.default_token.inner().to_owned(),
245 client.clone(),
246 )
247 .boxed();
248 let sink = self.build_processor(client, cx, validated)?;
249
250 Ok((sink, healthcheck))
251 }
252}
253
254#[async_trait::async_trait]
255#[typetag::serde(name = "splunk_hec_logs")]
256impl SinkConfig for HecLogsSinkConfig {
257 fn confinement_config(&self) -> Option<&crate::template::ConfinementConfig> {
258 Some(&self.confinement)
259 }
260
261 fn input(&self) -> Input {
262 Input::new(self.encoding.config().input_type() & DataType::Log)
263 }
264
265 fn acknowledgements(&self) -> &AcknowledgementsConfig {
266 &self.acknowledgements.inner
267 }
268
269 fn as_dyn_validated(&self) -> Option<&dyn DynValidatedSink> {
270 Some(self)
271 }
272}
273
274#[derive(Clone, Debug)]
275pub struct ValidatedHecLogsSink {
276 index: Option<ConfinedTemplate>,
277 source: Option<ConfinedTemplate>,
278 sourcetype: Option<ConfinedTemplate>,
279 batch_settings: BatcherSettings,
280}
281
282#[async_trait::async_trait]
283impl ValidatedSink for HecLogsSinkConfig {
284 type Validated = ValidatedHecLogsSink;
285
286 fn validate(&self) -> crate::Result<ValidatedHecLogsSink> {
287 self.validate_with_component_name(Self::NAME)
288 }
289
290 async fn build(
291 &self,
292 validated: &ValidatedHecLogsSink,
293 cx: SinkContext,
294 ) -> crate::Result<(VectorSink, Healthcheck)> {
295 self.build_from_validated(cx, validated)
296 }
297}
298
299impl HecLogsSinkConfig {
300 pub fn build_processor(
301 &self,
302 client: HttpClient,
303 _: SinkContext,
304 validated: &ValidatedHecLogsSink,
305 ) -> crate::Result<VectorSink> {
306 let ack_client = if self.acknowledgements.indexer_acknowledgements_enabled {
307 Some(client.clone())
308 } else {
309 None
310 };
311
312 let transformer = self.encoding.transformer();
313 let serializer = self.encoding.build()?;
314 let encoder = HecLogsEncoder {
315 transformer,
316 encoder: Encoder::<()>::new(serializer),
317 auto_extract_timestamp: self.auto_extract_timestamp.unwrap_or_default(),
318 };
319 let request_builder = HecLogsRequestBuilder {
320 encoder,
321 compression: self.compression,
322 };
323
324 let request_settings = self.request.into_settings();
325 let http_request_builder = Arc::new(HttpRequestBuilder::new(
326 self.endpoint.clone().into(),
327 self.endpoint_target,
328 self.default_token.inner().to_owned(),
329 self.compression,
330 ));
331 let http_service = ServiceBuilder::new()
332 .settings(request_settings, HttpRetryLogic::default())
333 .service(build_http_batch_service(
334 client,
335 Arc::clone(&http_request_builder),
336 self.endpoint_target,
337 self.auto_extract_timestamp.unwrap_or_default(),
338 ));
339
340 let service = HecService::new(
341 http_service,
342 ack_client,
343 http_request_builder,
344 self.acknowledgements.clone(),
345 );
346
347 let sink = HecLogsSink {
348 service,
349 request_builder,
350 batch_settings: validated.batch_settings,
351 sourcetype: validated.sourcetype.clone(),
352 source: validated.source.clone(),
353 index: validated.index.clone(),
354 indexed_fields: self
355 .indexed_fields
356 .iter()
357 .map(|config_path| config_path.0.clone())
358 .collect(),
359 host_key: self.host_key.clone(),
360 timestamp_nanos_key: self.timestamp_nanos_key.clone(),
361 timestamp_key: self.timestamp_key.clone(),
362 endpoint_target: self.endpoint_target,
363 auto_extract_timestamp: self.auto_extract_timestamp.unwrap_or_default(),
364 };
365
366 Ok(VectorSink::from_event_streamsink(sink))
367 }
368}
369
370#[cfg(test)]
371mod tests {
372 use vector_lib::{
373 codecs::{JsonSerializerConfig, MetricTagValues, encoding::format::JsonSerializerOptions},
374 config::LogNamespace,
375 };
376
377 use super::*;
378 use crate::components::validation::prelude::*;
379 use crate::template::{ConfinementConfig, Template};
380
381 #[test]
382 fn generate_config() {
383 crate::test_util::test_generate_config::<HecLogsSinkConfig>();
384 }
385
386 #[test]
387 fn validate_produces_usable_state() {
388 use crate::config::ValidatedSink;
389
390 let config = HecLogsSinkConfig {
391 default_token: "token".to_string().into(),
392 endpoint: HttpEndpoint::parse("http://localhost:8088").unwrap(),
393 host_key: None,
394 indexed_fields: vec![],
395 index: Some("custom_index".try_into().unwrap()),
396 sourcetype: None,
397 source: None,
398 encoding: JsonSerializerConfig::default().into(),
399 compression: Compression::default(),
400 batch: Default::default(),
401 request: Default::default(),
402 tls: None,
403 acknowledgements: Default::default(),
404 timestamp_nanos_key: None,
405 timestamp_key: None,
406 auto_extract_timestamp: None,
407 endpoint_target: EndpointTarget::Event,
408 confinement: Default::default(),
409 };
410
411 let validated = config.validate().expect("validation should succeed");
412 assert_eq!(
413 validated.index.as_ref().unwrap().to_string(),
414 "custom_index"
415 );
416 assert!(validated.source.is_none());
417 assert!(validated.sourcetype.is_none());
418 }
419
420 #[test]
421 fn confinement_rejects_unconfined_index() {
422 let template = Template::try_from("{{ index }}").unwrap();
423 let config = ConfinementConfig::default();
424 let result = template.confine(&config, "splunk_hec_logs", "index");
425 assert!(result.is_err());
426 }
427
428 #[test]
429 fn confinement_opt_out_allows_unconfined_index() {
430 let template = Template::try_from("{{ index }}").unwrap();
431 let config = ConfinementConfig {
432 dangerously_allow_unconfined_template_resolution: true,
433 };
434 let result = template.confine(&config, "splunk_hec_logs", "index");
435 assert!(result.is_ok());
436 }
437
438 #[test]
439 fn confinement_allows_prefixed_index() {
440 let template = Template::try_from("events-{{ env }}").unwrap();
441 let config = ConfinementConfig::default();
442 let result = template.confine(&config, "splunk_hec_logs", "index");
443 assert!(result.is_ok());
444 }
445
446 impl ValidatableComponent for HecLogsSinkConfig {
447 fn validation_configuration() -> ValidationConfiguration {
448 let endpoint = HttpEndpoint::parse("http://127.0.0.1:9001").unwrap();
449
450 let mut batch = BatchConfig::default();
451 batch.max_events = Some(1);
452
453 let config = Self {
454 endpoint: endpoint.clone(),
455 default_token: "i_am_an_island".to_string().into(),
456 host_key: None,
457 indexed_fields: vec![],
458 index: None,
459 sourcetype: None,
460 source: None,
461 encoding: EncodingConfig::new(
462 JsonSerializerConfig::new(
463 MetricTagValues::Full,
464 JsonSerializerOptions::default(),
465 )
466 .into(),
467 Transformer::default(),
468 ),
469 compression: Compression::default(),
470 batch,
471 request: TowerRequestConfig {
472 timeout_secs: 2,
473 retry_attempts: 0,
474 ..Default::default()
475 },
476 tls: None,
477 acknowledgements: HecClientAcknowledgementsConfig {
478 indexer_acknowledgements_enabled: false,
479 ..Default::default()
480 },
481 timestamp_nanos_key: None,
482 timestamp_key: None,
483 auto_extract_timestamp: None,
484 endpoint_target: EndpointTarget::Raw,
485 confinement: ConfinementConfig::default(),
486 };
487
488 let endpoint = endpoint
489 .append_path("services/collector/raw")
490 .unwrap()
491 .into_uri();
492
493 let external_resource = ExternalResource::new(
494 ResourceDirection::Push,
495 HttpResourceConfig::from_parts(endpoint, None),
496 config.encoding.clone(),
497 );
498
499 ValidationConfiguration::from_sink(
500 Self::NAME,
501 LogNamespace::Legacy,
502 vec![ComponentTestCaseConfig::from_sink(
503 config,
504 None,
505 Some(external_resource),
506 )],
507 )
508 }
509 }
510
511 register_validatable_component!(HecLogsSinkConfig);
512}