mirror of
https://github.com/modrinth/code.git
synced 2026-07-31 13:16:38 +00:00
* chore(labrinth): bump to redis 1.4.1 * feat(labrinth): redis cluster * chore: cleanup * feat(labrinth): cache locking * fix(labrinth): clippy * chore(labrinth): cleanup env, remove postcard support * chore(ci): fix test env for labrinth * chore(labrinth): bump all key versions * chore(labrinth): improve redis key identities handling * chore(labrinth): simplify deadline handling * chore(labrinth): remove unused lease tracking * chore(labrinth): remove distributed cache locking for now * chore(labrinth): improve redis backend init error * feat(labrinth): expose redis read replica strategy * chore(ci): remove other connection mode tests * chore: split xredis crate * feat(xredis): primaries routing * chore: tombi fmt * chore: clippy * chore: update query cache
1077 lines
33 KiB
Rust
1077 lines
33 KiB
Rust
//! # Design rationale
|
|
//!
|
|
//! - different metrics require different scopes
|
|
//! - views, downloads, playtime requires `Scopes::ANALYTICS`
|
|
//! - revenue requires `Scopes::PAYOUTS_READ`
|
|
//! - each request returns an array of N elements; if you have to make multiple
|
|
//! requests, you have to zip together M arrays of N elements
|
|
//! - this makes it inconvenient to have separate endpoints
|
|
|
|
use xredis::RedisPool;
|
|
|
|
pub mod facets;
|
|
mod metrics;
|
|
pub mod old;
|
|
|
|
use std::{
|
|
collections::{HashMap, HashSet},
|
|
num::NonZeroU64,
|
|
};
|
|
|
|
use crate::database::{PgPool, models::DBUserId};
|
|
use actix_web::{HttpRequest, post, web};
|
|
use ariadne::ids::UserId;
|
|
use chrono::{DateTime, TimeDelta, Utc};
|
|
use eyre::eyre;
|
|
use serde::{Deserialize, Serialize};
|
|
|
|
use crate::{
|
|
auth::{
|
|
AuthenticationError,
|
|
checks::{filter_visible_project_ids, filter_visible_version_ids},
|
|
get_user_from_headers,
|
|
},
|
|
database::{
|
|
self, DBProject,
|
|
models::version_item::VersionQueryResult,
|
|
models::{
|
|
DBAffiliateCode, DBAffiliateCodeId, DBProjectId, DBUser, DBVersion,
|
|
DBVersionId,
|
|
},
|
|
},
|
|
models::{
|
|
ids::{AffiliateCodeId, ProjectId, VersionId},
|
|
pats::Scopes,
|
|
projects::ProjectStatus,
|
|
teams::ProjectPermissions,
|
|
threads::MessageBody,
|
|
v3::{analytics::DownloadReason, projects::Project, users::User},
|
|
},
|
|
queue::session::AuthQueue,
|
|
routes::ApiError,
|
|
};
|
|
|
|
#[cfg(test)]
|
|
pub(crate) use metrics::normalize_download_source;
|
|
pub use metrics::*;
|
|
|
|
pub fn config(cfg: &mut actix_web::web::ServiceConfig) {
|
|
cfg.service(fetch_analytics);
|
|
cfg.configure(facets::config);
|
|
cfg.configure(old::config);
|
|
}
|
|
|
|
// request
|
|
|
|
/// Requests analytics data, aggregating over all possible analytics sources
|
|
/// like projects and affiliate codes, returning the data in a list of time
|
|
/// slices.
|
|
#[derive(Debug, Serialize, Deserialize, utoipa::ToSchema)]
|
|
pub struct GetRequest {
|
|
/// What time range to return statistics for.
|
|
pub time_range: TimeRange,
|
|
/// What analytics metrics to return data for.
|
|
#[serde(default)]
|
|
pub return_metrics: ReturnMetrics,
|
|
/// What project IDs to return data for.
|
|
///
|
|
/// If this is empty, all of the user's projects will be included.
|
|
#[serde(default)]
|
|
pub project_ids: Vec<ProjectId>,
|
|
}
|
|
|
|
/// Time range for fetching analytics.
|
|
#[derive(Debug, Serialize, Deserialize, utoipa::ToSchema)]
|
|
pub struct TimeRange {
|
|
/// When to start including data.
|
|
pub start: DateTime<Utc>,
|
|
/// When to stop including data.
|
|
pub end: DateTime<Utc>,
|
|
/// Determines how many time slices between the start and end will be
|
|
/// included, and how fine-grained those time slices will be.
|
|
///
|
|
/// This must fall within the bounds of [`MIN_RESOLUTION`] and
|
|
/// [`MAX_TIME_SLICES`].
|
|
pub resolution: TimeRangeResolution,
|
|
}
|
|
|
|
/// Determines how many time slices between the start and end will be
|
|
/// included, and how fine-grained those time slices will be.
|
|
#[derive(Debug, Serialize, Deserialize, utoipa::ToSchema)]
|
|
#[serde(rename_all = "snake_case")]
|
|
pub enum TimeRangeResolution {
|
|
/// Use a set number of time slices, with the resolution being determined
|
|
/// automatically.
|
|
#[schema(value_type = u64)]
|
|
Slices(NonZeroU64),
|
|
/// Each time slice will be a set number of minutes long, and the number of
|
|
/// slices is determined automatically.
|
|
#[schema(value_type = u64)]
|
|
Minutes(NonZeroU64),
|
|
}
|
|
|
|
/// Minimum width of a [`TimeSlice`], controlled by [`TimeRange::resolution`].
|
|
pub const MIN_RESOLUTION: TimeDelta = TimeDelta::minutes(60);
|
|
|
|
/// Maximum number of [`TimeSlice`]s in a [`GetResponse`], controlled by
|
|
/// [`TimeRange::resolution`].
|
|
pub const MAX_TIME_SLICES: usize = 1024;
|
|
pub(crate) const UNKNOWN_LOADER: &str = "unknown";
|
|
pub(crate) const UNKNOWN_COUNTRY: &str = "XX";
|
|
pub(crate) const COUNTRY_PRIVACY_FLOOR: u64 = 50;
|
|
pub(crate) const COUNTRY_PLAYTIME_PRIVACY_FLOOR_SECONDS: u64 = 4 * 60 * 60;
|
|
|
|
// response
|
|
|
|
/// Response for a [`GetRequest`].
|
|
#[derive(Debug, Default, Serialize, Deserialize, utoipa::ToSchema)]
|
|
pub struct GetResponse {
|
|
/// 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>,
|
|
/// Project metadata for projects referenced in the response metrics.
|
|
#[serde(default)]
|
|
pub projects: HashMap<ProjectId, Project>,
|
|
/// User metadata for users referenced in the response metrics.
|
|
#[serde(default)]
|
|
pub users: HashMap<UserId, User>,
|
|
/// List of events associated with projects that were requested.
|
|
pub project_events: Vec<ProjectAnalyticsEvent>,
|
|
}
|
|
|
|
/// Single time interval of metrics collection.
|
|
#[derive(Debug, Clone, Default, Serialize, Deserialize, utoipa::ToSchema)]
|
|
pub struct TimeSlice(pub Vec<AnalyticsData>);
|
|
|
|
/// Notable update to a project which may reflect in analytics metrics.
|
|
#[derive(Debug, Clone, Serialize, Deserialize, utoipa::ToSchema)]
|
|
pub struct ProjectAnalyticsEvent {
|
|
/// ID of the event's project.
|
|
pub project_id: ProjectId,
|
|
/// When the event occurred.
|
|
pub timestamp: DateTime<Utc>,
|
|
#[serde(flatten)]
|
|
pub kind: ProjectAnalyticsEventKind,
|
|
}
|
|
|
|
#[derive(Debug, Clone, Serialize, Deserialize, utoipa::ToSchema)]
|
|
#[serde(tag = "kind", rename_all = "snake_case")]
|
|
pub enum ProjectAnalyticsEventKind {
|
|
/// New version of this project was uploaded.
|
|
VersionUploaded {
|
|
version_id: VersionId,
|
|
version_name: String,
|
|
version_number: String,
|
|
},
|
|
/// Project changed status.
|
|
StatusChanged {
|
|
status_from: ProjectStatus,
|
|
status_to: ProjectStatus,
|
|
},
|
|
}
|
|
|
|
// logic
|
|
|
|
/// Fetch analytics data.
|
|
#[utoipa::path(
|
|
context_path = "/analytics",
|
|
tag = "analytics",
|
|
responses((status = OK, body = inline(GetResponse))),
|
|
)]
|
|
#[post("")]
|
|
pub async fn fetch_analytics(
|
|
http_req: HttpRequest,
|
|
req: web::Json<GetRequest>,
|
|
pool: web::Data<PgPool>,
|
|
redis: web::Data<RedisPool>,
|
|
session_queue: web::Data<AuthQueue>,
|
|
clickhouse: web::Data<clickhouse::Client>,
|
|
) -> Result<web::Json<GetResponse>, ApiError> {
|
|
let (scopes, user) = get_user_from_headers(
|
|
&http_req,
|
|
&**pool,
|
|
&redis,
|
|
&session_queue,
|
|
Scopes::ANALYTICS,
|
|
)
|
|
.await?;
|
|
|
|
let full_time_range = req.time_range.end - req.time_range.start;
|
|
if full_time_range < TimeDelta::zero() {
|
|
return Err(ApiError::InvalidInput(
|
|
"End date must be after start date".into(),
|
|
));
|
|
}
|
|
|
|
let (num_time_slices, resolution) = match req.time_range.resolution {
|
|
TimeRangeResolution::Slices(slices) => {
|
|
let slices = i32::try_from(slices.get()).map_err(|_| {
|
|
ApiError::InvalidInput(
|
|
"Number of slices must fit into an `i32`".into(),
|
|
)
|
|
})?;
|
|
let resolution = full_time_range / slices;
|
|
(slices as usize, resolution)
|
|
}
|
|
TimeRangeResolution::Minutes(resolution_minutes) => {
|
|
let resolution_minutes = i64::try_from(resolution_minutes.get())
|
|
.map_err(|_| {
|
|
ApiError::InvalidInput(
|
|
"Resolution must fit into a `i64`".into(),
|
|
)
|
|
})?;
|
|
let resolution = TimeDelta::try_minutes(resolution_minutes)
|
|
.ok_or_else(|| {
|
|
ApiError::InvalidInput("Resolution overflow".into())
|
|
})?;
|
|
|
|
let num_slices =
|
|
full_time_range.as_seconds_f64() / resolution.as_seconds_f64();
|
|
|
|
(num_slices as usize, resolution)
|
|
}
|
|
};
|
|
|
|
if num_time_slices > MAX_TIME_SLICES {
|
|
return Err(ApiError::Request(eyre!(
|
|
"Resolution is too fine or range is too large - maximum of {MAX_TIME_SLICES} time slices, was {num_time_slices}"
|
|
)));
|
|
}
|
|
if resolution < MIN_RESOLUTION {
|
|
return Err(ApiError::Request(eyre!(
|
|
"Resolution must be at least {MIN_RESOLUTION}, was {resolution}",
|
|
)));
|
|
}
|
|
|
|
let mut time_slices = vec![TimeSlice::default(); num_time_slices];
|
|
|
|
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?;
|
|
|
|
let project_id_values =
|
|
project_ids.iter().map(|id| id.0).collect::<Vec<_>>();
|
|
let parent_versions = sqlx::query!(
|
|
"
|
|
SELECT id, mod_id
|
|
FROM versions
|
|
WHERE mod_id = ANY($1)
|
|
",
|
|
&project_id_values,
|
|
)
|
|
.fetch_all(&**pool)
|
|
.await?;
|
|
let parent_version_ids = parent_versions
|
|
.iter()
|
|
.map(|version| DBVersionId(version.id))
|
|
.collect::<Vec<_>>();
|
|
let parent_version_projects = parent_versions
|
|
.iter()
|
|
.map(|version| (DBVersionId(version.id), DBProjectId(version.mod_id)))
|
|
.collect::<HashMap<_, _>>();
|
|
let parent_version_data =
|
|
DBVersion::get_many(&parent_version_ids, &**pool, &redis).await?;
|
|
let visible_version_ids = filter_visible_version_ids(
|
|
parent_version_data
|
|
.iter()
|
|
.map(|version| &version.inner)
|
|
.collect(),
|
|
&Some(user.clone()),
|
|
&pool,
|
|
&redis,
|
|
)
|
|
.await?;
|
|
let mut project_events = parent_version_data
|
|
.iter()
|
|
.filter(|version| {
|
|
visible_version_ids.contains(&version.inner.id)
|
|
&& version.inner.date_published >= req.time_range.start
|
|
&& version.inner.date_published < req.time_range.end
|
|
})
|
|
.map(|version| ProjectAnalyticsEvent {
|
|
project_id: version.inner.project_id.into(),
|
|
timestamp: version.inner.date_published,
|
|
kind: ProjectAnalyticsEventKind::VersionUploaded {
|
|
version_id: version.inner.id.into(),
|
|
version_name: version.inner.name.clone(),
|
|
version_number: version.inner.version_number.clone(),
|
|
},
|
|
})
|
|
.collect::<Vec<_>>();
|
|
project_events.extend(
|
|
fetch_project_status_change_events(
|
|
&project_ids,
|
|
&req.time_range,
|
|
&pool,
|
|
)
|
|
.await?,
|
|
);
|
|
project_events.sort_by_key(|event| event.timestamp);
|
|
|
|
let affiliate_code_ids =
|
|
DBAffiliateCode::get_by_affiliate(user.id.into(), &**pool)
|
|
.await?
|
|
.into_iter()
|
|
.map(|code| code.id)
|
|
.collect::<Vec<_>>();
|
|
let project_loaders = project_loader_map(&parent_version_data);
|
|
|
|
let mut query_clickhouse_cx = QueryClickhouseContext {
|
|
clickhouse: &clickhouse,
|
|
pool: &pool,
|
|
redis: &redis,
|
|
req: &req,
|
|
time_slices: &mut time_slices,
|
|
project_ids: &project_ids,
|
|
parent_version_ids: &parent_version_ids,
|
|
affiliate_code_ids: &affiliate_code_ids,
|
|
project_loaders: &project_loaders,
|
|
};
|
|
|
|
if let Some(metrics) = &req.return_metrics.project_views {
|
|
metrics::fetch_project_views(&mut query_clickhouse_cx, metrics).await?;
|
|
}
|
|
|
|
if let Some(metrics) = &req.return_metrics.project_downloads {
|
|
metrics::fetch_project_downloads(&mut query_clickhouse_cx, metrics)
|
|
.await?;
|
|
}
|
|
|
|
if let Some(metrics) = &req.return_metrics.project_playtime {
|
|
metrics::fetch_project_playtime(
|
|
&mut query_clickhouse_cx,
|
|
&parent_version_projects,
|
|
metrics,
|
|
)
|
|
.await?;
|
|
}
|
|
|
|
if let Some(metrics) = &req.return_metrics.affiliate_code_clicks {
|
|
metrics::fetch_affiliate_code_clicks(&mut query_clickhouse_cx, metrics)
|
|
.await?;
|
|
}
|
|
|
|
if let Some(metrics) = &req.return_metrics.project_revenue {
|
|
if !scopes.contains(Scopes::PAYOUTS_READ) {
|
|
return Err(AuthenticationError::InvalidCredentials.into());
|
|
}
|
|
|
|
let user_id_bucket_project_ids = sqlx::query!(
|
|
"
|
|
SELECT m.id
|
|
FROM mods m
|
|
INNER JOIN team_members tm ON tm.team_id = m.team_id
|
|
WHERE
|
|
m.id = ANY($1)
|
|
AND tm.user_id = $2
|
|
AND tm.accepted
|
|
",
|
|
&project_id_values,
|
|
DBUserId::from(user.id).0,
|
|
)
|
|
.fetch_all(&**pool)
|
|
.await?
|
|
.into_iter()
|
|
.map(|row| row.id)
|
|
.collect::<Vec<_>>();
|
|
|
|
metrics::fetch_project_revenue(
|
|
&pool,
|
|
&mut time_slices,
|
|
&req,
|
|
num_time_slices,
|
|
&project_id_values,
|
|
&user_id_bucket_project_ids,
|
|
user.role.is_mod(),
|
|
metrics,
|
|
)
|
|
.await?;
|
|
}
|
|
|
|
if let Some(metrics) = &req.return_metrics.affiliate_code_conversions {
|
|
metrics::fetch_affiliate_code_conversions(
|
|
&pool,
|
|
&mut time_slices,
|
|
&req,
|
|
user.id.into(),
|
|
num_time_slices,
|
|
metrics,
|
|
)
|
|
.await?;
|
|
}
|
|
|
|
if let Some(metrics) = &req.return_metrics.affiliate_code_revenue {
|
|
if !scopes.contains(Scopes::PAYOUTS_READ) {
|
|
return Err(AuthenticationError::InvalidCredentials.into());
|
|
}
|
|
|
|
metrics::fetch_affiliate_code_revenue(
|
|
&pool,
|
|
&mut time_slices,
|
|
&req,
|
|
user.id.into(),
|
|
num_time_slices,
|
|
metrics,
|
|
)
|
|
.await?;
|
|
}
|
|
|
|
let projects =
|
|
fetch_response_projects(&mut time_slices, &user, &pool, &redis).await?;
|
|
let users = fetch_response_users(&time_slices, &pool, &redis).await?;
|
|
|
|
Ok(web::Json(GetResponse {
|
|
metrics: time_slices,
|
|
projects,
|
|
users,
|
|
project_events,
|
|
}))
|
|
}
|
|
|
|
pub(crate) fn none_if_empty(s: String) -> Option<String> {
|
|
if s.is_empty() { None } else { Some(s) }
|
|
}
|
|
|
|
pub(crate) fn none_if_zero_version_id(v: DBVersionId) -> Option<VersionId> {
|
|
if v.0 == 0 { None } else { Some(v.into()) }
|
|
}
|
|
|
|
pub(crate) fn apply_country_privacy(
|
|
country: &mut Option<String>,
|
|
country_filter_applied: bool,
|
|
count: u64,
|
|
floor: u64,
|
|
) -> bool {
|
|
if count >= floor {
|
|
return true;
|
|
}
|
|
|
|
if country_filter_applied {
|
|
return false;
|
|
}
|
|
|
|
if country.is_some() {
|
|
*country = Some(UNKNOWN_COUNTRY.to_string());
|
|
}
|
|
|
|
true
|
|
}
|
|
|
|
pub(crate) fn project_loader_map(
|
|
versions: &[VersionQueryResult],
|
|
) -> HashMap<DBProjectId, HashSet<String>> {
|
|
let mut loaders = HashMap::<DBProjectId, HashSet<String>>::new();
|
|
|
|
for version in versions {
|
|
loaders
|
|
.entry(version.inner.project_id)
|
|
.or_default()
|
|
.extend(version.loaders.iter().cloned());
|
|
}
|
|
|
|
loaders
|
|
}
|
|
|
|
pub(crate) fn normalize_loader_for_project(
|
|
loader: String,
|
|
project_id: DBProjectId,
|
|
project_loaders: &HashMap<DBProjectId, HashSet<String>>,
|
|
) -> String {
|
|
if loader.is_empty() {
|
|
return loader;
|
|
}
|
|
|
|
let loader_is_valid = project_loaders
|
|
.get(&project_id)
|
|
.is_some_and(|loaders| loaders.contains(&loader));
|
|
|
|
if loader_is_valid {
|
|
loader
|
|
} else {
|
|
UNKNOWN_LOADER.to_string()
|
|
}
|
|
}
|
|
|
|
async fn fetch_response_projects(
|
|
time_slices: &mut [TimeSlice],
|
|
user: &crate::models::users::User,
|
|
pool: &PgPool,
|
|
redis: &RedisPool,
|
|
) -> Result<HashMap<ProjectId, Project>, ApiError> {
|
|
let mut project_ids = HashSet::<DBProjectId>::new();
|
|
|
|
for time_slice in &*time_slices {
|
|
for data in &time_slice.0 {
|
|
let AnalyticsData::Project(project) = data else {
|
|
continue;
|
|
};
|
|
|
|
let source_project_id = DBProjectId::from(project.source_project);
|
|
if source_project_id.0 != 0 {
|
|
project_ids.insert(source_project_id);
|
|
}
|
|
if let ProjectMetrics::Downloads(downloads) = &project.metrics
|
|
&& let Some(dependent_project_id) =
|
|
downloads.dependent_project_id
|
|
{
|
|
project_ids.insert(dependent_project_id.into());
|
|
}
|
|
}
|
|
}
|
|
|
|
let project_ids = project_ids.into_iter().collect::<Vec<_>>();
|
|
let projects = DBProject::get_many_ids(&project_ids, pool, redis).await?;
|
|
let visible_project_ids = filter_visible_project_ids(
|
|
projects.iter().map(|project| &project.inner).collect(),
|
|
&Some(user.clone()),
|
|
pool,
|
|
false,
|
|
)
|
|
.await?
|
|
.into_iter()
|
|
.collect::<HashSet<_>>();
|
|
|
|
filter_response_project_ids(time_slices, &visible_project_ids);
|
|
|
|
Ok(projects
|
|
.into_iter()
|
|
.filter(|project| visible_project_ids.contains(&project.inner.id))
|
|
.map(|project| {
|
|
let project_id = project.inner.id.into();
|
|
(project_id, Project::from(project))
|
|
})
|
|
.collect())
|
|
}
|
|
|
|
async fn fetch_response_users(
|
|
time_slices: &[TimeSlice],
|
|
pool: &PgPool,
|
|
redis: &RedisPool,
|
|
) -> Result<HashMap<UserId, User>, ApiError> {
|
|
let mut user_ids = HashSet::<DBUserId>::new();
|
|
|
|
for time_slice in time_slices {
|
|
for data in &time_slice.0 {
|
|
let AnalyticsData::Project(project) = data else {
|
|
continue;
|
|
};
|
|
|
|
if let ProjectMetrics::Revenue(revenue) = &project.metrics
|
|
&& let Some(user_id) = revenue.user_id
|
|
{
|
|
user_ids.insert(user_id.into());
|
|
}
|
|
}
|
|
}
|
|
|
|
let user_ids = user_ids.into_iter().collect::<Vec<_>>();
|
|
if user_ids.is_empty() {
|
|
return Ok(HashMap::new());
|
|
}
|
|
|
|
let users = DBUser::get_many_ids(&user_ids, pool, redis).await?;
|
|
|
|
Ok(users
|
|
.into_iter()
|
|
.map(|user| {
|
|
let user_id = UserId::from(user.id);
|
|
(user_id, User::from(user))
|
|
})
|
|
.collect())
|
|
}
|
|
|
|
fn filter_response_project_ids(
|
|
time_slices: &mut [TimeSlice],
|
|
visible_project_ids: &HashSet<DBProjectId>,
|
|
) {
|
|
for time_slice in time_slices {
|
|
time_slice.0.retain_mut(|data| {
|
|
let AnalyticsData::Project(project) = data else {
|
|
return true;
|
|
};
|
|
|
|
let source_project_id = DBProjectId::from(project.source_project);
|
|
if source_project_id.0 != 0
|
|
&& !visible_project_ids.contains(&source_project_id)
|
|
{
|
|
return false;
|
|
}
|
|
|
|
if let ProjectMetrics::Downloads(downloads) = &mut project.metrics
|
|
&& let Some(dependent_project_id) =
|
|
downloads.dependent_project_id
|
|
&& !visible_project_ids
|
|
.contains(&DBProjectId::from(dependent_project_id))
|
|
{
|
|
downloads.dependent_project_id = None;
|
|
}
|
|
|
|
true
|
|
});
|
|
}
|
|
}
|
|
|
|
async fn fetch_project_status_change_events(
|
|
project_ids: &[DBProjectId],
|
|
time_range: &TimeRange,
|
|
pool: &PgPool,
|
|
) -> Result<Vec<ProjectAnalyticsEvent>, ApiError> {
|
|
let project_id_values =
|
|
project_ids.iter().map(|id| id.0).collect::<Vec<_>>();
|
|
|
|
let rows = sqlx::query!(
|
|
r#"
|
|
SELECT
|
|
t.mod_id AS "project_id!",
|
|
tm.created,
|
|
tm.body AS "body: sqlx::types::Json<MessageBody>"
|
|
FROM threads_messages tm
|
|
INNER JOIN threads t ON t.id = tm.thread_id
|
|
WHERE
|
|
t.mod_id = ANY($1)
|
|
AND tm.body->>'type' = 'status_change'
|
|
AND tm.created >= $2
|
|
AND tm.created < $3
|
|
"#,
|
|
&project_id_values,
|
|
time_range.start,
|
|
time_range.end,
|
|
)
|
|
.fetch_all(&**pool)
|
|
.await?;
|
|
|
|
Ok(rows
|
|
.into_iter()
|
|
.filter_map(|row| {
|
|
let MessageBody::StatusChange {
|
|
old_status,
|
|
new_status,
|
|
} = row.body.0
|
|
else {
|
|
return None;
|
|
};
|
|
|
|
Some(ProjectAnalyticsEvent {
|
|
project_id: DBProjectId(row.project_id).into(),
|
|
timestamp: row.created,
|
|
kind: ProjectAnalyticsEventKind::StatusChanged {
|
|
status_from: old_status,
|
|
status_to: new_status,
|
|
},
|
|
})
|
|
})
|
|
.collect())
|
|
}
|
|
|
|
pub(crate) struct QueryClickhouseContext<'a> {
|
|
pub(crate) clickhouse: &'a clickhouse::Client,
|
|
pub(crate) pool: &'a PgPool,
|
|
pub(crate) redis: &'a RedisPool,
|
|
pub(crate) req: &'a GetRequest,
|
|
pub(crate) time_slices: &'a mut [TimeSlice],
|
|
pub(crate) project_ids: &'a [DBProjectId],
|
|
pub(crate) parent_version_ids: &'a [DBVersionId],
|
|
pub(crate) affiliate_code_ids: &'a [DBAffiliateCodeId],
|
|
pub(crate) project_loaders: &'a HashMap<DBProjectId, HashSet<String>>,
|
|
}
|
|
|
|
#[derive(Debug, Clone, Copy, Default)]
|
|
pub(crate) struct ClickhouseQueryParams {
|
|
pub(crate) project_ids: bool,
|
|
pub(crate) parent_version_ids: bool,
|
|
pub(crate) affiliate_code_ids: bool,
|
|
}
|
|
|
|
pub(crate) enum ClickhouseFilterParam<'a> {
|
|
String(&'a [String]),
|
|
Bool(&'static str, &'a [bool]),
|
|
VersionId(&'a [VersionId]),
|
|
AffiliateCodeId(&'a [AffiliateCodeId]),
|
|
DownloadReason(&'a [DownloadReason]),
|
|
}
|
|
|
|
impl ClickhouseFilterParam<'_> {
|
|
pub(crate) fn bind(
|
|
self,
|
|
query: clickhouse::query::Query,
|
|
) -> clickhouse::query::Query {
|
|
match self {
|
|
Self::String(values) => query.bind(values),
|
|
Self::Bool(name, values) => {
|
|
let value = match values {
|
|
[false] => 0,
|
|
[true] => 1,
|
|
_ => 2,
|
|
};
|
|
query.param(name, value)
|
|
}
|
|
Self::VersionId(values) => {
|
|
let values = values
|
|
.iter()
|
|
.map(|id| DBVersionId::from(*id))
|
|
.collect::<Vec<_>>();
|
|
query.bind(values)
|
|
}
|
|
Self::AffiliateCodeId(values) => {
|
|
let values = values
|
|
.iter()
|
|
.map(|id| DBAffiliateCodeId::from(*id))
|
|
.collect::<Vec<_>>();
|
|
query.bind(values)
|
|
}
|
|
Self::DownloadReason(values) => {
|
|
let values =
|
|
values.iter().map(ToString::to_string).collect::<Vec<_>>();
|
|
query.bind(values)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
impl ClickhouseQueryParams {
|
|
pub(crate) const fn empty() -> Self {
|
|
Self {
|
|
project_ids: false,
|
|
parent_version_ids: false,
|
|
affiliate_code_ids: false,
|
|
}
|
|
}
|
|
}
|
|
|
|
impl std::ops::BitOr for ClickhouseQueryParams {
|
|
type Output = Self;
|
|
|
|
fn bitor(self, rhs: Self) -> Self::Output {
|
|
Self {
|
|
project_ids: self.project_ids || rhs.project_ids,
|
|
parent_version_ids: self.parent_version_ids
|
|
|| rhs.parent_version_ids,
|
|
affiliate_code_ids: self.affiliate_code_ids
|
|
|| rhs.affiliate_code_ids,
|
|
}
|
|
}
|
|
}
|
|
|
|
pub(crate) async fn query_clickhouse<Row>(
|
|
cx: &mut QueryClickhouseContext<'_>,
|
|
query: &str,
|
|
params: ClickhouseQueryParams,
|
|
use_columns: &[(&str, bool)],
|
|
filter_params: Vec<ClickhouseFilterParam<'_>>,
|
|
row_filter: impl Fn(&Row::Value<'_>) -> bool,
|
|
// I hate using the hidden type Row::Value here, but it's what next() returns, so I see no other option
|
|
row_get_bucket: impl Fn(&Row::Value<'_>) -> u64,
|
|
row_to_analytics: impl Fn(Row::Value<'_>) -> AnalyticsData,
|
|
) -> Result<(), ApiError>
|
|
where
|
|
Row: clickhouse::RowRead + serde::de::DeserializeOwned + std::fmt::Debug,
|
|
{
|
|
let mut query = cx
|
|
.clickhouse
|
|
.query(query)
|
|
.param("time_range_start", cx.req.time_range.start.timestamp())
|
|
.param("time_range_end", cx.req.time_range.end.timestamp())
|
|
.param("time_slices", cx.time_slices.len());
|
|
if params.project_ids {
|
|
query = query.bind(cx.project_ids);
|
|
}
|
|
if params.parent_version_ids {
|
|
query = query.bind(cx.parent_version_ids);
|
|
}
|
|
if params.affiliate_code_ids {
|
|
query = query.bind(cx.affiliate_code_ids);
|
|
}
|
|
for (param_name, used) in use_columns {
|
|
query = query.param(param_name, used)
|
|
}
|
|
for filter_param in filter_params {
|
|
query = filter_param.bind(query);
|
|
}
|
|
let mut cursor = query.fetch::<Row>()?;
|
|
|
|
while let Some(row) = cursor.next().await? {
|
|
if !row_filter(&row) {
|
|
continue;
|
|
}
|
|
let bucket = row_get_bucket(&row) as usize;
|
|
add_to_time_slice(cx.time_slices, bucket, row_to_analytics(row))?;
|
|
}
|
|
|
|
Ok(())
|
|
}
|
|
|
|
pub(crate) fn add_to_time_slice(
|
|
time_slices: &mut [TimeSlice],
|
|
bucket: usize,
|
|
data: AnalyticsData,
|
|
) -> Result<(), ApiError> {
|
|
// Bucketed analytics queries must filter time ranges as `[start, end)`.
|
|
// `widthBucket` returns `num_time_slices + 1` for values at or after
|
|
// `end`, which is outside the response slice array.
|
|
// row.recorded < time_range_start => bucket = 0
|
|
// row.recorded >= time_range_end => bucket = num_time_slices
|
|
// (note: this is out of range of `time_slices`!)
|
|
let Some(bucket) = bucket.checked_sub(1) else {
|
|
return Ok(());
|
|
};
|
|
|
|
let num_time_slices = time_slices.len();
|
|
let slice = time_slices.get_mut(bucket).ok_or_else(|| {
|
|
ApiError::InvalidInput(
|
|
format!("bucket {bucket} returned by query out of range for {num_time_slices} - query bug!")
|
|
)
|
|
})?;
|
|
|
|
slice.0.push(data);
|
|
Ok(())
|
|
}
|
|
|
|
async fn filter_allowed_project_ids(
|
|
project_ids: &[DBProjectId],
|
|
user: &crate::models::users::User,
|
|
pool: &PgPool,
|
|
redis: &RedisPool,
|
|
) -> Result<Vec<DBProjectId>, ApiError> {
|
|
let projects = DBProject::get_many_ids(project_ids, pool, redis).await?;
|
|
|
|
let team_ids = projects
|
|
.iter()
|
|
.map(|x| x.inner.team_id)
|
|
.collect::<Vec<database::models::DBTeamId>>();
|
|
let team_members = database::models::DBTeamMember::get_from_team_full_many(
|
|
&team_ids, pool, redis,
|
|
)
|
|
.await?;
|
|
|
|
let organization_ids = projects
|
|
.iter()
|
|
.filter_map(|x| x.inner.organization_id)
|
|
.collect::<Vec<database::models::DBOrganizationId>>();
|
|
let organizations = database::models::DBOrganization::get_many_ids(
|
|
&organization_ids,
|
|
pool,
|
|
redis,
|
|
)
|
|
.await?;
|
|
|
|
let organization_team_ids = organizations
|
|
.iter()
|
|
.map(|x| x.team_id)
|
|
.collect::<Vec<database::models::DBTeamId>>();
|
|
let organization_team_members =
|
|
database::models::DBTeamMember::get_from_team_full_many(
|
|
&organization_team_ids,
|
|
pool,
|
|
redis,
|
|
)
|
|
.await?;
|
|
|
|
Ok(projects
|
|
.into_iter()
|
|
.filter(|project| {
|
|
let team_member = team_members.iter().find(|x| {
|
|
x.team_id == project.inner.team_id
|
|
&& x.user_id == user.id.into()
|
|
});
|
|
|
|
let organization = project
|
|
.inner
|
|
.organization_id
|
|
.and_then(|oid| organizations.iter().find(|x| x.id == oid));
|
|
|
|
let organization_team_member =
|
|
if let Some(organization) = organization {
|
|
organization_team_members.iter().find(|x| {
|
|
x.team_id == organization.team_id
|
|
&& x.user_id == user.id.into()
|
|
})
|
|
} else {
|
|
None
|
|
};
|
|
|
|
let permissions = ProjectPermissions::get_permissions_by_role(
|
|
&user.role,
|
|
&team_member.cloned(),
|
|
&organization_team_member.cloned(),
|
|
)
|
|
.unwrap_or_default();
|
|
|
|
permissions.contains(ProjectPermissions::VIEW_ANALYTICS)
|
|
})
|
|
.map(|project| project.inner.id)
|
|
.collect::<Vec<_>>())
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use rust_decimal::Decimal;
|
|
use serde_json::json;
|
|
|
|
use super::*;
|
|
|
|
#[test]
|
|
fn normalizes_download_sources() {
|
|
let cases = [
|
|
("MultiMC/5.0", Some(DownloadSource::Named("MultiMC".into()))),
|
|
(
|
|
"PrismLauncher/6.1",
|
|
Some(DownloadSource::Named("Prism Launcher".into())),
|
|
),
|
|
(
|
|
"modrinth/theseus/0.8.6 (support@modrinth.com)",
|
|
Some(DownloadSource::ModrinthApp),
|
|
),
|
|
(
|
|
"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15",
|
|
Some(DownloadSource::Website),
|
|
),
|
|
("curl/8.7.1", None),
|
|
];
|
|
|
|
for (user_agent, source) in cases {
|
|
assert_eq!(normalize_download_source(user_agent), source);
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn download_source_serializes_as_raw_string() {
|
|
assert_eq!(
|
|
serde_json::to_value(DownloadSource::Named("MultiMC".into()))
|
|
.unwrap(),
|
|
json!("MultiMC")
|
|
);
|
|
assert_eq!(
|
|
serde_json::to_value(DownloadSource::Website).unwrap(),
|
|
json!("website")
|
|
);
|
|
assert_eq!(
|
|
serde_json::to_value(DownloadSource::ModrinthApp).unwrap(),
|
|
json!("modrinth_app")
|
|
);
|
|
assert_eq!(
|
|
serde_json::to_value(DownloadSource::Other).unwrap(),
|
|
json!("other")
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn country_privacy_floor_suppresses_small_constrained_buckets() {
|
|
let mut country = None;
|
|
assert!(apply_country_privacy(
|
|
&mut country,
|
|
false,
|
|
1,
|
|
COUNTRY_PRIVACY_FLOOR
|
|
));
|
|
assert_eq!(country, None);
|
|
|
|
let mut country = Some("US".into());
|
|
assert!(apply_country_privacy(
|
|
&mut country,
|
|
false,
|
|
49,
|
|
COUNTRY_PRIVACY_FLOOR
|
|
));
|
|
assert_eq!(country, Some("XX".into()));
|
|
|
|
let mut country = Some("US".into());
|
|
assert!(!apply_country_privacy(
|
|
&mut country,
|
|
true,
|
|
49,
|
|
COUNTRY_PRIVACY_FLOOR
|
|
));
|
|
assert_eq!(country, Some("US".into()));
|
|
|
|
let mut country = Some("US".into());
|
|
assert!(apply_country_privacy(
|
|
&mut country,
|
|
true,
|
|
50,
|
|
COUNTRY_PRIVACY_FLOOR
|
|
));
|
|
assert_eq!(country, Some("US".into()));
|
|
}
|
|
|
|
#[test]
|
|
fn response_format() {
|
|
let test_project_1 = ProjectId(123);
|
|
let test_project_2 = ProjectId(456);
|
|
let test_project_3 = ProjectId(789);
|
|
|
|
let src = GetResponse {
|
|
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()
|
|
}),
|
|
}),
|
|
]),
|
|
TimeSlice(vec![AnalyticsData::Project(ProjectAnalytics {
|
|
source_project: test_project_3,
|
|
metrics: ProjectMetrics::Revenue(ProjectRevenue {
|
|
user_id: None,
|
|
revenue: Decimal::new(20000, 2),
|
|
}),
|
|
})]),
|
|
],
|
|
projects: HashMap::new(),
|
|
users: HashMap::new(),
|
|
project_events: vec![],
|
|
};
|
|
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",
|
|
}
|
|
]
|
|
],
|
|
"projects": {},
|
|
"users": {},
|
|
"project_events": []
|
|
});
|
|
|
|
assert_eq!(serde_json::to_value(src).unwrap(), target);
|
|
}
|
|
}
|