Skip to main content

mesh_llm_analytics/
consent.rs

1//! Resolving whether this machine reports analytics, and why.
2//!
3//! Every input is inspected in one place so `mesh-llm analytics status` can
4//! explain the decision instead of leaving users to guess.
5
6use std::env;
7
8/// Environment override for the analytics opt-out.
9pub const ENV_ANALYTICS: &str = "MESH_LLM_ANALYTICS";
10/// The cross-vendor opt-out convention (<https://consoledonottrack.com>).
11pub const ENV_DO_NOT_TRACK: &str = "DO_NOT_TRACK";
12/// Runtime override for the ingestion project key.
13pub const ENV_POSTHOG_KEY: &str = "MESH_LLM_POSTHOG_KEY";
14/// Runtime override for the ingestion host, for self-hosted PostHog.
15pub const ENV_POSTHOG_HOST: &str = "MESH_LLM_POSTHOG_HOST";
16
17/// Default ingestion host. PostHog US cloud.
18pub const DEFAULT_POSTHOG_HOST: &str = "https://us.i.posthog.com";
19
20/// The project key baked in at release build time.
21///
22/// Absent in source and development builds, which is deliberate: a build that
23/// was not produced by the release pipeline reports nothing at all.
24const BUILT_IN_POSTHOG_KEY: Option<&str> = option_env!("MESH_LLM_POSTHOG_KEY");
25
26/// Why analytics is on or off.
27#[derive(Clone, Copy, Debug, Eq, PartialEq)]
28pub enum Disposition {
29    /// Reporting, with no override in play.
30    EnabledByDefault,
31    /// Reporting because an override turned it on explicitly.
32    EnabledByEnv,
33    /// `MESH_LLM_ANALYTICS` is set to a falsey value.
34    DisabledByEnv,
35    /// `DO_NOT_TRACK` is set.
36    DisabledByDoNotTrack,
37    /// `[analytics] enabled = false` in the config file.
38    DisabledByConfig,
39    /// The config file exists but could not be read or parsed.
40    ///
41    /// A privacy control has to fail closed. A config with one mistyped key
42    /// anywhere fails to deserialize as a whole, and treating that as "no
43    /// preference stated" would silently re-enable reporting for someone who
44    /// had opted out.
45    DisabledConfigUnreadable,
46    /// No project key, so there is nowhere to report. Source builds land here.
47    DisabledNoKey,
48    /// A continuous-integration environment was detected.
49    DisabledInCi,
50}
51
52impl Disposition {
53    #[must_use]
54    pub const fn is_enabled(self) -> bool {
55        matches!(self, Self::EnabledByDefault | Self::EnabledByEnv)
56    }
57
58    /// A one-line explanation for `mesh-llm analytics status`.
59    #[must_use]
60    pub const fn explain(self) -> &'static str {
61        match self {
62            Self::EnabledByDefault => "enabled (default; run `mesh-llm analytics disable` to stop)",
63            Self::EnabledByEnv => "enabled by MESH_LLM_ANALYTICS",
64            Self::DisabledByEnv => "disabled by MESH_LLM_ANALYTICS",
65            Self::DisabledByDoNotTrack => "disabled by DO_NOT_TRACK",
66            Self::DisabledByConfig => "disabled by [analytics] enabled = false in config.toml",
67            Self::DisabledConfigUnreadable => {
68                "disabled: the config file could not be read, so the recorded preference is unknown"
69            }
70            Self::DisabledNoKey => {
71                "disabled: no analytics key is available (none compiled in, and MESH_LLM_POSTHOG_KEY is unset or empty)"
72            }
73            Self::DisabledInCi => "disabled: continuous integration environment detected",
74        }
75    }
76}
77
78/// What the config file says about analytics.
79#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
80pub enum ConfigPreference {
81    /// The config loaded and did not mention analytics.
82    #[default]
83    Unstated,
84    /// `[analytics] enabled` was stated explicitly.
85    Stated(bool),
86    /// The config exists but could not be read or parsed.
87    ///
88    /// Distinct from [`ConfigPreference::Unstated`] on purpose: an absent
89    /// config is an ordinary first run, while an unreadable one may be hiding
90    /// an opt-out and must not be read as consent.
91    Unreadable,
92}
93
94/// Inputs to the consent decision, gathered from the environment.
95#[derive(Clone, Debug, Default, Eq, PartialEq)]
96pub struct ConsentInputs {
97    /// `MESH_LLM_ANALYTICS`, if set.
98    pub env_override: Option<String>,
99    /// Whether `DO_NOT_TRACK` is set to a truthy value.
100    pub do_not_track: bool,
101    /// What the config file says.
102    pub config: ConfigPreference,
103    /// Whether a project key is available.
104    pub has_key: bool,
105    /// Whether this looks like CI.
106    pub in_ci: bool,
107}
108
109impl ConsentInputs {
110    /// Read every input from the process environment.
111    #[must_use]
112    pub fn from_env(config: ConfigPreference) -> Self {
113        Self {
114            env_override: env::var(ENV_ANALYTICS).ok(),
115            do_not_track: env::var(ENV_DO_NOT_TRACK)
116                .ok()
117                .is_some_and(|value| is_truthy(&value)),
118            config,
119            has_key: project_key().is_some(),
120            in_ci: detect_ci(),
121        }
122    }
123
124    /// Resolve the inputs into a single disposition.
125    ///
126    /// An explicit `MESH_LLM_ANALYTICS` wins over everything else, including
127    /// the CI check, so a deliberate test run can still exercise the path.
128    #[must_use]
129    pub fn resolve(&self) -> Disposition {
130        if !self.has_key {
131            return Disposition::DisabledNoKey;
132        }
133        if let Some(raw) = self.env_override.as_deref() {
134            return if is_truthy(raw) {
135                Disposition::EnabledByEnv
136            } else {
137                Disposition::DisabledByEnv
138            };
139        }
140        if self.do_not_track {
141            return Disposition::DisabledByDoNotTrack;
142        }
143        match self.config {
144            ConfigPreference::Stated(false) => return Disposition::DisabledByConfig,
145            ConfigPreference::Unreadable => return Disposition::DisabledConfigUnreadable,
146            ConfigPreference::Stated(true) | ConfigPreference::Unstated => {}
147        }
148        if self.in_ci {
149            return Disposition::DisabledInCi;
150        }
151        Disposition::EnabledByDefault
152    }
153}
154
155/// The ingestion project key, preferring the runtime override.
156#[must_use]
157pub fn project_key() -> Option<String> {
158    if let Ok(key) = env::var(ENV_POSTHOG_KEY) {
159        let key = key.trim().to_owned();
160        if !key.is_empty() {
161            return Some(key);
162        }
163    }
164    BUILT_IN_POSTHOG_KEY
165        .map(str::trim)
166        .filter(|key| !key.is_empty())
167        .map(str::to_owned)
168}
169
170/// Normalize a configured ingestion host, or `None` when nothing is left of it.
171///
172/// Split out from [`ingestion_host`] so the normalization is testable without
173/// mutating the process environment, which is global to the test binary.
174fn normalize_host(raw: &str) -> Option<String> {
175    let host = raw.trim().trim_end_matches('/');
176    (!host.is_empty()).then(|| host.to_owned())
177}
178
179/// The ingestion host, preferring the runtime override.
180#[must_use]
181pub fn ingestion_host() -> String {
182    env::var(ENV_POSTHOG_HOST)
183        .ok()
184        .and_then(|host| normalize_host(&host))
185        .unwrap_or_else(|| DEFAULT_POSTHOG_HOST.to_owned())
186}
187
188fn is_truthy(value: &str) -> bool {
189    matches!(
190        value.trim().to_ascii_lowercase().as_str(),
191        "1" | "true" | "yes" | "on"
192    )
193}
194
195/// Variables set by the common CI providers.
196pub(crate) const CI_MARKERS: &[&str] = &[
197    "CI",
198    "CONTINUOUS_INTEGRATION",
199    "GITHUB_ACTIONS",
200    "GITLAB_CI",
201    "BUILDKITE",
202    "CIRCLECI",
203    "TRAVIS",
204    "JENKINS_URL",
205    "TEAMCITY_VERSION",
206];
207
208/// Whether this looks like a CI job.
209///
210/// Shared with [`crate::properties::exec_env`] so the environment a run is
211/// *labelled* with cannot drift from the one reporting is *suppressed* for.
212pub(crate) fn detect_ci() -> bool {
213    CI_MARKERS.iter().any(|marker| {
214        env::var(marker).is_ok_and(|value| {
215            let value = value.trim();
216            // `CI` is conventionally truthy-by-presence, but an explicit
217            // `CI=false` should not count as continuous integration.
218            !value.is_empty() && !matches!(value.to_ascii_lowercase().as_str(), "0" | "false")
219        })
220    })
221}
222
223#[cfg(test)]
224#[path = "consent/tests.rs"]
225mod tests;