learn-product-analytics-with-phoebe / Analyst session 1 of 8
Learn Product Analytics with Phoebe · Analyst track · Session 1 of 8

The tracking plan as code

A tracking plan kept in a spreadsheet is a wish. The same plan kept as code is a test: it can be run against the live stream and it fails loudly when the stream drifts. Tonight we write a five-event plan in plain Python, map real GitHub events onto it, validate every property's type, and meet the three kinds of result a validator returns: tracked, not in the plan, and wrong. Then we settle the question every product dataset raises in its first week: which field is the person?

🟡 Core Product and data analysts Python, no libraries beyond the standard one 45 min
0-3 · Welcome 3-18 · Plan, types, identity 18-40 · Build-along: validate a real hour 40-45 · Q&A
Part 0

Where we start

The analyst track works on one real public dataset all the way through: GH Archive, the hourly record of GitHub's public activity. It is not a product you run, which is the point. It has the shape of every product's event stream, with bots, renames, a few actors doing most of the work and a signup moment it never records, and nothing about it has been cleaned up for teaching. Eight sessions take it from one hour of raw JSON to a bench that tests activation definitions on 8,348 real new repositories.

🎫 Clear all three quiz questions to stamp your passport Live = presented in the 45 minutes Self-study = the full depth, after class ★ Build-along = run the code yourself

★ What you walk out with tonight. A tracking plan as a Python dictionary, a validator that maps and type-checks every event in a real 279,130-event hour, the three counts it prints, and the rule that people are keyed on an id and never on a login.

Part 1 · covers the plan as a schema

A plan that can fail

6 min live

Written as code, a tracking plan has three parts: the names (Object Action, as leader session 1 argues), the rule that says which raw event becomes which plan event, and the properties each plan event must carry, with a type. Everything that arrives either maps to a plan event, maps to nothing, or maps and breaks a property. Those three buckets are the whole output of a validator.

ONE HOUR THROUGH THE PLAN GH Archive 2024-03-04 15:00 UTC · every event mapped, then type-checked 279,130 raw events five-event plan TRACKED 189,640 67.9 percent NOT IN THE PLAN 89,490 a decision TYPE VIOLATIONS 0 and still 234 wrong THE PLAN Repository Created Code Pushed Pull Request Opened Repository Starred Issue Opened Zero type violations proves the types. The 234 pushes carrying zero commits are valid by type and wrong by meaning.
🔍 Click to zoom - the middle tray is not a failure; a plan that says what it does not track is doing its job
LiveThe plan as a dictionary3 min▶

Each plan event has a when rule and a props map. The rule names the raw event type and, where one raw type hides several moments, the payload key and value that picks the right one: a CreateEvent is only "Repository Created" when ref_type == "repository", a PullRequestEvent is only "Pull Request Opened" when action == "opened". The props map is name to Python type. Nothing else. A plan this small can be read in a code review, which is most of its value.

Keeping the plan in the repository next to the code that sends events means a change to one shows up in the diff of the other. Commercial tools offer the same idea as a hosted schema with a "block unplanned events" switch; the principle is identical and the code version costs nothing.

Self-studyWhy "not in the plan" is a count, not an error2 min▶

89,490 events in the hour map to no plan event: 29,113 branch and tag creations, 15,033 deletions, 13,398 issue comments, 9,662 pull request events that are not openings, 8,515 reviews and the long tail. Printing that count, by type, every run, is what stops a plan from rotting. When the count for a type jumps, something new is being sent, and either the plan grows a row or someone asks why the product started emitting it.

Real world

The event nobody planned. A mobile release adds a "screen viewed" call to every screen for a debugging session and nobody removes it. Event volume triples, the analytics bill follows, and the weekly active chart jumps because a user who only opened the app now counts as active. A validator that prints "not in the plan: 4,000,000 events of a type we have never seen" on the morning after the release is the cheapest possible alarm.

Part 2 · covers typed properties

Valid by type, wrong by meaning

5 min live

A type check is the floor, not the ceiling. On the real hour every tracked event carries every required property with the right type, and the validator prints zero. Then one extra line, a check on what a value means rather than what type it is, finds 234 "Code Pushed" events that carry no commits.

TWO GATES, ONE EVENT Code Pushed repo_id: 7654... actor_id: 1234... commits: 0 a real shape, ids elided TYPE GATE int? int? int? yes passes 156,983 of 156,983 MEANING GATE commits at least 1? 234 stopped WHAT A ZERO-COMMIT PUSH USUALLY IS a branch moved, a force push, a ref updated with no new work in it If "active" means "pushed code", these 234 are active without having written anything. The meaning gate decides.
🔍 Click to zoom - the gold gate is one line of code and it is the one that protects the definition of active
LiveThree kinds of property check3 min▶

