learn-ship-ecommerce-enabler-with-phoebe / Analyst session 4 of 8
Ship It: Social Commerce Enablers · Analyst session 4 of 8

Per-minute events for a live channel

A live stream on Pasar Live touches eight teams, and every one of them will happily read a number that another team produced on a different clock. The table they share is only as good as the agreement underneath it: which five events exist, what each one means, who fires it, who owns it, which version of the definition was live on the night. Tonight you write that agreement as a spec, in Python, for the constructed channel from leader session 3. Then you build a minute table from one constructed 60-minute stream, run the four checks every stream must pass before it is scored, and break the stream twice on purpose to watch the checks do their job.

🟡 Core Analysts and data engineers Python, SQL 45 min
0-3 · Welcome 3-19 · Events, owners, checks 19-41 · Build-along: spec, table, checks 41-45 · Q&A
Part 0

Where this sits in the analyst track

Sessions 1 to 3 dealt with data that arrives whole: orders, lines, stores, SKUs, and the keys that do or do not survive masking. A live channel is different. Its most useful data is a stream of small events, most of them fired by software Serai does not own, and nobody downstream can tell a missing minute from a quiet one. Tonight is the operator side of that problem: the spec as a contract, the owners, the versioning and the gate. Session 5 reads the tables that pass the gate and turns them into Stop, Hold, Love, Buy. Pasar Live, Serai Commerce, the stream and every number on this page are constructed; the pattern is the one any team running a live channel across several brands ends up needing.

🎫 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 five-event spec with a definition, a grain, a source, an owner and a first-seen version on every field; a minute-table builder that de-duplicates on an idempotency key; and a four-check gate that says "scored" or "rejected" for every stream, with the reason printed. Plus the habit that saves the most arguments on a live channel: nobody reads a stream the gate has not passed.

Part 1 · covers the five events, their keys and the one clock

Five events, one clock

6 min live

Pasar Live's minute table counts five things: room entry, product card impression, product click, add to cart and order. Every row is keyed by stream_id + minute + sku, and every timestamp sits on one clock: UTC, with the minute the platform marks the room live as minute 0. How that table is modelled and what livestream data science does with it belongs to the live commerce course's session on the stream as data. This session is about the agreement that has to exist before the table can be trusted.

ONE CONSTRUCTED STREAM, PL-0412, MINUTES 0 TO 5 every mark is counted in the minute it fired, per stream and SKU min 0 min 1 min 2 min 3 min 4 min 5 room entry platform, per minute card impression Serai overlay product click Serai overlay add to cart platform, per minute order platform order API 7 14 18 29 24 34 15 15 23 24 28 30 1 6 3 2 4 5 1 3 2 1 3 3 1 1 2 1 2 2 minute 0 = 12:00:00 UTC Constructed, seed 4. Five events, keyed stream_id + minute + sku, all on one clock that starts at minute 0.
🔍 Click to zoom - the teal column is the only thing every team must agree on before any other number means anything
LiveSix fields on every event in the spec2 min▶
FieldWhat it saysWhy it has to be written
nameThe exact string in the log, product_clickA renamed event is a new event to every query that filters on the old name
definitionOne sentence a new analyst could compute from"Impression" means nothing until you say on screen for how long
grainThe key a count sits at: stream_id, minute or stream_id, minute, skuRoom entries belong to the room, not a product; summing them per SKU triple-counts
sourceWho fires it: Serai's overlay, the platform's per-minute totals, the order APIIt decides what you can fix yourself and what you can only reconcile
ownerOne team, never twoIt is who gets called when the column is empty at 21:00
first-seen versionThe spec version the event appeared inLets anyone tell "zero clicks" from "clicks were not tracked yet"

Why the minute, and not the frame or the whole stream? The frame is finer than any event can be timed across two systems, so it manufactures precision nobody has. The whole stream is too coarse to coach from: the point of the table is to find the minute a host lost the room. A minute is the smallest window in which Serai's overlay, the platform's per-minute totals and the order API can all be made to agree.

LiveWhat Serai logs itself, and what only the platform can tell it2 min▶

