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::{AcknowledgementsConfig, DataType, GenerateConfig, Input, SinkConfig, SinkContext},
13 http::Auth as HttpAuthConfig,
14 sinks::{
15 Healthcheck, VectorSink,
16 http::config::{HttpMethod, HttpSinkConfig},
17 util::{
18 BatchConfig, Compression, RealtimeSizeBasedDefaultBatchSettings,
19 http::{RequestConfig, RetryStrategy},
20 },
21 },
22 template::ConfinementConfig,
23 tls::TlsConfig,
24};
25
26static CLOUD_URL: &str = "https://api.axiom.co";
27
28#[configurable_component]
30#[derive(Clone, Debug, Default)]
31#[serde(default)]
32pub struct UrlOrRegion {
33 #[configurable(validation(format = "uri"))]
39 #[configurable(metadata(docs::examples = "https://api.eu.axiom.co"))]
40 #[configurable(metadata(docs::examples = "http://localhost:3400/ingest"))]
41 #[configurable(metadata(docs::examples = "${AXIOM_URL}"))]
42 pub url: Option<String>,
43
44 #[configurable(metadata(docs::examples = "${AXIOM_REGION}"))]
50 #[configurable(metadata(docs::examples = "mumbai.axiom.co"))]
51 #[configurable(metadata(docs::examples = "eu-central-1.aws.edge.axiom.co"))]
52 pub region: Option<String>,
53}
54
55impl UrlOrRegion {
56 fn validate(&self) -> crate::Result<()> {
58 if self.url.is_some() && self.region.is_some() {
59 return Err("Cannot set both `url` and `region`. Please use only one.".into());
60 }
61 Ok(())
62 }
63
64 pub fn url(&self) -> Option<&str> {
66 self.url.as_deref()
67 }
68
69 pub fn region(&self) -> Option<&str> {
71 self.region.as_deref()
72 }
73}
74
75#[configurable_component(sink("axiom", "Deliver log events to Axiom."))]
77#[derive(Clone, Debug, Default)]
78pub struct AxiomConfig {
79 #[configurable(metadata(docs::examples = "${AXIOM_ORG_ID}"))]
83 #[configurable(metadata(docs::examples = "123abc"))]
84 pub org_id: Option<String>,
85
86 #[configurable(metadata(docs::examples = "${AXIOM_TOKEN}"))]
88 #[configurable(metadata(docs::examples = "123abc"))]
89 pub token: SensitiveString,
90
91 #[configurable(metadata(docs::examples = "${AXIOM_DATASET}"))]
93 #[configurable(metadata(docs::examples = "vector_rocks"))]
94 pub dataset: String,
95
96 #[serde(flatten)]
98 #[configurable(derived)]
99 pub endpoint: UrlOrRegion,
100
101 #[configurable(derived)]
102 #[serde(default)]
103 pub request: RequestConfig,
104
105 #[configurable(derived)]
107 #[serde(default = "Compression::zstd_default")]
108 pub compression: Compression,
109
110 #[configurable(derived)]
114 pub tls: Option<TlsConfig>,
115
116 #[configurable(derived)]
118 #[serde(default)]
119 pub batch: BatchConfig<RealtimeSizeBasedDefaultBatchSettings>,
120
121 #[configurable(derived)]
123 #[serde(
124 default,
125 deserialize_with = "crate::serde::bool_or_struct",
126 skip_serializing_if = "crate::serde::is_default"
127 )]
128 pub acknowledgements: AcknowledgementsConfig,
129
130 #[configurable(derived)]
131 #[serde(default)]
132 pub retry_strategy: RetryStrategy,
133
134 #[serde(flatten)]
135 pub confinement: ConfinementConfig,
136}
137
138impl GenerateConfig for AxiomConfig {
139 fn generate_config() -> toml::Value {
140 toml::from_str(
141 r#"token = "${AXIOM_TOKEN}"
142 dataset = "${AXIOM_DATASET}"
143 url = "${AXIOM_URL}"
144 org_id = "${AXIOM_ORG_ID}""#,
145 )
146 .unwrap()
147 }
148}
149
150#[async_trait::async_trait]
151#[typetag::serde(name = "axiom")]
152impl SinkConfig for AxiomConfig {
153 async fn build(&self, cx: SinkContext) -> crate::Result<(VectorSink, Healthcheck)> {
154 self.endpoint.validate()?;
156
157 let mut request = self.request.clone();
158 if let Some(org_id) = &self.org_id {
159 request
161 .headers
162 .insert("X-Axiom-Org-Id".to_string(), org_id.clone());
163 }
164
165 let http_sink_config = HttpSinkConfig {
172 uri: self.build_endpoint().try_into()?,
173 compression: self.compression,
174 auth: Some(HttpAuthConfig::Bearer {
175 token: self.token.clone(),
176 }),
177 method: HttpMethod::Post,
178 tls: self.tls.clone(),
179 request,
180 acknowledgements: self.acknowledgements,
181 batch: self.batch,
182 encoding: EncodingConfigWithFraming::new(
183 Some(FramingConfig::NewlineDelimited),
184 SerializerConfig::Json(JsonSerializerConfig {
185 metric_tag_values: MetricTagValues::Single,
186 options: JsonSerializerOptions { pretty: false }, }),
188 Transformer::default(),
189 ),
190 payload_prefix: "".into(), payload_suffix: "".into(), retry_strategy: self.retry_strategy.clone(),
193 confinement: self.confinement.clone(),
194 };
195
196 let result = http_sink_config
200 .build_without_confinement_gauge(cx, Self::NAME)
201 .await?;
202 self.confinement.set_confinement_gauge("sink", Self::NAME);
203 Ok(result)
204 }
205
206 fn input(&self) -> Input {
207 Input::new(DataType::Metric | DataType::Log | DataType::Trace)
208 }
209
210 fn acknowledgements(&self) -> &AcknowledgementsConfig {
211 &self.acknowledgements
212 }
213}
214
215impl AxiomConfig {
216 fn build_endpoint(&self) -> String {
217 if let Some(url) = self.endpoint.url() {
221 let url = url.trim_end_matches('/');
222
223 if let Ok(parsed) = url::Url::parse(url) {
227 let path = parsed.path();
228 if path.is_empty() || path == "/" {
229 return format!("{url}/v1/datasets/{}/ingest", self.dataset);
231 }
232 }
233
234 return url.to_string();
236 }
237
238 if let Some(region) = self.endpoint.region() {
240 let region = region.trim_end_matches('/');
241 return format!("https://{region}/v1/ingest/{}", self.dataset);
242 }
243
244 format!("{CLOUD_URL}/v1/datasets/{}/ingest", self.dataset)
246 }
247}
248
249#[cfg(test)]
250mod test {
251 #[test]
252 fn generate_config() {
253 crate::test_util::test_generate_config::<super::AxiomConfig>();
254 }
255
256 #[test]
257 fn test_region_domain_only() {
258 let config = super::AxiomConfig {
260 endpoint: super::UrlOrRegion {
261 region: Some("mumbai.axiomdomain.co".to_string()),
262 url: None,
263 },
264 dataset: "test-3".to_string(),
265 ..Default::default()
266 };
267 let endpoint = config.build_endpoint();
268 assert_eq!(endpoint, "https://mumbai.axiomdomain.co/v1/ingest/test-3");
269 }
270
271 #[test]
272 fn test_default_no_config() {
273 let config = super::AxiomConfig {
275 dataset: "foo".to_string(),
276 ..Default::default()
277 };
278 let endpoint = config.build_endpoint();
279 assert_eq!(endpoint, "https://api.axiom.co/v1/datasets/foo/ingest");
280 }
281
282 #[test]
283 fn test_url_with_custom_path() {
284 let config = super::AxiomConfig {
286 endpoint: super::UrlOrRegion {
287 url: Some("http://localhost:3400/ingest".to_string()),
288 region: None,
289 },
290 dataset: "meh".to_string(),
291 ..Default::default()
292 };
293 let endpoint = config.build_endpoint();
294 assert_eq!(endpoint, "http://localhost:3400/ingest");
295 }
296
297 #[test]
298 fn test_url_without_path_backwards_compat() {
299 let config = super::AxiomConfig {
301 endpoint: super::UrlOrRegion {
302 url: Some("https://api.eu.axiom.co".to_string()),
303 region: None,
304 },
305 dataset: "qoo".to_string(),
306 ..Default::default()
307 };
308 let endpoint = config.build_endpoint();
309 assert_eq!(endpoint, "https://api.eu.axiom.co/v1/datasets/qoo/ingest");
310
311 let config = super::AxiomConfig {
313 endpoint: super::UrlOrRegion {
314 url: Some("https://api.eu.axiom.co/".to_string()),
315 region: None,
316 },
317 dataset: "qoo".to_string(),
318 ..Default::default()
319 };
320 let endpoint = config.build_endpoint();
321 assert_eq!(endpoint, "https://api.eu.axiom.co/v1/datasets/qoo/ingest");
322 }
323
324 #[test]
325 fn test_both_url_and_region_fails_validation() {
326 let endpoint = super::UrlOrRegion {
328 url: Some("http://localhost:3400/ingest".to_string()),
329 region: Some("mumbai.axiomdomain.co".to_string()),
330 };
331
332 let result = endpoint.validate();
333 assert!(result.is_err());
334 assert_eq!(
335 result.unwrap_err().to_string(),
336 "Cannot set both `url` and `region`. Please use only one."
337 );
338 }
339
340 #[test]
341 fn test_url_or_region_deserialization_with_url() {
342 let config: super::AxiomConfig = serde_yaml::from_str(indoc::indoc! {r#"
344 token: "test-token"
345 dataset: "test-dataset"
346 url: "https://api.eu.axiom.co"
347 "#})
348 .unwrap();
349
350 assert_eq!(config.endpoint.url(), Some("https://api.eu.axiom.co"));
351 assert_eq!(config.endpoint.region(), None);
352 }
353
354 #[test]
355 fn test_url_or_region_deserialization_with_region() {
356 let config: super::AxiomConfig = serde_yaml::from_str(indoc::indoc! {r#"
358 token: "test-token"
359 dataset: "test-dataset"
360 region: "mumbai.axiom.co"
361 "#})
362 .unwrap();
363
364 assert_eq!(config.endpoint.url(), None);
365 assert_eq!(config.endpoint.region(), Some("mumbai.axiom.co"));
366 }
367
368 #[test]
369 fn test_production_regional_edges() {
370 let config = super::AxiomConfig {
372 endpoint: super::UrlOrRegion {
373 region: Some("eu-central-1.aws.edge.axiom.co".to_string()),
374 url: None,
375 },
376 dataset: "my-dataset".to_string(),
377 ..Default::default()
378 };
379 let endpoint = config.build_endpoint();
380 assert_eq!(
381 endpoint,
382 "https://eu-central-1.aws.edge.axiom.co/v1/ingest/my-dataset"
383 );
384 }
385
386 #[test]
387 fn test_staging_environment_edges() {
388 let config = super::AxiomConfig {
390 endpoint: super::UrlOrRegion {
391 region: Some("us-east-1.edge.staging.axiomdomain.co".to_string()),
392 url: None,
393 },
394 dataset: "test-dataset".to_string(),
395 ..Default::default()
396 };
397 let endpoint = config.build_endpoint();
398 assert_eq!(
399 endpoint,
400 "https://us-east-1.edge.staging.axiomdomain.co/v1/ingest/test-dataset"
401 );
402 }
403
404 #[test]
405 fn test_dev_environment_edges() {
406 let config = super::AxiomConfig {
408 endpoint: super::UrlOrRegion {
409 region: Some("eu-west-1.edge.dev.axiomdomain.co".to_string()),
410 url: None,
411 },
412 dataset: "dev-dataset".to_string(),
413 ..Default::default()
414 };
415 let endpoint = config.build_endpoint();
416 assert_eq!(
417 endpoint,
418 "https://eu-west-1.edge.dev.axiomdomain.co/v1/ingest/dev-dataset"
419 );
420 }
421}