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

Meet the event stream

Session 1 asked what the events should be. Tonight we ask who actually produced them. The same GitHub hour goes into pandas, gets flattened from nested JSON into a frame you can group, and then answers three questions every product stream raises before any metric is safe to report: how much of this is automation, how concentrated is the activity in a few accounts, and where does one visit end and the next begin. None of the answers is comfortable, and every one of them changes a count you would otherwise have published.

🟡 Core Product and data analysts Python + pandas 45 min
0-3 · Welcome 3-18 · Shape, actors, sessions 18-40 · Build-along: one hour in pandas 40-45 · Q&A
Part 0

Where we start

The file is the one you downloaded for the homework: 2024-03-04-15.json.gz, one hour of GH Archive, 279,130 events. Last session it went through a validator line by line. Tonight it goes into a DataFrame, because the questions have changed from "is each event well formed" to "what does the whole hour look like", and those are grouping questions. Every number on this page is printed by the code in the build-along, on this file, and the ones that are sketches say so.

🎫 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 flattened frame of one real hour, a written bot rule and the 15.5 percent it removes, the shape of the actor distribution (median 1, mean 3.75, max 25,458), a 30-minute sessionizer that yields 76,584 sessions from 74,082 human actors, and the reason created_at alone can never order this stream.

Part 1 · covers the shape of a raw hour

What one hour looks like

5 min live

Before grouping anything, look at the raw shape: how many rows, which columns, and what mix of event types. GH Archive gives you 8 raw columns and 279,130 rows for this hour, and the mix is lopsided in a way most product streams share. One event type, PushEvent, is 56.2 percent of everything. Six types together cover more than nine tenths. The moment you see that, you know a naive "events per user" number is really "pushes per user" wearing a general name.

ONE HOUR BY EVENT TYPE 279,130 events · GH Archive 2024-03-04 15:00 UTC · share of the hour PushEvent 56.2 percent CreateEvent 14.2 percent · branch, tag or repository PullRequestEvent 7.1 percent DeleteEvent 5.4 percent IssueCommentEvent 4.8 percent WatchEvent 3.4 percent · a star 8 RAW COLUMNS id · type actor · repo payload public created_at org (sometimes) Pushes are more than half the hour. Any "events per user" figure on this stream is mostly a "pushes per user" figure.
🔍 Click to zoom - the gold bar is the reason "activity" needs a definition before it becomes a metric
LiveFlatten first, then look3 min▶

Each line of the file is one nested JSON object: actor is itself a dict with an id and a login, repo another, payload a third whose keys depend on the type. pandas reads the file in one call, but a column of dicts cannot be grouped. The first move is to pull out the handful of scalar fields you will actually group by: a UTC timestamp, the type, the actor login and id, the repository name. Five flat columns. Everything else stays in the raw frame for when a question needs it.

Then three cheap looks before any metric: the row count, the column list, and value_counts on type. On this hour that already tells you the stream is a push stream with other things mixed in. Your own product's frame will have the same skew somewhere, and it is worth finding before someone builds a chart on "total events".

Self-studyWhere the hidden moments still hide2 min▶

The type mix hides the same trap session 1 met in the validator. CreateEvent is 14.2 percent of the hour, but on the tracking-plan pass only 10,384 of its 39,497 events were repository births; the rest were branches and tags. A bar chart by raw type therefore overstates "new projects" by a factor near four. When a type's share looks surprisingly large, open its payload and count the sub-kinds before believing the bar.

The org column is worth a glance too: it exists only when a repository belongs to an organisation, so it is missing on most rows. pandas will show it as NaN. That is a legitimate "does not apply", not a data-quality fault, and it is the kind of absence a plan should document per property.

Real world

The frame that would not group. A common pattern: an analyst loads a product export where every row's properties is a dict, tries groupby("properties"), gets an unhashable-type error, and spends an afternoon on it. The fix is always the same as tonight's step 2: decide which three to six scalar fields the questions need, flatten only those, and leave the nested column alone. A frame is for grouping; the raw JSON is for looking things up.

Part 2 · covers actors and bots

Who wrote the diary

6 min live

279,130 events came from 74,491 actors, and the split is nothing like even. The median actor has 1 event in the hour; the mean is 3.75; the maximum is 25,458. 52.1 percent of actors appear exactly once. The top 1 percent of actors, 744 accounts, produce 38.7 percent of the events. And 409 of the logins end in [bot], which is GitHub's own suffix for apps, and those 409 wrote 15.5 percent of the hour. The busiest login of all does not carry the suffix.

