Skip to main content

rocketmq_observability/
config.rs

1// Copyright 2023 The RocketMQ Rust Authors
2//
3// Licensed under the Apache License, Version 2.0 (the "License");
4// you may not use this file except in compliance with the License.
5// You may obtain a copy of the License at
6//
7//     http://www.apache.org/licenses/LICENSE-2.0
8//
9// Unless required by applicable law or agreed to in writing, software
10// distributed under the License is distributed on an "AS IS" BASIS,
11// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12// See the License for the specific language governing permissions and
13// limitations under the License.
14
15use std::collections::HashMap;
16use std::fmt;
17
18use serde::Deserialize;
19use serde::Serialize;
20
21#[derive(Clone, Serialize, Deserialize)]
22#[serde(default)]
23pub struct ObservabilityConfig {
24    pub enabled: bool,
25    pub service_name: String,
26    pub service_namespace: String,
27    pub service_instance_id: String,
28    pub service_version: String,
29    pub environment: String,
30    pub cluster: String,
31    pub node_type: String,
32    pub node_id: String,
33    pub metrics: MetricsConfig,
34    pub traces: TracesConfig,
35    pub logs: LogsConfig,
36    pub otlp: OtlpConfig,
37    pub prometheus: PrometheusConfig,
38    pub subscriber_install_policy: SubscriberInstallPolicy,
39    pub resource_attributes: HashMap<String, String>,
40}
41
42impl fmt::Debug for ObservabilityConfig {
43    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
44        formatter
45            .debug_struct("ObservabilityConfig")
46            .field("enabled", &self.enabled)
47            .field("service_name", &self.service_name)
48            .field("service_namespace", &self.service_namespace)
49            .field("service_instance_id_configured", &!self.service_instance_id.is_empty())
50            .field("service_version", &self.service_version)
51            .field("environment", &self.environment)
52            .field("cluster", &self.cluster)
53            .field("node_type", &self.node_type)
54            .field("node_id", &self.node_id)
55            .field("metrics", &self.metrics)
56            .field("traces", &self.traces)
57            .field("logs", &self.logs)
58            .field("otlp", &self.otlp)
59            .field("prometheus", &self.prometheus)
60            .field("subscriber_install_policy", &self.subscriber_install_policy)
61            .field("resource_attributes_count", &self.resource_attributes.len())
62            .finish()
63    }
64}
65
66#[derive(Debug, Clone, Default, Serialize, Deserialize)]
67#[serde(default)]
68pub struct TelemetryBootstrapConfig {
69    pub observability: ObservabilityConfig,
70    pub logging: LoggingConfig,
71}
72
73#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
74#[serde(default)]
75pub struct LoggingConfig {
76    pub enabled: bool,
77    pub filter: String,
78    pub console: ConsoleLogConfig,
79    pub file: FileLogConfig,
80    pub reload: ReloadConfig,
81}
82
83#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
84#[serde(default)]
85pub struct ConsoleLogConfig {
86    pub enabled: bool,
87    pub format: LogFormat,
88    pub ansi: bool,
89    pub target: bool,
90    pub thread_ids: bool,
91    pub thread_names: bool,
92    pub line_number: bool,
93}
94
95#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
96#[serde(default)]
97pub struct FileLogConfig {
98    pub enabled: bool,
99    pub directory: String,
100    pub file_name_prefix: String,
101    pub rotation: LogRotation,
102    pub format: LogFormat,
103    pub non_blocking: NonBlockingLogConfig,
104}
105
106#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
107#[serde(default)]
108pub struct NonBlockingLogConfig {
109    pub buffered_lines_limit: usize,
110    pub lossy: bool,
111}
112
113#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
114#[serde(default)]
115pub struct ReloadConfig {
116    pub enabled: bool,
117}
118
119#[derive(Debug, Clone, Serialize, Deserialize)]
120#[serde(default)]
121pub struct MetricsConfig {
122    pub enabled: bool,
123    pub exporter: MetricsExporter,
124    pub export_interval_millis: u64,
125    pub export_timeout_millis: u64,
126    pub cardinality_limit: usize,
127    pub sample_ratio: f64,
128    pub topic_label_enabled: bool,
129    pub consumer_group_label_enabled: bool,
130}
131
132#[derive(Debug, Clone, Serialize, Deserialize)]
133#[serde(default)]
134pub struct TracesConfig {
135    pub enabled: bool,
136    pub exporter: TraceExporter,
137    pub sample_ratio: f64,
138    pub propagate_context: bool,
139    pub record_message_id: bool,
140    pub record_message_keys: bool,
141    pub record_body_size: bool,
142}
143
144#[derive(Debug, Clone, Serialize, Deserialize)]
145#[serde(default)]
146pub struct LogsConfig {
147    pub enabled: bool,
148    pub exporter: LogsExporter,
149}
150
151#[derive(Clone, Serialize, Deserialize)]
152#[serde(default)]
153pub struct OtlpConfig {
154    pub endpoint: String,
155    pub protocol: OtlpProtocol,
156    pub headers: HashMap<String, String>,
157    pub timeout_millis: u64,
158}
159
160impl fmt::Debug for OtlpConfig {
161    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
162        formatter
163            .debug_struct("OtlpConfig")
164            .field("endpoint_configured", &!self.endpoint.is_empty())
165            .field("protocol", &self.protocol)
166            .field("headers_count", &self.headers.len())
167            .field("timeout_millis", &self.timeout_millis)
168            .finish()
169    }
170}
171
172#[derive(Debug, Clone, Serialize, Deserialize)]
173#[serde(default)]
174pub struct PrometheusConfig {
175    pub host: String,
176    pub port: u16,
177    pub path: String,
178}
179
180#[derive(Clone, Default, PartialEq, Serialize, Deserialize)]
181#[serde(default, rename_all = "camelCase", deny_unknown_fields)]
182pub struct ObservabilityOverrides {
183    pub environment: Option<String>,
184    pub service_instance_id: Option<String>,
185    pub resource_attributes: Option<HashMap<String, String>>,
186    pub metrics: MetricsOverrides,
187    pub traces: TracesOverrides,
188    pub logs: LogsOverrides,
189    pub otlp: OtlpOverrides,
190    pub prometheus: PrometheusOverrides,
191}
192
193impl fmt::Debug for ObservabilityOverrides {
194    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
195        formatter
196            .debug_struct("ObservabilityOverrides")
197            .field("environment_configured", &self.environment.is_some())
198            .field("service_instance_id_configured", &self.service_instance_id.is_some())
199            .field(
200                "resource_attributes_count",
201                &self.resource_attributes.as_ref().map(HashMap::len),
202            )
203            .field("metrics", &self.metrics)
204            .field("traces", &self.traces)
205            .field("logs", &self.logs)
206            .field("otlp", &self.otlp)
207            .field("prometheus", &self.prometheus)
208            .finish()
209    }
210}
211
212#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
213#[serde(default, rename_all = "camelCase", deny_unknown_fields)]
214pub struct MetricsOverrides {
215    pub exporter: Option<MetricsExporter>,
216    pub export_interval_millis: Option<u64>,
217    pub export_timeout_millis: Option<u64>,
218    pub cardinality_limit: Option<usize>,
219    pub sample_ratio: Option<f64>,
220    pub topic_label_enabled: Option<bool>,
221    pub consumer_group_label_enabled: Option<bool>,
222}
223
224#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
225#[serde(default, rename_all = "camelCase", deny_unknown_fields)]
226pub struct TracesOverrides {
227    pub exporter: Option<TraceExporter>,
228    pub sample_ratio: Option<f64>,
229    pub propagate_context: Option<bool>,
230    pub record_message_id: Option<bool>,
231    pub record_message_keys: Option<bool>,
232    pub record_body_size: Option<bool>,
233}
234
235#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
236#[serde(default, rename_all = "camelCase", deny_unknown_fields)]
237pub struct LogsOverrides {
238    pub exporter: Option<LogsExporter>,
239}
240
241#[derive(Clone, Default, PartialEq, Serialize, Deserialize)]
242#[serde(default, rename_all = "camelCase", deny_unknown_fields)]
243pub struct OtlpOverrides {
244    pub endpoint: Option<String>,
245    pub protocol: Option<OtlpProtocol>,
246    pub headers: Option<HashMap<String, String>>,
247    pub timeout_millis: Option<u64>,
248}
249
250impl fmt::Debug for OtlpOverrides {
251    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
252        formatter
253            .debug_struct("OtlpOverrides")
254            .field("endpoint_configured", &self.endpoint.is_some())
255            .field("protocol", &self.protocol)
256            .field("headers_count", &self.headers.as_ref().map(HashMap::len))
257            .field("timeout_millis", &self.timeout_millis)
258            .finish()
259    }
260}
261
262#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
263#[serde(default, rename_all = "camelCase", deny_unknown_fields)]
264pub struct PrometheusOverrides {
265    pub host: Option<String>,
266    pub port: Option<u16>,
267    pub path: Option<String>,
268}
269
270impl ObservabilityOverrides {
271    /// Applies only explicitly configured file fields to a service-owned base.
272    ///
273    /// Omitted fields preserve the base value, configured maps replace their
274    /// base maps, and each signal exporter determines that signal's enabled
275    /// state. Service identity fields are intentionally not part of this type.
276    pub fn apply_to(&self, target: &mut ObservabilityConfig) {
277        if let Some(environment) = &self.environment {
278            target.environment = environment.clone();
279        }
280        if let Some(service_instance_id) = &self.service_instance_id {
281            target.service_instance_id = service_instance_id.clone();
282        }
283        if let Some(resource_attributes) = &self.resource_attributes {
284            target.resource_attributes = resource_attributes.clone();
285        }
286
287        if let Some(exporter) = self.metrics.exporter {
288            target.metrics.exporter = exporter;
289            target.metrics.enabled = exporter.is_enabled();
290        }
291        if let Some(export_interval_millis) = self.metrics.export_interval_millis {
292            target.metrics.export_interval_millis = export_interval_millis;
293        }
294        if let Some(export_timeout_millis) = self.metrics.export_timeout_millis {
295            target.metrics.export_timeout_millis = export_timeout_millis;
296        }
297        if let Some(cardinality_limit) = self.metrics.cardinality_limit {
298            target.metrics.cardinality_limit = cardinality_limit;
299        }
300        if let Some(sample_ratio) = self.metrics.sample_ratio {
301            target.metrics.sample_ratio = sample_ratio;
302        }
303        if let Some(topic_label_enabled) = self.metrics.topic_label_enabled {
304            target.metrics.topic_label_enabled = topic_label_enabled;
305        }
306        if let Some(consumer_group_label_enabled) = self.metrics.consumer_group_label_enabled {
307            target.metrics.consumer_group_label_enabled = consumer_group_label_enabled;
308        }
309
310        if let Some(exporter) = self.traces.exporter {
311            target.traces.exporter = exporter;
312            target.traces.enabled = !matches!(exporter, TraceExporter::Disable);
313        }
314        if let Some(sample_ratio) = self.traces.sample_ratio {
315            target.traces.sample_ratio = sample_ratio;
316        }
317        if let Some(propagate_context) = self.traces.propagate_context {
318            target.traces.propagate_context = propagate_context;
319        }
320        if let Some(record_message_id) = self.traces.record_message_id {
321            target.traces.record_message_id = record_message_id;
322        }
323        if let Some(record_message_keys) = self.traces.record_message_keys {
324            target.traces.record_message_keys = record_message_keys;
325        }
326        if let Some(record_body_size) = self.traces.record_body_size {
327            target.traces.record_body_size = record_body_size;
328        }
329
330        if let Some(exporter) = self.logs.exporter {
331            target.logs.exporter = exporter;
332            target.logs.enabled = !matches!(exporter, LogsExporter::Disable);
333        }
334
335        if let Some(endpoint) = &self.otlp.endpoint {
336            target.otlp.endpoint = endpoint.clone();
337        }
338        if let Some(protocol) = self.otlp.protocol {
339            target.otlp.protocol = protocol;
340        }
341        if let Some(headers) = &self.otlp.headers {
342            target.otlp.headers = headers.clone();
343        }
344        if let Some(timeout_millis) = self.otlp.timeout_millis {
345            target.otlp.timeout_millis = timeout_millis;
346        }
347
348        if let Some(host) = &self.prometheus.host {
349            target.prometheus.host = host.clone();
350        }
351        if let Some(port) = self.prometheus.port {
352            target.prometheus.port = port;
353        }
354        if let Some(path) = &self.prometheus.path {
355            target.prometheus.path = path.clone();
356        }
357
358        target.enabled = target.metrics.enabled || target.traces.enabled || target.logs.enabled;
359    }
360}
361
362#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
363#[serde(rename_all = "snake_case")]
364pub enum MetricsExporter {
365    Disable,
366    OtlpGrpc,
367    Prometheus,
368    Log,
369}
370
371impl MetricsExporter {
372    pub const fn is_enabled(self) -> bool {
373        !matches!(self, Self::Disable)
374    }
375}
376
377#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
378#[serde(rename_all = "snake_case")]
379pub enum TraceExporter {
380    Disable,
381    OtlpGrpc,
382    Log,
383}
384
385#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
386#[serde(rename_all = "snake_case")]
387pub enum LogsExporter {
388    Disable,
389    OtlpGrpc,
390    Log,
391}
392
393#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
394#[serde(rename_all = "snake_case")]
395pub enum OtlpProtocol {
396    Grpc,
397    HttpBinary,
398    HttpJson,
399}
400
401#[derive(Debug, Clone, Copy, Default, Serialize, Deserialize, PartialEq, Eq)]
402#[serde(rename_all = "snake_case")]
403pub enum LogFormat {
404    #[default]
405    Compact,
406    Pretty,
407    Json,
408}
409
410#[derive(Debug, Clone, Copy, Default, Serialize, Deserialize, PartialEq, Eq)]
411#[serde(rename_all = "snake_case")]
412pub enum LogRotation {
413    Never,
414    Hourly,
415    #[default]
416    Daily,
417}
418
419#[derive(Debug, Clone, Copy, Default, Serialize, Deserialize, PartialEq, Eq)]
420#[serde(rename_all = "snake_case")]
421pub enum SubscriberInstallPolicy {
422    Required,
423    #[default]
424    BestEffort,
425}
426
427#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
428pub struct SubscriberInstallStatus {
429    pub attempted: bool,
430    pub installed: bool,
431}
432
433impl SubscriberInstallStatus {
434    pub fn attempted(installed: bool) -> Self {
435        Self {
436            attempted: true,
437            installed,
438        }
439    }
440}
441
442impl Default for ObservabilityConfig {
443    fn default() -> Self {
444        Self {
445            enabled: false,
446            service_name: "rocketmq-rust".to_string(),
447            service_namespace: "rocketmq".to_string(),
448            service_instance_id: String::new(),
449            service_version: env!("CARGO_PKG_VERSION").to_string(),
450            environment: "dev".to_string(),
451            cluster: "DefaultCluster".to_string(),
452            node_type: "unknown".to_string(),
453            node_id: String::new(),
454            metrics: MetricsConfig::default(),
455            traces: TracesConfig::default(),
456            logs: LogsConfig::default(),
457            otlp: OtlpConfig::default(),
458            prometheus: PrometheusConfig::default(),
459            subscriber_install_policy: SubscriberInstallPolicy::default(),
460            resource_attributes: HashMap::new(),
461        }
462    }
463}
464
465impl Default for LoggingConfig {
466    fn default() -> Self {
467        Self {
468            enabled: true,
469            filter: crate::log_filter::DEFAULT_LOG_FILTER.to_string(),
470            console: ConsoleLogConfig::default(),
471            file: FileLogConfig::default(),
472            reload: ReloadConfig::default(),
473        }
474    }
475}
476
477impl Default for ConsoleLogConfig {
478    fn default() -> Self {
479        Self {
480            enabled: true,
481            format: LogFormat::Compact,
482            ansi: true,
483            target: true,
484            thread_ids: true,
485            thread_names: true,
486            line_number: true,
487        }
488    }
489}
490
491impl Default for FileLogConfig {
492    fn default() -> Self {
493        Self {
494            enabled: false,
495            directory: "logs".to_string(),
496            file_name_prefix: "rocketmq-rust".to_string(),
497            rotation: LogRotation::Daily,
498            format: LogFormat::Json,
499            non_blocking: NonBlockingLogConfig::default(),
500        }
501    }
502}
503
504impl Default for NonBlockingLogConfig {
505    fn default() -> Self {
506        Self {
507            buffered_lines_limit: 65_536,
508            lossy: false,
509        }
510    }
511}
512
513impl Default for MetricsConfig {
514    fn default() -> Self {
515        Self {
516            enabled: false,
517            exporter: MetricsExporter::Disable,
518            export_interval_millis: 30_000,
519            export_timeout_millis: 3_000,
520            cardinality_limit: 10_000,
521            sample_ratio: 1.0,
522            topic_label_enabled: true,
523            consumer_group_label_enabled: true,
524        }
525    }
526}
527
528impl Default for TracesConfig {
529    fn default() -> Self {
530        Self {
531            enabled: false,
532            exporter: TraceExporter::Disable,
533            sample_ratio: 0.01,
534            propagate_context: true,
535            record_message_id: false,
536            record_message_keys: false,
537            record_body_size: true,
538        }
539    }
540}
541
542impl Default for LogsConfig {
543    fn default() -> Self {
544        Self {
545            enabled: false,
546            exporter: LogsExporter::Disable,
547        }
548    }
549}
550
551impl Default for OtlpConfig {
552    fn default() -> Self {
553        Self {
554            endpoint: "http://127.0.0.1:4317".to_string(),
555            protocol: OtlpProtocol::Grpc,
556            headers: HashMap::new(),
557            timeout_millis: 3_000,
558        }
559    }
560}
561
562impl Default for PrometheusConfig {
563    fn default() -> Self {
564        Self {
565            host: "127.0.0.1".to_string(),
566            port: 5557,
567            path: "/metrics".to_string(),
568        }
569    }
570}
571
572#[cfg(test)]
573mod tests {
574    use super::*;
575
576    const ENDPOINT_CANARY: &str = "https://collector.invalid:4317/v1/traces?token=ENDPOINT_CANARY";
577    const HEADER_KEY_CANARY: &str = "x-header-key-canary";
578    const HEADER_VALUE_CANARY: &str = "Bearer HEADER_VALUE_CANARY";
579    const RESOURCE_KEY_CANARY: &str = "resource.key.canary";
580    const RESOURCE_VALUE_CANARY: &str = "RESOURCE_VALUE_CANARY";
581
582    fn assert_sensitive_debug_values_are_absent(output: &str) {
583        for canary in [
584            ENDPOINT_CANARY,
585            HEADER_KEY_CANARY,
586            HEADER_VALUE_CANARY,
587            RESOURCE_KEY_CANARY,
588            RESOURCE_VALUE_CANARY,
589        ] {
590            assert!(!output.contains(canary), "Debug output exposed {canary}: {output}");
591        }
592    }
593
594    #[test]
595    fn observability_config_debug_redacts_otlp_and_resource_values() {
596        let mut config = ObservabilityConfig::default();
597        config.otlp.endpoint = ENDPOINT_CANARY.to_string();
598        config
599            .otlp
600            .headers
601            .insert(HEADER_KEY_CANARY.to_string(), HEADER_VALUE_CANARY.to_string());
602        config
603            .resource_attributes
604            .insert(RESOURCE_KEY_CANARY.to_string(), RESOURCE_VALUE_CANARY.to_string());
605
606        let otlp_debug = format!("{:?}", config.otlp);
607        let config_debug = format!("{config:?}");
608
609        assert_sensitive_debug_values_are_absent(&otlp_debug);
610        assert_sensitive_debug_values_are_absent(&config_debug);
611        assert!(otlp_debug.contains("endpoint_configured"));
612        assert!(otlp_debug.contains("headers_count"));
613        assert!(config_debug.contains("resource_attributes_count"));
614    }
615
616    #[test]
617    fn observability_overrides_debug_redacts_otlp_and_resource_values() {
618        let overrides = ObservabilityOverrides {
619            resource_attributes: Some(HashMap::from([(
620                RESOURCE_KEY_CANARY.to_string(),
621                RESOURCE_VALUE_CANARY.to_string(),
622            )])),
623            otlp: OtlpOverrides {
624                endpoint: Some(ENDPOINT_CANARY.to_string()),
625                headers: Some(HashMap::from([(
626                    HEADER_KEY_CANARY.to_string(),
627                    HEADER_VALUE_CANARY.to_string(),
628                )])),
629                ..OtlpOverrides::default()
630            },
631            ..ObservabilityOverrides::default()
632        };
633
634        let otlp_debug = format!("{:?}", overrides.otlp);
635        let overrides_debug = format!("{overrides:?}");
636
637        assert_sensitive_debug_values_are_absent(&otlp_debug);
638        assert_sensitive_debug_values_are_absent(&overrides_debug);
639        assert!(otlp_debug.contains("endpoint_configured"));
640        assert!(otlp_debug.contains("headers_count"));
641        assert!(overrides_debug.contains("resource_attributes_count"));
642    }
643
644    #[test]
645    fn default_config_is_disabled_and_local_only() {
646        let config = ObservabilityConfig::default();
647
648        assert!(!config.enabled);
649        assert!(!config.metrics.enabled);
650        assert!(!config.traces.enabled);
651        assert!((config.metrics.sample_ratio - 1.0).abs() < f64::EPSILON);
652        assert_eq!(config.prometheus.host, "127.0.0.1");
653        assert!(!config.traces.record_message_id);
654        assert!(!config.traces.record_message_keys);
655        assert_eq!(config.subscriber_install_policy, SubscriberInstallPolicy::BestEffort);
656    }
657
658    #[test]
659    fn subscriber_install_policy_uses_snake_case_serde() {
660        let policy = serde_json::from_str::<SubscriberInstallPolicy>("\"required\"")
661            .expect("required policy should deserialize");
662
663        assert_eq!(policy, SubscriberInstallPolicy::Required);
664        assert_eq!(
665            serde_json::to_string(&SubscriberInstallPolicy::BestEffort).expect("policy should serialize"),
666            "\"best_effort\""
667        );
668    }
669
670    #[test]
671    fn logging_config_defaults_to_console_info_filter() {
672        let config = LoggingConfig::default();
673
674        assert!(config.enabled);
675        assert_eq!(config.filter, "info");
676        assert!(config.console.enabled);
677        assert_eq!(config.console.format, LogFormat::Compact);
678        assert!(config.console.ansi);
679        assert!(config.console.target);
680        assert!(config.console.thread_ids);
681        assert!(config.console.thread_names);
682        assert!(config.console.line_number);
683        assert!(!config.file.enabled);
684        assert_eq!(config.file.rotation, LogRotation::Daily);
685        assert_eq!(config.file.format, LogFormat::Json);
686        assert_eq!(config.file.non_blocking.buffered_lines_limit, 65_536);
687        assert!(!config.file.non_blocking.lossy);
688        assert!(!config.reload.enabled);
689    }
690
691    #[test]
692    fn logging_config_serde_roundtrip_preserves_enums() {
693        let config = LoggingConfig {
694            filter: "rocketmq_store=debug,rocketmq_transport=warn".to_string(),
695            console: ConsoleLogConfig {
696                format: LogFormat::Pretty,
697                ..ConsoleLogConfig::default()
698            },
699            file: FileLogConfig {
700                enabled: true,
701                rotation: LogRotation::Hourly,
702                format: LogFormat::Json,
703                non_blocking: NonBlockingLogConfig {
704                    buffered_lines_limit: 4_096,
705                    lossy: true,
706                },
707                ..FileLogConfig::default()
708            },
709            reload: ReloadConfig { enabled: true },
710            ..LoggingConfig::default()
711        };
712
713        let payload = serde_json::to_string(&config).expect("logging config should serialize");
714        assert!(payload.contains("\"pretty\""));
715        assert!(payload.contains("\"hourly\""));
716
717        let decoded = serde_json::from_str::<LoggingConfig>(&payload).expect("logging config should deserialize");
718
719        assert_eq!(decoded, config);
720    }
721
722    #[test]
723    fn invalid_logging_enum_values_are_rejected() {
724        assert!(serde_json::from_str::<LogFormat>("\"camelCase\"").is_err());
725        assert!(serde_json::from_str::<LogRotation>("\"weekly\"").is_err());
726    }
727
728    #[test]
729    fn telemetry_bootstrap_default_keeps_observability_disabled_and_logging_ready() {
730        let config = TelemetryBootstrapConfig::default();
731
732        assert!(!config.observability.enabled);
733        assert!(config.logging.enabled);
734        assert!(config.logging.console.enabled);
735        assert!(!config.logging.file.enabled);
736    }
737}