Track new analytics metrics in backend (#5895)

* Allow filtering by project IDs in analytics route

* Download meta info in header

* add recursion limit

* Track playtime country

* fix clickhouse migrations
This commit is contained in:
aecsocket
2026-04-24 16:43:25 +01:00
committed by GitHub
parent 42cdcc7df9
commit e3d6a498d0
7 changed files with 89 additions and 11 deletions

View File

@@ -214,6 +214,8 @@ async fn playtime_ingest(
)
.await?;
let headers = req.headers();
for (id, playtime) in playtimes {
if playtime.seconds > 300 {
continue;
@@ -230,6 +232,9 @@ async fn playtime_ingest(
loader: playtime.loader,
game_version: playtime.game_version,
parent: playtime.parent.map_or(0, |x| x.0),
country: headers
.get("cf-ipcountry")
.and_then(|c| c.to_str().map(|s| s.to_string()).ok()),
});
}
}

View File

@@ -1,7 +1,7 @@
use crate::auth::validate::get_user_record_from_bearer_token;
use crate::database::PgPool;
use crate::database::redis::RedisPool;
use crate::models::analytics::Download;
use crate::models::analytics::{Download, DownloadReason};
use crate::models::ids::ProjectId;
use crate::models::pats::Scopes;
use crate::queue::analytics::AnalyticsQueue;
@@ -35,6 +35,17 @@ pub struct DownloadBody {
pub headers: HashMap<String, String>,
}
/// Extra data attached to each download request, transmitted through the
/// [`DOWNLOAD_META_HEADER`] header.
#[derive(Debug, Clone, Deserialize)]
pub struct DownloadMeta {
pub reason: DownloadReason,
pub game_version: String,
pub loader: String,
}
pub const DOWNLOAD_META_HEADER: &str = "modrinth-download-meta";
// This is an internal route, cannot be used without key
#[utoipa::path(
patch,
@@ -118,6 +129,11 @@ pub async fn count_download(
let ip = crate::util::ip::convert_to_ip_v6(&download_body.ip)
.unwrap_or_else(|_| Ipv4Addr::new(127, 0, 0, 1).to_ipv6_mapped());
let meta = download_body
.headers
.get(DOWNLOAD_META_HEADER)
.and_then(|v| serde_json::from_str::<DownloadMeta>(v).ok());
analytics_queue.add_download(Download {
recorded: get_current_tenths_of_ms(),
domain: url.host_str().unwrap_or_default().to_string(),
@@ -153,6 +169,9 @@ pub async fn count_download(
.contains(&&*x.0.to_lowercase())
})
.collect(),
reason: meta.as_ref().map(|m| m.reason),
game_version: meta.as_ref().map(|m| m.game_version.clone()),
loader: meta.as_ref().map(|m| m.loader.clone()),
});
Ok(HttpResponse::NoContent().body(""))

View File

@@ -1,4 +1,4 @@
pub(crate) mod admin;
pub mod admin;
pub mod affiliate;
pub mod billing;
pub mod delphi;

View File

@@ -55,6 +55,10 @@ pub struct GetRequest {
pub time_range: TimeRange,
/// What analytics metrics to return data for.
pub return_metrics: ReturnMetrics,
/// What project IDs to return data for.
///
/// If this is empty, all of the user's projects will be included.
pub project_ids: Vec<ProjectId>,
}
/// Time range for fetching analytics.
@@ -108,10 +112,6 @@ pub struct ReturnMetrics {
pub affiliate_code_revenue: Option<Metrics<AffiliateCodeRevenueField>>,
}
/// Replacement for `()` because of a `utoipa` limitation.
#[derive(Debug, Default, Serialize, Deserialize, utoipa::ToSchema)]
pub struct Unit {}
/// See [`ReturnMetrics`].
#[derive(Debug, Serialize, Deserialize, utoipa::ToSchema)]
pub struct Metrics<F> {
@@ -612,9 +612,16 @@ pub async fn fetch_analytics(
let mut time_slices = vec![TimeSlice::default(); num_time_slices];
// TODO fetch from req
let project_ids =
DBUser::get_projects(user.id.into(), &**pool, &redis).await?;
let project_ids = {
if req.project_ids.is_empty() {
DBUser::get_projects(user.id.into(), &**pool, &redis).await?
} else {
req.project_ids
.iter()
.map(|id| DBProjectId::from(*id))
.collect::<Vec<_>>()
}
};
let project_ids =
filter_allowed_project_ids(&project_ids, &user, &pool, &redis).await?;