Session 1's obtainability words apply event by event. In the constructed Pasar Live set-up, Serai's studio runs its own overlay, the layer that pins product cards on screen and takes the tap, on its own studio clock, synced to UTC. Those events are owned: Serai fires them, stores them raw and can re-count them.

  • Owned, from the overlay: product card impressions and product clicks, one row per event with its own id.
  • Full via API: orders, from the platform's order API, each with a placed timestamp Serai can put on its own minute.
  • Partial, platform per-minute totals: room entries and add to cart. Serai gets a count per minute, not the events behind it.
  • Partial, platform totals only: live card impressions, the times the stream's cover appeared in someone's feed. That is the denominator of Stop, and it arrives as one number per stream, after the stream ends.

The line matters because it decides the fix. An owned event that looks wrong can be re-counted from raw. A platform total that looks wrong can only be reconciled against, which is exactly what check 4 in Part 3 does.

Self-studyLate events, duplicate events and the idempotency key3 min▶

Phones on bad networks retry. An overlay that did not hear an acknowledgement sends the same tap again, sometimes seconds later, sometimes after the stream has ended. If the pipeline counts what arrives, a retry storm looks like a surge of interest.

The fix is an idempotency key: an id the event carries from the moment it was fired, so that processing it twice gives the same result as processing it once. De-duplicate on that id, never on the timestamp (two real taps can share a second) and never on the whole row (a retry can carry a different arrival time). A late event is then just an event: it lands in the minute of its own timestamp, not the minute it arrived, and the stream's minute table is rebuilt until the platform's end-of-stream totals are in.

Platform per-minute totals have no event id, so they cannot be de-duplicated by you. Write that on the spec's source field. It is the honest reason those columns are reconciled, not re-counted.

Real world

Two impression counts for one stream, constructed. In Pasar Live's first month, Serai's overlay counted product card impressions from the moment a card was pinned; the brand's own report, read from the platform dashboard, counted a card only once it had been on screen for a full second. Same stream, same word, two numbers, and a brand manager asking which one her fee was built on. Nothing was broken. The word had no definition. The fix was one sentence in the spec and a version number on it.

Part 2 · covers ownership, change control and versioning of the event spec

The spec is a contract with owners

6 min live

Leader session 3 put eight teams around one minute-grain table. A table eight teams read is eight teams' numbers, and the spec underneath it is a contract between them: one owner per field, a stated way to change it, a version on every release. Without that, a single rename by one team silently changes the other seven teams' dashboards, and nobody finds out until a Monday meeting disagrees with itself.

THE PASAR LIVE EVENT CONTRACT, SPEC 1.0.0 constructed · filled dot = owns the field · open ring = reads it tech data eng analysts live ops traffic ops commercial brand teams hosts room entry card impression product click add to cart order minute 0 (stream start) minute 0: read by all eight teams, owned by none. Spec 1.0.0 gave it to data engineering. Constructed. The field every team reads and nobody owns is the one that breaks first, and quietly.
🔍 Click to zoom - five fields had an owner from day one; the clock everyone depended on had none
LiveEight teams, and the one question each one asks of the spec2 min▶
TeamOwns in the specReads, and needs to be true
TechRoom entry ingestion, card impression, product clickWhich events went missing, per stream
Data engineeringAdd to cart, order, the clock and minute 0Where the joins break
AnalystsThe stage definitions built on top (session 5)Every column, at the grain it was declared
Live opsNotes on the minute, attached as labelsRoom entries, to find the minute the room left
Traffic opsPromo and push timestampsEntries and orders after each push
CommercialAssortment per streamCards, clicks, carts and orders by SKU
Brand teamsRoom set-up, SKU countTheir own brand's impressions, carts and orders
HostsThe stream itselfEntries, clicks and orders on their own scorecard

Ownership here means "fires it, or ingests it, and answers for it". Analysts own no event, on purpose: the team that defines the stages should not also be the team that can quietly adjust the counts under them.

LiveChange control: a rename is a breaking change3 min▶

Version the spec the way software versions an interface, because that is what it is. A three-part number, MAJOR.MINOR.PATCH, with a rule for each part:

  • MAJOR: an event renamed or removed, a definition changed, a grain changed. Every reader's numbers can move. It needs every owner's sign-off, a date, and a period in which the old and new names are both emitted.
  • MINOR: a new event or a new optional field. Nobody's old numbers move. The owner announces it; the first_seen field records it.
  • PATCH: wording that clarifies without changing what is counted.

