Flying Blind Without Analytics
An ad system without analytics is flying blind. Every impression served, every click registered, every budget dollar spent, all of it needs to be captured, aggregated, and presented in real time. Advertisers expect to see how their campaigns perform now, not after a batch job runs overnight.
This article builds the complete analytics layer: the event tracking endpoint that records impressions and clicks, the metrics aggregation queries that compute KPIs, a Server-Sent Events (SSE) endpoint that streams live updates, and a dashboard interface with reactive charts. By the end, you will have a live dashboard that updates within seconds of an event occurring.
Event Tracking Architecture
The tracking system has three responsibilities: capture events from the client, persist them to the database, and notify active dashboard connections that new data is available.
The Event Tracker
The tracker is a server-only module that owns the business logic for recording an ad event. It sits between the HTTP endpoint and the database, keeping the route handler thin: the endpoint validates the request and extracts parameters; the tracker inserts the row, increments the campaign spend counter, and tells the event emitter that something happened. Separating these concerns means the same trackEvent function can be called from a background job, a webhook handler, or a test harness without duplicating logic.
// src/lib/server/ads/tracker.ts
import { db } from '$lib/server/db/client'
import { adEvents, campaigns } from '$lib/server/db/schema'
import { eq, sql } from 'drizzle-orm'
import crypto from 'node:crypto'
import type { AdEvent } from '$lib/types/ads'
import { emitAdEvent } from './event-emitter'
interface TrackEventInput {
type: 'impression' | 'click' | 'conversion'
campaignId: string
adGroupId: string
creativeId: string
placementId: string
sessionId: string
userId: string | null
metadata: Record<string, string>
}
/**
* Record an ad event and update campaign spend counters.
*/
export async function trackEvent(input: TrackEventInput): Promise<AdEvent> {
const event: AdEvent = {
id: crypto.randomUUID(),
...input,
timestamp: new Date()
}
// Insert the event
await db.insert(adEvents).values(event)
// Update campaign spend (increment by 1 cent per event)
await db
.update(campaigns)
.set({
budgetSpent: sql`${campaigns.budgetSpent} + 1`,
updatedAt: new Date()
})
.where(eq(campaigns.id, input.campaignId))
// Notify any active SSE connections
emitAdEvent(event)
return event
} The Event Emitter
The tracker knows it needs to tell something that an event happened, but it should not know how many dashboard connections are open, what SSE streams exist, or whether any client is even connected. That knowledge belongs to the SSE layer, not the business-logic layer.
The event emitter is the bridge that keeps the two sides decoupled: the tracker calls emitAdEvent() once; the emitter fans that call out to zero, one, or fifty active listeners without the tracker caring which. This pattern also makes the tracker trivially testable - you can call trackEvent() in a unit test without spinning up an SSE connection.
// src/lib/server/ads/event-emitter.ts
import type { AdEvent } from '$lib/types/ads'
type Listener = (event: AdEvent) => void
const listeners = new Set<Listener>()
/**
* Subscribe to ad events. Returns an unsubscribe function.
*/
export function onAdEvent(listener: Listener): () => void {
listeners.add(listener)
return () => listeners.delete(listener)
}
/**
* Emit an ad event to all active listeners.
*/
export function emitAdEvent(event: AdEvent): void {
for (const listener of listeners) {
try {
listener(event)
} catch (error) {
console.error('Error in ad event listener:', error)
}
}
}
/**
* Get the current number of active listeners (for monitoring).
*/
export function getListenerCount(): number {
return listeners.size
} In-Memory vs. External Pub/SubThe in-memory emitter works for single-server deployments. If you scale to multiple server instances, events emitted on one server will not reach SSE connections on another. For multi-server setups, replace this with Redis Pub/Sub, PostgreSQL LISTEN/NOTIFY, or a message queue.
The Tracking Endpoint
The route handler receives tracking events from the browser and delegates immediately to trackEvent.
Impressions arrive via sendBeacon - a browser API that sends a POST in the background without blocking navigation, which means the request completes even if the user clicks away before it returns.
Clicks take a different path: the user’s browser navigates to the tracking URL directly, the endpoint records the click, then issues a 302 redirect to the advertiser’s landing page. Both paths share the same underlying tracker; only the response differs. The endpoint also handles POST from sendBeacon and GET from direct navigation, routing both through the same logic.
// src/routes/api/ads/track/+server.ts
import { redirect } from '@sveltejs/kit'
import type { RequestHandler } from './$types'
import { trackEvent } from '$lib/server/ads/tracker'
/**
* Tracking endpoint handles both impressions and clicks.
*
* Impressions: GET /api/ads/track?type=impression&id=...
* Clicks: GET /api/ads/track?type=click&id=...&redirect=...
*
* In production, the tracking ID maps to a pre-registered decision
* that contains the campaign, ad group, creative, and placement IDs.
* For simplicity, we pass them as query parameters here.
*/
export const GET: RequestHandler = async ({ url, cookies }) => {
const type = url.searchParams.get('type') as 'impression' | 'click'
const trackingId = url.searchParams.get('id')
const redirectUrl = url.searchParams.get('redirect')
const campaignId = url.searchParams.get('campaign') ?? ''
const adGroupId = url.searchParams.get('adGroup') ?? ''
const creativeId = url.searchParams.get('creative') ?? ''
const placementId = url.searchParams.get('placement') ?? ''
if (!type || !trackingId) {
return new Response('Missing parameters', { status: 400 })
}
const sessionId = cookies.get('ad_session') ?? 'unknown'
// Record the event (fire-and-forget for impressions)
await trackEvent({
type,
campaignId,
adGroupId,
creativeId,
placementId,
sessionId,
userId: null,
metadata: {
trackingId,
userAgent: '' // Could extract from request
}
})
// For clicks, redirect to the advertiser's landing page
if (type === 'click' && redirectUrl) {
redirect(302, redirectUrl)
}
// For impressions, return a 1x1 transparent pixel
return new Response(
Buffer.from('R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7', 'base64'),
{
headers: {
'Content-Type': 'image/gif',
'Cache-Control': 'no-store, no-cache, must-revalidate'
}
}
)
}
// Also handle POST for sendBeacon (which sends POST requests)
export const POST: RequestHandler = async (event) => {
return GET(event)
} Metrics Aggregation
The event table grows by one row per impression and one per click. Exposing raw rows to the dashboard would mean the client doing the aggregation - impractical at any real volume. Instead, we compute aggregates on the server: total impressions, clicks, CTR, and spend are derived from COUNT and arithmetic, not stored as separate fields.
The trade-off here is flexibility versus speed. Maintaining pre-computed counters (a campaign_stats table updated on each event) would make reads O(1), but it would also make the queries rigid - adding a new KPI would require a schema change and a backfill. Computing from raw events means any new metric is a new query, at the cost of scanning more rows as the table grows. The indexing strategy from Part 7 keeps these scans fast enough that the flexibility is worth it at the scale this system is designed for.
One implementation detail worth noting: getCampaignMetrics runs three separate COUNT queries. A production implementation would consolidate these into one query using conditional aggregation (COUNT(*) FILTER (WHERE type = 'impression')), reducing three round-trips to one. The three-query version here prioritises readability.
Here are the core aggregation queries:
// src/lib/server/ads/metrics.ts
import { db } from '$lib/server/db/client'
import { adEvents, campaigns, creatives } from '$lib/server/db/schema'
import { eq, and, gte, lte, sql, desc } from 'drizzle-orm'
// ─── Types ─────────────────────────────────────────────
export interface CampaignMetrics {
campaignId: string
impressions: number
clicks: number
conversions: number
ctr: number // click-through rate (0–1)
cvr: number // conversion rate (0–1)
spend: number // in cents
costPerClick: number // in cents
costPerImpression: number // in cents (CPM / 1000)
}
export interface TimeseriesPoint {
timestamp: string // ISO date string (hourly buckets)
impressions: number
clicks: number
}
export interface TopCreative {
creativeId: string
creativeName: string
impressions: number
clicks: number
ctr: number
}
// ─── Queries ───────────────────────────────────────────
/**
* Get aggregate metrics for a campaign.
*/
export async function getCampaignMetrics(campaignId: string): Promise<CampaignMetrics> {
const impressions = await db
.select({ count: sql<number>`count(*)` })
.from(adEvents)
.where(and(eq(adEvents.campaignId, campaignId), eq(adEvents.type, 'impression')))
const clicks = await db
.select({ count: sql<number>`count(*)` })
.from(adEvents)
.where(and(eq(adEvents.campaignId, campaignId), eq(adEvents.type, 'click')))
const conversions = await db
.select({ count: sql<number>`count(*)` })
.from(adEvents)
.where(and(eq(adEvents.campaignId, campaignId), eq(adEvents.type, 'conversion')))
const impCount = impressions[0]?.count ?? 0
const clickCount = clicks[0]?.count ?? 0
const convCount = conversions[0]?.count ?? 0
return {
campaignId,
impressions: impCount,
clicks: clickCount,
conversions: convCount,
ctr: impCount > 0 ? clickCount / impCount : 0,
cvr: clickCount > 0 ? convCount / clickCount : 0,
spend: impCount + clickCount, // simplified: 1 cent per event
costPerClick: clickCount > 0 ? (impCount + clickCount) / clickCount : 0,
costPerImpression: impCount > 0 ? (impCount + clickCount) / impCount : 0
}
}
/**
* Get hourly timeseries data for a campaign over the last N hours.
*/
export async function getTimeseries(
campaignId: string,
hours: number = 24
): Promise<TimeseriesPoint[]> {
const since = new Date()
since.setHours(since.getHours() - hours)
const result = await db
.select({
hour: sql<string>`date_trunc('hour', ${adEvents.timestamp})`,
type: adEvents.type,
count: sql<number>`count(*)`
})
.from(adEvents)
.where(and(eq(adEvents.campaignId, campaignId), gte(adEvents.timestamp, since)))
.groupBy(sql`date_trunc('hour', ${adEvents.timestamp})`, adEvents.type)
.orderBy(sql`date_trunc('hour', ${adEvents.timestamp})`)
// Transform into timeseries points
const buckets = new Map<string, TimeseriesPoint>()
for (const row of result) {
const key = row.hour
if (!buckets.has(key)) {
buckets.set(key, { timestamp: key, impressions: 0, clicks: 0 })
}
const bucket = buckets.get(key)!
if (row.type === 'impression') bucket.impressions = row.count
if (row.type === 'click') bucket.clicks = row.count
}
return Array.from(buckets.values())
}
/**
* Get the top-performing creatives for a campaign.
*/
export async function getTopCreatives(
campaignId: string,
limit: number = 10
): Promise<TopCreative[]> {
const result = await db
.select({
creativeId: adEvents.creativeId,
type: adEvents.type,
count: sql<number>`count(*)`
})
.from(adEvents)
.where(eq(adEvents.campaignId, campaignId))
.groupBy(adEvents.creativeId, adEvents.type)
// Aggregate by creative
const map = new Map<string, { impressions: number; clicks: number }>()
for (const row of result) {
if (!map.has(row.creativeId)) {
map.set(row.creativeId, { impressions: 0, clicks: 0 })
}
const entry = map.get(row.creativeId)!
if (row.type === 'impression') entry.impressions = row.count
if (row.type === 'click') entry.clicks = row.count
}
// Fetch creative names
const creativeList = await db.query.creatives.findMany({
where: sql`${creatives.id} IN (${sql.join(
Array.from(map.keys()).map((id) => sql`${id}`),
sql`, `
)})`
})
const nameMap = new Map(creativeList.map((c) => [c.id, c.name]))
return Array.from(map.entries())
.map(([id, stats]) => ({
creativeId: id,
creativeName: nameMap.get(id) ?? 'Unknown',
impressions: stats.impressions,
clicks: stats.clicks,
ctr: stats.impressions > 0 ? stats.clicks / stats.impressions : 0
}))
.sort((a, b) => b.impressions - a.impressions)
.slice(0, limit)
} Server-Sent Events Endpoint
SSE uses plain HTTP - no protocol upgrade, no persistent socket negotiation. The server responds with Content-Type: text/event-stream and keeps the connection open, writing newline-delimited text frames as events occur. Each frame looks like this:
event: metrics
data: {"impressions":1420,"clicks":38,"ctr":0.0268}
The browser’s EventSource API parses this format natively. Named events (event: metrics) map directly to addEventListener('metrics', ...) on the client. The blank line after data: marks the end of one frame and the beginning of the next.
If the connection drops, EventSource reconnects automatically with an exponential backoff - behaviour you would have to implement yourself with WebSockets. Since the dashboard only needs server-to-client data flow, SSE is the right tool: simpler than WebSockets, natively reconnecting, and perfectly suited to push-only streams.
// src/routes/api/ads/metrics/+server.ts
import type { RequestHandler } from './$types'
import { onAdEvent } from '$lib/server/ads/event-emitter'
import { getCampaignMetrics } from '$lib/server/ads/metrics'
export const GET: RequestHandler = async ({ url }) => {
const campaignId = url.searchParams.get('campaign')
if (!campaignId) {
return new Response('Missing campaign parameter', { status: 400 })
}
// These must live outside the ReadableStream object because start() and
// cancel() are sibling methods - cancel() cannot reach const variables
// declared inside start(). Declaring them here as let gives both methods
// access through closure over the enclosing function scope.
let unsubscribe: (() => void) | null = null
let heartbeat: ReturnType<typeof setInterval> | null = null
const stream = new ReadableStream({
start(controller) {
const encoder = new TextEncoder()
function send(event: string, data: unknown) {
const payload = `event: ${event}\ndata: ${JSON.stringify(data)}\n\n`
controller.enqueue(encoder.encode(payload))
}
// Push initial metrics immediately so the dashboard is populated
// before the first real event arrives - no waiting, no spinner.
getCampaignMetrics(campaignId).then((metrics) => {
send('metrics', metrics)
})
// Assign to the outer let so cancel() can reach it on disconnect.
unsubscribe = onAdEvent(async (adEvent) => {
if (adEvent.campaignId !== campaignId) return
send('event', {
type: adEvent.type,
creativeId: adEvent.creativeId,
timestamp: adEvent.timestamp
})
// Recompute aggregate metrics after each event so KPI cards
// reflect the latest numbers without any client-side arithmetic.
const metrics = await getCampaignMetrics(campaignId)
send('metrics', metrics)
})
// Heartbeat keeps the TCP connection alive through proxies and
// load balancers that close idle HTTP connections after ~60s.
heartbeat = setInterval(() => {
send('heartbeat', { time: new Date().toISOString() })
}, 30_000)
},
// cancel() is invoked by the ReadableStream infrastructure when the
// client's EventSource closes - tab close, navigation, or network drop.
// This is the only reliable cleanup hook. Never proxy controller.close.
cancel() {
unsubscribe?.()
if (heartbeat) clearInterval(heartbeat)
}
})
return new Response(stream, {
headers: {
'Content-Type': 'text/event-stream',
'Cache-Control': 'no-cache',
Connection: 'keep-alive'
}
})
} Why SSE Instead of WebSockets?Server-Sent Events are simpler than WebSockets for one-way data flow (server → client). They work over standard HTTP, reconnect automatically, and are natively supported by all modern browsers via
EventSource. Since our dashboard only receives updates (it doesn’t send data back), SSE is the right tool.
The Dashboard Client
The dashboard uses two data sources in combination. The server’s load function provides the initial snapshot - campaign details, aggregate metrics, timeseries, and top creatives - so the page renders fully populated on first load without waiting for the SSE connection to establish.
The SSE connection then takes over, streaming updates as new events arrive. Without the server load, the user would see loading spinners for every metric card until the first event fires. Without SSE, the numbers would go stale the moment the page rendered. The two layers are complementary: one for immediacy, one for liveness.
SSE Connection Store
The connection is managed by a factory function rather than a module-level store. The distinction matters: a module store is a singleton - one instance shared across all uses. A factory function creates a fresh instance each time it is called, which is what we need here because each campaign page has its own SSE connection with its own campaignId.
The factory also handles two Svelte-specific concerns: the browser guard prevents EventSource from being instantiated during server-side rendering (where window does not exist), and the state object uses $state so any change to metrics, recentEvents, or connected automatically propagates to every component reading from it, without any manual subscription management.
// src/lib/stores/metrics.svelte.ts
import type { CampaignMetrics } from '$lib/server/ads/metrics'
import { browser } from '$app/environment'
interface MetricsState {
metrics: CampaignMetrics | null
recentEvents: Array<{
type: string
creativeId: string
timestamp: string
}>
connected: boolean
error: string | null
}
/**
* Create a reactive metrics connection for a campaign.
* Returns state that updates in real time via SSE.
*/
export function createMetricsConnection(campaignId: string) {
let state = $state<MetricsState>({
metrics: null,
recentEvents: [],
connected: false,
error: null
})
let eventSource: EventSource | null = null
function connect() {
if (!browser) return
eventSource = new EventSource(`/api/ads/metrics?campaign=${campaignId}`)
eventSource.addEventListener('metrics', (event) => {
state.metrics = JSON.parse(event.data)
state.connected = true
state.error = null
})
eventSource.addEventListener('event', (event) => {
const data = JSON.parse(event.data)
state.recentEvents = [data, ...state.recentEvents.slice(0, 49)]
})
eventSource.addEventListener('heartbeat', () => {
state.connected = true
})
eventSource.onerror = () => {
state.connected = false
state.error = 'Connection lost. Reconnecting...'
}
}
function disconnect() {
eventSource?.close()
eventSource = null
state.connected = false
}
return {
get state() {
return state
},
connect,
disconnect
}
} The Metrics Dashboard Page
The dashboard component calls createMetricsConnection during initialisation and wires the connection lifecycle to Svelte’s component lifecycle. onMount starts the SSE connection only after the component has been inserted into the DOM. The browser guard inside createMetricsConnection handles the SSR case, but onMount is the canonical place to start side effects that require the browser.
The onDestroy closes the connection when the user navigates away, preventing leaked EventSource objects and orphaned subscriptions in the event emitter. Every metric derived from connection.state is a $derived rune, so the reactive graph re-evaluates automatically whenever the SSE stream pushes new data.
<!-- src/routes/(app)/ads/campaigns/[id]/dashboard/+page.svelte -->
<script lang="ts">
import type { PageProps } from './$types'
import { createMetricsConnection } from '$lib/stores/metrics.svelte'
import { formatCurrency, formatPercent } from '$lib/utils/format'
import TimeseriesChart from '$lib/components/ads/TimeseriesChart.svelte'
import TopCreativesChart from '$lib/components/ads/TopCreativesChart.svelte'
let { data }: PageProps = $props()
const connection = createMetricsConnection(data.campaign.id)
let metrics = $derived(connection.state.metrics)
let recentEvents = $derived(connection.state.recentEvents)
let connected = $derived(connection.state.connected)
// $effect with a teardown replaces the onMount/onDestroy pair.
// The connection starts when the component mounts and disconnects when it
// is destroyed (navigation away, parent unmounts) - both handled by one effect.
$effect(() => {
connection.connect()
return () => connection.disconnect()
})
</script>
<div class="dashboard">
<header class="dashboard-header">
<h1>{data.campaign.name} - Live Dashboard</h1>
<div class="connection-status" class:connected>
<span class="status-dot"></span>
{connected ? 'Live' : 'Connecting...'}
</div>
</header>
<!--
svelte:boundary isolates the live metrics section. If SSE data arrives in an
unexpected shape and causes a rendering error, only this section fails - the
campaign header, connection status, and event feed remain functional.
The failed snippet shows a retry button that resets the boundary and tries again.
-->
<svelte:boundary>
{#if metrics}
<!-- KPI Cards -->
<div class="kpi-grid">
<div class="kpi-card">
<span class="kpi-label">Impressions</span>
<span class="kpi-value">{metrics.impressions.toLocaleString()}</span>
</div>
<div class="kpi-card">
<span class="kpi-label">Clicks</span>
<span class="kpi-value">{metrics.clicks.toLocaleString()}</span>
</div>
<div class="kpi-card">
<span class="kpi-label">CTR</span>
<span class="kpi-value">{formatPercent(metrics.ctr)}</span>
</div>
<div class="kpi-card">
<span class="kpi-label">Conversions</span>
<span class="kpi-value">{metrics.conversions.toLocaleString()}</span>
</div>
<div class="kpi-card">
<span class="kpi-label">Spend</span>
<span class="kpi-value">{formatCurrency(metrics.spend)}</span>
</div>
<div class="kpi-card">
<span class="kpi-label">Cost per Click</span>
<span class="kpi-value">{formatCurrency(metrics.costPerClick)}</span>
</div>
</div>
<!-- Timeseries Chart -->
<div class="chart-section">
<h2>Performance Over Time</h2>
<TimeseriesChart data={data.timeseries} />
</div>
<!-- Top Creatives -->
<div class="chart-section">
<h2>Top Creatives</h2>
<TopCreativesChart data={data.topCreatives} />
</div>
{:else}
<div class="loading">Loading metrics...</div>
{/if}
{#snippet failed(error, reset)}
<div class="metrics-error">
<p>Dashboard encountered a rendering error.</p>
<button class="btn btn-sm" onclick={reset}>Retry</button>
</div>
{/snippet}
</svelte:boundary>
<!-- Live Event Feed -->
<div class="event-feed">
<h2>Live Events</h2>
<ul class="event-list">
{#each recentEvents as event (event.timestamp)}
<li class="event-item" class:click={event.type === 'click'}>
<span class="event-type">
{event.type === 'impression' ? '👁' : '👆'}
{event.type}
</span>
<span class="event-creative">{event.creativeId.slice(0, 8)}...</span>
<time class="event-time">
{new Date(event.timestamp).toLocaleTimeString()}
</time>
</li>
{/each}
</ul>
</div>
</div>
<style>
.dashboard {
max-width: 1200px;
margin: 0 auto;
}
.dashboard-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 2rem;
}
.connection-status {
display: flex;
align-items: center;
gap: 0.5rem;
font-size: 0.85rem;
color: var(--text-muted);
}
.status-dot {
width: 8px;
height: 8px;
border-radius: 50%;
background: var(--accent-red-base, #dc2626);
}
.connected .status-dot {
background: var(--accent-green-base, #10b981);
animation: pulse 2s infinite;
}
@keyframes pulse {
0%,
100% {
opacity: 1;
}
50% {
opacity: 0.5;
}
}
.kpi-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
gap: 1rem;
margin-bottom: 2rem;
}
.kpi-card {
background: var(--surface-1, #fff);
border: 1px solid var(--border-color, #e5e7eb);
border-radius: 0.5rem;
padding: 1.25rem;
text-align: center;
}
.kpi-label {
display: block;
font-size: 0.8rem;
color: var(--text-muted, #6b7280);
text-transform: uppercase;
letter-spacing: 0.05em;
margin-bottom: 0.25rem;
}
.kpi-value {
display: block;
font-size: 1.5rem;
font-weight: 700;
}
.chart-section {
margin-bottom: 2rem;
background: var(--surface-1, #fff);
border: 1px solid var(--border-color, #e5e7eb);
border-radius: 0.5rem;
padding: 1.5rem;
}
.event-feed {
background: var(--surface-1, #fff);
border: 1px solid var(--border-color, #e5e7eb);
border-radius: 0.5rem;
padding: 1.5rem;
max-height: 400px;
overflow-y: auto;
}
.event-list {
list-style: none;
padding: 0;
margin: 0;
}
.event-item {
display: flex;
justify-content: space-between;
padding: 0.5rem 0;
border-bottom: 1px solid var(--border-color, #f3f4f6);
font-size: 0.85rem;
animation: slideIn 0.3s ease-out;
}
.event-item.click {
background: rgba(59, 130, 246, 0.05);
}
@keyframes slideIn {
from {
opacity: 0;
transform: translateY(-10px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
.event-type {
font-weight: 500;
min-width: 120px;
}
.event-time {
color: var(--text-muted, #6b7280);
}
</style> The Timeseries Chart Component
For charting, we use a lightweight Canvas-based approach. Two Svelte 5 patterns make the component cleaner than a direct port from Svelte 4 would be.
The canvas element receives the component’s setup logic via {@attach} rather than onMount plus bind:this. The attachment function receives the canvas DOM element directly, which means we do not need a separate $state variable to hold the reference. We also take the opportunity to add a ResizeObserver inside the attachment: whenever the container resizes (window resize, sidebar collapse, layout shift), the chart width updates and the drawing effect reruns. This makes the chart genuinely responsive without any additional code in the parent - the attachment manages the full DOM-to-render lifecycle. When the attachment tears down, the ResizeObserver is disconnected cleanly via the return function.
The drawing itself lives in a $effect nested inside {@attach}. The nested effect tracks data, width, and height as reactive dependencies, so the canvas redraws whenever any of them change. This replaces the combination of bind:this + standalone $effect + onMount that would have been needed in a more mechanical Svelte 4 style.
For charting, we use a lightweight Canvas-based approach. This avoids heavy chart library dependencies:
<!-- src/lib/components/ads/TimeseriesChart.svelte -->
<script lang="ts">
import type { TimeseriesPoint } from '$lib/server/ads/metrics'
interface Props {
data: TimeseriesPoint[]
height?: number
}
let { data, height = 300 }: Props = $props()
// Width is reactive state - updated by the ResizeObserver inside {@attach}
let width = $state(600)
function draw(canvas: HTMLCanvasElement, points: TimeseriesPoint[], w: number, h: number) {
const ctx = canvas.getContext('2d')
if (!ctx) return
const dpr = window.devicePixelRatio || 1
canvas.width = w * dpr
canvas.height = h * dpr
ctx.scale(dpr, dpr)
// Clear
ctx.clearRect(0, 0, w, h)
const padding = { top: 20, right: 20, bottom: 40, left: 60 }
const chartW = w - padding.left - padding.right
const chartH = h - padding.top - padding.bottom
// Find max values
const maxImpressions = Math.max(...points.map((p) => p.impressions), 1)
const maxClicks = Math.max(...points.map((p) => p.clicks), 1)
const maxValue = Math.max(maxImpressions, maxClicks)
// Draw grid lines
ctx.strokeStyle = '#e5e7eb'
ctx.lineWidth = 0.5
for (let i = 0; i <= 4; i++) {
const y = padding.top + (chartH / 4) * i
ctx.beginPath()
ctx.moveTo(padding.left, y)
ctx.lineTo(w - padding.right, y)
ctx.stroke()
// Y-axis labels
ctx.fillStyle = '#6b7280'
ctx.font = '11px sans-serif'
ctx.textAlign = 'right'
const label = Math.round(maxValue - (maxValue / 4) * i)
ctx.fillText(label.toString(), padding.left - 8, y + 4)
}
// Draw impressions line
drawLine(
ctx,
points.map((p) => p.impressions),
{
color: '#3b82f6',
maxValue,
chartW,
chartH,
padding
}
)
// Draw clicks line
drawLine(
ctx,
points.map((p) => p.clicks),
{
color: '#10b981',
maxValue,
chartW,
chartH,
padding
}
)
// Legend
ctx.font = '12px sans-serif'
ctx.fillStyle = '#3b82f6'
ctx.fillRect(padding.left, h - 15, 12, 12)
ctx.fillStyle = '#374151'
ctx.fillText('Impressions', padding.left + 18, h - 5)
ctx.fillStyle = '#10b981'
ctx.fillRect(padding.left + 110, h - 15, 12, 12)
ctx.fillStyle = '#374151'
ctx.fillText('Clicks', padding.left + 128, h - 5)
}
function drawLine(
ctx: CanvasRenderingContext2D,
values: number[],
opts: {
color: string
maxValue: number
chartW: number
chartH: number
padding: { top: number; left: number }
}
) {
if (values.length < 2) return
const { color, maxValue, chartW, chartH, padding } = opts
const step = chartW / (values.length - 1)
ctx.strokeStyle = color
ctx.lineWidth = 2
ctx.lineJoin = 'round'
ctx.lineCap = 'round'
ctx.beginPath()
for (let i = 0; i < values.length; i++) {
const x = padding.left + step * i
const y = padding.top + chartH - (values[i] / maxValue) * chartH
if (i === 0) ctx.moveTo(x, y)
else ctx.lineTo(x, y)
}
ctx.stroke()
// Area fill
ctx.lineTo(padding.left + step * (values.length - 1), padding.top + chartH)
ctx.lineTo(padding.left, padding.top + chartH)
ctx.closePath()
ctx.fillStyle = color + '15' // 15 = ~8% opacity
ctx.fill()
}
</script>
<div class="chart-container">
<!--
{@attach} replaces bind:this + onMount. The attachment function receives the
canvas element directly, sets up a ResizeObserver on the parent for responsive
width, then runs a nested $effect to redraw whenever data or dimensions change.
-->
<canvas
style="width: 100%; height: {height}px"
{@attach (canvasEl) => {
// Get initial width from parent container
width = canvasEl.parentElement?.clientWidth ?? 600
// Stay responsive: update width when the container resizes
const resizeObserver = new ResizeObserver(([entry]) => {
width = entry.contentRect.width
})
if (canvasEl.parentElement) {
resizeObserver.observe(canvasEl.parentElement)
}
// Reactive drawing: reruns whenever data, width, or height changes
$effect(() => {
if (data.length === 0) return
draw(canvasEl, data, width, height)
})
return () => resizeObserver.disconnect()
}}
></canvas>
</div>
<style>
.chart-container {
width: 100%;
position: relative;
}
</style> Chart Library AlternativesThe Canvas-based chart above is intentionally minimal to avoid external dependencies. For production dashboards, consider LayerCake (Svelte-native, highly customizable), Chart.js (popular, full-featured), or Apache ECharts (enterprise-grade). LayerCake is particularly well-suited for Svelte because it uses the component model for chart layers.
The Top Creatives Table
The TopCreativesChart imported in the dashboard page ranks each creative by impression volume and shows its click-through rate. Unlike the timeseries view, this comparison does not need a canvas - an HTML table with CSS-driven proportion bars is more readable for a ranked list.
The component receives the TopCreative[] array loaded server-side, so no client-side fetch is needed. Each row’s relative CTR bar is sized against the top performer using $derived, which keeps the calculation reactive if the data prop ever updates.
<!-- src/lib/components/ads/TopCreativesChart.svelte -->
<script lang="ts">
import type { TopCreative } from '$lib/server/ads/metrics'
import { formatPercent } from '$lib/utils/format'
interface Props {
data: TopCreative[]
limit?: number
}
let { data, limit = 10 }: Props = $props()
// Slice once; derive the max CTR so the bar widths stay proportional
// to the top performer rather than to an arbitrary fixed scale.
let rows = $derived(data.slice(0, limit))
let maxCtr = $derived(Math.max(...rows.map((r) => r.ctr), 0.001))
</script>
{#if rows.length === 0}
<p class="empty">No creative data yet - impressions will appear here as ads are served.</p>
{:else}
<div class="top-creatives">
<table>
<thead>
<tr>
<th>Creative</th>
<th class="num">Impressions</th>
<th class="num">Clicks</th>
<th class="num">CTR</th>
<th class="bar-col">Relative CTR</th>
</tr>
</thead>
<tbody>
{#each rows as row (row.creativeId)}
{@const relativeWidth = (row.ctr / maxCtr) * 100}
<tr>
<td class="name" title={row.creativeId}>{row.creativeName}</td>
<td class="num">{row.impressions.toLocaleString()}</td>
<td class="num">{row.clicks.toLocaleString()}</td>
<td class="num ctr">{formatPercent(row.ctr)}</td>
<td class="bar-col">
<div class="bar-track">
<div class="bar-fill" style="width: {relativeWidth}%"></div>
</div>
</td>
</tr>
{/each}
</tbody>
</table>
</div>
{/if}
<style>
.top-creatives {
overflow-x: auto;
}
table {
width: 100%;
border-collapse: collapse;
font-size: 0.875rem;
}
th {
text-align: left;
padding: 0.5rem 0.75rem;
font-size: 0.7rem;
text-transform: uppercase;
letter-spacing: 0.05em;
color: var(--text-muted, #6b7280);
border-bottom: 1px solid var(--border-color, #e5e7eb);
}
td {
padding: 0.6rem 0.75rem;
border-bottom: 1px solid var(--border-color, #f3f4f6);
white-space: nowrap;
}
td.name {
max-width: 220px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.num {
text-align: right;
}
.ctr {
font-weight: 600;
color: var(--accent-blue-base, #3b82f6);
}
.bar-col {
width: 140px;
padding-right: 0;
}
.bar-track {
height: 8px;
background: var(--surface-2, #f3f4f6);
border-radius: 4px;
overflow: hidden;
}
.bar-fill {
height: 100%;
background: var(--accent-blue-base, #3b82f6);
border-radius: 4px;
transition: width 0.4s ease;
}
tr:last-child td {
border-bottom: none;
}
.empty {
color: var(--text-muted, #6b7280);
font-size: 0.875rem;
padding: 1rem 0;
}
</style> Because rows and maxCtr are both $derived, any future SSE update that changes the data prop will automatically re-derive both values and the DOM will update without any manual bookkeeping.
Dashboard Load Function
The load function does two things: it confirms the campaign exists (returning a 404 if not) and pre-fetches the data the dashboard needs to render fully on first load. All four queries run in parallel via Promise.all - campaign details, aggregate metrics, timeseries, and top creatives arrive in a single server round-trip rather than sequentially.
This matters because SSE connections are established client-side, after the page has already rendered. A user opening the dashboard sees populated KPI cards immediately from the server-rendered HTML; the SSE connection then establishes in the background and begins streaming updates.
Without this initial server load, every metric card would show a loading state until the first event arrived - which could be seconds or minutes depending on traffic.
// src/routes/(app)/ads/campaigns/[id]/dashboard/+page.server.ts
import type { PageServerLoad } from './$types'
import { error } from '@sveltejs/kit'
import { campaignRepository } from '$lib/server/ads/campaigns'
import { getCampaignMetrics, getTimeseries, getTopCreatives } from '$lib/server/ads/metrics'
export const load: PageServerLoad = async ({ params }) => {
const campaign = await campaignRepository.findById(params.id)
if (!campaign) {
error(404, 'Campaign not found')
}
// Load initial metrics for immediate render
const [metrics, timeseries, topCreatives] = await Promise.all([
getCampaignMetrics(params.id),
getTimeseries(params.id, 24),
getTopCreatives(params.id, 10)
])
return {
campaign,
initialMetrics: metrics,
timeseries,
topCreatives
}
} The Metrics Data Flow
To summarize how everything connects:
What’s Next
The analytics layer is now complete:
- Event tracking endpoint handling impressions, clicks, and conversions
- In-memory event emitter connecting the tracker to SSE connections
- Metrics aggregation queries for KPIs, timeseries, and top creatives
- SSE endpoint streaming real-time updates to dashboard clients
- Reactive dashboard with KPI cards, charts, and a live event feed
- Canvas-based charting for lightweight, dependency-free visualization