1use vector_lib::{
2 codecs::{
3 MetricTagValues,
4 encoding::{FramingConfig, JsonSerializerConfig, JsonSerializerOptions, SerializerConfig},
5 },
6 configurable::configurable_component,
7 sensitive_string::SensitiveString,
8};
9
10use crate::{
11 codecs::{EncodingConfigWithFraming, Transformer},
12 config::{
13 AcknowledgementsConfig, DataType, DynValidatedSink, GenerateConfig, Input, SinkConfig,
14 SinkContext, ValidatedSink,
15 },
16 http::Auth as HttpAuthConfig,
17 sinks::{
18 Healthcheck, VectorSink,
19 http::config::{HttpMethod, HttpSinkConfig, ValidatedHttp},
20 util::{
21 BatchConfig, Compression, RealtimeSizeBasedDefaultBatchSettings,
22 http::{RequestConfig, RetryStrategy},
23 },
24 },
25 template::{ConfinementConfig, Template},
26 tls::TlsConfig,
27};
28
29static CLOUD_URL: &str = "https://api.axiom.co";
30
31#[configurable_component]
33#[derive(Clone, Debug, Default)]
34#[serde(default)]
35pub struct UrlOrRegion {
36 #[configurable(validation(format = "uri"))]
47 pub url: Option<String>,
48
49 #[configurable(metadata(docs::examples = "mumbai.axiom.co"))]
55 #[configurable(metadata(docs::examples = "${AXIOM_REGION}"))]
56 #[configurable(metadata(docs::examples = "eu-central-1.aws.edge.axiom.co"))]
57 pub region: Option<String>,
58}
59
60impl UrlOrRegion {
61 fn validate(&self) -> crate::Result<()> {
63 if self.url.is_some() && self.region.is_some() {
64 return Err("Cannot set both `url` and `region`. Please use only one.".into());
65 }
66 Ok(())
67 }
68
69 pub fn url(&self) -> Option<&str> {
71 self.url.as_deref()
72 }
73
74 pub fn region(&self) -> Option<&str> {
76 self.region.as_deref()
77 }
78}
79
80#[configurable_component(sink("axiom", "Deliver log events to Axiom."))]
82#[derive(Clone, Debug, Default)]
83pub struct AxiomConfig {
84 #[configurable(metadata(docs::examples = "${AXIOM_ORG_ID}"))]
88 #[configurable(metadata(docs::examples = "123abc"))]
89 pub org_id: Option<String>,
90
91 #[configurable(metadata(docs::examples = "${AXIOM_TOKEN}"))]
93 #[configurable(metadata(docs::examples = "123abc"))]
94 pub token: SensitiveString,
95
96 #[configurable(metadata(docs::examples = "${AXIOM_DATASET}"))]
98 #[configurable(metadata(docs::examples = "vector_rocks"))]
99 pub dataset: String,
100
101 #[serde(flatten)]
103 #[configurable(derived)]
104 pub endpoint: UrlOrRegion,
105
106 #[configurable(derived)]
107 #[serde(default)]
108 pub request: RequestConfig,
109
110 #[configurable(derived)]
112 #[serde(default = "Compression::zstd_default")]
113 pub compression: Compression,
114
115 #[configurable(derived)]
119 pub tls: Option<TlsConfig>,
120
121 #[configurable(derived)]
123 #[serde(default)]
124 pub batch: BatchConfig<RealtimeSizeBasedDefaultBatchSettings>,
125
126 #[configurable(derived)]
128 #[serde(
129 default,
130 deserialize_with = "crate::serde::bool_or_struct",
131 skip_serializing_if = "crate::serde::is_default"
132 )]
133 pub acknowledgements: AcknowledgementsConfig,
134
135 #[configurable(derived)]
136 #[serde(default)]
137 pub retry_strategy: RetryStrategy,
138
139 #[serde(flatten)]
140 pub confinement: ConfinementConfig,
141}
142
143impl GenerateConfig for AxiomConfig {
144 fn generate_config() -> serde_json::Value {
145 serde_yaml::from_str(indoc::indoc! {
146 r#"token: ${AXIOM_TOKEN}
147 dataset: ${AXIOM_DATASET}
148 url: ${AXIOM_URL}
149 org_id: ${AXIOM_ORG_ID}"#,
150 })
151 .unwrap()
152 }
153}
154
155#[async_trait::async_trait]
156#[typetag::serde(name = "axiom")]
157impl SinkConfig for AxiomConfig {
158 fn confinement_config(&self) -> Option<&crate::template::ConfinementConfig> {
159 Some(&self.confinement)
160 }
161
162 fn input(&self) -> Input {
163 Input::new(DataType::Metric | DataType::Log | DataType::Trace)
164 }
165
166 fn acknowledgements(&self) -> &AcknowledgementsConfig {
167 &self.acknowledgements
168 }
169
170 fn as_dyn_validated(&self) -> Option<&dyn DynValidatedSink> {
171 Some(self)
172 }
173}
174
175#[derive(Clone, Debug)]
176pub struct ValidatedAxiom {
177 uri: Template,
178 http: ValidatedHttp,
179}
180
181#[async_trait::async_trait]
182impl ValidatedSink for AxiomConfig {
183 type Validated = ValidatedAxiom;
184
185 fn validate(&self) -> crate::Result<ValidatedAxiom> {
186 self.endpoint.validate()?;
188
189 let uri: Template = self.build_endpoint().try_into()?;
191
192 let http_sink_config = self.http_sink_config(uri.clone())?;
197 let http = http_sink_config.validate()?;
198
199 Ok(ValidatedAxiom { uri, http })
200 }
201
202 async fn build(
203 &self,
204 validated: &ValidatedAxiom,
205 cx: SinkContext,
206 ) -> crate::Result<(VectorSink, Healthcheck)> {
207 let http_sink_config = self.http_sink_config(validated.uri.clone())?;
212 http_sink_config
213 .build_from_validated(&validated.http, cx, Self::NAME)
214 .await
215 }
216}
217
218impl AxiomConfig {
219 fn http_sink_config(&self, uri: Template) -> crate::Result<HttpSinkConfig> {
223 let mut request = self.request.clone();
224 if let Some(org_id) = &self.org_id {
225 request
227 .headers
228 .insert("X-Axiom-Org-Id".to_string(), org_id.clone());
229 }
230
231 Ok(HttpSinkConfig {
238 uri,
239 compression: self.compression,
240 auth: Some(HttpAuthConfig::Bearer {
241 token: self.token.clone(),
242 }),
243 method: HttpMethod::Post,
244 tls: self.tls.clone(),
245 request,
246 acknowledgements: self.acknowledgements,
247 batch: self.batch,
248 encoding: EncodingConfigWithFraming::new(
249 Some(FramingConfig::NewlineDelimited),
250 SerializerConfig::Json(JsonSerializerConfig {
251 metric_tag_values: MetricTagValues::Single,
252 options: JsonSerializerOptions { pretty: false }, }),
254 Transformer::default(),
255 ),
256 payload_prefix: "".into(), payload_suffix: "".into(), retry_strategy: self.retry_strategy.clone(),
259 confinement: self.confinement.clone(),
260 })
261 }
262
263 fn build_endpoint(&self) -> String {
264 if let Some(url) = self.endpoint.url() {
268 let url = url.trim_end_matches('/');
269
270 if let Ok(parsed) = url::Url::parse(url) {
274 let path = parsed.path();
275 if path.is_empty() || path == "/" {
276 return format!("{url}/v1/datasets/{}/ingest", self.dataset);
278 }
279 }
280
281 return url.to_string();
283 }
284
285 if let Some(region) = self.endpoint.region() {
287 let region = region.trim_end_matches('/');
288 return format!("https://{region}/v1/ingest/{}", self.dataset);
289 }
290
291 format!("{CLOUD_URL}/v1/datasets/{}/ingest", self.dataset)
293 }
294}
295
296#[cfg(test)]
297mod test {
298 #[test]
299 fn generate_config() {
300 crate::test_util::test_generate_config::<super::AxiomConfig>();
301 }
302
303 #[test]
304 fn test_region_domain_only() {
305 let config = super::AxiomConfig {
307 endpoint: super::UrlOrRegion {
308 region: Some("mumbai.axiomdomain.co".to_string()),
309 url: None,
310 },
311 dataset: "test-3".to_string(),
312 ..Default::default()
313 };
314 let endpoint = config.build_endpoint();
315 assert_eq!(endpoint, "https://mumbai.axiomdomain.co/v1/ingest/test-3");
316 }
317
318 #[test]
319 fn test_default_no_config() {
320 let config = super::AxiomConfig {
322 dataset: "foo".to_string(),
323 ..Default::default()
324 };
325 let endpoint = config.build_endpoint();
326 assert_eq!(endpoint, "https://api.axiom.co/v1/datasets/foo/ingest");
327 }
328
329 #[test]
330 fn test_url_with_custom_path() {
331 let config = super::AxiomConfig {
333 endpoint: super::UrlOrRegion {
334 url: Some("http://localhost:3400/ingest".to_string()),
335 region: None,
336 },
337 dataset: "meh".to_string(),
338 ..Default::default()
339 };
340 let endpoint = config.build_endpoint();
341 assert_eq!(endpoint, "http://localhost:3400/ingest");
342 }
343
344 #[test]
345 fn test_url_without_path_backwards_compat() {
346 let config = super::AxiomConfig {
348 endpoint: super::UrlOrRegion {
349 url: Some("https://api.eu.axiom.co".to_string()),
350 region: None,
351 },
352 dataset: "qoo".to_string(),
353 ..Default::default()
354 };
355 let endpoint = config.build_endpoint();
356 assert_eq!(endpoint, "https://api.eu.axiom.co/v1/datasets/qoo/ingest");
357
358 let config = super::AxiomConfig {
360 endpoint: super::UrlOrRegion {
361 url: Some("https://api.eu.axiom.co/".to_string()),
362 region: None,
363 },
364 dataset: "qoo".to_string(),
365 ..Default::default()
366 };
367 let endpoint = config.build_endpoint();
368 assert_eq!(endpoint, "https://api.eu.axiom.co/v1/datasets/qoo/ingest");
369 }
370
371 #[test]
372 fn validate_produces_usable_uri() {
373 use crate::config::ValidatedSink;
374 let config = super::AxiomConfig {
375 dataset: "foo".to_string(),
376 ..Default::default()
377 };
378 let validated = config.validate().expect("validation should succeed");
379 assert_eq!(
380 validated.uri.get_ref(),
381 "https://api.axiom.co/v1/datasets/foo/ingest"
382 );
383 }
384
385 #[test]
386 fn test_both_url_and_region_fails_validation() {
387 let endpoint = super::UrlOrRegion {
389 url: Some("http://localhost:3400/ingest".to_string()),
390 region: Some("mumbai.axiomdomain.co".to_string()),
391 };
392
393 let result = endpoint.validate();
394 assert!(result.is_err());
395 assert_eq!(
396 result.unwrap_err().to_string(),
397 "Cannot set both `url` and `region`. Please use only one."
398 );
399 }
400
401 #[test]
402 fn test_url_or_region_deserialization_with_url() {
403 let config: super::AxiomConfig = serde_yaml::from_str(indoc::indoc! {r#"
405 token: "test-token"
406 dataset: "test-dataset"
407 url: "https://api.eu.axiom.co"
408 "#})
409 .unwrap();
410
411 assert_eq!(config.endpoint.url(), Some("https://api.eu.axiom.co"));
412 assert_eq!(config.endpoint.region(), None);
413 }
414
415 #[test]
416 fn test_url_or_region_deserialization_with_region() {
417 let config: super::AxiomConfig = serde_yaml::from_str(indoc::indoc! {r#"
419 token: "test-token"
420 dataset: "test-dataset"
421 region: "mumbai.axiom.co"
422 "#})
423 .unwrap();
424
425 assert_eq!(config.endpoint.url(), None);
426 assert_eq!(config.endpoint.region(), Some("mumbai.axiom.co"));
427 }
428
429 #[test]
430 fn test_production_regional_edges() {
431 let config = super::AxiomConfig {
433 endpoint: super::UrlOrRegion {
434 region: Some("eu-central-1.aws.edge.axiom.co".to_string()),
435 url: None,
436 },
437 dataset: "my-dataset".to_string(),
438 ..Default::default()
439 };
440 let endpoint = config.build_endpoint();
441 assert_eq!(
442 endpoint,
443 "https://eu-central-1.aws.edge.axiom.co/v1/ingest/my-dataset"
444 );
445 }
446
447 #[test]
448 fn test_staging_environment_edges() {
449 let config = super::AxiomConfig {
451 endpoint: super::UrlOrRegion {
452 region: Some("us-east-1.edge.staging.axiomdomain.co".to_string()),
453 url: None,
454 },
455 dataset: "test-dataset".to_string(),
456 ..Default::default()
457 };
458 let endpoint = config.build_endpoint();
459 assert_eq!(
460 endpoint,
461 "https://us-east-1.edge.staging.axiomdomain.co/v1/ingest/test-dataset"
462 );
463 }
464
465 #[test]
466 fn test_dev_environment_edges() {
467 let config = super::AxiomConfig {
469 endpoint: super::UrlOrRegion {
470 region: Some("eu-west-1.edge.dev.axiomdomain.co".to_string()),
471 url: None,
472 },
473 dataset: "dev-dataset".to_string(),
474 ..Default::default()
475 };
476 let endpoint = config.build_endpoint();
477 assert_eq!(
478 endpoint,
479 "https://eu-west-1.edge.dev.axiomdomain.co/v1/ingest/dev-dataset"
480 );
481 }
482}