Every minute table carries the spec version it was built under, as a column. When session 5 compares a stream from March with one from June, the first question is whether they were counted under the same major version. If not, the comparison needs a sentence, and the metric governance session in the ecommerce metrics course covers how that sentence is kept for company metrics. Session 7 applies the same versioning to the metric registry.

Self-studyWhat "done" means for tech, written into the spec3 min▶

A tracking ticket that says "add click event" is done when the code ships. A tracking ticket written against the spec is done when four things are true, and the spec says so in its own words:

  1. The event fires with the exact name in the spec, carrying an event id, a UTC timestamp, the stream id and, where the grain says so, the SKU.
  2. It has run on one real stream and the minute table built from it passes all four checks in Part 3.
  3. The spec version is bumped, with the event's first_seen set, and every owner has seen the change.
  4. The readers named in the contract have been told what moved, and when.

Item 2 is the one that changes behaviour. It turns "we shipped the event" into "a stream counted with the event has passed the gate", which is the only version of done the other seven teams care about.

Real world

The overlay release that zeroed a stage, constructed. Two months into Pasar Live, an overlay release renamed product_click to card_tap, a tidy-up nobody thought of as a change. For two days every host's click rate read zero, commercial pulled a SKU that "nobody clicked", and a brand host was told her Buy stage had collapsed. The event had fired perfectly the whole time, under a name no query was filtering on. After that week, a rename became a MAJOR version with both names emitted for a fortnight, and "done" for tracking work started to mean a stream counted with the change had passed the gate.

Part 3 · covers the four checks a stream must pass before it is scored

Validate before anyone reads it

4 min live

A minute table that has not been checked is a rumour with columns. Four checks run on every stream, in order, before the table is released to the eight teams. A stream that fails any one of them is rejected, not scored with a footnote, and the reason is printed next to it. The checks are cheap; the argument they prevent is not.

FOUR CHECKS, EVERY STREAM, BEFORE ANYONE READS IT 1 · funnel order orders never pass clicks, clicks never pass cards 2 · no gap minutes every minute 0 to 59 has a row 17 a quiet minute is a zero 3 · one clock every source in UTC, minute 0 = room live overlay platform 4 · reconciles within 2% of the platform's end-of-stream totals ours platform all four pass: scored any one fails: rejected A rejected stream is not scored with a footnote. It goes back to its owner with the failing check printed.
🔍 Click to zoom - check 2 looks for what is missing, which is the one thing the other three cannot see
LiveThe four checks, and what each one catches3 min▶
CheckRuleWhat it catches
1 · Funnel orderCumulative card impressions >= clicks >= add to cart >= orders, at every minuteSwapped or mislabelled events, a click event double-firing
2 · No gap minutesEvery minute from 0 to the stream's last minute is presentA lost batch, an ingestion job that died for a minute
3 · One clockEvery timestamp in UTC; every minute between 0 and the lastA source still on local time, a stream whose minute 0 came from the wrong system
4 · ReconcilesSerai's counts within a tolerance of the platform's end-of-stream totalsAnything the first three missed that changes a total

Check 1 is cumulative on purpose. Checked minute by minute, it would reject a perfectly good stream the first time a viewer clicked in minute 17 and ordered in minute 18. Cumulative, it asks the question that must always hold: by this minute, have we counted more orders than clicks?

Self-studyChoosing the tolerance, and why check 4 is not enough on its own2 min▶

The tolerance is a written choice, owned by data engineering, and it goes in the spec next to the clock. Too tight and every stream fails on the platform's own rounding; too loose and a lost minute slips through. The build-along uses 2 percent and shows the second failure happening: dropping one minute of a 60-minute stream moves room entries by 1.99 percent, inside the tolerance, and only orders cross it.

That is the argument for running all four. Reconciliation sees totals; it cannot see where in the stream the difference sits, and a gap in the minute the host lost the room is precisely the gap that matters. The gap check sees position; it cannot see a count that is wrong but present. Neither replaces the other.

Real world

