Phase 2: analytics rollups, geo breakdown, event feed (backend)
- 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:
co-authored by
Claude Opus 4.8
parent
e66813ef58
commit
4770efd861
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user