A FEW ACTORS CARRY THE HOUR 74,491 ACTORS hachure: 52.1 percent appear once top 1 percent = 744 accounts 279,130 EVENTS 38.7 percent written by the other 99 percent · median actor: 1 event · mean 3.75 SAME EVENTS, BY LOGIN SUFFIX [bot] 15.5% 409 logins end in [bot] · the rest carry no suffix, and that is not the same as human 25,458 events from one login, censameesss, no [bot] suffix. A rule by name misses it. The suffix catches the bots that declare themselves. Decide the rest by behaviour, write the rule down, and print what it removes.
🔍 Click to zoom - the gold sliver and the gold block are the same 744 accounts, seen from two sides
LiveThe bot rule is a decision, so write it down3 min▶

GitHub makes the easy part easy: apps act under logins ending in [bot], so str.endswith("[bot]") finds 409 actors and removes 15.5 percent of the hour in one line. The top five logins show both halves of the problem. Three are declared bots: github-actions[bot] 19,528 events, dependabot[bot] 8,962, renovate[bot] 6,769. One, SolukGece at 3,033, has no suffix and no obvious tell. And the busiest of all, censameesss at 25,458 events in sixty minutes, has no suffix either. Nobody types 25,458 actions in an hour. A login without the suffix can still be automation.

So the rule has two parts, and both belong in the code with a comment: the suffix rule, which is safe and exact, and a behaviour rule, which is a judgment. Whichever threshold you choose for the second part, print the count of actors and the share of events it removes on every run. A bot rule nobody can see is a metric nobody can audit.

Self-studyMedian 1, mean 3.75: which one goes in the deck3 min▶

Both, with labels, and neither alone. The mean of 3.75 is pulled up by a right tail so long that one account has 25,458 events, 25,458 times the median. The median of 1 says what a typical actor did, which is: one thing. The 52.1 percent who appear once are not a quality problem; they are what a public activity stream looks like in any single hour, since most people are not active in any given sixty minutes. The mistake is reporting "3.75 events per active user" as if it described a person. It describes a mixture of people and machines.

The top-1-percent figure is the honest summary of concentration: 744 accounts, 38.7 percent of events. When a leader asks how engaged "our users" are, this is the number that stops the conversation from being about an average that no single user resembles. Session 5 builds the full curve; tonight the one number is enough to change the question.

Real world

The monitor that became a power user. A common pattern: an uptime checker hits a product's login page every minute from a service account nobody flagged. In the weekly review it is the most engaged "customer" in the account, its sessions are perfectly regular, and it never churns. The fix is not smarter analytics; it is the sentence in the bot rule that says "service accounts are excluded, here is the list", reviewed when the list changes.

Part 3 · covers sessionizing

Where one visit ends

4 min live

Events are points; a visit is a stretch. The standard way to turn one into the other is an inactivity gap: sort each actor's events by time, and wherever the gap to the previous event is longer than a threshold, start a new session. Thirty minutes is the common default, inherited from web analytics, and it is a choice, not a law. On this hour, for the 74,082 human actors, the 30-minute rule yields 76,584 sessions, median 1 event per session, mean 3.08, and 2,502 actors who came back for a second session inside the same sixty minutes.

ONE ACTOR, ONE HOUR, CUT BY GAPS a sketch of the rule, not a real actor gap over 30 min gap over 30 min session 1 session 2 session 3 · cut off 15:00 UTC 16:00 UTC one actor's events, sorted by time, then by id where the second is shared Measured on the hour: 74,082 human actors, 76,584 sessions, median 1 event per session, mean 3.08. Both edges cut. A session begun at 14:50, or still running at 16:00, is truncated, so these read short.
🔍 Click to zoom - the gold boxes are the whole rule; change 30 to 15 and every count on this page moves
LiveThe tie a timestamp cannot break2 min▶

GH Archive records created_at to the second, and this hour has so many events that every single distinct timestamp is shared by 2 or more events. Sort by created_at alone and the order within a second is whatever pandas happened to leave it in, which is not stable and not meaningful. For a funnel, "did the push come before the pull request" can flip between two runs on the same data.

The fix is a second sort key that is unique and monotone. GitHub's event id is both, so sort_values(["actor_id", "ts", "id"]) gives a stable order. Your own product's stream needs the same thing: a sequence number, an ingest offset, or an id that increases. If it has none, say so in the definition, because any step-order claim is then partly decided by row order.

Self-studyThirty minutes is inherited, not derived2 min▶

