Privacy-first website analytics — page views, user interactions, and content engagement tracking
The Website Analytics system provides lightweight, privacy-first analytics for the CPMP website. It tracks page views and user interaction events without cookies, fingerprinting, or collecting any personally identifiable information (PII). All data stays in Aurora PostgreSQL within the project's AWS account — no third-party services involved.
| Component | Role | Location |
|---|---|---|
analytics.js | Client-side tracking snippet (IIFE, zero deps) | cpmp-redesign/js/analytics.js |
POST /analytics/pageview | Records page views to Aurora | LPO Server — analytics.go |
POST /analytics/event | Records interaction events to Aurora | LPO Server — analytics.go |
GET /admin/page-analytics | Aggregated analytics query (admin auth) | LPO Server — analytics.go |
page_views table | Stores every page view | Aurora PostgreSQL |
page_events table | Stores every interaction event | Aurora PostgreSQL |
| TBCC Dashboard | Visual analytics widget | Command Center |
| KCC Script | CLI analytics check | bash scripts/kcc.sh |
The analytics system follows a simple ingest-store-query pattern. The client-side beacon fires page views and events to two public endpoints on the LPO server. The server validates, truncates, and inserts directly into Aurora PostgreSQL. The admin endpoint runs aggregation queries on demand — no pre-computed materialized views, no caching layer, no background jobs.
Analytics queries hit Aurora directly rather than ElastiCache. The data volume is low (website traffic, not API usage logs), the queries are simple aggregations with time-range filters, and the indexes are purpose-built. Adding a cache layer would add complexity without meaningful performance gain at current scale.
Contrast with Usage Analytics: The /admin/usage-analytics endpoint reports on LPO API usage (price queries, rate limits, billing). That data flows through the SQS queued write pipeline to Aurora. Website analytics is a completely separate system — different tables, different endpoints, different data.
flowchart LR
A["🌐 Browser
analytics.js"] -->|"sendBeacon / fetch
POST JSON"| B["⚡ LPO Server
analytics.go"]
B -->|"INSERT"| C["🗄️ Aurora PostgreSQL
page_views + page_events"]
C -->|"SELECT aggregations"| D["🔒 Admin Endpoint
GET /admin/page-analytics"]
D -->|"JSON response"| E["📊 TBCC Dashboard
+ KCC CLI"]
style A fill:#1e3a5f,stroke:#60a5fa,color:#e2e8f0
style B fill:#064e3b,stroke:#10b981,color:#e2e8f0
style C fill:#334155,stroke:#FF9900,color:#e2e8f0
style D fill:#4c1d95,stroke:#a855f7,color:#e2e8f0
style E fill:#78350f,stroke:#f59e0b,color:#e2e8f0
| Step | Component | What Happens |
|---|---|---|
| 1 | Browser | analytics.js fires on DOMContentLoaded. Sends page view via sendBeacon (or fetch fallback). Fire-and-forget — no callback, no retry. |
| 2 | CloudFront | Passes request through to ALB. Adds CloudFront-Viewer-Country header with 2-letter country code. |
| 3 | LPO Server | Validates JSON, truncates all fields to max lengths, extracts User-Agent from request header and country from CloudFront header. |
| 4 | Aurora PostgreSQL | Direct INSERT into page_views or page_events table. No batch buffering — each request is one row. |
| 5 | Admin Query | GET /admin/page-analytics?days=7 runs 8 aggregation queries against the tables and returns a single JSON response. |
The tracking snippet is a self-contained IIFE with zero dependencies. It lives at cpmp-redesign/js/analytics.js and is included on every page via a <script> tag before the closing </body>.
| ID | Storage | Prefix | Lifetime | Purpose |
|---|---|---|---|---|
| Visitor ID | localStorage (tb_vid) | v_ | Persists until user clears storage | Count unique visitors across sessions |
| Session ID | sessionStorage (tb_sid) | s_ | Resets when tab closes | Group page views within a single browsing session |
ID format: Both IDs are generated as prefix + Math.random().toString(36).substr(2,12) + Date.now().toString(36). They are random strings with no connection to any user identity. Example: v_k8f2m9x3a1b7lxyz123.
navigator.sendBeacon(url, blob) — survives page unload, non-blocking, fire-and-forgetfetch(url, { method: 'POST', keepalive: true }) — for browsers without sendBeacon supportOn every page load, analytics.js automatically sends a page view with:
| Field | Source | Example |
|---|---|---|
page_path | location.pathname | /freedom-moments.html |
page_title | document.title | Freedom Moments — CPMP |
referrer | document.referrer | https://google.com/ |
screen_width | window.innerWidth | 1440 |
screen_height | window.innerHeight | 900 |
visitor_id | localStorage | v_k8f2m9x3a1b7lxyz123 |
session_id | sessionStorage | s_p4r7n2w8c5d1mdef456 |
tbTrack()For interaction events, pages call the global tbTrack function:
// Signature
window.tbTrack(eventType, target, data)
// eventType — one of: video_click, map_pin_click, doc_click, cta_click, link_click
// target — what was clicked (string)
// data — optional object with additional context
// Map pin click (from map.html)
tbTrack('map_pin_click', 'Lahore Medical Camp', {
impact_type: 'medical-camps',
media_type: 'video'
});
// Video play (from index.html)
tbTrack('video_click', 'hero-video', { duration: '2:30' });
// Document library click
tbTrack('doc_click', 'Trinity-Beast-API-Reference');
// CTA button click
tbTrack('cta_click', 'join-us-hero');
<!-- Place before closing </body> tag, after i18n.js -->
<script src="js/i18n.js"></script>
<script src="js/analytics.js"></script>
sequenceDiagram
participant B as Browser
participant JS as analytics.js
participant LS as localStorage
participant SS as sessionStorage
participant API as LPO Server
participant DB as Aurora PostgreSQL
B->>JS: Page loads, IIFE executes
JS->>LS: getItem('tb_vid')
alt Visitor ID exists
LS-->>JS: Return existing ID
else First visit
JS->>LS: setItem('tb_vid', 'v_...')
end
JS->>SS: getItem('tb_sid')
alt Session ID exists
SS-->>JS: Return existing ID
else New tab
JS->>SS: setItem('tb_sid', 's_...')
end
JS->>API: sendBeacon POST /analytics/pageview
API->>DB: INSERT INTO page_views
Note over B,JS: Later — user clicks map pin
B->>JS: tbTrack('map_pin_click', ...)
JS->>API: sendBeacon POST /analytics/event
API->>DB: INSERT INTO page_events
Both tables are auto-created at server startup via MigrateAnalyticsTables() in analytics.go. The migration is idempotent — safe to run on every deploy. All CREATE TABLE and CREATE INDEX statements use IF NOT EXISTS.
| Column | Type | Default | Description |
|---|---|---|---|
id | UUID (PK) | gen_random_uuid() | Auto-generated unique identifier |
page_path | VARCHAR(500) | — | URL path (e.g. /freedom-moments.html) |
page_title | VARCHAR(500) | '' | Document title at time of view |
referrer | VARCHAR(1000) | '' | HTTP referrer URL (empty for direct visits) |
user_agent | VARCHAR(1000) | '' | Browser user-agent string (from request header, not from JS) |
visitor_id | VARCHAR(64) | '' | Anonymous visitor ID from localStorage |
session_id | VARCHAR(64) | '' | Session ID from sessionStorage (resets per tab) |
screen_width | INT | 0 | Viewport width in pixels |
screen_height | INT | 0 | Viewport height in pixels |
country | VARCHAR(10) | '' | 2-letter country code from CloudFront-Viewer-Country header (fallback: X-Country) |
created_at | TIMESTAMPTZ | NOW() | Server-side timestamp (UTC) |
| Column | Type | Default | Description |
|---|---|---|---|
id | UUID (PK) | gen_random_uuid() | Auto-generated unique identifier |
page_path | VARCHAR(500) | — | URL path where the event occurred |
event_type | VARCHAR(100) | — | Validated event type (see Section 5) |
event_target | VARCHAR(500) | '' | Target element or resource name |
event_data | JSONB | '{}' | Additional structured data (max 2KB validated) |
visitor_id | VARCHAR(64) | '' | Anonymous visitor ID |
session_id | VARCHAR(64) | '' | Session ID |
created_at | TIMESTAMPTZ | NOW() | Server-side timestamp (UTC) |
| Index Name | Table | Column(s) | Purpose |
|---|---|---|---|
idx_page_views_page_path | page_views | page_path | Top pages aggregation |
idx_page_views_created_at | page_views | created_at DESC | Time-range filtering |
idx_page_views_visitor_id | page_views | visitor_id | Unique visitor counting |
idx_page_events_event_type | page_events | event_type | Filter by event type |
idx_page_events_created_at | page_events | created_at DESC | Time-range filtering |
idx_page_events_page_path | page_events | page_path | Events per page |
The server validates incoming events against a whitelist of known types. Any event_type not in this list is rejected with 400 Bad Request. To add a new event type, update the validTypes map in analytics.go and redeploy.
| Event Type | Description | Typical Target | Event Data Example | Used On |
|---|---|---|---|---|
video_click |
Video play events | Video filename or ID | {"duration": "2:30"} |
index.html, impact pages |
map_pin_click |
Impact Map pin clicks | Pin title (e.g. "Lahore Medical Camp") | {"impact_type": "medical-camps", "media_type": "video"} |
map.html |
doc_click |
Document library card clicks | Document name | Optional | docs/index.html |
cta_click |
Call-to-action button clicks | Button label or ID | Optional | Any page with CTAs |
link_click |
General outbound/internal link tracking | Link text or URL | Optional | Any page |
Adding a new event type: Edit the validTypes map in trinity-beast-lpo-server/internal/handlers/analytics.go, add the new key with true, and redeploy the ECS services. No database migration needed — the event_type column is a free-form VARCHAR validated at the application layer.
Three endpoints power the analytics system. Two are public (no auth, fire-and-forget) and one is admin-protected.
Auth: None — public, fire-and-forget
Records a single page view. Called automatically by analytics.js on every page load.
| Field | Type | Required | Max Length | Description |
|---|---|---|---|---|
page_path | string | Yes | 500 | URL path (e.g. /freedom-moments.html) |
page_title | string | No | 500 | Document title |
referrer | string | No | 1000 | HTTP referrer |
visitor_id | string | No | 64 | Anonymous visitor ID |
session_id | string | No | 64 | Session ID |
screen_width | int | No | — | Viewport width in pixels |
screen_height | int | No | — | Viewport height in pixels |
Server-added fields (not in request body):
user_agent — extracted from the HTTP User-Agent request header (truncated to 1000 chars)country — extracted from CloudFront-Viewer-Country header, with X-Country as fallbackResponse: 200 OK
{"recorded": "ok"}
Auth: None — public, fire-and-forget
Records a user interaction event. Called via tbTrack() in client code.
| Field | Type | Required | Max Length | Description |
|---|---|---|---|---|
page_path | string | Yes | 500 | URL path where event occurred |
event_type | string | Yes | 100 | Must be in the whitelist (Section 5) |
event_target | string | No | 500 | Target element or resource |
event_data | object | No | 2000 bytes | Additional JSON context |
visitor_id | string | No | 64 | Anonymous visitor ID |
session_id | string | No | 64 | Session ID |
Response: 200 OK
{"recorded": "ok"}
Error responses:
400 — Missing page_path or event_type, or unknown event_type500 — Database insert failureAuth: X-Admin-Key header required
Returns aggregated analytics data for the specified time period. Runs 8 SQL queries against Aurora and returns a single JSON response.
| Param | Accepted Values | Default | Description |
|---|---|---|---|
days | 1, 7, 30, 90 | 7 | Lookback period in days |
curl -s -H "X-Admin-Key: <admin-key>" \
"https://api.cpmp-site.org/admin/page-analytics?days=30"
The GET /admin/page-analytics endpoint returns a JSON object with 10 top-level fields. Each field is the result of a separate SQL aggregation query.
{
"status": "✅ [LPO] [us-east-2] [BeastMirror] [/admin/page-analytics] [200]",
"status_code": 200,
"endpoint": "/admin/page-analytics",
"cluster_node": "BeastMirror",
"region": "us-east-2",
"timestamp": "2026-04-29T13:20:52Z",
"data": {
"period_days": 7,
"total_views": 1234,
"unique_visitors": 456,
"total_events": 89,
"top_pages": [ ... ],
"daily_trend": [ ... ],
"top_referrers": [ ... ],
"events_by_type": [ ... ],
"top_event_targets": [ ... ],
"map_pin_clicks": [ ... ],
"doc_clicks": [ ... ]
}
}
| Field | Type | Max Rows | Description |
|---|---|---|---|
period_days | int | — | The lookback period requested (1, 7, 30, or 90) |
total_views | int | — | Total page views in the period |
unique_visitors | int | — | Count of distinct visitor_id values (excludes empty) |
total_events | int | — | Total interaction events in the period |
top_pages | array | 25 | Most-viewed pages: page_path, page_title, views, unique_visitors |
daily_trend | array | 90 | One entry per day: date (EST), views, unique_visitors |
top_referrers | array | 15 | Traffic sources: referrer (or "(direct)"), count |
events_by_type | array | 5 | Event counts grouped by event_type |
top_event_targets | array | 25 | Most-interacted targets: event_type, event_target, count |
map_pin_clicks | array | — | Map pin clicks grouped by impact_type and media_type (from event_data JSONB) |
doc_clicks | array | — | Document library clicks: document (from event_target), count |
Timezone note: The daily_trend dates are computed in EST (America/New_York) to match the project's convention. All created_at timestamps in the database are stored in UTC.
| Field | Query Pattern |
|---|---|
total_views | SELECT COUNT(*) FROM page_views WHERE created_at >= $since |
unique_visitors | SELECT COUNT(DISTINCT visitor_id) FROM page_views WHERE created_at >= $since AND visitor_id != '' |
top_pages | GROUP BY page_path ORDER BY views DESC LIMIT 25 |
daily_trend | GROUP BY TO_CHAR(created_at AT TIME ZONE 'America/New_York', 'YYYY-MM-DD') ORDER BY day |
top_referrers | GROUP BY referrer ORDER BY cnt DESC LIMIT 15 (empty referrer → "(direct)") |
total_events | SELECT COUNT(*) FROM page_events WHERE created_at >= $since |
events_by_type | GROUP BY event_type ORDER BY cnt DESC |
top_event_targets | GROUP BY event_type, event_target ORDER BY cnt DESC LIMIT 25 |
map_pin_clicks | WHERE event_type = 'map_pin_click' GROUP BY event_data->>'impact_type', event_data->>'media_type' |
doc_clicks | WHERE event_type = 'doc_click' GROUP BY event_target ORDER BY cnt DESC |
The analytics endpoints are public (no auth) by design — the beacon must fire without API keys. Several server-side protections prevent abuse.
Every string field is truncated to its maximum column length before insertion. This prevents oversized payloads from consuming storage or causing insert failures.
| Field | Max Length | Truncation |
|---|---|---|
page_path | 500 chars | Server-side truncate() |
page_title | 500 chars | Server-side truncate() |
referrer | 1000 chars | Server-side truncate() |
visitor_id | 64 chars | Server-side truncate() |
session_id | 64 chars | Server-side truncate() |
user_agent | 1000 chars | Server-side truncate() |
event_type | 100 chars | Server-side truncate() |
event_target | 500 chars | Server-side truncate() |
The /analytics/event endpoint validates event_type against a hardcoded map of allowed values. Unknown types return 400 Bad Request. This prevents arbitrary event injection.
The event_data JSONB field is validated in two ways:
{}.{}. This prevents large payloads from inflating the JSONB column./analytics/pageview — requires page_path (non-empty)/analytics/event — requires both page_path and event_type (non-empty)Protected: Analytics endpoints are rate limited at the ALB WAF level. The RateLimit-Analytics-300 rule blocks any single IP that sends more than 300 requests to /analytics/* within a 5-minute window. This prevents beacon spam and data pollution while allowing normal visitor traffic. The global WAF rate limit (2,000 req/5min per IP) also applies as a secondary cap.
The analytics.js script is included on all public-facing pages. It is placed before the closing </body> tag, after i18n.js.
| Page | File | Custom Events |
|---|---|---|
| Homepage | index.html | video_click on video play |
| Give | give.html | — |
| Donate | donate.html | — |
| Subscribe (LPO) | subscribe-listener.html | — |
| Impact Map | map.html | map_pin_click with impact_type + media_type |
| Freedom Moments | freedom-moments.html | — |
| Wheelchairs | wheelchairs.html | — |
| Clean Water | clean-water.html | — |
| Medical Camps | medical-camps.html | — |
| Provisions | provisions.html | — |
| Training | training.html | — |
| Word of Life | word-of-life.html | — |
| Team | team.html | — |
| Origin Story | origin-story.html | — |
| Newsletters | newsletters.html | — |
| Support | support.html | — |
| Privacy Policy | privacy.html | — |
| Terms of Use | terms.html | — |
| Authority | authority.html | — |
| Copyright | copyright.html | — |
| Thank You (Donation) | thank-you.html | — |
| Thank You (Subscription) | thank-you-listener.html | — |
| Partner Application | partner-apply.html | — |
| Partner Status | partner-status.html | — |
24 pages tracked. All public-facing pages include the beacon. The Document Library (docs/index.html) and individual doc pages do not include it — they are technical reference pages, not public content.
Website analytics data is accessible through two operational interfaces: the Kiro Command Center (KCC) CLI and the Trinity Beast Command Center (TBCC) web dashboard.
The KCC verify script includes the page-analytics endpoint in its health check sweep:
# Full endpoint verification (includes page-analytics)
bash scripts/kcc.sh verify
# Direct curl for analytics data
curl -s -H "X-Admin-Key: <admin-key>" \
"https://api.cpmp-site.org/admin/page-analytics?days=7" | python3 -m json.tool
# 30-day lookback
curl -s -H "X-Admin-Key: <admin-key>" \
"https://api.cpmp-site.org/admin/page-analytics?days=30" | python3 -m json.tool
The Page Analytics widget in the TBCC dashboard provides a visual interface for reviewing analytics data.
Four time periods available:
| Period | API Parameter | Use Case |
|---|---|---|
| 1 day | ?days=1 | Today's traffic snapshot |
| 7 days | ?days=7 (default) | Weekly review |
| 30 days | ?days=30 | Monthly trends |
| 90 days | ?days=90 | Quarterly analysis |
| Component | Data Source | Description |
|---|---|---|
| Summary Cards | total_views, unique_visitors, total_events | Three headline numbers for the selected period |
| Top Pages Table | top_pages | Ranked list of most-viewed pages with view count and unique visitors |
| Daily Trend Chart | daily_trend | Bar chart showing views and unique visitors per day |
| Top Referrers | top_referrers | Traffic sources ranked by volume |
| Events by Type | events_by_type | Breakdown of interaction events by type |
| Top Event Targets | top_event_targets | Most-interacted elements across all event types |
| Map Pin Clicks | map_pin_clicks | Map interactions grouped by impact type and media type |
| Doc Clicks | doc_clicks | Most-clicked documents in the document library |
Real-time data: The widget fetches fresh data from GET /admin/page-analytics each time the period is changed or the dashboard is loaded. No caching — always live from Aurora.
A standalone, no-auth analytics dashboard is available at cpmp-site.org/docs/analytics-dashboard.html. It provides:
The dashboard calls GET /analytics/summary?days=7 (public, no auth) and renders all data client-side. Period selector supports 24h, 7d, 14d, 30d, and 90d windows.
GET /analytics/summary?days=7
# Returns: total_views, unique_visitors, total_sessions, avg_dwell_seconds,
# daily_trend, top_pages (with avg dwell), events_by_type, top_targets,
# scroll_depth, top_referrers, countries, hourly_heatmap, dwell_by_page
| Event | Source | Description |
|---|---|---|
video_play | Auto (analytics.js) | Any <video> play event |
cta_click | Auto (analytics.js) | .btn-primary clicks |
form_submit | Auto (analytics.js) | Any form submission |
download_click | Auto (analytics.js) | Download link clicks |
outbound_click | Auto (analytics.js) | External link clicks |
scroll_depth | Auto (analytics.js) | 25%, 50%, 75%, 100% milestones |
page_dwell | Auto (analytics.js) | Active reading time on page leave |
doc_click | docs/index.html | Document library card clicks |
map_pin_click | map.html | Impact map pin clicks |
video_click | index.html | Homepage video plays |
error_nav | error.html | Error page navigation clicks |
The analytics system is designed with privacy as a core principle. It collects the minimum data needed for useful analytics without compromising visitor privacy.
| Data Point | How It Works | User Control |
|---|---|---|
| Visitor ID | Random string (v_...) in localStorage. Not linked to any identity. | Clear browser storage at any time |
| Session ID | Random string (s_...) in sessionStorage. Resets when tab closes. | Automatic — closes with tab |
| Country | 2-letter code from CloudFront-Viewer-Country header. Set by AWS at the edge. No IP geolocation lookup by our code. | N/A — derived from CDN routing |
| User Agent | Stored as-is from the HTTP request header. Used for browser/device statistics only. | Browser-controlled |
| Screen Size | Viewport dimensions from window.innerWidth/Height. Used for responsive design insights. | Browser-controlled |
GDPR/CCPA friendly: Because no PII is collected and no cookies are used, the system operates without requiring consent banners or opt-in mechanisms.