Phase 2: analytics rollups, geo breakdown, event feed (backend)
CI / policy (push) Successful in 4s
CI / backend (push) Failing after 1m13s
CI / mobile (push) Successful in 19s
CI / profile (push) Successful in 48s

- Idempotent hourly rollup (run_rollup, INSERT ... ON CONFLICT DO UPDATE into
  analytics_rollups_hourly) + Tokio background task (startup then hourly)
- GET /v1/cards/{id}/analytics/geo — country-grouped visit counts
- GeoResolver trait: real MaxMind under the `geoip` feature, no-op default;
  raw IP resolved then discarded (only hash + coarse country/city persisted)
- analytics_events now store country/city; rollups read query for the dashboard

2 new tests (geo grouping, rollup aggregation + idempotency); 75 backend tests.
fmt + clippy clean; geoip feature compiles.

Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
This commit is contained in:
Omar Sobh
2026-06-04 11:34:52 -05:00
co-authored by Claude Opus 4.8
parent e66813ef58
commit 4770efd861
14 changed files with 419 additions and 11 deletions
@@ -0,0 +1,88 @@
//! IP → country/city resolution for analytics geo distribution (PRD §6.7.1,
//! §18). Behind a trait so the real MaxMind lookup (which needs a GeoLite2 mmdb
//! file) is optional and tests can inject a deterministic fake.
//!
//! The real resolver is gated behind the `geoip` feature; without it the
//! `NullGeoResolver` is used and country/city stay unresolved (analytics still
//! work, just without geo).
use std::net::IpAddr;
pub struct GeoLocation {
pub country: Option<String>,
pub city: Option<String>,
}
pub trait GeoResolver: Send + Sync {
/// Resolve a raw IP string to a location. Never errors — unknown IPs simply
/// return empty fields.
fn resolve(&self, ip: &str) -> GeoLocation;
}
/// Default resolver: resolves nothing. Used when `geoip` is disabled.
pub struct NullGeoResolver;
impl GeoResolver for NullGeoResolver {
fn resolve(&self, _ip: &str) -> GeoLocation {
GeoLocation {
country: None,
city: None,
}
}
}
#[cfg(feature = "geoip")]
pub use maxmind::MaxMindGeoResolver;
#[cfg(feature = "geoip")]
mod maxmind {
use std::net::IpAddr;
use std::sync::Arc;
use maxminddb::{geoip2, Reader};
use super::{GeoLocation, GeoResolver};
/// Production resolver backed by a MaxMind GeoLite2/GeoIP2 City database.
pub struct MaxMindGeoResolver {
reader: Arc<Reader<Vec<u8>>>,
}
impl MaxMindGeoResolver {
pub fn open(mmdb_path: &str) -> Result<Self, String> {
let reader = Reader::open_readfile(mmdb_path).map_err(|e| e.to_string())?;
Ok(Self {
reader: Arc::new(reader),
})
}
}
impl GeoResolver for MaxMindGeoResolver {
fn resolve(&self, ip: &str) -> GeoLocation {
let Ok(addr) = ip.parse::<IpAddr>() else {
return GeoLocation {
country: None,
city: None,
};
};
match self.reader.lookup::<geoip2::City>(addr) {
Ok(city) => GeoLocation {
country: city.country.and_then(|c| c.iso_code).map(|s| s.to_string()),
city: city
.city
.and_then(|c| c.names)
.and_then(|n| n.get("en").map(|s| s.to_string())),
},
Err(_) => GeoLocation {
country: None,
city: None,
},
}
}
}
}
/// Validate that a string parses as an IP (used to avoid storing junk).
pub fn is_ip(s: &str) -> bool {
s.parse::<IpAddr>().is_ok()
}
@@ -3,7 +3,7 @@
use axum::extract::{Path, State};
use axum::http::HeaderMap;
use axum::Json;
use cardclaws_db::models::analytics::{AnalyticsSummary, FeedEvent};
use cardclaws_db::models::analytics::{AnalyticsSummary, FeedEvent, GeoCount};
use serde::Deserialize;
use serde_json::{json, Value};
use uuid::Uuid;
@@ -63,6 +63,17 @@ pub async fn feed(
))
}
/// Owner-only geo distribution (country → visits).
pub async fn geo(
State(state): State<AppState>,
user: AuthUser,
Path(id): Path<Uuid>,
) -> ApiResult<Json<Vec<GeoCount>>> {
Ok(Json(
analytics_service::geo_breakdown(&state, id, user.user_id).await?,
))
}
/// Best-effort client IP from the proxy headers Cloudflare/Hetzner set. The
/// first IP in `x-forwarded-for` is the original client.
pub fn client_ip(headers: &HeaderMap) -> Option<String> {
@@ -8,6 +8,7 @@ pub mod assets;
pub mod cache;
pub mod email;
pub mod error;
pub mod geo;
pub mod handlers;
pub mod middleware;
pub mod router;
@@ -6,6 +6,7 @@ use std::sync::Arc;
use cardclaws_api::assets::R2Store;
use cardclaws_api::cache::RedisCache;
use cardclaws_api::email::ResendEmailSender;
use cardclaws_api::geo::{GeoResolver, NullGeoResolver};
use cardclaws_api::{build_router, AppState};
use cardclaws_auth::apple::HttpJwkProvider;
use cardclaws_auth::JwtKeys;
@@ -46,6 +47,7 @@ async fn main() -> Result<(), BoxError> {
let cache = RedisCache::connect(&config.redis_url).await?;
let assets = R2Store::new(&config.r2)?;
let pass_signer = build_pass_signer(&secrets).await?;
let geo = build_geo_resolver(&secrets).await;
// Brand glyphs bundled into every pass. Solid-fill placeholders for now;
// replaced by real CardClaws artwork when design assets land.
@@ -59,6 +61,7 @@ async fn main() -> Result<(), BoxError> {
cache: Arc::new(cache),
email: Arc::new(ResendEmailSender::new(resend_key, email_from)),
assets: Arc::new(assets),
geo,
jwt: JwtKeys::new(&config.jwt_secret),
apple: Arc::new(HttpJwkProvider::new()),
apple_audience,
@@ -69,6 +72,9 @@ async fn main() -> Result<(), BoxError> {
brand: Arc::new(brand),
};
// Hourly analytics rollup (PRD §18.2).
spawn_rollup_task(state.db.clone());
let app = build_router(state);
let listener = tokio::net::TcpListener::bind(&config.bind_addr).await?;
tracing::info!(addr = %config.bind_addr, "cardclaws-api listening");
@@ -76,6 +82,43 @@ async fn main() -> Result<(), BoxError> {
Ok(())
}
/// Spawn the background task that recomputes hourly analytics rollups. The first
/// tick fires immediately, then once per hour.
fn spawn_rollup_task(db: cardclaws_db::Db) {
tokio::spawn(async move {
let mut ticker = tokio::time::interval(std::time::Duration::from_secs(3600));
loop {
ticker.tick().await;
match cardclaws_db::queries::analytics::run_rollup(&db).await {
Ok(n) => tracing::debug!(buckets = n, "analytics rollup complete"),
Err(e) => tracing::warn!(error = %e, "analytics rollup failed"),
}
}
});
}
/// Build the geo resolver. With `geoip` enabled and `MAXMIND_DB_PATH` set, uses
/// the MaxMind City database; otherwise geo is unresolved (analytics still work).
#[cfg(feature = "geoip")]
async fn build_geo_resolver(secrets: &dyn SecretSource) -> Arc<dyn GeoResolver> {
use cardclaws_api::geo::MaxMindGeoResolver;
if let Some(path) = secrets.get("MAXMIND_DB_PATH").await {
match MaxMindGeoResolver::open(&path) {
Ok(r) => {
tracing::info!("geoip enabled");
return Arc::new(r);
}
Err(e) => tracing::warn!(error = %e, "failed to open MaxMind db; geo disabled"),
}
}
Arc::new(NullGeoResolver)
}
#[cfg(not(feature = "geoip"))]
async fn build_geo_resolver(_secrets: &dyn SecretSource) -> Arc<dyn GeoResolver> {
Arc::new(NullGeoResolver)
}
/// Build the Apple pass signer. With `apple-signing` enabled, loads the Pass
/// Type ID P12 + WWDR intermediate and signs for real; otherwise uses a fake
/// signer (dev only) and warns loudly.
@@ -35,6 +35,7 @@ pub fn build_router(state: AppState) -> Router {
.route("/cards/handle/:handle", get(cards::get_card_by_handle))
.route("/cards/:id/analytics", get(analytics::summary))
.route("/cards/:id/analytics/feed", get(analytics::feed))
.route("/cards/:id/analytics/geo", get(analytics::geo))
.route("/cards/:id/share", post(share::create_share))
.route("/cards/:id/share-links", get(share::list_shares))
.route("/s/:token", get(share::resolve_share))
@@ -68,6 +68,11 @@ pub async fn record(
) -> Result<(), AppError> {
debug_assert!(ALL_EVENT_TYPES.contains(&event_type));
let ip_hash = ip.map(|raw| hash_ip(&state.ip_hash_secret, raw));
// Resolve geo from the raw IP, then immediately drop the raw IP — only the
// hash and coarse country/city are persisted (PRD §18.3).
let geo = ip.map(|raw| state.geo.resolve(raw));
let country = geo.as_ref().and_then(|g| g.country.as_deref());
let city = geo.as_ref().and_then(|g| g.city.as_deref());
analytics::insert_event(
&state.db,
analytics::NewEvent {
@@ -75,6 +80,8 @@ pub async fn record(
event_type,
share_token,
ip_hash: ip_hash.as_deref(),
country,
city,
user_agent,
},
)
@@ -82,6 +89,16 @@ pub async fn record(
.map_db()
}
/// Owner-only geo distribution for a card.
pub async fn geo_breakdown(
state: &AppState,
card_id: Uuid,
user_id: Uuid,
) -> Result<Vec<cardclaws_db::models::analytics::GeoCount>, AppError> {
card_service::get_owned(state, card_id, user_id).await?;
analytics::geo_breakdown(&state.db, card_id).await.map_db()
}
/// Owner-only metrics summary for a card.
pub async fn summary(
state: &AppState,
@@ -12,6 +12,7 @@ use cardclaws_wallet::apple::BrandAssets;
use crate::assets::ObjectStore;
use crate::cache::Cache;
use crate::email::EmailSender;
use crate::geo::GeoResolver;
/// All shared dependencies. `Arc`-wrapped trait objects keep `AppState: Clone`
/// cheap while allowing test doubles to be injected (cache, Apple keys).
@@ -21,6 +22,7 @@ pub struct AppState {
pub cache: Arc<dyn Cache>,
pub email: Arc<dyn EmailSender>,
pub assets: Arc<dyn ObjectStore>,
pub geo: Arc<dyn GeoResolver>,
pub jwt: JwtKeys,
pub apple: Arc<dyn JwkProvider>,
/// Apple Services ID / bundle id the identity token must be addressed to.