diff --git a/.config/nextest.toml b/.config/nextest.toml index b21c53f67f28d..aad9ef51d94e2 100644 --- a/.config/nextest.toml +++ b/.config/nextest.toml @@ -16,6 +16,13 @@ fail-fast = false # timeout tests after 2 minutes slow-timeout = { period = "30s", terminate-after = 4 } +# blackbox_exporter tests need more time due to HTTP probing +[[profile.default.overrides]] +filter = 'test(blackbox_exporter)' +slow-timeout = { period = "5m", terminate-after = 4 } +# Also increase the hard timeout for these tests +timeout = { period = "10m" } + [profile.default.junit] # output test results at target/nextest/default/junit.xml path = "junit.xml" diff --git a/.github/workflows/build.yaml b/.github/workflows/build.yaml index 606fdc6a6693c..4b4100c1cde75 100644 --- a/.github/workflows/build.yaml +++ b/.github/workflows/build.yaml @@ -5,6 +5,7 @@ on: branches: - main - master + - feat/* workflow_dispatch: permissions: @@ -87,6 +88,11 @@ jobs: # sudo make install_sw # cd .. + - name: Run Tests + run: | + cargo nextest run --release --workspace --no-fail-fast -E 'test(blackbox_exporter)' + echo "All tests passed" + - name: Build Vector # env: # RUSTFLAGS: "-C target-feature=+crt-static" @@ -95,21 +101,16 @@ jobs: # OPENSSL_DIR: /musl # OPENSSL_NO_VENDOR: "1" run: | - cargo build --release + cargo build --release -p vector echo "Build completed successfully" - - - name: Run Tests - run: | - cargo nextest run --release --workspace --no-fail-fast --no-default-features --features default -E 'test(blackbox_exporter)' --slow-timeout 5m --terminate-after 4 - echo "All tests passed" - + - name: Create Release Archive run: | mkdir -p release - cp target/x86_64-unknown-linux-musl/release/vector release/ + cp target/release/vector release/ cd release - tar -czf vector-${{ steps.get_version.outputs.VERSION }}-x86_64-unknown-linux-musl.tar.gz vector - sha256sum vector-${{ steps.get_version.outputs.VERSION }}-x86_64-unknown-linux-musl.tar.gz > vector-${{ steps.get_version.outputs.VERSION }}-x86_64-unknown-linux-musl.tar.gz.sha256 + tar -czf vector-${{ steps.get_version.outputs.VERSION }}.tar.gz vector + sha256sum vector-${{ steps.get_version.outputs.VERSION }}.tar.gz > vector-${{ steps.get_version.outputs.VERSION }}.tar.gz.sha256 - name: Upload Release Artifacts uses: actions/upload-artifact@v4 @@ -121,7 +122,8 @@ jobs: release: name: Create Release needs: build-and-test - runs-on: ubuntu-latest + if: github.ref == 'refs/heads/main' || github.ref == 'refs/heads/master' + runs-on: vector-builder environment: name: production url: https://github.com/${{ github.repository }}/releases/tag/v${{ needs.build-and-test.outputs.version }} @@ -140,8 +142,8 @@ jobs: draft: false prerelease: false files: | - release/vector-${{ needs.build-and-test.outputs.version }}-x86_64-unknown-linux-musl.tar.gz - release/vector-${{ needs.build-and-test.outputs.version }}-x86_64-unknown-linux-musl.tar.gz.sha256 + release/vector-${{ needs.build-and-test.outputs.version }}.tar.gz + release/vector-${{ needs.build-and-test.outputs.version }}.tar.gz.sha256 body: | ## Vector v${{ needs.build-and-test.outputs.version }} @@ -151,8 +153,8 @@ jobs: ### Installation ```bash - curl -LO https://github.com/${{ github.repository }}/releases/download/v${{ needs.build-and-test.outputs.version }}/vector-${{ needs.build-and-test.outputs.version }}-x86_64-unknown-linux-musl.tar.gz - tar -xzf vector-${{ needs.build-and-test.outputs.version }}-x86_64-unknown-linux-musl.tar.gz + curl -LO https://github.com/${{ github.repository }}/releases/download/v${{ needs.build-and-test.outputs.version }}/vector-${{ needs.build-and-test.outputs.version }}.tar.gz + tar -xzf vector-${{ needs.build-and-test.outputs.version }}.tar.gz ./vector --version ``` env: diff --git a/config/examples/blackbox_exporter_http.yaml b/config/examples/blackbox_exporter_http.yaml index fc352594c6cca..31feb03759282 100644 --- a/config/examples/blackbox_exporter_http.yaml +++ b/config/examples/blackbox_exporter_http.yaml @@ -1,6 +1,8 @@ -# Blackbox Exporter HTTP probing example +# Blackbox Exporter HTTP probing example with optional labels # ------------------------------------------------------------------------------ # Scraping metrics from Blackbox Exporter HTTP probes and sending to console +# This example demonstrates using predefined optional labels to enrich metrics +# with geographic and infrastructure context data_dir: "/var/lib/vector" @@ -21,6 +23,19 @@ sources: scrape_interval_secs: 15 # Scrape timeout in seconds scrape_timeout_secs: 10 + # Optional labels to enrich all metrics with contextual information + # Geohash of the probe location + geohash: "9qx7hh9jd" + # Probe region (e.g., AMER, EMEA, APAC) + region: "AMER" + # Probe location (city or location name) + location: "Oregon" + # Two-digit country code + country: "US" + # Check friendly name + name: "Public Sites Monitor" + # Infrastructure provider + provider: "AWS" # Output sinks: diff --git a/config/examples/blackbox_exporter_labels.yaml b/config/examples/blackbox_exporter_labels.yaml new file mode 100644 index 0000000000000..99e47f74fe62e --- /dev/null +++ b/config/examples/blackbox_exporter_labels.yaml @@ -0,0 +1,92 @@ +# Blackbox Exporter with combined predefined and ad-hoc labels +# ------------------------------------------------------------------------------ +# Scraping metrics from Blackbox Exporter with both predefined optional labels +# and custom ad-hoc labels. This example demonstrates the full flexibility of +# the optional labels feature. + +data_dir: "/var/lib/vector" + +# Ingest +sources: + # Probe from US West Coast location + blackbox_us_west: + type: "blackbox_exporter" + url: "http://localhost:9115" + targets: + - "https://api.example.com" + - "https://app.example.com" + module: "http_2xx" + scrape_interval_secs: 15 + scrape_timeout_secs: 10 + # Predefined optional labels for geographic context + geohash: "9qx7hh9jd" + region: "AMER" + location: "Oregon" + country: "US" + name: "US West API Monitor" + provider: "AWS" + # Ad-hoc custom labels for additional context + labels: + environment: "production" + team: "platform" + datacenter: "us-west-2" + cost_center: "engineering" + + # Probe from European location + blackbox_eu: + type: "blackbox_exporter" + url: "http://blackbox-eu.example.com" + targets: + - "https://api.example.com" + - "https://app.example.com" + module: "http_2xx" + scrape_interval_secs: 15 + scrape_timeout_secs: 10 + # Predefined optional labels for European probe + geohash: "u4pruydqqvj" + region: "EMEA" + location: "Paris" + country: "FR" + name: "EU API Monitor" + provider: "GCP" + # Ad-hoc custom labels + labels: + environment: "production" + team: "platform" + datacenter: "europe-west1" + cost_center: "engineering" + + # Probe with partial labels (only some predefined labels) + blackbox_minimal: + type: "blackbox_exporter" + url: "http://localhost:9115" + targets: + - "https://internal.example.com" + module: "http_2xx" + scrape_interval_secs: 30 + scrape_timeout_secs: 10 + # Only specify the labels you need + region: "AMER" + name: "Internal Services" + # Ad-hoc labels can be used without predefined labels + labels: + environment: "staging" + internal: "true" + +# Transform - Optional: Add computed labels based on metric values +transforms: + enrich_metrics: + inputs: ["blackbox_us_west", "blackbox_eu", "blackbox_minimal"] + type: "remap" + source: | + # Add alert severity based on probe success + if .name == "probe_success" && .gauge.value == 0 { + .tags.severity = "critical" + } + +# Output +sinks: + prometheus: + inputs: ["enrich_metrics"] + type: "prometheus_remote_write" + endpoint: "http://localhost:9090/api/v1/write" diff --git a/config/examples/blackbox_exporter_multiple.yaml b/config/examples/blackbox_exporter_multiple.yaml index e18f31debe4bd..d3625e9c9fd02 100644 --- a/config/examples/blackbox_exporter_multiple.yaml +++ b/config/examples/blackbox_exporter_multiple.yaml @@ -1,6 +1,8 @@ -# Blackbox Exporter multiple targets example +# Blackbox Exporter multiple targets example with ad-hoc labels # ------------------------------------------------------------------------------ # Scraping metrics from multiple Blackbox Exporter probes with different modules +# This example demonstrates using ad-hoc custom labels to add arbitrary +# contextual information to metrics data_dir: "/var/lib/vector" @@ -16,6 +18,11 @@ sources: module: "http_2xx" scrape_interval_secs: 15 scrape_timeout_secs: 10 + # Ad-hoc custom labels for additional context + labels: + environment: "production" + team: "platform" + service_tier: "critical" # HTTPS probes with certificate validation blackbox_https: @@ -27,6 +34,11 @@ sources: module: "http_2xx_tls" scrape_interval_secs: 60 scrape_timeout_secs: 15 + # Different labels for different probe types + labels: + environment: "production" + team: "security" + compliance: "pci-dss" # TCP probes for service availability blackbox_tcp: @@ -38,6 +50,11 @@ sources: module: "tcp_connect" scrape_interval_secs: 30 scrape_timeout_secs: 5 + # Labels for infrastructure monitoring + labels: + environment: "production" + team: "database" + cost_center: "engineering" # Transform - Add environment label transforms: diff --git a/src/sources/blackbox_exporter/mod.rs b/src/sources/blackbox_exporter/mod.rs index c56b8116a1cbc..53b639cfc7416 100644 --- a/src/sources/blackbox_exporter/mod.rs +++ b/src/sources/blackbox_exporter/mod.rs @@ -100,6 +100,68 @@ pub struct BlackboxExporterConfig { #[configurable(derived)] #[configurable(metadata(docs::advanced))] auth: Option, + + /// Geohash of the probe location. + /// + /// This label will be added to all scraped metrics to identify the precise + /// geographic location of the probe. + #[configurable(metadata(docs::examples = "9qx7hh9jd"))] + #[configurable(metadata(docs::examples = "u4pruydqqvj"))] + geohash: Option, + + /// Probe region (e.g., AMER, EMEA, APAC). + /// + /// This label will be added to all scraped metrics to identify which broad + /// geographic region the probe is in. + #[configurable(metadata(docs::examples = "AMER"))] + #[configurable(metadata(docs::examples = "EMEA"))] + #[configurable(metadata(docs::examples = "APAC"))] + region: Option, + + /// Probe location (city or location name). + /// + /// This label will be added to all scraped metrics to identify the specific + /// city or location name of the probe. + #[configurable(metadata(docs::examples = "Paris"))] + #[configurable(metadata(docs::examples = "New York"))] + #[configurable(metadata(docs::examples = "Oregon"))] + location: Option, + + /// Two-digit country code. + /// + /// This label will be added to all scraped metrics to identify which country + /// the probe is located in. + #[configurable(metadata(docs::examples = "US"))] + #[configurable(metadata(docs::examples = "CA"))] + #[configurable(metadata(docs::examples = "FR"))] + country: Option, + + /// Check friendly name. + /// + /// This label will be added to all scraped metrics to give the check a + /// friendly, human-readable identifier. + #[configurable(metadata(docs::examples = "Google"))] + #[configurable(metadata(docs::examples = "Homepage"))] + #[configurable(metadata(docs::examples = "API Health"))] + name: Option, + + /// Infrastructure provider. + /// + /// This label will be added to all scraped metrics to identify which + /// infrastructure provider the probe is running on. + #[configurable(metadata(docs::examples = "AWS"))] + #[configurable(metadata(docs::examples = "GCP"))] + #[configurable(metadata(docs::examples = "AZURE"))] + provider: Option, + + /// Additional custom labels to add to all metrics. + /// + /// This allows you to add arbitrary key-value pairs as labels to all scraped + /// metrics. These labels are added after the predefined optional labels. + #[configurable(metadata( + docs::additional_props_description = "An arbitrary key-value pair to add as a label." + ))] + labels: Option>, } impl GenerateConfig for BlackboxExporterConfig { @@ -112,6 +174,16 @@ impl GenerateConfig for BlackboxExporterConfig { timeout: default_timeout(), tls: None, auth: None, + geohash: Some("9qx7hh9jd".to_string()), + region: Some("AMER".to_string()), + location: Some("Oregon".to_string()), + country: Some("US".to_string()), + name: Some("Example Check".to_string()), + provider: Some("AWS".to_string()), + labels: Some(HashMap::from([ + ("environment".to_string(), "production".to_string()), + ("team".to_string(), "platform".to_string()), + ])), }) .unwrap() } @@ -142,6 +214,7 @@ impl SourceConfig for BlackboxExporterConfig { // Create BlackboxExporterBuilder instance let builder = BlackboxExporterBuilder { module: self.module.clone(), + optional_labels: OptionalLabels::from_config(self), }; // Emit warning if timeout >= interval @@ -213,10 +286,41 @@ fn construct_probe_url( url_string.parse() } +/// Optional labels to add to all scraped metrics. +/// +/// This struct holds both predefined optional labels (geohash, region, location, +/// country, name, provider) and custom ad-hoc labels from the labels map. +#[derive(Clone, Debug)] +struct OptionalLabels { + geohash: Option, + region: Option, + location: Option, + country: Option, + name: Option, + provider: Option, + custom: HashMap, +} + +impl OptionalLabels { + /// Constructs OptionalLabels from BlackboxExporterConfig. + fn from_config(config: &BlackboxExporterConfig) -> Self { + Self { + geohash: config.geohash.clone(), + region: config.region.clone(), + location: config.location.clone(), + country: config.country.clone(), + name: config.name.clone(), + provider: config.provider.clone(), + custom: config.labels.clone().unwrap_or_default(), + } + } +} + /// Captures the configuration options required to build request-specific context. #[derive(Clone)] struct BlackboxExporterBuilder { module: String, + optional_labels: OptionalLabels, } impl HttpClientBuilder for BlackboxExporterBuilder { @@ -242,6 +346,7 @@ impl HttpClientBuilder for BlackboxExporterBuilder { BlackboxExporterContext { target, module: self.module.clone(), + optional_labels: self.optional_labels.clone(), } } } @@ -250,6 +355,8 @@ impl HttpClientBuilder for BlackboxExporterBuilder { struct BlackboxExporterContext { target: String, module: String, + #[allow(dead_code)] + optional_labels: OptionalLabels, } impl HttpClientContext for BlackboxExporterContext { @@ -294,6 +401,50 @@ impl HttpClientContext for BlackboxExporterContext { } // Add new module tag metric.replace_tag("module".to_string(), self.module.clone()); + + // Add predefined optional labels (after target and module) + self.add_optional_label(metric, "geohash", &self.optional_labels.geohash); + self.add_optional_label(metric, "region", &self.optional_labels.region); + self.add_optional_label(metric, "location", &self.optional_labels.location); + self.add_optional_label(metric, "country", &self.optional_labels.country); + self.add_optional_label(metric, "name", &self.optional_labels.name); + self.add_optional_label(metric, "provider", &self.optional_labels.provider); + + // Add ad-hoc custom labels + // Ad-hoc labels override predefined labels with the same key + for (key, value) in &self.optional_labels.custom { + self.add_optional_label(metric, key, &Some(value.clone())); + } + } + } +} + +impl BlackboxExporterContext { + /// Adds an optional label to a metric if the value is present and non-empty. + /// + /// If the metric already contains a tag with the same key, the existing tag + /// is renamed to `exported_` before adding the new tag. + /// + /// Empty string values are skipped and no tag is added. + fn add_optional_label( + &self, + metric: &mut vector_lib::event::Metric, + key: &str, + value: &Option, + ) { + if let Some(val) = value { + // Skip empty strings + if val.is_empty() { + return; + } + + // Handle conflicts by renaming existing tag + if let Some(existing) = metric.remove_tag(key) { + metric.replace_tag(format!("exported_{}", key), existing); + } + + // Add new tag + metric.replace_tag(key.to_string(), val.clone()); } } } diff --git a/src/sources/blackbox_exporter/tests.rs b/src/sources/blackbox_exporter/tests.rs index 1872db7874ef5..c3c4b897215a5 100644 --- a/src/sources/blackbox_exporter/tests.rs +++ b/src/sources/blackbox_exporter/tests.rs @@ -113,14 +113,14 @@ fn test_construct_probe_url_with_special_characters_in_target() { // Verify special characters are encoded (should contain % for percent encoding) let query = result.query().unwrap(); - let target_param = query - .split('&') - .find(|p| p.starts_with("target=")) - .unwrap(); - + let target_param = query.split('&').find(|p| p.starts_with("target=")).unwrap(); + // Special characters should be percent-encoded - assert!(target_param.contains('%'), "Special characters should be URL-encoded"); - + assert!( + target_param.contains('%'), + "Special characters should be URL-encoded" + ); + // Verify we can decode it back to the original target let decoded = target_param .strip_prefix("target=") @@ -131,8 +131,12 @@ fn test_construct_probe_url_with_special_characters_in_target() { .ok() }) .map(|s| s.to_string()); - - assert_eq!(decoded.as_deref(), Some(target), "Decoded target should match original"); + + assert_eq!( + decoded.as_deref(), + Some(target), + "Decoded target should match original" + ); } #[test] @@ -152,11 +156,8 @@ fn test_construct_probe_url_with_special_characters_in_module() { // Verify we can decode the module back let query = result.query().unwrap(); - let module_param = query - .split('&') - .find(|p| p.starts_with("module=")) - .unwrap(); - + let module_param = query.split('&').find(|p| p.starts_with("module=")).unwrap(); + let decoded = module_param .strip_prefix("module=") .and_then(|encoded| { @@ -166,8 +167,12 @@ fn test_construct_probe_url_with_special_characters_in_module() { .ok() }) .map(|s| s.to_string()); - - assert_eq!(decoded.as_deref(), Some(module), "Decoded module should match original"); + + assert_eq!( + decoded.as_deref(), + Some(module), + "Decoded module should match original" + ); } #[test] @@ -187,14 +192,14 @@ fn test_construct_probe_url_with_unicode_characters() { // Verify Unicode characters are properly encoded let query = result.query().unwrap(); - let target_param = query - .split('&') - .find(|p| p.starts_with("target=")) - .unwrap(); - + let target_param = query.split('&').find(|p| p.starts_with("target=")).unwrap(); + // Unicode should be percent-encoded - assert!(target_param.contains('%'), "Unicode characters should be URL-encoded"); - + assert!( + target_param.contains('%'), + "Unicode characters should be URL-encoded" + ); + // Verify we can decode it back to the original target let decoded = target_param .strip_prefix("target=") @@ -205,8 +210,12 @@ fn test_construct_probe_url_with_unicode_characters() { .ok() }) .map(|s| s.to_string()); - - assert_eq!(decoded.as_deref(), Some(target), "Decoded target should match original"); + + assert_eq!( + decoded.as_deref(), + Some(target), + "Decoded target should match original" + ); } #[test] @@ -221,8 +230,11 @@ fn test_construct_probe_url_with_nested_path() { let result_str = result.to_string(); // Verify nested path is preserved with /probe appended - assert!(result_str.contains("/api/v1/metrics/probe"), - "Nested path should be preserved: {}", result_str); + assert!( + result_str.contains("/api/v1/metrics/probe"), + "Nested path should be preserved: {}", + result_str + ); assert!(result_str.contains("target=")); assert!(result_str.contains("module=")); } @@ -239,10 +251,16 @@ fn test_construct_probe_url_with_trailing_slash_in_path() { let result_str = result.to_string(); // Verify trailing slash is handled correctly (should not result in double slash) - assert!(result_str.contains("/metrics/probe"), - "Path with trailing slash should be handled correctly: {}", result_str); - assert!(!result_str.contains("//probe"), - "Should not have double slash: {}", result_str); + assert!( + result_str.contains("/metrics/probe"), + "Path with trailing slash should be handled correctly: {}", + result_str + ); + assert!( + !result_str.contains("//probe"), + "Should not have double slash: {}", + result_str + ); } #[test] @@ -257,10 +275,19 @@ fn test_construct_probe_url_with_multiple_query_params() { let query = result.query().unwrap(); // Verify all original query parameters are preserved - assert!(query.contains("key1=value1"), "First param should be preserved"); - assert!(query.contains("key2=value2"), "Second param should be preserved"); - assert!(query.contains("key3=value3"), "Third param should be preserved"); - + assert!( + query.contains("key1=value1"), + "First param should be preserved" + ); + assert!( + query.contains("key2=value2"), + "Second param should be preserved" + ); + assert!( + query.contains("key3=value3"), + "Third param should be preserved" + ); + // Verify new parameters are added assert!(query.contains("target="), "Target param should be added"); assert!(query.contains("module="), "Module param should be added"); @@ -278,9 +305,12 @@ fn test_construct_probe_url_with_query_params_containing_special_chars() { let query = result.query().unwrap(); // Verify existing encoded query parameter is preserved - assert!(query.contains("auth=Bearer%20token123"), - "Encoded query param should be preserved: {}", query); - + assert!( + query.contains("auth=Bearer%20token123"), + "Encoded query param should be preserved: {}", + query + ); + // Verify new parameters are added assert!(query.contains("target="), "Target param should be added"); assert!(query.contains("module="), "Module param should be added"); @@ -288,9 +318,7 @@ fn test_construct_probe_url_with_query_params_containing_special_chars() { #[test] fn test_construct_probe_url_with_port_number() { - let base_url = "http://blackbox.example.com:9115" - .parse::() - .unwrap(); + let base_url = "http://blackbox.example.com:9115".parse::().unwrap(); let target = "https://app.example.com:8443"; let module = "http_2xx"; @@ -298,12 +326,15 @@ fn test_construct_probe_url_with_port_number() { let result_str = result.to_string(); // Verify port is preserved in base URL - assert!(result_str.starts_with("http://blackbox.example.com:9115"), - "Port should be preserved in base URL: {}", result_str); - + assert!( + result_str.starts_with("http://blackbox.example.com:9115"), + "Port should be preserved in base URL: {}", + result_str + ); + // Verify target with port is properly encoded assert!(result_str.contains("target=")); - + // Decode and verify target let query = result.query().unwrap(); let decoded_target = query @@ -317,16 +348,17 @@ fn test_construct_probe_url_with_port_number() { .ok() }) .map(|s| s.to_string()); - - assert_eq!(decoded_target.as_deref(), Some(target), - "Target with port should be preserved"); + + assert_eq!( + decoded_target.as_deref(), + Some(target), + "Target with port should be preserved" + ); } #[test] fn test_construct_probe_url_with_https_scheme() { - let base_url = "https://blackbox.example.com" - .parse::() - .unwrap(); + let base_url = "https://blackbox.example.com".parse::().unwrap(); let target = "https://app.example.com"; let module = "http_2xx"; @@ -334,8 +366,11 @@ fn test_construct_probe_url_with_https_scheme() { let result_str = result.to_string(); // Verify HTTPS scheme is preserved - assert!(result_str.starts_with("https://"), - "HTTPS scheme should be preserved: {}", result_str); + assert!( + result_str.starts_with("https://"), + "HTTPS scheme should be preserved: {}", + result_str + ); assert!(result_str.contains("/probe")); assert!(result_str.contains("target=")); assert!(result_str.contains("module=")); @@ -353,22 +388,29 @@ fn test_construct_probe_url_with_path_and_multiple_query_params() { let result_str = result.to_string(); // Verify path is preserved - assert!(result_str.contains("/api/v2/probe"), - "Path should be preserved: {}", result_str); - + assert!( + result_str.contains("/api/v2/probe"), + "Path should be preserved: {}", + result_str + ); + // Verify all query parameters are present let query = result.query().unwrap(); - assert!(query.contains("auth=token"), "Original auth param should be preserved"); - assert!(query.contains("region=us-east"), "Original region param should be preserved"); + assert!( + query.contains("auth=token"), + "Original auth param should be preserved" + ); + assert!( + query.contains("region=us-east"), + "Original region param should be preserved" + ); assert!(query.contains("target="), "Target param should be added"); assert!(query.contains("module="), "Module param should be added"); } #[test] fn test_construct_probe_url_with_empty_path() { - let base_url = "http://blackbox.example.com/" - .parse::() - .unwrap(); + let base_url = "http://blackbox.example.com/".parse::().unwrap(); let target = "https://app.example.com"; let module = "http_2xx"; @@ -376,17 +418,21 @@ fn test_construct_probe_url_with_empty_path() { let result_str = result.to_string(); // Verify /probe is added correctly (not //probe) - assert!(result_str.contains("/probe?"), - "Should have /probe path: {}", result_str); - assert!(!result_str.contains("//probe"), - "Should not have double slash: {}", result_str); + assert!( + result_str.contains("/probe?"), + "Should have /probe path: {}", + result_str + ); + assert!( + !result_str.contains("//probe"), + "Should not have double slash: {}", + result_str + ); } #[test] fn test_construct_probe_url_with_ipv4_address() { - let base_url = "http://192.168.1.100:9115" - .parse::() - .unwrap(); + let base_url = "http://192.168.1.100:9115".parse::().unwrap(); let target = "http://10.0.0.1"; let module = "icmp"; @@ -394,9 +440,12 @@ fn test_construct_probe_url_with_ipv4_address() { let result_str = result.to_string(); // Verify IPv4 address is preserved - assert!(result_str.starts_with("http://192.168.1.100:9115"), - "IPv4 address should be preserved: {}", result_str); - + assert!( + result_str.starts_with("http://192.168.1.100:9115"), + "IPv4 address should be preserved: {}", + result_str + ); + // Verify target IPv4 is properly encoded let query = result.query().unwrap(); let decoded_target = query @@ -410,9 +459,12 @@ fn test_construct_probe_url_with_ipv4_address() { .ok() }) .map(|s| s.to_string()); - - assert_eq!(decoded_target.as_deref(), Some(target), - "Target IPv4 should be preserved"); + + assert_eq!( + decoded_target.as_deref(), + Some(target), + "Target IPv4 should be preserved" + ); } #[test] @@ -421,6 +473,15 @@ fn test_context_on_response_success() { let mut context = BlackboxExporterContext { target: "https://example.com".to_string(), module: "http_2xx".to_string(), + optional_labels: OptionalLabels { + geohash: None, + region: None, + location: None, + country: None, + name: None, + provider: None, + custom: HashMap::new(), + }, }; let url = "http://blackbox.example.com/probe?target=https://example.com&module=http_2xx" @@ -457,6 +518,15 @@ fn test_context_on_response_parse_error() { let mut context = BlackboxExporterContext { target: "https://example.com".to_string(), module: "http_2xx".to_string(), + optional_labels: OptionalLabels { + geohash: None, + region: None, + location: None, + country: None, + name: None, + provider: None, + custom: HashMap::new(), + }, }; let url = "http://blackbox.example.com/probe?target=https://example.com&module=http_2xx" @@ -484,6 +554,15 @@ fn test_context_on_response_parse_error_malformed_metric() { let mut context = BlackboxExporterContext { target: "https://example.com".to_string(), module: "http_2xx".to_string(), + optional_labels: OptionalLabels { + geohash: None, + region: None, + location: None, + country: None, + name: None, + provider: None, + custom: HashMap::new(), + }, }; let url = "http://blackbox.example.com/probe?target=https://example.com&module=http_2xx" @@ -514,6 +593,15 @@ fn test_context_on_response_parse_error_invalid_value() { let mut context = BlackboxExporterContext { target: "https://example.com".to_string(), module: "http_2xx".to_string(), + optional_labels: OptionalLabels { + geohash: None, + region: None, + location: None, + country: None, + name: None, + provider: None, + custom: HashMap::new(), + }, }; let url = "http://blackbox.example.com/probe?target=https://example.com&module=http_2xx" @@ -544,6 +632,15 @@ fn test_context_on_response_empty_body() { let mut context = BlackboxExporterContext { target: "https://example.com".to_string(), module: "http_2xx".to_string(), + optional_labels: OptionalLabels { + geohash: None, + region: None, + location: None, + country: None, + name: None, + provider: None, + custom: HashMap::new(), + }, }; let url = "http://blackbox.example.com/probe?target=https://example.com&module=http_2xx" @@ -571,6 +668,15 @@ fn test_enrich_events_adds_tags() { let mut context = BlackboxExporterContext { target: "https://example.com".to_string(), module: "http_2xx".to_string(), + optional_labels: OptionalLabels { + geohash: None, + region: None, + location: None, + country: None, + name: None, + provider: None, + custom: HashMap::new(), + }, }; // Create a simple metric event @@ -600,6 +706,15 @@ fn test_enrich_events_handles_tag_conflicts() { let mut context = BlackboxExporterContext { target: "https://example.com".to_string(), module: "http_2xx".to_string(), + optional_labels: OptionalLabels { + geohash: None, + region: None, + location: None, + country: None, + name: None, + provider: None, + custom: HashMap::new(), + }, }; // Create a metric event with existing target and module tags @@ -639,6 +754,15 @@ fn test_enrich_events_preserves_other_tags() { let mut context = BlackboxExporterContext { target: "https://example.com".to_string(), module: "http_2xx".to_string(), + optional_labels: OptionalLabels { + geohash: None, + region: None, + location: None, + country: None, + name: None, + provider: None, + custom: HashMap::new(), + }, }; // Create a metric event with other tags @@ -672,6 +796,15 @@ fn test_builder_decodes_url_encoded_target() { // Test that the builder properly decodes URL-encoded target from query parameters let builder = BlackboxExporterBuilder { module: "http_2xx".to_string(), + optional_labels: OptionalLabels { + geohash: None, + region: None, + location: None, + country: None, + name: None, + provider: None, + custom: HashMap::new(), + }, }; // Create a URL with URL-encoded target (https://www.google.com encoded) @@ -699,7 +832,10 @@ fn test_valid_configuration_builds() { "#; let result: std::result::Result = toml::from_str(config_toml); - assert!(result.is_ok(), "Valid configuration should parse successfully"); + assert!( + result.is_ok(), + "Valid configuration should parse successfully" + ); let config = result.unwrap(); assert_eq!(config.url, "http://localhost:9115"); @@ -721,7 +857,10 @@ fn test_valid_configuration_with_optional_fields() { "#; let result: std::result::Result = toml::from_str(config_toml); - assert!(result.is_ok(), "Valid configuration with optional fields should parse successfully"); + assert!( + result.is_ok(), + "Valid configuration with optional fields should parse successfully" + ); let config = result.unwrap(); assert_eq!(config.url, "https://blackbox.example.com:9115"); @@ -745,9 +884,17 @@ fn test_default_interval_and_timeout() { let config = result.unwrap(); // Default interval should be 15 seconds (from default_interval()) - assert_eq!(config.interval, Duration::from_secs(15), "Default interval should be 15 seconds"); + assert_eq!( + config.interval, + Duration::from_secs(15), + "Default interval should be 15 seconds" + ); // Default timeout should be 5 seconds (from default_timeout()) - assert_eq!(config.timeout, Duration::from_secs(5), "Default timeout should be 5 seconds"); + assert_eq!( + config.timeout, + Duration::from_secs(5), + "Default timeout should be 5 seconds" + ); } #[test] @@ -759,7 +906,10 @@ fn test_invalid_configuration_missing_url() { "#; let result: std::result::Result = toml::from_str(config_toml); - assert!(result.is_err(), "Configuration without url should fail to parse"); + assert!( + result.is_err(), + "Configuration without url should fail to parse" + ); } #[test] @@ -771,7 +921,10 @@ fn test_invalid_configuration_missing_targets() { "#; let result: std::result::Result = toml::from_str(config_toml); - assert!(result.is_err(), "Configuration without targets should fail to parse"); + assert!( + result.is_err(), + "Configuration without targets should fail to parse" + ); } #[test] @@ -783,7 +936,10 @@ fn test_invalid_configuration_missing_module() { "#; let result: std::result::Result = toml::from_str(config_toml); - assert!(result.is_err(), "Configuration without module should fail to parse"); + assert!( + result.is_err(), + "Configuration without module should fail to parse" + ); } #[test] @@ -797,8 +953,11 @@ fn test_invalid_configuration_empty_targets() { let result: std::result::Result = toml::from_str(config_toml); // Empty targets list should parse at TOML level - assert!(result.is_ok(), "Configuration with empty targets should parse at TOML level"); - + assert!( + result.is_ok(), + "Configuration with empty targets should parse at TOML level" + ); + let config = result.unwrap(); assert!(config.targets.is_empty(), "Targets should be empty"); // Note: Empty targets validation happens during build(), not during parsing @@ -815,8 +974,11 @@ fn test_invalid_configuration_empty_module() { let result: std::result::Result = toml::from_str(config_toml); // Empty module should parse at TOML level - assert!(result.is_ok(), "Configuration with empty module should parse at TOML level"); - + assert!( + result.is_ok(), + "Configuration with empty module should parse at TOML level" + ); + let config = result.unwrap(); assert!(config.module.is_empty(), "Module should be empty"); // Note: Empty module validation happens during build(), not during parsing @@ -837,7 +999,10 @@ fn test_configuration_with_multiple_targets() { "#; let result: std::result::Result = toml::from_str(config_toml); - assert!(result.is_ok(), "Configuration with multiple targets should parse successfully"); + assert!( + result.is_ok(), + "Configuration with multiple targets should parse successfully" + ); let config = result.unwrap(); assert_eq!(config.targets.len(), 4); @@ -851,7 +1016,7 @@ fn test_configuration_with_multiple_targets() { fn test_configuration_with_different_modules() { // Test that configuration with different module types parses correctly let modules = vec!["http_2xx", "icmp", "tcp_connect", "dns_query"]; - + for module in modules { let config_toml = format!( r#" @@ -863,7 +1028,11 @@ fn test_configuration_with_different_modules() { ); let result: std::result::Result = toml::from_str(&config_toml); - assert!(result.is_ok(), "Configuration with module '{}' should parse successfully", module); + assert!( + result.is_ok(), + "Configuration with module '{}' should parse successfully", + module + ); let config = result.unwrap(); assert_eq!(config.module, module); @@ -882,7 +1051,10 @@ fn test_configuration_with_custom_intervals() { "#; let result: std::result::Result = toml::from_str(config_toml); - assert!(result.is_ok(), "Configuration with custom intervals should parse successfully"); + assert!( + result.is_ok(), + "Configuration with custom intervals should parse successfully" + ); let config = result.unwrap(); assert_eq!(config.interval, Duration::from_secs(60)); @@ -900,7 +1072,10 @@ fn test_configuration_with_fractional_timeout() { "#; let result: std::result::Result = toml::from_str(config_toml); - assert!(result.is_ok(), "Configuration with fractional timeout should parse successfully"); + assert!( + result.is_ok(), + "Configuration with fractional timeout should parse successfully" + ); let config = result.unwrap(); assert_eq!(config.timeout, Duration::from_millis(2500)); @@ -916,7 +1091,10 @@ fn test_configuration_with_https_url() { "#; let result: std::result::Result = toml::from_str(config_toml); - assert!(result.is_ok(), "Configuration with HTTPS URL should parse successfully"); + assert!( + result.is_ok(), + "Configuration with HTTPS URL should parse successfully" + ); let config = result.unwrap(); assert_eq!(config.url, "https://blackbox.example.com:9115"); @@ -932,7 +1110,10 @@ fn test_configuration_with_url_path() { "#; let result: std::result::Result = toml::from_str(config_toml); - assert!(result.is_ok(), "Configuration with URL path should parse successfully"); + assert!( + result.is_ok(), + "Configuration with URL path should parse successfully" + ); let config = result.unwrap(); assert_eq!(config.url, "http://localhost:9115/metrics"); @@ -948,7 +1129,10 @@ fn test_configuration_with_ipv4_address() { "#; let result: std::result::Result = toml::from_str(config_toml); - assert!(result.is_ok(), "Configuration with IPv4 addresses should parse successfully"); + assert!( + result.is_ok(), + "Configuration with IPv4 addresses should parse successfully" + ); let config = result.unwrap(); assert_eq!(config.url, "http://192.168.1.100:9115"); @@ -979,7 +1163,7 @@ probe_duration_seconds 0.123 # HELP probe_http_status_code Response HTTP status code # TYPE probe_http_status_code gauge probe_http_status_code 200 -"# +"#, ) .unwrap() }); @@ -996,13 +1180,25 @@ probe_http_status_code 200 timeout: Duration::from_millis(500), tls: None, auth: None, + geohash: None, + region: None, + location: None, + country: None, + name: None, + provider: None, + labels: None, }; // Run source and collect events - let events = run_and_assert_source_compliance(config, Duration::from_secs(3), &HTTP_PULL_SOURCE_TAGS).await; + let events = + run_and_assert_source_compliance(config, Duration::from_secs(3), &HTTP_PULL_SOURCE_TAGS) + .await; // Verify metrics are scraped and tagged correctly - assert!(!events.is_empty(), "Should have received at least one event"); + assert!( + !events.is_empty(), + "Should have received at least one event" + ); // Check that we got the expected metrics let metric_names: Vec = events @@ -1088,10 +1284,19 @@ probe_http_status_code {} timeout: Duration::from_millis(500), tls: None, auth: None, + geohash: None, + region: None, + location: None, + country: None, + name: None, + provider: None, + labels: None, }; // Run source and collect events - let events = run_and_assert_source_compliance(config, Duration::from_secs(3), &HTTP_PULL_SOURCE_TAGS).await; + let events = + run_and_assert_source_compliance(config, Duration::from_secs(3), &HTTP_PULL_SOURCE_TAGS) + .await; // Verify all targets are scraped assert!(!events.is_empty(), "Should have received events"); @@ -1153,8 +1358,8 @@ mod property_tests { use std::sync::Arc; // Feature: blackbox-exporter-source, Property 1: Configuration validation - // For any blackbox_exporter configuration, if all required fields (url, targets, module) - // are present and valid, then the configuration should parse successfully; if any required + // For any blackbox_exporter configuration, if all required fields (url, targets, module) + // are present and valid, then the configuration should parse successfully; if any required // field is missing or invalid, then parsing should fail with an appropriate error. proptest! { #![proptest_config(ProptestConfig::with_cases(100))] @@ -1225,12 +1430,12 @@ mod property_tests { // Parse configuration - should succeed at TOML level let result: std::result::Result = toml::from_str(&config_toml); - + // Configuration parsing might succeed, but URL validation should fail during build if let Ok(config) = result { // Try to parse the URL - this is where validation happens let url_parse_result = config.url.parse::(); - + // Invalid URLs should fail to parse // Note: Some strings might accidentally be valid URLs, so we can't assert failure here // The important thing is that the validation logic exists and is exercised @@ -1363,8 +1568,8 @@ mod property_tests { } // Feature: blackbox-exporter-source, Property 2: Probe URL construction - // For any valid Blackbox Exporter Instance URL, target, and module, the constructed - // probe URL should have the format `/probe?target=&module=` + // For any valid Blackbox Exporter Instance URL, target, and module, the constructed + // probe URL should have the format `/probe?target=&module=` // where target and module are properly URL-encoded. proptest! { #![proptest_config(ProptestConfig::with_cases(100))] @@ -1446,7 +1651,7 @@ mod property_tests { // We can verify this by checking that the raw special characters don't appear // in the target parameter value (they should be percent-encoded) let query_str = probe_url.query().unwrap(); - + // Extract the target parameter value if let Some(target_param) = query_str.split('&') .find(|p| p.starts_with("target=")) @@ -1454,7 +1659,7 @@ mod property_tests { { // Find where the target parameter ends (at next & or end of string) let target_value = target_param.split('&').next().unwrap(); - + // Verify that special characters are encoded (% should appear for encoding) prop_assert!( target_value.contains('%'), @@ -1508,7 +1713,7 @@ mod property_tests { } // Feature: blackbox-exporter-source, Property 3: URL path preservation - // For any Blackbox Exporter Instance URL with a path component, constructing a + // For any Blackbox Exporter Instance URL with a path component, constructing a // probe URL should preserve the original path before appending `/probe`. proptest! { #![proptest_config(ProptestConfig::with_cases(100))] @@ -1579,7 +1784,7 @@ mod property_tests { } // Feature: blackbox-exporter-source, Property 4: Query parameter preservation - // For any Blackbox Exporter Instance URL with existing query parameters, constructing + // For any Blackbox Exporter Instance URL with existing query parameters, constructing // a probe URL should preserve all existing parameters and append the target and module parameters. proptest! { #![proptest_config(ProptestConfig::with_cases(100))] @@ -1604,7 +1809,7 @@ mod property_tests { .map(|(k, v)| format!("{}={}", k, v)) .collect::>() .join("&"); - + let base_url_str = if let Some(p) = port { format!("{}://{}:{}?{}", scheme, host, p, query_string) } else { @@ -1695,10 +1900,10 @@ mod property_tests { } // Feature: blackbox-exporter-source, Property 9: Error isolation across targets - // For any set of targets where some scrape requests fail, metrics from successful + // For any set of targets where some scrape requests fail, metrics from successful // scrape requests should still be processed and emitted. proptest! { - #![proptest_config(ProptestConfig::with_cases(100))] + #![proptest_config(ProptestConfig::with_cases(10))] #[test] fn test_error_isolation_across_targets( @@ -1756,6 +1961,13 @@ probe_success 1 timeout: Duration::from_millis(500), tls: None, auth: None, + geohash: None, + region: None, + location: None, + country: None, + name: None, + provider: None, + labels: None, }; // Run source and collect events @@ -1794,10 +2006,10 @@ probe_success 1 } // Feature: blackbox-exporter-source, Property 10: Correct target tagging for multiple targets - // For any set of metrics collected from multiple targets, each metric should have a + // For any set of metrics collected from multiple targets, each metric should have a // "target" tag that matches the target URL it was scraped from. proptest! { - #![proptest_config(ProptestConfig::with_cases(100))] + #![proptest_config(ProptestConfig::with_cases(10))] #[test] fn test_correct_target_tagging( @@ -1847,6 +2059,13 @@ probe_success 1 timeout: Duration::from_millis(500), tls: None, auth: None, + geohash: None, + region: None, + location: None, + country: None, + name: None, + provider: None, + labels: None, }; // Run source and collect events @@ -1878,7 +2097,7 @@ probe_success 1 } // Feature: blackbox-exporter-source, Property 5: Target and module tag injection - // For any metric scraped from a probe URL, the enriched metric should contain both + // For any metric scraped from a probe URL, the enriched metric should contain both // a "target" tag with the target URL value and a "module" tag with the module name value. proptest! { #![proptest_config(ProptestConfig::with_cases(100))] @@ -1905,6 +2124,15 @@ probe_success 1 let mut context = BlackboxExporterContext { target: target.clone(), module: module.clone(), + optional_labels: OptionalLabels { + geohash: None, + region: None, + location: None, + country: None, + name: None, + provider: None, + custom: HashMap::new(), + }, }; // Create a metric event with random existing tags @@ -1918,7 +2146,7 @@ probe_success 1 let existing_tags: Vec<(String, String)> = (0..num_existing_tags) .map(|i| (format!("tag{}", i), format!("value{}", i))) .collect(); - + for (key, value) in &existing_tags { metric.replace_tag(key.clone(), value.clone()); } @@ -1965,7 +2193,7 @@ probe_success 1 metric_name.as_str(), "Metric name should be unchanged" ); - + if let vector_lib::event::MetricValue::Gauge { value } = enriched_metric.value() { prop_assert_eq!( *value, @@ -1977,8 +2205,8 @@ probe_success 1 } // Feature: blackbox-exporter-source, Property 6: Tag conflict resolution - // For any scraped metric that already contains a "target" or "module" tag, the - // enrichment process should rename the existing tag to "exported_target" or + // For any scraped metric that already contains a "target" or "module" tag, the + // enrichment process should rename the existing tag to "exported_target" or // "exported_module" respectively, and add the new tag with the correct value. proptest! { #![proptest_config(ProptestConfig::with_cases(100))] @@ -2011,6 +2239,15 @@ probe_success 1 let mut context = BlackboxExporterContext { target: target.clone(), module: module.clone(), + optional_labels: OptionalLabels { + geohash: None, + region: None, + location: None, + country: None, + name: None, + provider: None, + custom: HashMap::new(), + }, }; // Create a metric event with conflicting tags @@ -2032,7 +2269,7 @@ probe_success 1 let other_tags: Vec<(String, String)> = (0..num_other_tags) .map(|i| (format!("other_tag{}", i), format!("other_value{}", i))) .collect(); - + for (key, value) in &other_tags { metric.replace_tag(key.clone(), value.clone()); } @@ -2111,7 +2348,7 @@ probe_success 1 metric_name.as_str(), "Metric name should be unchanged" ); - + if let vector_lib::event::MetricValue::Gauge { value } = enriched_metric.value() { prop_assert_eq!( *value, @@ -2123,7 +2360,7 @@ probe_success 1 } // Feature: blackbox-exporter-source, Property 7: Tag preservation - // For any metric with existing tags, after enrichment with target and module tags, + // For any metric with existing tags, after enrichment with target and module tags, // all original tags (except those renamed due to conflicts) should still be present in the metric. proptest! { #![proptest_config(ProptestConfig::with_cases(100))] @@ -2148,6 +2385,15 @@ probe_success 1 let mut context = BlackboxExporterContext { target: target.clone(), module: module.clone(), + optional_labels: OptionalLabels { + geohash: None, + region: None, + location: None, + country: None, + name: None, + provider: None, + custom: HashMap::new(), + }, }; // Create a metric event with various existing tags @@ -2222,7 +2468,7 @@ probe_success 1 metric_name.as_str(), "Metric name should be unchanged" ); - + if let vector_lib::event::MetricValue::Gauge { value } = enriched_metric.value() { prop_assert_eq!( *value, @@ -2234,7 +2480,7 @@ probe_success 1 } // Feature: blackbox-exporter-source, Property 8: Multiple target URL generation - // For any configuration with N targets, the system should generate exactly N probe URLs, + // For any configuration with N targets, the system should generate exactly N probe URLs, // one for each target. proptest! { #![proptest_config(ProptestConfig::with_cases(100))] @@ -2282,7 +2528,7 @@ probe_success 1 .iter() .map(|u| u.to_string()) .collect(); - + prop_assert_eq!( unique_urls.len(), num_targets, @@ -2292,7 +2538,7 @@ probe_success 1 // Property: Each URL should correspond to exactly one target for (i, url) in urls.iter().enumerate() { let expected_target = &targets[i]; - + // Extract and decode the target parameter from the URL let decoded_target = url .query() @@ -2370,11 +2616,138 @@ probe_success 1 } } + // Feature: blackbox-exporter-optional-labels, Property 1: Optional label configuration validation + // For any blackbox_exporter configuration with any combination of optional label fields + // (geohash, region, location, country, name, provider, labels), the configuration should + // parse successfully and accept all provided fields. + proptest! { + #![proptest_config(ProptestConfig::with_cases(100))] + + #[test] + fn test_optional_label_configuration_validation( + port in 1024u16..65535, + num_targets in 1usize..3, + module in "[a-z_][a-z0-9_]{2,20}", + // Optional predefined labels - use Option to test all combinations + geohash in proptest::option::of("[a-z0-9]{5,12}"), + region in proptest::option::of("(AMER|EMEA|APAC|LATAM)"), + location in proptest::option::of("[A-Z][a-z]{2,15}"), + country in proptest::option::of("[A-Z]{2}"), + name in proptest::option::of("[A-Z][a-z0-9 ]{2,20}"), + provider in proptest::option::of("(AWS|GCP|AZURE|DO)"), + // Ad-hoc labels - generate 0-3 custom labels + num_custom_labels in 0usize..4, + ) { + // Generate valid configuration + let url = format!("http://localhost:{}", port); + let targets: Vec = (0..num_targets) + .map(|i| format!("https://target{}.com", i)) + .collect(); + + // Build TOML configuration string + let mut config_toml = format!( + r#" + url = "{}" + targets = [{}] + module = "{}" + "#, + url, + targets.iter().map(|t| format!("\"{}\"", t)).collect::>().join(", "), + module + ); + + // Add optional predefined labels if present + if let Some(ref gh) = geohash { + config_toml.push_str(&format!("geohash = \"{}\"\n", gh)); + } + if let Some(ref r) = region { + config_toml.push_str(&format!("region = \"{}\"\n", r)); + } + if let Some(ref l) = location { + config_toml.push_str(&format!("location = \"{}\"\n", l)); + } + if let Some(ref c) = country { + config_toml.push_str(&format!("country = \"{}\"\n", c)); + } + if let Some(ref n) = name { + config_toml.push_str(&format!("name = \"{}\"\n", n)); + } + if let Some(ref p) = provider { + config_toml.push_str(&format!("provider = \"{}\"\n", p)); + } + + // Add ad-hoc labels if any + if num_custom_labels > 0 { + config_toml.push_str("\n[labels]\n"); + for i in 0..num_custom_labels { + config_toml.push_str(&format!("custom_key_{} = \"custom_value_{}\"\n", i, i)); + } + } + + // Parse configuration + let result: std::result::Result = toml::from_str(&config_toml); + + // Property: Valid configuration with any combination of optional labels should parse successfully + prop_assert!( + result.is_ok(), + "Configuration with optional labels should parse successfully. Error: {:?}\nConfig:\n{}", + result.err(), + config_toml + ); + + if let Ok(config) = result { + // Verify all fields are correctly parsed + prop_assert_eq!(config.url, url, "URL should match"); + prop_assert_eq!(config.targets, targets, "Targets should match"); + prop_assert_eq!(config.module, module, "Module should match"); + + // Verify optional predefined labels + prop_assert_eq!(config.geohash, geohash, "Geohash should match"); + prop_assert_eq!(config.region, region, "Region should match"); + prop_assert_eq!(config.location, location, "Location should match"); + prop_assert_eq!(config.country, country, "Country should match"); + prop_assert_eq!(config.name, name, "Name should match"); + prop_assert_eq!(config.provider, provider, "Provider should match"); + + // Verify ad-hoc labels + if num_custom_labels > 0 { + prop_assert!(config.labels.is_some(), "Labels map should be present"); + let labels = config.labels.as_ref().unwrap(); + prop_assert_eq!( + labels.len(), + num_custom_labels, + "Should have {} custom labels", + num_custom_labels + ); + + // Verify each custom label is present + for i in 0..num_custom_labels { + let key = format!("custom_key_{}", i); + let expected_value = format!("custom_value_{}", i); + prop_assert_eq!( + labels.get(&key), + Some(&expected_value), + "Custom label '{}' should have value '{}'", + key, + expected_value + ); + } + } else { + // If no custom labels, the labels field should be None or empty + prop_assert!( + config.labels.is_none() || config.labels.as_ref().unwrap().is_empty(), + "Labels should be None or empty when no custom labels are specified" + ); + } + } + } + } + // Feature: blackbox-exporter-source, Property 11: Prometheus text format parsing - // For any valid Prometheus text format response body, the parser should successfully + // For any valid Prometheus text format response body, the parser should successfully // convert it into Vector metric events without errors. proptest! { - #![proptest_config(ProptestConfig::with_cases(100))] + #![proptest_config(ProptestConfig::with_cases(10))] #[test] fn test_prometheus_parsing( @@ -2414,6 +2787,13 @@ probe_success 1 timeout: Duration::from_millis(500), tls: None, auth: None, + geohash: None, + region: None, + location: None, + country: None, + name: None, + provider: None, + labels: None, }; // Run source and collect events @@ -2443,4 +2823,1765 @@ probe_success 1 })?; } } + + // Feature: blackbox-exporter-optional-labels, Property 2: Partial optional label injection + // For any subset of optional label fields specified in the configuration, only the + // specified labels should be added as tags to scraped metrics, and unspecified labels + // should not appear. + proptest! { + #![proptest_config(ProptestConfig::with_cases(100))] + + #[test] + fn test_partial_optional_label_injection( + target_scheme in "(http|https)", + target_host in "[a-z][a-z0-9-]{2,20}\\.[a-z]{2,5}", + module in "[a-z_][a-z0-9_]{2,20}", + metric_name in "[a-z_][a-z0-9_]{2,20}", + metric_value in 0.0f64..1000.0f64, + // Generate random subset of optional labels + has_geohash in proptest::bool::ANY, + has_region in proptest::bool::ANY, + has_location in proptest::bool::ANY, + geohash_val in "[a-z0-9]{5,12}", + region_val in "(AMER|EMEA|APAC)", + location_val in "[A-Z][a-z]{2,15}", + ) { + // Skip if no labels are selected + if !has_geohash && !has_region && !has_location { + return Ok(()); + } + + // Construct target URL + let target = format!("{}://{}", target_scheme, target_host); + + // Create OptionalLabels with only selected fields + let optional_labels = OptionalLabels { + geohash: if has_geohash { Some(geohash_val.clone()) } else { None }, + region: if has_region { Some(region_val.clone()) } else { None }, + location: if has_location { Some(location_val.clone()) } else { None }, + country: None, + name: None, + provider: None, + custom: HashMap::new(), + }; + + // Create a BlackboxExporterContext + let mut context = BlackboxExporterContext { + target: target.clone(), + module: module.clone(), + optional_labels, + }; + + // Create a metric event + let metric = vector_lib::event::Metric::new( + metric_name.clone(), + vector_lib::event::MetricKind::Absolute, + vector_lib::event::MetricValue::Gauge { value: metric_value }, + ); + + let mut events = vec![Event::Metric(metric)]; + + // Call enrich_events + context.enrich_events(&mut events); + + // Verify only specified labels are present + let enriched_metric = events[0].as_metric(); + let tags = enriched_metric.tags().expect("Metric should have tags"); + + // Property: If geohash was specified, it should be present; otherwise it should not + if has_geohash { + prop_assert_eq!( + tags.get("geohash"), + Some(geohash_val.as_str()), + "Geohash tag should be present with value '{}'", + geohash_val + ); + } else { + prop_assert!( + tags.get("geohash").is_none(), + "Geohash tag should not be present when not specified" + ); + } + + // Property: If region was specified, it should be present; otherwise it should not + if has_region { + prop_assert_eq!( + tags.get("region"), + Some(region_val.as_str()), + "Region tag should be present with value '{}'", + region_val + ); + } else { + prop_assert!( + tags.get("region").is_none(), + "Region tag should not be present when not specified" + ); + } + + // Property: If location was specified, it should be present; otherwise it should not + if has_location { + prop_assert_eq!( + tags.get("location"), + Some(location_val.as_str()), + "Location tag should be present with value '{}'", + location_val + ); + } else { + prop_assert!( + tags.get("location").is_none(), + "Location tag should not be present when not specified" + ); + } + + // Verify target and module tags are always present + prop_assert_eq!( + tags.get("target"), + Some(target.as_str()), + "Target tag should always be present" + ); + prop_assert_eq!( + tags.get("module"), + Some(module.as_str()), + "Module tag should always be present" + ); + } + } + + // Feature: blackbox-exporter-optional-labels, Property 3: Empty string handling + // For any optional label field with an empty string value, no tag should be added + // to metrics for that label key. + proptest! { + #![proptest_config(ProptestConfig::with_cases(100))] + + #[test] + fn test_empty_string_handling( + target_scheme in "(http|https)", + target_host in "[a-z][a-z0-9-]{2,20}\\.[a-z]{2,5}", + module in "[a-z_][a-z0-9_]{2,20}", + metric_name in "[a-z_][a-z0-9_]{2,20}", + metric_value in 0.0f64..1000.0f64, + // Control which labels have empty strings + geohash_empty in proptest::bool::ANY, + region_empty in proptest::bool::ANY, + location_empty in proptest::bool::ANY, + // Non-empty values for comparison + geohash_val in "[a-z0-9]{5,12}", + region_val in "(AMER|EMEA|APAC)", + location_val in "[A-Z][a-z]{2,15}", + ) { + // Skip if all are empty (nothing to test) + if geohash_empty && region_empty && location_empty { + return Ok(()); + } + + // Construct target URL + let target = format!("{}://{}", target_scheme, target_host); + + // Create OptionalLabels with empty strings for selected fields + let optional_labels = OptionalLabels { + geohash: Some(if geohash_empty { String::new() } else { geohash_val.clone() }), + region: Some(if region_empty { String::new() } else { region_val.clone() }), + location: Some(if location_empty { String::new() } else { location_val.clone() }), + country: None, + name: None, + provider: None, + custom: HashMap::new(), + }; + + // Create a BlackboxExporterContext + let mut context = BlackboxExporterContext { + target: target.clone(), + module: module.clone(), + optional_labels, + }; + + // Create a metric event + let metric = vector_lib::event::Metric::new( + metric_name.clone(), + vector_lib::event::MetricKind::Absolute, + vector_lib::event::MetricValue::Gauge { value: metric_value }, + ); + + let mut events = vec![Event::Metric(metric)]; + + // Call enrich_events + context.enrich_events(&mut events); + + // Verify empty strings are not added as tags + let enriched_metric = events[0].as_metric(); + let tags = enriched_metric.tags().expect("Metric should have tags"); + + // Property: If geohash is empty, it should not be present as a tag + if geohash_empty { + prop_assert!( + tags.get("geohash").is_none(), + "Geohash tag should not be present when value is empty string" + ); + } else { + prop_assert_eq!( + tags.get("geohash"), + Some(geohash_val.as_str()), + "Geohash tag should be present with non-empty value" + ); + } + + // Property: If region is empty, it should not be present as a tag + if region_empty { + prop_assert!( + tags.get("region").is_none(), + "Region tag should not be present when value is empty string" + ); + } else { + prop_assert_eq!( + tags.get("region"), + Some(region_val.as_str()), + "Region tag should be present with non-empty value" + ); + } + + // Property: If location is empty, it should not be present as a tag + if location_empty { + prop_assert!( + tags.get("location").is_none(), + "Location tag should not be present when value is empty string" + ); + } else { + prop_assert_eq!( + tags.get("location"), + Some(location_val.as_str()), + "Location tag should be present with non-empty value" + ); + } + + // Verify target and module tags are always present + prop_assert_eq!( + tags.get("target"), + Some(target.as_str()), + "Target tag should always be present" + ); + prop_assert_eq!( + tags.get("module"), + Some(module.as_str()), + "Module tag should always be present" + ); + } + } + + // Feature: blackbox-exporter-optional-labels, Property 4, 5, 6, 7, 8: Comprehensive label injection + // Tests predefined label injection, conflict resolution, tag preservation, ad-hoc labels, and combined labels + proptest! { + #![proptest_config(ProptestConfig::with_cases(100))] + + #[test] + fn test_comprehensive_label_injection( + target_scheme in "(http|https)", + target_host in "[a-z][a-z0-9-]{2,20}\\.[a-z]{2,5}", + module in "[a-z_][a-z0-9_]{2,20}", + metric_name in "[a-z_][a-z0-9_]{2,20}", + metric_value in 0.0f64..1000.0f64, + // Predefined labels + geohash_val in "[a-z0-9]{5,12}", + region_val in "(AMER|EMEA|APAC)", + location_val in "[A-Z][a-z]{2,15}", + country_val in "[A-Z]{2}", + name_val in "[A-Z][a-z0-9 ]{2,20}", + provider_val in "(AWS|GCP|AZURE)", + // Ad-hoc labels + num_custom_labels in 0usize..3, + // Existing tags to test preservation + num_existing_tags in 0usize..3, + // Conflict scenarios + has_region_conflict in proptest::bool::ANY, + existing_region_val in "[a-z][a-z0-9_-]{2,20}", + ) { + // Construct target URL + let target = format!("{}://{}", target_scheme, target_host); + + // Create ad-hoc labels + let mut custom_labels = HashMap::new(); + for i in 0..num_custom_labels { + custom_labels.insert(format!("custom_key_{}", i), format!("custom_value_{}", i)); + } + + // Create OptionalLabels with all predefined labels and custom labels + let optional_labels = OptionalLabels { + geohash: Some(geohash_val.clone()), + region: Some(region_val.clone()), + location: Some(location_val.clone()), + country: Some(country_val.clone()), + name: Some(name_val.clone()), + provider: Some(provider_val.clone()), + custom: custom_labels.clone(), + }; + + // Create a BlackboxExporterContext + let mut context = BlackboxExporterContext { + target: target.clone(), + module: module.clone(), + optional_labels, + }; + + // Create a metric event with existing tags + let mut metric = vector_lib::event::Metric::new( + metric_name.clone(), + vector_lib::event::MetricKind::Absolute, + vector_lib::event::MetricValue::Gauge { value: metric_value }, + ); + + // Add existing tags that should be preserved + let mut existing_tags = Vec::new(); + for i in 0..num_existing_tags { + let key = format!("existing_tag_{}", i); + let value = format!("existing_value_{}", i); + metric.replace_tag(key.clone(), value.clone()); + existing_tags.push((key, value)); + } + + // Add a conflicting region tag if requested + if has_region_conflict { + metric.replace_tag("region".to_string(), existing_region_val.clone()); + } + + let mut events = vec![Event::Metric(metric)]; + + // Call enrich_events + context.enrich_events(&mut events); + + // Verify all aspects + let enriched_metric = events[0].as_metric(); + let tags = enriched_metric.tags().expect("Metric should have tags"); + + // Property 4: All predefined labels should be present + prop_assert_eq!( + tags.get("geohash"), + Some(geohash_val.as_str()), + "Geohash tag should be present" + ); + prop_assert_eq!( + tags.get("region"), + Some(region_val.as_str()), + "Region tag should be present with configured value" + ); + prop_assert_eq!( + tags.get("location"), + Some(location_val.as_str()), + "Location tag should be present" + ); + prop_assert_eq!( + tags.get("country"), + Some(country_val.as_str()), + "Country tag should be present" + ); + prop_assert_eq!( + tags.get("name"), + Some(name_val.as_str()), + "Name tag should be present" + ); + prop_assert_eq!( + tags.get("provider"), + Some(provider_val.as_str()), + "Provider tag should be present" + ); + + // Property 5: Conflict resolution - if there was a region conflict, check exported_region + if has_region_conflict { + prop_assert_eq!( + tags.get("exported_region"), + Some(existing_region_val.as_str()), + "Conflicting region tag should be renamed to exported_region" + ); + } + + // Property 6: Tag preservation - all existing tags should still be present + for (key, value) in &existing_tags { + prop_assert_eq!( + tags.get(key.as_str()), + Some(value.as_str()), + "Existing tag '{}' should be preserved", + key + ); + } + + // Property 7: Ad-hoc labels should be present + for (key, value) in &custom_labels { + prop_assert_eq!( + tags.get(key.as_str()), + Some(value.as_str()), + "Ad-hoc label '{}' should be present", + key + ); + } + + // Property 8: Target and module tags should always be present + prop_assert_eq!( + tags.get("target"), + Some(target.as_str()), + "Target tag should be present" + ); + prop_assert_eq!( + tags.get("module"), + Some(module.as_str()), + "Module tag should be present" + ); + } + } + + // Feature: blackbox-exporter-optional-labels, Property 7: Ad-hoc label injection + // For any key-value pairs in the labels configuration map, all scraped metrics should + // contain tags for each key-value pair. + proptest! { + #![proptest_config(ProptestConfig::with_cases(100))] + + #[test] + fn test_adhoc_label_injection( + target_scheme in "(http|https)", + target_host in "[a-z][a-z0-9-]{2,20}\\.[a-z]{2,5}", + module in "[a-z_][a-z0-9_]{2,20}", + metric_name in "[a-z_][a-z0-9_]{2,20}", + metric_value in 0.0f64..1000.0f64, + // Generate random ad-hoc labels (1-5 labels) + num_adhoc_labels in 1usize..6, + ) { + // Construct target URL + let target = format!("{}://{}", target_scheme, target_host); + + // Generate ad-hoc labels + let mut custom_labels = HashMap::new(); + for i in 0..num_adhoc_labels { + custom_labels.insert( + format!("adhoc_key_{}", i), + format!("adhoc_value_{}", i) + ); + } + + // Create OptionalLabels with only ad-hoc labels + let optional_labels = OptionalLabels { + geohash: None, + region: None, + location: None, + country: None, + name: None, + provider: None, + custom: custom_labels.clone(), + }; + + // Create a BlackboxExporterContext + let mut context = BlackboxExporterContext { + target: target.clone(), + module: module.clone(), + optional_labels, + }; + + // Create a metric event + let metric = vector_lib::event::Metric::new( + metric_name.clone(), + vector_lib::event::MetricKind::Absolute, + vector_lib::event::MetricValue::Gauge { value: metric_value }, + ); + + let mut events = vec![Event::Metric(metric)]; + + // Call enrich_events + context.enrich_events(&mut events); + + // Verify all ad-hoc labels are present as tags + let enriched_metric = events[0].as_metric(); + let tags = enriched_metric.tags().expect("Metric should have tags"); + + // Property: For each ad-hoc label, a tag should be present with the correct value + for (key, value) in &custom_labels { + prop_assert_eq!( + tags.get(key.as_str()), + Some(value.as_str()), + "Ad-hoc label '{}' should be present with value '{}'", + key, + value + ); + } + + // Verify target and module tags are also present + prop_assert_eq!( + tags.get("target"), + Some(target.as_str()), + "Target tag should be present" + ); + prop_assert_eq!( + tags.get("module"), + Some(module.as_str()), + "Module tag should be present" + ); + + // Verify no predefined labels are present (since we didn't set any) + prop_assert!( + tags.get("geohash").is_none(), + "Geohash should not be present when not configured" + ); + prop_assert!( + tags.get("region").is_none(), + "Region should not be present when not configured" + ); + prop_assert!( + tags.get("location").is_none(), + "Location should not be present when not configured" + ); + } + } + + // Feature: blackbox-exporter-optional-labels, Property 8: Ad-hoc label conflict resolution + // For any scraped metric that already contains a tag matching an ad-hoc label key, the + // existing tag should be renamed to "exported_" and the new tag with the configured + // value should be added. + proptest! { + #![proptest_config(ProptestConfig::with_cases(100))] + + #[test] + fn test_adhoc_label_conflict_resolution( + target_scheme in "(http|https)", + target_host in "[a-z][a-z0-9-]{2,20}\\.[a-z]{2,5}", + module in "[a-z_][a-z0-9_]{2,20}", + metric_name in "[a-z_][a-z0-9_]{2,20}", + metric_value in 0.0f64..1000.0f64, + // Generate ad-hoc labels + num_adhoc_labels in 1usize..4, + // Generate existing conflicting values + num_conflicts in 1usize..3, + ) { + // Ensure we have at least one conflict + let num_conflicts = num_conflicts.min(num_adhoc_labels); + + // Construct target URL + let target = format!("{}://{}", target_scheme, target_host); + + // Generate ad-hoc labels + let mut custom_labels = HashMap::new(); + for i in 0..num_adhoc_labels { + custom_labels.insert( + format!("adhoc_key_{}", i), + format!("adhoc_value_{}", i) + ); + } + + // Create OptionalLabels with ad-hoc labels + let optional_labels = OptionalLabels { + geohash: None, + region: None, + location: None, + country: None, + name: None, + provider: None, + custom: custom_labels.clone(), + }; + + // Create a BlackboxExporterContext + let mut context = BlackboxExporterContext { + target: target.clone(), + module: module.clone(), + optional_labels, + }; + + // Create a metric event with conflicting tags + let mut metric = vector_lib::event::Metric::new( + metric_name.clone(), + vector_lib::event::MetricKind::Absolute, + vector_lib::event::MetricValue::Gauge { value: metric_value }, + ); + + // Add conflicting tags for the first num_conflicts ad-hoc labels + let mut conflicting_values = HashMap::new(); + for i in 0..num_conflicts { + let key = format!("adhoc_key_{}", i); + let existing_value = format!("existing_value_{}", i); + metric.replace_tag(key.clone(), existing_value.clone()); + conflicting_values.insert(key, existing_value); + } + + let mut events = vec![Event::Metric(metric)]; + + // Call enrich_events + context.enrich_events(&mut events); + + // Verify conflict resolution + let enriched_metric = events[0].as_metric(); + let tags = enriched_metric.tags().expect("Metric should have tags"); + + // Property: All ad-hoc labels should be present with configured values + for (key, value) in &custom_labels { + prop_assert_eq!( + tags.get(key.as_str()), + Some(value.as_str()), + "Ad-hoc label '{}' should be present with configured value '{}'", + key, + value + ); + } + + // Property: Conflicting tags should be renamed to exported_ + for (key, existing_value) in &conflicting_values { + let exported_key = format!("exported_{}", key); + prop_assert_eq!( + tags.get(exported_key.as_str()), + Some(existing_value.as_str()), + "Conflicting tag '{}' should be renamed to '{}' with value '{}'", + key, + exported_key, + existing_value + ); + } + + // Verify target and module tags are present + prop_assert_eq!( + tags.get("target"), + Some(target.as_str()), + "Target tag should be present" + ); + prop_assert_eq!( + tags.get("module"), + Some(module.as_str()), + "Module tag should be present" + ); + } + } + + // Feature: blackbox-exporter-optional-labels, Property 9: Combined predefined and ad-hoc labels + // For any configuration with both predefined optional labels and ad-hoc labels, all scraped + // metrics should contain tags for both types of labels. + proptest! { + #![proptest_config(ProptestConfig::with_cases(100))] + + #[test] + fn test_combined_predefined_and_adhoc_labels( + target_scheme in "(http|https)", + target_host in "[a-z][a-z0-9-]{2,20}\\.[a-z]{2,5}", + module in "[a-z_][a-z0-9_]{2,20}", + metric_name in "[a-z_][a-z0-9_]{2,20}", + metric_value in 0.0f64..1000.0f64, + // Predefined labels + geohash_val in "[a-z0-9]{5,12}", + region_val in "(AMER|EMEA|APAC)", + location_val in "[A-Z][a-z]{2,15}", + // Ad-hoc labels + num_adhoc_labels in 1usize..4, + ) { + // Construct target URL + let target = format!("{}://{}", target_scheme, target_host); + + // Generate ad-hoc labels + let mut custom_labels = HashMap::new(); + for i in 0..num_adhoc_labels { + custom_labels.insert( + format!("custom_key_{}", i), + format!("custom_value_{}", i) + ); + } + + // Create OptionalLabels with both predefined and ad-hoc labels + let optional_labels = OptionalLabels { + geohash: Some(geohash_val.clone()), + region: Some(region_val.clone()), + location: Some(location_val.clone()), + country: None, + name: None, + provider: None, + custom: custom_labels.clone(), + }; + + // Create a BlackboxExporterContext + let mut context = BlackboxExporterContext { + target: target.clone(), + module: module.clone(), + optional_labels, + }; + + // Create a metric event + let metric = vector_lib::event::Metric::new( + metric_name.clone(), + vector_lib::event::MetricKind::Absolute, + vector_lib::event::MetricValue::Gauge { value: metric_value }, + ); + + let mut events = vec![Event::Metric(metric)]; + + // Call enrich_events + context.enrich_events(&mut events); + + // Verify both predefined and ad-hoc labels are present + let enriched_metric = events[0].as_metric(); + let tags = enriched_metric.tags().expect("Metric should have tags"); + + // Property: All predefined labels should be present + prop_assert_eq!( + tags.get("geohash"), + Some(geohash_val.as_str()), + "Predefined geohash label should be present" + ); + prop_assert_eq!( + tags.get("region"), + Some(region_val.as_str()), + "Predefined region label should be present" + ); + prop_assert_eq!( + tags.get("location"), + Some(location_val.as_str()), + "Predefined location label should be present" + ); + + // Property: All ad-hoc labels should be present + for (key, value) in &custom_labels { + prop_assert_eq!( + tags.get(key.as_str()), + Some(value.as_str()), + "Ad-hoc label '{}' should be present with value '{}'", + key, + value + ); + } + + // Verify target and module tags are present + prop_assert_eq!( + tags.get("target"), + Some(target.as_str()), + "Target tag should be present" + ); + prop_assert_eq!( + tags.get("module"), + Some(module.as_str()), + "Module tag should be present" + ); + } + } + + // Feature: blackbox-exporter-optional-labels, Property 10: Ad-hoc label precedence over predefined + // For any ad-hoc label key that matches a predefined label key, the ad-hoc label value should + // be used and the predefined label should be ignored. + proptest! { + #![proptest_config(ProptestConfig::with_cases(100))] + + #[test] + fn test_adhoc_label_precedence_over_predefined( + target_scheme in "(http|https)", + target_host in "[a-z][a-z0-9-]{2,20}\\.[a-z]{2,5}", + module in "[a-z_][a-z0-9_]{2,20}", + metric_name in "[a-z_][a-z0-9_]{2,20}", + metric_value in 0.0f64..1000.0f64, + // Predefined label values + predefined_region in "(AMER|EMEA|APAC)", + predefined_location in "[A-Z][a-z]{2,15}", + // Ad-hoc label values that conflict with predefined + adhoc_region in "[a-z]{2,10}", + adhoc_location in "[a-z]{2,10}", + // Non-conflicting labels + geohash_val in "[a-z0-9]{5,12}", + ) { + // Construct target URL + let target = format!("{}://{}", target_scheme, target_host); + + // Create ad-hoc labels that conflict with predefined labels + let mut custom_labels = HashMap::new(); + custom_labels.insert("region".to_string(), adhoc_region.clone()); + custom_labels.insert("location".to_string(), adhoc_location.clone()); + custom_labels.insert("custom_key".to_string(), "custom_value".to_string()); + + // Create OptionalLabels with both predefined and conflicting ad-hoc labels + let optional_labels = OptionalLabels { + geohash: Some(geohash_val.clone()), + region: Some(predefined_region.clone()), + location: Some(predefined_location.clone()), + country: None, + name: None, + provider: None, + custom: custom_labels.clone(), + }; + + // Create a BlackboxExporterContext + let mut context = BlackboxExporterContext { + target: target.clone(), + module: module.clone(), + optional_labels, + }; + + // Create a metric event + let metric = vector_lib::event::Metric::new( + metric_name.clone(), + vector_lib::event::MetricKind::Absolute, + vector_lib::event::MetricValue::Gauge { value: metric_value }, + ); + + let mut events = vec![Event::Metric(metric)]; + + // Call enrich_events + context.enrich_events(&mut events); + + // Verify ad-hoc labels take precedence + let enriched_metric = events[0].as_metric(); + let tags = enriched_metric.tags().expect("Metric should have tags"); + + // Property: Ad-hoc "region" should override predefined "region" + prop_assert_eq!( + tags.get("region"), + Some(adhoc_region.as_str()), + "Ad-hoc region '{}' should override predefined region '{}'", + adhoc_region, + predefined_region + ); + + // Property: Ad-hoc "location" should override predefined "location" + prop_assert_eq!( + tags.get("location"), + Some(adhoc_location.as_str()), + "Ad-hoc location '{}' should override predefined location '{}'", + adhoc_location, + predefined_location + ); + + // Property: Non-conflicting predefined label should still be present + prop_assert_eq!( + tags.get("geohash"), + Some(geohash_val.as_str()), + "Non-conflicting predefined geohash should be present" + ); + + // Property: Non-conflicting ad-hoc label should be present + prop_assert_eq!( + tags.get("custom_key"), + Some("custom_value"), + "Non-conflicting ad-hoc label should be present" + ); + + // Verify target and module tags are present + prop_assert_eq!( + tags.get("target"), + Some(target.as_str()), + "Target tag should be present" + ); + prop_assert_eq!( + tags.get("module"), + Some(module.as_str()), + "Module tag should be present" + ); + } + } +} + +// Unit tests for OptionalLabels construction + +#[test] +fn test_optional_labels_from_config_with_all_fields() { + // Test building OptionalLabels from config with all fields populated + let mut labels_map = HashMap::new(); + labels_map.insert("environment".to_string(), "production".to_string()); + labels_map.insert("team".to_string(), "platform".to_string()); + + let config = BlackboxExporterConfig { + url: "http://localhost:9115".to_string(), + targets: vec!["https://example.com".to_string()], + module: "http_2xx".to_string(), + interval: Duration::from_secs(15), + timeout: Duration::from_secs(5), + tls: None, + auth: None, + geohash: Some("9qx7hh9jd".to_string()), + region: Some("AMER".to_string()), + location: Some("Oregon".to_string()), + country: Some("US".to_string()), + name: Some("Example Check".to_string()), + provider: Some("AWS".to_string()), + labels: Some(labels_map.clone()), + }; + + let optional_labels = OptionalLabels::from_config(&config); + + // Verify all predefined labels are set + assert_eq!(optional_labels.geohash, Some("9qx7hh9jd".to_string())); + assert_eq!(optional_labels.region, Some("AMER".to_string())); + assert_eq!(optional_labels.location, Some("Oregon".to_string())); + assert_eq!(optional_labels.country, Some("US".to_string())); + assert_eq!(optional_labels.name, Some("Example Check".to_string())); + assert_eq!(optional_labels.provider, Some("AWS".to_string())); + + // Verify custom labels are set + assert_eq!(optional_labels.custom.len(), 2); + assert_eq!( + optional_labels.custom.get("environment"), + Some(&"production".to_string()) + ); + assert_eq!( + optional_labels.custom.get("team"), + Some(&"platform".to_string()) + ); +} + +#[test] +fn test_optional_labels_from_config_with_subset_of_fields() { + // Test building OptionalLabels from config with only some fields populated + let config = BlackboxExporterConfig { + url: "http://localhost:9115".to_string(), + targets: vec!["https://example.com".to_string()], + module: "http_2xx".to_string(), + interval: Duration::from_secs(15), + timeout: Duration::from_secs(5), + tls: None, + auth: None, + geohash: Some("9qx7hh9jd".to_string()), + region: None, + location: Some("Oregon".to_string()), + country: None, + name: Some("Example Check".to_string()), + provider: None, + labels: None, + }; + + let optional_labels = OptionalLabels::from_config(&config); + + // Verify specified labels are set + assert_eq!(optional_labels.geohash, Some("9qx7hh9jd".to_string())); + assert_eq!(optional_labels.location, Some("Oregon".to_string())); + assert_eq!(optional_labels.name, Some("Example Check".to_string())); + + // Verify unspecified labels are None + assert_eq!(optional_labels.region, None); + assert_eq!(optional_labels.country, None); + assert_eq!(optional_labels.provider, None); + + // Verify custom labels are empty + assert!(optional_labels.custom.is_empty()); +} + +// Unit tests for label injection + +#[test] +fn test_enrich_events_adds_predefined_labels_without_conflicts() { + // Test adding predefined labels to metrics without conflicts + let mut context = BlackboxExporterContext { + target: "https://example.com".to_string(), + module: "http_2xx".to_string(), + optional_labels: OptionalLabels { + geohash: Some("9qx7hh9jd".to_string()), + region: Some("AMER".to_string()), + location: Some("Oregon".to_string()), + country: Some("US".to_string()), + name: Some("Example Check".to_string()), + provider: Some("AWS".to_string()), + custom: HashMap::new(), + }, + }; + + let metric = vector_lib::event::Metric::new( + "probe_success", + vector_lib::event::MetricKind::Absolute, + vector_lib::event::MetricValue::Gauge { value: 1.0 }, + ); + + let mut events = vec![Event::Metric(metric)]; + context.enrich_events(&mut events); + + let enriched_metric = events[0].as_metric(); + let tags = enriched_metric.tags().unwrap(); + + // Verify all predefined labels are added + assert_eq!(tags.get("geohash"), Some("9qx7hh9jd")); + assert_eq!(tags.get("region"), Some("AMER")); + assert_eq!(tags.get("location"), Some("Oregon")); + assert_eq!(tags.get("country"), Some("US")); + assert_eq!(tags.get("name"), Some("Example Check")); + assert_eq!(tags.get("provider"), Some("AWS")); + + // Verify target and module are also present + assert_eq!(tags.get("target"), Some("https://example.com")); + assert_eq!(tags.get("module"), Some("http_2xx")); +} + +#[test] +fn test_enrich_events_adds_adhoc_labels_without_conflicts() { + // Test adding ad-hoc labels to metrics without conflicts + let mut custom_labels = HashMap::new(); + custom_labels.insert("environment".to_string(), "production".to_string()); + custom_labels.insert("team".to_string(), "platform".to_string()); + custom_labels.insert("cost_center".to_string(), "engineering".to_string()); + + let mut context = BlackboxExporterContext { + target: "https://example.com".to_string(), + module: "http_2xx".to_string(), + optional_labels: OptionalLabels { + geohash: None, + region: None, + location: None, + country: None, + name: None, + provider: None, + custom: custom_labels, + }, + }; + + let metric = vector_lib::event::Metric::new( + "probe_success", + vector_lib::event::MetricKind::Absolute, + vector_lib::event::MetricValue::Gauge { value: 1.0 }, + ); + + let mut events = vec![Event::Metric(metric)]; + context.enrich_events(&mut events); + + let enriched_metric = events[0].as_metric(); + let tags = enriched_metric.tags().unwrap(); + + // Verify all ad-hoc labels are added + assert_eq!(tags.get("environment"), Some("production")); + assert_eq!(tags.get("team"), Some("platform")); + assert_eq!(tags.get("cost_center"), Some("engineering")); + + // Verify target and module are also present + assert_eq!(tags.get("target"), Some("https://example.com")); + assert_eq!(tags.get("module"), Some("http_2xx")); +} + +#[test] +fn test_enrich_events_conflict_resolution_for_predefined_labels() { + // Test conflict resolution for predefined labels + let mut context = BlackboxExporterContext { + target: "https://example.com".to_string(), + module: "http_2xx".to_string(), + optional_labels: OptionalLabels { + geohash: Some("9qx7hh9jd".to_string()), + region: Some("AMER".to_string()), + location: Some("Oregon".to_string()), + country: None, + name: None, + provider: None, + custom: HashMap::new(), + }, + }; + + // Create metric with conflicting tags + let mut metric = vector_lib::event::Metric::new( + "probe_success", + vector_lib::event::MetricKind::Absolute, + vector_lib::event::MetricValue::Gauge { value: 1.0 }, + ); + metric.replace_tag("region".to_string(), "internal_region".to_string()); + metric.replace_tag("location".to_string(), "internal_location".to_string()); + + let mut events = vec![Event::Metric(metric)]; + context.enrich_events(&mut events); + + let enriched_metric = events[0].as_metric(); + let tags = enriched_metric.tags().unwrap(); + + // Verify new labels are added with configured values + assert_eq!(tags.get("geohash"), Some("9qx7hh9jd")); + assert_eq!(tags.get("region"), Some("AMER")); + assert_eq!(tags.get("location"), Some("Oregon")); + + // Verify conflicting tags are renamed to exported_* + assert_eq!(tags.get("exported_region"), Some("internal_region")); + assert_eq!(tags.get("exported_location"), Some("internal_location")); +} + +#[test] +fn test_enrich_events_conflict_resolution_for_adhoc_labels() { + // Test conflict resolution for ad-hoc labels + let mut custom_labels = HashMap::new(); + custom_labels.insert("environment".to_string(), "production".to_string()); + custom_labels.insert("team".to_string(), "platform".to_string()); + + let mut context = BlackboxExporterContext { + target: "https://example.com".to_string(), + module: "http_2xx".to_string(), + optional_labels: OptionalLabels { + geohash: None, + region: None, + location: None, + country: None, + name: None, + provider: None, + custom: custom_labels, + }, + }; + + // Create metric with conflicting tags + let mut metric = vector_lib::event::Metric::new( + "probe_success", + vector_lib::event::MetricKind::Absolute, + vector_lib::event::MetricValue::Gauge { value: 1.0 }, + ); + metric.replace_tag("environment".to_string(), "internal_env".to_string()); + + let mut events = vec![Event::Metric(metric)]; + context.enrich_events(&mut events); + + let enriched_metric = events[0].as_metric(); + let tags = enriched_metric.tags().unwrap(); + + // Verify new labels are added with configured values + assert_eq!(tags.get("environment"), Some("production")); + assert_eq!(tags.get("team"), Some("platform")); + + // Verify conflicting tag is renamed to exported_* + assert_eq!(tags.get("exported_environment"), Some("internal_env")); +} + +#[test] +fn test_enrich_events_empty_string_values_not_added() { + // Test that empty string values are not added as tags + let mut context = BlackboxExporterContext { + target: "https://example.com".to_string(), + module: "http_2xx".to_string(), + optional_labels: OptionalLabels { + geohash: Some(String::new()), // Empty string + region: Some("AMER".to_string()), + location: Some(String::new()), // Empty string + country: Some("US".to_string()), + name: Some(String::new()), // Empty string + provider: None, + custom: HashMap::new(), + }, + }; + + let metric = vector_lib::event::Metric::new( + "probe_success", + vector_lib::event::MetricKind::Absolute, + vector_lib::event::MetricValue::Gauge { value: 1.0 }, + ); + + let mut events = vec![Event::Metric(metric)]; + context.enrich_events(&mut events); + + let enriched_metric = events[0].as_metric(); + let tags = enriched_metric.tags().unwrap(); + + // Verify empty string labels are not added + assert!(tags.get("geohash").is_none()); + assert!(tags.get("location").is_none()); + assert!(tags.get("name").is_none()); + + // Verify non-empty labels are added + assert_eq!(tags.get("region"), Some("AMER")); + assert_eq!(tags.get("country"), Some("US")); +} + +#[test] +fn test_enrich_events_preserves_existing_tags() { + // Test that existing tags are preserved during enrichment + let mut context = BlackboxExporterContext { + target: "https://example.com".to_string(), + module: "http_2xx".to_string(), + optional_labels: OptionalLabels { + geohash: Some("9qx7hh9jd".to_string()), + region: Some("AMER".to_string()), + location: None, + country: None, + name: None, + provider: None, + custom: HashMap::new(), + }, + }; + + // Create metric with existing tags + let mut metric = vector_lib::event::Metric::new( + "probe_success", + vector_lib::event::MetricKind::Absolute, + vector_lib::event::MetricValue::Gauge { value: 1.0 }, + ); + metric.replace_tag("instance".to_string(), "server1".to_string()); + metric.replace_tag("job".to_string(), "blackbox".to_string()); + metric.replace_tag("custom_tag".to_string(), "custom_value".to_string()); + + let mut events = vec![Event::Metric(metric)]; + context.enrich_events(&mut events); + + let enriched_metric = events[0].as_metric(); + let tags = enriched_metric.tags().unwrap(); + + // Verify existing tags are preserved + assert_eq!(tags.get("instance"), Some("server1")); + assert_eq!(tags.get("job"), Some("blackbox")); + assert_eq!(tags.get("custom_tag"), Some("custom_value")); + + // Verify new labels are added + assert_eq!(tags.get("geohash"), Some("9qx7hh9jd")); + assert_eq!(tags.get("region"), Some("AMER")); + + // Verify target and module are also present + assert_eq!(tags.get("target"), Some("https://example.com")); + assert_eq!(tags.get("module"), Some("http_2xx")); +} + +#[test] +fn test_optional_labels_from_config_with_no_optional_labels() { + // Test building OptionalLabels from config with no optional labels + let config = BlackboxExporterConfig { + url: "http://localhost:9115".to_string(), + targets: vec!["https://example.com".to_string()], + module: "http_2xx".to_string(), + interval: Duration::from_secs(15), + timeout: Duration::from_secs(5), + tls: None, + auth: None, + geohash: None, + region: None, + location: None, + country: None, + name: None, + provider: None, + labels: None, + }; + + let optional_labels = OptionalLabels::from_config(&config); + + // Verify all predefined labels are None + assert_eq!(optional_labels.geohash, None); + assert_eq!(optional_labels.region, None); + assert_eq!(optional_labels.location, None); + assert_eq!(optional_labels.country, None); + assert_eq!(optional_labels.name, None); + assert_eq!(optional_labels.provider, None); + + // Verify custom labels are empty + assert!(optional_labels.custom.is_empty()); +} + +// Unit tests for ad-hoc label precedence + +#[test] +fn test_adhoc_labels_override_predefined_labels_with_same_key() { + // Test that ad-hoc labels override predefined labels when keys conflict + let mut custom_labels = HashMap::new(); + custom_labels.insert("region".to_string(), "adhoc_region".to_string()); + custom_labels.insert("location".to_string(), "adhoc_location".to_string()); + custom_labels.insert("custom_key".to_string(), "custom_value".to_string()); + + let mut context = BlackboxExporterContext { + target: "https://example.com".to_string(), + module: "http_2xx".to_string(), + optional_labels: OptionalLabels { + geohash: Some("9qx7hh9jd".to_string()), + region: Some("AMER".to_string()), + location: Some("Oregon".to_string()), + country: Some("US".to_string()), + name: None, + provider: None, + custom: custom_labels, + }, + }; + + let metric = vector_lib::event::Metric::new( + "probe_success", + vector_lib::event::MetricKind::Absolute, + vector_lib::event::MetricValue::Gauge { value: 1.0 }, + ); + + let mut events = vec![Event::Metric(metric)]; + context.enrich_events(&mut events); + + let enriched_metric = events[0].as_metric(); + let tags = enriched_metric.tags().unwrap(); + + // Verify ad-hoc labels override predefined labels + assert_eq!(tags.get("region"), Some("adhoc_region")); + assert_eq!(tags.get("location"), Some("adhoc_location")); + + // Verify non-conflicting predefined labels are still present + assert_eq!(tags.get("geohash"), Some("9qx7hh9jd")); + assert_eq!(tags.get("country"), Some("US")); + + // Verify non-conflicting ad-hoc label is present + assert_eq!(tags.get("custom_key"), Some("custom_value")); +} + +#[test] +fn test_both_predefined_and_adhoc_labels_added_when_keys_dont_conflict() { + // Test that both predefined and ad-hoc labels are added when keys don't conflict + let mut custom_labels = HashMap::new(); + custom_labels.insert("environment".to_string(), "production".to_string()); + custom_labels.insert("team".to_string(), "platform".to_string()); + + let mut context = BlackboxExporterContext { + target: "https://example.com".to_string(), + module: "http_2xx".to_string(), + optional_labels: OptionalLabels { + geohash: Some("9qx7hh9jd".to_string()), + region: Some("AMER".to_string()), + location: Some("Oregon".to_string()), + country: None, + name: None, + provider: None, + custom: custom_labels, + }, + }; + + let metric = vector_lib::event::Metric::new( + "probe_success", + vector_lib::event::MetricKind::Absolute, + vector_lib::event::MetricValue::Gauge { value: 1.0 }, + ); + + let mut events = vec![Event::Metric(metric)]; + context.enrich_events(&mut events); + + let enriched_metric = events[0].as_metric(); + let tags = enriched_metric.tags().unwrap(); + + // Verify all predefined labels are present + assert_eq!(tags.get("geohash"), Some("9qx7hh9jd")); + assert_eq!(tags.get("region"), Some("AMER")); + assert_eq!(tags.get("location"), Some("Oregon")); + + // Verify all ad-hoc labels are present + assert_eq!(tags.get("environment"), Some("production")); + assert_eq!(tags.get("team"), Some("platform")); + + // Verify target and module are present + assert_eq!(tags.get("target"), Some("https://example.com")); + assert_eq!(tags.get("module"), Some("http_2xx")); +} + +// Integration tests for optional labels + +#[tokio::test] +async fn test_integration_predefined_optional_labels() { + // Set up mock Blackbox Exporter endpoint + let (_guard, addr) = next_addr(); + + let mock_endpoint = warp::path!("probe") + .and(warp::query::>()) + .map(|_q: std::collections::HashMap| { + // Return mock Prometheus metrics + warp::http::Response::builder() + .header("Content-Type", "text/plain") + .body( + r#"# HELP probe_success Displays whether or not the probe was a success +# TYPE probe_success gauge +probe_success 1 +# HELP probe_duration_seconds Returns how long the probe took to complete in seconds +# TYPE probe_duration_seconds gauge +probe_duration_seconds 0.123 +"#, + ) + .unwrap() + }); + + tokio::spawn(warp::serve(mock_endpoint).run(addr)); + wait_for_tcp(addr).await; + + // Configure source with predefined optional labels + let config = BlackboxExporterConfig { + url: format!("http://{}", addr), + targets: vec!["https://example.com".to_string()], + module: "http_2xx".to_string(), + interval: Duration::from_secs(1), + timeout: Duration::from_millis(500), + tls: None, + auth: None, + geohash: Some("9qx7hh9jd".to_string()), + region: Some("AMER".to_string()), + location: Some("Oregon".to_string()), + country: Some("US".to_string()), + name: Some("Example Check".to_string()), + provider: Some("AWS".to_string()), + labels: None, + }; + + // Run source and collect events + let events = + run_and_assert_source_compliance(config, Duration::from_secs(3), &HTTP_PULL_SOURCE_TAGS) + .await; + + // Verify metrics are scraped + assert!( + !events.is_empty(), + "Should have received at least one event" + ); + + // Verify all metrics have the predefined optional labels + for event in &events { + let metric = event.as_metric(); + let tags = metric.tags().expect("Metric should have tags"); + + // Verify target and module tags + assert_eq!( + tags.get("target"), + Some("https://example.com"), + "Metric should have correct target tag" + ); + assert_eq!( + tags.get("module"), + Some("http_2xx"), + "Metric should have correct module tag" + ); + + // Verify all predefined optional labels are present + assert_eq!( + tags.get("geohash"), + Some("9qx7hh9jd"), + "Metric should have geohash label" + ); + assert_eq!( + tags.get("region"), + Some("AMER"), + "Metric should have region label" + ); + assert_eq!( + tags.get("location"), + Some("Oregon"), + "Metric should have location label" + ); + assert_eq!( + tags.get("country"), + Some("US"), + "Metric should have country label" + ); + assert_eq!( + tags.get("name"), + Some("Example Check"), + "Metric should have name label" + ); + assert_eq!( + tags.get("provider"), + Some("AWS"), + "Metric should have provider label" + ); + } +} + +#[tokio::test] +async fn test_integration_adhoc_labels() { + // Set up mock Blackbox Exporter endpoint + let (_guard, addr) = next_addr(); + + let mock_endpoint = warp::path!("probe") + .and(warp::query::>()) + .map(|_q: std::collections::HashMap| { + // Return mock Prometheus metrics + warp::http::Response::builder() + .header("Content-Type", "text/plain") + .body( + r#"# HELP probe_success Displays whether or not the probe was a success +# TYPE probe_success gauge +probe_success 1 +# HELP probe_duration_seconds Returns how long the probe took to complete in seconds +# TYPE probe_duration_seconds gauge +probe_duration_seconds 0.123 +"#, + ) + .unwrap() + }); + + tokio::spawn(warp::serve(mock_endpoint).run(addr)); + wait_for_tcp(addr).await; + + // Configure source with ad-hoc labels in labels map + let mut labels_map = HashMap::new(); + labels_map.insert("environment".to_string(), "production".to_string()); + labels_map.insert("team".to_string(), "platform".to_string()); + labels_map.insert("cost_center".to_string(), "engineering".to_string()); + + let config = BlackboxExporterConfig { + url: format!("http://{}", addr), + targets: vec!["https://example.com".to_string()], + module: "http_2xx".to_string(), + interval: Duration::from_secs(1), + timeout: Duration::from_millis(500), + tls: None, + auth: None, + geohash: None, + region: None, + location: None, + country: None, + name: None, + provider: None, + labels: Some(labels_map.clone()), + }; + + // Run source and collect events + let events = + run_and_assert_source_compliance(config, Duration::from_secs(3), &HTTP_PULL_SOURCE_TAGS) + .await; + + // Verify metrics are scraped + assert!( + !events.is_empty(), + "Should have received at least one event" + ); + + // Verify all metrics have the ad-hoc labels + for event in &events { + let metric = event.as_metric(); + let tags = metric.tags().expect("Metric should have tags"); + + // Verify target and module tags + assert_eq!( + tags.get("target"), + Some("https://example.com"), + "Metric should have correct target tag" + ); + assert_eq!( + tags.get("module"), + Some("http_2xx"), + "Metric should have correct module tag" + ); + + // Verify all ad-hoc labels are present + assert_eq!( + tags.get("environment"), + Some("production"), + "Metric should have environment label" + ); + assert_eq!( + tags.get("team"), + Some("platform"), + "Metric should have team label" + ); + assert_eq!( + tags.get("cost_center"), + Some("engineering"), + "Metric should have cost_center label" + ); + + // Verify no predefined labels are present (since we didn't set any) + assert!( + tags.get("geohash").is_none(), + "Metric should not have geohash label" + ); + assert!( + tags.get("region").is_none(), + "Metric should not have region label" + ); + assert!( + tags.get("location").is_none(), + "Metric should not have location label" + ); + assert!( + tags.get("country").is_none(), + "Metric should not have country label" + ); + assert!( + tags.get("name").is_none(), + "Metric should not have name label" + ); + assert!( + tags.get("provider").is_none(), + "Metric should not have provider label" + ); + } +} + +#[tokio::test] +async fn test_integration_combined_labels() { + // Set up mock Blackbox Exporter endpoint + let (_guard, addr) = next_addr(); + + let mock_endpoint = warp::path!("probe") + .and(warp::query::>()) + .map(|_q: std::collections::HashMap| { + // Return mock Prometheus metrics + warp::http::Response::builder() + .header("Content-Type", "text/plain") + .body( + r#"# HELP probe_success Displays whether or not the probe was a success +# TYPE probe_success gauge +probe_success 1 +# HELP probe_duration_seconds Returns how long the probe took to complete in seconds +# TYPE probe_duration_seconds gauge +probe_duration_seconds 0.123 +"#, + ) + .unwrap() + }); + + tokio::spawn(warp::serve(mock_endpoint).run(addr)); + wait_for_tcp(addr).await; + + // Configure source with both predefined and ad-hoc labels + let mut labels_map = HashMap::new(); + labels_map.insert("environment".to_string(), "production".to_string()); + labels_map.insert("team".to_string(), "platform".to_string()); + + let config = BlackboxExporterConfig { + url: format!("http://{}", addr), + targets: vec!["https://example.com".to_string()], + module: "http_2xx".to_string(), + interval: Duration::from_secs(1), + timeout: Duration::from_millis(500), + tls: None, + auth: None, + geohash: Some("9qx7hh9jd".to_string()), + region: Some("AMER".to_string()), + location: Some("Oregon".to_string()), + country: Some("US".to_string()), + name: Some("Example Check".to_string()), + provider: Some("AWS".to_string()), + labels: Some(labels_map.clone()), + }; + + // Run source and collect events + let events = + run_and_assert_source_compliance(config, Duration::from_secs(3), &HTTP_PULL_SOURCE_TAGS) + .await; + + // Verify metrics are scraped + assert!( + !events.is_empty(), + "Should have received at least one event" + ); + + // Verify all metrics have both predefined and ad-hoc labels + for event in &events { + let metric = event.as_metric(); + let tags = metric.tags().expect("Metric should have tags"); + + // Verify target and module tags + assert_eq!( + tags.get("target"), + Some("https://example.com"), + "Metric should have correct target tag" + ); + assert_eq!( + tags.get("module"), + Some("http_2xx"), + "Metric should have correct module tag" + ); + + // Verify all predefined optional labels are present + assert_eq!( + tags.get("geohash"), + Some("9qx7hh9jd"), + "Metric should have geohash label" + ); + assert_eq!( + tags.get("region"), + Some("AMER"), + "Metric should have region label" + ); + assert_eq!( + tags.get("location"), + Some("Oregon"), + "Metric should have location label" + ); + assert_eq!( + tags.get("country"), + Some("US"), + "Metric should have country label" + ); + assert_eq!( + tags.get("name"), + Some("Example Check"), + "Metric should have name label" + ); + assert_eq!( + tags.get("provider"), + Some("AWS"), + "Metric should have provider label" + ); + + // Verify all ad-hoc labels are present + assert_eq!( + tags.get("environment"), + Some("production"), + "Metric should have environment label" + ); + assert_eq!( + tags.get("team"), + Some("platform"), + "Metric should have team label" + ); + } +} + +#[tokio::test] +async fn test_integration_no_optional_labels() { + // Set up mock Blackbox Exporter endpoint + let (_guard, addr) = next_addr(); + + let mock_endpoint = warp::path!("probe") + .and(warp::query::>()) + .map(|_q: std::collections::HashMap| { + // Return mock Prometheus metrics + warp::http::Response::builder() + .header("Content-Type", "text/plain") + .body( + r#"# HELP probe_success Displays whether or not the probe was a success +# TYPE probe_success gauge +probe_success 1 +# HELP probe_duration_seconds Returns how long the probe took to complete in seconds +# TYPE probe_duration_seconds gauge +probe_duration_seconds 0.123 +"#, + ) + .unwrap() + }); + + tokio::spawn(warp::serve(mock_endpoint).run(addr)); + wait_for_tcp(addr).await; + + // Configure source without any optional labels + let config = BlackboxExporterConfig { + url: format!("http://{}", addr), + targets: vec!["https://example.com".to_string()], + module: "http_2xx".to_string(), + interval: Duration::from_secs(1), + timeout: Duration::from_millis(500), + tls: None, + auth: None, + geohash: None, + region: None, + location: None, + country: None, + name: None, + provider: None, + labels: None, + }; + + // Run source and collect events + let events = + run_and_assert_source_compliance(config, Duration::from_secs(3), &HTTP_PULL_SOURCE_TAGS) + .await; + + // Verify metrics are scraped + assert!( + !events.is_empty(), + "Should have received at least one event" + ); + + // Verify source works normally with only target and module tags + for event in &events { + let metric = event.as_metric(); + let tags = metric.tags().expect("Metric should have tags"); + + // Verify target and module tags are present + assert_eq!( + tags.get("target"), + Some("https://example.com"), + "Metric should have correct target tag" + ); + assert_eq!( + tags.get("module"), + Some("http_2xx"), + "Metric should have correct module tag" + ); + + // Verify no optional labels are present + assert!( + tags.get("geohash").is_none(), + "Metric should not have geohash label" + ); + assert!( + tags.get("region").is_none(), + "Metric should not have region label" + ); + assert!( + tags.get("location").is_none(), + "Metric should not have location label" + ); + assert!( + tags.get("country").is_none(), + "Metric should not have country label" + ); + assert!( + tags.get("name").is_none(), + "Metric should not have name label" + ); + assert!( + tags.get("provider").is_none(), + "Metric should not have provider label" + ); + } } diff --git a/website/cue/reference/components/sources/blackbox_exporter.cue b/website/cue/reference/components/sources/blackbox_exporter.cue index c89ded2713c58..83c3df147bd1d 100644 --- a/website/cue/reference/components/sources/blackbox_exporter.cue +++ b/website/cue/reference/components/sources/blackbox_exporter.cue @@ -96,6 +96,81 @@ components: sources: blackbox_exporter: { """ } + optional_labels: { + title: "Optional labels" + body: """ + The source supports optional labels to enrich all scraped metrics with + contextual information about the probe location and infrastructure. You can + configure predefined labels (geohash, region, location, country, name, + provider) and/or custom ad-hoc labels using the `labels` map. + + **Predefined Labels:** + + - `geohash`: Geohash of the probe location (e.g., "9qx7hh9jd") + - `region`: Broad geographic region (e.g., "AMER", "EMEA", "APAC") + - `location`: City or location name (e.g., "Oregon", "Paris") + - `country`: Two-digit country code (e.g., "US", "FR") + - `name`: Friendly name for the check (e.g., "API Health") + - `provider`: Infrastructure provider (e.g., "AWS", "GCP", "AZURE") + + **Ad-hoc Labels:** + + Use the `labels` configuration map to add any custom key-value pairs as + labels. This is useful for adding environment, team, cost center, or other + organizational metadata. + + **Conflict Resolution:** + + If a scraped metric already contains a label that matches an optional label + key, the existing label is renamed to `exported_` and the configured + value is added as the new label, following Prometheus conventions. + + **Precedence:** + + If an ad-hoc label key conflicts with a predefined label key, the ad-hoc + label value takes precedence and the predefined label is ignored. + + **Example Configuration:** + + ```yaml + sources: + blackbox_http: + type: "blackbox_exporter" + url: "http://localhost:9115" + targets: ["https://example.com"] + module: "http_2xx" + # Predefined optional labels + geohash: "9qx7hh9jd" + region: "AMER" + location: "Oregon" + country: "US" + name: "API Monitor" + provider: "AWS" + # Ad-hoc custom labels + labels: + environment: "production" + team: "platform" + ``` + + **Example Output:** + + ``` + probe_success{ + target="https://example.com", + module="http_2xx", + geohash="9qx7hh9jd", + region="AMER", + location="Oregon", + country="US", + name="API Monitor", + provider="AWS", + environment="production", + team="platform" + } 1 + ``` + """ + } + multiple_targets: { title: "Multiple targets" body: """ @@ -147,6 +222,66 @@ components: sources: blackbox_exporter: { examples: ["custom"] required: false } + "geohash": { + description: "Geohash of the probe location. Only present if configured in the source." + examples: ["9qx7hh9jd", "u4pruydqqvj"] + required: false + } + "region": { + description: "Broad geographic region of the probe. Only present if configured in the source." + examples: ["AMER", "EMEA", "APAC"] + required: false + } + "location": { + description: "City or location name of the probe. Only present if configured in the source." + examples: ["Oregon", "Paris", "Tokyo"] + required: false + } + "country": { + description: "Two-digit country code of the probe location. Only present if configured in the source." + examples: ["US", "FR", "JP"] + required: false + } + "name": { + description: "Friendly name for the check. Only present if configured in the source." + examples: ["API Health", "Homepage", "Database"] + required: false + } + "provider": { + description: "Infrastructure provider where the probe is running. Only present if configured in the source." + examples: ["AWS", "GCP", "AZURE"] + required: false + } + "exported_geohash": { + description: "The original geohash label from the metric, if it existed. Only present if the scraped metric already had a 'geohash' label and a geohash was configured." + examples: ["abc123"] + required: false + } + "exported_region": { + description: "The original region label from the metric, if it existed. Only present if the scraped metric already had a 'region' label and a region was configured." + examples: ["internal"] + required: false + } + "exported_location": { + description: "The original location label from the metric, if it existed. Only present if the scraped metric already had a 'location' label and a location was configured." + examples: ["internal"] + required: false + } + "exported_country": { + description: "The original country label from the metric, if it existed. Only present if the scraped metric already had a 'country' label and a country was configured." + examples: ["XX"] + required: false + } + "exported_name": { + description: "The original name label from the metric, if it existed. Only present if the scraped metric already had a 'name' label and a name was configured." + examples: ["internal"] + required: false + } + "exported_provider": { + description: "The original provider label from the metric, if it existed. Only present if the scraped metric already had a 'provider' label and a provider was configured." + examples: ["internal"] + required: false + } } counter: output._passthrough_counter & {