Skip to main content

vector/sinks/humio/
metrics.rs

1use async_trait::async_trait;
2use futures::StreamExt;
3use futures_util::stream::BoxStream;
4use indoc::indoc;
5use vector_lib::{
6    codecs::JsonSerializerConfig,
7    configurable::configurable_component,
8    lookup,
9    lookup::lookup_v2::{ConfigValuePath, OptionalTargetPath, OptionalValuePath},
10    sensitive_string::SensitiveString,
11    sink::StreamSink,
12};
13
14use super::{
15    config_host_key,
16    logs::{HOST, HumioLogsConfig},
17};
18use crate::{
19    config::{
20        AcknowledgementsConfig, DynValidatedSink, GenerateConfig, Input, SinkConfig, SinkContext,
21        TransformContext, ValidatedSink,
22    },
23    event::{Event, EventArray, EventContainer},
24    sinks::{
25        Healthcheck, VectorSink,
26        splunk_hec::{common::SplunkHecDefaultBatchSettings, logs::config::ValidatedHecLogsSink},
27        util::{BatchConfig, Compression, HttpEndpoint, TowerRequestConfig},
28    },
29    template::Template,
30    tls::TlsConfig,
31    transforms::{
32        FunctionTransform, OutputBuffer,
33        metric_to_log::{MetricToLog, MetricToLogConfig},
34    },
35};
36
37/// Configuration for the `humio_metrics` sink.
38//
39// TODO: This sink overlaps almost entirely with the `humio_logs` sink except for the metric-to-log
40// transform that it uses to get metrics into the shape of a log before sending to Humio. However,
41// due to issues with aliased fields and flattened fields [1] in `serde`, we can't embed the
42// `humio_logs` config here.
43//
44// [1]: https://github.com/serde-rs/serde/issues/1504
45#[configurable_component(sink("humio_metrics", "Deliver metric event data to Humio."))]
46#[derive(Clone, Debug)]
47#[serde(deny_unknown_fields)]
48pub struct HumioMetricsConfig {
49    #[serde(flatten)]
50    transform: MetricToLogConfig,
51
52    /// The Humio ingestion token.
53    #[configurable(metadata(
54        docs::examples = "${HUMIO_TOKEN}",
55        docs::examples = "A94A8FE5CCB19BA61C4C08"
56    ))]
57    token: SensitiveString,
58
59    /// The base URL of the Humio instance.
60    ///
61    /// The scheme (`http` or `https`) must be specified. No path should be included since the paths defined
62    /// by the [`Splunk`][splunk] API are used.
63    ///
64    /// [splunk]: https://docs.splunk.com/Documentation/Splunk/8.0.0/Data/HECRESTendpoints
65    #[serde(alias = "host")]
66    #[serde(default = "default_endpoint")]
67    #[configurable(metadata(
68        docs::examples = "http://127.0.0.1",
69        docs::examples = "https://example.com",
70    ))]
71    pub(super) endpoint: HttpEndpoint,
72
73    /// The source of events sent to this sink.
74    ///
75    /// Typically the filename the metrics originated from. Maps to `@source` in Humio.
76    source: Option<Template>,
77
78    /// The type of events sent to this sink. Humio uses this as the name of the parser to use to ingest the data.
79    ///
80    /// If unset, Humio defaults it to none.
81    #[configurable(metadata(
82        docs::examples = "json",
83        docs::examples = "none",
84        docs::examples = "event_type-{{ event_type }}"
85    ))]
86    event_type: Option<Template>,
87
88    /// Overrides the name of the log field used to retrieve the hostname to send to Humio.
89    ///
90    /// By default, the [global `log_schema.host_key` option][global_host_key] is used if log
91    /// events are Legacy namespaced, or the semantic meaning of "host" is used, if defined.
92    ///
93    /// [global_host_key]: https://vector.dev/docs/reference/configuration/global-options/#log_schema.host_key
94    #[serde(default = "config_host_key")]
95    host_key: OptionalValuePath,
96
97    /// Event fields to be added to Humio’s extra fields.
98    ///
99    /// Can be used to tag events by specifying fields starting with `#`.
100    ///
101    /// For more information, see [Humio’s Format of Data][humio_data_format].
102    ///
103    /// [humio_data_format]: https://docs.humio.com/integrations/data-shippers/hec/#format-of-data
104    #[serde(default)]
105    indexed_fields: Vec<ConfigValuePath>,
106
107    /// Optional name of the repository to ingest into.
108    ///
109    /// In public-facing APIs, this must (if present) be equal to the repository used to create the ingest token used for authentication.
110    ///
111    /// In private cluster setups, Humio can be configured to allow these to be different.
112    ///
113    /// For more information, see [Humio’s Format of Data][humio_data_format].
114    ///
115    /// [humio_data_format]: https://docs.humio.com/integrations/data-shippers/hec/#format-of-data
116    #[serde(default)]
117    #[configurable(metadata(
118        docs::examples = "index-{{ host }}",
119        docs::examples = "custom_index"
120    ))]
121    index: Option<Template>,
122
123    #[configurable(derived)]
124    #[serde(default)]
125    compression: Compression,
126
127    #[configurable(derived)]
128    #[serde(default)]
129    request: TowerRequestConfig,
130
131    #[configurable(derived)]
132    #[serde(default)]
133    batch: BatchConfig<SplunkHecDefaultBatchSettings>,
134
135    #[configurable(derived)]
136    tls: Option<TlsConfig>,
137
138    #[configurable(derived)]
139    #[serde(
140        default,
141        deserialize_with = "crate::serde::bool_or_struct",
142        skip_serializing_if = "crate::serde::is_default"
143    )]
144    acknowledgements: AcknowledgementsConfig,
145
146    #[serde(flatten)]
147    pub confinement: crate::template::ConfinementConfig,
148}
149
150fn default_endpoint() -> HttpEndpoint {
151    HttpEndpoint::parse(HOST).expect("static default endpoint should be a valid http(s) URL")
152}
153
154impl GenerateConfig for HumioMetricsConfig {
155    fn generate_config() -> serde_json::Value {
156        serde_yaml::from_str(indoc! {r#"
157            host_key: hostname
158            token: ${HUMIO_TOKEN}
159        "#})
160        .unwrap()
161    }
162}
163
164#[async_trait::async_trait]
165#[typetag::serde(name = "humio_metrics")]
166impl SinkConfig for HumioMetricsConfig {
167    fn confinement_config(&self) -> Option<&crate::template::ConfinementConfig> {
168        Some(&self.confinement)
169    }
170
171    fn input(&self) -> Input {
172        Input::metric()
173    }
174
175    fn acknowledgements(&self) -> &AcknowledgementsConfig {
176        &self.acknowledgements
177    }
178
179    fn as_dyn_validated(&self) -> Option<&dyn DynValidatedSink> {
180        Some(self)
181    }
182}
183
184#[derive(Clone, Debug)]
185pub struct ValidatedHumioMetrics {
186    hec: ValidatedHecLogsSink,
187}
188
189#[async_trait::async_trait]
190impl ValidatedSink for HumioMetricsConfig {
191    type Validated = ValidatedHumioMetrics;
192
193    fn validate(&self) -> crate::Result<ValidatedHumioMetrics> {
194        let humio_logs = self.build_humio_logs_config();
195        let validated = humio_logs.validate_with_component_name(Self::NAME)?;
196        Ok(ValidatedHumioMetrics { hec: validated.hec })
197    }
198
199    async fn build(
200        &self,
201        validated: &ValidatedHumioMetrics,
202        cx: SinkContext,
203    ) -> crate::Result<(VectorSink, Healthcheck)> {
204        let transform = self
205            .transform
206            .build_transform(&TransformContext::new_with_globals(cx.globals.clone()));
207
208        let humio_logs = self.build_humio_logs_config();
209
210        // Route through the inner Humio helper threaded with our own component
211        // type, so per-template security warnings carry `humio_metrics` rather
212        // than the delegated `humio_logs`/`splunk_hec_logs`.
213        let (sink, healthcheck) = humio_logs.build_from_validated(cx, &validated.hec)?;
214
215        let sink = HumioMetricsSink {
216            inner: sink,
217            transform,
218        };
219        Ok((VectorSink::Stream(Box::new(sink)), healthcheck))
220    }
221}
222
223impl HumioMetricsConfig {
224    fn build_humio_logs_config(&self) -> HumioLogsConfig {
225        HumioLogsConfig {
226            token: self.token.clone(),
227            endpoint: self.endpoint.clone(),
228            source: self.source.clone(),
229            encoding: JsonSerializerConfig::default().into(),
230            event_type: self.event_type.clone(),
231            host_key: OptionalTargetPath::from(
232                vrl::path::PathPrefix::Event,
233                self.host_key.path.clone(),
234            ),
235            indexed_fields: self.indexed_fields.clone(),
236            index: self.index.clone(),
237            compression: self.compression,
238            request: self.request,
239            batch: self.batch,
240            tls: self.tls.clone(),
241            timestamp_nanos_key: None,
242            acknowledgements: Default::default(),
243            // hard coded as humio expects this format so no sense in making it configurable
244            timestamp_key: OptionalTargetPath::from(
245                vrl::path::PathPrefix::Event,
246                Some(lookup::owned_value_path!("timestamp")),
247            ),
248            confinement: self.confinement.clone(),
249        }
250    }
251}
252
253pub struct HumioMetricsSink {
254    inner: VectorSink,
255    transform: MetricToLog,
256}
257
258#[async_trait]
259impl StreamSink<EventArray> for HumioMetricsSink {
260    async fn run(self: Box<Self>, input: BoxStream<'_, EventArray>) -> Result<(), ()> {
261        let mut transform = self.transform;
262        self.inner
263            .run(input.map(move |events| {
264                let mut buf = OutputBuffer::with_capacity(events.len());
265                for event in events.into_events() {
266                    transform.transform(&mut buf, event);
267                }
268                // Awkward but necessary for the `EventArray` type
269                let events = buf.into_events().map(Event::into_log).collect::<Vec<_>>();
270                events.into()
271            }))
272            .await
273    }
274}
275
276#[cfg(test)]
277mod tests {
278    use chrono::{Utc, offset::TimeZone};
279    use futures::stream;
280    use indoc::indoc;
281    use similar_asserts::assert_eq;
282    use vector_lib::metric_tags;
283
284    use super::*;
285    use crate::{
286        event::{
287            Event, Metric,
288            metric::{MetricKind, MetricValue, StatisticKind},
289        },
290        sinks::util::test::{build_test_server, load_sink},
291        test_util::{
292            self,
293            components::{HTTP_SINK_TAGS, run_and_assert_sink_compliance},
294        },
295    };
296
297    #[test]
298    fn generate_config() {
299        crate::test_util::test_generate_config::<HumioMetricsConfig>();
300    }
301
302    #[test]
303    fn validate_rejects_unconfined_template() {
304        use crate::config::ValidatedSink;
305
306        let config = HumioMetricsConfig {
307            transform: MetricToLogConfig::default(),
308            token: "token".to_string().into(),
309            endpoint: default_endpoint(),
310            source: None,
311            event_type: None,
312            host_key: config_host_key(),
313            indexed_fields: vec![],
314            index: Some("{{ index }}".try_into().unwrap()),
315            compression: Compression::default(),
316            request: TowerRequestConfig::default(),
317            batch: BatchConfig::default(),
318            tls: None,
319            acknowledgements: Default::default(),
320            confinement: Default::default(),
321        };
322
323        let result = config.validate();
324        assert!(result.is_err());
325    }
326
327    #[test]
328    fn test_endpoint_field() {
329        let (config, _) = load_sink::<HumioMetricsConfig>(indoc! {r#"
330            token = "atoken"
331            batch.max_events = 1
332            endpoint = "https://localhost:9200/"
333        "#})
334        .unwrap();
335
336        assert_eq!(
337            HttpEndpoint::parse("https://localhost:9200/").unwrap(),
338            config.endpoint
339        );
340        let (config, _) = load_sink::<HumioMetricsConfig>(indoc! {r#"
341            token = "atoken"
342            batch.max_events = 1
343            host = "https://localhost:9200/"
344        "#})
345        .unwrap();
346
347        assert_eq!(
348            HttpEndpoint::parse("https://localhost:9200/").unwrap(),
349            config.endpoint
350        );
351    }
352
353    #[tokio::test]
354    async fn smoke_json() {
355        let (mut config, cx) = load_sink::<HumioMetricsConfig>(indoc! {r#"
356            token = "atoken"
357            batch.max_events = 1
358        "#})
359        .unwrap();
360
361        let (_guard, addr) = test_util::addr::next_addr();
362        // Swap out the endpoint so we can force send it
363        // to our local server
364        config.endpoint = HttpEndpoint::parse(&format!("http://{addr}")).unwrap();
365
366        let (sink, _) = SinkConfig::build(&config, cx).await.unwrap();
367
368        let (rx, _trigger, server) = build_test_server(addr);
369        tokio::spawn(server);
370
371        // Make our test metrics.
372        let metrics = vec![
373            Event::from(
374                Metric::new(
375                    "metric1",
376                    MetricKind::Incremental,
377                    MetricValue::Counter { value: 42.0 },
378                )
379                .with_tags(Some(metric_tags!("os.host" => "somehost")))
380                .with_timestamp(Some(
381                    Utc.with_ymd_and_hms(2020, 8, 18, 21, 0, 1)
382                        .single()
383                        .expect("invalid timestamp"),
384                )),
385            ),
386            Event::from(
387                Metric::new(
388                    "metric2",
389                    MetricKind::Absolute,
390                    MetricValue::Distribution {
391                        samples: vector_lib::samples![1.0 => 100, 2.0 => 200, 3.0 => 300],
392                        statistic: StatisticKind::Histogram,
393                    },
394                )
395                .with_tags(Some(metric_tags!("os.host" => "somehost")))
396                .with_timestamp(Some(
397                    Utc.with_ymd_and_hms(2020, 8, 18, 21, 0, 2)
398                        .single()
399                        .expect("invalid timestamp"),
400                )),
401            ),
402        ];
403
404        let len = metrics.len();
405        run_and_assert_sink_compliance(sink, stream::iter(metrics), &HTTP_SINK_TAGS).await;
406
407        let output = rx.take(len).collect::<Vec<_>>().await;
408        assert_eq!(
409            r#"{"event":{"counter":{"value":42.0},"kind":"incremental","name":"metric1","tags":{"os.host":"somehost"}},"fields":{},"time":1597784401.0}"#,
410            output[0].1
411        );
412        assert_eq!(
413            r#"{"event":{"distribution":{"samples":[{"rate":100,"value":1.0},{"rate":200,"value":2.0},{"rate":300,"value":3.0}],"statistic":"histogram"},"kind":"absolute","name":"metric2","tags":{"os.host":"somehost"}},"fields":{},"time":1597784402.0}"#,
414            output[1].1
415        );
416    }
417
418    #[tokio::test]
419    async fn multi_value_tags() {
420        let (mut config, cx) = load_sink::<HumioMetricsConfig>(indoc! {r#"
421            token = "atoken"
422            batch.max_events = 1
423            metric_tag_values = "full"
424        "#})
425        .unwrap();
426
427        let (_guard, addr) = test_util::addr::next_addr();
428        // Swap out the endpoint so we can force send it
429        // to our local server
430        config.endpoint = HttpEndpoint::parse(&format!("http://{addr}")).unwrap();
431
432        let (sink, _) = SinkConfig::build(&config, cx).await.unwrap();
433
434        let (rx, _trigger, server) = build_test_server(addr);
435        tokio::spawn(server);
436
437        // Make our test metrics.
438        let metrics = vec![Event::from(
439            Metric::new(
440                "metric1",
441                MetricKind::Incremental,
442                MetricValue::Counter { value: 42.0 },
443            )
444            .with_tags(Some(metric_tags!(
445                "code" => "200",
446                "code" => "success"
447            )))
448            .with_timestamp(Some(
449                Utc.with_ymd_and_hms(2020, 8, 18, 21, 0, 1)
450                    .single()
451                    .expect("invalid timestamp"),
452            )),
453        )];
454
455        let len = metrics.len();
456        run_and_assert_sink_compliance(sink, stream::iter(metrics), &HTTP_SINK_TAGS).await;
457
458        let output = rx.take(len).collect::<Vec<_>>().await;
459        assert_eq!(
460            r#"{"event":{"counter":{"value":42.0},"kind":"incremental","name":"metric1","tags":{"code":["200","success"]}},"fields":{},"time":1597784401.0}"#,
461            output[0].1
462        );
463    }
464}