Skip to main content

vector/sinks/sematext/
logs.rs

1#![expect(
2    clippy::let_underscore_must_use,
3    reason = "derivative's Debug derive with ignored fields expands to a must_use let binding"
4)]
5
6use async_trait::async_trait;
7use derivative::Derivative;
8use futures::stream::{BoxStream, StreamExt};
9use indoc::indoc;
10use vector_lib::{configurable::configurable_component, sensitive_string::SensitiveString};
11use vrl::event_path;
12
13use super::Region;
14use crate::{
15    codecs::Transformer,
16    config::{
17        AcknowledgementsConfig, DynValidatedSink, GenerateConfig, Input, SinkConfig, SinkContext,
18        ValidatedSink,
19    },
20    event::EventArray,
21    sinks::{
22        Healthcheck, VectorSink,
23        elasticsearch::{BulkConfig, ElasticsearchApiVersion, ElasticsearchConfig},
24        util::{
25            BatchConfig, Compression, HttpEndpoint, RealtimeSizeBasedDefaultBatchSettings,
26            StreamSink, TowerRequestConfig, http::RequestConfig,
27        },
28    },
29    template::Template,
30};
31
32/// Configuration for the `sematext_logs` sink.
33#[configurable_component(sink("sematext_logs", "Publish log events to Sematext."))]
34#[derive(Clone, Debug)]
35pub struct SematextLogsConfig {
36    #[serde(default = "super::default_region")]
37    #[configurable(derived)]
38    region: Region,
39
40    /// The endpoint to send data to.
41    ///
42    /// Setting this option overrides the `region` option.
43    #[serde(alias = "host")]
44    #[configurable(metadata(docs::examples = "http://127.0.0.1"))]
45    #[configurable(metadata(docs::examples = "https://example.com"))]
46    endpoint: Option<String>,
47
48    /// The token that is used to write to Sematext.
49    #[configurable(metadata(docs::examples = "${SEMATEXT_TOKEN}"))]
50    #[configurable(metadata(docs::examples = "some-sematext-token"))]
51    token: SensitiveString,
52
53    #[configurable(derived)]
54    #[serde(skip_serializing_if = "crate::serde::is_default", default)]
55    pub encoding: Transformer,
56
57    #[configurable(derived)]
58    #[serde(default)]
59    request: TowerRequestConfig,
60
61    #[configurable(derived)]
62    #[serde(default)]
63    batch: BatchConfig<RealtimeSizeBasedDefaultBatchSettings>,
64
65    #[configurable(derived)]
66    #[serde(
67        default,
68        deserialize_with = "crate::serde::bool_or_struct",
69        skip_serializing_if = "crate::serde::is_default"
70    )]
71    acknowledgements: AcknowledgementsConfig,
72}
73
74impl GenerateConfig for SematextLogsConfig {
75    fn generate_config() -> serde_json::Value {
76        serde_yaml::from_str(indoc! {r#"
77            token: ${SEMATEXT_TOKEN}
78        "#})
79        .unwrap()
80    }
81}
82
83// https://sematext.com/docs/logs/index-events-via-elasticsearch-api/
84const US_ENDPOINT: &str = "https://logsene-receiver.sematext.com";
85const EU_ENDPOINT: &str = "https://logsene-receiver.eu.sematext.com";
86
87#[async_trait::async_trait]
88#[typetag::serde(name = "sematext_logs")]
89impl SinkConfig for SematextLogsConfig {
90    fn input(&self) -> Input {
91        Input::log()
92    }
93    fn acknowledgements(&self) -> &AcknowledgementsConfig {
94        &self.acknowledgements
95    }
96
97    fn as_dyn_validated(&self) -> Option<&dyn DynValidatedSink> {
98        Some(self)
99    }
100}
101
102#[derive(Clone, Derivative)]
103#[derivative(Debug)]
104pub struct ValidatedSematextLogs {
105    endpoint: String,
106    // Omitted: `index` is built from the write token and would leak it via Debug.
107    #[derivative(Debug = "ignore")]
108    index: Template,
109}
110
111#[async_trait::async_trait]
112impl ValidatedSink for SematextLogsConfig {
113    type Validated = ValidatedSematextLogs;
114
115    fn validate(&self) -> crate::Result<ValidatedSematextLogs> {
116        let endpoint = match (&self.endpoint, &self.region) {
117            (Some(endpoint), _) => endpoint.clone(),
118            (None, Region::Us) => US_ENDPOINT.to_owned(),
119            (None, Region::Eu) => EU_ENDPOINT.to_owned(),
120        };
121
122        let index = Template::try_from(self.token.inner())
123            .map_err(|error| format!("unable to parse token as Template: {error}"))?;
124
125        // Run the derived Elasticsearch config's full structural validation
126        // (endpoints, batch, versioning, confinement) so a malformed endpoint
127        // or token template is rejected here rather than at startup.
128        self.derived_elasticsearch_config(&endpoint, &index)?
129            .validate()?;
130
131        Ok(ValidatedSematextLogs { endpoint, index })
132    }
133
134    async fn build(
135        &self,
136        validated: &ValidatedSematextLogs,
137        cx: SinkContext,
138    ) -> crate::Result<(VectorSink, Healthcheck)> {
139        let ValidatedSematextLogs { endpoint, index } = validated;
140
141        let es_config = self.derived_elasticsearch_config(endpoint, index)?;
142        let (sink, healthcheck) = SinkConfig::build(&es_config, cx).await?;
143
144        let stream = sink.into_stream();
145        let mapped_stream = MapTimestampStream { inner: stream };
146
147        Ok((VectorSink::Stream(Box::new(mapped_stream)), healthcheck))
148    }
149}
150
151impl SematextLogsConfig {
152    /// Build the Elasticsearch config this sink delegates to.
153    fn derived_elasticsearch_config(
154        &self,
155        endpoint: &str,
156        index: &Template,
157    ) -> crate::Result<ElasticsearchConfig> {
158        let endpoint =
159            HttpEndpoint::parse(endpoint).map_err(|e| format!("invalid Sematext endpoint: {e}"))?;
160        Ok(ElasticsearchConfig {
161            endpoints: vec![endpoint],
162            compression: Compression::None,
163            doc_type: "logs".to_string(),
164            bulk: BulkConfig {
165                index: index.clone(),
166                ..Default::default()
167            },
168            batch: self.batch,
169            request: RequestConfig {
170                tower: self.request,
171                ..Default::default()
172            },
173            encoding: self.encoding.clone(),
174            api_version: ElasticsearchApiVersion::V6,
175            ..Default::default()
176        })
177    }
178}
179
180struct MapTimestampStream {
181    inner: Box<dyn StreamSink<EventArray> + Send>,
182}
183
184#[async_trait]
185impl StreamSink<EventArray> for MapTimestampStream {
186    async fn run(self: Box<Self>, input: BoxStream<'_, EventArray>) -> Result<(), ()> {
187        let mapped_input = input.map(map_timestamp).boxed();
188        self.inner.run(mapped_input).await
189    }
190}
191
192/// Used to map `timestamp` to `@timestamp`.
193fn map_timestamp(mut events: EventArray) -> EventArray {
194    match &mut events {
195        EventArray::Logs(logs) => {
196            for log in logs {
197                if let Some(path) = log.timestamp_path().cloned().as_ref() {
198                    log.rename_key(path, event_path!("@timestamp"));
199                }
200
201                if let Some(path) = log.host_path().cloned().as_ref() {
202                    log.rename_key(path, event_path!("os.host"));
203                }
204            }
205        }
206        _ => unreachable!("This sink only accepts logs"),
207    }
208
209    events
210}
211
212#[cfg(test)]
213mod tests {
214    use futures::StreamExt;
215    use indoc::indoc;
216
217    use super::*;
218    use crate::{
219        config::{SinkConfig, ValidatedSink},
220        sinks::util::test::{build_test_server, load_sink},
221        test_util::{
222            addr::next_addr,
223            components::{self, HTTP_SINK_TAGS},
224            random_lines_with_stream,
225        },
226    };
227
228    #[test]
229    fn generate_config() {
230        crate::test_util::test_generate_config::<SematextLogsConfig>();
231    }
232
233    #[test]
234    fn prepares_valid_config() {
235        let config = SematextLogsConfig {
236            region: Region::Us,
237            endpoint: None,
238            token: "mylogtoken".to_string().into(),
239            encoding: Default::default(),
240            request: Default::default(),
241            batch: Default::default(),
242            acknowledgements: Default::default(),
243        };
244
245        let validated = config.validate().expect("preparation should succeed");
246        assert_eq!(validated.endpoint, US_ENDPOINT);
247        assert_eq!(validated.index.get_ref(), "mylogtoken");
248    }
249
250    #[test]
251    fn debug_impl_does_not_leak_token() {
252        // `ValidatedSematextLogs`'s `index` field is built from the write token,
253        // so its Debug output must not expose the token value.
254        let token = "mylogtoken";
255        let config = SematextLogsConfig {
256            region: Region::Us,
257            endpoint: None,
258            token: token.to_string().into(),
259            encoding: Default::default(),
260            request: Default::default(),
261            batch: Default::default(),
262            acknowledgements: Default::default(),
263        };
264
265        let validated = config.validate().expect("preparation should succeed");
266        let debug = format!("{validated:?}");
267        assert!(
268            !debug.contains(token),
269            "Debug output must not contain the write token, got: {debug}"
270        );
271    }
272
273    #[test]
274    fn validate_rejects_unconfined_token_template() {
275        // A token that is an unconfined routing template (no literal prefix)
276        // passes template parsing but is rejected by the derived Elasticsearch
277        // config's confinement check. Structural validation must catch it here
278        // rather than deferring the error to startup.
279        let config = SematextLogsConfig {
280            region: Region::Us,
281            endpoint: None,
282            token: "{{ index }}".to_string().into(),
283            encoding: Default::default(),
284            request: Default::default(),
285            batch: Default::default(),
286            acknowledgements: Default::default(),
287        };
288
289        assert!(
290            config.validate().is_err(),
291            "an unconfined token template should fail validation"
292        );
293    }
294
295    #[test]
296    fn validate_rejects_malformed_token_template() {
297        // A token with invalid template syntax (a dangling `%` is an invalid
298        // strftime item) must surface a validation error rather than panic
299        // during structural validation.
300        let config = SematextLogsConfig {
301            region: Region::Us,
302            endpoint: None,
303            token: "%".to_string().into(),
304            encoding: Default::default(),
305            request: Default::default(),
306            batch: Default::default(),
307            acknowledgements: Default::default(),
308        };
309
310        let err = config
311            .validate()
312            .expect_err("a malformed token template should fail validation");
313        assert!(
314            err.to_string()
315                .contains("unable to parse token as Template"),
316            "unexpected error: {err}"
317        );
318    }
319
320    #[test]
321    fn validate_rejects_malformed_endpoint() {
322        // A custom endpoint that parses as a URI but has no host must be
323        // rejected by the derived Elasticsearch config's structural validation
324        // rather than failing at startup.
325        let config = SematextLogsConfig {
326            region: Region::Us,
327            endpoint: Some("/path".to_string()),
328            token: "mylogtoken".to_string().into(),
329            encoding: Default::default(),
330            request: Default::default(),
331            batch: Default::default(),
332            acknowledgements: Default::default(),
333        };
334
335        let err = config
336            .validate()
337            .expect_err("an endpoint without a host should fail validation");
338        assert!(
339            err.to_string().contains("invalid Sematext endpoint"),
340            "unexpected error: {err}"
341        );
342    }
343
344    #[tokio::test]
345    async fn smoke() {
346        let (mut config, cx) = load_sink::<SematextLogsConfig>(indoc! {r#"
347            token = "mylogtoken"
348        "#})
349        .unwrap();
350
351        // Make sure we can build the config
352        _ = SinkConfig::build(&config, cx.clone()).await.unwrap();
353
354        let (_guard, addr) = next_addr();
355        // Swap out the host so we can force send it
356        // to our local server
357        config.endpoint = Some(format!("http://{addr}"));
358
359        let (sink, _) = SinkConfig::build(&config, cx).await.unwrap();
360
361        let (mut rx, _trigger, server) = build_test_server(addr);
362        tokio::spawn(server);
363
364        let (expected, events) = random_lines_with_stream(100, 10, None);
365        components::run_and_assert_sink_compliance(sink, events, &HTTP_SINK_TAGS).await;
366
367        let output = rx.next().await.unwrap();
368
369        // A stream of `serde_json::Value`
370        let json = serde_json::Deserializer::from_slice(&output.1[..])
371            .into_iter::<serde_json::Value>()
372            .map(|v| v.expect("decoding json"));
373
374        let mut expected_message_idx = 0;
375        for (i, val) in json.enumerate() {
376            // Every even message is the index which contains the token for sematext
377            // Every odd message is the actual message in JSON format.
378            if i % 2 == 0 {
379                // Fetch {index: {_index: ""}}
380                let token = val
381                    .get("index")
382                    .unwrap()
383                    .get("_index")
384                    .unwrap()
385                    .as_str()
386                    .unwrap();
387
388                assert_eq!(token, "mylogtoken");
389            } else {
390                let message = val.get("message").unwrap().as_str().unwrap();
391                assert_eq!(message, &expected[expected_message_idx]);
392                expected_message_idx += 1;
393            }
394        }
395    }
396}