mirror of
https://github.com/modrinth/code.git
synced 2026-08-27 18:14:49 +00:00
Implement analytics marker events (#6090)
* Analytics events * prepare * change route prefix * update route return * Add mod launcher analytics * more UA strings * fix ci * caching on analytics events * Return parent modpack versions for playtime queries * sqlx prepare * fmt * dummy fixtures
This commit is contained in:
@@ -0,0 +1,140 @@
|
||||
use chrono::{DateTime, Utc};
|
||||
use futures::{StreamExt, TryStreamExt};
|
||||
use sqlx::types::Json;
|
||||
|
||||
use crate::{
|
||||
database::{
|
||||
models::{DBAnalyticsEventId, DatabaseError},
|
||||
redis::RedisPool,
|
||||
},
|
||||
models::v3::analytics_event::AnalyticsEventMeta,
|
||||
};
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
const ANALYTICS_EVENTS_NAMESPACE: &str = "analytics_events";
|
||||
const ANALYTICS_EVENTS_ALL_KEY: &str = "all";
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct DBAnalyticsEvent {
|
||||
pub id: DBAnalyticsEventId,
|
||||
pub meta: AnalyticsEventMeta,
|
||||
pub starts: DateTime<Utc>,
|
||||
pub ends: DateTime<Utc>,
|
||||
}
|
||||
|
||||
impl DBAnalyticsEvent {
|
||||
pub async fn insert(
|
||||
&self,
|
||||
exec: impl crate::database::Executor<'_, Database = sqlx::Postgres>,
|
||||
) -> Result<(), DatabaseError> {
|
||||
sqlx::query!(
|
||||
"
|
||||
INSERT INTO analytics_events (id, meta, starts, ends)
|
||||
VALUES ($1, $2, $3, $4)
|
||||
",
|
||||
self.id as DBAnalyticsEventId,
|
||||
sqlx::types::Json(&self.meta) as Json<&AnalyticsEventMeta>,
|
||||
self.starts,
|
||||
self.ends,
|
||||
)
|
||||
.execute(exec)
|
||||
.await?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn update(
|
||||
&self,
|
||||
exec: impl crate::database::Executor<'_, Database = sqlx::Postgres>,
|
||||
) -> Result<bool, DatabaseError> {
|
||||
let result = sqlx::query!(
|
||||
"
|
||||
UPDATE analytics_events
|
||||
SET meta = $2, starts = $3, ends = $4
|
||||
WHERE id = $1
|
||||
",
|
||||
self.id as DBAnalyticsEventId,
|
||||
sqlx::types::Json(&self.meta) as Json<&AnalyticsEventMeta>,
|
||||
self.starts,
|
||||
self.ends,
|
||||
)
|
||||
.execute(exec)
|
||||
.await?;
|
||||
|
||||
Ok(result.rows_affected() > 0)
|
||||
}
|
||||
|
||||
pub async fn remove(
|
||||
id: DBAnalyticsEventId,
|
||||
exec: impl crate::database::Executor<'_, Database = sqlx::Postgres>,
|
||||
) -> Result<bool, DatabaseError> {
|
||||
let result = sqlx::query!(
|
||||
"
|
||||
DELETE FROM analytics_events
|
||||
WHERE id = $1
|
||||
",
|
||||
id as DBAnalyticsEventId,
|
||||
)
|
||||
.execute(exec)
|
||||
.await?;
|
||||
|
||||
Ok(result.rows_affected() > 0)
|
||||
}
|
||||
|
||||
pub async fn get_all(
|
||||
exec: impl crate::database::Executor<'_, Database = sqlx::Postgres>,
|
||||
redis: &RedisPool,
|
||||
) -> Result<Vec<DBAnalyticsEvent>, DatabaseError> {
|
||||
let mut redis = redis.connect().await?;
|
||||
|
||||
if let Some(events) = redis
|
||||
.get_deserialized_from_json(
|
||||
ANALYTICS_EVENTS_NAMESPACE,
|
||||
ANALYTICS_EVENTS_ALL_KEY,
|
||||
)
|
||||
.await?
|
||||
{
|
||||
return Ok(events);
|
||||
}
|
||||
|
||||
let events = sqlx::query!(
|
||||
r#"
|
||||
SELECT id, meta AS "meta: Json<AnalyticsEventMeta>", starts, ends
|
||||
FROM analytics_events
|
||||
ORDER BY starts DESC
|
||||
"#
|
||||
)
|
||||
.fetch(exec)
|
||||
.map(|record| {
|
||||
let record = record?;
|
||||
|
||||
Ok::<_, DatabaseError>(DBAnalyticsEvent {
|
||||
id: DBAnalyticsEventId(record.id),
|
||||
meta: record.meta.0,
|
||||
starts: record.starts,
|
||||
ends: record.ends,
|
||||
})
|
||||
})
|
||||
.try_collect::<Vec<_>>()
|
||||
.await?;
|
||||
|
||||
redis
|
||||
.set_serialized_to_json(
|
||||
ANALYTICS_EVENTS_NAMESPACE,
|
||||
ANALYTICS_EVENTS_ALL_KEY,
|
||||
&events,
|
||||
None,
|
||||
)
|
||||
.await?;
|
||||
|
||||
Ok(events)
|
||||
}
|
||||
|
||||
pub async fn clear_cache(redis: &RedisPool) -> Result<(), DatabaseError> {
|
||||
let mut redis = redis.connect().await?;
|
||||
redis
|
||||
.delete(ANALYTICS_EVENTS_NAMESPACE, ANALYTICS_EVENTS_ALL_KEY)
|
||||
.await?;
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
@@ -1,12 +1,12 @@
|
||||
use super::DatabaseError;
|
||||
use crate::database::PgTransaction;
|
||||
use crate::models::ids::{
|
||||
AffiliateCodeId, ChargeId, CollectionId, FileId, ImageId, NotificationId,
|
||||
OAuthAccessTokenId, OAuthClientAuthorizationId, OAuthClientId,
|
||||
OAuthRedirectUriId, OrganizationId, PatId, PayoutId, ProductId,
|
||||
ProductPriceId, ProjectId, ReportId, SessionId, SharedInstanceId,
|
||||
SharedInstanceVersionId, TeamId, TeamMemberId, ThreadId, ThreadMessageId,
|
||||
UserSubscriptionId, VersionId,
|
||||
AffiliateCodeId, AnalyticsEventId, ChargeId, CollectionId, FileId, ImageId,
|
||||
NotificationId, OAuthAccessTokenId, OAuthClientAuthorizationId,
|
||||
OAuthClientId, OAuthRedirectUriId, OrganizationId, PatId, PayoutId,
|
||||
ProductId, ProductPriceId, ProjectId, ReportId, SessionId,
|
||||
SharedInstanceId, SharedInstanceVersionId, TeamId, TeamMemberId, ThreadId,
|
||||
ThreadMessageId, UserSubscriptionId, VersionId,
|
||||
};
|
||||
use ariadne::ids::base62_impl::to_base62;
|
||||
use ariadne::ids::{UserId, random_base62_rng, random_base62_rng_range};
|
||||
@@ -269,6 +269,10 @@ db_id_interface!(
|
||||
AffiliateCodeId,
|
||||
generator: generate_affiliate_code_id @ "affiliate_codes",
|
||||
);
|
||||
db_id_interface!(
|
||||
AnalyticsEventId,
|
||||
generator: generate_analytics_event_id @ "analytics_events",
|
||||
);
|
||||
|
||||
id_type!(CategoryId as i32);
|
||||
id_type!(GameId as i32);
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
use thiserror::Error;
|
||||
|
||||
pub mod affiliate_code_item;
|
||||
pub mod analytics_event_item;
|
||||
pub mod categories;
|
||||
pub mod charge_item;
|
||||
pub mod collection_item;
|
||||
@@ -44,6 +45,7 @@ pub mod users_subscriptions_credits;
|
||||
pub mod version_item;
|
||||
|
||||
pub use affiliate_code_item::DBAffiliateCode;
|
||||
pub use analytics_event_item::DBAnalyticsEvent;
|
||||
pub use collection_item::DBCollection;
|
||||
pub use ids::*;
|
||||
pub use image_item::DBImage;
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
use std::collections::HashSet;
|
||||
|
||||
use chrono::{DateTime, Utc};
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::models::ids::AnalyticsEventId;
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, utoipa::ToSchema)]
|
||||
pub struct AnalyticsEvent {
|
||||
pub id: AnalyticsEventId,
|
||||
#[serde(flatten)]
|
||||
pub meta: AnalyticsEventMeta,
|
||||
pub starts: DateTime<Utc>,
|
||||
pub ends: DateTime<Utc>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, utoipa::ToSchema)]
|
||||
pub struct AnalyticsEventMeta {
|
||||
#[serde(default)]
|
||||
pub title: String,
|
||||
#[serde(default)]
|
||||
pub announcement_url: Option<String>,
|
||||
#[serde(default)]
|
||||
pub for_metric_kind: HashSet<MetricKind>,
|
||||
}
|
||||
|
||||
#[derive(
|
||||
Debug,
|
||||
Clone,
|
||||
Copy,
|
||||
PartialEq,
|
||||
Eq,
|
||||
Hash,
|
||||
Serialize,
|
||||
Deserialize,
|
||||
utoipa::ToSchema,
|
||||
)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum MetricKind {
|
||||
Views,
|
||||
Downloads,
|
||||
Playtime,
|
||||
Revenue,
|
||||
}
|
||||
|
||||
impl From<crate::database::models::DBAnalyticsEvent> for AnalyticsEvent {
|
||||
fn from(data: crate::database::models::DBAnalyticsEvent) -> Self {
|
||||
Self {
|
||||
id: data.id.into(),
|
||||
meta: data.meta,
|
||||
starts: data.starts,
|
||||
ends: data.ends,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -26,3 +26,4 @@ base62_id!(ThreadMessageId);
|
||||
base62_id!(UserSubscriptionId);
|
||||
base62_id!(VersionId);
|
||||
base62_id!(AffiliateCodeId);
|
||||
base62_id!(AnalyticsEventId);
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
pub mod affiliate_code;
|
||||
pub mod analytics;
|
||||
pub mod analytics_event;
|
||||
pub mod billing;
|
||||
pub mod collections;
|
||||
pub mod ids;
|
||||
|
||||
@@ -0,0 +1,201 @@
|
||||
use actix_web::{HttpRequest, delete, get, patch, post, web};
|
||||
use chrono::{DateTime, Utc};
|
||||
use eyre::eyre;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::{
|
||||
auth::get_user_from_headers,
|
||||
database::{
|
||||
PgPool,
|
||||
models::{
|
||||
DBAnalyticsEvent, DBAnalyticsEventId, generate_analytics_event_id,
|
||||
},
|
||||
redis::RedisPool,
|
||||
},
|
||||
models::{
|
||||
ids::AnalyticsEventId,
|
||||
pats::Scopes,
|
||||
v3::analytics_event::{AnalyticsEvent, AnalyticsEventMeta},
|
||||
},
|
||||
queue::session::AuthQueue,
|
||||
routes::ApiError,
|
||||
util::error::Context,
|
||||
};
|
||||
|
||||
pub fn config(cfg: &mut utoipa_actix_web::service_config::ServiceConfig) {
|
||||
cfg.service(analytics_events_get)
|
||||
.service(analytics_event_create)
|
||||
.service(analytics_event_edit)
|
||||
.service(analytics_event_delete);
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, utoipa::ToSchema)]
|
||||
pub struct AnalyticsEventUpsert {
|
||||
#[serde(flatten)]
|
||||
pub meta: AnalyticsEventMeta,
|
||||
pub starts: DateTime<Utc>,
|
||||
pub ends: DateTime<Utc>,
|
||||
}
|
||||
|
||||
/// Fetches all analytics events.
|
||||
#[utoipa::path(responses((status = OK, body = Vec<AnalyticsEvent>)))]
|
||||
#[get("")]
|
||||
pub async fn analytics_events_get(
|
||||
pool: web::Data<PgPool>,
|
||||
redis: web::Data<RedisPool>,
|
||||
) -> Result<web::Json<Vec<AnalyticsEvent>>, ApiError> {
|
||||
let events = DBAnalyticsEvent::get_all(&**pool, &redis)
|
||||
.await
|
||||
.wrap_internal_err("failed to fetch analytics events")?
|
||||
.into_iter()
|
||||
.map(AnalyticsEvent::from)
|
||||
.collect();
|
||||
|
||||
Ok(web::Json(events))
|
||||
}
|
||||
|
||||
/// Creates an analytics event.
|
||||
#[utoipa::path(responses((status = OK, body = AnalyticsEvent)))]
|
||||
#[post("")]
|
||||
pub async fn analytics_event_create(
|
||||
req: HttpRequest,
|
||||
event: web::Json<AnalyticsEventUpsert>,
|
||||
pool: web::Data<PgPool>,
|
||||
redis: web::Data<RedisPool>,
|
||||
session_queue: web::Data<AuthQueue>,
|
||||
) -> Result<web::Json<AnalyticsEvent>, ApiError> {
|
||||
let user = get_user_from_headers(
|
||||
&req,
|
||||
&**pool,
|
||||
&redis,
|
||||
&session_queue,
|
||||
Scopes::empty(),
|
||||
)
|
||||
.await?
|
||||
.1;
|
||||
|
||||
if !user.role.is_admin() {
|
||||
return Err(ApiError::Auth(eyre!(
|
||||
"you do not have permission to manage analytics events"
|
||||
)));
|
||||
}
|
||||
|
||||
let mut transaction = pool
|
||||
.begin()
|
||||
.await
|
||||
.wrap_internal_err("failed to begin transaction")?;
|
||||
let id = generate_analytics_event_id(&mut transaction)
|
||||
.await
|
||||
.wrap_internal_err("failed to generate analytics event ID")?;
|
||||
|
||||
let event = DBAnalyticsEvent {
|
||||
id,
|
||||
meta: event.meta.clone(),
|
||||
starts: event.starts,
|
||||
ends: event.ends,
|
||||
};
|
||||
event
|
||||
.insert(&mut transaction)
|
||||
.await
|
||||
.wrap_internal_err("failed to insert analytics event")?;
|
||||
|
||||
transaction
|
||||
.commit()
|
||||
.await
|
||||
.wrap_internal_err("failed to commit transaction")?;
|
||||
DBAnalyticsEvent::clear_cache(&redis)
|
||||
.await
|
||||
.wrap_internal_err("failed to clear analytics event cache")?;
|
||||
|
||||
Ok(web::Json(event.into()))
|
||||
}
|
||||
|
||||
/// Edits an analytics event.
|
||||
#[utoipa::path(responses((status = OK, body = AnalyticsEvent)))]
|
||||
#[patch("/{id}")]
|
||||
pub async fn analytics_event_edit(
|
||||
req: HttpRequest,
|
||||
id: web::Path<(AnalyticsEventId,)>,
|
||||
event: web::Json<AnalyticsEventUpsert>,
|
||||
pool: web::Data<PgPool>,
|
||||
redis: web::Data<RedisPool>,
|
||||
session_queue: web::Data<AuthQueue>,
|
||||
) -> Result<web::Json<AnalyticsEvent>, ApiError> {
|
||||
let user = get_user_from_headers(
|
||||
&req,
|
||||
&**pool,
|
||||
&redis,
|
||||
&session_queue,
|
||||
Scopes::empty(),
|
||||
)
|
||||
.await?
|
||||
.1;
|
||||
|
||||
if !user.role.is_admin() {
|
||||
return Err(ApiError::Auth(eyre!(
|
||||
"you do not have permission to manage analytics events"
|
||||
)));
|
||||
}
|
||||
|
||||
let event = DBAnalyticsEvent {
|
||||
id: DBAnalyticsEventId::from(id.into_inner().0),
|
||||
meta: event.meta.clone(),
|
||||
starts: event.starts,
|
||||
ends: event.ends,
|
||||
};
|
||||
|
||||
let updated = event
|
||||
.update(&**pool)
|
||||
.await
|
||||
.wrap_internal_err("failed to update analytics event")?;
|
||||
if !updated {
|
||||
return Err(ApiError::NotFound);
|
||||
}
|
||||
DBAnalyticsEvent::clear_cache(&redis)
|
||||
.await
|
||||
.wrap_internal_err("failed to clear analytics event cache")?;
|
||||
|
||||
Ok(web::Json(event.into()))
|
||||
}
|
||||
|
||||
/// Deletes an analytics event.
|
||||
#[utoipa::path(responses((status = NO_CONTENT)))]
|
||||
#[delete("/{id}")]
|
||||
pub async fn analytics_event_delete(
|
||||
req: HttpRequest,
|
||||
id: web::Path<(AnalyticsEventId,)>,
|
||||
pool: web::Data<PgPool>,
|
||||
redis: web::Data<RedisPool>,
|
||||
session_queue: web::Data<AuthQueue>,
|
||||
) -> Result<(), ApiError> {
|
||||
let user = get_user_from_headers(
|
||||
&req,
|
||||
&**pool,
|
||||
&redis,
|
||||
&session_queue,
|
||||
Scopes::empty(),
|
||||
)
|
||||
.await?
|
||||
.1;
|
||||
|
||||
if !user.role.is_admin() {
|
||||
return Err(ApiError::Auth(eyre!(
|
||||
"you do not have permission to manage analytics events"
|
||||
)));
|
||||
}
|
||||
|
||||
let deleted = DBAnalyticsEvent::remove(
|
||||
DBAnalyticsEventId::from(id.into_inner().0),
|
||||
&**pool,
|
||||
)
|
||||
.await
|
||||
.wrap_internal_err("failed to delete analytics event")?;
|
||||
if !deleted {
|
||||
return Err(ApiError::NotFound);
|
||||
}
|
||||
DBAnalyticsEvent::clear_cache(&redis)
|
||||
.await
|
||||
.wrap_internal_err("failed to clear analytics event cache")?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
@@ -9,15 +9,16 @@
|
||||
|
||||
mod old;
|
||||
|
||||
use std::num::NonZeroU64;
|
||||
use std::{num::NonZeroU64, sync::LazyLock};
|
||||
|
||||
use crate::database::PgPool;
|
||||
use actix_web::{HttpRequest, post, web};
|
||||
use chrono::{DateTime, TimeDelta, Utc};
|
||||
use eyre::eyre;
|
||||
use futures::StreamExt;
|
||||
use regex::Regex;
|
||||
use rust_decimal::Decimal;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde::{Deserialize, Deserializer, Serialize, Serializer, de::Error as _};
|
||||
|
||||
use crate::{
|
||||
auth::{AuthenticationError, get_user_from_headers},
|
||||
@@ -167,8 +168,8 @@ pub enum ProjectDownloadsField {
|
||||
VersionId,
|
||||
/// Referrer domain which linked to this project.
|
||||
Domain,
|
||||
/// Modrinth site path which was visited, e.g. `/mod/foo`.
|
||||
SitePath,
|
||||
/// Normalized user agent used to download this project.
|
||||
UserAgent,
|
||||
/// Whether these downloads were monetized or not.
|
||||
Monetized,
|
||||
/// What country these downloads came from.
|
||||
@@ -325,9 +326,9 @@ pub struct ProjectDownloads {
|
||||
/// [`ProjectDownloadsField::Domain`].
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
domain: Option<String>,
|
||||
/// [`ProjectDownloadsField::SitePath`].
|
||||
/// [`ProjectDownloadsField::UserAgent`].
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
site_path: Option<String>,
|
||||
user_agent: Option<DownloadSource>,
|
||||
/// [`ProjectDownloadsField::VersionId`].
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
version_id: Option<VersionId>,
|
||||
@@ -350,6 +351,56 @@ pub struct ProjectDownloads {
|
||||
downloads: u64,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, utoipa::ToSchema)]
|
||||
pub enum DownloadSource {
|
||||
Website,
|
||||
ModrinthApp,
|
||||
ModrinthHosting,
|
||||
ModrinthMaven,
|
||||
Other,
|
||||
Named(String),
|
||||
}
|
||||
|
||||
impl Serialize for DownloadSource {
|
||||
fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
|
||||
where
|
||||
S: Serializer,
|
||||
{
|
||||
match self {
|
||||
Self::Named(name) => serializer.serialize_str(name),
|
||||
Self::Website => serializer.serialize_str("website"),
|
||||
Self::ModrinthApp => serializer.serialize_str("modrinth_app"),
|
||||
Self::ModrinthHosting => {
|
||||
serializer.serialize_str("modrinth_hosting")
|
||||
}
|
||||
Self::ModrinthMaven => serializer.serialize_str("modrinth_maven"),
|
||||
Self::Other => serializer.serialize_str("other"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl<'de> Deserialize<'de> for DownloadSource {
|
||||
fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
|
||||
where
|
||||
D: Deserializer<'de>,
|
||||
{
|
||||
let source = String::deserialize(deserializer)?;
|
||||
Ok(match source.as_str() {
|
||||
"website" => Self::Website,
|
||||
"modrinth_app" => Self::ModrinthApp,
|
||||
"modrinth_hosting" => Self::ModrinthHosting,
|
||||
"modrinth_maven" => Self::ModrinthMaven,
|
||||
"other" => Self::Other,
|
||||
_ if !source.is_empty() => Self::Named(source),
|
||||
_ => {
|
||||
return Err(D::Error::custom(
|
||||
"download source cannot be empty",
|
||||
));
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/// [`ReturnMetrics::project_playtime`].
|
||||
#[derive(Debug, Clone, Default, Serialize, Deserialize, utoipa::ToSchema)]
|
||||
pub struct ProjectPlaytime {
|
||||
@@ -476,7 +527,7 @@ mod query {
|
||||
pub bucket: u64,
|
||||
pub project_id: DBProjectId,
|
||||
pub domain: String,
|
||||
pub site_path: String,
|
||||
pub user_agent: String,
|
||||
pub version_id: DBVersionId,
|
||||
pub monetized: i8,
|
||||
pub country: String,
|
||||
@@ -489,7 +540,7 @@ mod query {
|
||||
pub const DOWNLOADS: &str = {
|
||||
const USE_PROJECT_ID: &str = "{use_project_id: Bool}";
|
||||
const USE_DOMAIN: &str = "{use_domain: Bool}";
|
||||
const USE_SITE_PATH: &str = "{use_site_path: Bool}";
|
||||
const USE_USER_AGENT: &str = "{use_user_agent: Bool}";
|
||||
const USE_VERSION_ID: &str = "{use_version_id: Bool}";
|
||||
const USE_MONETIZED: &str = "{use_monetized: Bool}";
|
||||
const USE_COUNTRY: &str = "{use_country: Bool}";
|
||||
@@ -502,7 +553,7 @@ mod query {
|
||||
widthBucket(toUnixTimestamp(recorded), {TIME_RANGE_START}, {TIME_RANGE_END}, {TIME_SLICES}) AS bucket,
|
||||
if({USE_PROJECT_ID}, project_id, 0) AS project_id,
|
||||
if({USE_DOMAIN}, domain, '') AS domain,
|
||||
if({USE_SITE_PATH}, site_path, '') AS site_path,
|
||||
if({USE_USER_AGENT}, user_agent, '') AS user_agent,
|
||||
if({USE_VERSION_ID}, version_id, 0) AS version_id,
|
||||
if({USE_MONETIZED}, CAST(user_id != 0 AS Int8), -1) AS monetized,
|
||||
if({USE_COUNTRY}, country, '') AS country,
|
||||
@@ -517,7 +568,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, monetized, country, reason, game_version, loader"
|
||||
GROUP BY bucket, project_id, domain, user_agent, version_id, monetized, country, reason, game_version, loader"
|
||||
)
|
||||
};
|
||||
|
||||
@@ -538,23 +589,48 @@ mod query {
|
||||
const USE_LOADER: &str = "{use_loader: Bool}";
|
||||
const USE_GAME_VERSION: &str = "{use_game_version: Bool}";
|
||||
const USE_COUNTRY: &str = "{use_country: Bool}";
|
||||
const PARENT_VERSION_IDS: &str = "{parent_version_ids: Array(UInt64)}";
|
||||
const PARENT_VERSION_PROJECT_IDS: &str =
|
||||
"{parent_version_project_ids: Array(UInt64)}";
|
||||
|
||||
formatcp!(
|
||||
"SELECT
|
||||
widthBucket(toUnixTimestamp(recorded), {TIME_RANGE_START}, {TIME_RANGE_END}, {TIME_SLICES}) AS bucket,
|
||||
if({USE_PROJECT_ID}, project_id, 0) AS project_id,
|
||||
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,
|
||||
bucket,
|
||||
if({USE_PROJECT_ID}, source_project_id, 0) AS project_id,
|
||||
version_id,
|
||||
loader,
|
||||
game_version,
|
||||
country,
|
||||
SUM(seconds) AS seconds
|
||||
FROM playtime
|
||||
WHERE
|
||||
recorded BETWEEN {TIME_RANGE_START} AND {TIME_RANGE_END}
|
||||
-- make sure that the REAL project id is included,
|
||||
-- not the possibly-zero one,
|
||||
-- by using `playtime.project_id` instead of `project_id`
|
||||
AND playtime.project_id IN {PROJECT_IDS}
|
||||
FROM (
|
||||
SELECT
|
||||
widthBucket(toUnixTimestamp(recorded), {TIME_RANGE_START}, {TIME_RANGE_END}, {TIME_SLICES}) AS bucket,
|
||||
project_id AS source_project_id,
|
||||
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,
|
||||
seconds
|
||||
FROM playtime
|
||||
WHERE
|
||||
recorded BETWEEN {TIME_RANGE_START} AND {TIME_RANGE_END}
|
||||
AND playtime.project_id IN {PROJECT_IDS}
|
||||
|
||||
UNION ALL
|
||||
|
||||
SELECT
|
||||
widthBucket(toUnixTimestamp(recorded), {TIME_RANGE_START}, {TIME_RANGE_END}, {TIME_SLICES}) AS bucket,
|
||||
transform(parent, {PARENT_VERSION_IDS}, {PARENT_VERSION_PROJECT_IDS}) AS source_project_id,
|
||||
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,
|
||||
seconds
|
||||
FROM playtime
|
||||
WHERE
|
||||
recorded BETWEEN {TIME_RANGE_START} AND {TIME_RANGE_END}
|
||||
AND parent IN {PARENT_VERSION_IDS}
|
||||
)
|
||||
GROUP BY bucket, project_id, version_id, loader, game_version, country"
|
||||
)
|
||||
};
|
||||
@@ -672,6 +748,27 @@ pub async fn fetch_analytics(
|
||||
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_project_ids = parent_versions
|
||||
.iter()
|
||||
.map(|version| DBProjectId(version.mod_id))
|
||||
.collect::<Vec<_>>();
|
||||
|
||||
let affiliate_code_ids =
|
||||
DBAffiliateCode::get_by_affiliate(user.id.into(), &**pool)
|
||||
.await?
|
||||
@@ -684,6 +781,8 @@ pub async fn fetch_analytics(
|
||||
req: &req,
|
||||
time_slices: &mut time_slices,
|
||||
project_ids: &project_ids,
|
||||
parent_version_ids: &parent_version_ids,
|
||||
parent_version_project_ids: &parent_version_project_ids,
|
||||
affiliate_code_ids: &affiliate_code_ids,
|
||||
};
|
||||
|
||||
@@ -737,7 +836,7 @@ pub async fn fetch_analytics(
|
||||
&[
|
||||
("use_project_id", uses(F::ProjectId)),
|
||||
("use_domain", uses(F::Domain)),
|
||||
("use_site_path", uses(F::SitePath)),
|
||||
("use_user_agent", uses(F::UserAgent)),
|
||||
("use_version_id", uses(F::VersionId)),
|
||||
("use_monetized", uses(F::Monetized)),
|
||||
("use_country", uses(F::Country)),
|
||||
@@ -756,7 +855,11 @@ pub async fn fetch_analytics(
|
||||
source_project: row.project_id.into(),
|
||||
metrics: ProjectMetrics::Downloads(ProjectDownloads {
|
||||
domain: none_if_empty(row.domain),
|
||||
site_path: none_if_empty(row.site_path),
|
||||
user_agent: if uses(F::UserAgent) {
|
||||
normalize_download_source(&row.user_agent)
|
||||
} else {
|
||||
None
|
||||
},
|
||||
version_id: none_if_zero_version_id(row.version_id),
|
||||
monetized: match row.monetized {
|
||||
0 => Some(false),
|
||||
@@ -840,9 +943,6 @@ pub async fn fetch_analytics(
|
||||
return Err(AuthenticationError::InvalidCredentials.into());
|
||||
}
|
||||
|
||||
let project_id_values =
|
||||
project_ids.iter().map(|id| id.0).collect::<Vec<_>>();
|
||||
|
||||
let mut rows = sqlx::query!(
|
||||
"SELECT
|
||||
WIDTH_BUCKET(
|
||||
@@ -1016,6 +1116,76 @@ fn none_if_zero_version_id(v: DBVersionId) -> Option<VersionId> {
|
||||
if v.0 == 0 { None } else { Some(v.into()) }
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy)]
|
||||
enum DownloadSourcePattern {
|
||||
Named(&'static str),
|
||||
Website,
|
||||
ModrinthApp,
|
||||
ModrinthHosting,
|
||||
ModrinthMaven,
|
||||
}
|
||||
|
||||
impl DownloadSourcePattern {
|
||||
fn into_source(self) -> DownloadSource {
|
||||
match self {
|
||||
Self::Named(name) => DownloadSource::Named(name.into()),
|
||||
Self::Website => DownloadSource::Website,
|
||||
Self::ModrinthApp => DownloadSource::ModrinthApp,
|
||||
Self::ModrinthHosting => DownloadSource::ModrinthHosting,
|
||||
Self::ModrinthMaven => DownloadSource::ModrinthMaven,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
static DOWNLOAD_SOURCE_PATTERNS: LazyLock<Vec<(Regex, DownloadSourcePattern)>> =
|
||||
LazyLock::new(|| {
|
||||
use DownloadSourcePattern as P;
|
||||
|
||||
[
|
||||
(r"^modrinth/kyros/", P::ModrinthHosting),
|
||||
(r"^modrinth/theseus/", P::ModrinthApp),
|
||||
(r"^(Gradle/|Apache-Maven/)", P::ModrinthMaven),
|
||||
(r"^MultiMC/", P::Named("MultiMC")),
|
||||
(r"^PrismLauncher/", P::Named("Prism Launcher")),
|
||||
(r"^PolyMC/", P::Named("PolyMC")),
|
||||
(r"^FCL/", P::Named("FCL")),
|
||||
(r"^PCL2/", P::Named("PCL2")),
|
||||
(r"^HMCL/", P::Named("HMCL")),
|
||||
(r"^Lunar Client Launcher", P::Named("Lunar Client")),
|
||||
(r"^PojavLauncher", P::Named("PojavLauncher")),
|
||||
(r"^ATLauncher/", P::Named("ATLauncher")),
|
||||
(r"FeatherLauncher/", P::Named("Feather Client")),
|
||||
(
|
||||
r"^FeatherMC/Feather Client Rust Launcher/",
|
||||
P::Named("Feather Client"),
|
||||
),
|
||||
(r"Feather/[0-9A-Za-z]+", P::Named("Feather Client")),
|
||||
(r"^PandoraLauncher/", P::Named("Pandora Launcher")),
|
||||
(r"^unsup", P::Named("unsup")),
|
||||
(r"nothub/mrpack-install", P::Named("mrpack-install")),
|
||||
(r"^(packwiz-installer|packwiz/)", P::Named("Packwiz")),
|
||||
(
|
||||
r"^(Mozilla/|Chrome/|Chromium/|Firefox/|Safari/|AppleWebKit/|Edg/|OPR/)",
|
||||
P::Website,
|
||||
),
|
||||
]
|
||||
.into_iter()
|
||||
.map(|(pattern, source)| {
|
||||
(
|
||||
Regex::new(pattern)
|
||||
.expect("download source regex should be valid"),
|
||||
source,
|
||||
)
|
||||
})
|
||||
.collect()
|
||||
});
|
||||
|
||||
fn normalize_download_source(user_agent: &str) -> Option<DownloadSource> {
|
||||
DOWNLOAD_SOURCE_PATTERNS.iter().find_map(|(regex, source)| {
|
||||
regex.is_match(user_agent).then(|| source.into_source())
|
||||
})
|
||||
}
|
||||
|
||||
fn condense_country(country: String, count: u64) -> String {
|
||||
// Every country under '50' (view or downloads) should be condensed into 'XX'
|
||||
if count < 50 {
|
||||
@@ -1030,6 +1200,8 @@ struct QueryClickhouseContext<'a> {
|
||||
req: &'a GetRequest,
|
||||
time_slices: &'a mut [TimeSlice],
|
||||
project_ids: &'a [DBProjectId],
|
||||
parent_version_ids: &'a [DBVersionId],
|
||||
parent_version_project_ids: &'a [DBProjectId],
|
||||
affiliate_code_ids: &'a [DBAffiliateCodeId],
|
||||
}
|
||||
|
||||
@@ -1051,6 +1223,8 @@ where
|
||||
.param("time_range_end", cx.req.time_range.end.timestamp())
|
||||
.param("time_slices", cx.time_slices.len())
|
||||
.param("project_ids", cx.project_ids)
|
||||
.param("parent_version_ids", cx.parent_version_ids)
|
||||
.param("parent_version_project_ids", cx.parent_version_project_ids)
|
||||
.param("affiliate_code_ids", cx.affiliate_code_ids);
|
||||
for (param_name, used) in use_columns {
|
||||
query = query.param(param_name, used)
|
||||
@@ -1170,6 +1344,51 @@ mod tests {
|
||||
|
||||
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 response_format() {
|
||||
let test_project_1 = ProjectId(123);
|
||||
|
||||
@@ -3,6 +3,7 @@ use crate::util::cors::default_cors;
|
||||
use actix_web::{HttpResponse, web};
|
||||
use serde_json::json;
|
||||
|
||||
pub mod analytics_event;
|
||||
pub mod analytics_get;
|
||||
pub mod collections;
|
||||
pub mod friends;
|
||||
@@ -59,6 +60,11 @@ pub fn utoipa_config(
|
||||
.wrap(default_cors())
|
||||
.configure(analytics_get::config),
|
||||
);
|
||||
cfg.service(
|
||||
utoipa_actix_web::scope("/v3/analytics-event")
|
||||
.wrap(default_cors())
|
||||
.configure(analytics_event::config),
|
||||
);
|
||||
cfg.service(
|
||||
utoipa_actix_web::scope("/v3/payout")
|
||||
.wrap(default_cors())
|
||||
|
||||
Reference in New Issue
Block a user