Skip to main content

provide_telemetry/
setup.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::{Mutex, OnceLock};
7
8use crate::config::TelemetryConfig;
9use crate::errors::TelemetryError;
10use crate::otel::{flush_otel, setup_otel, shutdown_otel};
11use crate::policies::apply_policies;
12use crate::runtime::{get_runtime_config, set_active_config};
13
14#[derive(Clone, Copy, Debug, Default)]
15struct SetupState {
16    done: bool,
17}
18
19static SETUP_STATE: OnceLock<Mutex<SetupState>> = OnceLock::new();
20
21#[cfg_attr(test, mutants::skip)] // Equivalent mutants only swap in Mutex::default().
22fn default_setup_state_mutex() -> Mutex<SetupState> {
23    Mutex::new(SetupState::default())
24}
25
26fn setup_state() -> &'static Mutex<SetupState> {
27    SETUP_STATE.get_or_init(default_setup_state_mutex)
28}
29
30/// Install telemetry providers, idempotently.
31///
32/// `config` supplies an in-memory configuration instead of reading the process
33/// environment — the Rust equivalent of Python's `setup_telemetry(config)`,
34/// TypeScript's `setupTelemetry(config)` and Go's `WithConfig`. Pass `None` for
35/// the environment-derived config. Rust has no default arguments, so `Option`
36/// is the idiom here, matching `get_logger(Option<&str>)` elsewhere in this crate.
37pub fn setup_telemetry(config: Option<TelemetryConfig>) -> Result<TelemetryConfig, TelemetryError> {
38    let mut state = crate::_lock::lock(setup_state());
39    if state.done {
40        return get_runtime_config()
41            .ok_or_else(|| TelemetryError::new("telemetry setup state is inconsistent"));
42    }
43
44    let config = match config {
45        Some(explicit) => explicit,
46        None => TelemetryConfig::from_env().map_err(|err| TelemetryError::new(err.message))?,
47    };
48    // An explicit config has been through no parser, so nothing has range-checked
49    // it. `apply_policies` below clamps rather than rejects, which would leave
50    // `get_runtime_config()` reporting a sampling rate that is not the one in
51    // force. `from_env` already validates, so this only ever fires for Some(_).
52    config
53        .validate()
54        .map_err(|err| TelemetryError::new(err.message))?;
55    setup_otel(&config)?;
56    apply_policies(&config);
57    set_active_config(Some(config.clone()));
58    state.done = true;
59    Ok(config)
60}
61
62/// Force-flush installed providers without tearing them down.
63///
64/// The drain half of [`shutdown_telemetry`]: every provider we installed is
65/// force-flushed under the bounded-shutdown deadline
66/// (`PROVIDE_EXPORTER_LOGS_SHUTDOWN_TIMEOUT_SECONDS`) and stays installed and
67/// usable. Use it where records must be out before control returns — a request
68/// boundary, a checkpoint, a serverless freeze — rather than shutting telemetry
69/// down and paying to set it up again.
70///
71/// Returns `Ok(())` when every signal drained within the deadline (including
72/// when nothing is installed) and `Err` when any was abandoned, so a caller
73/// flushing to be sure its records are out learns when they are not.
74///
75/// `timeout_seconds` overrides the configured deadline for this call; `None`
76/// uses the configured one. Matches Python's `flush_telemetry(timeout_seconds)`,
77/// TypeScript's `flushTelemetry(timeoutMs)` and Go's context deadline.
78pub fn flush_telemetry(timeout_seconds: Option<f64>) -> Result<(), TelemetryError> {
79    match flush_otel(timeout_seconds) {
80        crate::otel::DrainOutcome::Drained => Ok(()),
81        // The exporter answered inside the deadline and said no — claiming the
82        // deadline was exceeded would send an operator tuning timeouts when
83        // the fix is a bad auth header or an unreachable collector.
84        crate::otel::DrainOutcome::Failed => Err(TelemetryError::new(
85            "telemetry flush failed: an exporter rejected the drain; records may not have been exported",
86        )),
87        crate::otel::DrainOutcome::TimedOut => Err(TelemetryError::new(
88            "telemetry flush exceeded its deadline; records may not have been exported",
89        )),
90    }
91}
92
93/// Flush and tear down providers, then clear local runtime state.
94///
95/// `timeout_seconds` bounds the whole drain-and-teardown — the part that can
96/// hang on an unreachable collector — and `None` uses the configured deadline.
97///
98/// There is deliberately no separate pre-drain: each per-signal teardown already
99/// runs `force_flush` then `shutdown` under this deadline, so draining first
100/// would export every signal twice and could spend the caller's whole budget
101/// before the teardown it was meant to bound had started.
102pub fn shutdown_telemetry(timeout_seconds: Option<f64>) -> Result<(), TelemetryError> {
103    {
104        let mut state = crate::_lock::lock(setup_state());
105        state.done = false;
106    }
107    shutdown_otel(timeout_seconds);
108    set_active_config(None);
109    Ok(())
110}
111
112#[cfg(test)]
113mod tests {
114    use super::*;
115
116    use crate::testing::acquire_test_state_lock;
117
118    /// The explicit-config arm of `setup_telemetry`, and the validation that
119    /// now guards it. Nothing exercised `Some(_)` before, which left both the
120    /// match arm and the reject path uncovered.
121    #[test]
122    fn an_explicit_config_is_installed_and_reported_back() {
123        let _guard = acquire_test_state_lock();
124        shutdown_telemetry(None).expect("pre-test shutdown should succeed");
125
126        let cfg = TelemetryConfig {
127            service_name: "explicit-setup".to_string(),
128            ..Default::default()
129        };
130
131        let got = setup_telemetry(Some(cfg)).expect("a valid explicit config should install");
132
133        assert_eq!(got.service_name, "explicit-setup");
134        // The runtime snapshot must agree with what was handed in — the whole
135        // point of rejecting rather than clamping.
136        assert_eq!(
137            get_runtime_config()
138                .expect("an explicit setup should leave a runtime config")
139                .service_name,
140            "explicit-setup"
141        );
142
143        shutdown_telemetry(None).expect("shutdown should succeed");
144    }
145
146    /// An out-of-range rate in an explicit config is rejected outright. Left to
147    /// `apply_policies` it would be silently clamped to 1.0 while the snapshot
148    /// kept reporting 2.0.
149    #[test]
150    fn an_invalid_explicit_config_is_rejected_before_install() {
151        let _guard = acquire_test_state_lock();
152        shutdown_telemetry(None).expect("pre-test shutdown should succeed");
153
154        let mut cfg = TelemetryConfig::default();
155        cfg.sampling.logs_rate = 2.0;
156
157        let err = setup_telemetry(Some(cfg)).expect_err("a rate above one must be rejected");
158
159        assert!(
160            err.message.contains("PROVIDE_SAMPLING_LOGS_RATE"),
161            "unexpected message: {}",
162            err.message
163        );
164        assert!(
165            get_runtime_config().is_none(),
166            "a rejected config must not be installed"
167        );
168
169        shutdown_telemetry(None).expect("shutdown should succeed");
170    }
171
172    #[test]
173    fn flush_is_ok_when_nothing_is_installed() {
174        let _guard = acquire_test_state_lock();
175        shutdown_telemetry(None).expect("pre-test shutdown should succeed");
176
177        // Nothing installed means nothing to drain — a successful no-op, not
178        // an error, so callers can flush unconditionally.
179        flush_telemetry(None).expect("flush with no providers should succeed");
180    }
181
182    #[test]
183    fn flush_leaves_telemetry_set_up_and_repeatable() {
184        let _guard = acquire_test_state_lock();
185        shutdown_telemetry(None).expect("pre-test shutdown should succeed");
186        let config = setup_telemetry(None).expect("setup should succeed");
187
188        flush_telemetry(None).expect("first flush should succeed");
189        flush_telemetry(None).expect("second flush should succeed");
190
191        // Unlike shutdown, flush must leave the active runtime config in place.
192        assert_eq!(
193            get_runtime_config().expect("runtime config should survive a flush"),
194            config
195        );
196        shutdown_telemetry(None).expect("shutdown should succeed");
197    }
198
199    #[test]
200    fn setup_test_round_trip_sets_and_clears_runtime_state() {
201        let _guard = acquire_test_state_lock();
202        shutdown_telemetry(None).expect("pre-test shutdown should succeed");
203
204        let config = setup_telemetry(None).expect("setup should succeed");
205        assert_eq!(
206            get_runtime_config().expect("runtime config should exist"),
207            config
208        );
209        assert!(crate::_lock::lock(setup_state()).done);
210
211        shutdown_telemetry(None).expect("shutdown should succeed");
212        assert!(get_runtime_config().is_none());
213        assert!(!crate::_lock::lock(setup_state()).done);
214    }
215
216    #[test]
217    fn setup_test_repeated_setup_returns_existing_runtime_config() {
218        let _guard = acquire_test_state_lock();
219        shutdown_telemetry(None).expect("pre-test shutdown should succeed");
220
221        let first = setup_telemetry(None).expect("first setup should succeed");
222        let second = setup_telemetry(None).expect("second setup should return existing config");
223
224        assert_eq!(first, second);
225        shutdown_telemetry(None).expect("shutdown should succeed");
226    }
227
228    #[test]
229    fn setup_test_inconsistent_done_state_returns_error() {
230        let _guard = acquire_test_state_lock();
231        shutdown_telemetry(None).expect("pre-test shutdown should succeed");
232        set_active_config(None);
233        crate::_lock::lock(setup_state()).done = true;
234
235        let err = setup_telemetry(None).expect_err("inconsistent state must fail");
236        assert!(
237            err.message.contains("inconsistent"),
238            "unexpected error: {}",
239            err.message
240        );
241
242        crate::_lock::lock(setup_state()).done = false;
243    }
244
245    #[test]
246    fn setup_test_invalid_env_surfaces_parse_error() {
247        let _guard = acquire_test_state_lock();
248        shutdown_telemetry(None).expect("pre-test shutdown should succeed");
249        std::env::set_var("PROVIDE_LOG_INCLUDE_TIMESTAMP", "not-a-bool");
250
251        let err = setup_telemetry(None).expect_err("invalid env must fail setup");
252        assert!(err.message.contains("PROVIDE_LOG_INCLUDE_TIMESTAMP"));
253
254        std::env::remove_var("PROVIDE_LOG_INCLUDE_TIMESTAMP");
255    }
256
257    #[cfg(feature = "otel")]
258    #[test]
259    fn setup_test_invalid_otel_endpoint_surfaces_setup_error() {
260        let _guard = acquire_test_state_lock();
261        shutdown_telemetry(None).expect("pre-test shutdown should succeed");
262        std::env::set_var("OTEL_EXPORTER_OTLP_LOGS_ENDPOINT", "ftp://collector:4318");
263        std::env::set_var("PROVIDE_EXPORTER_LOGS_FAIL_OPEN", "false");
264
265        let err = setup_telemetry(None).expect_err("invalid OTEL endpoint must fail setup");
266        assert!(err.message.contains("scheme"));
267
268        std::env::remove_var("OTEL_EXPORTER_OTLP_LOGS_ENDPOINT");
269        std::env::remove_var("PROVIDE_EXPORTER_LOGS_FAIL_OPEN");
270    }
271}