Even the platform's totals carry a definition. Sea Limited's Annual Report on Form 20-F for fiscal 2019 defines Shopee's GMV as the value of orders on the marketplace, including shipping and other charges, and counts an order "regardless of whether the transaction is settled" or the item is returned. A listed marketplace chose that deliberately and wrote it down. Check 4 compares Serai's orders with a platform total, so the spec has to say which definition that total uses: placed or paid, with or without cancellations. Reconciling against a number whose definition you have not written is comparing two unknowns.

Build-along

Write the spec, then test a stream against it

★ 22 min · everyone builds

Five short steps in plain Python with numpy and pandas. The stream is constructed with a fixed seed, default_rng(4), so every output below is exactly what the code prints, run as one session in the order shown (numpy 1.26, pandas 2.1). Keep the seed and you will see the same numbers; the lesson is in the checks, not the counts.

SPEC, THEN TABLE, THEN GATE, THEN A VERDICT constructed stream PL-0412, seed 4, as printed below spec 1.0.0 5 events, 6 fields each event log 3,403 received, 50 duplicates minute table de-duplicated, 236 rows four checks funnel, gaps, clock, reconcile as generated: scored 4 of 4 pass; off by 1.1% at most minute 17 lost: rejected fails no gap minutes, reconciles orders off by 4.08% click and order swapped: rejected fails funnel order, reconciles orders off by 295.92% Constructed. Losing minute 17 moved room entries by 1.99%, inside the 2% tolerance; the gap check still caught it.
🔍 Click to zoom - two broken streams, two different checks failing, and each one says why

Write the spec as data. A Python dict, one entry per event, the six fields from Part 1 on each, plus the clock as its own owned field. In production this is a YAML file in a repo with owners on the review list; the shape is identical. Everything after this step reads SPEC, never its own copy of the event names.

★ Step 1 · the spec, version 1.0.0SPEC_VERSION = "1.0.0" SPEC = { "room_entry": {"definition": "a viewer's player joins the room", "grain": ["stream_id", "minute"], "source": "platform, per-minute totals", "owner": "tech", "first_seen": "1.0.0"}, "card_impression": {"definition": "a product card is on screen for 1 second or more", "grain": ["stream_id", "minute", "sku"], "source": "Serai overlay", "owner": "tech", "first_seen": "1.0.0"}, "product_click": {"definition": "a viewer taps the product card", "grain": ["stream_id", "minute", "sku"], "source": "Serai overlay", "owner": "tech", "first_seen": "1.0.0"}, "add_to_cart": {"definition": "the product lands in a cart from the room", "grain": ["stream_id", "minute", "sku"], "source": "platform, per-minute totals", "owner": "data eng", "first_seen": "1.0.0"}, "order": {"definition": "an order placed, in its placed minute; cancels leave", "grain": ["stream_id", "minute", "sku"], "source": "platform order API", "owner": "data eng", "first_seen": "1.0.0"}, } CLOCK = {"tz": "UTC", "minute_0": "the minute the platform marks the room live", "owner": "data eng"} FUNNEL = ["card_impression", "product_click", "add_to_cart", "order"] print(len(SPEC), "events, spec", SPEC_VERSION, "| clock", CLOCK["tz"], "owned by", CLOCK["owner"]) # 5 events, spec 1.0.0 | clock UTC owned by data eng

Generate one constructed 60-minute stream as raw events. Three SKUs, one featured per 20 minutes, a room that ramps up and slowly fades. Every event gets an id at the moment it fires, and 1.5 percent of events are sent twice, the way retries arrive. Room entries sit on sku = "ALL" because their grain is the room.

