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:
aecsocket
2026-05-19 14:06:04 +01:00
committed by GitHub
parent 48bb44155d
commit 244c263e40
19 changed files with 928 additions and 35 deletions
@@ -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"
}
@@ -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"
}
@@ -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"
}
@@ -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"
}
@@ -0,0 +1,38 @@
{
"db_name": "PostgreSQL",
"query": "\n\t\t\tSELECT id, meta AS \"meta: Json<AnalyticsEventMeta>\", 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<AnalyticsEventMeta>",
"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"
}
@@ -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"
}
+7 -1
View File
@@ -1,5 +1,11 @@
- Use `ApiError` as the error type for API routes
- Prefer `ApiError::Internal` and `ApiError::Request` over `ApiError::InvalidInput`
- The return type of an HTTP route should not be `HttpResponse` if possible; always prefer more specific types
- Use `web::Json<T>` for JSON-encoded response
- Use `()` for no content
- 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
@@ -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"}
@@ -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;
@@ -0,0 +1,6 @@
create table analytics_events (
id bigint primary key,
meta jsonb not null,
starts timestamptz not null,
ends timestamptz not null
);
@@ -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(())
}
}
+10 -6
View File
@@ -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);
+2
View File
@@ -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,
}
}
}
+1
View File
@@ -26,3 +26,4 @@ base62_id!(ThreadMessageId);
base62_id!(UserSubscriptionId);
base62_id!(VersionId);
base62_id!(AffiliateCodeId);
base62_id!(AnalyticsEventId);
+1
View File
@@ -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(())
}
+247 -28
View File
@@ -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);
+6
View File
@@ -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())