This document describes the BTAA Geospatial API analytics program: what we collect, how the data moves through the system, the performance rules we are protecting, and how to operate and extend the analytics stack safely.
The analytics program has two complementary goals:
- Capture every API request so we can understand real traffic, client behavior, and service-tier usage.
- Capture product behavior in the Geoportal so we can understand searches, impressions, resource views, downloads, and outbound link clicks.
- Always log requests. API request logging is independent from rate limiting. Setting
RATE_LIMIT_ENABLED=falsedisables throttling, not analytics. - Do not slow the product down for analytics. Request handlers and frontend interactions should not wait on Postgres writes.
- Fail open. If Redis or Celery is unhealthy, the application should keep serving traffic even if analytics are delayed or dropped.
- Keep raw data for a limited time. Raw analytics are valuable for debugging and detailed analysis, but they must not grow forever.
- Prefer explicit source identity. We want to know whether traffic came from the Geoportal, SSR, QGIS, docs, or a partner client when that information is available.
Every API request is serialized by APIUsageLogService and queued to Celery. The worker task writes the row to analytics_api_usage_logs, then enriches browser, OS, and device-type fields from the user agent.
Current implementation:
- Request serialization:
backend/app/services/api_usage_log_service.py - Async persistence and enrichment:
backend/app/tasks/api_usage_enrichment.py - Middleware entrypoint:
backend/app/middleware/rate_limit_middleware.py
Important behavior:
- Request logging still runs when
RATE_LIMIT_ENABLED=false. - Query params are preserved in
properties.query_params. - UTM fields are stored in top-level columns.
visit_token,client_*, andsource_hostare captured when available.
Geoportal analytics are sent as lightweight batched beacons to /api/v1/analytics/events. The backend accepts the payload, adds request defaults, queues it to Celery, and returns 202 Accepted.
Current implementation:
- Frontend batching and transport:
frontend/src/services/analytics.ts - Ingest endpoint:
backend/app/api/v1/endpoint_modules/analytics.py - Batch persistence:
backend/app/tasks/analytics_events.py
This is intentionally separate from normal search/resource API responses so search and detail pages do not block on analytics writes.
We track source and client identity in two ways:
- Request headers
X-BTAA-Client-NameX-BTAA-Client-VersionX-BTAA-Client-ChannelX-BTAA-Client-InstanceX-Visit-Token
- Derived request context
source_hostfromOrigin/Refererreferring_domain- API key / service tier for authenticated API requests
Current first-party client identities:
geoportal-webfor same-origin browser API requestsgeoportal-ssrfor server-side fetches
External or partner clients can opt into the same attribution model by sending the X-BTAA-Client-* headers.
analytics_api_usage_logs- One row per API request
- Includes endpoint, method, status, latency, tier, API key, visit token, source, UTM fields, and request properties
analytics_searches- One row per rendered search result page
- Includes query, view, page, sort, search field, result counts, zero-results flag, and serialized search constraints
analytics_search_impressions- One row per resource impression in a search result page
- Includes
search_id,resource_id, rank, page, and view
analytics_events- One row per user interaction event
- Includes resource views, result clicks, downloads, outbound links, citation actions, and related metadata
analytics_daily_api_usage_metrics- Daily request counts by endpoint, method, status, tier, API key, and client/source dimensions
analytics_daily_search_metrics- Daily counts of searches, zero-results searches, total search results, and total impressions by client/source/view/sort/search field
analytics_daily_resource_metrics- Daily per-resource event counts by event type and client/source dimensions
analytics_maintenance_state- Checkpoint table used by the maintenance job to track rollup progress
All raw analytics_* tables are monthly partitioned by partition_month.
That gives us three benefits:
- Fast partition drops when raw data ages out
- Bounded table/index growth
- Safer long-term retention than large
DELETEjobs
analytics_api_usage_logs currently captures:
- endpoint path and method
- status code and response time
- service tier and API key id
- IP address and user agent
- browser / OS / device-type enrichment
- visit token
- referrer and referring domain
- UTM fields
- source attribution fields (
client_name,client_version,client_channel,client_instance,source_host) - serialized request query params in
properties.query_params
analytics_searches currently captures:
search_id- query text
- canonical search URL
- view (
list,gallery,map) - page and per-page
- sort and search field
- total result count and total pages
zero_results- full serialized constraints in
properties.constraints - spelling suggestions in
properties.spelling_suggestions
analytics_search_impressions currently captures:
search_idresource_id- absolute rank
- page
- view
analytics_events currently captures these implemented event types:
result_clickresource_viewnext_resultprevious_resultdownload_clickdownload_prepare_requesteddownload_prepare_successdownload_prepare_failuremetadata_downloadweb_service_clickvisit_source_clickexternal_geoportal_clickdocumentation_clickoutbound_link_clickpermalink_copycitation_copycitation_export
Common event fields include:
search_idresource_id- rank / page / view
- link label
- destination URL
- source component
- extra structured properties
The analytics program is designed around strict no-hot-path rules.
- API request logging is queued to Celery instead of writing directly to Postgres.
/api/v1/analytics/eventsvalidates lightly, enqueues work, and returns202.- If the Celery enqueue step fails, the request still succeeds and the failure is logged.
- Search and resource endpoints do not issue extra Postgres writes for analytics.
- Analytics are sent in batches, not per-result requests.
- Search impressions are emitted once per rendered result page.
requestIdleCallbackis used when available so analytics dispatch waits for idle time.navigator.sendBeacon()is preferred to avoid blocking navigation or unload.fetch(..., { keepalive: true })is the fallback path.
The Geoportal only adds custom analytics headers on same-origin browser API requests. That avoids unnecessary CORS preflight latency for cross-origin browser requests.
Current defaults:
ANALYTICS_RETENTION_API_USAGE_DAYS=30
ANALYTICS_RETENTION_SEARCH_DAYS=90
ANALYTICS_RETENTION_IMPRESSION_DAYS=30
ANALYTICS_RETENTION_EVENT_DAYS=90
ANALYTICS_PARTITION_HISTORY_MONTHS=2
ANALYTICS_PARTITION_FUTURE_MONTHS=2
ANALYTICS_ROLLUP_MAX_DAYS_PER_RUN=31
Retention is enforced by dropping whole monthly partitions after rollups have safely covered the relevant dates.
The maintenance job:
- Ensures raw monthly partitions exist
- Rolls up completed days into daily summary tables
- Drops expired raw partitions only after rollup checkpoints have advanced past them
Important detail:
- Daily rollups only process completed days, up through yesterday
- It is normal for same-day raw data to exist while the daily rollup tables remain empty for that date
Large DELETE jobs create table bloat and vacuum pressure in Postgres. Monthly partition drops are much cheaper and more predictable.
Local Docker:
docker compose exec -T api bash -lc 'cd /app/backend && python scripts/run_migrations.py'Kamal / remote:
kamal app exec -d dev1 --roles web "bash -lc 'cd /app/backend && /opt/venv/bin/python scripts/run_migrations.py'"Local:
make analytics-maintenance
make analytics-size-reportDirect script:
cd backend
python scripts/manage_analytics_storage.py --mode maintenance
python scripts/manage_analytics_storage.py --mode size-report
python scripts/manage_analytics_storage.py --mode ensureRemote:
kamal app exec -d dev1 --roles web "bash -lc 'cd /app/backend && /opt/venv/bin/python scripts/manage_analytics_storage.py --mode maintenance'"The Kamal cron container currently runs analytics maintenance daily at 4:45 AM.
make db-sync preserves destination-local analytics tables by default:
analytics_api_usage_logsanalytics_searchesanalytics_search_impressionsanalytics_events
This prevents local or environment-specific analytics history from being overwritten during syncs.
- Emit it with
scheduleAnalyticsBatch(...)in the relevant component. - Include stable identifiers such as
search_id,resource_id,label,destination_url, andsource_componentwhen relevant. - Prefer structured fields over unbounded strings in
properties. - Add or update frontend tests if the event is behaviorally important.
- Send
X-BTAA-Client-*headers from the client or proxy. - Reuse
X-Visit-Tokenwhere session continuity matters. - Keep the client name stable across releases.
- Use
client_versionfor release or build identification.
If a raw field needs to survive beyond raw retention:
- Add it to a daily rollup table, or create a new rollup table
- Update the maintenance SQL in
backend/db/migrations/analytics_storage.py - Re-run migrations and maintenance
analytics_daily_search_metricsandanalytics_daily_resource_metricsdo not include the current day until the next maintenance run.analytics_daily_resource_metricssummarizes resource-scoped events. Events withoutresource_idare not preserved there.- Search impression counts are preserved in
analytics_daily_search_metrics; there is not currently a separate daily impression rollup table. - Raw data outside retention windows is intentionally discarded once its month is safely rolled up.