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
127/// True unless a *loaded* config switched this signal off.
128///
129/// Only the OTel backend asks, so this is gated with it.
130///
131/// Before `setup_telemetry` nothing has read the environment, so the signal
132/// defaults on — a host that installs its own SDK and never calls setup must
133/// still be reported as exporting. Matches the Python, Go and TypeScript rule
134/// (see `behavioral_parity.provider_adoption_reporting` in the spec).
135#[cfg(feature = "otel")]
136pub(crate) fn tracing_enabled_by_loaded_config() -> bool {
137    // match, not is_none_or: that is stable since 1.82 and the crate's MSRV is 1.81.
138    match crate::_lock::rwlock_read(active_config()).as_ref() {
139        Some(cfg) => cfg.tracing.enabled,
140        None => true,
141    }
142}
143
144/// Metrics counterpart of [`tracing_enabled_by_loaded_config`].
145#[cfg(feature = "otel")]
146pub(crate) fn metrics_enabled_by_loaded_config() -> bool {
147    match crate::_lock::rwlock_read(active_config()).as_ref() {
148        Some(cfg) => cfg.metrics.enabled,
149        None => true,
150    }
151}
152
153fn runtime_config_snapshot() -> (Option<TelemetryConfig>, bool) {
154    let guard = crate::_lock::rwlock_read(active_config());
155    let cfg = guard.clone();
156    (cfg.clone(), cfg.is_some())
157}
158
159pub fn get_runtime_status() -> RuntimeStatus {
160    let (cfg, setup_done) = runtime_config_snapshot();
161    let cfg = runtime_config_or_default(cfg);
162
163    #[cfg(feature = "otel")]
164    let providers = SignalStatus {
165        logs: crate::otel::logs::logger_provider_installed(),
166        traces: crate::otel::traces_provider_effective(),
167        metrics: crate::otel::metrics_provider_effective(),
168    };
169
170    #[cfg(not(feature = "otel"))]
171    let providers = SignalStatus {
172        logs: false,
173        traces: false,
174        metrics: false,
175    };
176
177    RuntimeStatus {
178        setup_done,
179        signals: SignalStatus {
180            logs: true,
181            traces: cfg.tracing.enabled,
182            metrics: cfg.metrics.enabled,
183        },
184        fallback: SignalStatus {
185            logs: !providers.logs,
186            traces: !providers.traces,
187            metrics: !providers.metrics,
188        },
189        providers,
190        setup_error: crate::health::get_health_snapshot().setup_error,
191    }
192}
193
194pub fn update_runtime_config(
195    overrides: RuntimeOverrides,
196) -> Result<TelemetryConfig, TelemetryError> {
197    let logging_override = overrides.logging.clone();
198    let next = {
199        let mut guard = crate::_lock::rwlock_write(active_config());
200        let current = match guard.as_ref().cloned() {
201            Some(current) => current,
202            None => {
203                return Err(TelemetryError::new(
204                    "telemetry not set up: call setup_telemetry first",
205                ));
206            }
207        };
208        let next = apply_runtime_overrides(current, overrides);
209        *guard = Some(next.clone());
210        next
211    }; // write lock released here before calling apply_policies
212    apply_policies(&next);
213    // When the caller supplies a logging override, mirror Python's behavior:
214    // reconfigure the logger so level/format/module-level changes take effect
215    // on the next log event.  The logger's `active_logging_config()` already
216    // prefers the programmatic override over runtime config, so this makes
217    // the override win consistently across both read paths.
218    if let Some(cfg) = logging_override {
219        crate::logger::configure_logging(cfg);
220    }
221    Ok(next)
222}
223
224pub fn reload_runtime_from_env() -> Result<TelemetryConfig, TelemetryError> {
225    let fresh = match TelemetryConfig::from_env() {
226        Ok(fresh) => fresh,
227        Err(err) => return Err(TelemetryError::new(err.message)),
228    };
229    let current = match get_runtime_config() {
230        Some(current) => current,
231        None => {
232            return Err(TelemetryError::new(
233                "telemetry not set up: call setup_telemetry first",
234            ))
235        }
236    };
237
238    // Warn on cold-field drift (matches Python/TypeScript/Go behavior).
239    let drifted = compute_cold_drift(&current, &fresh);
240    if !drifted.is_empty() {
241        eprintln!(
242            "[provide-telemetry] runtime.cold_field_drift: {} — restart required to apply",
243            drifted.join(", ")
244        );
245    }
246
247    // Exporter timeout fields are baked into OTLP exporters at construction
248    // time.  Only freeze them per-signal when the signal's OTel provider is
249    // actually live — otherwise they remain hot-reloadable.  Preserving them
250    // *before* update_runtime_config ensures apply_policies() and the stored
251    // snapshot always agree (no split-brain).
252    #[allow(unused_mut)] // `mut` is only exercised when the `otel` feature is enabled
253    let mut hot_exporter = fresh.exporter;
254    #[cfg(feature = "otel")]
255    {
256        if crate::otel::logs::logger_provider_installed() {
257            hot_exporter.logs_timeout_seconds = current.exporter.logs_timeout_seconds;
258        }
259        if crate::otel::traces::tracer_provider_installed() {
260            hot_exporter.traces_timeout_seconds = current.exporter.traces_timeout_seconds;
261        }
262        if crate::otel::metrics::meter_provider_installed() {
263            hot_exporter.metrics_timeout_seconds = current.exporter.metrics_timeout_seconds;
264        }
265    }
266
267    // Logging: level / fmt / include_timestamp / module_levels are hot.
268    // `otlp_endpoint`, `otlp_headers`, and `otlp_protocol` are baked into the
269    // OTLP log exporter at construction — freeze them from `current` when the
270    // log provider is live so env drift on those fields can't silently
271    // diverge from the installed exporter.
272    #[allow(unused_mut)] // `mut` is only exercised when the `otel` feature is enabled
273    let mut hot_logging = fresh.logging.clone();
274    #[cfg(feature = "otel")]
275    {
276        if crate::otel::logs::logger_provider_installed() {
277            hot_logging.otlp_endpoint = current.logging.otlp_endpoint.clone();
278            hot_logging.otlp_headers = current.logging.otlp_headers.clone();
279            hot_logging.otlp_protocol = current.logging.otlp_protocol.clone();
280        }
281    }
282
283    let overrides = RuntimeOverrides {
284        sampling: Some(fresh.sampling),
285        backpressure: Some(fresh.backpressure),
286        exporter: Some(hot_exporter),
287        security: Some(fresh.security),
288        slo: Some(fresh.slo),
289        pii_max_depth: Some(fresh.pii_max_depth),
290        strict_schema: Some(fresh.strict_schema),
291        event_schema: Some(fresh.event_schema),
292        logging: Some(hot_logging),
293    };
294
295    let mut next = apply_runtime_overrides(current.clone(), overrides);
296    set_active_config(Some(next.clone()));
297    apply_policies(&next);
298    // Reconfigure the logger so env-driven level / fmt / module-level drift
299    // takes effect on the next log event (mirrors Python parity).
300    crate::logger::configure_logging(next.logging.clone());
301    next.service_name = current.service_name;
302    next.environment = current.environment;
303    next.version = current.version;
304    next.tracing.enabled = current.tracing.enabled;
305    next.tracing.otlp_headers = current.tracing.otlp_headers;
306    next.metrics.enabled = current.metrics.enabled;
307    next.metrics.otlp_headers = current.metrics.otlp_headers;
308
309    set_active_config(Some(next.clone()));
310    Ok(next)
311}
312
313fn apply_runtime_overrides(
314    current: TelemetryConfig,
315    overrides: RuntimeOverrides,
316) -> TelemetryConfig {
317    let mut next = current;
318    next.sampling = overrides.sampling.unwrap_or(next.sampling);
319    next.backpressure = overrides.backpressure.unwrap_or(next.backpressure);
320    next.exporter = overrides.exporter.unwrap_or(next.exporter);
321    next.security = overrides.security.unwrap_or(next.security);
322    next.slo = overrides.slo.unwrap_or(next.slo);
323    next.pii_max_depth = overrides.pii_max_depth.unwrap_or(next.pii_max_depth);
324    next.strict_schema = overrides.strict_schema.unwrap_or(next.strict_schema);
325    next.event_schema = overrides.event_schema.unwrap_or(next.event_schema);
326    next.logging = overrides.logging.unwrap_or(next.logging);
327    next
328}
329
330fn runtime_config_or_default(config: Option<TelemetryConfig>) -> TelemetryConfig {
331    match config {
332        Some(config) => config,
333        None => TelemetryConfig::from_env().unwrap_or_default(),
334    }
335}
336
337pub fn reconfigure_telemetry(
338    config: Option<TelemetryConfig>,
339) -> Result<TelemetryConfig, TelemetryError> {
340    let target = match config {
341        Some(config) => config,
342        None => match TelemetryConfig::from_env() {
343            Ok(config) => config,
344            Err(err) => return Err(TelemetryError::new(err.message)),
345        },
346    };
347
348    #[cfg(feature = "otel")]
349    if let Some(current) = get_runtime_config() {
350        if otel_installed() {
351            let logs_live = crate::otel::logs::logger_provider_installed();
352            let traces_live = crate::otel::traces::tracer_provider_installed();
353            let metrics_live = crate::otel::metrics::meter_provider_installed();
354            // Identity fields affect every installed provider's Resource; per-signal
355            // fields only matter when that signal's provider is actually live.
356            let reject = identity_config_changed(&current, &target)
357                || (logs_live && logging_provider_config_changed(&current, &target))
358                || (traces_live && tracing_provider_config_changed(&current, &target))
359                || (metrics_live && metrics_provider_config_changed(&current, &target));
360            if reject {
361                Err(TelemetryError::new(PROVIDER_CHANGE_RESTART_MESSAGE))
362            } else {
363                Ok(())
364            }?;
365        }
366    }
367
368    set_active_config(Some(target.clone()));
369    apply_policies(&target);
370    Ok(target)
371}
372
373#[cfg(test)]
374#[path = "runtime_tests.rs"]
375mod tests;
376
377#[cfg(test)]
378#[path = "runtime_logging_tests.rs"]
379mod logging_tests;