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::{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/// Configuration of the URL/region to use when interacting with Axiom.
29#[configurable_component]
30#[derive(Clone, Debug, Default)]
31#[serde(default)]
32pub struct UrlOrRegion {
33    /// URI of the Axiom endpoint to send data to.
34    ///
35    /// If a path is provided, the URL is used as-is.
36    /// If no path (or only `/`) is provided, `/v1/datasets/{dataset}/ingest` is appended for backwards compatibility.
37    /// This takes precedence over `region` if both are set (but both should not be set).
38    #[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    /// The Axiom regional edge domain to use for ingestion.
45    ///
46    /// Specify the domain name only (no scheme, no path).
47    /// When set, data is sent to `https://{region}/v1/ingest/{dataset}`.
48    /// Cannot be used together with `url`.
49    #[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    /// Validates that url and region are not both set.
57    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    /// Returns the url if set.
65    pub fn url(&self) -> Option<&str> {
66        self.url.as_deref()
67    }
68
69    /// Returns the region if set.
70    pub fn region(&self) -> Option<&str> {
71        self.region.as_deref()
72    }
73}
74
75/// Configuration for the `axiom` sink.
76#[configurable_component(sink("axiom", "Deliver log events to Axiom."))]
77#[derive(Clone, Debug, Default)]
78pub struct AxiomConfig {
79    /// The Axiom organization ID.
80    ///
81    /// Only required when using personal tokens.
82    #[configurable(metadata(docs::examples = "${AXIOM_ORG_ID}"))]
83    #[configurable(metadata(docs::examples = "123abc"))]
84    pub org_id: Option<String>,
85
86    /// The Axiom API token.
87    #[configurable(metadata(docs::examples = "${AXIOM_TOKEN}"))]
88    #[configurable(metadata(docs::examples = "123abc"))]
89    pub token: SensitiveString,
90
91    /// The Axiom dataset to write to.
92    #[configurable(metadata(docs::examples = "${AXIOM_DATASET}"))]
93    #[configurable(metadata(docs::examples = "vector_rocks"))]
94    pub dataset: String,
95
96    /// Configuration for the URL or regional edge endpoint.
97    #[serde(flatten)]
98    #[configurable(derived)]
99    pub endpoint: UrlOrRegion,
100
101    #[configurable(derived)]
102    #[serde(default)]
103    pub request: RequestConfig,
104
105    /// The compression algorithm to use.
106    #[configurable(derived)]
107    #[serde(default = "Compression::zstd_default")]
108    pub compression: Compression,
109
110    /// The TLS settings for the connection.
111    ///
112    /// Optional, constrains TLS settings for this sink.
113    #[configurable(derived)]
114    pub tls: Option<TlsConfig>,
115
116    /// The batch settings for the sink.
117    #[configurable(derived)]
118    #[serde(default)]
119    pub batch: BatchConfig<RealtimeSizeBasedDefaultBatchSettings>,
120
121    /// Controls how acknowledgements are handled for this sink.
122    #[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        // Validate that url and region are not both set
155        self.endpoint.validate()?;
156
157        let mut request = self.request.clone();
158        if let Some(org_id) = &self.org_id {
159            // NOTE: Only add the org id header if an org id is provided
160            request
161                .headers
162                .insert("X-Axiom-Org-Id".to_string(), org_id.clone());
163        }
164
165        // Axiom has a custom high-performance database that can be ingested
166        // into using the native HTTP ingest endpoint. This configuration wraps
167        // the vector HTTP sink with the necessary adjustments to send data
168        // to Axiom, whilst keeping the configuration simple and easy to use
169        // and maintenance of the vector axiom sink to a minimum.
170        //
171        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 }, // Minified JSON
187                }),
188                Transformer::default(),
189            ),
190            payload_prefix: "".into(), // Always newline delimited JSON
191            payload_suffix: "".into(), // Always newline delimited JSON
192            retry_strategy: self.retry_strategy.clone(),
193            confinement: self.confinement.clone(),
194        };
195
196        // Route through the HTTP builder that doesn't emit the gauge, so
197        // per-template warnings and the sink-level gauge both carry
198        // `component_type=axiom` — not `http`.
199        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        // Priority: url > region > default cloud endpoint
218
219        // If url is set, check if it has a path
220        if let Some(url) = self.endpoint.url() {
221            let url = url.trim_end_matches('/');
222
223            // Parse URL to check if path is provided
224            // If path is empty or just "/", append the legacy format for backwards compatibility
225            // Otherwise, use the URL as-is
226            if let Ok(parsed) = url::Url::parse(url) {
227                let path = parsed.path();
228                if path.is_empty() || path == "/" {
229                    // Backwards compatibility: append legacy path format
230                    return format!("{url}/v1/datasets/{}/ingest", self.dataset);
231                }
232            }
233
234            // URL has a custom path, use as-is
235            return url.to_string();
236        }
237
238        // If region is set, build the regional edge endpoint
239        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        // Default: use cloud endpoint with legacy path format
245        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        // region: mumbai.axiomdomain.co → https://mumbai.axiomdomain.co/v1/ingest/test-3
259        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        // No url, no region → https://api.axiom.co/v1/datasets/foo/ingest
274        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        // url: http://localhost:3400/ingest → http://localhost:3400/ingest (as-is)
285        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        // url: https://api.eu.axiom.co/ → https://api.eu.axiom.co/v1/datasets/qoo/ingest
300        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        // Also test with trailing slash
312        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        // When both url and region are set, validation should fail
327        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        // Test that url can be deserialized at the top level (flattened)
343        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        // Test that region can be deserialized at the top level (flattened)
357        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        // Production AWS edge
371        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        // Staging environment edge
389        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        // Dev environment edge
407        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}