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 crate::config::RuntimeOverrides;
9use crate::config::TelemetryConfig;
10use crate::errors::TelemetryError;
11#[cfg(feature = "otel")]
12use crate::otel::otel_installed;
13use crate::policies::apply_policies;
14pub use crate::runtime_facade::{
15    flush_result, provider_mode, reconfigure_result, runtime_state, runtime_status,
16    signal_flush_result, telemetry_config, telemetry_runtime, FlushResult, ProviderMode,
17    ReconfigureResult, RuntimeState, RuntimeStatus, SignalFlushResult, SignalStatus,
18    TelemetryRuntime,
19};
20
21static ACTIVE_CONFIG: OnceLock<RwLock<Option<TelemetryConfig>>> = OnceLock::new();
22#[cfg(feature = "otel")]
23const PROVIDER_CHANGE_RESTART_MESSAGE: &str =
24    "OpenTelemetry providers already installed; restart the process for provider-changing config";
25
26#[cfg_attr(test, mutants::skip)] // Equivalent mutants only change constructors for the same empty lock.
27fn empty_active_config() -> RwLock<Option<TelemetryConfig>> {
28    RwLock::new(None)
29}
30
31fn active_config() -> &'static RwLock<Option<TelemetryConfig>> {
32    ACTIVE_CONFIG.get_or_init(empty_active_config)
33}
34
35pub(crate) fn set_active_config(config: Option<TelemetryConfig>) {
36    *crate::_lock::rwlock_write(active_config()) = config;
37}
38
39/// Resource identity fields — baked into every installed provider's `Resource`.
40/// A change here requires all live providers to be reinstalled.
41#[cfg(any(feature = "otel", test))]
42fn identity_config_changed(current: &TelemetryConfig, target: &TelemetryConfig) -> bool {
43    current.service_name != target.service_name
44        || current.environment != target.environment
45        || current.version != target.version
46}
47
48/// Logging-signal fields baked into the log exporter/provider at construction.
49#[cfg(any(feature = "otel", test))]
50fn logging_provider_config_changed(current: &TelemetryConfig, target: &TelemetryConfig) -> bool {
51    current.logging.otlp_endpoint != target.logging.otlp_endpoint
52        || current.logging.otlp_headers != target.logging.otlp_headers
53        || current.logging.otlp_protocol != target.logging.otlp_protocol
54        || current.exporter.logs_timeout_seconds != target.exporter.logs_timeout_seconds
55}
56
57/// Tracing-signal fields baked into the span exporter/provider at construction.
58#[cfg(any(feature = "otel", test))]
59fn tracing_provider_config_changed(current: &TelemetryConfig, target: &TelemetryConfig) -> bool {
60    current.tracing.enabled != target.tracing.enabled
61        || current.tracing.otlp_endpoint != target.tracing.otlp_endpoint
62        || current.tracing.otlp_headers != target.tracing.otlp_headers
63        || current.tracing.otlp_protocol != target.tracing.otlp_protocol
64        || current.exporter.traces_timeout_seconds != target.exporter.traces_timeout_seconds
65}
66
67/// Metrics-signal fields baked into the metric exporter/PeriodicReader at construction.
68#[cfg(any(feature = "otel", test))]
69fn metrics_provider_config_changed(current: &TelemetryConfig, target: &TelemetryConfig) -> bool {
70    current.metrics.enabled != target.metrics.enabled
71        || current.metrics.otlp_endpoint != target.metrics.otlp_endpoint
72        || current.metrics.otlp_headers != target.metrics.otlp_headers
73        || current.metrics.otlp_protocol != target.metrics.otlp_protocol
74        || current.metrics.metric_export_interval_ms != target.metrics.metric_export_interval_ms
75        || current.exporter.metrics_timeout_seconds != target.exporter.metrics_timeout_seconds
76}
77
78/// Returns `true` if any provider-baked field changed. Used in tests to
79/// assert the full set of provider-changing fields; production code uses
80/// the per-signal helpers directly inside `reconfigure_telemetry`.
81#[cfg(test)]
82pub(crate) fn provider_config_changed(current: &TelemetryConfig, target: &TelemetryConfig) -> bool {
83    identity_config_changed(current, target)
84        || logging_provider_config_changed(current, target)
85        || tracing_provider_config_changed(current, target)
86        || metrics_provider_config_changed(current, target)
87}
88
89pub fn get_runtime_config() -> Option<TelemetryConfig> {
90    crate::_lock::rwlock_read(active_config()).clone()
91}
92
93/// Pure helper used by `reload_runtime_from_env` to detect drift between the
94/// current and freshly-loaded `TelemetryConfig`. Returns the names of cold
95/// fields that differ; the caller decides whether to warn.
96fn compute_cold_drift(current: &TelemetryConfig, fresh: &TelemetryConfig) -> Vec<&'static str> {
97    let mut drifted: Vec<&'static str> = Vec::new();
98    if current.service_name != fresh.service_name {
99        drifted.push("service_name");
100    }
101    if current.environment != fresh.environment {
102        drifted.push("environment");
103    }
104    if current.version != fresh.version {
105        drifted.push("version");
106    }
107    if current.tracing.enabled != fresh.tracing.enabled {
108        drifted.push("tracing.enabled");
109    }
110    if current.metrics.enabled != fresh.metrics.enabled {
111        drifted.push("metrics.enabled");
112    }
113    drifted
114}
115
116/// True unless a *loaded* config switched this signal off.
117///
118/// Only the OTel backend asks, so this is gated with it.
119///
120/// Before `setup_telemetry` nothing has read the environment, so the signal
121/// defaults on — a host that installs its own SDK and never calls setup must
122/// still be reported as exporting. Matches the Python, Go and TypeScript rule
123/// (see `behavioral_parity.provider_adoption_reporting` in the spec).
124#[cfg(feature = "otel")]
125pub(crate) fn tracing_enabled_by_loaded_config() -> bool {
126    // match, not is_none_or: that is stable since 1.82 and the crate's MSRV is 1.81.
127    match crate::_lock::rwlock_read(active_config()).as_ref() {
128        Some(cfg) => cfg.tracing.enabled,
129        None => true,
130    }
131}
132
133/// Metrics counterpart of [`tracing_enabled_by_loaded_config`].
134#[cfg(feature = "otel")]
135pub(crate) fn metrics_enabled_by_loaded_config() -> bool {
136    match crate::_lock::rwlock_read(active_config()).as_ref() {
137        Some(cfg) => cfg.metrics.enabled,
138        None => true,
139    }
140}
141
142fn runtime_config_snapshot() -> (Option<TelemetryConfig>, bool) {
143    let guard = crate::_lock::rwlock_read(active_config());
144    let cfg = guard.clone();
145    (cfg.clone(), cfg.is_some())
146}
147
148pub fn get_runtime_status() -> RuntimeStatus {
149    let (cfg, setup_done) = runtime_config_snapshot();
150    let cfg = runtime_config_or_default(cfg);
151
152    #[cfg(feature = "otel")]
153    let providers = SignalStatus {
154        logs: crate::otel::logs::logger_provider_installed(),
155        traces: crate::otel::traces_provider_effective(),
156        metrics: crate::otel::metrics_provider_effective(),
157    };
158
159    #[cfg(not(feature = "otel"))]
160    let providers = SignalStatus {
161        logs: false,
162        traces: false,
163        metrics: false,
164    };
165
166    RuntimeStatus {
167        setup_done,
168        signals: SignalStatus {
169            logs: true,
170            traces: cfg.tracing.enabled,
171            metrics: cfg.metrics.enabled,
172        },
173        fallback: SignalStatus {
174            logs: !providers.logs,
175            traces: !providers.traces,
176            metrics: !providers.metrics,
177        },
178        providers,
179        setup_error: crate::health::get_health_snapshot().setup_error,
180    }
181}
182
183pub fn update_runtime_config(
184    overrides: RuntimeOverrides,
185) -> Result<TelemetryConfig, TelemetryError> {
186    let logging_override = overrides.logging.clone();
187    // Read once, outside the config lock: the provider slot has its own lock
188    // and this avoids nesting the two.
189    #[cfg(feature = "otel")]
190    let log_provider_live = crate::otel::logs::logger_provider_installed();
191    let next = {
192        let mut guard = crate::_lock::rwlock_write(active_config());
193        let current = match guard.as_ref().cloned() {
194            Some(current) => current,
195            None => {
196                return Err(TelemetryError::new(
197                    "telemetry not set up: call setup_telemetry first",
198                ));
199            }
200        };
201        let next = apply_runtime_overrides(current.clone(), overrides);
202        // The OTLP log exporter bakes endpoint/headers/protocol (and its
203        // timeout) in at construction. Applying a change here would leave
204        // `get_runtime_config()` naming a collector the installed exporter
205        // never sends to — reject instead, exactly as Python's
206        // `update_runtime_config` and this crate's `reconfigure_telemetry` do.
207        // `reload_runtime_from_env` freezes the same fields for the same reason.
208        #[cfg(feature = "otel")]
209        if log_provider_live && logging_provider_config_changed(&current, &next) {
210            return Err(TelemetryError::from(
211                crate::errors::ProviderImmutableError::new(
212                    "provider-changing logging reconfiguration is unsupported after \
213                     OpenTelemetry log providers are installed. Restart the process and \
214                     call setup_telemetry() with the new config.",
215                ),
216            ));
217        }
218        #[cfg(not(feature = "otel"))]
219        let _ = &current;
220        *guard = Some(next.clone());
221        next
222    }; // write lock released here before calling apply_policies
223    apply_policies(&next);
224    // When the caller supplies a logging override, mirror Python's behavior:
225    // reconfigure the logger so level/format/module-level changes take effect
226    // on the next log event.  The logger's `active_logging_config()` already
227    // prefers the programmatic override over runtime config, so this makes
228    // the override win consistently across both read paths.
229    if let Some(cfg) = logging_override {
230        crate::logger::configure_logging(cfg);
231    }
232    Ok(next)
233}
234
235pub fn reload_runtime_from_env() -> Result<TelemetryConfig, TelemetryError> {
236    let fresh = match TelemetryConfig::from_env() {
237        Ok(fresh) => fresh,
238        Err(err) => return Err(TelemetryError::new(err.message)),
239    };
240    let current = match get_runtime_config() {
241        Some(current) => current,
242        None => {
243            return Err(TelemetryError::new(
244                "telemetry not set up: call setup_telemetry first",
245            ))
246        }
247    };
248
249    // Warn on cold-field drift (matches Python/TypeScript/Go behavior).
250    let drifted = compute_cold_drift(&current, &fresh);
251    if !drifted.is_empty() {
252        eprintln!(
253            "[provide-telemetry] runtime.cold_field_drift: {} — restart required to apply",
254            drifted.join(", ")
255        );
256    }
257
258    // Exporter timeout fields are baked into OTLP exporters at construction
259    // time.  Only freeze them per-signal when the signal's OTel provider is
260    // actually live — otherwise they remain hot-reloadable.  Preserving them
261    // *before* update_runtime_config ensures apply_policies() and the stored
262    // snapshot always agree (no split-brain).
263    #[allow(unused_mut)] // `mut` is only exercised when the `otel` feature is enabled
264    let mut hot_exporter = fresh.exporter;
265    #[cfg(feature = "otel")]
266    {
267        if crate::otel::logs::logger_provider_installed() {
268            hot_exporter.logs_timeout_seconds = current.exporter.logs_timeout_seconds;
269        }
270        if crate::otel::traces::tracer_provider_installed() {
271            hot_exporter.traces_timeout_seconds = current.exporter.traces_timeout_seconds;
272        }
273        if crate::otel::metrics::meter_provider_installed() {
274            hot_exporter.metrics_timeout_seconds = current.exporter.metrics_timeout_seconds;
275        }
276    }
277
278    // Logging: level / fmt / include_timestamp / module_levels are hot.
279    // `otlp_endpoint`, `otlp_headers`, and `otlp_protocol` are baked into the
280    // OTLP log exporter at construction — freeze them from `current` when the
281    // log provider is live so env drift on those fields can't silently
282    // diverge from the installed exporter.
283    #[allow(unused_mut)] // `mut` is only exercised when the `otel` feature is enabled
284    let mut hot_logging = fresh.logging.clone();
285    #[cfg(feature = "otel")]
286    {
287        if crate::otel::logs::logger_provider_installed() {
288            hot_logging.otlp_endpoint = current.logging.otlp_endpoint.clone();
289            hot_logging.otlp_headers = current.logging.otlp_headers.clone();
290            hot_logging.otlp_protocol = current.logging.otlp_protocol.clone();
291        }
292    }
293
294    let overrides = RuntimeOverrides {
295        sampling: Some(fresh.sampling),
296        backpressure: Some(fresh.backpressure),
297        exporter: Some(hot_exporter),
298        security: Some(fresh.security),
299        slo: Some(fresh.slo),
300        pii_max_depth: Some(fresh.pii_max_depth),
301        strict_schema: Some(fresh.strict_schema),
302        event_schema: Some(fresh.event_schema),
303        logging: Some(hot_logging),
304    };
305
306    let mut next = apply_runtime_overrides(current.clone(), overrides);
307    set_active_config(Some(next.clone()));
308    apply_policies(&next);
309    // Reconfigure the logger so env-driven level / fmt / module-level drift
310    // takes effect on the next log event (mirrors Python parity).
311    crate::logger::configure_logging(next.logging.clone());
312    next.service_name = current.service_name;
313    next.environment = current.environment;
314    next.version = current.version;
315    next.tracing.enabled = current.tracing.enabled;
316    next.tracing.otlp_headers = current.tracing.otlp_headers;
317    next.metrics.enabled = current.metrics.enabled;
318    next.metrics.otlp_headers = current.metrics.otlp_headers;
319
320    set_active_config(Some(next.clone()));
321    Ok(next)
322}
323
324fn apply_runtime_overrides(
325    current: TelemetryConfig,
326    overrides: RuntimeOverrides,
327) -> TelemetryConfig {
328    let mut next = current;
329    next.sampling = overrides.sampling.unwrap_or(next.sampling);
330    next.backpressure = overrides.backpressure.unwrap_or(next.backpressure);
331    next.exporter = overrides.exporter.unwrap_or(next.exporter);
332    next.security = overrides.security.unwrap_or(next.security);
333    next.slo = overrides.slo.unwrap_or(next.slo);
334    next.pii_max_depth = overrides.pii_max_depth.unwrap_or(next.pii_max_depth);
335    next.strict_schema = overrides.strict_schema.unwrap_or(next.strict_schema);
336    next.event_schema = overrides.event_schema.unwrap_or(next.event_schema);
337    next.logging = overrides.logging.unwrap_or(next.logging);
338    next
339}
340
341fn runtime_config_or_default(config: Option<TelemetryConfig>) -> TelemetryConfig {
342    match config {
343        Some(config) => config,
344        None => TelemetryConfig::from_env().unwrap_or_default(),
345    }
346}
347
348pub fn reconfigure_telemetry(
349    config: Option<TelemetryConfig>,
350) -> Result<TelemetryConfig, TelemetryError> {
351    let target = match config {
352        Some(config) => config,
353        None => match TelemetryConfig::from_env() {
354            Ok(config) => config,
355            Err(err) => return Err(TelemetryError::new(err.message)),
356        },
357    };
358
359    #[cfg(feature = "otel")]
360    if let Some(current) = get_runtime_config() {
361        if otel_installed() {
362            let logs_live = crate::otel::logs::logger_provider_installed();
363            let traces_live = crate::otel::traces::tracer_provider_installed();
364            let metrics_live = crate::otel::metrics::meter_provider_installed();
365            // Identity fields affect every installed provider's Resource; per-signal
366            // fields only matter when that signal's provider is actually live.
367            let reject = identity_config_changed(&current, &target)
368                || (logs_live && logging_provider_config_changed(&current, &target))
369                || (traces_live && tracing_provider_config_changed(&current, &target))
370                || (metrics_live && metrics_provider_config_changed(&current, &target));
371            if reject {
372                // Produced as ProviderImmutableError so the caller can branch on
373                // is_provider_immutable() rather than matching the message text.
374                Err(TelemetryError::from(
375                    crate::errors::ProviderImmutableError::new(PROVIDER_CHANGE_RESTART_MESSAGE),
376                ))
377            } else {
378                Ok(())
379            }?;
380        }
381    }
382
383    set_active_config(Some(target.clone()));
384    apply_policies(&target);
385    Ok(target)
386}
387
388#[cfg(test)]
389#[path = "runtime_tests.rs"]
390mod tests;
391
392#[cfg(test)]
393#[path = "runtime_logging_tests.rs"]
394mod logging_tests;