Presence: is the property there at all. Type: is it an int, a string, a bool. Meaning: is it a value that fits what the event claims happened, a push with at least one commit, a price above zero, a timestamp not in the future. Most tools check the first two. The third is where product definitions live, and it has to be written by someone who knows what the event is for.

Keep meaning checks as warnings with counts, not as filters that silently drop rows. The 234 are real events. Whether they count toward "active" is a definition question for session 3, and the validator's job is to make sure the question gets asked.

Self-studyMissing is not the same as false2 min▶

GitHub adds an org object only when a repository belongs to an organisation, so "is_org" is derived from whether the key exists. That is a legitimate encoding, and a dangerous one to copy: in your own product, a property that is absent because the client failed to send it looks exactly like one absent because it does not apply. Plans should say, per property, whether absent means false or means unknown.

Part 3 · covers identity

Which field is the person

4 min live

GitHub's stream is the easy case: every event is by a logged-in account. Even so, 7 account ids appear with more than one login in a single hour, and no login appears with more than one id. Most products have the harder case too: a visitor who is anonymous until they sign up, and whose earlier events have to be joined to the account they become.

A RENAME AN ANONYMOUS VISITOR BECOMES A PERSON one account id login before rename login after rename by login: 2 people by id: 1 7 ids with two logins, in one hour anonymous events identify() known person earlier events merged in reset() fresh anonymous id on logout, so a shared laptop does not merge two people into one Left is GitHub, measured. Right is the pattern in PostHog's identify docs; the diagram is a sketch of it, not data.
🔍 Click to zoom - both halves are the same rule: the person is the stable id, and every other label is joined to it
LiveKey on the id, join the labels2 min▶

The rule is short: the person is a stable id; logins, emails and display names are properties of the person that can change. The build-along counts ids per login and logins per id on the real hour and finds 7 and 0. The asymmetry is the lesson: an id never splits, a label sometimes does.

For anonymous-then-known products, PostHog's documentation describes the standard mechanism: every visitor gets an anonymous id, and calling identify at login "merges the anonymous person into the identified person", so the pre-signup events join the account. Two warnings from the same docs matter to an analyst: call reset() on logout so a shared device does not merge two people, and never identify with a generic value like "null" or "guest", which PostHog blocks because it would merge strangers.

Self-studyWhat merging does to your history2 min▶

A merge rewrites the past: yesterday's "anonymous visitor who left" becomes today's "customer who browsed first". Funnels computed before and after a merge disagree for the same day, and both are correct for their moment. Freeze the definition of the population you report on, and when a number changes after the fact, check whether identity resolution moved people before assuming behaviour moved.

Build-along

Validate one real hour against the plan

★ 22 min · everyone builds

Standard-library Python and one GH Archive file. Every number printed below is what this code prints on the file 2024-03-04-15.json.gz. The file is about 150 MB and the full pass takes under a minute on a laptop.

FIVE STEPS, ONE FILE 1 · one hour about 150 MB 2 · the plan a dict, 5 events 3 · map raw to plan name 4 · validate types, then meaning 5 · four counts and an identity check 279,130 events in 189,640 mapped 0 type violations 7 ids, two logins The same five steps work on your product's export. Only step 2 changes, and step 2 is the tracking plan.
🔍 Click to zoom - step 2 is the only product-specific line of the whole pipeline

Download the hour. GH Archive names files by UTC date and hour, no login needed. One hour is enough for everything tonight.

★ Step 1 · one file from GH Archivecurl -O https://data.gharchive.org/2024-03-04-15.json.gz ls -l 2024-03-04-15.json.gz # 150807371 bytes

Write the plan. Names, the rule that picks the raw moment, and typed properties. This is the whole contract.

★ Step 2 · the tracking plan as a dictPLAN = { "Repository Created": {"when": ("CreateEvent", "ref_type", "repository"), "props": {"repo_id": int, "owner_id": int, "is_org": bool}}, "Code Pushed": {"when": ("PushEvent", None, None), "props": {"repo_id": int, "actor_id": int, "commits": int}}, "Issue Opened": {"when": ("IssuesEvent", "action", "opened"), "props": {"repo_id": int, "actor_id": int, "issue_number": int}}, "Pull Request Opened": {"when": ("PullRequestEvent", "action", "opened"), "props": {"repo_id": int, "actor_id": int, "base_branch": str}}, "Repository Starred": {"when": ("WatchEvent", "action", "started"), "props": {"repo_id": int, "actor_id": int}}, }

Map a raw event to a plan event. Return the plan name and only the properties the plan asks for, or None.

