Skip to main content

openauth_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 **`OPENAUTH_*`** prefix:
6//!
7//! | Purpose | Variable |
8//! |---------|----------|
9//! | Master switch | `OPENAUTH_TELEMETRY` |
10//! | Debug logging (prints JSON instead of POST) | `OPENAUTH_TELEMETRY_DEBUG` |
11//! | Collector URL | `OPENAUTH_TELEMETRY_ENDPOINT` |
12//!
13//! Unless `OPENAUTH_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 OpenAuth does not receive telemetry by default; whoever deploys the app chooses the endpoint (their own collector, internal analytics, etc.) or wires `custom_track`.
14//!
15//! # Intentional gaps vs upstream
16//!
17//! - **Framework** detection is stubbed to Axum until HTTP stack sniffing exists.
18//! - **Database** detection from manifests is not implemented (`None` unless overridden in tests).
19//! - **`get_telemetry_auth_config`** emits Better Auth-shaped JSON; many branches are static defaults
20//!   until matching fields exist on [`openauth_core::options::OpenAuthOptions`].
21//! - **Runtime** is reported as `rust` (not Node/Bun/Deno).
22//! - **System metrics** (CPU, memory, Docker, WSL, TTY) are mostly unset (`null`), matching the
23//!   non-Node “edge” build of upstream telemetry.
24//! - **HTTP**: JSON POST uses `reqwest` when the `http` feature is enabled (default).
25
26mod auth_config;
27mod detectors;
28mod env;
29mod project_id;
30mod transport;
31pub mod types;
32mod utils;
33
34pub use auth_config::get_telemetry_auth_config;
35pub use types::{
36    CustomTrackFn, DetectionInfo, RuntimeInfo, TelemetryContext, TelemetryEvent,
37    TelemetryHttpError, TelemetryHttpTransport, TelemetryTestHooks,
38};
39
40use std::future::Future;
41use std::pin::Pin;
42use std::sync::Arc;
43
44use openauth_core::options::OpenAuthOptions;
45use serde_json::json;
46use tokio::sync::Mutex;
47
48use crate::project_id::resolve_project_id;
49#[cfg(not(feature = "http"))]
50use crate::transport::NoopTransport;
51#[cfg(feature = "http")]
52use crate::transport::ReqwestTelemetryTransport;
53
54/// Current crate version.
55pub const VERSION: &str = env!("CARGO_PKG_VERSION");
56
57type TrackFn =
58    Arc<dyn Fn(TelemetryEvent) -> Pin<Box<dyn Future<Output = ()> + Send>> + Send + Sync>;
59
60/// Live telemetry handle ([`Self::publish`] is a no-op when telemetry is disabled).
61pub struct TelemetryPublisher {
62    hard_noop: bool,
63    enabled: bool,
64    anonymous_id: Arc<Mutex<Option<String>>>,
65    base_url: Option<String>,
66    test_anonymous_id: Option<String>,
67    track: TrackFn,
68}
69
70impl TelemetryPublisher {
71    /// Never publishes (upstream behavior when no endpoint and no custom sink).
72    pub fn noop() -> Self {
73        Self {
74            hard_noop: true,
75            enabled: false,
76            anonymous_id: Arc::new(Mutex::new(None)),
77            base_url: None,
78            test_anonymous_id: None,
79            track: Arc::new(|_| Box::pin(async move {})),
80        }
81    }
82
83    pub async fn publish(&self, event: TelemetryEvent) {
84        if self.hard_noop || !self.enabled {
85            return;
86        }
87        let mut guard = self.anonymous_id.lock().await;
88        if guard.is_none() {
89            let id = self
90                .test_anonymous_id
91                .clone()
92                .unwrap_or_else(|| resolve_project_id(self.base_url.as_deref()));
93            *guard = Some(id);
94        }
95        let anonymous_id = guard.clone().unwrap_or_default();
96        drop(guard);
97        let TelemetryEvent {
98            event_type,
99            payload,
100            ..
101        } = event;
102        let full = TelemetryEvent {
103            event_type,
104            anonymous_id: Some(anonymous_id),
105            payload,
106        };
107        (self.track)(full).await;
108    }
109}
110
111fn resolve_transport(context: &TelemetryContext) -> Arc<dyn TelemetryHttpTransport> {
112    if let Some(client) = &context.http_transport {
113        return client.clone();
114    }
115    #[cfg(feature = "http")]
116    {
117        Arc::new(ReqwestTelemetryTransport::default())
118    }
119    #[cfg(not(feature = "http"))]
120    {
121        return Arc::new(NoopTransport);
122    }
123}
124
125async fn is_enabled(options: &OpenAuthOptions, context: &TelemetryContext) -> bool {
126    let env_on = crate::env::telemetry_enabled_env();
127    let opt_on = options.telemetry.enabled.unwrap_or(false);
128    let allow_under_test = context.skip_test_check || !crate::env::is_test();
129    (env_on || opt_on) && allow_under_test
130}
131
132fn debug_enabled(options: &OpenAuthOptions) -> bool {
133    options.telemetry.debug || crate::env::telemetry_debug_env()
134}
135
136fn build_track_fn(
137    context: &TelemetryContext,
138    endpoint: Option<String>,
139    debug_mode: bool,
140    transport: Arc<dyn TelemetryHttpTransport>,
141) -> TrackFn {
142    let custom = context.custom_track.clone();
143    Arc::new(move |event: TelemetryEvent| {
144        let custom = custom.clone();
145        let endpoint = endpoint.clone();
146        let transport = transport.clone();
147        Box::pin(async move {
148            if let Some(cb) = custom {
149                let _ = tokio::spawn(async move { cb(event).await }).await;
150                return;
151            }
152            let Some(url) = endpoint else {
153                return;
154            };
155            let Ok(body) = event.to_json_value() else {
156                return;
157            };
158            if debug_mode {
159                eprintln!(
160                    "telemetry event {}",
161                    serde_json::to_string_pretty(&body).unwrap_or_default()
162                );
163                return;
164            }
165            let _ = transport.post_json(&url, &body).await;
166        })
167    })
168}
169
170fn runtime_for(context: &TelemetryContext) -> RuntimeInfo {
171    context
172        .test_hooks
173        .as_ref()
174        .and_then(|h| h.runtime.clone())
175        .unwrap_or_else(detectors::detect_runtime)
176}
177
178fn database_for(context: &TelemetryContext) -> Option<DetectionInfo> {
179    context
180        .test_hooks
181        .as_ref()
182        .and_then(|h| h.database.clone())
183        .unwrap_or_else(detectors::detect_database)
184}
185
186fn framework_for(context: &TelemetryContext) -> Option<DetectionInfo> {
187    context
188        .test_hooks
189        .as_ref()
190        .and_then(|h| h.framework.clone())
191        .unwrap_or_else(detectors::detect_framework)
192}
193
194fn environment_for(context: &TelemetryContext) -> String {
195    context
196        .test_hooks
197        .as_ref()
198        .and_then(|h| h.environment.clone())
199        .unwrap_or_else(detectors::detect_environment)
200}
201
202fn system_info_for(context: &TelemetryContext) -> serde_json::Value {
203    context
204        .test_hooks
205        .as_ref()
206        .and_then(|h| h.system_info.clone())
207        .unwrap_or_else(detectors::detect_system_info)
208}
209
210fn package_manager_for(context: &TelemetryContext) -> Option<DetectionInfo> {
211    context
212        .test_hooks
213        .as_ref()
214        .and_then(|h| h.package_manager.clone())
215        .unwrap_or_else(detectors::detect_package_manager)
216}
217
218/// Creates a telemetry publisher (possibly a hard no-op when neither endpoint nor custom track exist).
219pub async fn create_telemetry(
220    options: &OpenAuthOptions,
221    context: TelemetryContext,
222) -> TelemetryPublisher {
223    let endpoint = crate::env::telemetry_endpoint();
224    if endpoint.is_none() && context.custom_track.is_none() {
225        return TelemetryPublisher::noop();
226    }
227
228    let enabled = is_enabled(options, &context).await;
229    let transport = resolve_transport(&context);
230    let track = build_track_fn(&context, endpoint, debug_enabled(options), transport);
231
232    let test_anonymous_id = context
233        .test_hooks
234        .as_ref()
235        .and_then(|h| h.anonymous_id.clone());
236
237    let anonymous_id_cell = Arc::new(Mutex::new(None));
238
239    if enabled {
240        let aid = test_anonymous_id
241            .clone()
242            .unwrap_or_else(|| resolve_project_id(options.base_url.as_deref()));
243        {
244            let mut g = anonymous_id_cell.lock().await;
245            *g = Some(aid.clone());
246        }
247
248        let payload = json!({
249            "config": get_telemetry_auth_config(options, &context),
250            "runtime": runtime_for(&context),
251            "database": database_for(&context),
252            "framework": framework_for(&context),
253            "environment": environment_for(&context),
254            "systemInfo": system_info_for(&context),
255            "packageManager": package_manager_for(&context),
256        });
257
258        let init = TelemetryEvent {
259            event_type: "init".to_owned(),
260            anonymous_id: Some(aid),
261            payload,
262        };
263        let track_init = track.clone();
264        tokio::spawn(async move {
265            track_init(init).await;
266        });
267    }
268
269    TelemetryPublisher {
270        hard_noop: false,
271        enabled,
272        anonymous_id: anonymous_id_cell,
273        base_url: options.base_url.clone(),
274        test_anonymous_id,
275        track,
276    }
277}