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.
|
/// Why a project was downloaded.
|
||||||
#[derive(
|
#[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")]
|
#[serde(rename_all = "snake_case")]
|
||||||
#[display(rename_all = "snake_case")]
|
#[display(rename_all = "snake_case")]
|
||||||
@@ -47,6 +56,15 @@ pub enum DownloadReason {
|
|||||||
Modpack,
|
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)]
|
#[derive(Debug, Row, Serialize, Deserialize, Clone, Eq, PartialEq, Hash)]
|
||||||
pub struct PageView {
|
pub struct PageView {
|
||||||
pub recorded: i64,
|
pub recorded: i64,
|
||||||
|
|||||||
@@ -33,6 +33,7 @@ use crate::{
|
|||||||
ids::{AffiliateCodeId, ProjectId, VersionId},
|
ids::{AffiliateCodeId, ProjectId, VersionId},
|
||||||
pats::Scopes,
|
pats::Scopes,
|
||||||
teams::ProjectPermissions,
|
teams::ProjectPermissions,
|
||||||
|
v3::analytics::DownloadReason,
|
||||||
},
|
},
|
||||||
queue::session::AuthQueue,
|
queue::session::AuthQueue,
|
||||||
routes::ApiError,
|
routes::ApiError,
|
||||||
@@ -168,10 +169,16 @@ pub enum ProjectDownloadsField {
|
|||||||
Domain,
|
Domain,
|
||||||
/// Modrinth site path which was visited, e.g. `/mod/foo`.
|
/// Modrinth site path which was visited, e.g. `/mod/foo`.
|
||||||
SitePath,
|
SitePath,
|
||||||
/// What country these views came from.
|
/// What country these downloads came from.
|
||||||
///
|
///
|
||||||
/// To anonymize the data, the country may be reported as `XX`.
|
/// To anonymize the data, the country may be reported as `XX`.
|
||||||
Country,
|
Country,
|
||||||
|
/// Download reason.
|
||||||
|
Reason,
|
||||||
|
/// Game version used for this download.
|
||||||
|
GameVersion,
|
||||||
|
/// Mod loader used for this download.
|
||||||
|
Loader,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Fields for [`ReturnMetrics::project_playtime`].
|
/// Fields for [`ReturnMetrics::project_playtime`].
|
||||||
@@ -188,6 +195,10 @@ pub enum ProjectPlaytimeField {
|
|||||||
Loader,
|
Loader,
|
||||||
/// Game version which this project was played on.
|
/// Game version which this project was played on.
|
||||||
GameVersion,
|
GameVersion,
|
||||||
|
/// What country this playtime came from.
|
||||||
|
///
|
||||||
|
/// To anonymize the data, the country may be reported as `XX`.
|
||||||
|
Country,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Fields for [`ReturnMetrics::project_revenue`].
|
/// Fields for [`ReturnMetrics::project_revenue`].
|
||||||
@@ -240,12 +251,13 @@ pub const MAX_TIME_SLICES: usize = 1024;
|
|||||||
// response
|
// response
|
||||||
|
|
||||||
/// Response for a [`GetRequest`].
|
/// 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)]
|
#[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.
|
/// Single time interval of metrics collection.
|
||||||
#[derive(Debug, Clone, Default, Serialize, Deserialize, utoipa::ToSchema)]
|
#[derive(Debug, Clone, Default, Serialize, Deserialize, utoipa::ToSchema)]
|
||||||
@@ -320,6 +332,15 @@ pub struct ProjectDownloads {
|
|||||||
/// [`ProjectDownloadsField::Country`].
|
/// [`ProjectDownloadsField::Country`].
|
||||||
#[serde(skip_serializing_if = "Option::is_none")]
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
country: Option<String>,
|
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.
|
/// Total number of downloads for this bucket.
|
||||||
downloads: u64,
|
downloads: u64,
|
||||||
}
|
}
|
||||||
@@ -336,6 +357,9 @@ pub struct ProjectPlaytime {
|
|||||||
/// [`ProjectPlaytimeField::GameVersion`].
|
/// [`ProjectPlaytimeField::GameVersion`].
|
||||||
#[serde(skip_serializing_if = "Option::is_none")]
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
game_version: Option<String>,
|
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.
|
/// Total number of seconds of playtime for this bucket.
|
||||||
seconds: u64,
|
seconds: u64,
|
||||||
}
|
}
|
||||||
@@ -450,6 +474,9 @@ mod query {
|
|||||||
pub site_path: String,
|
pub site_path: String,
|
||||||
pub version_id: DBVersionId,
|
pub version_id: DBVersionId,
|
||||||
pub country: String,
|
pub country: String,
|
||||||
|
pub reason: String,
|
||||||
|
pub game_version: String,
|
||||||
|
pub loader: String,
|
||||||
pub downloads: u64,
|
pub downloads: u64,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -459,6 +486,9 @@ mod query {
|
|||||||
const USE_SITE_PATH: &str = "{use_site_path: Bool}";
|
const USE_SITE_PATH: &str = "{use_site_path: Bool}";
|
||||||
const USE_VERSION_ID: &str = "{use_version_id: Bool}";
|
const USE_VERSION_ID: &str = "{use_version_id: Bool}";
|
||||||
const USE_COUNTRY: &str = "{use_country: 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!(
|
formatcp!(
|
||||||
"SELECT
|
"SELECT
|
||||||
@@ -468,6 +498,9 @@ mod query {
|
|||||||
if({USE_SITE_PATH}, site_path, '') AS site_path,
|
if({USE_SITE_PATH}, site_path, '') AS site_path,
|
||||||
if({USE_VERSION_ID}, version_id, 0) AS version_id,
|
if({USE_VERSION_ID}, version_id, 0) AS version_id,
|
||||||
if({USE_COUNTRY}, country, '') AS country,
|
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
|
COUNT(*) AS downloads
|
||||||
FROM downloads
|
FROM downloads
|
||||||
WHERE
|
WHERE
|
||||||
@@ -476,7 +509,7 @@ mod query {
|
|||||||
-- not the possibly-zero one,
|
-- not the possibly-zero one,
|
||||||
-- by using `downloads.project_id` instead of `project_id`
|
-- by using `downloads.project_id` instead of `project_id`
|
||||||
AND downloads.project_id IN {PROJECT_IDS}
|
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 version_id: DBVersionId,
|
||||||
pub loader: String,
|
pub loader: String,
|
||||||
pub game_version: String,
|
pub game_version: String,
|
||||||
|
pub country: String,
|
||||||
pub seconds: u64,
|
pub seconds: u64,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -495,6 +529,7 @@ mod query {
|
|||||||
const USE_VERSION_ID: &str = "{use_version_id: Bool}";
|
const USE_VERSION_ID: &str = "{use_version_id: Bool}";
|
||||||
const USE_LOADER: &str = "{use_loader: Bool}";
|
const USE_LOADER: &str = "{use_loader: Bool}";
|
||||||
const USE_GAME_VERSION: &str = "{use_game_version: Bool}";
|
const USE_GAME_VERSION: &str = "{use_game_version: Bool}";
|
||||||
|
const USE_COUNTRY: &str = "{use_country: Bool}";
|
||||||
|
|
||||||
formatcp!(
|
formatcp!(
|
||||||
"SELECT
|
"SELECT
|
||||||
@@ -503,6 +538,7 @@ mod query {
|
|||||||
if({USE_VERSION_ID}, version_id, 0) AS version_id,
|
if({USE_VERSION_ID}, version_id, 0) AS version_id,
|
||||||
if({USE_LOADER}, loader, '') AS loader,
|
if({USE_LOADER}, loader, '') AS loader,
|
||||||
if({USE_GAME_VERSION}, game_version, '') AS game_version,
|
if({USE_GAME_VERSION}, game_version, '') AS game_version,
|
||||||
|
if({USE_COUNTRY}, country, '') AS country,
|
||||||
SUM(seconds) AS seconds
|
SUM(seconds) AS seconds
|
||||||
FROM playtime
|
FROM playtime
|
||||||
WHERE
|
WHERE
|
||||||
@@ -511,7 +547,7 @@ mod query {
|
|||||||
-- not the possibly-zero one,
|
-- not the possibly-zero one,
|
||||||
-- by using `playtime.project_id` instead of `project_id`
|
-- by using `playtime.project_id` instead of `project_id`
|
||||||
AND playtime.project_id IN {PROJECT_IDS}
|
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_site_path", uses(F::SitePath)),
|
||||||
("use_version_id", uses(F::VersionId)),
|
("use_version_id", uses(F::VersionId)),
|
||||||
("use_country", uses(F::Country)),
|
("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| row.bucket,
|
||||||
|row| {
|
|row| {
|
||||||
@@ -711,6 +750,10 @@ pub async fn fetch_analytics(
|
|||||||
site_path: none_if_empty(row.site_path),
|
site_path: none_if_empty(row.site_path),
|
||||||
version_id: none_if_zero_version_id(row.version_id),
|
version_id: none_if_zero_version_id(row.version_id),
|
||||||
country,
|
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,
|
downloads: row.downloads,
|
||||||
}),
|
}),
|
||||||
})
|
})
|
||||||
@@ -731,15 +774,22 @@ pub async fn fetch_analytics(
|
|||||||
("use_version_id", uses(F::VersionId)),
|
("use_version_id", uses(F::VersionId)),
|
||||||
("use_loader", uses(F::Loader)),
|
("use_loader", uses(F::Loader)),
|
||||||
("use_game_version", uses(F::GameVersion)),
|
("use_game_version", uses(F::GameVersion)),
|
||||||
|
("use_country", uses(F::Country)),
|
||||||
],
|
],
|
||||||
|row| row.bucket,
|
|row| row.bucket,
|
||||||
|row| {
|
|row| {
|
||||||
|
let country = if uses(F::Country) {
|
||||||
|
Some(condense_country(row.country, row.seconds))
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
};
|
||||||
AnalyticsData::Project(ProjectAnalytics {
|
AnalyticsData::Project(ProjectAnalytics {
|
||||||
source_project: row.project_id.into(),
|
source_project: row.project_id.into(),
|
||||||
metrics: ProjectMetrics::Playtime(ProjectPlaytime {
|
metrics: ProjectMetrics::Playtime(ProjectPlaytime {
|
||||||
version_id: none_if_zero_version_id(row.version_id),
|
version_id: none_if_zero_version_id(row.version_id),
|
||||||
loader: none_if_empty(row.loader),
|
loader: none_if_empty(row.loader),
|
||||||
game_version: none_if_empty(row.game_version),
|
game_version: none_if_empty(row.game_version),
|
||||||
|
country,
|
||||||
seconds: row.seconds,
|
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> {
|
fn none_if_empty(s: String) -> Option<String> {
|
||||||
@@ -1108,55 +1160,59 @@ mod tests {
|
|||||||
let test_project_2 = ProjectId(456);
|
let test_project_2 = ProjectId(456);
|
||||||
let test_project_3 = ProjectId(789);
|
let test_project_3 = ProjectId(789);
|
||||||
|
|
||||||
let src = FetchResponse(vec![
|
let src = FetchResponse {
|
||||||
TimeSlice(vec![
|
metrics: vec![
|
||||||
AnalyticsData::Project(ProjectAnalytics {
|
TimeSlice(vec![
|
||||||
source_project: test_project_1,
|
AnalyticsData::Project(ProjectAnalytics {
|
||||||
metrics: ProjectMetrics::Views(ProjectViews {
|
source_project: test_project_1,
|
||||||
domain: Some("youtube.com".into()),
|
metrics: ProjectMetrics::Views(ProjectViews {
|
||||||
views: 100,
|
domain: Some("youtube.com".into()),
|
||||||
..Default::default()
|
views: 100,
|
||||||
|
..Default::default()
|
||||||
|
}),
|
||||||
}),
|
}),
|
||||||
}),
|
AnalyticsData::Project(ProjectAnalytics {
|
||||||
AnalyticsData::Project(ProjectAnalytics {
|
source_project: test_project_2,
|
||||||
source_project: test_project_2,
|
metrics: ProjectMetrics::Downloads(ProjectDownloads {
|
||||||
metrics: ProjectMetrics::Downloads(ProjectDownloads {
|
domain: Some("discord.com".into()),
|
||||||
domain: Some("discord.com".into()),
|
downloads: 150,
|
||||||
downloads: 150,
|
..Default::default()
|
||||||
..Default::default()
|
}),
|
||||||
}),
|
}),
|
||||||
}),
|
]),
|
||||||
]),
|
TimeSlice(vec![AnalyticsData::Project(ProjectAnalytics {
|
||||||
TimeSlice(vec![AnalyticsData::Project(ProjectAnalytics {
|
source_project: test_project_3,
|
||||||
source_project: test_project_3,
|
metrics: ProjectMetrics::Revenue(ProjectRevenue {
|
||||||
metrics: ProjectMetrics::Revenue(ProjectRevenue {
|
revenue: Decimal::new(20000, 2),
|
||||||
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,
|
|
||||||
}
|
|
||||||
],
|
],
|
||||||
[
|
};
|
||||||
{
|
let target = json!({
|
||||||
"source_project": test_project_3.to_string(),
|
"metrics": [
|
||||||
"metric_kind": "revenue",
|
[
|
||||||
"revenue": "200.00",
|
{
|
||||||
}
|
"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);
|
assert_eq!(serde_json::to_value(src).unwrap(), target);
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user