docs/features/external-apps-analytics.md

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:

  1. 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.
  2. 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_REGIONCACHE_BUCKET_REGIONus-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)

ItemPKSKPurpose
Daily rollupANALYTICS#<appId>DAY#<yyyy-mm-dd>Page views, engagement, and new-visitor counters
Raw eventANALYTICS#<appId>#EV#<yyyy-mm-dd><ts>#<rand>Dimension and session reduction; TTL 90 days
VisitorANALYTICS#<appId>#VIS<vid>First-seen marker; TTL 400 days
ControlsAPPCTRL#<appId>CONFIGCollection 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:

  • pageview records the normalized path and acquisition/device dimensions.
  • engagement records visible milliseconds and maximum scroll depth.
  • event records explicit data-analytics-event / data-evt clicks 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 gated dev.pokemonpicker.app deployment keeps collection disabled so development sessions do not pollute production trends.

Adding another tracked site

  1. Add the site to ANALYTICS_APPS in src/config/analytics-apps.ts. Mark it external to include it in /admin/apps.
  2. Configure its allowed origins and either a fixed CloudFront distribution id, a distribution-id environment variable, or aliases for discovery.
  3. 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.