diff --git a/Cargo.lock b/Cargo.lock index 160be0c7..ab29123d 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -987,6 +987,7 @@ dependencies = [ "base64 0.22.1", "blurhash", "bytes", + "conduwuit_build_metadata", "conduwuit_core", "conduwuit_database", "const-str", diff --git a/conduwuit-example.toml b/conduwuit-example.toml index 1a8be2aa..95a10ced 100644 --- a/conduwuit-example.toml +++ b/conduwuit-example.toml @@ -119,6 +119,15 @@ # #allow_announcements_check = true +# If enabled, continuwuity will send anonymous analytics data periodically +# to help improve development. This includes basic server metadata like +# version, commit hash, and federation status. All requests are signed +# with the server's federation signing key. Data is sent on startup (with +# up to 5 minutes jitter) and every 12 hours thereafter (with up to 30 +# minutes jitter) to distribute load. +# +#allow_analytics = true + # Set this to any float value to multiply continuwuity's in-memory LRU # caches with such as "auth_chain_cache_capacity". # diff --git a/src/admin/server/commands.rs b/src/admin/server/commands.rs index 6027a9eb..5c6983a6 100644 --- a/src/admin/server/commands.rs +++ b/src/admin/server/commands.rs @@ -145,6 +145,16 @@ pub(super) async fn restart(&self, force: bool) -> Result { self.write_str("Restarting server...").await } +#[admin_command] +pub(super) async fn upload_analytics(&self) -> Result { + match self.services.analytics.force_upload().await { + | Ok(()) => self.write_str("Analytics uploaded successfully.").await, + | Err(e) => + self.write_str(&format!("Failed to upload analytics: {e}")) + .await, + } +} + #[admin_command] pub(super) async fn shutdown(&self) -> Result { warn!("shutdown command"); diff --git a/src/admin/server/mod.rs b/src/admin/server/mod.rs index 6b99e5de..b82eb24a 100644 --- a/src/admin/server/mod.rs +++ b/src/admin/server/mod.rs @@ -64,4 +64,7 @@ pub(super) enum ServerCommand { /// - Shutdown the server Shutdown, + + /// - Upload analytics + UploadAnalytics, } diff --git a/src/core/config/mod.rs b/src/core/config/mod.rs index d4a10345..337413d9 100644 --- a/src/core/config/mod.rs +++ b/src/core/config/mod.rs @@ -169,6 +169,18 @@ pub struct Config { #[serde(alias = "allow_check_for_updates", default = "true_fn")] pub allow_announcements_check: bool, + /// If enabled, continuwuity will send anonymous analytics data periodically + /// to help improve development. This includes basic server metadata like + /// version, build information and federation status. All requests are + /// signed with the server's federation signing key. + /// + /// This is also used to warn about potential problems with federation, if + /// federation is enabled. + /// + /// default: true + #[serde(default = "true_fn")] + pub allow_analytics: bool, + /// Set this to any float value to multiply continuwuity's in-memory LRU /// caches with such as "auth_chain_cache_capacity". /// diff --git a/src/service/Cargo.toml b/src/service/Cargo.toml index 8b0d1405..3e775f96 100644 --- a/src/service/Cargo.toml +++ b/src/service/Cargo.toml @@ -78,6 +78,7 @@ zstd_compression = [ async-trait.workspace = true base64.workspace = true bytes.workspace = true +conduwuit-build-metadata.workspace = true conduwuit-core.workspace = true conduwuit-database.workspace = true const-str.workspace = true diff --git a/src/service/analytics/mod.rs b/src/service/analytics/mod.rs new file mode 100644 index 00000000..9cc6d374 --- /dev/null +++ b/src/service/analytics/mod.rs @@ -0,0 +1,245 @@ +//! # Analytics service +//! +//! This service is responsible for collecting and uploading anonymous server +//! metadata to help improve continuwuity development. +//! +//! All requests are signed with the server's federation signing key for +//! authentication. This service respects the `allow_analytics` configuration +//! option and is enabled by default. +//! +//! Analytics are sent on startup (with up to 5 minutes jitter) and every 12 +//! hours thereafter (with up to 30 minutes jitter) to distribute load. + +use std::{sync::Arc, time::Duration}; + +use async_trait::async_trait; +use conduwuit::{ + Result, Server, debug, err, info, + version::{self, user_agent}, + warn, +}; +use database::{Deserialized, Map}; +use rand::Rng; +use ruma::ServerName; +use serde::{Deserialize, Serialize}; +use tokio::{ + sync::Notify, + time::{MissedTickBehavior, interval}, +}; + +use crate::{Dep, client, config, federation, globals, server_keys, users}; + +extern crate conduwuit_build_metadata as build_metadata; + +pub struct Service { + interval: Duration, + jitter: Duration, + startup_jitter: Duration, + interrupt: Notify, + db: Arc, + services: Services, +} + +struct Services { + client: Dep, + globals: Dep, + server_keys: Dep, + federation: Dep, + users: Dep, + server: Arc, + config: Dep, +} + +#[derive(Debug, Serialize)] +struct AnalyticsPayload { + server_name: String, + version: &'static str, + commit_hash: Option<&'static str>, + user_count: usize, + federation_enabled: bool, + room_creation_allowed: bool, + public_room_directory_over_federation: bool, + build_profile: &'static str, + opt_level: &'static str, + rustc_version: &'static str, + features: Vec<&'static str>, + host: &'static str, + target: &'static str, + // the following can all be derived from the target + target_arch: &'static str, + target_os: &'static str, + target_env: &'static str, + target_family: &'static str, +} + +#[derive(Debug, Deserialize)] +struct AnalyticsResponse { + success: bool, + message: Option, +} + +const ANALYTICS_URL: &str = "https://analytics.continuwuity.org/api/v1/metrics"; +const ANALYTICS_SERVERNAME: &str = "analytics.continuwuity.org"; +const ANALYTICS_INTERVAL: u64 = 43200; // 12 hours in seconds +const ANALYTICS_JITTER: u64 = 1800; // 30 minutes in seconds +const ANALYTICS_STARTUP_JITTER: u64 = 300; // 5 minutes in seconds +const LAST_ANALYTICS_TIMESTAMP: &[u8; 21] = b"last_analytics_upload"; + +#[async_trait] +impl crate::Service for Service { + fn build(args: crate::Args<'_>) -> Result> { + let mut rng = rand::thread_rng(); + let jitter_seconds = rng.gen_range(0..=ANALYTICS_JITTER); + let startup_jitter_seconds = rng.gen_range(0..=ANALYTICS_STARTUP_JITTER); + + Ok(Arc::new(Self { + interval: Duration::from_secs(ANALYTICS_INTERVAL), + jitter: Duration::from_secs(jitter_seconds), + startup_jitter: Duration::from_secs(startup_jitter_seconds), + interrupt: Notify::new(), + db: args.db["global"].clone(), + services: Services { + globals: args.depend::("globals"), + client: args.depend::("client"), + config: args.depend::("config"), + server_keys: args.depend::("server_keys"), + users: args.depend::("users"), + federation: args.depend::("federation"), + server: args.server.clone(), + }, + })) + } + + #[tracing::instrument(skip_all, name = "analytics", level = "debug")] + async fn worker(self: Arc) -> Result<()> { + if !self.services.server.config.allow_analytics { + debug!("Analytics collection is disabled"); + return Ok(()); + } + + // Send initial analytics on startup (with shorter jitter) + tokio::time::sleep(self.startup_jitter).await; + if let Err(e) = self.upload_analytics().await { + warn!(%e, "Failed to upload initial analytics"); + } + + let mut i = interval(self.interval); + i.set_missed_tick_behavior(MissedTickBehavior::Delay); + i.reset_after(self.interval + self.jitter); + + loop { + tokio::select! { + () = self.interrupt.notified() => break, + _ = i.tick() => { + if let Err(e) = self.upload_analytics().await { + warn!(%e, "Failed to upload analytics"); + } + } + } + } + + Ok(()) + } + + fn interrupt(&self) { self.interrupt.notify_waiters(); } + + fn name(&self) -> &str { crate::service::make_name(std::module_path!()) } +} + +impl Service { + #[tracing::instrument(skip_all)] + async fn upload_analytics(&self) -> Result<()> { + let payload = self.collect_metadata().await; + let json_payload = serde_json::to_vec(&payload)?; + + // Create HTTP request + let request = http::Request::builder() + .method("POST") + .uri(ANALYTICS_URL) + .header("Content-Type", "application/json") + .header("User-Agent", user_agent()) + .body(json_payload)?; + + // Sign the request using federation signing + let reqwest_request = self.services.federation.sign_non_federation_request( + ServerName::parse(ANALYTICS_SERVERNAME).unwrap(), + request, + )?; + // self.sign_analytics_request(&mut request).await?; + + let response = self + .services + .client + .default + .execute(reqwest_request) + .await?; + let status = response.status(); + if let Ok(analytics_response) = + serde_json::from_str::(&response.text().await?) + { + if analytics_response.success { + debug!("Analytics uploaded successfully"); + self.update_last_upload_timestamp().await; + } + let msg = analytics_response.message.unwrap_or_default(); + warn!("Analytics upload warning: {}", msg); + } else if status.is_success() { + info!("Analytics uploaded successfully (no structured response)"); + self.update_last_upload_timestamp().await; + } else { + warn!("Analytics upload failed (no structured response) with status: {}", status); + } + + Ok(()) + } + + async fn collect_metadata(&self) -> AnalyticsPayload { + let config = &self.services.config; + + AnalyticsPayload { + server_name: self.services.globals.server_name().to_string(), + version: version::version(), + commit_hash: build_metadata::GIT_COMMIT_HASH, + user_count: self.services.users.count().await, + federation_enabled: config.allow_federation, + room_creation_allowed: config.allow_room_creation, + public_room_directory_over_federation: config + .allow_public_room_directory_over_federation, + build_profile: build_metadata::built::PROFILE, + opt_level: build_metadata::built::OPT_LEVEL, + rustc_version: build_metadata::built::RUSTC_VERSION, + features: build_metadata::built::FEATURES.to_vec(), + host: build_metadata::built::HOST, + target: build_metadata::built::TARGET, + target_arch: build_metadata::built::CFG_TARGET_ARCH, + target_os: build_metadata::built::CFG_OS, + target_env: build_metadata::built::CFG_ENV, + target_family: build_metadata::built::CFG_FAMILY, + } + } + + async fn update_last_upload_timestamp(&self) { + let timestamp = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap_or_default() + .as_secs(); + + self.db.raw_put(LAST_ANALYTICS_TIMESTAMP, timestamp); + } + + pub async fn last_upload_timestamp(&self) -> u64 { + self.db + .get(LAST_ANALYTICS_TIMESTAMP) + .await + .deserialized() + .unwrap_or(0_u64) + } + + pub async fn force_upload(&self) -> Result<()> { + if !self.services.config.allow_analytics { + return Err(err!(Config("allow_analytics", "Analytics collection is disabled"))); + } + + self.upload_analytics().await + } +} diff --git a/src/service/federation/execute.rs b/src/service/federation/execute.rs index 1d1d1154..b8efd334 100644 --- a/src/service/federation/execute.rs +++ b/src/service/federation/execute.rs @@ -107,6 +107,20 @@ fn prepare(&self, dest: &ServerName, mut request: http::Request>) -> Res Ok(request) } +#[implement(super::Service)] +pub fn sign_non_federation_request( + &self, + dest: &ServerName, + mut request: http::Request>, +) -> Result { + self.sign_request(&mut request, dest); + + let request = Request::try_from(request)?; + self.services.server.check_running()?; + + Ok(request) +} + #[implement(super::Service)] fn validate_url(&self, url: &Url) -> Result<()> { if let Some(url_host) = url.host_str() { diff --git a/src/service/mod.rs b/src/service/mod.rs index 3d7a3aa9..65da744f 100644 --- a/src/service/mod.rs +++ b/src/service/mod.rs @@ -9,6 +9,7 @@ pub mod state; pub mod account_data; pub mod admin; +pub mod analytics; pub mod announcements; pub mod appservice; pub mod client; diff --git a/src/service/services.rs b/src/service/services.rs index daece245..6919ae00 100644 --- a/src/service/services.rs +++ b/src/service/services.rs @@ -10,8 +10,8 @@ use futures::{Stream, StreamExt, TryStreamExt}; use tokio::sync::Mutex; use crate::{ - account_data, admin, announcements, appservice, client, config, emergency, federation, - globals, key_backups, + account_data, admin, analytics, announcements, appservice, client, config, emergency, + federation, globals, key_backups, manager::Manager, media, moderation, presence, pusher, resolver, rooms, sending, server_keys, service, service::{Args, Map, Service}, @@ -21,6 +21,7 @@ use crate::{ pub struct Services { pub account_data: Arc, pub admin: Arc, + pub analytics: Arc, pub appservice: Arc, pub config: Arc, pub client: Arc, @@ -68,6 +69,7 @@ impl Services { Ok(Arc::new(Self { account_data: build!(account_data::Service), admin: build!(admin::Service), + analytics: build!(analytics::Service), appservice: build!(appservice::Service), resolver: build!(resolver::Service), client: build!(client::Service),