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 std::{collections::HashMap, future::ready, task::Poll};
7
8use bytes::{Bytes, BytesMut};
9use derivative::Derivative;
10use futures::{FutureExt, SinkExt, future::BoxFuture, stream};
11use http::{StatusCode, Uri};
12use hyper::{Body, Request};
13use indoc::indoc;
14use tower::Service;
15use vector_lib::{
16 ByteSizeOf, EstimatedJsonEncodedSizeOf, configurable::configurable_component,
17 sensitive_string::SensitiveString,
18};
19
20use super::Region;
21use crate::{
22 Result,
23 config::{
24 AcknowledgementsConfig, DynValidatedSink, GenerateConfig, Input, SinkConfig, SinkContext,
25 ValidatedSink,
26 },
27 event::{
28 Event, KeyString,
29 metric::{Metric, MetricValue},
30 },
31 http::HttpClient,
32 internal_events::{SematextMetricsEncodeEventError, SematextMetricsInvalidMetricError},
33 sinks::{
34 Healthcheck, HealthcheckError, VectorSink,
35 influxdb::{Field, ProtocolVersion, encode_timestamp, encode_uri, influx_line_protocol},
36 util::{
37 BatchConfig, BatchSettings, EncodedEvent, HttpEndpoint, SinkBatchSettings,
38 TowerRequestConfig,
39 buffer::metrics::{MetricNormalize, MetricNormalizer, MetricSet, MetricsBuffer},
40 http::{HttpBatchService, HttpRetryLogic},
41 },
42 },
43 vector_version,
44};
45
46#[derive(Clone)]
47struct SematextMetricsService {
48 config: SematextMetricsConfig,
49 inner: HttpBatchService<BoxFuture<'static, Result<Request<Bytes>>>>,
50}
51
52#[derive(Clone, Copy, Debug, Default)]
53pub(crate) struct SematextMetricsDefaultBatchSettings;
54
55impl SinkBatchSettings for SematextMetricsDefaultBatchSettings {
56 const MAX_EVENTS: Option<usize> = Some(20);
57 const MAX_BYTES: Option<usize> = None;
58 const TIMEOUT_SECS: f64 = 1.0;
59}
60
61#[configurable_component(sink("sematext_metrics", "Publish metric events to Sematext."))]
63#[derive(Clone, Debug)]
64pub struct SematextMetricsConfig {
65 #[configurable(metadata(docs::examples = "service"))]
70 pub default_namespace: String,
71
72 #[serde(default = "super::default_region")]
73 #[configurable(derived)]
74 pub region: Region,
75
76 #[configurable(metadata(docs::examples = "http://127.0.0.1"))]
80 #[configurable(metadata(docs::examples = "https://example.com"))]
81 pub endpoint: Option<String>,
82
83 #[configurable(metadata(docs::examples = "${SEMATEXT_TOKEN}"))]
85 #[configurable(metadata(docs::examples = "some-sematext-token"))]
86 pub token: SensitiveString,
87
88 #[configurable(derived)]
89 #[serde(default)]
90 pub(self) batch: BatchConfig<SematextMetricsDefaultBatchSettings>,
91
92 #[configurable(derived)]
93 #[serde(default)]
94 pub request: TowerRequestConfig,
95
96 #[configurable(derived)]
97 #[serde(
98 default,
99 deserialize_with = "crate::serde::bool_or_struct",
100 skip_serializing_if = "crate::serde::is_default"
101 )]
102 acknowledgements: AcknowledgementsConfig,
103}
104
105impl GenerateConfig for SematextMetricsConfig {
106 fn generate_config() -> serde_json::Value {
107 serde_yaml::from_str(indoc! {r#"
108 default_namespace: vector
109 token: ${SEMATEXT_TOKEN}
110 "#})
111 .unwrap()
112 }
113}
114
115async fn healthcheck(endpoint: String, client: HttpClient) -> Result<()> {
116 let uri = HttpEndpoint::parse(&endpoint)?
117 .append_path("health")?
118 .into_uri();
119
120 let request = Request::get(uri)
121 .body(Body::empty())
122 .map_err(|e| e.to_string())?;
123
124 let response = client.send(request).await?;
125
126 match response.status() {
127 StatusCode::OK => Ok(()),
128 StatusCode::NO_CONTENT => Ok(()),
129 other => Err(HealthcheckError::UnexpectedStatus { status: other }.into()),
130 }
131}
132
133const US_ENDPOINT: &str = "https://spm-receiver.sematext.com";
135const EU_ENDPOINT: &str = "https://spm-receiver.eu.sematext.com";
136
137#[async_trait::async_trait]
138#[typetag::serde(name = "sematext_metrics")]
139impl SinkConfig for SematextMetricsConfig {
140 fn input(&self) -> Input {
141 Input::metric()
142 }
143 fn acknowledgements(&self) -> &AcknowledgementsConfig {
144 &self.acknowledgements
145 }
146
147 fn as_dyn_validated(&self) -> Option<&dyn DynValidatedSink> {
148 Some(self)
149 }
150}
151
152#[derive(Clone, Derivative)]
153#[derivative(Debug)]
154pub struct ValidatedSematextMetrics {
155 endpoint: String,
156 uri: Uri,
157 #[derivative(Debug = "ignore")]
158 batch: BatchSettings<MetricsBuffer>,
159}
160
161#[async_trait::async_trait]
162impl ValidatedSink for SematextMetricsConfig {
163 type Validated = ValidatedSematextMetrics;
164
165 fn validate(&self) -> Result<ValidatedSematextMetrics> {
166 let endpoint = match (&self.endpoint, &self.region) {
167 (Some(endpoint), _) => endpoint.clone(),
168 (None, Region::Us) => US_ENDPOINT.to_owned(),
169 (None, Region::Eu) => EU_ENDPOINT.to_owned(),
170 };
171
172 let uri = write_uri(&HttpEndpoint::parse(&endpoint)?)?;
173 let batch = self.batch.into_batch_settings()?;
174
175 Ok(ValidatedSematextMetrics {
176 endpoint,
177 uri,
178 batch,
179 })
180 }
181
182 async fn build(
183 &self,
184 validated: &ValidatedSematextMetrics,
185 cx: SinkContext,
186 ) -> Result<(VectorSink, Healthcheck)> {
187 let ValidatedSematextMetrics { endpoint, .. } = validated;
188
189 let client = HttpClient::new(None, cx.proxy())?;
190
191 let healthcheck = healthcheck(endpoint.clone(), client.clone()).boxed();
192 let sink = SematextMetricsService::from_validated(self.clone(), validated, client)?;
193
194 Ok((sink, healthcheck))
195 }
196}
197
198fn write_uri(endpoint: &HttpEndpoint) -> Result<Uri> {
199 encode_uri(
200 endpoint,
201 "write",
202 &[
203 ("db", Some("metrics".into())),
204 ("v", Some(format!("vector-{}", vector_version()))),
205 ("precision", Some("ns".into())),
206 ],
207 )
208}
209
210impl SematextMetricsService {
211 fn from_validated(
212 config: SematextMetricsConfig,
213 validated: &ValidatedSematextMetrics,
214 client: HttpClient,
215 ) -> Result<VectorSink> {
216 let ValidatedSematextMetrics { uri, batch, .. } = validated;
217 let request = config.request.into_settings();
218 let http_service = HttpBatchService::new(client, create_build_request(uri.clone()));
219 let sematext_service = SematextMetricsService {
220 config,
221 inner: http_service,
222 };
223 let mut normalizer = MetricNormalizer::<SematextMetricNormalize>::default();
224
225 let sink = request
226 .batch_sink(
227 HttpRetryLogic::default(),
228 sematext_service,
229 MetricsBuffer::new(batch.size),
230 batch.timeout,
231 )
232 .with_flat_map(move |event: Event| {
233 stream::iter({
234 let byte_size = event.size_of();
235 let json_byte_size = event.estimated_json_encoded_size_of();
236 normalizer
237 .normalize(event.into_metric())
238 .map(|item| Ok(EncodedEvent::new(item, byte_size, json_byte_size)))
239 })
240 })
241 .sink_map_err(|error| error!(message = "Fatal sematext metrics sink error.", %error, internal_log_rate_limit = false));
242
243 #[allow(deprecated)]
244 Ok(VectorSink::from_event_sink(sink))
245 }
246}
247
248impl Service<Vec<Metric>> for SematextMetricsService {
249 type Response = http::Response<bytes::Bytes>;
250 type Error = crate::Error;
251 type Future = BoxFuture<'static, std::result::Result<Self::Response, Self::Error>>;
252
253 fn poll_ready(
254 &mut self,
255 cx: &mut std::task::Context,
256 ) -> Poll<std::result::Result<(), Self::Error>> {
257 self.inner.poll_ready(cx)
258 }
259
260 fn call(&mut self, items: Vec<Metric>) -> Self::Future {
261 let input = encode_events(
262 self.config.token.inner(),
263 &self.config.default_namespace,
264 items,
265 );
266 let body = input.item;
267
268 self.inner.call(body)
269 }
270}
271
272#[derive(Default)]
273struct SematextMetricNormalize;
274
275impl MetricNormalize for SematextMetricNormalize {
276 fn normalize(&mut self, state: &mut MetricSet, metric: Metric) -> Option<Metric> {
277 match &metric.value() {
278 MetricValue::Gauge { .. } => state.make_absolute(metric),
279 MetricValue::Counter { .. } => state.make_incremental(metric),
280 _ => {
281 emit!(SematextMetricsInvalidMetricError { metric: &metric });
282 None
283 }
284 }
285 }
286}
287
288fn create_build_request(
289 uri: http::Uri,
290) -> impl Fn(Bytes) -> BoxFuture<'static, Result<Request<Bytes>>> + Sync + Send + 'static {
291 move |body| {
292 Box::pin(ready(
293 Request::post(uri.clone())
294 .header("Content-Type", "text/plain")
295 .body(body)
296 .map_err(Into::into),
297 ))
298 }
299}
300
301fn encode_events(
302 token: &str,
303 default_namespace: &str,
304 metrics: Vec<Metric>,
305) -> EncodedEvent<Bytes> {
306 let mut output = BytesMut::new();
307 let byte_size = metrics.size_of();
308 let json_byte_size = metrics.estimated_json_encoded_size_of();
309 for metric in metrics.into_iter() {
310 let (series, data, _metadata) = metric.into_parts();
311 let namespace = series
312 .name
313 .namespace
314 .unwrap_or_else(|| default_namespace.into());
315 let label = series.name.name;
316 let ts = encode_timestamp(data.time.timestamp);
317
318 let mut tags = series.tags.unwrap_or_default();
320 tags.replace("token".into(), token.to_string());
321 let (metric_type, fields) = match data.value {
322 MetricValue::Counter { value } => ("counter", to_fields(label, value)),
323 MetricValue::Gauge { value } => ("gauge", to_fields(label, value)),
324 _ => unreachable!(), };
326
327 tags.replace("metric_type".into(), metric_type.to_string());
328
329 if let Err(error) = influx_line_protocol(
330 ProtocolVersion::V1,
331 &namespace,
332 Some(tags),
333 Some(fields),
334 ts,
335 &mut output,
336 ) {
337 emit!(SematextMetricsEncodeEventError { error });
338 };
339 }
340
341 if !output.is_empty() {
342 output.truncate(output.len() - 1);
343 }
344 EncodedEvent::new(output.freeze(), byte_size, json_byte_size)
345}
346
347fn to_fields(label: String, value: f64) -> HashMap<KeyString, Field> {
348 let mut result = HashMap::new();
349 result.insert(label.into(), Field::Float(value));
350 result
351}
352
353#[cfg(test)]
354mod tests {
355 use chrono::{Timelike, Utc, offset::TimeZone};
356 use futures::StreamExt;
357 use indoc::indoc;
358 use vector_lib::metric_tags;
359
360 use super::*;
361 use crate::{
362 config::ValidatedSink,
363 event::{Event, metric::MetricKind},
364 sinks::util::test::{build_test_server, load_sink},
365 test_util::{
366 addr::next_addr,
367 components::{HTTP_SINK_TAGS, assert_sink_compliance},
368 test_generate_config,
369 },
370 };
371
372 #[test]
373 fn generate_config() {
374 test_generate_config::<SematextMetricsConfig>();
375 }
376
377 #[test]
378 fn prepares_valid_config() {
379 let config = SematextMetricsConfig {
380 default_namespace: "ns".to_string(),
381 region: Region::Us,
382 endpoint: Some("http://localhost:9999".to_string()),
383 token: "atoken".to_string().into(),
384 batch: Default::default(),
385 request: Default::default(),
386 acknowledgements: Default::default(),
387 };
388
389 let validated = config.validate().expect("preparation should succeed");
390 assert_eq!(validated.endpoint, "http://localhost:9999");
391 assert!(
392 validated
393 .uri
394 .to_string()
395 .starts_with("http://localhost:9999/write?db=metrics&v=vector-")
396 );
397 }
398
399 #[test]
400 fn rejects_non_absolute_endpoint() {
401 let config = |endpoint: &str| SematextMetricsConfig {
402 default_namespace: "ns".to_string(),
403 region: Region::Us,
404 endpoint: Some(endpoint.to_string()),
405 token: "atoken".to_string().into(),
406 batch: Default::default(),
407 request: Default::default(),
408 acknowledgements: Default::default(),
409 };
410
411 assert!(
413 config("ftp://spm-receiver.sematext.com")
414 .validate()
415 .is_err()
416 );
417 assert!(config("/write").validate().is_err());
419 }
420
421 #[test]
422 fn test_encode_counter_event() {
423 let events = vec![
424 Metric::new(
425 "pool.used",
426 MetricKind::Incremental,
427 MetricValue::Counter { value: 42.0 },
428 )
429 .with_namespace(Some("jvm"))
430 .with_timestamp(Some(
431 Utc.with_ymd_and_hms(2020, 8, 18, 21, 0, 0)
432 .single()
433 .expect("invalid timestamp"),
434 )),
435 ];
436
437 assert_eq!(
438 "jvm,metric_type=counter,token=aaa pool.used=42 1597784400000000000",
439 encode_events("aaa", "ns", events).item
440 );
441 }
442
443 #[test]
444 fn test_encode_counter_event_no_namespace() {
445 let events = vec![
446 Metric::new(
447 "used",
448 MetricKind::Incremental,
449 MetricValue::Counter { value: 42.0 },
450 )
451 .with_timestamp(Some(
452 Utc.with_ymd_and_hms(2020, 8, 18, 21, 0, 0)
453 .single()
454 .expect("invalid timestamp"),
455 )),
456 ];
457
458 assert_eq!(
459 "ns,metric_type=counter,token=aaa used=42 1597784400000000000",
460 encode_events("aaa", "ns", events).item
461 );
462 }
463
464 #[test]
465 fn test_encode_counter_multiple_events() {
466 let events = vec![
467 Metric::new(
468 "pool.used",
469 MetricKind::Incremental,
470 MetricValue::Counter { value: 42.0 },
471 )
472 .with_namespace(Some("jvm"))
473 .with_timestamp(Some(
474 Utc.with_ymd_and_hms(2020, 8, 18, 21, 0, 0)
475 .single()
476 .expect("invalid timestamp"),
477 )),
478 Metric::new(
479 "pool.committed",
480 MetricKind::Incremental,
481 MetricValue::Counter { value: 18874368.0 },
482 )
483 .with_namespace(Some("jvm"))
484 .with_timestamp(Some(
485 Utc.with_ymd_and_hms(2020, 8, 18, 21, 0, 0)
486 .single()
487 .and_then(|t| t.with_nanosecond(1))
488 .expect("invalid timestamp"),
489 )),
490 ];
491
492 assert_eq!(
493 "jvm,metric_type=counter,token=aaa pool.used=42 1597784400000000000\n\
494 jvm,metric_type=counter,token=aaa pool.committed=18874368 1597784400000000001",
495 encode_events("aaa", "ns", events).item
496 );
497 }
498
499 #[tokio::test]
500 async fn smoke() {
501 assert_sink_compliance(&HTTP_SINK_TAGS, async {
502
503 let (mut config, cx) = load_sink::<SematextMetricsConfig>(indoc! {r#"
504 default_namespace = "ns"
505 token = "atoken"
506 batch.max_events = 1
507 "#})
508 .unwrap();
509
510 let (_guard, addr) = next_addr();
511 let endpoint = format!("http://{addr}");
514 config.endpoint = Some(endpoint.clone());
515
516 let (sink, _) = SinkConfig::build(&config, cx).await.unwrap();
517
518 let (rx, _trigger, server) = build_test_server(addr);
519 tokio::spawn(server);
520
521 let metrics = vec![
523 ("os", "swap.size", 324292.0),
524 ("os", "network.tx", 42000.0),
525 ("os", "network.rx", 54293.0),
526 ("process", "count", 12.0),
527 ("process", "uptime", 32423.0),
528 ("process", "rss", 2342333.0),
529 ("jvm", "pool.used", 18874368.0),
530 ("jvm", "pool.committed", 18868584.0),
531 ("jvm", "pool.max", 18874368.0),
532 ];
533
534 let mut events = Vec::new();
535 for (i, (namespace, metric, val)) in metrics.iter().enumerate() {
536 let event = Event::from(
537 Metric::new(
538 *metric,
539 MetricKind::Incremental,
540 MetricValue::Counter { value: *val },
541 )
542 .with_namespace(Some(*namespace))
543 .with_tags(Some(metric_tags!("os.host" => "somehost")))
544 .with_timestamp(Some(Utc.with_ymd_and_hms(2020, 8, 18, 21, 0, 0).single()
545 .and_then(|t| t.with_nanosecond(i as u32))
546 .expect("invalid timestamp"))),
547 );
548 events.push(event);
549 }
550
551 sink.run_events(events).await.unwrap();
552
553 let output = rx.take(metrics.len()).collect::<Vec<_>>().await;
554 assert_eq!("os,metric_type=counter,os.host=somehost,token=atoken swap.size=324292 1597784400000000000", output[0].1);
555 assert_eq!("os,metric_type=counter,os.host=somehost,token=atoken network.tx=42000 1597784400000000001", output[1].1);
556 assert_eq!("os,metric_type=counter,os.host=somehost,token=atoken network.rx=54293 1597784400000000002", output[2].1);
557 assert_eq!("process,metric_type=counter,os.host=somehost,token=atoken count=12 1597784400000000003", output[3].1);
558 assert_eq!("process,metric_type=counter,os.host=somehost,token=atoken uptime=32423 1597784400000000004", output[4].1);
559 assert_eq!("process,metric_type=counter,os.host=somehost,token=atoken rss=2342333 1597784400000000005", output[5].1);
560 assert_eq!("jvm,metric_type=counter,os.host=somehost,token=atoken pool.used=18874368 1597784400000000006", output[6].1);
561 assert_eq!("jvm,metric_type=counter,os.host=somehost,token=atoken pool.committed=18868584 1597784400000000007", output[7].1);
562 assert_eq!("jvm,metric_type=counter,os.host=somehost,token=atoken pool.max=18874368 1597784400000000008", output[8].1);
563 }).await;
564 }
565}