Analytics and labs
Skill andymai/gridfinity-layout-tool/.claude/skills/analytics-and-labs
PostHog event conventions (trackEvent, snake_case names, #1466 import-cycle rule), ML telemetry pipeline (/api/ml-telemetry, label hashing), labs feature flags (FEATURE_FLAGS, useFeatureFlag, graduate/remove), and shared-hook traps (stale closures, cross-tab sync, auto-save races). Load when adding/renaming analytics events, adding or removing a labs flag, debugging "event never appears", "flag toggle does nothing", or "blank page in production build".From its SKILL.md
npx -y skills add andymai/gridfinity-layout-tool --skill analytics-and-labsAssembled from the repository path, not quoted from the project. Check it against their README if it does not work.
SKILL.md
12.4 KB, as published. Nobody here has run it
Analytics & Labs
When to use
- Adding, renaming, or debugging a PostHog event or person property.
- Adding, graduating, or removing a labs feature flag, or gating code behind one.
- Touching ML telemetry (
src/shared/analytics/mlTelemetry/) orapi/ml-telemetry.ts. - Production build boots to a blank page after an analytics change; a labs toggle "does nothing".
Mental model
- Two pipelines, identical function names.
src/shared/analytics/posthog/sends product events to PostHog;src/shared/analytics/mlTelemetry/batches training data to/api/ml-telemetryvia sendBeacon. Both exporttrackLayoutSnapshotandtrackFillOperationwith different signatures. The root barrelsrc/shared/analytics/index.tsdeliberately re-exports only mlTelemetry + label utilities — import PostHog helpers from@/shared/analytics/posthog, ML trackers from@/shared/analyticsor@/shared/analytics/mlTelemetry. Auto-import picking the wrong twin compiles fine and sends data to the wrong pipeline. ML telemetry gets the layout store injected viasetLayoutStoreRef()(called inmain.tsx;mlTelemetry/init.tsavoids circular deps this way), counts only edits withlastEditSource === 'local'as edit activity — collab/remote edits don't — and its buffer flushes at 20 events or a 30s timer, force-flushing onvisibilitychange/pagehide. - The #1466 cycle rule.
src/shared/analytics/posthog/metrics.tsimportsuseLabsStore, so any module a Zustand store imports (especiallysrc/core/store/labs.ts) must deep-import from@/shared/analytics/posthog/trackEvent(leaf), never the barrel or./events/./metrics. Violation crashes production boot only (chunk-level static-import cycle → blank page; dev is fine). Nothing mechanical checks this — the constraint lives in the header oftrackEvent.ts. - Analytics never throws, and never runs in dev. Every tracker try/catches and fails silently;
posthog/init.ts,mlTelemetry/init.ts, anduseAnalytics.tsall early-return onimport.meta.env.DEV. You cannot verify events withpnpm run dev— use unit tests, orpnpm run build && pnpm run previewwithVITE_PUBLIC_POSTHOG_KEYset. - Flag gating precedence is duplicated on purpose in
src/shared/hooks/useFeatureFlag.ts(reactive) andisFeatureEnabledinsrc/core/store/labs.ts(getState path):graduated→ always true,deprecated→ always false,comingSoon→ always false, else the localStorage preference (gridfinity-labs-v1). A new status value must be handled in both. - Fire-once state lives in localStorage
gridfinity-analytics-v1(posthog/identity.ts): userId, milestones,designsCreated, feature-adoption flags (markFeatureUsedineventsPerson.ts). Clearing it re-fires milestones and skews funnels; the module-level cache means tests must reset it (pruneAnalyticsData). Two milestone ladders ineventsCore.ts:first_bin/engagedon bins drawn, andfirst_design/designs_4/designs_8on distinct designs created, counted from the create branch ofsaveDesign. Bin counts above five do not separate outcomes, which is why the oldsubstantial/power_userrungs were retired rather than rethresholded — reusing their names would have left a redefined metric still answering. - Privacy: ML telemetry never sends raw bin labels — only 8-char non-reversible hashes via
processLabel(src/shared/analytics/labelVocabulary/normalize.ts). New trackers touching user text must hash or truncate (seesanitizeQueryinposthog/events.ts). Both pipelines respectsettings.analyticsEnabled(isEnabled()inmlTelemetry/trackersHelpers.ts).
Recipes
Add a PostHog event
Traps, not steps. There is no CQRS analytics middleware, so a command emits
nothing on its own; every event is an explicit trackXxx() call in
src/shared/analytics/posthog/.
- Props are
string|number|boolean|nullonly, anddevice_typeis attached automatically. Arrays and objects need the lower-levelcapture()from./init(seelayout_snapshotineventsCore.ts). - If the caller is a store, or anything a store imports, deep-import
@/shared/analytics/posthog/trackEvent. The barrel forms an import cycle (mental model #2), and the symptom is a blank page in a production build. - Re-export from
posthog/events.tsandposthog/index.tsand wire the call site in the same PR: knip inpnpm run qualityfails on an exported-but-uncalled tracker. - Once-only person properties need the two-arg form
setPersonProperties({}, onceProps). The one-arg form lets mutable values overwrite first-touch attribution.
Naming: snake_case, past-tense or noun_action (bin_created,
labs_feature_toggle), with a domain prefix for families (labs_*, help_*,
bin_export_*).
Add a labs flag and gate a feature
Append to FEATURE_FLAGS in src/core/labs/features.ts; LabsDrawer renders
everything from getToggleableFeatures(), so there is no wiring step. The
traps:
FeatureIdpicks up the id automatically, butgetFeature(id)accepts any string and silently returns undefined. PassFeatureId-typed values.- Gate non-React code with
useLabsStore.getState().isFeatureEnabled(...)called at decision time. Caching at module or mount scope gives a value that never updates on toggle. - A flag read once at worker spawn (kernel selection, the
brepkit_kernelpattern) needsrequiresRefresh: trueand a warning saying so. comingSoon: truerenders it unclickable and makes toggles no-op returning OK.
Toggling already emits labs_feature_toggle from the store; no extra analytics.
Graduate or remove a labs flag
- Graduate: set
status: 'graduated'+graduatedAt: 'YYYY-MM'infeatures.ts. Keep the id and every runtime check — graduated short-circuits to true in both gate implementations (manifold_previewprecedent). The drawer moves it to its graduated section automatically. - Remove: delete the array entry, leave a tombstone comment (see the
cqrs_undo/occt_wasm_kernellines infeatures.ts), fix everyFeatureIdusage the compiler flags, and adddelete prefs.enabledFeatures.<id>;toloadPreferences()insrc/core/store/labs.ts(see thehandle_ledges/angled_dividerslines) — otherwise the orphaned localStorage key inflatescomputeLabsMetrics()forever. - Update
features.test.tsandsrc/features/labs/README.mdeither way.
Copy the shared-hook safety patterns
- Stale closures in
[]-dep effects: read fresh state viauseStore.getState()at call time (useAnalytics.ts,useAutoSave.ts) oruseLatestReffor non-React listeners. - Async save/mutation hooks: copy the
useAutoSave.tsguard — capturesavingLayoutIdbefore the await, re-readuseLayoutStore.getState().activeLayoutIdafter, discard the update on mismatch. Otherwise a quick layout switch mid-save corrupts the wrong layout's metadata. - Cross-tab labs sync uses the window
storageevent (useCrossTabSync.ts, keyed onLABS_STORAGE_KEY); StorageEvents fire only in OTHER tabs, so same-tab localStorage writes in tests never trigger the listener.
Verification
pnpm run test:run src/shared/analytics
pnpm run test:run src/core/labs src/core/store/labs.test.ts src/shared/hooks
pnpm run typecheck # safety net for FeatureId changes
pnpm run quality # knip catches exported-but-uncalled trackers
pnpm run check:boundaries # after new shared/ <-> features/ imports
pnpm run build && pnpm run preview # only real repro for #1466 blank-page cycles
Traps
| Symptom | Cause | Fix |
|---|---|---|
| Prod build blank page, dev fine, after adding tracking to a store | Store → posthog barrel → metrics.ts → useLabsStore cycle (#1466) | Deep-import @/shared/analytics/posthog/trackEvent; repro with build+preview |
| Event never appears while testing locally | DEV early-return in init.ts; also needs VITE_PUBLIC_POSTHOG_KEY + settings.analyticsEnabled | Assert via unit test mocking the leaf; live-verify on a production build |
| Flag toggle has no visible effect | requiresRefresh: true, or comingSoon (toggle no-ops OK), or consumer cached isFeatureEnabled() non-reactively | Reload / use useFeatureFlag in components / call getState().isFeatureEnabled() at decision time |
computeLabsMetrics counts flags that no longer exist | Removed flag's key persists in gridfinity-labs-v1 | delete prefs.enabledFeatures.<id> in loadPreferences() |
| Milestone fires again for an existing user | gridfinity-analytics-v1 cleared or identity cache reset | Treat that key as durable identity; never clear it in migrations |
| Data lands in the wrong pipeline despite compiling | Wrong twin: trackLayoutSnapshot/trackFillOperation exist in both posthog and mlTelemetry | Import from @/shared/analytics/posthog vs @/shared/analytics/mlTelemetry explicitly |
| PostHog floods with junk errors, or one error splits into many issues | Extension noise through global handlers; multi-mount-site errors lack a fingerprint | Add regex to IGNORED_MESSAGE_PATTERNS/IGNORED_SOURCE_PATTERNS in posthog/errorFilters.ts; pin an $exception_fingerprint like WEBGL_CONTEXT_FINGERPRINT in errorFilters.ts (its matching WEBGL_CONTEXT_ERROR message substring must stay in sync with WebGLErrorBoundary.tsx) |
For module-boundary rules and slice anatomy (why shared/ re-export barrels exist), see the feature-slices skill. For sibling-test and knip gate details, see quality-gates; for vitest project layout, see testing.