Skip to content

Reporting Dashboard - Architecture & Data Flow

Source: /reporting/ (Vue 3 + Vite + TanStack Query v5) Last updated: 2026-07-02

The reporting dashboard is a standalone Vue 3 SPA that visualizes creative performance data from two pipelines: the legacy Aggregation API (Cloudflare) and Bunny Analytics (DuckDB/Parquet). This document covers the frontend architecture, data flow, and lessons learned during development.

High-Level Architecture

Browser (Vue 3 SPA)
  |
  |-- Composables (useReportTotals, useBunnyHourly, useBuckets, useCounts)
  |     |-- TanStack Vue Query v5 (caching, dedup, refetch)
  |     |
  |     +---> /api/* (Cloudflare Pages Function proxy)
  |                 |
  |                 +---> application-backend.cavai.com/v2/...
  |                       |
  |                       +-- /aggregation-api/counts   (CF pipeline)
  |                       +-- /aggregation-api/buckets  (CF pipeline)
  |                       +-- /analytics/bunny/hourly-summary/:id (Bunny pipeline)
  |                       +-- /analytics/bunny/metrics/:id (Bunny custom metric discovery)

Key Files

FilePurpose
src/stores/currentReport.tsPinia store: parsed report state from URL (metrics, resources, dates)
src/stores/reports.tsPinia store: saved reports in localStorage
src/utils/metricCodes.tsMetric definitions, URL encoding/decoding, computed metrics
src/utils/reportUrl.tsReport state <-> URL query params serialization
src/utils/bunnyCsvParser.tsParses Bunny hourly-summary CSV into typed rows
src/utils/formatters.tsNumber/percentage/duration formatting
src/composables/useReportTotals.tsAggregates totals across resources for KPI cards
src/composables/useReportTimeSeries.tsTime series data for charts (aggregation or bunny)
src/composables/useBunnyHourly.tsTanStack query for bunny hourly-summary endpoint
src/composables/useBuckets.tsTanStack query for aggregation buckets endpoint
src/composables/useCounts.tsTanStack query for aggregation counts endpoint
src/composables/useWidgetLayout.tsResolves layout string into widget specs
src/widgets/WidgetGrid.vueGrid container, fetches totals once, passes to children
src/widgets/WidgetCard.vueIndividual KPI card, receives data via props
src/widgets/TimeSeriesChart.vueDaily metrics chart with multi-metric selection

Data Sources

Aggregation API (Legacy / Cloudflare)

  • 16 standard metrics via metric[]=base
  • Two endpoints: /counts (totals) and /buckets (time series)
  • Source of truth for older creatives without Bunny data
  • Data comes from Cloudflare Worker log processing pipeline

Bunny Analytics (New / DuckDB Parquet)

  • Backend reads hive-partitioned parquet files via DuckDB
  • Returns CSV with hourly granularity per device type
  • 9 standard columns exposed in CSV (impressions, clicks, link_clicks, watched_*, seconds_total_active)
  • 22 columns exist in parquet (see reporting-dashboard-progress.md for full list)
  • Custom metrics via MAP column, discovered per-creative
  • 204 No Content = creative has no Bunny data

Source Conflict Problem

Metrics with the same key (e.g. impressions, interacted) exist in both sources with different values. Bunny counts COUNT(DISTINCT session_id) while CF counts CDN-level events. Mixing sources in one report produces nonsensical rates (e.g. "552% of impressions" when dividing CF reached_end by Bunny impressions).

Decision (2026-07-02): Go all-in on Bunny. Hide CF metrics behind a devTools flag for localhost. All creatives have used Bunny for 6+ months per Kevin.

Metric System

MetricSpec

Every metric in the system is a MetricSpec:

ts
interface MetricSpec {
  key: string        // e.g. 'impressions', 'link_clicks', 'scratch_done'
  label: string      // e.g. 'Impressions', 'Link Clicks', 'Scratch done'
  format: 'number' | 'percentage' | 'duration'
  source: 'aggregation' | 'bunny'
}

URL Encoding

Metrics are encoded as short codes in the URL m= parameter:

  • Standard aggregation: imp, vimp, interacted, ir, ctr, start, cont, ca, lc, hc, bc, re, plays, w25, w50, w75, w100, siv, vtr
  • Bunny standard: b_imp, b_clicks, b_link_clicks, b_interacted, b_w25, b_w50, b_w75, b_w100, b_stotal
  • Custom bunny: custom:metric_name

Computed Metrics

interaction-rate, ctr, and vtr are calculated client-side from raw counts. They return ratios (0-1); formatPercentage handles the *100 display conversion.

Dependencies are declared in COMPUTED_DEPENDENCIES so the composables know which raw metrics to fetch.

Rate Display

KPI cards can show a secondary rate (e.g. "0.04% of impressions"). Defined in METRIC_RATES:

ts
METRIC_RATES: Record<string, { denominatorKey: string, denominatorLabel: string }>

WidgetGrid pre-fetches rate denominators alongside the selected metrics to avoid extra API calls.

Widget Layout System

URL Format

?layout=imp:4,b_clicks:3,custom:scratch_done:2

Each entry is metricCode:colspan. Order = display position. Resolved by useWidgetLayout.ts.

Data Flow (KPI Cards)

WidgetGrid
  |-- useReportTotals(ALL metrics + rate denominators)  // single API call
  |     Returns: ResourceTotals = Map<resourceId, Map<metricKey, number>>
  |
  +-- WidgetCard (receives totals, totalsLoading, bunnyNoData as props)
        |-- totalFor(key): sums across all resources
        |-- rateDisplay: computes secondary percentage
        |-- isBunnyUnavailable: shows "No data" instead of "0"

This architecture was a deliberate refactor from the original design where each WidgetCard created its own useReportTotals — that caused N duplicate API calls (one per card) because TanStack Query cache keys differed per metric set.

Data Flow (Time Series Chart)

TimeSeriesChart
  |-- useReportTimeSeries(metric) x8 (pre-allocated slots)
  |     |-- aggregation path: useBuckets → aggregateStandard()
  |     |-- bunny path: useBunnyHourly per creative → aggregateBunny()
  |     |-- computed path: fetch dependencies → calculate
  |
  |-- ALL_CHARTABLE (computed): prefers bunny source when report uses bunny
  |-- useReportTotals(ALL_CHARTABLE): determines which pill buttons to show
  |-- categorizedMetrics: groups by METRIC_CATEGORIES, hides empty categories
  |-- activeRange: zooms chart to days with data > 0

Bunny CSV Parser

parseBunnyCsv(csv: string): BunnyRow[]

Maps CSV column headers to metric keys via STANDARD_COL_TO_KEY:

CSV HeaderMetric Key
Impressionsimpressions
Clicklink_clicks
Clicksclicks
Total Interactionsinteracted
Watched 25%watched_25
Watched 50%watched_50
Watched 75%watched_75
Watched 100%watched_100
Seconds Total Activeseconds_total_active

Non-reserved columns are treated as custom metrics (passed through as-is).

API Client

src/api/client.ts implements a concurrency-limited fetch wrapper:

  • MAX_CONCURRENT = 2 semaphore prevents overwhelming the backend
  • 204 responses return undefined (type lie but handled by parseBunnyCsv)
  • Auth token from Pinia auth store

Known Issues & Debugging Notes

Parquet Format Mismatch (2026-07-02)

Logs-Parser bunny_analytics branch writes hourly_summary.parquet (single file). Backend queries hourly_summary/*/*/*/*.parquet (hive-partitioned glob). Result: backend finds almost no data. See todos/Humology/bunny-parquet-mismatch.md.

Impression Counting Differences

  • CF aggregation counts CDN-level events → higher numbers
  • Bunny Analytics counts COUNT(DISTINCT session_id) → closer to real impressions
  • Bunny is recommended for Humology (see todos/Humology/22a-impression-discrepancy.md)

TanStack Query Cache Key Dedup

Cache keys include the sorted list of metric API keys. Different metric sets = different cache keys = separate API calls. This is why WidgetGrid fetches all metrics in one call rather than letting each card fetch its own subset.

204 No Content Handling

When a creative has no Bunny data, the backend returns 204. apiFetchText returns undefined, parseBunnyCsv returns []. The hasBunnyNoData computed in composables detects this and widgets show "No data" instead of "0".

Internal documentation