From 5da0591265d551ba70bd4b9a02d14f98518060b4 Mon Sep 17 00:00:00 2001 From: aecsocket Date: Wed, 13 May 2026 13:03:58 +0100 Subject: [PATCH 01/12] Analytics events --- .../20260513111617_analytics_events.sql | 6 + .../database/models/analytics_event_item.rs | 101 +++++++++ apps/labrinth/src/database/models/ids.rs | 16 +- apps/labrinth/src/database/models/mod.rs | 2 + .../labrinth/src/models/v3/analytics_event.rs | 30 +++ apps/labrinth/src/models/v3/ids.rs | 1 + apps/labrinth/src/models/v3/mod.rs | 1 + .../labrinth/src/routes/v3/analytics_event.rs | 203 ++++++++++++++++++ apps/labrinth/src/routes/v3/mod.rs | 4 +- 9 files changed, 357 insertions(+), 7 deletions(-) create mode 100644 apps/labrinth/migrations/20260513111617_analytics_events.sql create mode 100644 apps/labrinth/src/database/models/analytics_event_item.rs create mode 100644 apps/labrinth/src/models/v3/analytics_event.rs create mode 100644 apps/labrinth/src/routes/v3/analytics_event.rs diff --git a/apps/labrinth/migrations/20260513111617_analytics_events.sql b/apps/labrinth/migrations/20260513111617_analytics_events.sql new file mode 100644 index 0000000000..d45cda977d --- /dev/null +++ b/apps/labrinth/migrations/20260513111617_analytics_events.sql @@ -0,0 +1,6 @@ +create table analytics_events ( + id bigint primary key, + meta jsonb not null, + starts timestamptz not null, + ends timestamptz not null +); diff --git a/apps/labrinth/src/database/models/analytics_event_item.rs b/apps/labrinth/src/database/models/analytics_event_item.rs new file mode 100644 index 0000000000..a5e72541f7 --- /dev/null +++ b/apps/labrinth/src/database/models/analytics_event_item.rs @@ -0,0 +1,101 @@ +use chrono::{DateTime, Utc}; +use futures::{StreamExt, TryStreamExt}; +use sqlx::types::Json; + +use crate::{ + database::models::{DBAnalyticsEventId, DatabaseError}, + models::v3::analytics_event::AnalyticsEventMeta, +}; + +#[derive(Debug, Clone)] +pub struct DBAnalyticsEvent { + pub id: DBAnalyticsEventId, + pub meta: AnalyticsEventMeta, + pub starts: DateTime, + pub ends: DateTime, +} + +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 { + 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 { + 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>, + ) -> Result, DatabaseError> { + sqlx::query!( + " + SELECT id, meta AS \"meta: Json\", 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::>() + .await + } +} diff --git a/apps/labrinth/src/database/models/ids.rs b/apps/labrinth/src/database/models/ids.rs index 646d5e4cc2..d88c601805 100644 --- a/apps/labrinth/src/database/models/ids.rs +++ b/apps/labrinth/src/database/models/ids.rs @@ -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); diff --git a/apps/labrinth/src/database/models/mod.rs b/apps/labrinth/src/database/models/mod.rs index 0db87c5082..ae7547918c 100644 --- a/apps/labrinth/src/database/models/mod.rs +++ b/apps/labrinth/src/database/models/mod.rs @@ -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; @@ -43,6 +44,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; diff --git a/apps/labrinth/src/models/v3/analytics_event.rs b/apps/labrinth/src/models/v3/analytics_event.rs new file mode 100644 index 0000000000..809075d526 --- /dev/null +++ b/apps/labrinth/src/models/v3/analytics_event.rs @@ -0,0 +1,30 @@ +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, + pub ends: DateTime, +} + +#[derive(Debug, Clone, Serialize, Deserialize, utoipa::ToSchema)] +pub struct AnalyticsEventMeta { + pub title: String, + pub announcement_url: Option, +} + +impl From for AnalyticsEvent { + fn from(data: crate::database::models::DBAnalyticsEvent) -> Self { + Self { + id: data.id.into(), + meta: data.meta, + starts: data.starts, + ends: data.ends, + } + } +} diff --git a/apps/labrinth/src/models/v3/ids.rs b/apps/labrinth/src/models/v3/ids.rs index 7cb162ec27..519c0d4765 100644 --- a/apps/labrinth/src/models/v3/ids.rs +++ b/apps/labrinth/src/models/v3/ids.rs @@ -26,3 +26,4 @@ base62_id!(ThreadMessageId); base62_id!(UserSubscriptionId); base62_id!(VersionId); base62_id!(AffiliateCodeId); +base62_id!(AnalyticsEventId); diff --git a/apps/labrinth/src/models/v3/mod.rs b/apps/labrinth/src/models/v3/mod.rs index 9e87e679a0..83290a5f57 100644 --- a/apps/labrinth/src/models/v3/mod.rs +++ b/apps/labrinth/src/models/v3/mod.rs @@ -1,5 +1,6 @@ pub mod affiliate_code; pub mod analytics; +pub mod analytics_event; pub mod billing; pub mod collections; pub mod ids; diff --git a/apps/labrinth/src/routes/v3/analytics_event.rs b/apps/labrinth/src/routes/v3/analytics_event.rs new file mode 100644 index 0000000000..cf49c9716b --- /dev/null +++ b/apps/labrinth/src/routes/v3/analytics_event.rs @@ -0,0 +1,203 @@ +use actix_web::{HttpRequest, HttpResponse, 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, + pub ends: DateTime, +} + +/// Fetches all analytics events. +#[utoipa::path(responses((status = OK, body = Vec)))] +#[get("/events")] +pub async fn analytics_events_get( + req: HttpRequest, + pool: web::Data, + redis: web::Data, + session_queue: web::Data, +) -> Result>, ApiError> { + get_user_from_headers( + &req, + &**pool, + &redis, + &session_queue, + Scopes::empty(), + ) + .await?; + + let events = DBAnalyticsEvent::get_all(&**pool) + .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("/event")] +pub async fn analytics_event_create( + req: HttpRequest, + event: web::Json, + pool: web::Data, + redis: web::Data, + session_queue: web::Data, +) -> 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 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")?; + + Ok(web::Json(event.into())) +} + +/// Edits an analytics event. +#[utoipa::path(responses((status = OK, body = AnalyticsEvent)))] +#[patch("/event/{id}")] +pub async fn analytics_event_edit( + req: HttpRequest, + id: web::Path<(AnalyticsEventId,)>, + event: web::Json, + pool: web::Data, + redis: web::Data, + session_queue: web::Data, +) -> 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 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); + } + + Ok(web::Json(event.into())) +} + +/// Deletes an analytics event. +#[utoipa::path(responses((status = NO_CONTENT)))] +#[delete("/event/{id}")] +pub async fn analytics_event_delete( + req: HttpRequest, + id: web::Path<(AnalyticsEventId,)>, + pool: web::Data, + redis: web::Data, + session_queue: web::Data, +) -> Result { + 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); + } + + Ok(HttpResponse::NoContent().body("")) +} diff --git a/apps/labrinth/src/routes/v3/mod.rs b/apps/labrinth/src/routes/v3/mod.rs index 471853fbc8..7aee126535 100644 --- a/apps/labrinth/src/routes/v3/mod.rs +++ b/apps/labrinth/src/routes/v3/mod.rs @@ -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; @@ -57,7 +58,8 @@ pub fn utoipa_config( cfg.service( utoipa_actix_web::scope("/v3/analytics") .wrap(default_cors()) - .configure(analytics_get::config), + .configure(analytics_get::config) + .configure(analytics_event::config), ); cfg.service( utoipa_actix_web::scope("/v3/payout") From b6d16ab8d3d883c668aac89e0a0df6b2e096141e Mon Sep 17 00:00:00 2001 From: aecsocket Date: Wed, 13 May 2026 13:09:21 +0100 Subject: [PATCH 02/12] prepare --- ...7ec367b211113464c6c79a3c9e226ae3fdb79.json | 17 +++++++++ ...b0509d3a6b4cc6c4b194901d1690203020977.json | 17 +++++++++ ...d205bb294479eaae95fd1e9cb00b0975ca297.json | 22 +++++++++++ ...b20d5856c1747c651a52e7d94d8a2ee23dc08.json | 14 +++++++ ...ade55b8c920df7d98adde36c9e5d1cf5801f1.json | 38 +++++++++++++++++++ 5 files changed, 108 insertions(+) create mode 100644 apps/labrinth/.sqlx/query-2e6ffe58fef1368fdd1377534b27ec367b211113464c6c79a3c9e226ae3fdb79.json create mode 100644 apps/labrinth/.sqlx/query-67dbb27773b9d5dc77ee7ea9ce8b0509d3a6b4cc6c4b194901d1690203020977.json create mode 100644 apps/labrinth/.sqlx/query-94cec16e1be48761fe78a87fcead205bb294479eaae95fd1e9cb00b0975ca297.json create mode 100644 apps/labrinth/.sqlx/query-a53a7681f3e8ab918d225d16690b20d5856c1747c651a52e7d94d8a2ee23dc08.json create mode 100644 apps/labrinth/.sqlx/query-c7817c07dc91b562dfabc3b8c4bade55b8c920df7d98adde36c9e5d1cf5801f1.json diff --git a/apps/labrinth/.sqlx/query-2e6ffe58fef1368fdd1377534b27ec367b211113464c6c79a3c9e226ae3fdb79.json b/apps/labrinth/.sqlx/query-2e6ffe58fef1368fdd1377534b27ec367b211113464c6c79a3c9e226ae3fdb79.json new file mode 100644 index 0000000000..35446d4f24 --- /dev/null +++ b/apps/labrinth/.sqlx/query-2e6ffe58fef1368fdd1377534b27ec367b211113464c6c79a3c9e226ae3fdb79.json @@ -0,0 +1,17 @@ +{ + "db_name": "PostgreSQL", + "query": "\n\t\t\tUPDATE analytics_events\n\t\t\tSET meta = $2, starts = $3, ends = $4\n\t\t\tWHERE id = $1\n\t\t\t", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Int8", + "Jsonb", + "Timestamptz", + "Timestamptz" + ] + }, + "nullable": [] + }, + "hash": "2e6ffe58fef1368fdd1377534b27ec367b211113464c6c79a3c9e226ae3fdb79" +} diff --git a/apps/labrinth/.sqlx/query-67dbb27773b9d5dc77ee7ea9ce8b0509d3a6b4cc6c4b194901d1690203020977.json b/apps/labrinth/.sqlx/query-67dbb27773b9d5dc77ee7ea9ce8b0509d3a6b4cc6c4b194901d1690203020977.json new file mode 100644 index 0000000000..f6e3a3febc --- /dev/null +++ b/apps/labrinth/.sqlx/query-67dbb27773b9d5dc77ee7ea9ce8b0509d3a6b4cc6c4b194901d1690203020977.json @@ -0,0 +1,17 @@ +{ + "db_name": "PostgreSQL", + "query": "\n\t\t\tINSERT INTO analytics_events (id, meta, starts, ends)\n\t\t\tVALUES ($1, $2, $3, $4)\n\t\t\t", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Int8", + "Jsonb", + "Timestamptz", + "Timestamptz" + ] + }, + "nullable": [] + }, + "hash": "67dbb27773b9d5dc77ee7ea9ce8b0509d3a6b4cc6c4b194901d1690203020977" +} diff --git a/apps/labrinth/.sqlx/query-94cec16e1be48761fe78a87fcead205bb294479eaae95fd1e9cb00b0975ca297.json b/apps/labrinth/.sqlx/query-94cec16e1be48761fe78a87fcead205bb294479eaae95fd1e9cb00b0975ca297.json new file mode 100644 index 0000000000..5a909c6806 --- /dev/null +++ b/apps/labrinth/.sqlx/query-94cec16e1be48761fe78a87fcead205bb294479eaae95fd1e9cb00b0975ca297.json @@ -0,0 +1,22 @@ +{ + "db_name": "PostgreSQL", + "query": "SELECT EXISTS(SELECT 1 FROM analytics_events WHERE id=$1)", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "exists", + "type_info": "Bool" + } + ], + "parameters": { + "Left": [ + "Int8" + ] + }, + "nullable": [ + null + ] + }, + "hash": "94cec16e1be48761fe78a87fcead205bb294479eaae95fd1e9cb00b0975ca297" +} diff --git a/apps/labrinth/.sqlx/query-a53a7681f3e8ab918d225d16690b20d5856c1747c651a52e7d94d8a2ee23dc08.json b/apps/labrinth/.sqlx/query-a53a7681f3e8ab918d225d16690b20d5856c1747c651a52e7d94d8a2ee23dc08.json new file mode 100644 index 0000000000..cf765a8b20 --- /dev/null +++ b/apps/labrinth/.sqlx/query-a53a7681f3e8ab918d225d16690b20d5856c1747c651a52e7d94d8a2ee23dc08.json @@ -0,0 +1,14 @@ +{ + "db_name": "PostgreSQL", + "query": "\n\t\t\tDELETE FROM analytics_events\n\t\t\tWHERE id = $1\n\t\t\t", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Int8" + ] + }, + "nullable": [] + }, + "hash": "a53a7681f3e8ab918d225d16690b20d5856c1747c651a52e7d94d8a2ee23dc08" +} diff --git a/apps/labrinth/.sqlx/query-c7817c07dc91b562dfabc3b8c4bade55b8c920df7d98adde36c9e5d1cf5801f1.json b/apps/labrinth/.sqlx/query-c7817c07dc91b562dfabc3b8c4bade55b8c920df7d98adde36c9e5d1cf5801f1.json new file mode 100644 index 0000000000..20cfb4cbac --- /dev/null +++ b/apps/labrinth/.sqlx/query-c7817c07dc91b562dfabc3b8c4bade55b8c920df7d98adde36c9e5d1cf5801f1.json @@ -0,0 +1,38 @@ +{ + "db_name": "PostgreSQL", + "query": "\n\t\t\tSELECT id, meta AS \"meta: Json\", starts, ends\n\t\t\tFROM analytics_events\n\t\t\tORDER BY starts DESC\n\t\t\t", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "id", + "type_info": "Int8" + }, + { + "ordinal": 1, + "name": "meta: Json", + "type_info": "Jsonb" + }, + { + "ordinal": 2, + "name": "starts", + "type_info": "Timestamptz" + }, + { + "ordinal": 3, + "name": "ends", + "type_info": "Timestamptz" + } + ], + "parameters": { + "Left": [] + }, + "nullable": [ + false, + false, + false, + false + ] + }, + "hash": "c7817c07dc91b562dfabc3b8c4bade55b8c920df7d98adde36c9e5d1cf5801f1" +} From 1033a5a3af853fd9a56052c8c54222f5f0ef8955 Mon Sep 17 00:00:00 2001 From: aecsocket Date: Wed, 13 May 2026 13:30:18 +0100 Subject: [PATCH 03/12] change route prefix --- apps/labrinth/AGENTS.md | 5 ++++- apps/labrinth/src/routes/v3/analytics_event.rs | 8 ++++---- apps/labrinth/src/routes/v3/mod.rs | 6 +++++- 3 files changed, 13 insertions(+), 6 deletions(-) diff --git a/apps/labrinth/AGENTS.md b/apps/labrinth/AGENTS.md index 2e6a6e36ff..00d30d63a2 100644 --- a/apps/labrinth/AGENTS.md +++ b/apps/labrinth/AGENTS.md @@ -1,5 +1,8 @@ - Use `ApiError` as the error type for API routes -- Prefer `ApiError::Internal` and `ApiError::Request` over `ApiError::InvalidInput` +- Prefer `ApiError` variants: + - `ApiError::Request` instead of `ApiError::InvalidInput` + - `ApiError::Auth` instead of `ApiError::CustomAuthentication` + - `ApiError::Internal` for database errors, 3rd party service errors, anything else internal - Use `eyre!` to construct a value for `Internal` and `Request` variants - Error messages (both for errors and exceptions) must be formatted as per the Rust API guidelines: - lowercase message diff --git a/apps/labrinth/src/routes/v3/analytics_event.rs b/apps/labrinth/src/routes/v3/analytics_event.rs index cf49c9716b..a1f236bdf2 100644 --- a/apps/labrinth/src/routes/v3/analytics_event.rs +++ b/apps/labrinth/src/routes/v3/analytics_event.rs @@ -39,7 +39,7 @@ pub struct AnalyticsEventUpsert { /// Fetches all analytics events. #[utoipa::path(responses((status = OK, body = Vec)))] -#[get("/events")] +#[get("")] pub async fn analytics_events_get( req: HttpRequest, pool: web::Data, @@ -67,7 +67,7 @@ pub async fn analytics_events_get( /// Creates an analytics event. #[utoipa::path(responses((status = OK, body = AnalyticsEvent)))] -#[post("/event")] +#[post("")] pub async fn analytics_event_create( req: HttpRequest, event: web::Json, @@ -120,7 +120,7 @@ pub async fn analytics_event_create( /// Edits an analytics event. #[utoipa::path(responses((status = OK, body = AnalyticsEvent)))] -#[patch("/event/{id}")] +#[patch("/{id}")] pub async fn analytics_event_edit( req: HttpRequest, id: web::Path<(AnalyticsEventId,)>, @@ -165,7 +165,7 @@ pub async fn analytics_event_edit( /// Deletes an analytics event. #[utoipa::path(responses((status = NO_CONTENT)))] -#[delete("/event/{id}")] +#[delete("/{id}")] pub async fn analytics_event_delete( req: HttpRequest, id: web::Path<(AnalyticsEventId,)>, diff --git a/apps/labrinth/src/routes/v3/mod.rs b/apps/labrinth/src/routes/v3/mod.rs index 7aee126535..cb28d96c5b 100644 --- a/apps/labrinth/src/routes/v3/mod.rs +++ b/apps/labrinth/src/routes/v3/mod.rs @@ -58,7 +58,11 @@ pub fn utoipa_config( cfg.service( utoipa_actix_web::scope("/v3/analytics") .wrap(default_cors()) - .configure(analytics_get::config) + .configure(analytics_get::config), + ); + cfg.service( + utoipa_actix_web::scope("/v3/analytics-event") + .wrap(default_cors()) .configure(analytics_event::config), ); cfg.service( From 3707382db2979338989d8757a447d681b8143dd1 Mon Sep 17 00:00:00 2001 From: aecsocket Date: Wed, 13 May 2026 13:35:07 +0100 Subject: [PATCH 04/12] update route return --- apps/labrinth/AGENTS.md | 3 +++ apps/labrinth/src/routes/v3/analytics_event.rs | 18 +++--------------- 2 files changed, 6 insertions(+), 15 deletions(-) diff --git a/apps/labrinth/AGENTS.md b/apps/labrinth/AGENTS.md index 00d30d63a2..dd00fb4cbd 100644 --- a/apps/labrinth/AGENTS.md +++ b/apps/labrinth/AGENTS.md @@ -1,4 +1,7 @@ - Use `ApiError` as the error type for API routes +- The return type of an HTTP route should not be `HttpResponse` if possible; always prefer more specific types + - Use `web::Json` for JSON-encoded response + - Use `()` for no content - Prefer `ApiError` variants: - `ApiError::Request` instead of `ApiError::InvalidInput` - `ApiError::Auth` instead of `ApiError::CustomAuthentication` diff --git a/apps/labrinth/src/routes/v3/analytics_event.rs b/apps/labrinth/src/routes/v3/analytics_event.rs index a1f236bdf2..d9660f2d45 100644 --- a/apps/labrinth/src/routes/v3/analytics_event.rs +++ b/apps/labrinth/src/routes/v3/analytics_event.rs @@ -1,4 +1,4 @@ -use actix_web::{HttpRequest, HttpResponse, delete, get, patch, post, web}; +use actix_web::{HttpRequest, delete, get, patch, post, web}; use chrono::{DateTime, Utc}; use eyre::eyre; use serde::{Deserialize, Serialize}; @@ -41,20 +41,8 @@ pub struct AnalyticsEventUpsert { #[utoipa::path(responses((status = OK, body = Vec)))] #[get("")] pub async fn analytics_events_get( - req: HttpRequest, pool: web::Data, - redis: web::Data, - session_queue: web::Data, ) -> Result>, ApiError> { - get_user_from_headers( - &req, - &**pool, - &redis, - &session_queue, - Scopes::empty(), - ) - .await?; - let events = DBAnalyticsEvent::get_all(&**pool) .await .wrap_internal_err("failed to fetch analytics events")? @@ -172,7 +160,7 @@ pub async fn analytics_event_delete( pool: web::Data, redis: web::Data, session_queue: web::Data, -) -> Result { +) -> Result<(), ApiError> { let user = get_user_from_headers( &req, &**pool, @@ -199,5 +187,5 @@ pub async fn analytics_event_delete( return Err(ApiError::NotFound); } - Ok(HttpResponse::NoContent().body("")) + Ok(()) } From 24137e919b99413978a4ab4c8394b5c5fd526c1a Mon Sep 17 00:00:00 2001 From: aecsocket Date: Wed, 13 May 2026 20:18:22 +0100 Subject: [PATCH 05/12] Add mod launcher analytics --- .../labrinth/src/models/v3/analytics_event.rs | 25 +++ apps/labrinth/src/routes/v3/analytics_get.rs | 164 ++++++++++++++++-- 2 files changed, 177 insertions(+), 12 deletions(-) diff --git a/apps/labrinth/src/models/v3/analytics_event.rs b/apps/labrinth/src/models/v3/analytics_event.rs index 809075d526..c1c73c7b04 100644 --- a/apps/labrinth/src/models/v3/analytics_event.rs +++ b/apps/labrinth/src/models/v3/analytics_event.rs @@ -1,3 +1,5 @@ +use std::collections::HashSet; + use chrono::{DateTime, Utc}; use serde::{Deserialize, Serialize}; @@ -14,8 +16,31 @@ pub struct AnalyticsEvent { #[derive(Debug, Clone, Serialize, Deserialize, utoipa::ToSchema)] pub struct AnalyticsEventMeta { + #[serde(default)] pub title: String, + #[serde(default)] pub announcement_url: Option, + #[serde(default)] + pub for_metric_kind: HashSet, +} + +#[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 for AnalyticsEvent { diff --git a/apps/labrinth/src/routes/v3/analytics_get.rs b/apps/labrinth/src/routes/v3/analytics_get.rs index 38a78de6a7..ca8a0179c7 100644 --- a/apps/labrinth/src/routes/v3/analytics_get.rs +++ b/apps/labrinth/src/routes/v3/analytics_get.rs @@ -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, Serialize, Serializer}; 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, - /// [`ProjectDownloadsField::SitePath`]. + /// [`ProjectDownloadsField::UserAgent`]. #[serde(skip_serializing_if = "Option::is_none")] - site_path: Option, + user_agent: Option, /// [`ProjectDownloadsField::VersionId`]. #[serde(skip_serializing_if = "Option::is_none")] version_id: Option, @@ -350,6 +351,32 @@ pub struct ProjectDownloads { downloads: u64, } +#[derive(Debug, Clone, PartialEq, Eq, utoipa::ToSchema)] +pub enum DownloadSource { + Website, + ModrinthApp, + ModrinthHosting, + Other, + Named(String), +} + +impl Serialize for DownloadSource { + fn serialize(&self, serializer: S) -> Result + 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::Other => serializer.serialize_str("other"), + } + } +} + /// [`ReturnMetrics::project_playtime`]. #[derive(Debug, Clone, Default, Serialize, Deserialize, utoipa::ToSchema)] pub struct ProjectPlaytime { @@ -476,7 +503,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 +516,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 +529,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 +544,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" ) }; @@ -737,7 +764,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 +783,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), @@ -1016,6 +1047,70 @@ fn none_if_zero_version_id(v: DBVersionId) -> Option { if v.0 == 0 { None } else { Some(v.into()) } } +#[derive(Debug, Clone, Copy)] +enum DownloadSourcePattern { + Named(&'static str), + Website, + ModrinthApp, + ModrinthHosting, +} + +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, + } + } +} + +static DOWNLOAD_SOURCE_PATTERNS: LazyLock> = + LazyLock::new(|| { + use DownloadSourcePattern as P; + + [ + (r"^modrinth/kyros/", P::ModrinthApp), + (r"^modrinth/theseus/", P::ModrinthApp), + (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"^(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 { + 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 { @@ -1170,6 +1265,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); From 5c35fdac8c24cf4d548c4f91dd1218c6b9228ef9 Mon Sep 17 00:00:00 2001 From: aecsocket Date: Wed, 13 May 2026 20:40:51 +0100 Subject: [PATCH 06/12] more UA strings --- apps/labrinth/src/routes/v3/analytics_get.rs | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/apps/labrinth/src/routes/v3/analytics_get.rs b/apps/labrinth/src/routes/v3/analytics_get.rs index ca8a0179c7..f36a67c912 100644 --- a/apps/labrinth/src/routes/v3/analytics_get.rs +++ b/apps/labrinth/src/routes/v3/analytics_get.rs @@ -1053,6 +1053,7 @@ enum DownloadSourcePattern { Website, ModrinthApp, ModrinthHosting, + ModrinthMaven, } impl DownloadSourcePattern { @@ -1062,6 +1063,7 @@ impl DownloadSourcePattern { Self::Website => DownloadSource::Website, Self::ModrinthApp => DownloadSource::ModrinthApp, Self::ModrinthHosting => DownloadSource::ModrinthHosting, + Self::ModrinthMaven => DownloadSource::ModrinthMaven, } } } @@ -1073,6 +1075,7 @@ static DOWNLOAD_SOURCE_PATTERNS: LazyLock> = [ (r"^modrinth/kyros/", P::ModrinthApp), (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")), @@ -1089,6 +1092,9 @@ static DOWNLOAD_SOURCE_PATTERNS: LazyLock> = ), (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, From d9f1dc50a194330177413b0f0df3b0b52ab6431c Mon Sep 17 00:00:00 2001 From: aecsocket Date: Thu, 14 May 2026 11:41:02 +0100 Subject: [PATCH 07/12] fix ci --- .../database/models/analytics_event_item.rs | 6 ++--- apps/labrinth/src/routes/v3/analytics_get.rs | 26 +++++++++++++++++-- 2 files changed, 27 insertions(+), 5 deletions(-) diff --git a/apps/labrinth/src/database/models/analytics_event_item.rs b/apps/labrinth/src/database/models/analytics_event_item.rs index a5e72541f7..8d29f721ab 100644 --- a/apps/labrinth/src/database/models/analytics_event_item.rs +++ b/apps/labrinth/src/database/models/analytics_event_item.rs @@ -78,11 +78,11 @@ impl DBAnalyticsEvent { exec: impl crate::database::Executor<'_, Database = sqlx::Postgres>, ) -> Result, DatabaseError> { sqlx::query!( - " - SELECT id, meta AS \"meta: Json\", starts, ends + r#" + SELECT id, meta AS "meta: Json", starts, ends FROM analytics_events ORDER BY starts DESC - " + "# ) .fetch(exec) .map(|record| { diff --git a/apps/labrinth/src/routes/v3/analytics_get.rs b/apps/labrinth/src/routes/v3/analytics_get.rs index f36a67c912..86395bed6b 100644 --- a/apps/labrinth/src/routes/v3/analytics_get.rs +++ b/apps/labrinth/src/routes/v3/analytics_get.rs @@ -18,7 +18,9 @@ use eyre::eyre; use futures::StreamExt; use regex::Regex; use rust_decimal::Decimal; -use serde::{Deserialize, Serialize, Serializer}; +use serde::{ + Deserialize, Deserializer, Serialize, Serializer, de::Error as _, +}; use crate::{ auth::{AuthenticationError, get_user_from_headers}, @@ -356,6 +358,7 @@ pub enum DownloadSource { Website, ModrinthApp, ModrinthHosting, + ModrinthMaven, Other, Named(String), } @@ -372,11 +375,30 @@ impl Serialize for DownloadSource { 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(deserializer: D) -> Result + 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 { @@ -1073,7 +1095,7 @@ static DOWNLOAD_SOURCE_PATTERNS: LazyLock> = use DownloadSourcePattern as P; [ - (r"^modrinth/kyros/", P::ModrinthApp), + (r"^modrinth/kyros/", P::ModrinthHosting), (r"^modrinth/theseus/", P::ModrinthApp), (r"^(Gradle/|Apache-Maven/)", P::ModrinthMaven), (r"^MultiMC/", P::Named("MultiMC")), From 14b506f37b1bacd4c4d879f7e352744cff80ee13 Mon Sep 17 00:00:00 2001 From: aecsocket Date: Thu, 14 May 2026 12:22:19 +0100 Subject: [PATCH 08/12] caching on analytics events --- .../database/models/analytics_event_item.rs | 47 +++++++++++++++++-- .../labrinth/src/routes/v3/analytics_event.rs | 12 ++++- apps/labrinth/src/routes/v3/analytics_get.rs | 8 ++-- 3 files changed, 58 insertions(+), 9 deletions(-) diff --git a/apps/labrinth/src/database/models/analytics_event_item.rs b/apps/labrinth/src/database/models/analytics_event_item.rs index 8d29f721ab..bcf89aa344 100644 --- a/apps/labrinth/src/database/models/analytics_event_item.rs +++ b/apps/labrinth/src/database/models/analytics_event_item.rs @@ -3,11 +3,18 @@ use futures::{StreamExt, TryStreamExt}; use sqlx::types::Json; use crate::{ - database::models::{DBAnalyticsEventId, DatabaseError}, + database::{ + models::{DBAnalyticsEventId, DatabaseError}, + redis::RedisPool, + }, models::v3::analytics_event::AnalyticsEventMeta, }; +use serde::{Deserialize, Serialize}; -#[derive(Debug, Clone)] +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, @@ -76,8 +83,21 @@ impl DBAnalyticsEvent { pub async fn get_all( exec: impl crate::database::Executor<'_, Database = sqlx::Postgres>, + redis: &RedisPool, ) -> Result, DatabaseError> { - sqlx::query!( + 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", starts, ends FROM analytics_events @@ -96,6 +116,25 @@ impl DBAnalyticsEvent { }) }) .try_collect::>() - .await + .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(()) } } diff --git a/apps/labrinth/src/routes/v3/analytics_event.rs b/apps/labrinth/src/routes/v3/analytics_event.rs index d9660f2d45..9f2f4998b3 100644 --- a/apps/labrinth/src/routes/v3/analytics_event.rs +++ b/apps/labrinth/src/routes/v3/analytics_event.rs @@ -42,8 +42,9 @@ pub struct AnalyticsEventUpsert { #[get("")] pub async fn analytics_events_get( pool: web::Data, + redis: web::Data, ) -> Result>, ApiError> { - let events = DBAnalyticsEvent::get_all(&**pool) + let events = DBAnalyticsEvent::get_all(&**pool, &redis) .await .wrap_internal_err("failed to fetch analytics events")? .into_iter() @@ -102,6 +103,9 @@ pub async fn analytics_event_create( .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())) } @@ -147,6 +151,9 @@ pub async fn analytics_event_edit( 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())) } @@ -186,6 +193,9 @@ pub async fn analytics_event_delete( if !deleted { return Err(ApiError::NotFound); } + DBAnalyticsEvent::clear_cache(&redis) + .await + .wrap_internal_err("failed to clear analytics event cache")?; Ok(()) } diff --git a/apps/labrinth/src/routes/v3/analytics_get.rs b/apps/labrinth/src/routes/v3/analytics_get.rs index 86395bed6b..fdfc080f32 100644 --- a/apps/labrinth/src/routes/v3/analytics_get.rs +++ b/apps/labrinth/src/routes/v3/analytics_get.rs @@ -18,9 +18,7 @@ use eyre::eyre; use futures::StreamExt; use regex::Regex; use rust_decimal::Decimal; -use serde::{ - Deserialize, Deserializer, Serialize, Serializer, de::Error as _, -}; +use serde::{Deserialize, Deserializer, Serialize, Serializer, de::Error as _}; use crate::{ auth::{AuthenticationError, get_user_from_headers}, @@ -394,7 +392,9 @@ impl<'de> Deserialize<'de> for DownloadSource { "modrinth_maven" => Self::ModrinthMaven, "other" => Self::Other, _ if !source.is_empty() => Self::Named(source), - _ => return Err(D::Error::custom("download source cannot be empty")), + _ => { + return Err(D::Error::custom("download source cannot be empty")); + } }) } } From 724e4e5b1944e7cd5621390594288f208903dff0 Mon Sep 17 00:00:00 2001 From: aecsocket Date: Thu, 14 May 2026 12:53:34 +0100 Subject: [PATCH 09/12] Return parent modpack versions for playtime queries --- apps/labrinth/src/routes/v3/analytics_get.rs | 81 ++++++++++++++++---- 1 file changed, 65 insertions(+), 16 deletions(-) diff --git a/apps/labrinth/src/routes/v3/analytics_get.rs b/apps/labrinth/src/routes/v3/analytics_get.rs index fdfc080f32..2e29ee7fe4 100644 --- a/apps/labrinth/src/routes/v3/analytics_get.rs +++ b/apps/labrinth/src/routes/v3/analytics_get.rs @@ -587,23 +587,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" ) }; @@ -721,6 +746,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::>(); + 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::>(); + let parent_version_project_ids = parent_versions + .iter() + .map(|version| DBProjectId(version.mod_id)) + .collect::>(); + let affiliate_code_ids = DBAffiliateCode::get_by_affiliate(user.id.into(), &**pool) .await? @@ -733,6 +779,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, }; @@ -893,9 +941,6 @@ pub async fn fetch_analytics( return Err(AuthenticationError::InvalidCredentials.into()); } - let project_id_values = - project_ids.iter().map(|id| id.0).collect::>(); - let mut rows = sqlx::query!( "SELECT WIDTH_BUCKET( @@ -1153,6 +1198,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], } @@ -1174,6 +1221,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) From be17c8d7f78144a398c448ea9e66d14dce6ad4f7 Mon Sep 17 00:00:00 2001 From: aecsocket Date: Thu, 14 May 2026 13:00:47 +0100 Subject: [PATCH 10/12] sqlx prepare --- ...3e47d858deca66530ffdba4d0eac3aeac1433.json | 28 +++++++++++++++++++ 1 file changed, 28 insertions(+) create mode 100644 apps/labrinth/.sqlx/query-e425e8fce0571388d90cbe861223e47d858deca66530ffdba4d0eac3aeac1433.json diff --git a/apps/labrinth/.sqlx/query-e425e8fce0571388d90cbe861223e47d858deca66530ffdba4d0eac3aeac1433.json b/apps/labrinth/.sqlx/query-e425e8fce0571388d90cbe861223e47d858deca66530ffdba4d0eac3aeac1433.json new file mode 100644 index 0000000000..4d0b52a78d --- /dev/null +++ b/apps/labrinth/.sqlx/query-e425e8fce0571388d90cbe861223e47d858deca66530ffdba4d0eac3aeac1433.json @@ -0,0 +1,28 @@ +{ + "db_name": "PostgreSQL", + "query": "\n SELECT id, mod_id\n FROM versions\n WHERE mod_id = ANY($1)\n ", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "id", + "type_info": "Int8" + }, + { + "ordinal": 1, + "name": "mod_id", + "type_info": "Int8" + } + ], + "parameters": { + "Left": [ + "Int8Array" + ] + }, + "nullable": [ + false, + false + ] + }, + "hash": "e425e8fce0571388d90cbe861223e47d858deca66530ffdba4d0eac3aeac1433" +} From 2b63a684f8384f26994f4b0517ce971d7269fdd8 Mon Sep 17 00:00:00 2001 From: aecsocket Date: Thu, 14 May 2026 13:12:35 +0100 Subject: [PATCH 11/12] fmt --- apps/labrinth/src/routes/v3/analytics_get.rs | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/apps/labrinth/src/routes/v3/analytics_get.rs b/apps/labrinth/src/routes/v3/analytics_get.rs index 2e29ee7fe4..64a80418d7 100644 --- a/apps/labrinth/src/routes/v3/analytics_get.rs +++ b/apps/labrinth/src/routes/v3/analytics_get.rs @@ -393,7 +393,9 @@ impl<'de> Deserialize<'de> for DownloadSource { "other" => Self::Other, _ if !source.is_empty() => Self::Named(source), _ => { - return Err(D::Error::custom("download source cannot be empty")); + return Err(D::Error::custom( + "download source cannot be empty", + )); } }) } From 74198a24cf5fd26f049ae1063c9114ce64507a0b Mon Sep 17 00:00:00 2001 From: aecsocket Date: Thu, 14 May 2026 19:11:54 +0100 Subject: [PATCH 12/12] dummy fixtures --- .../fixtures/analytics-changes-clickhouse.sql | 19 ++++ .../fixtures/analytics-changes-postgres.sql | 97 +++++++++++++++++++ 2 files changed, 116 insertions(+) create mode 100644 apps/labrinth/fixtures/analytics-changes-clickhouse.sql create mode 100644 apps/labrinth/fixtures/analytics-changes-postgres.sql diff --git a/apps/labrinth/fixtures/analytics-changes-clickhouse.sql b/apps/labrinth/fixtures/analytics-changes-clickhouse.sql new file mode 100644 index 0000000000..58399184df --- /dev/null +++ b/apps/labrinth/fixtures/analytics-changes-clickhouse.sql @@ -0,0 +1,19 @@ +-- Dummy ClickHouse download rows for exercising v3 analytics download-source normalization. +-- Run this against the analytics ClickHouse database, for example: +-- curl -u default:default 'http://localhost:8123/?database=staging_ariadne' --data-binary @apps/labrinth/fixtures/analytics-changes-clickhouse.sql +-- +-- Project 910000000000003 = 4AP3jpvKl + +INSERT INTO downloads FORMAT JSONEachRow +{"recorded":"2026-05-13 00:05:00.0000","domain":"cdn.modrinth.com","site_path":"/data/analytics-fixture.jar","user_id":0,"project_id":910000000000003,"version_id":0,"ip":"::1","country":"US","user_agent":"modrinth/kyros/1.0.0 (support@modrinth.com)","headers":[],"reason":"primary","game_version":"1.20.1","loader":"fabric"} +{"recorded":"2026-05-13 00:10:00.0000","domain":"cdn.modrinth.com","site_path":"/data/analytics-fixture.jar","user_id":0,"project_id":910000000000003,"version_id":0,"ip":"::1","country":"US","user_agent":"modrinth/theseus/0.8.6 (support@modrinth.com)","headers":[],"reason":"primary","game_version":"1.20.1","loader":"fabric"} +{"recorded":"2026-05-13 00:15:00.0000","domain":"cdn.modrinth.com","site_path":"/data/analytics-fixture.jar","user_id":0,"project_id":910000000000003,"version_id":0,"ip":"::1","country":"US","user_agent":"Gradle/8.8","headers":[],"reason":"primary","game_version":"1.20.1","loader":"fabric"} +{"recorded":"2026-05-13 00:20:00.0000","domain":"cdn.modrinth.com","site_path":"/data/analytics-fixture.jar","user_id":0,"project_id":910000000000003,"version_id":0,"ip":"::1","country":"US","user_agent":"MultiMC/5.0","headers":[],"reason":"primary","game_version":"1.20.1","loader":"fabric"} +{"recorded":"2026-05-13 00:25:00.0000","domain":"cdn.modrinth.com","site_path":"/data/analytics-fixture.jar","user_id":0,"project_id":910000000000003,"version_id":0,"ip":"::1","country":"US","user_agent":"PrismLauncher/6.1","headers":[],"reason":"primary","game_version":"1.20.1","loader":"fabric"} +{"recorded":"2026-05-13 00:30:00.0000","domain":"cdn.modrinth.com","site_path":"/data/analytics-fixture.jar","user_id":0,"project_id":910000000000003,"version_id":0,"ip":"::1","country":"US","user_agent":"PolyMC/7.0","headers":[],"reason":"primary","game_version":"1.20.1","loader":"fabric"} +{"recorded":"2026-05-13 00:35:00.0000","domain":"cdn.modrinth.com","site_path":"/data/analytics-fixture.jar","user_id":0,"project_id":910000000000003,"version_id":0,"ip":"::1","country":"US","user_agent":"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) FeatherLauncher/2.6.12-c Chrome/144.0.7559.236 Electron/40.9.1 Safari/537.36 (hello@feathermc.com)","headers":[],"reason":"primary","game_version":"1.20.1","loader":"fabric"} +{"recorded":"2026-05-13 00:40:00.0000","domain":"cdn.modrinth.com","site_path":"/data/analytics-fixture.jar","user_id":0,"project_id":910000000000003,"version_id":0,"ip":"::1","country":"US","user_agent":"FeatherMC/Feather Client Rust Launcher/1.0.0 (hello@feathermc.com)","headers":[],"reason":"primary","game_version":"1.20.1","loader":"fabric"} +{"recorded":"2026-05-13 00:45:00.0000","domain":"cdn.modrinth.com","site_path":"/data/analytics-fixture.jar","user_id":0,"project_id":910000000000003,"version_id":0,"ip":"::1","country":"US","user_agent":"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/103.0.5060.134 Feather/9b6bb39d Safari/537.36","headers":[],"reason":"primary","game_version":"1.20.1","loader":"fabric"} +{"recorded":"2026-05-13 00:50:00.0000","domain":"cdn.modrinth.com","site_path":"/data/analytics-fixture.jar","user_id":0,"project_id":910000000000003,"version_id":0,"ip":"::1","country":"US","user_agent":"PandoraLauncher/1.2.3","headers":[],"reason":"primary","game_version":"1.20.1","loader":"fabric"} +{"recorded":"2026-05-13 00:55:00.0000","domain":"cdn.modrinth.com","site_path":"/data/analytics-fixture.jar","user_id":0,"project_id":910000000000003,"version_id":0,"ip":"::1","country":"US","user_agent":"Mozilla/5.0 AppleWebKit/605.1.15","headers":[],"reason":"primary","game_version":"1.20.1","loader":"fabric"} +{"recorded":"2026-05-13 00:59:00.0000","domain":"cdn.modrinth.com","site_path":"/data/analytics-fixture.jar","user_id":0,"project_id":910000000000003,"version_id":0,"ip":"::1","country":"US","user_agent":"curl/8.7.1","headers":[],"reason":"primary","game_version":"1.20.1","loader":"fabric"} diff --git a/apps/labrinth/fixtures/analytics-changes-postgres.sql b/apps/labrinth/fixtures/analytics-changes-postgres.sql new file mode 100644 index 0000000000..d05f939d25 --- /dev/null +++ b/apps/labrinth/fixtures/analytics-changes-postgres.sql @@ -0,0 +1,97 @@ +-- Dummy analytics data for exercising v3 analytics events and project download analytics. +-- IDs are listed as integers, followed by their equivalent base62 representation. + +-- User 103587649610509 = 1XZwx9qL +INSERT INTO users ( + id, username, email, role, badges, balance, email_verified +) +VALUES ( + 103587649610509, 'Analytics Admin', 'analytics-admin@modrinth.com', + 'admin', 0, 0, TRUE +) +ON CONFLICT (id) DO UPDATE SET + role = EXCLUDED.role; + +INSERT INTO sessions ( + id, session, user_id, expires, refresh_expires, ip, user_agent +) +VALUES ( + 103587649610510, 'mra_analytics_admin', 103587649610509, + '2030-01-01T00:00:00Z', '2030-01-01T00:00:00Z', + '127.0.0.1', 'analytics fixture' +) +ON CONFLICT (session) DO UPDATE SET + user_id = EXCLUDED.user_id, + expires = EXCLUDED.expires, + refresh_expires = EXCLUDED.refresh_expires; + +-- Project 910000000000003 = 4AP3jpvKl +-- Team 910000000000001 = 4AP3jpvKj +-- Thread 910000000000004 = 4AP3jpvKm +INSERT INTO teams (id) +VALUES (910000000000001) +ON CONFLICT (id) DO NOTHING; + +INSERT INTO team_members ( + id, team_id, user_id, role, permissions, accepted, payouts_split, ordering, + organization_permissions, is_owner +) +VALUES ( + 910000000000002, 910000000000001, 103587649610509, 'Owner', + 1023, TRUE, 100, 0, NULL, TRUE +) +ON CONFLICT (id) DO UPDATE SET + permissions = EXCLUDED.permissions, + accepted = EXCLUDED.accepted, + is_owner = EXCLUDED.is_owner; + +INSERT INTO mods ( + id, team_id, name, summary, downloads, slug, description, follows, + license, status, requested_status, monetization_status, + side_types_migration_review_status, components +) +VALUES ( + 910000000000003, 910000000000001, 'Analytics Fixture Project', + 'Project used by analytics fixture data.', 0, 'analytics-fixture-project', + '', 0, 'LicenseRef-All-Rights-Reserved', 'approved', 'approved', + 'monetized', 'reviewed', '{}'::jsonb +) +ON CONFLICT (id) DO UPDATE SET + team_id = EXCLUDED.team_id, + status = EXCLUDED.status, + requested_status = EXCLUDED.requested_status, + monetization_status = EXCLUDED.monetization_status; + +INSERT INTO threads (id, thread_type, mod_id) +VALUES (910000000000004, 'project', 910000000000003) +ON CONFLICT (id) DO NOTHING; + +-- Analytics events used to test /v3/analytics-event and Redis caching. +-- Event 910000000000101 = 4AP3jpvMR +-- Event 910000000000102 = 4AP3jpvMS +INSERT INTO analytics_events (id, meta, starts, ends) +VALUES + ( + 910000000000101, + '{ + "title": "Downloads launch", + "announcement_url": "https://modrinth.com/news/downloads-launch", + "for_metric_kind": ["downloads"] + }'::jsonb, + '2026-05-13T00:00:00Z', + '2026-05-14T00:00:00Z' + ), + ( + 910000000000102, + '{ + "title": "Revenue promo", + "announcement_url": "https://modrinth.com/news/revenue-promo", + "for_metric_kind": ["revenue"] + }'::jsonb, + '2026-05-14T00:00:00Z', + '2026-05-15T00:00:00Z' + ) +ON CONFLICT (id) DO UPDATE SET + meta = EXCLUDED.meta, + starts = EXCLUDED.starts, + ends = EXCLUDED.ends;