Index Ai Academy
Phase 11 · Professional practice · Shipping

Measuring an AI feature from its logs

Typed views over event logs, metric specs, cost from a price table, and rollups you can rerun.

About 40 minutes · A free account opens the whole lesson

Create a free account Try part of it here

Already have an account? Sign in to open this lesson

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

  1. Log events, decide the metrics later
  2. Typed views: parse the payload once
  3. The latest rating per reply
  4. Write the metric down before you compute it
  5. The daily numbers in one query
  6. Rollups you can run twice
  7. Ingest a batch exactly once
Quick check
A shop's checkout assistant has run for a month. What lets you measure whether it works?
  1. Logged events per conversation, turned into metrics afterward
  2. The number of times the page was loaded
  3. Asking the model whether it's been helpful
  4. Counting how many lines of code the assistant has
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())
It runs right here in your browser, with nothing to install.

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.

Quick check
SQLite's json_extract reads a value from a JSON string by path. What does this print?
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())
  1. (900, None)
  2. ('900', None)
  3. (900, 0)
  4. An error: latency_ms isn't in the payload
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.

Drill

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())
In the lesson, your code is checked as you write it.

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:

metricnumeratordenominatorleft out
resolution rateanswered sessions with no handoffsessions with at least one model calltest users
thumbs-down per 100 repliesreplies whose latest rating is downall replies, rated or nottest users
cost per daysum 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.)

Try it now

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.)

Quick check
Ana rated e2 down; Bo rated e6 down and then up. The output above shows 2 replies and 1 down. Which reply is the down, and why only one?
  1. e6: it was rated down first
  2. Neither: the count comes from the test session's reply
  3. Both are downs, but the test session's reply cancels one out
  4. e2: the ratings view keeps only e6's latest rating, which is up
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:

  1. Ingest with a unique event id. event_id is the primary key, and new events go in with INSERT OR IGNORE, so a batch delivered twice adds nothing the second time. (OR IGNORE also skips rows that break other rules, such as a NOT NULL column, without a word; INSERT ... ON CONFLICT(event_id) DO NOTHING skips only duplicates, so prefer it when a table has other rules.)
  2. Write rollups as upserts. INSERT ... ON CONFLICT(day) DO UPDATE replaces the day's row instead of adding a second one. (Deleting the day's row and inserting it again inside one transaction works too.)
  3. 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.)

Drill

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))
In the lesson, your code is checked as you write it.

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

Create a free account

Try a quiz question

Why define a typed view per event type instead of parsing the JSON payload in each report?

  1. The parsing, including old payload shapes, lives in one place, so a fix reaches every report
  2. Views make queries run faster than tables
  3. JSON can't be read by SQL except through views
  4. Views store a copy of the data, so reports don't touch the events table

Answer it at the end of the lesson to see if you're right, and why.

Before this lesson