Skip to main content

vector/sinks/axiom/
config.rs

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/// Configuration of the URL/region to use when interacting with Axiom.
32#[configurable_component]
33#[derive(Clone, Debug, Default)]
34#[serde(default)]
35pub struct UrlOrRegion {
36    /// URI of the Axiom endpoint to send data to.
37    ///
38    /// If a path is provided, the URL is used as-is.
39    /// If no path (or only `/`) is provided, `/v1/datasets/{dataset}/ingest` is appended for backwards compatibility.
40    /// This takes precedence over `region` if both are set (but both should not be set).
41    //
42    // No examples are rendered for this field: `url` and `region` are mutually
43    // exclusive but neither is required, and the example-config generator emits
44    // every field that has examples, which would produce an invalid config with
45    // both set.
46    #[configurable(validation(format = "uri"))]
47    pub url: Option<String>,
48
49    /// The Axiom regional edge domain to use for ingestion.
50    ///
51    /// Specify the domain name only (no scheme, no path).
52    /// When set, data is sent to `https://{region}/v1/ingest/{dataset}`.
53    /// Cannot be used together with `url`.
54    #[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    /// Validates that url and region are not both set.
62    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    /// Returns the url if set.
70    pub fn url(&self) -> Option<&str> {
71        self.url.as_deref()
72    }
73
74    /// Returns the region if set.
75    pub fn region(&self) -> Option<&str> {
76        self.region.as_deref()
77    }
78}
79
80/// Configuration for the `axiom` sink.
81#[configurable_component(sink("axiom", "Deliver log events to Axiom."))]
82#[derive(Clone, Debug, Default)]
83pub struct AxiomConfig {
84    /// The Axiom organization ID.
85    ///
86    /// Only required when using personal tokens.
87    #[configurable(metadata(docs::examples = "${AXIOM_ORG_ID}"))]
88    #[configurable(metadata(docs::examples = "123abc"))]
89    pub org_id: Option<String>,
90
91    /// The Axiom API token.
92    #[configurable(metadata(docs::examples = "${AXIOM_TOKEN}"))]
93    #[configurable(metadata(docs::examples = "123abc"))]
94    pub token: SensitiveString,
95
96    /// The Axiom dataset to write to.
97    #[configurable(metadata(docs::examples = "${AXIOM_DATASET}"))]
98    #[configurable(metadata(docs::examples = "vector_rocks"))]
99    pub dataset: String,
100
101    /// Configuration for the URL or regional edge endpoint.
102    #[serde(flatten)]
103    #[configurable(derived)]
104    pub endpoint: UrlOrRegion,
105
106    #[configurable(derived)]
107    #[serde(default)]
108    pub request: RequestConfig,
109
110    /// The compression algorithm to use.
111    #[configurable(derived)]
112    #[serde(default = "Compression::zstd_default")]
113    pub compression: Compression,
114
115    /// The TLS settings for the connection.
116    ///
117    /// Optional, constrains TLS settings for this sink.
118    #[configurable(derived)]
119    pub tls: Option<TlsConfig>,
120
121    /// The batch settings for the sink.
122    #[configurable(derived)]
123    #[serde(default)]
124    pub batch: BatchConfig<RealtimeSizeBasedDefaultBatchSettings>,
125
126    /// Controls how acknowledgements are handled for this sink.
127    #[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        // Validate that url and region are not both set
187        self.endpoint.validate()?;
188
189        // Resolve and parse the ingest endpoint up front (pure, no I/O).
190        let uri: Template = self.build_endpoint().try_into()?;
191
192        // Construct and validate the derived HTTP config up front so
193        // `vector validate --no-environment` catches pure HTTP sink errors
194        // (invalid batch settings, invalid `X-Axiom-Org-Id` header value, ...)
195        // that the delegated HTTP sink would otherwise only reject at build.
196        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        // Route through the HTTP builder threaded with our own component type,
208        // so per-template security warnings carry `component_type=axiom` rather
209        // than `http`. The derived HTTP config was already constructed and
210        // validated during `validate`, so we build from the retained state.
211        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    /// Build the derived HTTP sink configuration. The org-id header is added
220    /// here so the derived config (including the header value) is validated
221    /// during `validate`.
222    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            // NOTE: Only add the org id header if an org id is provided
226            request
227                .headers
228                .insert("X-Axiom-Org-Id".to_string(), org_id.clone());
229        }
230
231        // Axiom has a custom high-performance database that can be ingested
232        // into using the native HTTP ingest endpoint. This configuration wraps
233        // the vector HTTP sink with the necessary adjustments to send data
234        // to Axiom, whilst keeping the configuration simple and easy to use
235        // and maintenance of the vector axiom sink to a minimum.
236        //
237        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 }, // Minified JSON
253                }),
254                Transformer::default(),
255            ),
256            payload_prefix: "".into(), // Always newline delimited JSON
257            payload_suffix: "".into(), // Always newline delimited JSON
258            retry_strategy: self.retry_strategy.clone(),
259            confinement: self.confinement.clone(),
260        })
261    }
262
263    fn build_endpoint(&self) -> String {
264        // Priority: url > region > default cloud endpoint
265
266        // If url is set, check if it has a path
267        if let Some(url) = self.endpoint.url() {
268            let url = url.trim_end_matches('/');
269
270            // Parse URL to check if path is provided
271            // If path is empty or just "/", append the legacy format for backwards compatibility
272            // Otherwise, use the URL as-is
273            if let Ok(parsed) = url::Url::parse(url) {
274                let path = parsed.path();
275                if path.is_empty() || path == "/" {
276                    // Backwards compatibility: append legacy path format
277                    return format!("{url}/v1/datasets/{}/ingest", self.dataset);
278                }
279            }
280
281            // URL has a custom path, use as-is
282            return url.to_string();
283        }
284
285        // If region is set, build the regional edge endpoint
286        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        // Default: use cloud endpoint with legacy path format
292        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        // region: mumbai.axiomdomain.co → https://mumbai.axiomdomain.co/v1/ingest/test-3
306        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        // No url, no region → https://api.axiom.co/v1/datasets/foo/ingest
321        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        // url: http://localhost:3400/ingest → http://localhost:3400/ingest (as-is)
332        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        // url: https://api.eu.axiom.co/ → https://api.eu.axiom.co/v1/datasets/qoo/ingest
347        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        // Also test with trailing slash
359        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        // When both url and region are set, validation should fail
388        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        // Test that url can be deserialized at the top level (flattened)
404        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        // Test that region can be deserialized at the top level (flattened)
418        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        // Production AWS edge
432        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        // Staging environment edge
450        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        // Dev environment edge
468        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}