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.
Why two tabs?
Section titled “Why two tabs?”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.
Traffic analytics
Section titled “Traffic analytics”How events get collected
Section titled “How events get collected”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).
Event shape
Section titled “Event shape”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.
Storage + retention
Section titled “Storage + retention”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.
What the dashboard returns
Section titled “What the dashboard returns”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:autobecomeshourlyfor7d,dailyfor30d,weeklyfor90d/all. - Top breakdowns — top 10 each: pages (with avg dwell), referrers, countries, device classes.
- Bot section — separated
pageviews+topCrawlersso admins can see crawl frequency without polluting human KPIs. empty: truewhen 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 resolution
Section titled “Country resolution”Country is resolved at ingest in this order:
CloudFront-Viewer-CountryorX-Vercel-IP-Countryheader — accurate when present, but typically only set when the request flows through an edge proxy.- Client-derived
countryfrom the payload — the tracker readsIntl.Locale(navigator.language).region. Reflects browser locale, not exact IP location, but accurate enough for “where are visitors mostly from” dashboards. "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.
SEO / GEO analytics
Section titled “SEO / GEO analytics”Where the data comes from
Section titled “Where the data comes from”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
lastScanfrom the SEO meta blob (score, scanned-at, issues, URL). - GEO citations — the
geoProbeshistory, filtered to the requested range. The dashboard returnstotalProbes,citedProbes, andcitationRate(cited / total, ornullwhen the window has no probes). - Search Console — connection state from the
oauthTokensrepo + most-recentgscMetricssnapshot 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.
urlstracked,changedUrlsthat 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 vs derived
Section titled “Real vs derived”- 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 usage (cross-cutting)
Section titled “AI usage (cross-cutting)”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 requireWebsiteAccess — admin 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.
See also
Section titled “See also”- Inspect site analytics — how-to: read SEO analytics via SDK, embed the tracker, query traffic.
- AnalyticsApi reference — full SDK surface.
- REST API —
/v1/websites/:id/analytics,/v1/websites/:id/analytics/seo,/public/v1/c.