Ad Click Aggregator

10 billion clicks per day, counted in real-time for billing and analytics. Every click is money. The hard parts: a streaming aggregation pipeline that groups clicks by campaign, ad, geo, and minute with exactly-once semantics, inline fraud detection that filters bot clicks, click farms, and SDK spoofing before they pollute billing, and an OLAP query layer that answers "how many clicks did campaign X get in the last 24 hours?" in under a second. Google Ads, Meta Ads, Amazon DSP — same pipeline, different scale.

Core: Stream Aggregate + Fraud Filter + OLAP Query~10B clicks/day~115K clicks/sec avg~500K/sec peakMinute granularity
02

Requirements

Functional
  • Ingest clicks from ad servers in batches via Kafka — high throughput, durable
  • Fraud detection inline: filter bot clicks, click farms, SDK spoofing before aggregation
  • Aggregate clicks by campaign_id, ad_id, geo, minute in real-time streaming windows
  • Query analytics: "campaign X clicks last 24h grouped by hour" in < 1 second
  • Billing: hourly batch computes advertiser spend from aggregates, generates invoices
  • Raw clicks archived to S3 for audit and batch reconciliation
Non-Functional
  • Exactly-once counting — double-count = overcharge advertiser = lawsuit
  • End-to-end latency click-to-queryable < 2 minutes
  • Handle 500K clicks/sec peak without backpressure or data loss
  • Query latency < 1 second for dashboard aggregations
  • Fraud filter false-positive rate < 0.1% — legitimate clicks must not be dropped
  • System survives node failures with zero data loss (Kafka replay)
03

Scale Estimation

Clicks/day
~10B
raw events; ~30% filtered as fraud, ~7B billable
Clicks/sec average
~115K
500K peak during ad-heavy hours (US evening)
Aggregation granularity
1 minute
~1M unique (campaign, ad, geo, minute) buckets per day
Raw click size
~500 bytes
10B x 500B = ~5 TB/day raw; archived to S3
Aggregated row size
~100 bytes
1M rows/day x 100B = ~100 MB/day in ClickHouse
Query volume
~10K/sec
dashboard refreshes + API calls from advertisers
04

API Design

POST/api/clicks

Batch ingest clicks from ad servers. Body: {clicks: [{click_id, campaign_id, ad_id, user_id, device_id, ip, geo, timestamp, user_agent}]}. Writes to Kafka topic. Returns {accepted: N, queued: true}. Fire-and-forget from ad server perspective.

GET/api/reports?campaign_id&start&end&group_by=hour

Analytics query. Returns {rows: [{period, campaign_id, ad_id, geo, click_count, spend}]}. Served from ClickHouse. Supports group_by: minute, hour, day. Filters: campaign, ad, geo, date range. Sub-second response.

GET/api/billing?advertiser_id&period=2026-04

Billing summary. Returns {advertiser_id, period, total_clicks, total_spend, breakdown_by_campaign: [...]}. Served from Postgres billing DB. Used for invoice generation.

05

Architecture

A streaming pipeline from ingestion to query: Kafka buffers raw clicks, Fraud Filter (ML scorer) discards bots inline, Flink aggregates by (campaign, ad, geo, minute) with exactly-once checkpoints, ClickHouse stores aggregates for OLAP queries, Postgres stores billing rows for invoicing. Raw clicks archived to S3 for audit.

Ad Click Aggregator ArchitectureSVG
Ad Serversclick events Kafkaclick ingest topic Fraud FilterML scorer inlinebot + farm + spoof Flink Aggregatorgroup by campaign/ad/geo/minute window ClickHouseOLAP queries S3 Archiveraw clicks for audit Postgresbilling DB Dead Letterlate events > 5 min Billing Batchhourly invoice gen Query APIdashboard + reports Advertisers: real-time dashboards (ClickHouse) + monthly invoices (Postgres billing)
Request Flow — Step Through
Ad Servers · click eventsKafka · click ingest topicFraud Filter · ML scorerFlink · 1-min window aggClickHouse · OLAP queriesPostgres · billing DBS3 · raw click archive
Click Next Step to walk through the request flow.
06

Deep Dive — Streaming Aggregation + Fraud Detection

(a) Streaming aggregation. Flink reads from the Kafka "clicks" topic, groups by (campaign_id, ad_id, geo, minute), and counts clicks per window. Exactly-once semantics via Flink checkpoints + Kafka transactional producer.

// Flink pseudocode: 1-minute tumbling window aggregation
clickStream
  .filter(click -> click.fraudScore < THRESHOLD)  // post-fraud-filter
  .keyBy(click -> (click.campaignId, click.adId, click.geo))
  .window(TumblingEventTimeWindows.of(Time.minutes(1)))
  .allowedLateness(Time.minutes(5))
  .sideOutputLateData(deadLetterTag)
  .aggregate(new ClickCounter())     // {key, minute, count}
  .addSink(clickHouseSink);          // write aggregate row

