Product instrumentation
Skill gamedevpl/www.gamedev.pl/.claude/skills/product-instrumentation
How to instrument features on www.gamedev.pl so product health stays measurable — the telemetry architecture, its privacy invariants, the event vocabulary, and the questions the data must always be able to answer. Use whenever you add or change a user-facing flow (play, creation, sign-in, sharing, party mode), touch telemetry code on either side, or add a metric or aggregate.From its SKILL.md
npx -y skills add gamedevpl/www.gamedev.pl --skill product-instrumentationAssembled from the repository path, not quoted from the project. Check it against their README if it does not work.
One thing to look at
- 7 stars7 stars. Stars are a popularity signal and not a quality one, but at this level it is likely that nobody has read this closely except its author, and you would be relying on your own review.
SKILL.md
21.6 KB, as published. Nobody here has run it
Product Instrumentation
A feature that ships without measurement is a feature nobody can tell is working. This skill defines what "instrumented well enough" means here, so every agent applies the same bar without re-deriving it.
The questions the data must always answer
Treat this as the measurement contract. Any change that makes one of these questions harder to answer is a regression, even if no test fails. Any change that touches a flow listed here ships with the events that keep the question answerable.
- First minute: how long from landing to a first game actually being played, and where do people fall out in between?
- Session depth: how many games does one visit play, and does a visit that arrives on a game page go on to play a second game?
- Acquisition: did this visit arrive from a shared game link, a social post, search, or direct? (Referrer domain and UTM parameters — never more than that.)
- Creator funnel: of the people who start typing a prompt, how many reach sign-in, submit, answer QA, get a published game, and play their own game?
- Creator return: did a creator come back within 7 days of their game publishing — to play it, revise it, or make another?
- Per-game health: does the game load, run, hold attention, and get finished? (Opens, focused play-time, alive frames, errors, progress, score, end.)
- Build economics: how long did a build take from submission to publish, and how many revision cycles did it need?
Architecture (both halves, always in sync)
Play telemetry has a fixed vocabulary defined in three places that must change together, plus tests:
- apps/web/src/telemetry.ts — browser batching,
caps, normalization.
TelemetrySessionis deliberately DOM-free so limits are testable. - apps/api/src/telemetry/telemetry.ts — zod schema, rate limits, published-slug gating, server-anchored timestamps.
- apps/api/src/store/records/telemetry.ts
—
TelemetryEvent. Daily-partition storage is store/slices/telemetry.ts.
Events from inside a game iframe arrive as postMessage with source: 'gdpl-player'
(apps/web/src/gamePlayer.ts). The host accepts
error, alive, progress, score, end — validated, clamped, capped. The games repo
side of this contract is .github/skills/report-play-signals/SKILL.md in that repo; if
you change the vocabulary here, that skill must change in the same sitting.
Visit telemetry is the second, separate stream — the funnel before and between games:
- apps/web/src/visitTelemetry.ts — per-tab identity, acquisition capture, navigation subscription.
- apps/api/src/telemetry/visit-telemetry.ts —
POST /api/telemetry/visit, schema and caps. - apps/api/src/telemetry/visit-funnel.ts — the read-side
aggregator (
summarizeVisitFunnel). VisitEvent/VISIT_COLLECTIONin store/records/telemetry.ts — the event's field types are plainstringhere, not the closed enums below.
The step/via/kind vocabularies each of those three files share (route kinds, funnel
steps, PlayVia, and so on) live in one canonical module,
packages/contract/src/visit-vocab.ts —
an X_VALUES array plus an X type per vocabulary. All three files import from it
instead of declaring their own copy. Adding a rung means adding it to the shared array
in visit-vocab.ts; the browser client, the zod schema, and the funnel aggregator pick
it up from that one import. BuilderDimension ('platform' | 'self') is the one
exception still declared separately in each of the three files — it also touches two
frozen mega-files (mcp-server.ts, submissions.ts) and has not been folded in yet.
The two streams must stay unjoinable. A play event names a game and never a visit; a
visit event names a visit and never a game. That is why play_started carries no slug —
it answers "did this sitting play a second game" without making "which games did this tab
play, in order" derivable. Adding a slug (or any shared key) to the visit stream breaks
the privacy posture of both, so treat it as a design change, not a field addition.
Route kinds are read through the app's own router (parsePathRoute), never re-derived
from the URL. Routing has already changed shape once — hash fragments to real paths — and
a private copy of the URL grammar survives that change by silently reporting home for
every visit, including the shared game links this stream exists to count.
Creator-side facts (submissions, build events, revision messages, publish times) live in Firestore via the store — they are identity-attached and that is fine; creators are signed in. Both telemetry streams are the anonymous half.
Invariants (do not renegotiate these per-feature)
- Play telemetry measures games, not people. No uid, no IP, no user agent, no persistent identifier in play events. A per-game-open session id is a uuid held in memory only. If a metric seems to require identifying a player, redesign the metric.
- Visit-scoped correlation, when needed, is ephemeral:
sessionStorageat most, never a cookie, never localStorage, gone when the tab closes. - Acquisition data is coarse: referrer domain (not full URL) and UTM values. Never place personal data in URLs or events.
- Everything from a game iframe is hostile input. Fixed vocabulary, length clamps, numeric clamps, per-request / per-session / per-IP caps. Copy the existing patterns; do not invent looser ones.
- Aggregates only leave the building. Admin and any future dashboard return aggregates, never raw event rows.
- Telemetry never breaks the product. Best-effort sends, swallowed failures, and a
flush path that survives tab death (
keepalive, flush-on-hidden). - Derive, don't declare. Prefer metrics computed from observed behavior over self-reported flags (the same principle as deriving touch support from game source).
- Stable names. Event types and progress labels are an API: renaming one breaks
every time series that contains it. Extend deliberately; never repurpose. This binds
fields as much as event names, and
VisitEventis one flat bag shared by every event type — so its field names are already spoken for across the whole stream.entryis the route a visit landed on;viais which surface opened a thing;step,route,builder,detail,reopenlikewise. A new dimension gets a new name (controlfor which remix button was pressed), never a second meaning on an existing one — a field that means two things makes every row written before the second meaning ambiguous, and no migration can tell them apart afterwards. - Bots are not people, and the data must know it. Automation accounts live in the
bot:uid namespace (docs/agent-access-tokens.md). A token-authenticated request records noactiveDaysentry, andbot:submissions are excluded fromsummarizeCreatorMetrics— otherwise an agent on a schedule reports flawless retention for a creator who does not exist, and the one number Stage 0 gates on becomes a measurement of our own test suite. Any new person-shaped metric (retention, return, cohort, funnel-by-account) must exclude the prefix the same way; play and visit telemetry need no change, since neither is attributed at all. Play affinity (the signed-in open history behind home-page recommendations) is person-shaped and follows the same rule:bot:uids neither write affinity nor receive personalised picks. Affinity is not play telemetry — it lives underusers/{uid}/playAffinityand is erased with the account; never join it toplayEventsor visit events.
Known gaps (prefer closing one over inventing new metrics)
Current state, audited 2026-07-25 and updated as gaps close. When your task touches an adjacent flow, close the gap in the same change or flag it explicitly in the PR:
No landing/visit events— closed:visit_started/route_viewed/play_startednow cover questions 1–3.Session depth unmeasurable— closed: visit-scoped ephemeral id insessionStorage; countplay_startedpervisitId.No referrer/UTM capture— closed: coarse referrer hostname plus filteredutm_source/utm_medium/utm_campaignonvisit_started.- Follow-ups the visit stream left open:
— closed 2026-07-26: the policy is live, and infra/setup-gcp.sh step 6/6 now loops over every telemetry collection group instead of namingvisitEventsneeds its own Firestore TTL policyplayEventsalone. A new stream must add its group to that loop in the same change — a group with no policy still writesexpiresAt, nothing errors, and nothing expires.— closed:startVisitTrackingpatcheshistory.pushStateAppemits agdpl:navigateevent (5fe02928) and the listener uses it (79dd4026).
Nothing reads the visit stream— closed 2026-07-26:summarizeVisitFunnel(visit-funnel.ts) behindGET /api/admin/telemetry/visits, rendered byVisitFunnelPanelbeside game health on the operator page. Both admin reads share one partition-scan budget, so the two views cannot drift in how they report truncation.Creator funnel starts too late— closed 2026-07-26:create_stepon the visit stream recordsprompt_started→spec_submitted→signin_required→qa_shown→title_confirmed→submission_created→handoff_shown→handoff_enter_studio(the last two measure the platform welcome handoff before Studio). Steps dedupe per visit (a rung means "this visit got this far"), and the aggregate dedupes again so a replayed flush cannot inflate one. Adding a rung means adding it toCREATE_STEPSinpackages/contract/src/visit-vocab.ts— the order is the funnel's meaning, and all three files (visitTelemetry.ts,visit-telemetry.ts,visit-funnel.ts) import it from there. The waitlist funnel (waitlist_step/WAITLIST_STEPS) follows the same shared-module contract beside it.Closed-beta waitlist funnel unmeasured— closed 2026-07-31:waitlist_stepon the visit stream recordscta_clicked→joined. Same shared-module contract ascreate_step—WAITLIST_STEPSinpackages/contract/src/visit-vocab.ts; rendered as a Waitlist block onVisitFunnelPanelbeside Creating. The Join CTA is visible before sign-in; the drop between click and join is the sign-in wall, so there is no separatesignin_requiredrung (that name already means the creation wall).BYOCA / self-build funnel unmeasured— closed 2026-08-01 (BY-08):studio_stepon the visit stream recordsbuilder_chosen→connect_copied(alsoconnect_deeplink,connect_dismissed,connect_restored) →agent_signaled→gate_verdict, each with a requiredbuilderdimension (platform|self). Optionaldetailis a closed enum (install|kickoff|header|cursor|vscode|green|red|kit_outdated|creator|agent). BY-18c adds sibling stepconnect_deeplink(detail: cursor | vscode) so one-click install clicks are not conflated with clipboard copies.create_stepmay also carry optionalbuilderonce chosen.StudioStep/StudioStepDetaillive inpackages/contract/src/visit-vocab.tslike the other step vocabularies;builderitself is stillBuilderDimension, declared separately in each ofvisitTelemetry.ts,visit-telemetry.ts, andVisitEventinstore/records/telemetry.ts(not yet folded into the shared module — see Architecture above). Time-to-first agent signal ismsSinceStartonagent_signaled.No admin rollup yet— closed 2026-08-06: daily MCP adoption series (selfChosen/connected/signaled/gateVerdicts) onGET /api/admin/telemetry/trends, rendered on the Trends strip of the operator telemetry tab beside visits/plays/creations.Code surface funnel written but unread— closed 2026-08-11:code_step(offered→opened→file_opened→edited→typechecked→previewed→delivered→published, plusread_only_agent/conflict_seenoutcomes beside the ladder) was landing on the visit stream since CE-01 with no read side —summarizeVisitFunnelhad no rollup and the admin route couldn't expose it. AddedcodingtoVisitFunnel(visit-funnel.ts), rendered as a Code surface block onVisitFunnelPanelbeside Editing.CODE_STEPSlives in the shared vocabulary module like the others.Managed delivery preflight / gate effectiveness unmeasured— closed (MR-07): server log metrics indelivery-metrics.ts(delivery preflight refused,delivery accepted,delivery gate verdict) answer whether audio/symbols/typecheck preflights catch bad submits, how many attempts a caught round needs, and whether async gate failure rates fall. No visit-stream join; no source/prompt/uid in payloads.
How-to-play opens recorded but unreadable— closed 2026-07-31 for the read path that #395 needs:how_to_play_openedcarriesvia: 'bar' | 'more'and optionalreopen: true(same theater card opened again — not "opened twice in the visit"), is recorded only for published plays (reportSlug, same population asplay_started), andsummarizeVisitFunnelexposeshowToPlay(opens/visits among playing visits, same- card repeat visits, via, byEntry withplayingVisitsdenominators). Rendered onVisitFunnelPanel. The product decision in #395 is to ship the richer How to play tone (Goal required; Scoring / Mode optional) via game.legend-keys— seedocs/how-to-play-plan.md. Funnel numbers still inform discoverability; they no longer gate whether to ship the format.Creator return is under-measured— closed 2026-07-26:User.activeDays(a capped list ofyyyy-mm-dd, touched once per account per day from the auth hook) plussummarizeCreatorMetricsbehindGET /api/admin/telemetry/creators. Use a list rather than alastSeenAtinstant if you extend this — a single timestamp cannot tell "returned on day 2 and day 30" from "returned only on day 30", which is the whole question. Build duration median/p90 ships alongside it, covering question 7.Games emit no depth events— closed 2026-07-26, both halves. GameKit's sharedreportfunnel emitsendon the transition into a terminal snapshot state (and the round'sscorewith it), so all 83 games gained it in one change;summarizeGameHealthturns those intofinishRate,winRate,medianBestScoreand aprogressLabelsfunnel, rendered as four columns on the operator page. Two rules that came out of it:- A game that emits nothing and a game nobody finishes produce identical rows. The
view renders
—, never0%, when a game reported no endings at all — and no verdict badge reads off finish rate, because "nobody finishes this" is not yet supportable. Any future depth metric inherits this: absence of evidence renders as absence. - Depth events take no continuity check, unlike
alive. That check exists because a frame counter is meaningless without the interval it covers; an ending is a discrete thing a player did, and a slept machine does not fabricate one.
- A game that emits nothing and a game nobody finishes produce identical rows. The
view renders
progresslandmarks are emitted by 13 of ~82 games so far (2026-07-26) — the vocabulary, the cap, and the read-side funnel all exist and are tested, but labels are per-game and GameKit cannot guess them. Unlike the other depth signals this one cannot be closed by a platform-wide change: coverage grows only as maintenance touches a game and adds its landmarks (see the games repo'sreport-play-signalsskill), so most of the catalog stays dark on this signal indefinitely — that is expected, not a bug to fix in one pass.Nothing aggregates per-game signals durably— closed 2026-07-27: a nightly Cloud Scheduler sweep (POST /api/internal/scorecard-sweep, scorecard.ts) writesgames/{slug}/scorecard/currentfrom a 28-day telemetry window plus vote and feedback counts. This doc is the only thing IL-3's agents are permitted to read, which makes two of its properties load-bearing for anything added to it later:- Game-supplied strings live under
untrusted, never beside the numbers.errorSamples[].messageandprogressLabels[].labelare chosen by a game inside the sandbox. Quarantining them structurally (rather than by comment) means a prompt built by destructuring the scorecard cannot pick one up by accident, and a test asserts no such string is reachable elsewhere in the doc. Any new attacker-controlled field belongs inuntrustedtoo — that is the rule, not a one-off. nullis "no evidence";0is "measured zero". The operator page renders—for the same distinction; the scorecard encodes it in the data, because an agent acts on the value rather than reading a dash next to it.finishRateis null when a game emitted no endings at all.- Scorecards carry a feedback count and no text. Text arrives with the theme extraction that summarizes it, not before — the moment raw text lands here it is on the path to an agent.
- No TTL, deliberately: retention is a promise about raw play rows, and an aggregate is
what is meant to outlive them. Do not add this group to the TTL loop.
Partial progress from Creator Studio: the
/studioroute is a distinct visit kind (studio), so "did they open the control panel after publish" is measurable from the visit stream without joining to play events.
- Game-supplied strings live under
- Build economics are duration-only — submission→publish timestamps and build events exist; revision-cycle counts are derivable; keep it that way as builds evolve.
Shared zones were unmeasured— closed 2026-07-31:zone_link(admitted→joined→lost) on the play stream, aggregated aszoneJoinRateand rendered as the Shared column beside Finished. It exists because the shell falls back to solo play in silence when it cannot reach the host — correct for the player, and the reason a dead zone host and a healthy one are indistinguishable from outside. Three rules came out of it, and they generalise to any signal the shell reports about a game rather than from one:- The rung is the evidence, not the attempt.
joinedfires on the arrival of a snapshot, never on the socket opening. A connection that upgrades and is then closed is exactly how the host failed on its first day; pinning the rung to the socket would have had the metric reporting health throughout the outage. - A shell-owned signal needs its own channel and its own budget.
gamePlayer.tsdoes not accept these types over postMessage, so a game cannot claim to be shared while sitting alone — but until they also had a session cap of their own, a frame could flood past the shared 400-event ceiling and force its own zone to render—. Separating the channel stops a game lying; separating the budget stops it silencing. - A ratio must not be able to exceed its own maximum. Both rungs travel in
best-effort batches, so a session can land
joinedwhile the request carryingadmittedwas lost. Count the numerator only inside the denominator's set; a session missing itsadmittedis one more "no evidence", and the residual bias then understates success rather than inventing it.
- The rung is the evidence, not the attempt.
- A game with no rounds cannot report a score —
scoreis emitted only from the transition into a terminal snapshot state (games-reposhared/modules/core.ts), so ember-watch reports none since it became a continuous world (games-repo #163), andfinishRate/winRate/medianBestScoreread—for it permanently. Half of that is right: a world with no endings has no finish rate. The other half is a real loss — saves are a meaningful score there, they just have no round boundary to be reported at. Closing it means deciding whatscoremeans for a game that never ends, which is a platform question rather than one game's, so it is named here rather than special-cased.
Definition of done for instrumentation
Before finishing a change to a user-facing flow, answer in the PR description: which of the seven questions does this flow affect, and what query over which data now answers it? If the honest answer is "none" or "still can't", say that explicitly — a named gap is acceptable; a silent one is not.
Self-improvement clause: if this skill is wrong, stale, or missing something that cost you time, update it in the same session.