mesh_llm_analytics/
consent.rs1use std::env;
7
8pub const ENV_ANALYTICS: &str = "MESH_LLM_ANALYTICS";
10pub const ENV_DO_NOT_TRACK: &str = "DO_NOT_TRACK";
12pub const ENV_POSTHOG_KEY: &str = "MESH_LLM_POSTHOG_KEY";
14pub const ENV_POSTHOG_HOST: &str = "MESH_LLM_POSTHOG_HOST";
16
17pub const DEFAULT_POSTHOG_HOST: &str = "https://us.i.posthog.com";
19
20const BUILT_IN_POSTHOG_KEY: Option<&str> = option_env!("MESH_LLM_POSTHOG_KEY");
25
26#[derive(Clone, Copy, Debug, Eq, PartialEq)]
28pub enum Disposition {
29 EnabledByDefault,
31 EnabledByEnv,
33 DisabledByEnv,
35 DisabledByDoNotTrack,
37 DisabledByConfig,
39 DisabledConfigUnreadable,
46 DisabledNoKey,
48 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 #[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#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
80pub enum ConfigPreference {
81 #[default]
83 Unstated,
84 Stated(bool),
86 Unreadable,
92}
93
94#[derive(Clone, Debug, Default, Eq, PartialEq)]
96pub struct ConsentInputs {
97 pub env_override: Option<String>,
99 pub do_not_track: bool,
101 pub config: ConfigPreference,
103 pub has_key: bool,
105 pub in_ci: bool,
107}
108
109impl ConsentInputs {
110 #[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 #[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#[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
170fn 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#[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
195pub(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
208pub(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 !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;