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 http::HttpClient,
12 sinks::{
13 prelude::*,
14 splunk_hec::common::{
15 EndpointTarget, SplunkHecDefaultBatchSettings,
16 acknowledgements::HecClientAcknowledgementsConfig,
17 build_healthcheck, build_http_batch_service, create_client,
18 service::{HecService, HttpRequestBuilder},
19 },
20 util::http::HttpRetryLogic,
21 },
22 template::ConfinementConfig,
23};
24
25#[configurable_component(sink(
27 "splunk_hec_logs",
28 "Deliver log data to Splunk's HTTP Event Collector."
29))]
30#[derive(Clone, Debug)]
31#[serde(deny_unknown_fields)]
32pub struct HecLogsSinkConfig {
33 #[serde(alias = "token")]
37 pub default_token: SensitiveString,
38
39 #[configurable(metadata(
46 docs::examples = "https://http-inputs-hec.splunkcloud.com",
47 docs::examples = "https://hec.splunk.com:8088",
48 docs::examples = "http://example.com"
49 ))]
50 #[configurable(validation(format = "uri"))]
51 pub endpoint: String,
52
53 #[configurable(metadata(docs::advanced))]
63 pub host_key: Option<OptionalTargetPath>,
64
65 #[configurable(metadata(docs::advanced))]
69 #[serde(default)]
70 #[configurable(metadata(docs::examples = "field1", docs::examples = "field2"))]
71 pub indexed_fields: Vec<ConfigValuePath>,
72
73 #[configurable(metadata(docs::examples = "{{ host }}", docs::examples = "custom_index"))]
77 pub index: Option<Template>,
78
79 #[configurable(metadata(docs::advanced))]
83 #[configurable(metadata(docs::examples = "{{ sourcetype }}", docs::examples = "_json",))]
84 pub sourcetype: Option<Template>,
85
86 #[configurable(metadata(docs::advanced))]
92 #[configurable(metadata(
93 docs::examples = "{{ file }}",
94 docs::examples = "/var/log/syslog",
95 docs::examples = "UDP:514"
96 ))]
97 pub source: Option<Template>,
98
99 #[configurable(derived)]
100 pub encoding: EncodingConfig,
101
102 #[configurable(derived)]
103 #[serde(default)]
104 pub compression: Compression,
105
106 #[configurable(derived)]
107 #[serde(default)]
108 pub batch: BatchConfig<SplunkHecDefaultBatchSettings>,
109
110 #[configurable(derived)]
111 #[serde(default)]
112 pub request: TowerRequestConfig,
113
114 #[configurable(derived)]
115 pub tls: Option<TlsConfig>,
116
117 #[configurable(derived)]
118 #[serde(default)]
119 pub acknowledgements: HecClientAcknowledgementsConfig,
120
121 #[serde(skip)]
124 pub timestamp_nanos_key: Option<String>,
125
126 #[configurable(metadata(docs::advanced))]
134 #[configurable(metadata(docs::examples = "timestamp", docs::examples = ""))]
135 pub timestamp_key: Option<OptionalTargetPath>,
139
140 #[serde(default)]
149 pub auto_extract_timestamp: Option<bool>,
150
151 #[configurable(derived)]
152 #[configurable(metadata(docs::advanced))]
153 #[serde(default = "default_endpoint_target")]
154 pub endpoint_target: EndpointTarget,
155
156 #[configurable(derived)]
157 #[serde(flatten)]
158 pub confinement: ConfinementConfig,
159}
160
161const fn default_endpoint_target() -> EndpointTarget {
162 EndpointTarget::Event
163}
164
165impl GenerateConfig for HecLogsSinkConfig {
166 fn generate_config() -> toml::Value {
167 toml::Value::try_from(Self {
168 default_token: "${VECTOR_SPLUNK_HEC_TOKEN}".to_owned().into(),
169 endpoint: "endpoint".to_owned(),
170 host_key: None,
171 indexed_fields: vec![],
172 index: None,
173 sourcetype: None,
174 source: None,
175 encoding: TextSerializerConfig::default().into(),
176 compression: Compression::default(),
177 batch: BatchConfig::default(),
178 request: TowerRequestConfig::default(),
179 tls: None,
180 acknowledgements: Default::default(),
181 timestamp_nanos_key: None,
182 timestamp_key: None,
183 auto_extract_timestamp: None,
184 endpoint_target: EndpointTarget::Event,
185 confinement: ConfinementConfig::default(),
186 })
187 .unwrap()
188 }
189}
190
191impl HecLogsSinkConfig {
192 pub(crate) fn build_without_confinement_gauge(
198 &self,
199 cx: SinkContext,
200 component_name: &'static str,
201 ) -> crate::Result<(VectorSink, Healthcheck)> {
202 if self.auto_extract_timestamp.is_some() && self.endpoint_target == EndpointTarget::Raw {
203 return Err("`auto_extract_timestamp` cannot be set for the `raw` endpoint.".into());
204 }
205
206 let mut confined_config = self.clone();
207 confined_config.index = confined_config
208 .index
209 .map(|t| t.confine(&self.confinement, component_name, "index"))
210 .transpose()?;
211 confined_config.source = confined_config
212 .source
213 .map(|t| t.confine(&self.confinement, component_name, "source"))
214 .transpose()?;
215 confined_config.sourcetype = confined_config
216 .sourcetype
217 .map(|t| t.confine(&self.confinement, component_name, "sourcetype"))
218 .transpose()?;
219
220 let client = create_client(self.tls.as_ref(), cx.proxy())?;
221 let healthcheck = build_healthcheck(
222 self.endpoint.clone(),
223 self.default_token.inner().to_owned(),
224 client.clone(),
225 )
226 .boxed();
227 let sink = confined_config.build_processor(client, cx)?;
228
229 Ok((sink, healthcheck))
230 }
231}
232
233#[async_trait::async_trait]
234#[typetag::serde(name = "splunk_hec_logs")]
235impl SinkConfig for HecLogsSinkConfig {
236 async fn build(&self, cx: SinkContext) -> crate::Result<(VectorSink, Healthcheck)> {
237 let result = self.build_without_confinement_gauge(cx, Self::NAME)?;
238 self.confinement.set_confinement_gauge("sink", Self::NAME);
239 Ok(result)
240 }
241
242 fn input(&self) -> Input {
243 Input::new(self.encoding.config().input_type() & DataType::Log)
244 }
245
246 fn acknowledgements(&self) -> &AcknowledgementsConfig {
247 &self.acknowledgements.inner
248 }
249}
250
251impl HecLogsSinkConfig {
252 pub fn build_processor(&self, client: HttpClient, _: SinkContext) -> crate::Result<VectorSink> {
253 let ack_client = if self.acknowledgements.indexer_acknowledgements_enabled {
254 Some(client.clone())
255 } else {
256 None
257 };
258
259 let transformer = self.encoding.transformer();
260 let serializer = self.encoding.build()?;
261 let encoder = Encoder::<()>::new(serializer);
262 let encoder = HecLogsEncoder {
263 transformer,
264 encoder,
265 auto_extract_timestamp: self.auto_extract_timestamp.unwrap_or_default(),
266 };
267 let request_builder = HecLogsRequestBuilder {
268 encoder,
269 compression: self.compression,
270 };
271
272 let request_settings = self.request.into_settings();
273 let http_request_builder = Arc::new(HttpRequestBuilder::new(
274 self.endpoint.clone(),
275 self.endpoint_target,
276 self.default_token.inner().to_owned(),
277 self.compression,
278 ));
279 let http_service = ServiceBuilder::new()
280 .settings(request_settings, HttpRetryLogic::default())
281 .service(build_http_batch_service(
282 client,
283 Arc::clone(&http_request_builder),
284 self.endpoint_target,
285 self.auto_extract_timestamp.unwrap_or_default(),
286 ));
287
288 let service = HecService::new(
289 http_service,
290 ack_client,
291 http_request_builder,
292 self.acknowledgements.clone(),
293 );
294
295 let batch_settings = self.batch.into_batcher_settings()?;
296
297 let sink = HecLogsSink {
298 service,
299 request_builder,
300 batch_settings,
301 sourcetype: self.sourcetype.clone(),
302 source: self.source.clone(),
303 index: self.index.clone(),
304 indexed_fields: self
305 .indexed_fields
306 .iter()
307 .map(|config_path| config_path.0.clone())
308 .collect(),
309 host_key: self.host_key.clone(),
310 timestamp_nanos_key: self.timestamp_nanos_key.clone(),
311 timestamp_key: self.timestamp_key.clone(),
312 endpoint_target: self.endpoint_target,
313 auto_extract_timestamp: self.auto_extract_timestamp.unwrap_or_default(),
314 };
315
316 Ok(VectorSink::from_event_streamsink(sink))
317 }
318}
319
320#[cfg(test)]
321mod tests {
322 use vector_lib::{
323 codecs::{JsonSerializerConfig, MetricTagValues, encoding::format::JsonSerializerOptions},
324 config::LogNamespace,
325 };
326
327 use super::*;
328 use crate::components::validation::prelude::*;
329 use crate::template::{ConfinementConfig, Template};
330
331 #[test]
332 fn generate_config() {
333 crate::test_util::test_generate_config::<HecLogsSinkConfig>();
334 }
335
336 #[test]
337 fn confinement_rejects_unconfined_index() {
338 let template = Template::try_from("{{ index }}").unwrap();
339 let config = ConfinementConfig::default();
340 let result = template.confine(&config, "splunk_hec_logs", "index");
341 assert!(result.is_err());
342 }
343
344 #[test]
345 fn confinement_opt_out_allows_unconfined_index() {
346 let template = Template::try_from("{{ index }}").unwrap();
347 let config = ConfinementConfig {
348 dangerously_allow_unconfined_template_resolution: true,
349 };
350 let result = template.confine(&config, "splunk_hec_logs", "index");
351 assert!(result.is_ok());
352 }
353
354 #[test]
355 fn confinement_allows_prefixed_index() {
356 let template = Template::try_from("events-{{ env }}").unwrap();
357 let config = ConfinementConfig::default();
358 let result = template.confine(&config, "splunk_hec_logs", "index");
359 assert!(result.is_ok());
360 }
361
362 impl ValidatableComponent for HecLogsSinkConfig {
363 fn validation_configuration() -> ValidationConfiguration {
364 let endpoint = "http://127.0.0.1:9001".to_string();
365
366 let mut batch = BatchConfig::default();
367 batch.max_events = Some(1);
368
369 let config = Self {
370 endpoint: endpoint.clone(),
371 default_token: "i_am_an_island".to_string().into(),
372 host_key: None,
373 indexed_fields: vec![],
374 index: None,
375 sourcetype: None,
376 source: None,
377 encoding: EncodingConfig::new(
378 JsonSerializerConfig::new(
379 MetricTagValues::Full,
380 JsonSerializerOptions::default(),
381 )
382 .into(),
383 Transformer::default(),
384 ),
385 compression: Compression::default(),
386 batch,
387 request: TowerRequestConfig {
388 timeout_secs: 2,
389 retry_attempts: 0,
390 ..Default::default()
391 },
392 tls: None,
393 acknowledgements: HecClientAcknowledgementsConfig {
394 indexer_acknowledgements_enabled: false,
395 ..Default::default()
396 },
397 timestamp_nanos_key: None,
398 timestamp_key: None,
399 auto_extract_timestamp: None,
400 endpoint_target: EndpointTarget::Raw,
401 confinement: ConfinementConfig::default(),
402 };
403
404 let endpoint = format!("{endpoint}/services/collector/raw");
405
406 let external_resource = ExternalResource::new(
407 ResourceDirection::Push,
408 HttpResourceConfig::from_parts(
409 http::Uri::try_from(&endpoint).expect("should not fail to parse URI"),
410 None,
411 ),
412 config.encoding.clone(),
413 );
414
415 ValidationConfiguration::from_sink(
416 Self::NAME,
417 LogNamespace::Legacy,
418 vec![ComponentTestCaseConfig::from_sink(
419 config,
420 None,
421 Some(external_resource),
422 )],
423 )
424 }
425 }
426
427 register_validatable_component!(HecLogsSinkConfig);
428}