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#[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 #[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 #[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
83const 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 #[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 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 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
192fn 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 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 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 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 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 _ = SinkConfig::build(&config, cx.clone()).await.unwrap();
353
354 let (_guard, addr) = next_addr();
355 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 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 if i % 2 == 0 {
379 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}