Skip to main content

provide_telemetry/
runtime.rs

1// SPDX-FileCopyrightText: Copyright (C) 2026 provide.io llc
2// SPDX-License-Identifier: Apache-2.0
3// SPDX-Comment: Part of provide-telemetry.
4//
5
6use std::sync::{OnceLock, RwLock};
7
8use serde::{Deserialize, Serialize};
9
10use crate::config::TelemetryConfig;
11use crate::errors::TelemetryError;
12#[cfg(feature = "otel")]
13use crate::otel::otel_installed;
14use crate::policies::apply_policies;
15use crate::RuntimeOverrides;
16
17static ACTIVE_CONFIG: OnceLock<RwLock<Option<TelemetryConfig>>> = OnceLock::new();
18#[cfg(feature = "otel")]
19const PROVIDER_CHANGE_RESTART_MESSAGE: &str =
20    "OpenTelemetry providers already installed; restart the process for provider-changing config";
21
22fn empty_active_config() -> RwLock<Option<TelemetryConfig>> {
23    RwLock::new(None)
24}
25
26fn active_config() -> &'static RwLock<Option<TelemetryConfig>> {
27    ACTIVE_CONFIG.get_or_init(empty_active_config)
28}
29
30#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
31pub struct SignalStatus {
32    pub logs: bool,
33    pub traces: bool,
34    pub metrics: bool,
35}
36
37#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
38pub struct RuntimeStatus {
39    pub setup_done: bool,
40    pub signals: SignalStatus,
41    pub providers: SignalStatus,
42    pub fallback: SignalStatus,
43    pub setup_error: Option<String>,
44}
45
46pub(crate) fn set_active_config(config: Option<TelemetryConfig>) {
47    *crate::_lock::rwlock_write(active_config()) = config;
48}
49
50/// Resource identity fields — baked into every installed provider's `Resource`.
51/// A change here requires all live providers to be reinstalled.
52#[cfg(any(feature = "otel", test))]
53fn identity_config_changed(current: &TelemetryConfig, target: &TelemetryConfig) -> bool {
54    current.service_name != target.service_name
55        || current.environment != target.environment
56        || current.version != target.version
57}
58
59/// Logging-signal fields baked into the log exporter/provider at construction.
60#[cfg(any(feature = "otel", test))]
61fn logging_provider_config_changed(current: &TelemetryConfig, target: &TelemetryConfig) -> bool {
62    current.logging.otlp_endpoint != target.logging.otlp_endpoint
63        || current.logging.otlp_headers != target.logging.otlp_headers
64        || current.logging.otlp_protocol != target.logging.otlp_protocol
65        || current.exporter.logs_timeout_seconds != target.exporter.logs_timeout_seconds
66}
67
68/// Tracing-signal fields baked into the span exporter/provider at construction.
69#[cfg(any(feature = "otel", test))]
70fn tracing_provider_config_changed(current: &TelemetryConfig, target: &TelemetryConfig) -> bool {
71    current.tracing.enabled != target.tracing.enabled
72        || current.tracing.otlp_endpoint != target.tracing.otlp_endpoint
73        || current.tracing.otlp_headers != target.tracing.otlp_headers
74        || current.tracing.otlp_protocol != target.tracing.otlp_protocol
75        || current.exporter.traces_timeout_seconds != target.exporter.traces_timeout_seconds
76}
77
78/// Metrics-signal fields baked into the metric exporter/PeriodicReader at construction.
79#[cfg(any(feature = "otel", test))]
80fn metrics_provider_config_changed(current: &TelemetryConfig, target: &TelemetryConfig) -> bool {
81    current.metrics.enabled != target.metrics.enabled
82        || current.metrics.otlp_endpoint != target.metrics.otlp_endpoint
83        || current.metrics.otlp_headers != target.metrics.otlp_headers
84        || current.metrics.otlp_protocol != target.metrics.otlp_protocol
85        || current.metrics.metric_export_interval_ms != target.metrics.metric_export_interval_ms
86        || current.exporter.metrics_timeout_seconds != target.exporter.metrics_timeout_seconds
87}
88
89/// Returns `true` if any provider-baked field changed. Used in tests to
90/// assert the full set of provider-changing fields; production code uses
91/// the per-signal helpers directly inside `reconfigure_telemetry`.
92#[cfg(test)]
93pub(crate) fn provider_config_changed(current: &TelemetryConfig, target: &TelemetryConfig) -> bool {
94    identity_config_changed(current, target)
95        || logging_provider_config_changed(current, target)
96        || tracing_provider_config_changed(current, target)
97        || metrics_provider_config_changed(current, target)
98}
99
100pub fn get_runtime_config() -> Option<TelemetryConfig> {
101    crate::_lock::rwlock_read(active_config()).clone()
102}
103
104/// Pure helper used by `reload_runtime_from_env` to detect drift between the
105/// current and freshly-loaded `TelemetryConfig`. Returns the names of cold
106/// fields that differ; the caller decides whether to warn.
107fn compute_cold_drift(current: &TelemetryConfig, fresh: &TelemetryConfig) -> Vec<&'static str> {
108    let mut drifted: Vec<&'static str> = Vec::new();
109    if current.service_name != fresh.service_name {
110        drifted.push("service_name");
111    }
112    if current.environment != fresh.environment {
113        drifted.push("environment");
114    }
115    if current.version != fresh.version {
116        drifted.push("version");
117    }
118    if current.tracing.enabled != fresh.tracing.enabled {
119        drifted.push("tracing.enabled");
120    }
121    if current.metrics.enabled != fresh.metrics.enabled {
122        drifted.push("metrics.enabled");
123    }
124    drifted
125}
126
127fn runtime_config_snapshot() -> (Option<TelemetryConfig>, bool) {
128    let guard = crate::_lock::rwlock_read(active_config());
129    let cfg = guard.clone();
130    (cfg.clone(), cfg.is_some())
131}
132
133pub fn get_runtime_status() -> RuntimeStatus {
134    let (cfg, setup_done) = runtime_config_snapshot();
135    let cfg = runtime_config_or_default(cfg);
136
137    #[cfg(feature = "otel")]
138    let providers = SignalStatus {
139        logs: crate::otel::logs::logger_provider_installed(),
140        traces: crate::otel::traces::tracer_provider_installed(),
141        metrics: crate::otel::metrics::meter_provider_installed(),
142    };
143
144    #[cfg(not(feature = "otel"))]
145    let providers = SignalStatus {
146        logs: false,
147        traces: false,
148        metrics: false,
149    };
150
151    RuntimeStatus {
152        setup_done,
153        signals: SignalStatus {
154            logs: true,
155            traces: cfg.tracing.enabled,
156            metrics: cfg.metrics.enabled,
157        },
158        fallback: SignalStatus {
159            logs: !providers.logs,
160            traces: !providers.traces,
161            metrics: !providers.metrics,
162        },
163        providers,
164        setup_error: crate::health::get_health_snapshot().setup_error,
165    }
166}
167
168pub fn update_runtime_config(
169    overrides: RuntimeOverrides,
170) -> Result<TelemetryConfig, TelemetryError> {
171    let logging_override = overrides.logging.clone();
172    let next = {
173        let mut guard = crate::_lock::rwlock_write(active_config());
174        let current = match guard.as_ref().cloned() {
175            Some(current) => current,
176            None => {
177                return Err(TelemetryError::new(
178                    "telemetry not set up: call setup_telemetry first",
179                ));
180            }
181        };
182        let next = apply_runtime_overrides(current, overrides);
183        *guard = Some(next.clone());
184        next
185    }; // write lock released here before calling apply_policies
186    apply_policies(&next);
187    // When the caller supplies a logging override, mirror Python's behavior:
188    // reconfigure the logger so level/format/module-level changes take effect
189    // on the next log event.  The logger's `active_logging_config()` already
190    // prefers the programmatic override over runtime config, so this makes
191    // the override win consistently across both read paths.
192    if let Some(cfg) = logging_override {
193        crate::logger::configure_logging(cfg);
194    }
195    Ok(next)
196}
197
198pub fn reload_runtime_from_env() -> Result<TelemetryConfig, TelemetryError> {
199    let fresh = match TelemetryConfig::from_env() {
200        Ok(fresh) => fresh,
201        Err(err) => return Err(TelemetryError::new(err.message)),
202    };
203    let current = match get_runtime_config() {
204        Some(current) => current,
205        None => {
206            return Err(TelemetryError::new(
207                "telemetry not set up: call setup_telemetry first",
208            ))
209        }
210    };
211
212    // Warn on cold-field drift (matches Python/TypeScript/Go behavior).
213    let drifted = compute_cold_drift(&current, &fresh);
214    if !drifted.is_empty() {
215        eprintln!(
216            "[provide-telemetry] runtime.cold_field_drift: {} — restart required to apply",
217            drifted.join(", ")
218        );
219    }
220
221    // Exporter timeout fields are baked into OTLP exporters at construction
222    // time.  Only freeze them per-signal when the signal's OTel provider is
223    // actually live — otherwise they remain hot-reloadable.  Preserving them
224    // *before* update_runtime_config ensures apply_policies() and the stored
225    // snapshot always agree (no split-brain).
226    #[allow(unused_mut)] // `mut` is only exercised when the `otel` feature is enabled
227    let mut hot_exporter = fresh.exporter;
228    #[cfg(feature = "otel")]
229    {
230        if crate::otel::logs::logger_provider_installed() {
231            hot_exporter.logs_timeout_seconds = current.exporter.logs_timeout_seconds;
232        }
233        if crate::otel::traces::tracer_provider_installed() {
234            hot_exporter.traces_timeout_seconds = current.exporter.traces_timeout_seconds;
235        }
236        if crate::otel::metrics::meter_provider_installed() {
237            hot_exporter.metrics_timeout_seconds = current.exporter.metrics_timeout_seconds;
238        }
239    }
240
241    // Logging: level / fmt / include_timestamp / module_levels are hot.
242    // `otlp_endpoint`, `otlp_headers`, and `otlp_protocol` are baked into the
243    // OTLP log exporter at construction — freeze them from `current` when the
244    // log provider is live so env drift on those fields can't silently
245    // diverge from the installed exporter.
246    #[allow(unused_mut)] // `mut` is only exercised when the `otel` feature is enabled
247    let mut hot_logging = fresh.logging.clone();
248    #[cfg(feature = "otel")]
249    {
250        if crate::otel::logs::logger_provider_installed() {
251            hot_logging.otlp_endpoint = current.logging.otlp_endpoint.clone();
252            hot_logging.otlp_headers = current.logging.otlp_headers.clone();
253            hot_logging.otlp_protocol = current.logging.otlp_protocol.clone();
254        }
255    }
256
257    let overrides = RuntimeOverrides {
258        sampling: Some(fresh.sampling),
259        backpressure: Some(fresh.backpressure),
260        exporter: Some(hot_exporter),
261        security: Some(fresh.security),
262        slo: Some(fresh.slo),
263        pii_max_depth: Some(fresh.pii_max_depth),
264        strict_schema: Some(fresh.strict_schema),
265        event_schema: Some(fresh.event_schema),
266        logging: Some(hot_logging),
267    };
268
269    let mut next = apply_runtime_overrides(current.clone(), overrides);
270    set_active_config(Some(next.clone()));
271    apply_policies(&next);
272    // Reconfigure the logger so env-driven level / fmt / module-level drift
273    // takes effect on the next log event (mirrors Python parity).
274    crate::logger::configure_logging(next.logging.clone());
275    next.service_name = current.service_name;
276    next.environment = current.environment;
277    next.version = current.version;
278    next.tracing.enabled = current.tracing.enabled;
279    next.tracing.otlp_headers = current.tracing.otlp_headers;
280    next.metrics.enabled = current.metrics.enabled;
281    next.metrics.otlp_headers = current.metrics.otlp_headers;
282
283    set_active_config(Some(next.clone()));
284    Ok(next)
285}
286
287fn apply_runtime_overrides(
288    current: TelemetryConfig,
289    overrides: RuntimeOverrides,
290) -> TelemetryConfig {
291    let mut next = current;
292    next.sampling = overrides.sampling.unwrap_or(next.sampling);
293    next.backpressure = overrides.backpressure.unwrap_or(next.backpressure);
294    next.exporter = overrides.exporter.unwrap_or(next.exporter);
295    next.security = overrides.security.unwrap_or(next.security);
296    next.slo = overrides.slo.unwrap_or(next.slo);
297    next.pii_max_depth = overrides.pii_max_depth.unwrap_or(next.pii_max_depth);
298    next.strict_schema = overrides.strict_schema.unwrap_or(next.strict_schema);
299    next.event_schema = overrides.event_schema.unwrap_or(next.event_schema);
300    next.logging = overrides.logging.unwrap_or(next.logging);
301    next
302}
303
304fn runtime_config_or_default(config: Option<TelemetryConfig>) -> TelemetryConfig {
305    match config {
306        Some(config) => config,
307        None => TelemetryConfig::from_env().unwrap_or_default(),
308    }
309}
310
311pub fn reconfigure_telemetry(
312    config: Option<TelemetryConfig>,
313) -> Result<TelemetryConfig, TelemetryError> {
314    let target = match config {
315        Some(config) => config,
316        None => match TelemetryConfig::from_env() {
317            Ok(config) => config,
318            Err(err) => return Err(TelemetryError::new(err.message)),
319        },
320    };
321
322    #[cfg(feature = "otel")]
323    if let Some(current) = get_runtime_config() {
324        if otel_installed() {
325            let logs_live = crate::otel::logs::logger_provider_installed();
326            let traces_live = crate::otel::traces::tracer_provider_installed();
327            let metrics_live = crate::otel::metrics::meter_provider_installed();
328            // Identity fields affect every installed provider's Resource; per-signal
329            // fields only matter when that signal's provider is actually live.
330            let reject = identity_config_changed(&current, &target)
331                || (logs_live && logging_provider_config_changed(&current, &target))
332                || (traces_live && tracing_provider_config_changed(&current, &target))
333                || (metrics_live && metrics_provider_config_changed(&current, &target));
334            if reject {
335                Err(TelemetryError::new(PROVIDER_CHANGE_RESTART_MESSAGE))
336            } else {
337                Ok(())
338            }?;
339        }
340    }
341
342    set_active_config(Some(target.clone()));
343    apply_policies(&target);
344    Ok(target)
345}
346
347#[cfg(test)]
348#[path = "runtime_tests.rs"]
349mod tests;
350
351#[cfg(test)]
352#[path = "runtime_logging_tests.rs"]
353mod logging_tests;