Skip to content

Analytics

import { Aside } from ‘@astrojs/starlight/components’;

Melbora exposes two analytics surfaces per Website, served from one top-level vc.analytics SDK resource and one Analytics page in the portal:

  • Traffic — pageviews, sessions, dwell, bounce, top pages / referrers / countries / device classes. Comes from a first-party tracker embedded in the website template.
  • SEO / GEO — health-scan score, GEO citation rate, Search Console metrics, citation checklist progress, competitor diff activity, AI quota usage. Composed from data the platform already collects; no new storage.

Both tabs accept a range (7d / 30d / 90d / all, default 30d). The traffic tab also takes an interval (hourly / daily / weekly / auto) for timeline bucket granularity.

They answer different questions:

  • Traffic answers “how many humans are reaching this site, what are they reading, where are they coming from.”
  • SEO / GEO answers “how is this site doing in Google search and AI search.”

Different data sources, different update cadences, different empty states. Splitting them keeps each chart honest.

The website template POSTs JSON events via sendBeacon() to two aliased ingest paths on the Melbora API:

  • /public/v1/c — primary, blocklist-neutral path.
  • /public/analytics/ingest — legacy alias kept for SDK probes and curl scripts.

Both paths require no auth — the tracker fires from end-user browsers. API Gateway rate-limits per IP; bad payload shapes are dropped silently (a 4xx response would leak the schema to scrapers).

Each ingest carries:

interface AnalyticsIngestPayload {
websiteId: string; // wb_<ulid>
sessionId: string; // client-generated UUID, rotated daily
path: string; // URL path, no query string
referrer: string | null; // document.referrer at pageview
dwell: number; // ms visible (0 on pageview, populated on pagehide)
event: "pageview" | "pageend";
country?: string; // 2-letter ISO; client-derived from navigator.language
}

No PII. IP and exact UA are never sent — country and uaClass (desktop / mobile / tablet / bot / other) are derived server-side at ingest from request headers and the optional client-provided country code.

Events land in the analytics-events DynamoDB table keyed by (websiteId, sk) where sk is <ISO timestamp>#<random suffix>. Each row has a 90-day TTL — DynamoDB sweeps them out automatically past that horizon. The range: "all" query is bounded by that 90-day window.

The /v1/websites/:id/analytics route Queries the table for the requested range, aggregates in-memory, and returns:

  • KPIs (humans only)pageviews, sessions, avgDwellMs, bounceRate. Bot traffic is filtered out.
  • Timeline — bucketed array. Bucket size resolves from interval: auto becomes hourly for 7d, daily for 30d, weekly for 90d/all.
  • Top breakdowns — top 10 each: pages (with avg dwell), referrers, countries, device classes.
  • Bot section — separated pageviews + topCrawlers so admins can see crawl frequency without polluting human KPIs.
  • empty: true when no events have been recorded — surfaces a friendly “tracker not installed / no traffic yet” state.

Aggregation is per-request — for the agency-scale traffic this targets, a per-request Query is cheap. There’s a safety cap of 50,000 events per dashboard load; sites that exceed it will get a planned nightly rollup table.

Country is resolved at ingest in this order:

  1. CloudFront-Viewer-Country or X-Vercel-IP-Country header — accurate when present, but typically only set when the request flows through an edge proxy.
  2. Client-derived country from the payload — the tracker reads Intl.Locale(navigator.language).region. Reflects browser locale, not exact IP location, but accurate enough for “where are visitors mostly from” dashboards.
  3. "ZZ" — the explicit “unknown” sentinel.

Direct API Gateway calls (which is what the tracker uses) don’t get the edge headers, so most rows fall through to the client-provided value.

This tab is composed from data already on the SEO service row — no new storage. Reading /v1/websites/:id/analytics/seo reads:

  • Health scan — most recent lastScan from the SEO meta blob (score, scanned-at, issues, URL).
  • GEO citations — the geoProbes history, filtered to the requested range. The dashboard returns totalProbes, citedProbes, and citationRate (cited / total, or null when the window has no probes).
  • Search Console — connection state from the oauthTokens repo + most-recent gscMetrics snapshot from the SEO meta. The cron pulls metrics in the background; this route just reads what’s already there.
  • Citations checklist — directory-listing entries with status tally (live / submitted / pending / failed).
  • Competitor diffs — Premium+ only. urls tracked, changedUrls that had at least one diff in the window, total diff count.
  • AI usage — monthly counters (altText, metaDescription, blogDraft, geoProbe, localPage) vs the tier’s quotas.

Most fields are nullable because the underlying integration may not be set up — the UI degrades to an inline “Connect X” CTA in those cases rather than rendering empty cards.

  • Real (read from a live source) — Search Console metrics (cached from the GSC API), health scan results (from the SEO scanner), GEO probe runs (from the GEO probe history), citation entries (operator-maintained), competitor diff records.
  • Derived (computed at request time) — citation rate (cited / total within the window), AI quota remaining (limit minus used), changedUrls (URLs with diffs in the range).

Nothing on the SEO tab is fabricated — an empty store gets real zeros (or null for ratios that need a denominator), never sample figures.

AI quota counters live on the SEO service row’s metadata.aiUsage blob and are stamped with a monthKey (YYYY-MM). They flip on month boundaries. The atomic-counter write in the API guarantees no lost increments under concurrent AI invocations.

The traffic tab does not include AI usage — that’s an SEO-tier concern, so it’s surfaced in the SEO tab alongside scan/citation data.

Both analytics endpoints use requireWebsiteAccessadmin OR the website’s owning client. The same React component renders on /admin/websites/:id/analytics (admin view) and /app/websites/:id/analytics (client view); the API enforces scope.