★ Step 2 · one stream, constructedimport numpy as np, pandas as pd rng = np.random.default_rng(4) START = pd.Timestamp("2026-06-12 12:00:00", tz="UTC") SKUS = ["SKU-A", "SKU-B", "SKU-C"] rows = [] for m in range(60): shape = min(1, (m + 1) / 6) * (1 - m / 90) # ramp up, slow fade featured = SKUS[m // 20] # one hero product per 20 minutes rows += [("room_entry", m, "ALL")] * rng.poisson(40 * shape) for sku in SKUS: imp = rng.poisson(30 * shape if sku == featured else 3) clk = rng.binomial(imp, 0.12) cart = rng.binomial(clk, 0.45) order = rng.binomial(cart, 0.5) for ev, n in zip(FUNNEL, [imp, clk, cart, order]): rows += [(ev, m, sku)] * n log = pd.DataFrame(rows, columns=["event", "m", "sku"]) log["ts"] = START + pd.to_timedelta(log["m"] * 60 + rng.uniform(0, 60, len(log)), unit="s") log["event_id"] = [f"e{i:05d}" for i in range(len(log))] log["stream_id"] = "PL-0412" # retries: the same event, sent twice resent = log.sample(frac=0.015, random_state=4) raw = pd.concat([log, resent]).drop(columns="m") print(len(raw), "events received,", raw["event_id"].duplicated().sum(), "duplicates") # 3403 events received, 50 duplicates

Build the minute table: de-duplicate on the idempotency key, put every event on the one clock, count at the grain. The columns come from SPEC, in the spec's order, so an event missing from the log still appears as a column of zeros instead of silently vanishing.

★ Step 3 · the minute tabledef minute_table(raw, start=START): ev = raw.drop_duplicates("event_id") # idempotency key ev = ev.assign(minute=((ev["ts"] - start).dt.total_seconds() // 60).astype(int)) t = ev.pivot_table(index=["stream_id", "minute", "sku"], columns="event", values="event_id", aggfunc="count", fill_value=0) return t.reindex(columns=list(SPEC), fill_value=0).reset_index(), ev mt, ev = minute_table(raw) print(mt.shape, "rows x cols") print(mt.groupby("minute")[list(SPEC)].sum().iloc[:4].to_string()) print("totals:", mt[list(SPEC)].sum().to_dict()) # (236, 8) rows x cols # event room_entry card_impression product_click add_to_cart order # minute # 0 7 15 1 1 1 # 1 14 15 6 3 1 # 2 18 23 3 2 2 # 3 29 24 2 1 1 # totals: {'room_entry': 1507, 'card_impression': 1511, 'product_click': 194, 'add_to_cart': 92, 'order': 49}

Write the gate and run it. The platform's end-of-stream totals are constructed here as the true counts nudged by a small random error, because a real platform's totals never match yours to the unit. Four checks, one verdict, and the evidence printed with it: the gap list and how far each total is from the platform's.

★ Step 4 · four checks, one verdict# the platform's own end-of-stream totals, constructed: close to ours, not identical truth = mt[list(SPEC)].sum() PLATFORM = {k: int(round(truth[k] * (1 + rng.normal(0, 0.006)))) for k in ["room_entry", "add_to_cart", "order"]} def validate(mt, ev, duration=60, tol=0.02): per_min = mt.groupby("minute")[list(SPEC)].sum() cum = per_min[FUNNEL].cumsum() funnel = all((cum[a] >= cum[b]).all() for a, b in zip(FUNNEL, FUNNEL[1:])) gaps = sorted(set(range(duration)) - set(per_min.index)) clock = str(ev["ts"].dt.tz) == CLOCK["tz"] and ev["minute"].between(0, duration - 1).all() ours = per_min.sum() recon = {k: round(abs(ours[k] - v) / v, 4) for k, v in PLATFORM.items()} res = {"funnel order": funnel, "no gap minutes": not gaps, "one clock": clock, "reconciles": all(d <= tol for d in recon.values())} for k, ok in res.items(): print(f"{'PASS' if ok else 'FAIL'} {k}") print(" gaps:", gaps, "| off platform by:", recon) return "scored" if all(res.values()) else "rejected" print("platform totals:", PLATFORM) print(validate(mt, ev)) # platform totals: {'room_entry': 1511, 'add_to_cart': 91, 'order': 49} # PASS funnel order # PASS no gap minutes # PASS one clock # PASS reconciles # gaps: [] | off platform by: {'room_entry': 0.0026, 'add_to_cart': 0.011, 'order': 0.0} # scored

Break it twice, on purpose. A gate you have only ever seen pass is a gate you have not tested. First, lose minute 17, the way a dead ingestion job would. Second, swap two event names, the way a careless overlay release would. Each break should fail a different check, and say which.

