Skip to main content

rustauth_telemetry/
lib.rs

1//! Telemetry collection compatible with Better Auth `@better-auth/telemetry` (v1.6.9).
2//!
3//! # Environment variables
4//!
5//! All telemetry-related variables use the **`RUSTAUTH_*`** prefix:
6//!
7//! | Purpose | Variable |
8//! |---------|----------|
9//! | Master switch | `RUSTAUTH_TELEMETRY` |
10//! | Debug logging (prints JSON instead of POST) | `RUSTAUTH_TELEMETRY_DEBUG` |
11//! | Collector URL | `RUSTAUTH_TELEMETRY_ENDPOINT` |
12//!
13//! ## Enablement precedence
14//!
15//! `RUSTAUTH_TELEMETRY` is a master switch that takes precedence over
16//! [`TelemetryOptions::enabled`](rustauth_core::options::TelemetryOptions):
17//!
18//! - `RUSTAUTH_TELEMETRY=false` (or `0`) is a hard opt-out: telemetry stays off
19//!   even when application code sets `TelemetryOptions::enabled(true)`.
20//! - `RUSTAUTH_TELEMETRY=true` (or `1`) is an opt-in that enables telemetry on
21//!   its own, regardless of the options value.
22//! - When the variable is unset, [`TelemetryOptions`](rustauth_core::options::TelemetryOptions)
23//!   decides (disabled by default).
24//!
25//! Regardless of the switch, telemetry is also suppressed under tests (unless
26//! [`TelemetryContext::skip_test_check`](crate::TelemetryContext) is set).
27//!
28//! Unless `RUSTAUTH_TELEMETRY_ENDPOINT` is set **or** [`TelemetryContext::custom_track`](crate::TelemetryContext) is provided, the publisher is a no-op: nothing is sent over the network. The maintainer of RustAuth does not receive telemetry by default; whoever deploys the app chooses the endpoint (their own collector, internal analytics, etc.) or wires `custom_track`.
29//!
30//! # Intentional gaps vs upstream
31//!
32//! - **Framework** and **database** are inferred from the host `Cargo.toml` when `CARGO_MANIFEST_DIR`
33//!   (or cwd) is available; override with [`TelemetryContext`] or [`TelemetryTestHooks`].
34//! - **`get_telemetry_auth_config`** emits Better Auth-shaped JSON from modeled
35//!   [`RustAuthOptions`] branches; physical table/column names are redacted in telemetry output.
36//! - **Runtime** is reported as `rust` (not Node/Bun/Deno).
37//! - **System metrics** `cpuModel`, `cpuSpeed`, and `memory` stay `null` without a platform sysinfo dependency.
38//! - **HTTP**: JSON POST uses `reqwest` when the `http` feature is enabled (default).
39//! - **OAuth/social providers**: the `socialProviders` config branch is populated only
40//!   when the `oauth` feature is enabled (direct consumers must pass
41//!   `features = ["oauth"]`; the umbrella `rustauth` `telemetry` feature enables it).
42//!
43//! Parity summary: [`UPSTREAM.md`](../UPSTREAM.md).
44
45mod auth_config;
46mod detectors;
47mod env;
48mod project_id;
49mod transport;
50pub mod types;
51mod utils;
52
53#[doc(hidden)]
54pub use auth_config::get_telemetry_auth_config;
55pub use types::{
56    CustomTrackFn, TelemetryContext, TelemetryEvent, TelemetryHttpError, TelemetryHttpTransport,
57};
58#[doc(hidden)]
59pub use types::{DetectionInfo, RuntimeInfo, TelemetryTestHooks};
60
61use std::future::Future;
62use std::pin::Pin;
63use std::sync::Arc;
64
65use rustauth_core::options::RustAuthOptions;
66use serde_json::json;
67use tokio::sync::{watch, Mutex};
68
69use crate::project_id::resolve_project_id;
70#[cfg(not(feature = "http"))]
71use crate::transport::NoopTransport;
72#[cfg(feature = "http")]
73use crate::transport::ReqwestTelemetryTransport;
74
75/// Current crate version.
76pub const VERSION: &str = env!("CARGO_PKG_VERSION");
77
78type TrackFn =
79    Arc<dyn Fn(TelemetryEvent) -> Pin<Box<dyn Future<Output = ()> + Send>> + Send + Sync>;
80
81/// Live telemetry handle ([`Self::publish`] is a no-op when telemetry is disabled).
82#[derive(Clone)]
83pub struct TelemetryPublisher {
84    hard_noop: bool,
85    enabled: bool,
86    anonymous_id: Arc<Mutex<Option<String>>>,
87    base_url: Option<String>,
88    test_anonymous_id: Option<String>,
89    track: TrackFn,
90    init_done: watch::Receiver<bool>,
91}
92
93impl TelemetryPublisher {
94    /// Never publishes (upstream behavior when no endpoint and no custom sink).
95    pub fn noop() -> Self {
96        Self {
97            hard_noop: true,
98            enabled: false,
99            anonymous_id: Arc::new(Mutex::new(None)),
100            base_url: None,
101            test_anonymous_id: None,
102            track: Arc::new(|_| Box::pin(async move {})),
103            init_done: watch::channel(true).1,
104        }
105    }
106
107    pub async fn publish(&self, event: TelemetryEvent) {
108        if self.hard_noop || !self.enabled {
109            return;
110        }
111        if !*self.init_done.borrow() {
112            let mut init_done = self.init_done.clone();
113            let _ = init_done.changed().await;
114        }
115        let mut guard = self.anonymous_id.lock().await;
116        if guard.is_none() {
117            let id = self
118                .test_anonymous_id
119                .clone()
120                .unwrap_or_else(|| resolve_project_id(self.base_url.as_deref()));
121            *guard = Some(id);
122        }
123        let anonymous_id = guard.clone().unwrap_or_default();
124        drop(guard);
125        let TelemetryEvent {
126            event_type,
127            payload,
128            ..
129        } = event;
130        let full = TelemetryEvent {
131            event_type,
132            anonymous_id: Some(anonymous_id),
133            payload,
134        };
135        (self.track)(full).await;
136    }
137}
138
139fn resolve_transport(context: &TelemetryContext) -> Arc<dyn TelemetryHttpTransport> {
140    if let Some(client) = &context.http_transport {
141        return client.clone();
142    }
143    #[cfg(feature = "http")]
144    {
145        Arc::new(ReqwestTelemetryTransport::default())
146    }
147    #[cfg(not(feature = "http"))]
148    {
149        return Arc::new(NoopTransport);
150    }
151}
152
153async fn is_enabled(options: &RustAuthOptions, context: &TelemetryContext) -> bool {
154    // `RUSTAUTH_TELEMETRY` is the master switch and takes precedence over
155    // `TelemetryOptions`: an explicit opt-out (`false` / `0`) forces telemetry
156    // off even when options enable it. When unset, options decide; an explicit
157    // opt-in (`true` / `1`) enables telemetry on its own.
158    let env_setting = crate::env::telemetry_env_setting();
159    if env_setting == Some(false) {
160        return false;
161    }
162    let opt_on = options.telemetry.enabled.unwrap_or(false);
163    let allow_under_test = context.skip_test_check || !crate::env::is_test();
164    (env_setting == Some(true) || opt_on) && allow_under_test
165}
166
167fn debug_enabled(options: &RustAuthOptions) -> bool {
168    options.telemetry.debug || crate::env::telemetry_debug_env()
169}
170
171fn build_track_fn(
172    context: &TelemetryContext,
173    endpoint: Option<String>,
174    debug_mode: bool,
175    transport: Arc<dyn TelemetryHttpTransport>,
176) -> TrackFn {
177    let custom = context.custom_track.clone();
178    Arc::new(move |event: TelemetryEvent| {
179        let custom = custom.clone();
180        let endpoint = endpoint.clone();
181        let transport = transport.clone();
182        Box::pin(async move {
183            if let Some(cb) = custom {
184                let _ = tokio::spawn(async move { cb(event).await }).await;
185                return;
186            }
187            let Some(url) = endpoint else {
188                return;
189            };
190            let Ok(body) = event.to_json_value() else {
191                return;
192            };
193            if debug_mode {
194                eprintln!(
195                    "telemetry event {}",
196                    serde_json::to_string_pretty(&body).unwrap_or_default()
197                );
198                return;
199            }
200            let _ = transport.post_json(&url, &body).await;
201        })
202    })
203}
204
205fn runtime_for(context: &TelemetryContext) -> RuntimeInfo {
206    context
207        .test_hooks
208        .as_ref()
209        .and_then(|h| h.runtime.clone())
210        .unwrap_or_else(detectors::detect_runtime)
211}
212
213fn database_for(context: &TelemetryContext) -> Option<DetectionInfo> {
214    context
215        .test_hooks
216        .as_ref()
217        .and_then(|h| h.database.clone())
218        .unwrap_or_else(detectors::detect_database)
219}
220
221fn framework_for(context: &TelemetryContext) -> Option<DetectionInfo> {
222    context
223        .test_hooks
224        .as_ref()
225        .and_then(|h| h.framework.clone())
226        .unwrap_or_else(detectors::detect_framework)
227}
228
229fn environment_for(context: &TelemetryContext) -> String {
230    context
231        .test_hooks
232        .as_ref()
233        .and_then(|h| h.environment.clone())
234        .unwrap_or_else(detectors::detect_environment)
235}
236
237fn system_info_for(context: &TelemetryContext) -> serde_json::Value {
238    context
239        .test_hooks
240        .as_ref()
241        .and_then(|h| h.system_info.clone())
242        .unwrap_or_else(detectors::detect_system_info)
243}
244
245fn package_manager_for(context: &TelemetryContext) -> Option<DetectionInfo> {
246    context
247        .test_hooks
248        .as_ref()
249        .and_then(|h| h.package_manager.clone())
250        .unwrap_or_else(detectors::detect_package_manager)
251}
252
253/// Creates a telemetry publisher (possibly a hard no-op when neither endpoint nor custom track exist).
254pub async fn create_telemetry(
255    options: &RustAuthOptions,
256    context: TelemetryContext,
257) -> TelemetryPublisher {
258    let endpoint = crate::env::telemetry_endpoint();
259    if endpoint.is_none() && context.custom_track.is_none() {
260        return TelemetryPublisher::noop();
261    }
262
263    let enabled = is_enabled(options, &context).await;
264    let transport = resolve_transport(&context);
265    let track = build_track_fn(&context, endpoint, debug_enabled(options), transport);
266
267    let test_anonymous_id = context
268        .test_hooks
269        .as_ref()
270        .and_then(|h| h.anonymous_id.clone());
271
272    let anonymous_id_cell = Arc::new(Mutex::new(None));
273    let (init_done_tx, init_done_rx) = watch::channel(!enabled);
274
275    if enabled {
276        let aid = test_anonymous_id
277            .clone()
278            .unwrap_or_else(|| resolve_project_id(options.base_url.as_deref()));
279        {
280            let mut g = anonymous_id_cell.lock().await;
281            *g = Some(aid.clone());
282        }
283
284        let payload = json!({
285            "config": get_telemetry_auth_config(options, &context),
286            "runtime": runtime_for(&context),
287            "database": database_for(&context),
288            "framework": framework_for(&context),
289            "environment": environment_for(&context),
290            "systemInfo": system_info_for(&context),
291            "packageManager": package_manager_for(&context),
292        });
293
294        let init = TelemetryEvent {
295            event_type: "init".to_owned(),
296            anonymous_id: Some(aid),
297            payload,
298        };
299        let track_init = track.clone();
300        tokio::spawn(async move {
301            track_init(init).await;
302            let _ = init_done_tx.send(true);
303        });
304    }
305
306    TelemetryPublisher {
307        hard_noop: false,
308        enabled,
309        anonymous_id: anonymous_id_cell,
310        base_url: options.base_url.clone(),
311        test_anonymous_id,
312        track,
313        init_done: init_done_rx,
314    }
315}