Measuring an AI feature from its logs
Typed views over event logs, metric specs, cost from a price table, and rollups you can rerun.
Create a free account Try part of it here
Why it matters
The shop added an assistant to its checkout page, and a month later someone asks: is it working? Does it settle questions, or do people still ask for a person? What does each conversation cost? Did people like the answers? The answers are in the logs, but logs are a pile of events in different shapes, written by code that changed during the month, with duplicates from retries and test traffic mixed in. This lesson turns that pile into numbers you can defend: views that parse each event once, metrics defined before they're computed, and daily rollups you can rerun any time without counting anything twice.
What you'll be able to do
- Log raw events and turn them into typed SQL views, coping with payloads whose shape changed over time
- Write a metric down precisely, with its numerator, denominator and what's left out, before computing it
- Compute a feature's daily metrics with SQL, including cost from a price table and the latest rating per reply
- Make ingestion and daily rollups idempotent, so reruns and late events never double count
What it covers
- Log events, decide the metrics later
- Typed views: parse the payload once
- The latest rating per reply
- Write the metric down before you compute it
- The daily numbers in one query
- Rollups you can run twice
- Ingest a batch exactly once
Show the answer
Logged events per conversation, turned into metrics afterward
Log what happened (questions, answers, handoffs to a person, purchases), and decide the metrics later: resolution rate, handoffs, cost per conversation. Data has to be recorded before it can be measured.
Log events, decide the metrics later
The assistant's code doesn't compute metrics. It records events: something happened, when, in which session, for which user, plus a payload of details as JSON. A session starts; the model is called (with the model's name, token counts and how long it took); the user rates a reply; the user asks for a person, a handoff. Because the raw events are kept, a metric nobody thought of on launch day can still be computed for every past day.
import json import sqlite3 db = sqlite3.connect(":memory:") db.execute("""CREATE TABLE events (event_id TEXT PRIMARY KEY, ts TEXT, session_id TEXT, user_id TEXT, type TEXT, payload TEXT)""") def ev(event_id, ts, session, user, kind, **payload): return (event_id, ts, session, user, kind, json.dumps(payload)) EVENTS = [ ev("e1", "2025-06-02 09:00:05", "s1", "ana", "session_start", page="checkout"), ev("e2", "2025-06-02 09:00:09", "s1", "ana", "model_call", model="small", input_tokens=900, output_tokens=120, latency_s=1.4), ev("e3", "2025-06-02 09:01:30", "s1", "ana", "feedback", reply_id="e2", rating="down"), ev("e4", "2025-06-02 09:02:10", "s1", "ana", "handoff", reason="asked for a person"), ev("e5", "2025-06-02 14:15:00", "s2", "bo", "session_start", page="tents"), ev("e6", "2025-06-02 14:15:04", "s2", "bo", "model_call", model="large", input_tokens=1500, output_tokens=300, latency_ms=2600), ev("e7", "2025-06-02 14:16:00", "s2", "bo", "feedback", reply_id="e6", rating="down"), ev("e7b", "2025-06-02 14:17:30", "s2", "bo", "feedback", reply_id="e6", rating="up"), ev("e8", "2025-06-02 15:00:00", "s3", "test-load", "session_start", page="checkout"), ev("e9", "2025-06-02 15:00:02", "s3", "test-load", "model_call", model="small", input_tokens=50, output_tokens=10, latency_ms=300), ev("e10", "2025-06-02 16:40:00", "s4", "cy", "session_start", page="checkout"), ev("e11", "2025-06-03 08:30:00", "s5", "dee", "session_start", page="checkout"), ev("e12", "2025-06-03 08:30:03", "s5", "dee", "model_call", model="small", input_tokens=700, output_tokens=90, latency_ms=900), ev("e13", "2025-06-03 08:31:10", "s5", "dee", "model_call", model="small", input_tokens=800, output_tokens=150, latency_ms=1100), ev("e14", "2025-06-03 08:32:00", "s5", "dee", "feedback", reply_id="e13", rating="down"), ev("e16", "2025-06-03 11:05:00", "s6", "eli", "session_start", page="returns"), ev("e17", "2025-06-03 11:05:06", "s6", "eli", "model_call", model="large", input_tokens=2100, output_tokens=420, latency_ms=3900), ev("e18", "2025-06-03 11:07:00", "s6", "eli", "handoff", reason="refund over limit"), ] db.executemany("INSERT INTO events VALUES (?, ?, ?, ?, ?, ?)", EVENTS) print(db.execute("SELECT type, count(*) FROM events GROUP BY type ORDER BY type").fetchall())
Look at the two latency fields. Until midday on June 2 the logger wrote latency_s in seconds; a later release switched to latency_ms in milliseconds. Changes like that happen in every real log, and any query that reads latency_ms directly silently loses the older rows.
import json, sqlite3 db = sqlite3.connect(":memory:") payload = json.dumps({"model": "small", "usage": {"input_tokens": 900}}) print(db.execute("SELECT json_extract(?, '$.usage.input_tokens'), json_extract(?, '$.latency_ms')", (payload, payload)).fetchone())
Show the answer
(900, None)
The path $.usage.input_tokens walks into the nested object and returns the number 900 as a number. A path that doesn't exist gives SQL NULL, which Python shows as None: no error, which is exactly why a renamed field can vanish from a report without anyone noticing.
Typed views: parse the payload once
Every report that digs into the JSON itself repeats the parsing, and each copy can get it slightly wrong. Instead, define a view per event type: a saved query that looks like a table, with proper columns and types. The view is the one place that knows the payload's shape, including its history, so when the logger changes again you fix one view and every report is right.
import json import sqlite3 db = sqlite3.connect(":memory:") db.execute("CREATE TABLE events (event_id TEXT PRIMARY KEY, ts TEXT, session_id TEXT, user_id TEXT, type TEXT, payload TEXT)") calls = [("e2", "2025-06-02 09:00:09", {"model": "small", "input_tokens": 900, "output_tokens": 120, "latency_s": 1.4}), ("e6", "2025-06-02 14:15:04", {"model": "large", "input_tokens": 1500, "output_tokens": 300, "latency_ms": 2600}), ("e12", "2025-06-03 08:30:03", {"model": "small", "input_tokens": 700, "output_tokens": 90, "latency_ms": 900})] db.executemany("INSERT INTO events VALUES (?, ?, 's1', 'ana', 'model_call', ?)", [(i, ts, json.dumps(p)) for i, ts, p in calls]) db.execute(""" CREATE VIEW model_calls AS SELECT event_id, ts, session_id, user_id, json_extract(payload, '$.model') AS model, CAST(json_extract(payload, '$.input_tokens') AS INTEGER) AS input_tokens, CAST(json_extract(payload, '$.output_tokens') AS INTEGER) AS output_tokens, CAST(COALESCE(json_extract(payload, '$.latency_ms'), json_extract(payload, '$.latency_s') * 1000) AS REAL) AS latency_ms FROM events WHERE type = 'model_call'""") for row in db.execute("SELECT event_id, model, input_tokens, latency_ms FROM model_calls"): print(row)
COALESCE returns its first argument that isn't NULL: the new field when it's there, otherwise the old one converted to milliseconds, and CAST(... AS REAL) gives every row the same type. Every row now has a latency in one unit. A view stores no data, so it's always current with the table underneath. A sessions view does the same for session_start events, with the day each session started as a column.
The latest rating per reply
A user can rate a reply, then change their mind; only their last rating counts. Write create_ratings_view(db): create a view ratings with columns reply_id and rating, one row per rated reply, holding the rating from that reply's latest feedback event (by ts). Use json_extract on the payload and a window function: ROW_NUMBER() OVER (PARTITION BY ... ORDER BY ts DESC) numbers each reply's ratings from newest to oldest, so you keep row number 1.
import json import sqlite3 def create_ratings_view(db): db.execute("""CREATE VIEW ratings AS SELECT json_extract(payload, '$.reply_id') AS reply_id, json_extract(payload, '$.rating') AS rating FROM events WHERE type = 'feedback'""") db = sqlite3.connect(":memory:") db.execute("CREATE TABLE events (event_id TEXT PRIMARY KEY, ts TEXT, session_id TEXT, user_id TEXT, type TEXT, payload TEXT)") db.executemany("INSERT INTO events VALUES (?, ?, ?, ?, ?, ?)", [ ("f1", "2025-06-03 08:32:00", "s5", "dee", "feedback", json.dumps({"reply_id": "e13", "rating": "down"})), ("f2", "2025-06-03 08:33:00", "s5", "dee", "feedback", json.dumps({"reply_id": "e13", "rating": "up"})), ]) create_ratings_view(db) print(db.execute("SELECT * FROM ratings").fetchall())
Write the metric down before you compute it
"Resolution rate" sounds obvious until you compute it. Out of which sessions: all of them, or only those where the assistant answered at least once? Does a test account count? A handoff the next day? Two analysts with the same logs will publish different numbers unless the definition is written down first, as a short metric spec:
| metric | numerator | denominator | left out |
|---|---|---|---|
| resolution rate | answered sessions with no handoff | sessions with at least one model call | test users |
| thumbs-down per 100 replies | replies whose latest rating is down | all replies, rated or not | test users |
| cost per day | sum of each call's tokens times its model's price | (a total, no denominator) | nothing: test traffic costs money too |
All the timestamps are in UTC, so "a day" is a UTC day; a spec says so, because a team in California and one in Berlin would otherwise split the days differently.
Notice the last row. Whether to leave something out depends on the question: test sessions aren't customers, so they don't belong in a resolution rate, but the bill includes them.
Tokens become money through a price table, not a cost the logger computed: prices change, and a table keeps them in one place. Give it valid_from and valid_to dates, joined on each call's date, and every past day is priced at the rate that applied then. (The practice table below has one price per model, to keep it short.)
Unrated replies belong in the denominator: leaving them out makes the rate look far worse than it is.
The daily numbers in one query
With typed views in place, each metric is a short, readable query. A common table expression (WITH name AS (...)) names each step, so the query reads like the spec. This cell rebuilds June 2's events with the three views (the ratings view is the drill's) and the price table, then computes the day:
import json import sqlite3 db = sqlite3.connect(":memory:") db.executescript(""" CREATE TABLE events (event_id TEXT PRIMARY KEY, ts TEXT, session_id TEXT, user_id TEXT, type TEXT, payload TEXT); CREATE TABLE prices (model TEXT PRIMARY KEY, input_per_mtok REAL, output_per_mtok REAL); INSERT INTO prices VALUES ('small', 1.0, 5.0), ('large', 3.0, 15.0); -- practice prices, dollars per million tokens CREATE VIEW sessions AS SELECT session_id, user_id, date(ts) AS day FROM events WHERE type = 'session_start'; CREATE VIEW model_calls AS SELECT event_id, session_id, json_extract(payload, '$.model') AS model, json_extract(payload, '$.input_tokens') AS input_tokens, json_extract(payload, '$.output_tokens') AS output_tokens FROM events WHERE type = 'model_call'; CREATE VIEW ratings AS SELECT reply_id, rating FROM ( SELECT json_extract(payload, '$.reply_id') AS reply_id, json_extract(payload, '$.rating') AS rating, ROW_NUMBER() OVER (PARTITION BY json_extract(payload, '$.reply_id') ORDER BY ts DESC) AS newest FROM events WHERE type = 'feedback') WHERE newest = 1; """) day = [("e1", "09:00:05", "s1", "ana", "session_start", {}), ("e2", "09:00:09", "s1", "ana", "model_call", {"model": "small", "input_tokens": 900, "output_tokens": 120}), ("e3", "09:01:30", "s1", "ana", "feedback", {"reply_id": "e2", "rating": "down"}), ("e4", "09:02:10", "s1", "ana", "handoff", {}), ("e5", "14:15:00", "s2", "bo", "session_start", {}), ("e6", "14:15:04", "s2", "bo", "model_call", {"model": "large", "input_tokens": 1500, "output_tokens": 300}), ("e7", "14:16:00", "s2", "bo", "feedback", {"reply_id": "e6", "rating": "down"}), ("e7b", "14:17:30", "s2", "bo", "feedback", {"reply_id": "e6", "rating": "up"}), ("e8", "15:00:00", "s3", "test-load", "session_start", {}), ("e9", "15:00:02", "s3", "test-load", "model_call", {"model": "small", "input_tokens": 50, "output_tokens": 10}), ("e10", "16:40:00", "s4", "cy", "session_start", {})] db.executemany("INSERT INTO events VALUES (?, ?, ?, ?, ?, ?)", [(i, "2025-06-02 " + t, s, u, k, json.dumps(p)) for i, t, s, u, k, p in day]) DAILY = """ WITH real AS (SELECT session_id FROM sessions WHERE day = :day AND user_id NOT LIKE 'test-%'), calls AS (SELECT m.event_id, m.session_id FROM model_calls m JOIN real r ON r.session_id = m.session_id), answered AS (SELECT DISTINCT session_id FROM calls), handed AS (SELECT DISTINCT session_id FROM events WHERE type = 'handoff') SELECT (SELECT count(*) FROM real) AS sessions, (SELECT count(*) FROM answered) AS answered, (SELECT count(*) FROM answered a WHERE NOT EXISTS (SELECT 1 FROM handed h WHERE h.session_id = a.session_id)) AS resolved, (SELECT count(*) FROM calls) AS replies, (SELECT count(*) FROM calls c JOIN ratings g ON g.reply_id = c.event_id WHERE g.rating = 'down') AS downs """ COST = """SELECT round(sum(m.input_tokens * p.input_per_mtok + m.output_tokens * p.output_per_mtok) / 1e6, 4) FROM model_calls m JOIN prices p ON p.model = m.model WHERE m.session_id IN (SELECT session_id FROM sessions WHERE day = :day)""" cursor = db.execute(DAILY, {"day": "2025-06-02"}) print(dict(zip([c[0] for c in cursor.description], cursor.fetchone()))) print("cost", db.execute(COST, {"day": "2025-06-02"}).fetchone()[0])
Three real sessions started (the test one is left out), two got an answer, and one of those ended in a handoff: a resolution rate of 1 in 2. The query says NOT EXISTS rather than NOT IN on purpose: if a single handoff event was logged with a NULL session, x NOT IN (...) is never true for any session (SQL can't say x differs from an unknown), and every day would report zero resolved sessions. Messy logs make that trap real. So do missing prices: the join to prices silently drops calls from a model with no price row, so a trust check before the report counts calls with no price, and calls with no session_start. The cost counts every call in the sessions that started that day, including the test session's, as the spec says. (Attributing everything to the day the session started keeps a conversation that runs past midnight in one day's numbers.)
Show the answer
e2: the ratings view keeps only e6's latest rating, which is up
The ratings view numbers each reply's feedback newest first and keeps row 1, so e6 counts as up and e2 as down. That's the spec's rule written once, in the view, instead of in every report. The test session's reply isn't in the count at all.
Python's built-in SQLite has no percentile function, so for latency percentiles pull the typed latency_ms column into Python and use np.percentile, as in the observability lesson. The view has already put every row in milliseconds.
Rollups you can run twice
A dashboard shouldn't scan every event each time someone opens it, so a nightly job writes each day's numbers into a rollup table, one row per day. Two things will happen to that job: it will run twice (a retry, a person rerunning it after a fix), and events will arrive late (a phone that was offline sends its rating the next morning). As the queues lesson put it, the job must be idempotent: running it twice has the same effect as running it once.
Three habits get you there:
- Ingest with a unique event id.
event_idis the primary key, and new events go in withINSERT OR IGNORE, so a batch delivered twice adds nothing the second time. (OR IGNOREalso skips rows that break other rules, such as aNOT NULLcolumn, without a word;INSERT ... ON CONFLICT(event_id) DO NOTHINGskips only duplicates, so prefer it when a table has other rules.) - Write rollups as upserts.
INSERT ... ON CONFLICT(day) DO UPDATEreplaces the day's row instead of adding a second one. (Deleting the day's row and inserting it again inside one transaction works too.) - Recompute a window, not just yesterday. Each night, rebuild the last few days, so late events land in the right day. The price is that a recent day's numbers can still change, so say so on the dashboard.
import sqlite3 db = sqlite3.connect(":memory:") db.executescript(""" CREATE TABLE events (event_id TEXT PRIMARY KEY, session_day TEXT, type TEXT); CREATE TABLE daily_metrics (day TEXT PRIMARY KEY, sessions INTEGER, ratings INTEGER); """) def ingest(batch): db.executemany("INSERT OR IGNORE INTO events VALUES (?, ?, ?)", batch) def rollup(day): sessions, ratings = db.execute("""SELECT sum(type = 'session_start'), sum(type = 'feedback') FROM events WHERE session_day = ?""", (day,)).fetchone() db.execute("""INSERT INTO daily_metrics VALUES (?, ?, ?) ON CONFLICT(day) DO UPDATE SET sessions = excluded.sessions, ratings = excluded.ratings""", (day, sessions, ratings)) batch = [("e1", "2025-06-03", "session_start"), ("e2", "2025-06-03", "feedback"), ("e3", "2025-06-03", "session_start")] ingest(batch); ingest(batch) # the same batch delivered twice rollup("2025-06-03"); rollup("2025-06-03") # the job run twice print(db.execute("SELECT * FROM daily_metrics").fetchall()) ingest([("e9", "2025-06-03", "feedback")]) # a rating for a June 3 session, arriving on June 4 rollup("2025-06-03") # the next night's window rerun includes June 3 print(db.execute("SELECT * FROM daily_metrics").fetchall())
Delivered twice and rolled up twice, June 3 still has two sessions and one rating, in one row. The late rating arrived on June 4, but it belongs to a June 3 session, so rerunning June 3 counted it. (In the real views, a late rating finds its day by joining to its reply's session.)
Ingest a batch exactly once
Write ingest(db, events): insert each event tuple (event_id, ts, session_id, user_id, type, payload) into the events table, ignoring any whose event_id is already there (including repeats inside the same batch), and return how many new events were stored. Do it in one transaction: if any event is malformed (not six fields), store none of the batch and raise ValueError.
import sqlite3 def ingest(db, events): # a plain INSERT fails on a repeated event_id: store only new events, and count them return 0 db = sqlite3.connect(":memory:") db.execute("CREATE TABLE events (event_id TEXT PRIMARY KEY, ts TEXT, session_id TEXT, user_id TEXT, type TEXT, payload TEXT)") batch = [("e1", "2025-06-02 09:00", "s1", "ana", "session_start", "{}"), ("e2", "2025-06-02 09:01", "s1", "ana", "model_call", "{}")] print(ingest(db, batch), ingest(db, batch))
Practice what you just read
- 4 code examples you can edit and run
- a practice exercise that checks your code
- a 5-question quiz
- 5 review cards for spaced repetition
Try a quiz question
Why define a typed view per event type instead of parsing the JSON payload in each report?