1use std::collections::HashMap;
2
3use anyhow::Result;
4use serde::{Deserialize, Serialize};
5
6use crate::export::{Compression, ExportConfig, ExportMode, Sampling};
7use crate::init::{TelemetryConfig, init_telemetry_from_config};
8use crate::presets;
9
10#[derive(Clone, Debug, Default, Serialize, Deserialize)]
12pub struct TlsConfig {
13 #[serde(default)]
14 pub ca_cert_pem: Option<String>,
15 #[serde(default)]
16 pub client_cert_pem: Option<String>,
17 #[serde(default)]
18 pub client_key_pem: Option<String>,
19}
20
21#[derive(Clone, Debug, Serialize, Deserialize)]
23pub struct TenantAttribution {
24 #[serde(default = "default_true")]
26 pub include_tenant: bool,
27 #[serde(default = "default_true")]
29 pub include_team: bool,
30 #[serde(default)]
32 pub include_team_in_metrics: bool,
33 #[serde(default)]
35 pub hash_ids: bool,
36}
37
38impl Default for TenantAttribution {
39 fn default() -> Self {
40 Self {
41 include_tenant: true,
42 include_team: true,
43 include_team_in_metrics: false,
44 hash_ids: false,
45 }
46 }
47}
48
49#[derive(Clone, Debug, Serialize, Deserialize)]
55pub struct TelemetryProviderConfig {
56 #[serde(default = "default_export_mode")]
58 pub export_mode: String,
59
60 #[serde(default)]
62 pub endpoint: Option<String>,
63
64 #[serde(default)]
66 pub headers: HashMap<String, String>,
67
68 #[serde(default = "default_sampling_ratio")]
70 pub sampling_ratio: f64,
71
72 #[serde(default)]
74 pub compression: Option<String>,
75
76 #[serde(default)]
78 pub service_name: Option<String>,
79
80 #[serde(default)]
82 pub resource_attributes: HashMap<String, String>,
83
84 #[serde(default)]
86 pub redaction_patterns: Vec<String>,
87
88 #[serde(default)]
90 pub preset: Option<String>,
91
92 #[serde(default = "default_true")]
94 pub enable_operation_subs: bool,
95
96 #[serde(default)]
98 pub operation_subs_mode: Option<String>,
99
100 #[serde(default = "default_true")]
102 pub include_denied_ops: bool,
103
104 #[serde(default)]
106 pub payload_policy: Option<String>,
107
108 #[serde(default)]
111 pub min_log_level: Option<String>,
112
113 #[serde(default)]
115 pub tls_config: Option<TlsConfig>,
116
117 #[serde(default)]
119 pub exclude_ops: Vec<String>,
120
121 #[serde(default)]
123 pub drop_payloads: bool,
124
125 #[serde(default)]
127 pub tenant_attribution: Option<TenantAttribution>,
128}
129
130fn default_export_mode() -> String {
131 "json-stdout".into()
132}
133
134fn default_sampling_ratio() -> f64 {
135 1.0
136}
137
138fn default_true() -> bool {
139 true
140}
141
142impl Default for TelemetryProviderConfig {
143 fn default() -> Self {
144 Self {
145 export_mode: default_export_mode(),
146 endpoint: None,
147 headers: HashMap::new(),
148 sampling_ratio: 1.0,
149 compression: None,
150 service_name: None,
151 resource_attributes: HashMap::new(),
152 redaction_patterns: Vec::new(),
153 preset: None,
154 enable_operation_subs: true,
155 operation_subs_mode: None,
156 include_denied_ops: true,
157 payload_policy: None,
158 min_log_level: None,
159 tls_config: None,
160 exclude_ops: Vec::new(),
161 drop_payloads: false,
162 tenant_attribution: None,
163 }
164 }
165}
166
167pub fn to_export_config(config: &TelemetryProviderConfig) -> ExportConfig {
170 let mode = match config.export_mode.to_ascii_lowercase().as_str() {
171 "otlp-grpc" => ExportMode::OtlpGrpc,
172 "otlp-http" => ExportMode::OtlpHttp,
173 "azure-appinsights" => ExportMode::AzureAppInsights,
174 "aws-xray" => ExportMode::AwsXRay,
175 "gcp-cloud-trace" => ExportMode::GcpCloudTrace,
176 "json-stdout" => ExportMode::JsonStdout,
177 "none" => ExportMode::JsonStdout,
178 _ => ExportMode::JsonStdout,
179 };
180
181 let sampling = if config.sampling_ratio <= 0.0 {
182 Sampling::AlwaysOff
183 } else if config.sampling_ratio >= 1.0 {
184 Sampling::AlwaysOn
185 } else {
186 Sampling::TraceIdRatio(config.sampling_ratio)
187 };
188
189 let compression =
190 config
191 .compression
192 .as_deref()
193 .and_then(|c| match c.to_ascii_lowercase().as_str() {
194 "gzip" => Some(Compression::Gzip),
195 _ => None,
196 });
197
198 ExportConfig {
199 mode,
200 endpoint: config.endpoint.clone(),
201 headers: config.headers.clone(),
202 sampling,
203 compression,
204 resource_attributes: config.resource_attributes.clone(),
205 tls_config: config.tls_config.clone(),
206 }
207}
208
209fn resolve_with_preset(config: &TelemetryProviderConfig) -> Result<ExportConfig> {
212 let preset_name = config.preset.as_deref().unwrap_or("none");
213 let preset = match preset_name.to_ascii_lowercase().as_str() {
214 "aws" => presets::CloudPreset::Aws,
215 "gcp" => presets::CloudPreset::Gcp,
216 "azure" => presets::CloudPreset::Azure,
217 "datadog" => presets::CloudPreset::Datadog,
218 "loki" => presets::CloudPreset::Loki,
219 "honeycomb" => presets::CloudPreset::Honeycomb,
220 "newrelic" => presets::CloudPreset::NewRelic,
221 "elastic" => presets::CloudPreset::Elastic,
222 "grafana-tempo" | "grafana_tempo" => presets::CloudPreset::GrafanaTempo,
223 "jaeger" => presets::CloudPreset::Jaeger,
224 "zipkin" => presets::CloudPreset::Zipkin,
225 "otlp-grpc" | "otlp_grpc" => presets::CloudPreset::OtlpGrpc,
226 "otlp-http" | "otlp_http" => presets::CloudPreset::OtlpHttp,
227 "stdout" => presets::CloudPreset::Stdout,
228 _ => presets::CloudPreset::None,
229 };
230
231 let preset_cfg = presets::load_preset(preset)?;
232
233 let mode = if config.export_mode != "json-stdout" || config.preset.is_none() {
235 match config.export_mode.to_ascii_lowercase().as_str() {
237 "otlp-grpc" => ExportMode::OtlpGrpc,
238 "otlp-http" => ExportMode::OtlpHttp,
239 "azure-appinsights" => ExportMode::AzureAppInsights,
240 "aws-xray" => ExportMode::AwsXRay,
241 "gcp-cloud-trace" => ExportMode::GcpCloudTrace,
242 _ => ExportMode::JsonStdout,
243 }
244 } else {
245 preset_cfg.export_mode.unwrap_or(ExportMode::JsonStdout)
246 };
247
248 let endpoint = config.endpoint.clone().or(preset_cfg.otlp_endpoint);
250
251 let mut headers = preset_cfg.otlp_headers;
253 headers.extend(config.headers.clone());
254
255 let effective_ratio = if (config.sampling_ratio - 1.0).abs() < f64::EPSILON {
257 preset_cfg.sampling_ratio.unwrap_or(config.sampling_ratio)
258 } else {
259 config.sampling_ratio
260 };
261
262 let sampling = if effective_ratio <= 0.0 {
263 Sampling::AlwaysOff
264 } else if effective_ratio >= 1.0 {
265 Sampling::AlwaysOn
266 } else {
267 Sampling::TraceIdRatio(effective_ratio)
268 };
269
270 let compression =
271 config
272 .compression
273 .as_deref()
274 .and_then(|c| match c.to_ascii_lowercase().as_str() {
275 "gzip" => Some(Compression::Gzip),
276 _ => None,
277 });
278
279 Ok(ExportConfig {
280 mode,
281 endpoint,
282 headers,
283 sampling,
284 compression,
285 resource_attributes: config.resource_attributes.clone(),
286 tls_config: config.tls_config.clone(),
287 })
288}
289
290pub fn init_from_provider_config(config: &TelemetryProviderConfig) -> Result<()> {
298 if let Some(ref level) = config.min_log_level
300 && std::env::var("RUST_LOG").is_err()
301 {
302 unsafe {
304 std::env::set_var("RUST_LOG", level);
305 }
306 }
307
308 if !config.redaction_patterns.is_empty() {
310 let joined = config.redaction_patterns.join(",");
311 unsafe {
313 std::env::set_var("PII_MASK_REGEXES", &joined);
314 }
315 }
316
317 let service_name = config
318 .service_name
319 .clone()
320 .unwrap_or_else(|| "greentic-operator".into());
321
322 let export = if config.preset.is_some() {
323 resolve_with_preset(config)?
324 } else {
325 to_export_config(config)
326 };
327
328 init_telemetry_from_config(TelemetryConfig { service_name }, export)
329}
330
331const KNOWN_EXPORT_MODES: &[&str] = &[
337 "otlp-grpc",
338 "otlp-http",
339 "json-stdout",
340 "azure-appinsights",
341 "aws-xray",
342 "gcp-cloud-trace",
343 "none",
344];
345
346const SENSITIVE_HEADER_KEYS: &[&str] = &[
348 "authorization",
349 "api-key",
350 "x-api-key",
351 "x-honeycomb-team",
352 "dd_api_key",
353 "dd-api-key",
354];
355
356const KNOWN_SUBS_MODES: &[&str] = &["metrics_only", "traces_only", "metrics_and_traces"];
357const KNOWN_PAYLOAD_POLICIES: &[&str] = &["none", "hash_only"];
358const KNOWN_COMPRESSIONS: &[&str] = &["gzip"];
359const KNOWN_LOG_LEVELS: &[&str] = &["trace", "debug", "info", "warn", "error"];
360
361pub fn validate_telemetry_config(config: &TelemetryProviderConfig) -> Vec<String> {
368 let mut warnings = Vec::new();
369 let mode_lower = config.export_mode.to_ascii_lowercase();
370
371 if !KNOWN_EXPORT_MODES.contains(&mode_lower.as_str()) {
373 warnings.push(format!(
374 "unknown export_mode '{}'; expected one of: {}",
375 config.export_mode,
376 KNOWN_EXPORT_MODES.join(", ")
377 ));
378 }
379
380 let needs_endpoint = matches!(mode_lower.as_str(), "otlp-grpc" | "otlp-http");
382 if needs_endpoint && config.endpoint.is_none() && config.preset.is_none() {
383 warnings.push(format!(
384 "export_mode '{}' requires an endpoint but none is configured and no preset is set",
385 config.export_mode
386 ));
387 }
388
389 for key in config.headers.keys() {
391 if SENSITIVE_HEADER_KEYS.contains(&key.to_ascii_lowercase().as_str()) {
392 warnings.push(format!(
393 "header '{}' appears to contain credentials; consider using secrets-backed values",
394 key
395 ));
396 }
397 }
398
399 if !(0.0..=1.0).contains(&config.sampling_ratio) {
401 warnings.push(format!(
402 "sampling_ratio {} is out of range 0.0..=1.0",
403 config.sampling_ratio
404 ));
405 }
406
407 if let Some(ref c) = config.compression
409 && !KNOWN_COMPRESSIONS.contains(&c.to_ascii_lowercase().as_str())
410 {
411 warnings.push(format!(
412 "unknown compression '{}'; expected one of: {}",
413 c,
414 KNOWN_COMPRESSIONS.join(", ")
415 ));
416 }
417
418 if let Some(ref m) = config.operation_subs_mode
420 && !KNOWN_SUBS_MODES.contains(&m.to_ascii_lowercase().as_str())
421 {
422 warnings.push(format!(
423 "unknown operation_subs_mode '{}'; expected one of: {}",
424 m,
425 KNOWN_SUBS_MODES.join(", ")
426 ));
427 }
428
429 if let Some(ref p) = config.payload_policy
431 && !KNOWN_PAYLOAD_POLICIES.contains(&p.to_ascii_lowercase().as_str())
432 {
433 warnings.push(format!(
434 "unknown payload_policy '{}'; expected one of: {}",
435 p,
436 KNOWN_PAYLOAD_POLICIES.join(", ")
437 ));
438 }
439
440 if config
442 .redaction_patterns
443 .iter()
444 .any(|p| p.trim().is_empty())
445 {
446 warnings.push("redaction_patterns contains an empty entry".into());
447 }
448
449 if let Some(ref level) = config.min_log_level
451 && !KNOWN_LOG_LEVELS.contains(&level.to_ascii_lowercase().as_str())
452 {
453 warnings.push(format!(
454 "unknown min_log_level '{}'; expected one of: {}",
455 level,
456 KNOWN_LOG_LEVELS.join(", ")
457 ));
458 }
459
460 if let Some(ref tls) = config.tls_config
462 && (tls.client_cert_pem.is_some() != tls.client_key_pem.is_some())
463 {
464 warnings.push(
465 "tls_config has client_cert_pem without client_key_pem (or vice versa); both are required for mTLS".into()
466 );
467 }
468
469 if let Some(ref attr) = config.tenant_attribution
471 && attr.hash_ids
472 && !attr.include_tenant
473 && !attr.include_team
474 {
475 warnings.push(
476 "tenant_attribution.hash_ids is enabled but both include_tenant and include_team are false; hashing has no effect".into()
477 );
478 }
479
480 warnings
481}
482
483#[cfg(test)]
484mod tests {
485 use super::*;
486
487 #[test]
488 fn default_config_produces_json_stdout() {
489 let config = TelemetryProviderConfig::default();
490 let export = to_export_config(&config);
491 assert_eq!(export.mode, ExportMode::JsonStdout);
492 assert!(export.endpoint.is_none());
493 assert!(export.headers.is_empty());
494 }
495
496 #[test]
497 fn otlp_grpc_config() {
498 let config = TelemetryProviderConfig {
499 export_mode: "otlp-grpc".into(),
500 endpoint: Some("http://collector:4317".into()),
501 headers: {
502 let mut h = HashMap::new();
503 h.insert("x-api-key".into(), "secret123".into());
504 h
505 },
506 sampling_ratio: 0.5,
507 compression: Some("gzip".into()),
508 ..Default::default()
509 };
510 let export = to_export_config(&config);
511 assert_eq!(export.mode, ExportMode::OtlpGrpc);
512 assert_eq!(export.endpoint.as_deref(), Some("http://collector:4317"));
513 assert_eq!(export.headers.get("x-api-key").unwrap(), "secret123");
514 assert!(
515 matches!(export.sampling, Sampling::TraceIdRatio(r) if (r - 0.5).abs() < f64::EPSILON)
516 );
517 assert!(matches!(export.compression, Some(Compression::Gzip)));
518 }
519
520 #[test]
521 fn otlp_http_config() {
522 let config = TelemetryProviderConfig {
523 export_mode: "otlp-http".into(),
524 endpoint: Some("http://collector:4318".into()),
525 ..Default::default()
526 };
527 let export = to_export_config(&config);
528 assert_eq!(export.mode, ExportMode::OtlpHttp);
529 }
530
531 #[test]
532 fn none_mode_falls_back_to_json_stdout() {
533 let config = TelemetryProviderConfig {
534 export_mode: "none".into(),
535 ..Default::default()
536 };
537 let export = to_export_config(&config);
538 assert_eq!(export.mode, ExportMode::JsonStdout);
539 }
540
541 #[test]
542 fn sampling_boundaries() {
543 let config = TelemetryProviderConfig {
545 sampling_ratio: 0.0,
546 ..Default::default()
547 };
548 assert!(matches!(
549 to_export_config(&config).sampling,
550 Sampling::AlwaysOff
551 ));
552
553 let config = TelemetryProviderConfig {
555 sampling_ratio: 1.0,
556 ..Default::default()
557 };
558 assert!(matches!(
559 to_export_config(&config).sampling,
560 Sampling::AlwaysOn
561 ));
562
563 let config = TelemetryProviderConfig {
565 sampling_ratio: 0.25,
566 ..Default::default()
567 };
568 assert!(matches!(
569 to_export_config(&config).sampling,
570 Sampling::TraceIdRatio(_)
571 ));
572 }
573
574 #[test]
575 fn preset_resolution_honeycomb() {
576 let config = TelemetryProviderConfig {
577 preset: Some("honeycomb".into()),
578 headers: {
579 let mut h = HashMap::new();
580 h.insert("x-honeycomb-team".into(), "my-key".into());
581 h
582 },
583 ..Default::default()
584 };
585 let export = resolve_with_preset(&config).unwrap();
586 assert_eq!(export.mode, ExportMode::OtlpGrpc);
587 assert!(export.endpoint.is_some());
588 assert!(export.headers.contains_key("x-honeycomb-team"));
589 }
590
591 #[test]
592 fn preset_resolution_jaeger() {
593 let config = TelemetryProviderConfig {
594 preset: Some("jaeger".into()),
595 ..Default::default()
596 };
597 let export = resolve_with_preset(&config).unwrap();
598 assert_eq!(export.mode, ExportMode::OtlpGrpc);
599 assert_eq!(export.endpoint.as_deref(), Some("http://localhost:4317"));
600 }
601
602 #[test]
603 fn explicit_endpoint_overrides_preset() {
604 let config = TelemetryProviderConfig {
605 preset: Some("honeycomb".into()),
606 endpoint: Some("http://custom:4317".into()),
607 ..Default::default()
608 };
609 let export = resolve_with_preset(&config).unwrap();
610 assert_eq!(export.endpoint.as_deref(), Some("http://custom:4317"));
611 }
612
613 #[test]
614 fn compression_gzip_parsed() {
615 let config = TelemetryProviderConfig {
616 compression: Some("gzip".into()),
617 ..Default::default()
618 };
619 let export = to_export_config(&config);
620 assert!(matches!(export.compression, Some(Compression::Gzip)));
621 }
622
623 #[test]
624 fn compression_unknown_ignored() {
625 let config = TelemetryProviderConfig {
626 compression: Some("lz4".into()),
627 ..Default::default()
628 };
629 let export = to_export_config(&config);
630 assert!(export.compression.is_none());
631 }
632
633 #[test]
634 fn resource_attributes_passed_through() {
635 let config = TelemetryProviderConfig {
636 resource_attributes: {
637 let mut m = HashMap::new();
638 m.insert("deployment.environment".into(), "staging".into());
639 m.insert("service.version".into(), "1.2.3".into());
640 m
641 },
642 ..Default::default()
643 };
644 let export = to_export_config(&config);
645 assert_eq!(
646 export
647 .resource_attributes
648 .get("deployment.environment")
649 .unwrap(),
650 "staging"
651 );
652 assert_eq!(
653 export.resource_attributes.get("service.version").unwrap(),
654 "1.2.3"
655 );
656 }
657
658 #[test]
659 fn resource_attributes_passed_through_preset() {
660 let config = TelemetryProviderConfig {
661 preset: Some("jaeger".into()),
662 resource_attributes: {
663 let mut m = HashMap::new();
664 m.insert("k8s.pod.name".into(), "test-pod".into());
665 m
666 },
667 ..Default::default()
668 };
669 let export = resolve_with_preset(&config).unwrap();
670 assert_eq!(
671 export.resource_attributes.get("k8s.pod.name").unwrap(),
672 "test-pod"
673 );
674 }
675
676 #[test]
677 fn default_service_name_is_greentic_operator() {
678 let config = TelemetryProviderConfig::default();
682 assert!(config.service_name.is_none());
683 let name = config
684 .service_name
685 .unwrap_or_else(|| "greentic-operator".into());
686 assert_eq!(name, "greentic-operator");
687 }
688
689 #[test]
690 fn custom_service_name_used() {
691 let config = TelemetryProviderConfig {
692 service_name: Some("my-service".into()),
693 ..Default::default()
694 };
695 assert_eq!(config.service_name.as_deref(), Some("my-service"));
696 }
697
698 #[test]
701 fn validate_default_config_no_warnings() {
702 let config = TelemetryProviderConfig::default();
703 let warnings = validate_telemetry_config(&config);
704 assert!(warnings.is_empty());
705 }
706
707 #[test]
708 fn validate_unknown_export_mode() {
709 let config = TelemetryProviderConfig {
710 export_mode: "kafka".into(),
711 ..Default::default()
712 };
713 let warnings = validate_telemetry_config(&config);
714 assert_eq!(warnings.len(), 1);
715 assert!(warnings[0].contains("unknown export_mode"));
716 }
717
718 #[test]
719 fn validate_otlp_grpc_without_endpoint_warns() {
720 let config = TelemetryProviderConfig {
721 export_mode: "otlp-grpc".into(),
722 endpoint: None,
723 preset: None,
724 ..Default::default()
725 };
726 let warnings = validate_telemetry_config(&config);
727 assert!(warnings.iter().any(|w| w.contains("requires an endpoint")));
728 }
729
730 #[test]
731 fn validate_otlp_grpc_with_preset_no_endpoint_ok() {
732 let config = TelemetryProviderConfig {
733 export_mode: "otlp-grpc".into(),
734 endpoint: None,
735 preset: Some("jaeger".into()),
736 ..Default::default()
737 };
738 let warnings = validate_telemetry_config(&config);
739 assert!(!warnings.iter().any(|w| w.contains("requires an endpoint")));
740 }
741
742 #[test]
743 fn validate_otlp_grpc_with_endpoint_ok() {
744 let config = TelemetryProviderConfig {
745 export_mode: "otlp-grpc".into(),
746 endpoint: Some("http://localhost:4317".into()),
747 ..Default::default()
748 };
749 let warnings = validate_telemetry_config(&config);
750 assert!(warnings.is_empty());
751 }
752
753 #[test]
754 fn validate_sensitive_header_warns() {
755 let config = TelemetryProviderConfig {
756 headers: {
757 let mut h = HashMap::new();
758 h.insert("x-honeycomb-team".into(), "my-key".into());
759 h
760 },
761 ..Default::default()
762 };
763 let warnings = validate_telemetry_config(&config);
764 assert_eq!(warnings.len(), 1);
765 assert!(warnings[0].contains("credentials"));
766 }
767
768 #[test]
769 fn validate_non_sensitive_header_ok() {
770 let config = TelemetryProviderConfig {
771 headers: {
772 let mut h = HashMap::new();
773 h.insert("x-custom-header".into(), "value".into());
774 h
775 },
776 ..Default::default()
777 };
778 let warnings = validate_telemetry_config(&config);
779 assert!(warnings.is_empty());
780 }
781
782 #[test]
783 fn validate_sampling_ratio_out_of_range() {
784 let config = TelemetryProviderConfig {
785 sampling_ratio: -0.5,
786 ..Default::default()
787 };
788 let warnings = validate_telemetry_config(&config);
789 assert!(warnings.iter().any(|w| w.contains("sampling_ratio")));
790
791 let config = TelemetryProviderConfig {
792 sampling_ratio: 1.5,
793 ..Default::default()
794 };
795 let warnings = validate_telemetry_config(&config);
796 assert!(warnings.iter().any(|w| w.contains("sampling_ratio")));
797 }
798
799 #[test]
800 fn validate_unknown_compression() {
801 let config = TelemetryProviderConfig {
802 compression: Some("lz4".into()),
803 ..Default::default()
804 };
805 let warnings = validate_telemetry_config(&config);
806 assert!(warnings.iter().any(|w| w.contains("unknown compression")));
807 }
808
809 #[test]
810 fn validate_unknown_operation_subs_mode() {
811 let config = TelemetryProviderConfig {
812 operation_subs_mode: Some("everything".into()),
813 ..Default::default()
814 };
815 let warnings = validate_telemetry_config(&config);
816 assert!(
817 warnings
818 .iter()
819 .any(|w| w.contains("unknown operation_subs_mode"))
820 );
821 }
822
823 #[test]
824 fn validate_unknown_payload_policy() {
825 let config = TelemetryProviderConfig {
826 payload_policy: Some("full_body".into()),
827 ..Default::default()
828 };
829 let warnings = validate_telemetry_config(&config);
830 assert!(
831 warnings
832 .iter()
833 .any(|w| w.contains("unknown payload_policy"))
834 );
835 }
836
837 #[test]
838 fn validate_empty_redaction_pattern() {
839 let config = TelemetryProviderConfig {
840 redaction_patterns: vec!["\\d+".into(), "".into()],
841 ..Default::default()
842 };
843 let warnings = validate_telemetry_config(&config);
844 assert!(warnings.iter().any(|w| w.contains("empty entry")));
845 }
846
847 #[test]
848 fn validate_unknown_min_log_level() {
849 let config = TelemetryProviderConfig {
850 min_log_level: Some("verbose".into()),
851 ..Default::default()
852 };
853 let warnings = validate_telemetry_config(&config);
854 assert!(warnings.iter().any(|w| w.contains("unknown min_log_level")));
855 }
856
857 #[test]
858 fn validate_valid_min_log_level_ok() {
859 let config = TelemetryProviderConfig {
860 min_log_level: Some("debug".into()),
861 ..Default::default()
862 };
863 let warnings = validate_telemetry_config(&config);
864 assert!(warnings.is_empty());
865 }
866
867 #[test]
868 fn validate_tls_cert_without_key() {
869 let config = TelemetryProviderConfig {
870 tls_config: Some(TlsConfig {
871 ca_cert_pem: None,
872 client_cert_pem: Some("cert-data".into()),
873 client_key_pem: None,
874 }),
875 ..Default::default()
876 };
877 let warnings = validate_telemetry_config(&config);
878 assert!(
879 warnings
880 .iter()
881 .any(|w| w.contains("client_cert_pem without client_key_pem"))
882 );
883 }
884
885 #[test]
886 fn validate_tls_complete_ok() {
887 let config = TelemetryProviderConfig {
888 tls_config: Some(TlsConfig {
889 ca_cert_pem: Some("ca".into()),
890 client_cert_pem: Some("cert".into()),
891 client_key_pem: Some("key".into()),
892 }),
893 ..Default::default()
894 };
895 let warnings = validate_telemetry_config(&config);
896 assert!(warnings.is_empty());
897 }
898
899 #[test]
900 fn preset_resolution_zipkin() {
901 let config = TelemetryProviderConfig {
902 preset: Some("zipkin".into()),
903 ..Default::default()
904 };
905 let export = resolve_with_preset(&config).unwrap();
906 assert_eq!(export.mode, ExportMode::OtlpHttp);
907 assert_eq!(export.endpoint.as_deref(), Some("http://localhost:9411"));
908 }
909
910 #[test]
911 fn new_fields_default_values() {
912 let config = TelemetryProviderConfig::default();
913 assert!(config.min_log_level.is_none());
914 assert!(config.tls_config.is_none());
915 assert!(config.exclude_ops.is_empty());
916 assert!(!config.drop_payloads);
917 assert!(config.tenant_attribution.is_none());
918 }
919
920 #[test]
921 fn new_fields_serde_roundtrip() {
922 let config = TelemetryProviderConfig {
923 min_log_level: Some("debug".into()),
924 tls_config: Some(TlsConfig {
925 ca_cert_pem: Some("ca-data".into()),
926 client_cert_pem: None,
927 client_key_pem: None,
928 }),
929 exclude_ops: vec!["healthcheck".into(), "ping".into()],
930 drop_payloads: true,
931 ..Default::default()
932 };
933 let json = serde_json::to_string(&config).unwrap();
934 let deserialized: TelemetryProviderConfig = serde_json::from_str(&json).unwrap();
935 assert_eq!(deserialized.min_log_level.as_deref(), Some("debug"));
936 assert!(deserialized.tls_config.is_some());
937 assert_eq!(deserialized.exclude_ops.len(), 2);
938 assert!(deserialized.drop_payloads);
939 }
940
941 #[test]
944 fn preset_resolution_otlp_grpc() {
945 let config = TelemetryProviderConfig {
946 preset: Some("otlp-grpc".into()),
947 ..Default::default()
948 };
949 let export = resolve_with_preset(&config).unwrap();
950 assert_eq!(export.mode, ExportMode::OtlpGrpc);
951 assert_eq!(export.endpoint.as_deref(), Some("http://localhost:4317"));
952 }
953
954 #[test]
955 fn preset_resolution_otlp_http() {
956 let config = TelemetryProviderConfig {
957 preset: Some("otlp-http".into()),
958 ..Default::default()
959 };
960 let export = resolve_with_preset(&config).unwrap();
961 assert_eq!(export.mode, ExportMode::OtlpHttp);
962 assert_eq!(export.endpoint.as_deref(), Some("http://localhost:4318"));
963 }
964
965 #[test]
966 fn preset_resolution_stdout() {
967 let config = TelemetryProviderConfig {
968 preset: Some("stdout".into()),
969 ..Default::default()
970 };
971 let export = resolve_with_preset(&config).unwrap();
972 assert_eq!(export.mode, ExportMode::JsonStdout);
973 assert!(export.endpoint.is_none());
974 }
975
976 #[test]
977 fn preset_sampling_fallback() {
978 let config = TelemetryProviderConfig {
982 preset: Some("jaeger".into()),
983 ..Default::default()
984 };
985 let export = resolve_with_preset(&config).unwrap();
986 assert!(matches!(export.sampling, Sampling::AlwaysOn));
987 }
988
989 #[test]
990 fn explicit_sampling_overrides_preset() {
991 let config = TelemetryProviderConfig {
992 preset: Some("jaeger".into()),
993 sampling_ratio: 0.5,
994 ..Default::default()
995 };
996 let export = resolve_with_preset(&config).unwrap();
997 assert!(
998 matches!(export.sampling, Sampling::TraceIdRatio(r) if (r - 0.5).abs() < f64::EPSILON)
999 );
1000 }
1001
1002 #[test]
1005 fn tenant_attribution_default_values() {
1006 let attr = TenantAttribution::default();
1007 assert!(attr.include_tenant);
1008 assert!(attr.include_team);
1009 assert!(!attr.include_team_in_metrics);
1010 assert!(!attr.hash_ids);
1011 }
1012
1013 #[test]
1014 fn tenant_attribution_serde_roundtrip() {
1015 let config = TelemetryProviderConfig {
1016 tenant_attribution: Some(TenantAttribution {
1017 include_tenant: true,
1018 include_team: false,
1019 include_team_in_metrics: true,
1020 hash_ids: true,
1021 }),
1022 ..Default::default()
1023 };
1024 let json = serde_json::to_string(&config).unwrap();
1025 let deserialized: TelemetryProviderConfig = serde_json::from_str(&json).unwrap();
1026 let attr = deserialized.tenant_attribution.unwrap();
1027 assert!(attr.include_tenant);
1028 assert!(!attr.include_team);
1029 assert!(attr.include_team_in_metrics);
1030 assert!(attr.hash_ids);
1031 }
1032
1033 #[test]
1034 fn validate_hash_ids_without_includes_warns() {
1035 let config = TelemetryProviderConfig {
1036 tenant_attribution: Some(TenantAttribution {
1037 include_tenant: false,
1038 include_team: false,
1039 include_team_in_metrics: false,
1040 hash_ids: true,
1041 }),
1042 ..Default::default()
1043 };
1044 let warnings = validate_telemetry_config(&config);
1045 assert!(warnings.iter().any(|w| w.contains("hash_ids")));
1046 }
1047
1048 #[test]
1049 fn validate_hash_ids_with_includes_ok() {
1050 let config = TelemetryProviderConfig {
1051 tenant_attribution: Some(TenantAttribution {
1052 include_tenant: true,
1053 include_team: true,
1054 include_team_in_metrics: false,
1055 hash_ids: true,
1056 }),
1057 ..Default::default()
1058 };
1059 let warnings = validate_telemetry_config(&config);
1060 assert!(!warnings.iter().any(|w| w.contains("hash_ids")));
1061 }
1062
1063 #[test]
1064 fn tenant_attribution_none_no_warnings() {
1065 let config = TelemetryProviderConfig::default();
1066 let warnings = validate_telemetry_config(&config);
1067 assert!(warnings.is_empty());
1068 }
1069}