Each 1-minute window produces one row per unique (campaign, ad, geo) combination. At 10B clicks/day across ~1M unique key combinations, ClickHouse ingests ~1M rows/day of aggregates — trivial for ClickHouse.

(b) Fraud detection. An inline ML scorer evaluates each click before it enters the aggregation window:

  • Bot fingerprint: headless browser detection (missing JS APIs), datacenter IP ranges, impossibly fast click timing (human can't click 50 ads in 1 second).
  • Click farm: same device_id clicking many different ads; geographic anomalies (IP in country A, timezone in country B).
  • Click injection / SDK spoofing: mobile attribution fraud — fake click events generated by malware to steal credit for organic installs.

Score > threshold: discard the click, log it to a "fraud" Kafka topic for analysis. Score < threshold: pass to aggregation. Model updated weekly from labeled fraud data. False-positive rate must stay < 0.1% — dropping real clicks costs advertisers money and generates disputes.

Fraud scoring pipeline detail:

// Fraud scoring pseudocode — runs per click in < 5 ms
features = {
  is_datacenter_ip:   ipLookup(click.ip),          // known datacenter ranges
  is_headless:        click.user_agent lacks WebGL/Canvas APIs,
  click_velocity:     countClicksLast60s(click.device_id),
  device_ad_diversity: distinctAdsLast1h(click.device_id),
  geo_mismatch:       click.ip_country != click.tz_country,
  sdk_signature:      validateSDKSignature(click.sig)
}
score = model.predict(features)  // 0.0 (legit) to 1.0 (fraud)
if score > 0.85: DISCARD + log to fraud topic
if score > 0.60: FLAG for manual review
else: PASS to aggregation

The model runs in a sidecar container co-located with the Flink task manager. Feature lookups (IP ranges, device history) use a local Redis cache refreshed every minute. Inference latency: < 5 ms per click, well within the streaming budget.

Fraud transparency report. All discarded clicks are logged to a separate "fraud" Kafka topic with the full click payload + fraud score + feature values. This data feeds a daily transparency report per advertiser: "We filtered X clicks as fraudulent for your campaign. Breakdown: 40% bot, 35% click farm, 25% SDK spoof." Advertisers can appeal — if a pattern of false positives is detected, the model threshold is adjusted for that advertiser's traffic profile.

(c) Late-arriving events. Clicks can arrive late due to network delays, mobile app batching, or ad server retries. Flink uses watermark-based windowing:

  • Events up to 5 minutes late: Flink reprocesses the window (allowed lateness). The ClickHouse row is updated via a ReplacingMergeTree — last version wins.
  • Events beyond 5 minutes: routed to a dead-letter topic. A nightly batch job reconciles dead-letter events against ClickHouse aggregates and applies corrections.

(d) Billing pipeline. An hourly batch job reads ClickHouse aggregates for the past hour, computes per-advertiser spend (click_count x CPC bid), and writes billing rows to Postgres. Monthly invoice generation reads Postgres. Separation of concerns: ClickHouse for fast analytics, Postgres for transactional billing integrity.

Billing reconciliation detail:

  1. Hourly batch reads ClickHouse: SELECT campaign_id, SUM(click_count) FROM agg_clicks WHERE minute BETWEEN :start AND :end GROUP BY campaign_id.
  2. Join with campaign metadata (CPC bid, advertiser_id, budget cap) from a campaign config DB.
  3. Compute spend: click_count * cpc_bid. Apply budget cap — if spend exceeds daily budget, mark campaign as paused.
  4. Write billing row to Postgres: {advertiser_id, hour, campaign_id, clicks, spend, created_at}.
  5. Monthly invoice job aggregates Postgres billing rows: SELECT advertiser_id, SUM(spend) WHERE period = '2026-04' GROUP BY advertiser_id.

ClickHouse schema design. The aggregation table uses ReplacingMergeTree to handle late-arriving window updates:

CREATE TABLE agg_clicks (
  campaign_id  UInt64,
  ad_id        UInt64,
  geo          LowCardinality(String),
  minute       DateTime,
  click_count  UInt64,
  fraud_count  UInt64,
  version      UInt64     -- Flink checkpoint ID for dedup
) ENGINE = ReplacingMergeTree(version)
ORDER BY (campaign_id, ad_id, geo, minute)
PARTITION BY toYYYYMM(minute);

ReplacingMergeTree keeps only the latest version per unique key. When Flink reprocesses a window due to late events, it writes a new row with a higher version — ClickHouse merges and keeps the latest. No double-counting.

Kafka topic design. The clicks topic is partitioned by campaign_id to ensure all clicks for a campaign land on the same partition — preserving ordering per campaign. With ~500K clicks/sec peak, we need ~100 partitions across a 10-broker cluster (each broker handles ~50K msg/sec). Retention: 7 days, giving ample replay window for Flink recovery. Compression: LZ4 for throughput over ratio.

Exactly-once end-to-end. The chain: Kafka consumer offsets committed atomically with Flink checkpoints (Flink's Kafka connector handles this). Flink checkpoints to S3 every 30 seconds. ClickHouse sink uses ReplacingMergeTree with Flink checkpoint ID as the version column — replayed writes are idempotent. The only gap: if ClickHouse acknowledges a write but Flink crashes before checkpointing, the same window is re-sent — but ReplacingMergeTree deduplicates it. True exactly-once from Kafka to ClickHouse.

Click Lifecycle — Ingest to BillingMermaid
sequenceDiagram participant AS as Ad Server participant K as Kafka participant FF as Fraud Filter participant FL as Flink participant CH as ClickHouse participant PG as Postgres (billing) participant D as Dashboard AS->>K: batch of click events K->>FF: consume from topic FF->>FF: ML score each click FF-->>K: discard fraud (score > threshold) FF->>FL: pass clean clicks FL->>FL: group by (campaign, ad, geo, minute) FL->>CH: write aggregate row per window D->>CH: SELECT campaign_id, hour, SUM(clicks) WHERE last 24h CH-->>D: results in < 1 sec Note over PG: Hourly billing batch CH->>PG: read hour aggregates, compute spend PG->>PG: write billing rows, generate invoice
Interview answer

"Clicks flow from ad servers into Kafka. An inline ML fraud scorer filters bots, click farms, and SDK spoofing — score above threshold is discarded. Clean clicks enter Flink, which aggregates by (campaign, ad, geo, minute) in tumbling windows with exactly-once checkpoints. Aggregates write to ClickHouse for sub-second OLAP queries. Late events up to 5 min are handled via allowed lateness; beyond 5 min go to a dead-letter for batch reconciliation. Hourly billing batch reads ClickHouse, computes advertiser spend, writes to Postgres for invoicing. Raw clicks archived to S3 for audit."

07

Interview Tips

  1. Lead with the pipeline. "Kafka ingest, inline fraud ML, Flink streaming aggregation, ClickHouse OLAP, Postgres billing." Name each component and why it's there. This shows you can design end-to-end streaming systems.
  2. Exactly-once is the money question. "Flink checkpoints + Kafka consumer offsets + ClickHouse ReplacingMergeTree for idempotent writes." Double-counting = overcharging = lawsuit. Under-counting = revenue leak. Get this right.
  3. Fraud detection is not optional. In ad tech, ~30% of clicks are fraudulent. If you skip fraud detection, your billing system is fundamentally broken. Name the three types: bot fingerprint, click farm, SDK spoofing.
  4. Explain the late event strategy. "Watermark-based windowing with 5-min allowed lateness. Beyond 5 min: dead-letter queue + nightly batch reconciliation." This shows you understand real-world event-time challenges.
  5. Distinguish storage tiers. "Raw clicks to S3 (audit), aggregates to ClickHouse (analytics), billing rows to Postgres (invoicing)." Each store is chosen for its strength. Using one store for everything fails.
  6. Know your numbers. 10B clicks/day = 115K/sec avg. 500 bytes/click = 5 TB/day raw. Pre-aggregated = ~100 MB/day. These numbers justify every architecture choice.
08

Anti-patterns

-
Count all clicks including bots — "we'll filter later"

Advertiser overpays for bot traffic. Disputes follow. Chargebacks follow. Advertiser churns. In ad tech, fraud filtering is not optional — it's the product.

Better: Inline fraud detection before aggregation. Every click scored before it counts toward billing. Fraudulent clicks logged separately for transparency.
-
Aggregate in batch hourly only — "real-time isn't needed"

Advertiser running a time-sensitive campaign can't see click counts for the current hour. Budget pacing fails — overspend detected 1 hour too late. Billing disputes over the lag window.

Better: Flink streaming aggregation with 1-minute windows. Dashboard shows near-real-time counts. Billing still runs hourly batch for precision, but monitoring is live.
-
Store raw clicks in Postgres — "it's our standard DB"

10B rows/day in an OLTP database. Indexes bloat. Queries timeout. Vacuum can't keep up. Dead in a week. Postgres is not an OLAP engine.

Better: Raw clicks to S3 (cheap archive). Pre-aggregated rows to ClickHouse (columnar, compressed, fast scans). Postgres only for billing rows (~thousands/day).
09

Tradeoffs & Design Choices

  • Flink (true streaming, exactly-once) vs Spark Structured Streaming (micro-batch, simpler ops). Flink gives sub-second latency and native exactly-once via checkpoints. Spark micro-batch adds 5-30 second latency and is simpler to operate. For ad billing where every click is money, Flink's exactly-once is worth the operational complexity.
  • ClickHouse (fast OLAP, MergeTree) vs Druid (real-time ingestion, complex ops). ClickHouse handles both fast ingestion and fast queries with simpler operations. Druid excels at real-time ingestion with segments but requires more operational overhead (ZooKeeper, deep storage). ClickHouse wins on simplicity at this scale.
  • Pre-aggregation (fast reads, limited slicing) vs raw events (flexible slicing, slow scans). Pre-aggregating to (campaign, ad, geo, minute) makes dashboard queries fast but you can't slice by user_agent or device_type after the fact. Raw events in S3 allow any ad-hoc query but are slow. We do both: pre-agg for dashboards, raw archive for deep-dive analysis.
  • Inline fraud filter (low latency, potential false positives) vs post-aggregation filter (no false positives on counts, delayed fraud removal). Inline is better — you never want fraudulent clicks in the billing pipeline even temporarily. False positives are managed by keeping the threshold conservative and logging all decisions for review.
  • Event-time vs processing-time windows. Event-time (timestamp from ad server) is correct but requires watermarks and late-event handling. Processing-time (Flink wall clock) is simpler but skews counts when events arrive out of order. For billing accuracy, event-time is non-negotiable — you must count clicks in the minute they actually happened, not when Flink processed them.
  • Single Kafka topic vs per-advertiser topics. Single topic with campaign_id partitioning is simpler to manage and allows global fraud detection. Per-advertiser topics provide isolation but explode topic count (100K advertisers = 100K topics). Start with a single topic; shard by advertiser only if noisy-neighbor issues arise.
10

Failure Modes

--
Flink checkpoint failure
Flink job crashes mid-window. In-flight aggregation state lost. Risk of double-counting or missing clicks on restart.
Mitigation: Flink replays from last successful Kafka offset checkpoint. Exactly-once semantics ensured via Kafka consumer group + Flink's checkpoint barriers. ClickHouse ReplacingMergeTree deduplicates re-sent rows.
--
ClickHouse node down
One ClickHouse replica fails. Queries to that shard timeout. Dashboard shows stale data or errors.
Mitigation: ClickHouse ReplicatedMergeTree with 3 replicas per shard. ZooKeeper-based replication. Queries automatically route to healthy replicas. No data loss.
--
Fraud model false positive — legitimate clicks dropped
ML model update increases false-positive rate. Real user clicks are discarded. Advertiser sees unexplained drop in click counts. Complaints follow.
Mitigation: shadow-score new models before deployment. Compare fraud rates between old and new model. Alert on > 0.5% delta. All discarded clicks logged to fraud topic — can be re-ingested if model is rolled back.
--
Late events beyond watermark (lost without dead-letter)
Mobile SDK batches clicks and sends them 30 minutes late. Flink's 5-min allowed lateness drops them. Those clicks are never counted.
Mitigation: dead-letter topic for events beyond watermark. Nightly batch reconciliation job reads dead-letter, re-aggregates, patches ClickHouse. Billing corrections applied before invoice generation.
--
Kafka broker failure during peak
A Kafka broker dies during US evening peak (500K clicks/sec). Partitions on that broker become unavailable. Clicks from affected ad servers are rejected.
Mitigation: Kafka replication factor 3, min.insync.replicas=2. On broker failure, controller reassigns partition leadership to replicas within seconds. Ad servers retry with exponential backoff. No data loss if acks=all.
--
Budget pacing failure — overspend
Billing batch runs hourly. Between batches, a viral campaign burns through its daily budget without being paused. Advertiser overspends by 2x.
Mitigation: real-time budget monitoring. A separate Flink job tracks per-campaign spend in a sliding window. When spend approaches 90% of daily budget, publish a "pause campaign" event. Ad servers stop serving ads for that campaign within seconds.
12

Evolution

1

Batch MapReduce hourly

Hadoop job runs every hour, counts clicks from HDFS logs. Dashboard updates once per hour. Billing computed daily. Simple but high latency.

2

Spark micro-batch every 5 min

Spark Structured Streaming reads from Kafka in 5-min micro-batches. Dashboard latency drops to 5 min. Still not real-time for budget pacing.

3

Flink true streaming + ClickHouse

Flink tumbling windows at 1-min granularity. ClickHouse for sub-second OLAP. Dashboard is near-real-time. Budget pacing works.

4

Inline fraud ML + exactly-once

ML fraud scorer runs inline in the Flink pipeline. Exactly-once via checkpoints. Billing is accurate and fraud-free. Advertiser trust improves.

5

Real-time anomaly detection + self-serve analytics

Anomaly detection on click patterns (sudden spike = bot attack or viral ad). Self-serve SQL analytics for advertisers via a ClickHouse query proxy with row-level access control. Auto-scaling Flink job slots based on Kafka consumer lag metrics.

Next up