★ Step 3 · raw to plandef to_plan(e): p = e.get("payload") or {} for name, spec in PLAN.items(): t, key, val = spec["when"] if e["type"] == t and (key is None or p.get(key) == val): props = {"repo_id": e["repo"]["id"], "actor_id": e["actor"]["id"], "owner_id": e["actor"]["id"], "is_org": "org" in e, "commits": p.get("size"), "issue_number": (p.get("issue") or {}).get("number"), "base_branch": ((p.get("pull_request") or {}).get("base") or {}).get("ref")} return name, {k: props[k] for k in spec["props"]} return None

Stream the file and count the three buckets. One pass, line by line, so memory stays flat. The meaning check for pushes and the identity check ride along.

★ Step 4 · validate every eventimport gzip, json from collections import Counter def violations(name, props): return [k for k, typ in PLAN[name]["props"].items() if not isinstance(props.get(k), typ)] tracked, untracked, bad = Counter(), Counter(), Counter() zero_commit_pushes, logins_per_id, ids_per_login, n = 0, {}, {}, 0 for line in gzip.open("2024-03-04-15.json.gz", "rt"): e = json.loads(line); n += 1 logins_per_id.setdefault(e["actor"]["id"], set()).add(e["actor"]["login"]) ids_per_login.setdefault(e["actor"]["login"], set()).add(e["actor"]["id"]) m = to_plan(e) if m is None: untracked[e["type"]] += 1; continue tracked[m[0]] += 1 if m[0] == "Code Pushed" and m[1]["commits"] == 0: zero_commit_pushes += 1 for k in violations(*m): bad[(m[0], k)] += 1

Print the report. These are the numbers the real hour gives.

★ Step 5 · four counts and the identity checkprint(n, sum(tracked.values())) # 279130 189640 (67.9 percent) print(tracked.most_common()) # Code Pushed 156983 · Repository Created 10384 · Pull Request Opened 10030 # Repository Starred 9447 · Issue Opened 2796 print(untracked.most_common(5)) # CreateEvent 29113 · DeleteEvent 15033 · IssueCommentEvent 13398 # PullRequestEvent 9662 · PullRequestReviewEvent 8515 print(dict(bad) or "none") # none print(zero_commit_pushes) # 234 print(sum(len(s) > 1 for s in logins_per_id.values())) # 7 print(sum(len(s) > 1 for s in ids_per_login.values())) # 0

Run it on a schedule, not once. The report is most useful as a diff: yesterday's counts against today's. A new raw type appearing in the untracked list, or a violation count leaving zero, is the moment to look, and the plan is what makes either one visible.

Homework

Before session 2

30 min
  1. Add a sixth plan event for "Repository Forked" (raw type ForkEvent) with the properties you think it needs, re-run, and write down the new tracked share.
  2. Write one more meaning check, for example a pull request whose base branch is empty, and count it on the hour.
  3. Print the 7 renamed ids' event counts. Decide, in one sentence, whether a weekly active count by login would have over- or under-counted them, and by how many.
  4. Keep the file. Session 2 loads the same hour into pandas and looks at who produced it.
Check yourself

Three questions

Clear all three and analyst session 1 gets stamped in your passport.

1 · The validator prints zero type violations on the real hour. What does that prove?

Presence and type are the floor. Meaning checks, written by someone who knows what the event is for, are where definitions are protected, and they should report counts rather than silently drop rows.

2 · Why does the plan map CreateEvent to "Repository Created" only when ref_type == "repository"?

The rule that picks the raw moment is as much a part of the plan as the name. When you inherit a stream, look for names that hide several moments and split them in the mapping.

3 · In a product with anonymous visitors, what does calling reset() on logout protect against?

Identify merges the anonymous person into the known one; reset ends that link. Without it, two people on one laptop become one person in every chart, which is a merge nobody can undo from the data alone.

Sources covered

What this session teaches, and where it came from

✓GitHub event object and payload fieldsGitHub Docs, "GitHub event types": CreateEvent ref_type, IssuesEvent and PullRequestEvent actions, WatchEvent as a star
✓Every count in the build-alongMeasured on GH Archive 2024-03-04 15:00 UTC by the code on this page
✓Anonymous-to-identified merge, reset on logout, blocked generic idsPostHog Docs, "Identifying users", read at source
✓Object Action namesTwilio Segment naming conventions, as in leader session 1
◐Segment's Identify specificationNot quoted; the page could not be read during this build

Every fact on this page, and its verification tier, is recorded in the course's source map.

Analyst session 1 cheat sheet · pin this

Plan = names + rule + typed propsA dict in the repository, reviewed like code.
Three bucketsTracked, not in the plan, violations. Print all three every run.
Type is the floorMeaning checks catch the 234 zero-commit pushes.
Split hidden momentsCreateEvent is branch, tag or repository. The payload decides.
People are ids7 ids with two logins in one hour; 0 logins with two ids.
identify, then resetMerge anonymous into known at login; fresh id at logout.