Expose new analytics data in backend route (#5982)

* Expose more analytics data in backend

* Adjust fetch analytics body

* fix

* fix
This commit is contained in:
aecsocket
2026-05-04 17:33:26 +01:00
committed by GitHub
parent e13a89dd72
commit 2f311643a0
2 changed files with 129 additions and 55 deletions

View File

@@ -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<Self, Self::Err> {
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,

View File

@@ -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<TimeSlice>);
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<TimeSlice>,
}
/// 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<String>,
/// [`ProjectDownloadsField::Reason`].
#[serde(skip_serializing_if = "Option::is_none")]
reason: Option<DownloadReason>,
/// [`ProjectDownloadsField::GameVersion`].
#[serde(skip_serializing_if = "Option::is_none")]
game_version: Option<String>,
/// [`ProjectDownloadsField::Loader`].
#[serde(skip_serializing_if = "Option::is_none")]
loader: Option<String>,
/// 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<String>,
/// [`ProjectPlaytimeField::Country`].
#[serde(skip_serializing_if = "Option::is_none")]
country: Option<String>,
/// 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<String> {
@@ -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);
}