From 2f311643a06bb150768c9f759704c88c5e962dfe Mon Sep 17 00:00:00 2001 From: aecsocket Date: Mon, 4 May 2026 17:33:26 +0100 Subject: [PATCH] Expose new analytics data in backend route (#5982) * Expose more analytics data in backend * Adjust fetch analytics body * fix * fix --- apps/labrinth/src/models/v3/analytics.rs | 20 ++- apps/labrinth/src/routes/v3/analytics_get.rs | 164 +++++++++++++------ 2 files changed, 129 insertions(+), 55 deletions(-) diff --git a/apps/labrinth/src/models/v3/analytics.rs b/apps/labrinth/src/models/v3/analytics.rs index c10cbb723..a71ba26c0 100644 --- a/apps/labrinth/src/models/v3/analytics.rs +++ b/apps/labrinth/src/models/v3/analytics.rs @@ -33,7 +33,16 @@ pub struct Download { /// Why a project was downloaded. #[derive( - Debug, Display, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize, + Debug, + Display, + Clone, + Copy, + PartialEq, + Eq, + Hash, + Serialize, + Deserialize, + utoipa::ToSchema, )] #[serde(rename_all = "snake_case")] #[display(rename_all = "snake_case")] @@ -47,6 +56,15 @@ pub enum DownloadReason { Modpack, } +impl std::str::FromStr for DownloadReason { + type Err = (); + + fn from_str(s: &str) -> Result { + serde_json::from_value(serde_json::Value::String(s.to_string())) + .map_err(|_| ()) + } +} + #[derive(Debug, Row, Serialize, Deserialize, Clone, Eq, PartialEq, Hash)] pub struct PageView { pub recorded: i64, diff --git a/apps/labrinth/src/routes/v3/analytics_get.rs b/apps/labrinth/src/routes/v3/analytics_get.rs index 756c467e0..51c9a6500 100644 --- a/apps/labrinth/src/routes/v3/analytics_get.rs +++ b/apps/labrinth/src/routes/v3/analytics_get.rs @@ -33,6 +33,7 @@ use crate::{ ids::{AffiliateCodeId, ProjectId, VersionId}, pats::Scopes, teams::ProjectPermissions, + v3::analytics::DownloadReason, }, queue::session::AuthQueue, routes::ApiError, @@ -168,10 +169,16 @@ pub enum ProjectDownloadsField { Domain, /// Modrinth site path which was visited, e.g. `/mod/foo`. SitePath, - /// What country these views came from. + /// What country these downloads came from. /// /// To anonymize the data, the country may be reported as `XX`. Country, + /// Download reason. + Reason, + /// Game version used for this download. + GameVersion, + /// Mod loader used for this download. + Loader, } /// Fields for [`ReturnMetrics::project_playtime`]. @@ -188,6 +195,10 @@ pub enum ProjectPlaytimeField { Loader, /// Game version which this project was played on. GameVersion, + /// What country this playtime came from. + /// + /// To anonymize the data, the country may be reported as `XX`. + Country, } /// Fields for [`ReturnMetrics::project_revenue`]. @@ -240,12 +251,13 @@ pub const MAX_TIME_SLICES: usize = 1024; // response /// Response for a [`GetRequest`]. -/// -/// This is a list of N [`TimeSlice`]s, where each slice represents an equal -/// time interval of metrics collection. The number of slices is determined -/// by [`GetRequest::time_range`]. #[derive(Debug, Default, Serialize, Deserialize, utoipa::ToSchema)] -pub struct FetchResponse(pub Vec); +pub struct FetchResponse { + /// List of N [`TimeSlice`]s, where each slice represents an equal + /// time interval of metrics collection. The number of slices is determined + /// by [`GetRequest::time_range`]. + pub metrics: Vec, +} /// Single time interval of metrics collection. #[derive(Debug, Clone, Default, Serialize, Deserialize, utoipa::ToSchema)] @@ -320,6 +332,15 @@ pub struct ProjectDownloads { /// [`ProjectDownloadsField::Country`]. #[serde(skip_serializing_if = "Option::is_none")] country: Option, + /// [`ProjectDownloadsField::Reason`]. + #[serde(skip_serializing_if = "Option::is_none")] + reason: Option, + /// [`ProjectDownloadsField::GameVersion`]. + #[serde(skip_serializing_if = "Option::is_none")] + game_version: Option, + /// [`ProjectDownloadsField::Loader`]. + #[serde(skip_serializing_if = "Option::is_none")] + loader: Option, /// Total number of downloads for this bucket. downloads: u64, } @@ -336,6 +357,9 @@ pub struct ProjectPlaytime { /// [`ProjectPlaytimeField::GameVersion`]. #[serde(skip_serializing_if = "Option::is_none")] game_version: Option, + /// [`ProjectPlaytimeField::Country`]. + #[serde(skip_serializing_if = "Option::is_none")] + country: Option, /// Total number of seconds of playtime for this bucket. seconds: u64, } @@ -450,6 +474,9 @@ mod query { pub site_path: String, pub version_id: DBVersionId, pub country: String, + pub reason: String, + pub game_version: String, + pub loader: String, pub downloads: u64, } @@ -459,6 +486,9 @@ mod query { const USE_SITE_PATH: &str = "{use_site_path: Bool}"; const USE_VERSION_ID: &str = "{use_version_id: Bool}"; const USE_COUNTRY: &str = "{use_country: Bool}"; + const USE_REASON: &str = "{use_reason: Bool}"; + const USE_GAME_VERSION: &str = "{use_game_version: Bool}"; + const USE_LOADER: &str = "{use_loader: Bool}"; formatcp!( "SELECT @@ -468,6 +498,9 @@ mod query { if({USE_SITE_PATH}, site_path, '') AS site_path, if({USE_VERSION_ID}, version_id, 0) AS version_id, if({USE_COUNTRY}, country, '') AS country, + if({USE_REASON}, reason, '') AS reason, + if({USE_GAME_VERSION}, game_version, '') AS game_version, + if({USE_LOADER}, loader, '') AS loader, COUNT(*) AS downloads FROM downloads WHERE @@ -476,7 +509,7 @@ mod query { -- not the possibly-zero one, -- by using `downloads.project_id` instead of `project_id` AND downloads.project_id IN {PROJECT_IDS} - GROUP BY bucket, project_id, domain, site_path, version_id, country" + GROUP BY bucket, project_id, domain, site_path, version_id, country, reason, game_version, loader" ) }; @@ -487,6 +520,7 @@ mod query { pub version_id: DBVersionId, pub loader: String, pub game_version: String, + pub country: String, pub seconds: u64, } @@ -495,6 +529,7 @@ mod query { const USE_VERSION_ID: &str = "{use_version_id: Bool}"; const USE_LOADER: &str = "{use_loader: Bool}"; const USE_GAME_VERSION: &str = "{use_game_version: Bool}"; + const USE_COUNTRY: &str = "{use_country: Bool}"; formatcp!( "SELECT @@ -503,6 +538,7 @@ mod query { if({USE_VERSION_ID}, version_id, 0) AS version_id, if({USE_LOADER}, loader, '') AS loader, if({USE_GAME_VERSION}, game_version, '') AS game_version, + if({USE_COUNTRY}, country, '') AS country, SUM(seconds) AS seconds FROM playtime WHERE @@ -511,7 +547,7 @@ mod query { -- not the possibly-zero one, -- by using `playtime.project_id` instead of `project_id` AND playtime.project_id IN {PROJECT_IDS} - GROUP BY bucket, project_id, version_id, loader, game_version" + GROUP BY bucket, project_id, version_id, loader, game_version, country" ) }; @@ -696,6 +732,9 @@ pub async fn fetch_analytics( ("use_site_path", uses(F::SitePath)), ("use_version_id", uses(F::VersionId)), ("use_country", uses(F::Country)), + ("use_reason", uses(F::Reason)), + ("use_game_version", uses(F::GameVersion)), + ("use_loader", uses(F::Loader)), ], |row| row.bucket, |row| { @@ -711,6 +750,10 @@ pub async fn fetch_analytics( site_path: none_if_empty(row.site_path), version_id: none_if_zero_version_id(row.version_id), country, + reason: none_if_empty(row.reason) + .and_then(|s| s.parse().ok()), + game_version: none_if_empty(row.game_version), + loader: none_if_empty(row.loader), downloads: row.downloads, }), }) @@ -731,15 +774,22 @@ pub async fn fetch_analytics( ("use_version_id", uses(F::VersionId)), ("use_loader", uses(F::Loader)), ("use_game_version", uses(F::GameVersion)), + ("use_country", uses(F::Country)), ], |row| row.bucket, |row| { + let country = if uses(F::Country) { + Some(condense_country(row.country, row.seconds)) + } else { + None + }; AnalyticsData::Project(ProjectAnalytics { source_project: row.project_id.into(), metrics: ProjectMetrics::Playtime(ProjectPlaytime { version_id: none_if_zero_version_id(row.version_id), loader: none_if_empty(row.loader), game_version: none_if_empty(row.game_version), + country, seconds: row.seconds, }), }) @@ -937,7 +987,9 @@ pub async fn fetch_analytics( } } - Ok(web::Json(FetchResponse(time_slices))) + Ok(web::Json(FetchResponse { + metrics: time_slices, + })) } fn none_if_empty(s: String) -> Option { @@ -1108,55 +1160,59 @@ mod tests { let test_project_2 = ProjectId(456); let test_project_3 = ProjectId(789); - let src = FetchResponse(vec![ - TimeSlice(vec![ - AnalyticsData::Project(ProjectAnalytics { - source_project: test_project_1, - metrics: ProjectMetrics::Views(ProjectViews { - domain: Some("youtube.com".into()), - views: 100, - ..Default::default() + let src = FetchResponse { + metrics: vec![ + TimeSlice(vec![ + AnalyticsData::Project(ProjectAnalytics { + source_project: test_project_1, + metrics: ProjectMetrics::Views(ProjectViews { + domain: Some("youtube.com".into()), + views: 100, + ..Default::default() + }), }), - }), - AnalyticsData::Project(ProjectAnalytics { - source_project: test_project_2, - metrics: ProjectMetrics::Downloads(ProjectDownloads { - domain: Some("discord.com".into()), - downloads: 150, - ..Default::default() + AnalyticsData::Project(ProjectAnalytics { + source_project: test_project_2, + metrics: ProjectMetrics::Downloads(ProjectDownloads { + domain: Some("discord.com".into()), + downloads: 150, + ..Default::default() + }), }), - }), - ]), - TimeSlice(vec![AnalyticsData::Project(ProjectAnalytics { - source_project: test_project_3, - metrics: ProjectMetrics::Revenue(ProjectRevenue { - revenue: Decimal::new(20000, 2), - }), - })]), - ]); - let target = json!([ - [ - { - "source_project": test_project_1.to_string(), - "metric_kind": "views", - "domain": "youtube.com", - "views": 100, - }, - { - "source_project": test_project_2.to_string(), - "metric_kind": "downloads", - "domain": "discord.com", - "downloads": 150, - } + ]), + TimeSlice(vec![AnalyticsData::Project(ProjectAnalytics { + source_project: test_project_3, + metrics: ProjectMetrics::Revenue(ProjectRevenue { + revenue: Decimal::new(20000, 2), + }), + })]), ], - [ - { - "source_project": test_project_3.to_string(), - "metric_kind": "revenue", - "revenue": "200.00", - } + }; + let target = json!({ + "metrics": [ + [ + { + "source_project": test_project_1.to_string(), + "metric_kind": "views", + "domain": "youtube.com", + "views": 100, + }, + { + "source_project": test_project_2.to_string(), + "metric_kind": "downloads", + "domain": "discord.com", + "downloads": 150, + } + ], + [ + { + "source_project": test_project_3.to_string(), + "metric_kind": "revenue", + "revenue": "200.00", + } + ] ] - ]); + }); assert_eq!(serde_json::to_value(src).unwrap(), target); }