The 30-minute gap comes from web analytics tooling and survived because it is a round number, not because anyone measured it for your product. For a developer pushing commits, a 45-minute think between pushes is one work session; for a mobile app opened for eight seconds, 30 minutes is an eternity. Test the threshold: compute session counts at 10, 30 and 60 minutes and look at how many actors gain a second session. On this hour 2,502 actors have 2 or more sessions under the 30-minute rule; that number is what a different gap would move first.

Then remember the window. One hour of data truncates every session that started before 15:00 or was still going at 16:00, so mean length is biased short and the "2+ sessions" count is biased low. When you sessionize a day or a week, the same thing happens at the edges of the extract, only less visibly.

Real world

The funnel that changed overnight. A common pattern: a step-order funnel is rebuilt on the same day's data and the conversion moves by a point. Nothing in the product changed. The events carried second-level timestamps, hundreds shared each second, and the sort had no tie-breaker, so a fraction of "viewed then clicked" pairs became "clicked then viewed" between runs. Adding the event id as a second sort key makes the number stop wandering; it does not make the order true, only stable, and the definition should say which.

Build-along

One real hour in pandas

★ 22 min · everyone builds

pandas and the same GH Archive file. Every number in the comments below is what this code prints on 2024-03-04-15.json.gz. Reading 150 MB of gzipped JSON into a frame takes a minute or two and a few gigabytes of memory; if your laptop objects, read with chunksize and concatenate the five flat columns only.

FIVE STEPS, ONE FRAME 1 · one hour 150,807,371 bytes 2 · load, flatten five scalar columns 3 · bot rule then events per actor 4 · sessionize 30-minute gap 5 · the tie check shared timestamps 279,130 events in 15.5 percent removed 76,584 sessions 100 percent shared Step 3 is the only judgment call in the pipeline. Everything else is arithmetic on the frame, and step 3 decides what the arithmetic counts.
🔍 Click to zoom - the gold box is the decision; the rest is grouping

Get the hour. If you kept the file from session 1, skip this. GH Archive names files by UTC date and hour, no login.

★ 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

Load and flatten. One read_json call, then five scalar columns pulled out of the nested ones with the .str accessor, which indexes into a column of dicts. Then the three cheap looks.

★ Step 2 · a frame you can groupimport pandas as pd raw = pd.read_json("2024-03-04-15.json.gz", lines=True, compression="gzip") df = pd.DataFrame({ "ts": pd.to_datetime(raw["created_at"], utc=True), "type": raw["type"], "actor": raw["actor"].str["login"], "actor_id": raw["actor"].str["id"], "repo": raw["repo"].str["name"], }) print("events", len(df), "columns in the raw file", list(raw.columns)) # events 279130 columns in the raw file ['id', 'type', 'actor', 'repo', 'payload', 'public', 'created_at', 'org'] print("span", df.ts.min(), "to", df.ts.max()) # span ... one hour, 15:00 to 15:59 UTC on 2024-03-04 print((df["type"].value_counts(normalize=True) * 100).round(1).head(6).to_string()) # PushEvent 56.2 # CreateEvent 14.2 # PullRequestEvent 7.1 # DeleteEvent 5.4 # IssueCommentEvent 4.8 # WatchEvent 3.4

The bot rule, then events per actor. The suffix rule is one line. The distribution is one groupby. Print the median, the mean and the maximum together, always.

★ Step 3 · who wrote itdf["is_bot"] = df["actor"].str.endswith("[bot]") print("actors", df.actor_id.nunique(), "bot actors", df[df.is_bot].actor_id.nunique(), "bot share of events %.1f%%" % (100 * df.is_bot.mean())) # actors 74491 bot actors 409 bot share of events 15.5% per = df.groupby("actor_id").size() print("events per actor: median %d, mean %.2f, max %d; actors with 1 event %.1f%%" % ( per.median(), per.mean(), per.max(), 100 * (per == 1).mean())) # events per actor: median 1, mean 3.75, max 25458; actors with 1 event 52.1%

Concentration. The top five logins by name, and the share of events the top 1 percent of actors produce. Two of the top five have no [bot] suffix.

★ Step 4 · the few who carry the hourtop = df.groupby("actor").size().sort_values(ascending=False).head(5) print("top actors:", top.to_dict()) # top actors: {'censameesss': 25458, 'github-actions[bot]': 19528, 'dependabot[bot]': 8962, # 'renovate[bot]': 6769, 'SolukGece': 3033} top1 = per.sort_values(ascending=False) k = max(1, len(top1) // 100) print("top 1%% of actors (%d) produce %.1f%% of events" % (k, 100 * top1.head(k).sum() / len(df))) # top 1% of actors (744) produce 38.7% of events

