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:
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user