★ Step 5 · two broken streams# break 1: the pipeline loses minute 17 lost = raw[((raw["ts"] - START).dt.total_seconds() // 60) != 17] print(validate(*minute_table(lost))) # break 2: an overlay release maps click and order to each other's names swap = raw.assign(event=raw["event"].replace({"product_click": "order", "order": "product_click"})) print(validate(*minute_table(swap))) # PASS funnel order # FAIL no gap minutes # PASS one clock # FAIL reconciles # gaps: [17] | off platform by: {'room_entry': 0.0199, 'add_to_cart': 0.011, 'order': 0.0408} # rejected # FAIL funnel order # PASS no gap minutes # PASS one clock # FAIL reconciles # gaps: [] | off platform by: {'room_entry': 0.0026, 'add_to_cart': 0.011, 'order': 2.9592} # rejected

Read the first break carefully. Room entries moved by 0.0199 against the platform, under the 0.02 tolerance: on entries alone, reconciliation would have waved the stream through. Orders crossed it at 0.0408, and the gap check named the minute. The swap is louder: 194 clicks relabelled as orders is 2.96 times the platform's 49, and the funnel check fails before reconciliation even gets a say.

Keep the gate next to the spec, and import both. Session 5 computes Stop, Hold, Love, Buy only on streams whose verdict is "scored", and it reads the event names from the same SPEC. A second list of event names in a notebook is the rename from Part 2 waiting to happen.

Homework

Before session 5

30 min
  1. Write the spec for one event stream you work with, live or not: five events or fewer, all six fields on each, and one clock with an owner. Mark every event as owned, full via API or partial.
  2. Draw the contract sheet for it: events down the side, teams across the top, one owner per row. Find the field everyone reads and nobody owns. Most teams have one.
  3. Add a third break to step 5: put one source on local time, UTC+8, and check which of the four checks fails. If none does, change the clock check until it would.
  4. Rerun step 4 with a tolerance of 0.01 and then 0.05. Write one sentence on which you would put in the spec, and who should sign it.
Check yourself

Three questions

Clear all three and Analyst session 4 gets stamped in your passport.

1 · Live card impressions, the times the stream's cover appeared in a feed, reach Serai only as one platform total per stream. What does that mean for the spec?

The source field decides the fix. An owned event can be re-counted from raw; a platform total can only be compared with. Writing that down stops a team from "correcting" a number nobody at Serai can see behind.

2 · An overlay release renames product_click to card_tap. Nothing else changes. How should the spec treat it?

The definition did not move, but every query that reads the event did. To the other seven teams a renamed event is a removed one, which is exactly how Pasar Live's click rate read zero for two days in the constructed case.

3 · With minute 17 lost, room entries were 1.99 percent off the platform's total, inside the 2 percent tolerance. What does that show?

Each check sees something the others cannot. Reconciliation tells you a total is off; only the gap check tells you which minute is missing, and the missing minute may be the one where the host lost the room.

Sources covered

What this session teaches, and where it came from

✓The per-minute event specConstructed for Pasar Live: five events, keyed stream_id + minute + sku, one UTC clock with minute 0 at room live, six fields per event
✓Ownership and change controlPractice: one owner per field across the eight teams, versioning MAJOR.MINOR.PATCH, a definition of done for tracking work; the owner grid is constructed
✓Validation checks before scoringPractice, computed on a constructed stream with default_rng(4): funnel order, no gap minutes, one clock, reconciliation within a tolerance; every printed output was run
✓Platform totals carry a definitionSea Limited, Annual Report on Form 20-F for the fiscal year 2019, "Conventions" definitions: GMV includes shipping; orders counted whether or not settled
◐Minute-table mechanics and livestream methodsOwned by the live commerce analytics course, linked from Part 1, not taught here

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

Analyst session 4 cheat sheet · pin this

Five eventsRoom entry, card impression, click, add to cart, order. Keyed stream_id + minute + sku.
One clockUTC, minute 0 = the room goes live. It is a field, and it needs an owner.
Six fieldsName, definition, grain, source, owner, first-seen version. On every event.
A rename is MAJORTo every reader it is a removal. Sign-off, a date, both names for a while.
Dedupe on the idThe idempotency key, never the timestamp. Late events land in their own minute.
Four checks, then scoreFunnel order, no gaps, one clock, reconciles. Fail one: rejected, with the reason.