Sessionize the humans. Sort by actor and time, take the gap to the previous event within each actor, and start a new session where the gap is missing (first event) or over 30 minutes. A cumulative sum of the "new" flag numbers the sessions.

★ Step 5 · 30-minute gaphum = df[~df.is_bot].sort_values(["actor_id", "ts"]) gap = hum.groupby("actor_id")["ts"].diff() hum = hum.assign(new=(gap.isna() | (gap > pd.Timedelta(minutes=30)))) hum["session"] = hum.groupby("actor_id")["new"].cumsum() sess = hum.groupby(["actor_id", "session"]).agg(n=("type", "size"), start=("ts", "min"), end=("ts", "max")) print("human actors", hum.actor_id.nunique(), "sessions", len(sess), "median events/session %d, mean %.2f" % (sess.n.median(), sess.n.mean())) # human actors 74082 sessions 76584 median events/session 1, mean 3.08 print("actors with 2+ sessions inside the hour", int((hum.groupby("actor_id")["session"].max() >= 2).sum())) # actors with 2+ sessions inside the hour 2502

The tie check. How many distinct timestamps are shared by more than one event. On this hour: all of them. Add raw["id"] to the frame and to the sort before any step-order claim.

★ Step 6 · can created_at order the streamsame = (df.groupby("ts").size() > 1).mean() print("share of distinct timestamps shared by 2+ events %.1f%%" % (100 * same)) # share of distinct timestamps shared by 2+ events 100.0% # so: carry the id and sort on it second df["id"] = raw["id"] ordered = df.sort_values(["actor_id", "ts", "id"])

Print the rule with the result. Wherever the bot share or the session count appears in a report, the line above it should say the rule that produced it: "logins ending in [bot]; 30-minute gap; one hour, truncated at both edges". A number with its rule can be challenged; a number alone can only be believed or not.

Homework

Before session 3

30 min
  1. Write the second half of the bot rule. Pick a behaviour threshold (events per hour, or regularity of gaps) that would catch censameesss, apply it to the human frame, and print how many actors and what share of events it removes. Write one sentence defending the threshold.
  2. Move the gap. Re-run step 5 at 10 and 60 minutes. Report the session count and the number of actors with 2+ sessions at each, next to the 30-minute values (76,584 and 2,502).
  3. Sort without the id, twice. Take one busy actor, sort by ts only, run it two ways (once after shuffling the frame) and check whether the event order inside a shared second changes. Then repeat with id as the second key.
  4. Keep the flattened frame. Save df to Parquet. Session 3 uses the same accounts to compute three different "active" counts from three definitions.
Check yourself

Three questions

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

1 · The [bot] suffix rule finds 409 actors and removes 15.5 percent of the hour's events. What does that share tell you about automation in the stream?

A rule by name is exact for the accounts that follow the convention and blind to the rest. Print what each half of the rule removes on every run, so the definition of "human activity" can be audited rather than believed.

2 · Why can created_at alone not order this stream?

A step-order claim such as "pushed before opening the pull request" needs a stable order. The id makes the order stable; whether it is the true order is a separate question the definition should state.

3 · What does the 30-minute gap decide in the sessionizer?

The threshold is inherited from web analytics, not derived from your product. Test 10, 30 and 60 minutes, pick one for a reason, and remember that a bounded extract truncates sessions at both edges.

Sources covered

What this session teaches, and where it came from

✓GH Archive hourly filesGH Archive, https://www.gharchive.org/ (hourly archives, data.gharchive.org): UTC files, no login, Events API format since 2015
✓Event object fields and typesGitHub Docs, "GitHub event types": id, type, actor, repo, payload, public, created_at; CreateEvent ref_type; WatchEvent as a star
✓Every count in the build-alongMeasured on GH Archive 2024-03-04 15:00 UTC by the pandas code on this page
◐The 30-minute session gapTaught as an inherited convention from web analytics, not sourced to a paper; the page asks you to test it
◐Automation without the [bot] suffixObserved on the hour (censameesss, 25,458 events); no external source classifies the account, so it is taught as a judgment

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

Analyst session 2 cheat sheet · pin this

Flatten to scalarsFive columns you group by; the nested JSON stays for lookups.
Look at the type mix firstPushEvent is 56.2 percent; "events" mostly means pushes.
Bot rule, two halvesSuffix is exact (409, 15.5 percent). Behaviour is a judgment. Print both.
Median, mean, max together1, 3.75, 25,458. No one of them is the user.
Gap sessionizerSort, diff, flag gap over 30 min, cumsum. 76,584 sessions; edges truncate.
Always a tie-breakerEvery second is shared. Sort on ts, then id.