Site Analytics
The admin has two analytics entry points backed by the same system:
- Analytics (
/admin/analytics) reports on the portfolio itself. - Apps (
/admin/apps) reports on standalone sites such as Clankie.
Every dashboard combines two complementary sources:
- First-party beacon — a small cookieless client posts page views,
engagement, referrer/UTM context, device dimensions, and outbound or explicit
CTA events. The portfolio beacon lives in
src/components/PortfolioAnalytics.tsx; standalone sites use the same wire contract. - CloudFront edge metrics — CloudWatch (
AWS/CloudFront) supplies total requests, bandwidth, and error rates, including traffic from bots and non-JavaScript clients that the beacon cannot see.
No IP address, cookie, form value, chat message, or other page content is stored.
Visitor and session ids are anonymous random values held in localStorage and
sessionStorage respectively.
Data flow
flowchart TD
portfolioSite["jcvolpe.me<br/>integrated React beacon"]
externalSite["standalone site<br/>inline beacon"]
collect["/api/apps/[appId]/collect<br/><b>Lambda@Edge</b> · public"]
controls{"DynamoDB controls<br/>enabled? sampled?"}
admintable[("AdminData<br/>rollups · raw events · visitors")]
statsapi["/api/admin/apps/[appId]/stats<br/><b>regional admin function</b>"]
dashboard["/admin/analytics or /admin/apps/[appId]<br/>edge SSR + client"]
discovery["CloudFront ListDistributions<br/>portfolio alias discovery"]
cloudwatch[("CloudWatch<br/>AWS/CloudFront")]
portfolioSite -->|"same-origin text/plain beacon"| collect
externalSite -->|"CORS simple request"| collect
collect --> controls
controls -->|enabled| admintable
controls -->|off / sampled out| drop["204 no-op"]
dashboard -->|"prefetch stats + controls"| admintable
dashboard --> statsapi
statsapi --> discovery
statsapi --> cloudwatch
statsapi --> admintable
Runtime placement
/api/apps/* stays on the Lambda@Edge default server so collection is close to
the visitor. At the edge, AWS_REGION is the edge location rather than the
AdminData table region, so the analytics DynamoDB client resolves
APP_ANALYTICS_TABLE_REGION → CACHE_BUCKET_REGION → us-east-1.
Admin pages also render on the edge and prefetch only DynamoDB-backed data. The
browser then calls the regional /api/admin/apps/[appId]/stats route for
CloudFront metrics, keeping the CloudWatch and CloudFront SDKs and read-only IAM
permissions out of the edge bundle.
The portfolio distribution id is generated by CDK. The regional metrics service
therefore discovers it by the aliases in src/config/analytics-apps.ts and
caches the result. PORTFOLIO_CLOUDFRONT_DISTRIBUTION_ID can bypass discovery.
Externally owned distributions can provide a fixed id and an environment
override, as Clankie does.
Storage schema (AdminData table)
| Item | PK | SK | Purpose |
|---|---|---|---|
| Daily rollup | ANALYTICS#<appId> | DAY#<yyyy-mm-dd> | Page views, engagement, and new-visitor counters |
| Raw event | ANALYTICS#<appId>#EV#<yyyy-mm-dd> | <ts>#<rand> | Dimension and session reduction; TTL 90 days |
| Visitor | ANALYTICS#<appId>#VIS | <vid> | First-seen marker; TTL 400 days |
| Controls | APPCTRL#<appId> | CONFIG | Collection kill switch and sample rate |
Read-time reduction is appropriate at current traffic. Each day is capped at 20,000 raw events per dashboard read; the UI marks breakdowns as sampled when a day reaches that cap. Daily materialized dimension rollups are the scaling path if traffic outgrows this model.
Controls
Each dashboard edits controls stored in DynamoDB and cached by the edge ingest route for about 60 seconds:
- Collection enabled — the master kill switch. Disabled collection returns a silent 204 without storing the payload.
- Sample rate — the 0–100% fraction of events retained server-side.
Event contract
The beacon posts text/plain JSON so cross-origin requests remain CORS-simple:
pageviewrecords the normalized path and acquisition/device dimensions.engagementrecords visible milliseconds and maximum scroll depth.eventrecords explicitdata-analytics-event/data-evtclicks and automatically labeled outbound links.
The portfolio beacon ignores /admin, /api, and /debug paths.
Tracked standalone sites
- Clankie (
clankie.bot) uses the shared ingest contract and its own CloudFront distribution. - Pokémon Picker (
pokemonpicker.app) sends production pageview, engagement, acquisition, device, and custom-event data. Its gateddev.pokemonpicker.appdeployment keeps collection disabled so development sessions do not pollute production trends.
Adding another tracked site
- Add the site to
ANALYTICS_APPSinsrc/config/analytics-apps.ts. Mark itexternalto include it in/admin/apps. - Configure its allowed origins and either a fixed CloudFront distribution id, a distribution-id environment variable, or aliases for discovery.
- Deploy a beacon that posts the shared payload to
https://jcvolpe.me/api/apps/<id>/collect.
The dynamic collect route, protected admin APIs, storage, controls, and dashboard need no per-site route copy.
