From 9b93c93039cc1d2fe9ada5b1e4da3c81aefe978e Mon Sep 17 00:00:00 2001 From: Caleb Call Date: Sun, 30 Nov 2025 23:45:38 -0700 Subject: [PATCH 1/9] feat(nextest.toml): add specific timeout settings for blackbox_exporter tests The blackbox_exporter tests require more time due to HTTP probing. To accommodate this, specific timeout settings have been added for these tests. This includes a slow-timeout period of 5 minutes and a hard timeout period of 10 minutes. --- .config/nextest.toml | 7 +++++++ 1 file changed, 7 insertions(+) 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" From c0a703fd3abdc1972ddf82d68d6928cb95bb3344 Mon Sep 17 00:00:00 2001 From: Caleb Call Date: Sun, 30 Nov 2025 23:46:05 -0700 Subject: [PATCH 2/9] feat(blackbox_exporter): add optional labels to enrich metrics with context 1. Updated 'blackbox_exporter_http.yaml' to include optional labels for geographic and infrastructure context. This provides more detailed information about the probe location and infrastructure provider. 2. Created a new file 'blackbox_exporter_labels.yaml' that demonstrates the use of both predefined optional labels and custom ad-hoc labels. This allows for more flexibility in providing context to the metrics. 3. Updated 'blackbox_exporter_multiple.yaml' to include ad-hoc custom labels for additional context. This allows for arbitrary contextual information to be added to the metrics. --- config/examples/blackbox_exporter_http.yaml | 17 +++- config/examples/blackbox_exporter_labels.yaml | 92 +++++++++++++++++++ .../examples/blackbox_exporter_multiple.yaml | 19 +++- 3 files changed, 126 insertions(+), 2 deletions(-) create mode 100644 config/examples/blackbox_exporter_labels.yaml 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: From d13da7440702c932d46a84a4318e1c0f06a35d3d Mon Sep 17 00:00:00 2001 From: Caleb Call Date: Sun, 30 Nov 2025 23:46:35 -0700 Subject: [PATCH 3/9] feat(blackbox_exporter.cue): add optional labels support for enriched metrics This commit introduces optional labels to the blackbox_exporter source. These labels provide additional context about the probe location and infrastructure. Users can now configure predefined labels (geohash, region, location, country, name, provider) and/or custom ad-hoc labels using the `labels` map. This feature enhances the usability of the blackbox_exporter source by providing more detailed and customizable metrics. --- .../components/sources/blackbox_exporter.cue | 135 ++++++++++++++++++ 1 file changed, 135 insertions(+) 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 & { From 6e9c77679aa830f90ce2a69ab75b59cfa4520436 Mon Sep 17 00:00:00 2001 From: Caleb Call Date: Sun, 30 Nov 2025 23:51:05 -0700 Subject: [PATCH 4/9] Adds Blackbox exporter source tests Adds comprehensive unit, integration, and property-based tests for the Blackbox exporter source. These tests cover: - Probe URL construction and encoding - Prometheus text format parsing - Configuration validation - Metric enrichment and tag injection, including conflict resolution - Optional label injection - Error isolation across targets --- src/sources/blackbox_exporter/tests.rs | 2393 ++++++++++++++++++++++-- 1 file changed, 2267 insertions(+), 126 deletions(-) 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" + ); + } } From 0a8da3cd0f797985eb442ef470f1c4b83a2d69db Mon Sep 17 00:00:00 2001 From: Caleb Call Date: Sun, 30 Nov 2025 23:51:35 -0700 Subject: [PATCH 5/9] feat(blackbox_exporter): add optional labels to scraped metrics for better identification This commit introduces optional labels to the BlackboxExporterConfig struct. These labels include geohash, region, location, country, name, provider, and custom labels. These labels will be added to all scraped metrics, providing more detailed information about the probe and the infrastructure it's running on. This will help in better identification and categorization of the metrics. --- src/sources/blackbox_exporter/mod.rs | 151 +++++++++++++++++++++++++++ 1 file changed, 151 insertions(+) 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()); } } } From c2206012684b1a93fe68cbde482951b355ddf701 Mon Sep 17 00:00:00 2001 From: Caleb Call Date: Mon, 1 Dec 2025 00:06:08 -0700 Subject: [PATCH 6/9] chore(build.yaml): move 'Run Tests' step before 'Build Vector' in GitHub Actions workflow This change is made to ensure that tests are run before the build process. This way, if any test fails, the build process will not proceed, saving resources and time. --- .github/workflows/build.yaml | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/.github/workflows/build.yaml b/.github/workflows/build.yaml index 606fdc6a6693c..1fb05a1c55413 100644 --- a/.github/workflows/build.yaml +++ b/.github/workflows/build.yaml @@ -87,6 +87,11 @@ jobs: # sudo make install_sw # cd .. + - name: Run Tests + run: | + cargo nextest run --release --workspace --no-fail-fast --no-default-features --features default -E 'test(blackbox_exporter)' + echo "All tests passed" + - name: Build Vector # env: # RUSTFLAGS: "-C target-feature=+crt-static" @@ -97,12 +102,7 @@ jobs: run: | cargo build --release 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 From 42289f31e60ce658aff3034b8216415f4d98dd97 Mon Sep 17 00:00:00 2001 From: Caleb Call Date: Mon, 1 Dec 2025 07:41:05 -0700 Subject: [PATCH 7/9] refactor(build.yaml): simplify the naming of the release archive and its checksum file The changes were made to simplify the naming of the release archive and its checksum file by removing the specific architecture details from the file names. This change will make it easier for users to download and install the release, as they no longer need to know the specific architecture details. --- .github/workflows/build.yaml | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/.github/workflows/build.yaml b/.github/workflows/build.yaml index 1fb05a1c55413..4a017b726a65c 100644 --- a/.github/workflows/build.yaml +++ b/.github/workflows/build.yaml @@ -106,10 +106,10 @@ jobs: - 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 @@ -140,8 +140,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 +151,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: From 0e7b362122fd40d2c5c8be9876961bf0f511876c Mon Sep 17 00:00:00 2001 From: Caleb Call Date: Mon, 1 Dec 2025 08:28:15 -0700 Subject: [PATCH 8/9] chore(build.yaml): merge 'Run Tests' and 'Build Vector' steps in GitHub Actions workflow This change was made to optimize the workflow by building the vector package once before running the tests, instead of building it separately in the 'Build Vector' step. This reduces redundancy and improves the efficiency of the workflow. --- .github/workflows/build.yaml | 25 +++++++++++++------------ 1 file changed, 13 insertions(+), 12 deletions(-) diff --git a/.github/workflows/build.yaml b/.github/workflows/build.yaml index 4a017b726a65c..d7c50b2be02cb 100644 --- a/.github/workflows/build.yaml +++ b/.github/workflows/build.yaml @@ -87,21 +87,22 @@ jobs: # sudo make install_sw # cd .. - - name: Run Tests + - name: Build Vector and Run Tests run: | - cargo nextest run --release --workspace --no-fail-fast --no-default-features --features default -E 'test(blackbox_exporter)' + cargo build --release -p vector + 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" - # CC_x86_64_unknown_linux_musl: musl-gcc - # OPENSSL_STATIC: "1" - # OPENSSL_DIR: /musl - # OPENSSL_NO_VENDOR: "1" - run: | - cargo build --release - echo "Build completed successfully" + # - name: Build Vector + # # env: + # # RUSTFLAGS: "-C target-feature=+crt-static" + # # CC_x86_64_unknown_linux_musl: musl-gcc + # # OPENSSL_STATIC: "1" + # # OPENSSL_DIR: /musl + # # OPENSSL_NO_VENDOR: "1" + # run: | + # cargo build --release + # echo "Build completed successfully" - name: Create Release Archive run: | From 8b0d67d7d8aa8491b7a0ac31d206b75f3cc7bec8 Mon Sep 17 00:00:00 2001 From: Caleb Call Date: Mon, 1 Dec 2025 09:55:47 -0700 Subject: [PATCH 9/9] chore(build.yaml): separate build and test steps in GitHub Actions workflow - Split the "Build Vector and Run Tests" step into two separate steps: "Run Tests" and "Build Vector". This change improves the readability and maintainability of the workflow. - Add support for 'feat/*' branches to trigger the workflow. This allows testing new features before they are merged into the main or master branches. - Change the runner for the 'release' job from 'ubuntu-latest' to 'vector-builder'. This ensures that the release is built in a consistent environment. - Add a condition to the 'release' job to only run if the current branch is 'main' or 'master'. This prevents accidental releases from feature branches. --- .github/workflows/build.yaml | 27 ++++++++++++++------------- 1 file changed, 14 insertions(+), 13 deletions(-) diff --git a/.github/workflows/build.yaml b/.github/workflows/build.yaml index d7c50b2be02cb..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,22 +88,21 @@ jobs: # sudo make install_sw # cd .. - - name: Build Vector and Run Tests + - name: Run Tests run: | - cargo build --release -p vector 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" - # # CC_x86_64_unknown_linux_musl: musl-gcc - # # OPENSSL_STATIC: "1" - # # OPENSSL_DIR: /musl - # # OPENSSL_NO_VENDOR: "1" - # run: | - # cargo build --release - # echo "Build completed successfully" + - name: Build Vector + # env: + # RUSTFLAGS: "-C target-feature=+crt-static" + # CC_x86_64_unknown_linux_musl: musl-gcc + # OPENSSL_STATIC: "1" + # OPENSSL_DIR: /musl + # OPENSSL_NO_VENDOR: "1" + run: | + cargo build --release -p vector + echo "Build completed successfully" - name: Create Release Archive run: | @@ -122,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 }}