Skip to main content

greentic_telemetry/
provider.rs

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/// TLS configuration for mTLS connections to OTLP endpoints.
11#[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/// Controls which tenant/team identifiers appear in spans and metrics.
22#[derive(Clone, Debug, Serialize, Deserialize)]
23pub struct TenantAttribution {
24    /// Include tenant_id in span attributes (default: true)
25    #[serde(default = "default_true")]
26    pub include_tenant: bool,
27    /// Include team_id in span attributes (default: true)
28    #[serde(default = "default_true")]
29    pub include_team: bool,
30    /// Include team_id in metric labels (default: false — cardinality concern)
31    #[serde(default)]
32    pub include_team_in_metrics: bool,
33    /// Hash tenant/team IDs before emitting (privacy/cardinality)
34    #[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/// Configuration returned by a telemetry provider component.
50///
51/// This is the canonical config model for pack-based telemetry setup.
52/// A provider WASM component returns this as JSON; the host (operator)
53/// passes it to [`init_from_provider_config`] to configure OTel.
54#[derive(Clone, Debug, Serialize, Deserialize)]
55pub struct TelemetryProviderConfig {
56    /// Export mode: "otlp-grpc" | "otlp-http" | "json-stdout" | "none"
57    #[serde(default = "default_export_mode")]
58    pub export_mode: String,
59
60    /// OTLP endpoint (e.g. "http://localhost:4317")
61    #[serde(default)]
62    pub endpoint: Option<String>,
63
64    /// Auth/metadata headers (typically from secrets)
65    #[serde(default)]
66    pub headers: HashMap<String, String>,
67
68    /// Sampling ratio: 0.0..=1.0
69    #[serde(default = "default_sampling_ratio")]
70    pub sampling_ratio: f64,
71
72    /// Optional compression: "gzip" | null
73    #[serde(default)]
74    pub compression: Option<String>,
75
76    /// Service name (default: "greentic-operator")
77    #[serde(default)]
78    pub service_name: Option<String>,
79
80    /// Additional OTel resource attributes
81    #[serde(default)]
82    pub resource_attributes: HashMap<String, String>,
83
84    /// Regex patterns for PII redaction
85    #[serde(default)]
86    pub redaction_patterns: Vec<String>,
87
88    /// Backend preset name: "honeycomb", "datadog", "newrelic", "zipkin", etc.
89    #[serde(default)]
90    pub preset: Option<String>,
91
92    /// Enable operation subscription telemetry
93    #[serde(default = "default_true")]
94    pub enable_operation_subs: bool,
95
96    /// Operation subs mode: "metrics_only" | "traces_only" | "metrics_and_traces"
97    #[serde(default)]
98    pub operation_subs_mode: Option<String>,
99
100    /// Include denied operations in telemetry
101    #[serde(default = "default_true")]
102    pub include_denied_ops: bool,
103
104    /// Payload policy: "none" | "hash_only"
105    #[serde(default)]
106    pub payload_policy: Option<String>,
107
108    /// Minimum log level: "trace" | "debug" | "info" | "warn" | "error"
109    /// Applied via RUST_LOG before OTel init (env var takes precedence).
110    #[serde(default)]
111    pub min_log_level: Option<String>,
112
113    /// TLS configuration for mTLS connections to OTLP endpoints.
114    #[serde(default)]
115    pub tls_config: Option<TlsConfig>,
116
117    /// Operation names to skip in telemetry emission.
118    #[serde(default)]
119    pub exclude_ops: Vec<String>,
120
121    /// Hard-drop all payload content from telemetry.
122    #[serde(default)]
123    pub drop_payloads: bool,
124
125    /// Multi-tenant attribution controls.
126    #[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
167/// Convert a [`TelemetryProviderConfig`] into an [`ExportConfig`]
168/// suitable for [`init_telemetry_from_config`].
169pub 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
209/// Resolve a preset name to a base [`ExportConfig`], then overlay
210/// any explicit fields from the provider config on top.
211fn 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    // Start from preset defaults
234    let mode = if config.export_mode != "json-stdout" || config.preset.is_none() {
235        // Explicit export_mode overrides preset
236        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    // Explicit endpoint overrides preset
249    let endpoint = config.endpoint.clone().or(preset_cfg.otlp_endpoint);
250
251    // Merge headers: preset defaults + explicit overrides
252    let mut headers = preset_cfg.otlp_headers;
253    headers.extend(config.headers.clone());
254
255    // Use preset sampling as fallback when user hasn't overridden (default is 1.0)
256    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
290/// Initialize the full OTel pipeline from a provider config.
291///
292/// If a `preset` is specified, resolves the preset first, then overlays
293/// any explicit fields from the config. Otherwise, converts directly.
294///
295/// Redaction patterns from the config are applied by setting `PII_MASK_REGEXES`
296/// before the telemetry pipeline initializes the redactor.
297pub fn init_from_provider_config(config: &TelemetryProviderConfig) -> Result<()> {
298    // Set RUST_LOG from min_log_level if not already set by the environment.
299    if let Some(ref level) = config.min_log_level
300        && std::env::var("RUST_LOG").is_err()
301    {
302        // Safety: called early in single-threaded init path before spawning workers.
303        unsafe {
304            std::env::set_var("RUST_LOG", level);
305        }
306    }
307
308    // Set redaction patterns before init (redactor reads PII_MASK_REGEXES once)
309    if !config.redaction_patterns.is_empty() {
310        let joined = config.redaction_patterns.join(",");
311        // Safety: called early in single-threaded init path before spawning workers.
312        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
331// ---------------------------------------------------------------------------
332// Config validation (called by operator after receiving provider config)
333// ---------------------------------------------------------------------------
334
335/// Known export modes.
336const 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
346/// Header keys that should be secrets-backed rather than plain text.
347const 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
361/// Validate a [`TelemetryProviderConfig`] and return a list of warnings.
362///
363/// Checks:
364/// - `export_mode` is a known value
365/// - `endpoint` is present when export mode requires it (otlp-grpc, otlp-http)
366/// - Headers with sensitive keys are flagged (should be secrets-backed)
367pub 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    // 1. Unknown export mode
372    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    // 2. Endpoint required for OTLP modes (unless preset provides a default)
381    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    // 3. Sensitive headers should be secrets-backed
390    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    // 4. sampling_ratio out of range
400    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    // 5. Unknown compression
408    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    // 6. Unknown operation_subs_mode
419    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    // 7. Unknown payload_policy
430    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    // 8. Empty redaction_patterns entry
441    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    // 9. Unknown min_log_level
450    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    // 10. TLS cert without key (or vice versa)
461    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    // 11. hash_ids enabled but both tenant and team excluded (hashing unused)
470    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        // 0.0 → AlwaysOff
544        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        // 1.0 → AlwaysOn
554        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        // In between → TraceIdRatio
564        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        // init_from_provider_config uses "greentic-operator" when service_name is None.
679        // We can't easily test the full init (it's idempotent + global), so verify
680        // the default value is correct in the config.
681        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    // --- validate_telemetry_config tests ---
699
700    #[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    // --- Story 4.1: Convenience presets ---
942
943    #[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        // When user hasn't changed sampling (default 1.0) and preset provides a ratio,
979        // the preset's ratio should be used.
980        // Current presets all return None, so the fallback should remain 1.0 (AlwaysOn).
981        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    // --- Story 4.2: Tenant attribution ---
1003
1004    #[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}