Ad Click Attribution Pipeline
MediumAI-assisted coding · AI-Assisted Feature Work
Ad Click Attribution Pipeline
You've joined Ads Measurement. adtrack ingests raw tracker events (impressions and clicks) from the web and mobile SDKs and produces the hourly delivery report that billing reconciles against. This sprint you own three tickets: a billing discrepancy in the hourly report, a new reach report, and last-click conversion attribution.
Codebase
| File | What it does |
|---|---|
adtrack/config.py | Constants: bucket size, attribution window, event type names |
adtrack/events.py | Event, ParseResult, IngestSummary dataclasses |
adtrack/parser.py | Validates and normalises one raw payload into an Event |
adtrack/timeutil.py | Hour bucketing and range helpers |
adtrack/store.py | EventStore: de-duplication by event_id, per-campaign time index |
adtrack/rollups.py | HourlyRollup: per-campaign, per-hour counters updated on ingest |
adtrack/reach.py | Reach report (stub, Part 2) |
adtrack/attribution.py | Attribution model interface (empty, Part 3) |
adtrack/service.py | MeasurementService: ingest and reports |
adtrack/app.py | run(operations) dispatcher |
tests/ | Existing unit tests (happy paths only) |
How your code is run
run(operations: list) -> list creates a fresh service, executes each operation in order and returns one output per operation.
A raw event is an object: event_id (string or integer; 7 and "7" are the same id), type (case-insensitive, surrounding spaces ignored), campaign_id (string or integer, normalised to a string), user_id (string; see Part 1), ts (epoch seconds, may be fractional).
| Operation | Arguments | Returns | Errors |
|---|---|---|---|
ingest | events (list of raw events) | {"accepted": n, "duplicates": n, "rejected": n} | invalid payloads are counted in rejected, never raised |
hourly_report | campaign_id, from_ts, to_ts | list of rows, see below | "error: invalid range" if from_ts > to_ts |
reach | campaign_id, end_ts, window_seconds | Part 2 | "error: invalid window" if window_seconds <= 0 |
attribution_report | from_ts, to_ts | Part 3 | "error: invalid range" if from_ts > to_ts |
Unknown operations return "error: unknown operation: <name>".
Ingest rules (existing behaviour). A payload is rejected if it isn't an object, has a missing/blank event_id or campaign_id, an unsupported type, or a ts that is not a number (booleans and strings don't count) or is negative. A payload whose event_id was already ingested, in this batch or an earlier one, is a duplicate and is ignored. Events may arrive out of order.
Hourly report. Hours are epoch-aligned buckets [h, h + 3600). Each row is {"hour": h, "impressions": n, "clicks": n, "ctr": clicks / impressions rounded to 4 places (0.0 when impressions is 0)}. A row is returned for every hour h with from_ts <= h < to_ts that has at least one event for the campaign, in ascending order.
Worked example (H = 1767225600, i.e. 2026-01-01T00:00:00Z; write it as the literal number):
[["ingest", [
{"event_id": "e1", "type": "impression", "campaign_id": "cmp-7", "user_id": "u1", "ts": 1767229200.4},
{"event_id": "e2", "type": "click", "campaign_id": "cmp-7", "user_id": "u1", "ts": 1767229260},
{"event_id": "e2", "type": "click", "campaign_id": "cmp-7", "user_id": "u1", "ts": 1767229260},
{"event_id": "e3", "type": "purchase", "campaign_id": "cmp-7", "user_id": "u1", "ts": 1767229300}]],
["hourly_report", "cmp-7", 1767225600, 1767312000]]returns
[{"accepted": 2, "duplicates": 1, "rejected": 1},
[{"hour": 1767229200, "impressions": 1, "clicks": 1, "ctr": 1.0}]]Part 1 — Hourly report disagrees with the raw logs
Goal: make the hourly report match the raw logs exactly. Two reports from this week:
- *Billing:* "Every hour a few clicks are counted in the next hour's row. They're always events logged in the last moments of the hour, e.g. a click at
1767229199.6(00:59:59.6) shows up under 01:00." - *On-call:* "Since the web SDK started sending logged-out traffic, any
ingestbatch containing an event with"user_id": nullblows up and none of the batch is processed."
Rules
- Fractional timestamps are truncated to whole seconds (
1767229199.999is second1767229199, in the hour starting1767225600). This applies everywhere timestamps are used. user_idthat isnull, missing, or blank/whitespace-only means *logged out*. Such events are valid: they are accepted and count in the hourly report like any other.- Nothing else about ingest or the hourly report changes.
Part 2 — Reach report (ADS-412)
Goal: implement reach in adtrack/reach.py: how many distinct people saw a campaign recently, and how often.
Rules
- The window is
end_ts - window_seconds < ts <= end_ts(start exclusive, end inclusive), using truncated timestamps. - Only impressions count, and only from identified users (non-null
user_id). Clicks and logged-out impressions are ignored entirely. Only the requested campaign counts. - Return
{"users": U, "identified_impressions": I, "frequency": F, "distribution": D}:
U= number of distinct users with at least one counted impression;I= number of counted impressions (duplicates byevent_idare already dropped at ingest);F=I / Urounded to 2 decimal places,0.0whenUis 0;D={"1": a, "2": b, "3+": c}: how many users saw the campaign exactly once, exactly twice, three or more times. All three keys are always present.
Example. Impressions for cmp-1 at H (u4), H+100 and H+200 (u1), H+300 (u2), H+400 (user_id null), a click at H+450 (u3), and an impression at H+3700 (u3). ["reach", "cmp-1", H+3600, 3600] returns {"users": 2, "identified_impressions": 3, "frequency": 1.5, "distribution": {"1": 1, "2": 1, "3+": 0}}. The impression at exactly H is outside the window.
Part 3 — Last-click attribution (ADS-430)
Goal: accept conversion events and credit each one to the campaign of the user's most recent click in the 24 hours before it.
Rules
ingestaccepts"type": "conversion"payloads withevent_id,ts,user_id(optional, same rules as Part 1),order_id(required; string or integer, normalised like other ids) andvalue_cents(required integer>= 0; floats and booleans are invalid).campaign_idis not required on a conversion and is ignored if present. Invalid conversions count asrejected.- An order is counted once: a conversion whose
order_idwas already ingested counts as a duplicate, even with a newevent_id. Repeatedevent_ids are duplicates as before. - A click *qualifies* for a conversion at time T when it has the same (non-null)
user_idandT - 86400 <= click ts <= T(both ends inclusive). Clicks from any campaign qualify. A logged-out conversion never has a qualifying click. - The credited click is the qualifying click with the latest
ts; if several share thatts, the one with the smallestevent_id(string comparison) wins. The same click can be credited by any number of conversions. - Attribution is computed over everything ingested so far, whatever the arrival order: a click ingested after its conversion still earns credit.
["attribution_report", from_ts, to_ts]covers conversions withfrom_ts <= ts < to_ts(the credited click may be earlier) and returns{"campaigns": {campaign_id: {"conversions": n, "revenue_cents": n}}, "unattributed": {"conversions": n, "revenue_cents": n}}. Campaigns with no credited conversions are omitted;unattributedis always present.- Conversions are not ad delivery: they never change
impressions,clicks,ctr, orreach. hourly_reporttakes an optional 5th argument, an options object. With{"conversions": true}, every row also has"conversions": the number of conversions credited to this campaign whose owntsfalls in that hour, and an hour that has credited conversions but no impressions or clicks gets a row with zero counts (ctr0.0). Without the argument, or with"conversions": false, the output is exactly what it was before.
Example. u1 clicks cmp-1 at H and cmp-2 at H+1000; u1 converts at H+5000 (order_id "o-1", 2500 cents); u2 converts at H+6000 with no clicks (1000 cents); order "o-1" is reported again at H+5100 with a new event_id. The ingest returns {"accepted": 4, "duplicates": 1, "rejected": 0}, and ["attribution_report", H, H+86400] returns {"campaigns": {"cmp-2": {"conversions": 1, "revenue_cents": 2500}}, "unattributed": {"conversions": 1, "revenue_cents": 1000}}. ["hourly_report", "cmp-2", H, H+86400, {"conversions": true}] returns a row for hour H (clicks 1, conversions 0) and a row for hour H+3600 (all zeros, conversions 1).
Notes
timeutil.pyandstore.pyare known-good;EventStore.campaign_eventsranges are[from_ts, to_ts).- You may use the AI assistant freely. Say what you're asking it and check what it gives you.
- Later parts must not break earlier ones; hidden tests re-check Part 1 and 2 behaviour.
- The files in
tests/are not run by the grader. Use them, extend them, or ignore them.
Example 1
- Input:
- [["ingest", [impression e1 for cmp-7 at H+3600.4, click e2 at H+3660, the same click e2 again, an unknown type "purchase"]], ["hourly_report", "cmp-7", H, H+86400]] where H = 1767225600
- Output:
- [{"accepted": 2, "duplicates": 1, "rejected": 1}, [{"hour": H+3600, "impressions": 1, "clicks": 1, "ctr": 1.0}]]
- Explanation
- The retried click is a duplicate, the unknown type is rejected, and both remaining events fall in the hour starting at H+3600.
Example 2
- Input:
- Impressions at H (u4), H+100 and H+200 (u1), H+300 (u2), H+400 (logged out), a click at H+450 (u3), an impression at H+3700 (u3); then ["reach", "cmp-1", H+3600, 3600]
- Output:
- {"users": 2, "identified_impressions": 3, "frequency": 1.5, "distribution": {"1": 1, "2": 1, "3+": 0}}
- Explanation
- The window is (H, H+3600], so the impression at exactly H is out; clicks and logged-out impressions never count.
Example 3
- Input:
- u1 clicks cmp-1 at H and cmp-2 at H+1000, converts at H+5000 (order o-1, 2500 cents); u2 converts with no clicks (1000 cents); order o-1 is reported again; then ["attribution_report", H, H+86400]
- Output:
- {"campaigns": {"cmp-2": {"conversions": 1, "revenue_cents": 2500}}, "unattributed": {"conversions": 1, "revenue_cents": 1000}}
- Explanation
- The most recent click within 24 hours gets the credit; the repeated order is a duplicate and counted once.
Guidelines
- Python 3 standard library only
- Timestamps are epoch seconds (UTC); fractional values are truncated to whole seconds
- Money is integer cents
- Events may arrive out of order and may be retried with the same event_id
- Every operation returns a value; errors are "error: ..." strings, never exceptions