Skip to content

Commit 0ced2df

Browse files
committed
feat(analytics): collapse 28 events into 9 with typed registry
Replaces the application_starter_* and partner_* event family with a trimmed taxonomy designed for GA4 funnel analysis and BigQuery export: - 9 events total (page_view, partner_*, builder_*) instead of ~28 - 4-step builder funnel (page_view -> _analyzed -> _generated -> _activated) with no overloaded event names or filter conditions - mode_used and idea_used become session-context props on every builder event so any breakdown works without session joins - New typed event registry (src/utils/analytics/events.ts) — wrong props for an event are a TypeScript error rather than silent bad data downstream - Reference doc at .agents/analytics.md covers funnel definition, custom dimensions to register in GA4 admin, BigQuery setup, and the event-to-event migration mapping Structural fixes from the prior audit: - Stale analyticsProperties closure on awaited actions (events shipped generated:false after a successful await) — killed by removing the memo entirely; each call site computes fresh props at fire time - _value_copied auto-trigger inflation — auto-copies no longer fire an event; only user-driven copies emit builder_activated - Partner directory active_filters_count always >=1 — replaced with library_filters joined string and nullable status_filter - partner click sites that throw on bad URL hrefs — now wrapped in try/catch so the trackEvent still fires Removed events (folded into others or dropped as noise): - _library_toggled, _integration_toggled, _package_manager_toggled, _toolchain_toggled (final config carried on _generated) - _continue_clicked, _generate_clicked (intent implied by outcomes) - _login_required, _login_clicked (folded into _failed[stage=login_blocked]) - _builder_result_applied, _final_partner_in_prompt, _final_addon_in_prompt (data carried on _generated) - _value_copied (auto trigger only) - _action_clicked umbrella (split into typed builder_activated.action enum) - partner_card_clicked, partner_click, partner_impression, partner_detail_viewed, partners_filter_changed, become_partner_clicked, partner_inquiry_clicked (all renamed/restructured)
1 parent bb26993 commit 0ced2df

20 files changed

Lines changed: 876 additions & 384 deletions

.agents/analytics.md

Lines changed: 349 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,349 @@
1+
# Analytics
2+
3+
Reference for the TanStack.com event taxonomy in Google Analytics 4.
4+
5+
**GA4 property:** `G-JMT1Z50SPS`
6+
**First-party proxy:** all hits route through `/_a/g/collect` on tanstack.com (see `netlify.toml`)
7+
**Implementation:** [src/utils/analytics.ts](../src/utils/analytics.ts), [src/utils/analytics/events.ts](../src/utils/analytics/events.ts)
8+
9+
## Design principles
10+
11+
1. **Event names describe what happened.** Properties carry context. Adding a new partner placement is an enum value, not a new event.
12+
2. **One event per outcome, not per intent.** No `_clicked`/`_attempted` events when an outcome event is going to fire anyway.
13+
3. **No per-row events.** Counts and joined-string arrays as properties, not N rows per generation.
14+
4. **Session context propagates.** Slow-changing props like `mode_used` and `idea_used` are stamped on every builder event so any breakdown works without joins.
15+
5. **Typed registry.** Wrong props for an event = TypeScript error, not silent bad data.
16+
17+
---
18+
19+
## The funnel
20+
21+
Application Builder, four steps. No `OR` conditions, no overlapping event names.
22+
23+
| # | Step | Event | Filter |
24+
|---|---|---|---|
25+
| 1 | Landed on builder | `page_view` | `page_type = application_builder` |
26+
| 2 | Got an analysis | `builder_analyzed` ||
27+
| 3 | Got a generation | `builder_generated` ||
28+
| 4 | Took action on result | `builder_activated` ||
29+
30+
Drop-off between any two steps is unambiguous.
31+
32+
**Failure rates** are separate explorations, not branches off the funnel:
33+
34+
- Analysis failure rate = `builder_failed[stage=analysis]` ÷ (`builder_analyzed` + `builder_failed[stage=analysis]`)
35+
- Generation failure rate = `builder_failed[stage=generation]` ÷ (`builder_generated` + `builder_failed[stage=generation]`)
36+
- Login wall rate = `builder_failed[stage=login_blocked]` ÷ `page_view[page_type=application_builder]`
37+
38+
---
39+
40+
## Event reference (9 events)
41+
42+
Every event automatically receives `page_location`, `page_path`, `page_title`, `page_type` from the analytics utility.
43+
44+
### `page_view`
45+
Fires on initial load (auto from gtag config) and on every SPA navigation.
46+
47+
| Prop | Type | Notes |
48+
|---|---|---|
49+
| `page_location` | string | Full URL |
50+
| `page_path` | string | Pathname |
51+
| `page_title` | string | `document.title` |
52+
| `page_type` | enum | `home`, `partners_index`, `partner_detail`, `blog_index`, `blog_post`, `docs`, `partners_embed`, `application_builder`, `page` |
53+
54+
---
55+
56+
### `partner_viewed`
57+
A partner UI element scrolled ≥50% into the viewport. Fires once per element-mount per session (the underlying `IntersectionObserver` disconnects after first fire).
58+
59+
| Prop | Type | Notes |
60+
|---|---|---|
61+
| `partner_id` | string | Stable partner identifier |
62+
| `placement` | enum | See `PartnerPlacement` below |
63+
| `slot_index` | number? | Position in the surface (0-indexed) when applicable |
64+
65+
**Note on inflation:** when filters change on the partners directory, cards unmount/remount and `partner_viewed` re-fires. Treat session-unique impressions as the dedup'd metric (compute in BigQuery with `FIRST_VALUE(... PARTITION BY session_id, partner_id)`).
66+
67+
---
68+
69+
### `partner_clicked`
70+
User clicked a partner UI element to navigate somewhere.
71+
72+
| Prop | Type | Notes |
73+
|---|---|---|
74+
| `partner_id` | string | |
75+
| `placement` | enum | See `PartnerPlacement` below |
76+
| `destination` | enum | `external` (partner's site) or `internal_detail` (our partner detail page) |
77+
| `destination_host` | string? | Host of the destination URL when `external` |
78+
| `slot_index` | number? | Position in the surface when applicable |
79+
80+
CTR per placement = `partner_clicked` ÷ `partner_viewed` filtered to same `placement`.
81+
82+
---
83+
84+
### `partner_filter_applied`
85+
User changed the filter state on the partners directory.
86+
87+
| Prop | Type | Notes |
88+
|---|---|---|
89+
| `change` | enum | `libraries_changed`, `status_changed`, `cleared_all` |
90+
| `library_filters` | string | Comma-joined library ids in the filter, or empty string |
91+
| `status_filter` | string \| null | `null` means "no status filter applied" — distinguishes from "user explicitly chose 'active'" |
92+
| `result_count` | number | Number of partners visible after the filter |
93+
94+
---
95+
96+
### `partner_inquiry_started`
97+
User clicked a "get in touch", "let's chat", or "become a partner" CTA.
98+
99+
| Prop | Type | Notes |
100+
|---|---|---|
101+
| `placement` | enum | `partners_index_cta`, `library_callout`, `docs_right_rail` |
102+
103+
---
104+
105+
### `builder_analyzed`
106+
Analysis API call succeeded. Outcome event — always preceded by user intent, no separate `_requested` event.
107+
108+
| Prop | Type | Notes |
109+
|---|---|---|
110+
| `mode_used` | enum | Session context: `lucky`, `confident`, `none` |
111+
| `idea_used` | string | Session context: idea label, or `none` |
112+
| `analysis_deployment` | string? | Inferred deploy target |
113+
| `inferred_library_count` | number | |
114+
| `inferred_partner_count` | number | |
115+
| `feature_count` | number | |
116+
117+
---
118+
119+
### `builder_generated`
120+
Generation API call succeeded.
121+
122+
| Prop | Type | Notes |
123+
|---|---|---|
124+
| `mode_used` | enum | Session context |
125+
| `idea_used` | string | Session context |
126+
| `final_deployment` | string? | The chosen deploy target on the final result |
127+
| `final_package_manager` | string | `pnpm`, `npm`, `yarn`, `bun` |
128+
| `final_library_count` | number | |
129+
| `final_partner_count` | number | |
130+
| `final_addon_count` | number | |
131+
| `library_ids` | string | Comma-joined LibraryIds — use `SPLIT()` in BigQuery for top-N analysis |
132+
| `partner_ids` | string | Comma-joined |
133+
| `addon_ids` | string | Comma-joined |
134+
135+
---
136+
137+
### `builder_failed`
138+
Single umbrella event for analysis failures, generation failures, and login-wall blocks. Use the `stage` prop to distinguish.
139+
140+
| Prop | Type | Notes |
141+
|---|---|---|
142+
| `mode_used` | enum | Session context |
143+
| `idea_used` | string | Session context |
144+
| `stage` | enum | `analysis`, `generation`, `login_blocked` |
145+
| `error_message` | string? | Free-form error message — high cardinality, don't register as a dimension; query in BigQuery |
146+
| `retry_after` | number? | Seconds until retry permitted (login_blocked only) |
147+
| `anonymous_generations_remaining` | number? | When the failure was rate-limit related |
148+
149+
---
150+
151+
### `builder_activated`
152+
User took an action on the generated result. Single event with `action` prop covers all post-generation actions.
153+
154+
| Prop | Type | Notes |
155+
|---|---|---|
156+
| `mode_used` | enum | Session context |
157+
| `idea_used` | string | Session context |
158+
| `action` | enum | See `BuilderAction` below |
159+
| `surface` | enum | `result_panel` (main builder UI) or `deploy_dialog` |
160+
| `provider` | string? | Deploy provider when applicable: `vercel`, `netlify`, `cloudflare` |
161+
| `automatic` | boolean | `true` for system-driven actions (e.g., deploy_dialog auto-redirect countdown). Filter to `false` for true user click rates. |
162+
163+
**Important:** automatic prompt-copies that fire as a side-effect of generation do NOT emit `builder_activated`. Only user-driven actions count as activation.
164+
165+
---
166+
167+
## Enums
168+
169+
### `PartnerPlacement`
170+
| Value | Where it appears |
171+
|---|---|
172+
| `directory` | Partner cards in `/partners` index |
173+
| `detail` | Partner detail page CTA |
174+
| `docs_rail` | Right rail on docs pages — partner cards AND the "Become a Partner" link both fire with this placement (event name distinguishes) |
175+
| `blog_rail` | Right rail on blog pages |
176+
| `grid` | Generic partners grid (fallback) |
177+
| `home_grid` | Home page social-proof grid |
178+
| `library_grid` | Library page partners section — filter `page_path` to know which library |
179+
| `embed_grid` | Partners embed view |
180+
| `docs_strip` | Mobile partner strip in docs |
181+
| `ecosystem_game` | 3D ecosystem game islands |
182+
| `partners_index_cta` | "Get in touch" mailto on `/partners` |
183+
| `library_callout` | "Let's chat" callout per library |
184+
185+
### `BuilderAction`
186+
| Value | Means |
187+
|---|---|
188+
| `copy_prompt` | User clicked a copy button on the prompt |
189+
| `deploy` | Started a deploy through the deploy dialog |
190+
| `clone_repo` | Cloned the GitHub repo |
191+
| `open_codex` | Opened the result in Codex |
192+
| `open_claude` | Opened the result in Claude |
193+
| `open_cursor` | Opened the result in Cursor |
194+
| `download` | Downloaded the project as a zip |
195+
| `open_advanced` | Opened the advanced builder editor |
196+
| `netlify_start` | Started a Netlify deploy from the result |
197+
| `provider_redirect_manual` | User clicked through to deploy provider |
198+
| `provider_redirect_auto` | Countdown auto-redirected user to deploy provider (`automatic = true`) |
199+
| `open_repo` | Opened the project repo from the deploy dialog |
200+
201+
---
202+
203+
## Session context
204+
205+
`mode_used` and `idea_used` are tracked in the builder hook and stamped on **every** builder event. This means any builder event can be sliced by mode or idea without session joins.
206+
207+
**`mode_used`** transitions:
208+
- `none``lucky` when user clicks "I'm feeling lucky"
209+
- `none``confident` when user clicks "I'm feeling confident"
210+
211+
**`idea_used`** transitions:
212+
- `none` → idea label string when user picks a suggested idea
213+
- Reset back to `none` if user clears or types fresh input (TBD — currently sticks for the session)
214+
215+
---
216+
217+
## Custom dimensions to register in GA4
218+
219+
Admin → Custom definitions → Create custom dimension. **Event scope** for all of these. Without registration, they're stored in BigQuery export but invisible in the GA4 UI.
220+
221+
| Dimension name | API name | Used on |
222+
|---|---|---|
223+
| Placement | `placement` | partner_viewed, partner_clicked, partner_inquiry_started |
224+
| Partner ID | `partner_id` | partner_viewed, partner_clicked |
225+
| Mode used | `mode_used` | all builder events |
226+
| Idea used | `idea_used` | all builder events |
227+
| Action | `action` | builder_activated |
228+
| Surface | `surface` | builder_activated |
229+
| Stage | `stage` | builder_failed |
230+
| Final deployment | `final_deployment` | builder_generated |
231+
| Final package manager | `final_package_manager` | builder_generated |
232+
| Final library count | `final_library_count` | builder_generated |
233+
| Final partner count | `final_partner_count` | builder_generated |
234+
| Page type | `page_type` | all events |
235+
236+
12 dimensions. Well under the 50-dimension event-scoped limit.
237+
238+
**Don't register:** `error_message`, `library_ids`, `partner_ids`, `addon_ids`, `destination_host`. High cardinality. Query in BigQuery.
239+
240+
---
241+
242+
## Common GA4 queries
243+
244+
### "What's our funnel completion rate?"
245+
**Explore → Funnel exploration**, four steps as defined above. Open funnel. Show elapsed time.
246+
247+
### "Lucky vs Confident: which mode converts better?"
248+
Same funnel, breakdown dropdown = `mode_used`. Compare side by side.
249+
250+
### "Which deploy targets do successful generations land on?"
251+
**Explore → Free-form**, dimension = `final_deployment`, metric = event count of `builder_generated`.
252+
253+
### "What % of users hit the login wall?"
254+
Free-form, filter `event_name = builder_failed`, breakdown by `stage`.
255+
256+
### "Which placement converts best for partner discovery?"
257+
Free-form, filter `event_name = partner_clicked OR partner_viewed`, breakdown by `placement`. Compute CTR yourself: clicks ÷ views per placement.
258+
259+
### "Top 10 libraries in completed generations"
260+
Requires BigQuery — `library_ids` is high-cardinality. Sample query:
261+
262+
```sql
263+
SELECT
264+
library_id,
265+
COUNT(*) AS generations
266+
FROM `tanstack.analytics_*.events_*`,
267+
UNNEST(SPLIT((SELECT value.string_value
268+
FROM UNNEST(event_params)
269+
WHERE key = 'library_ids'), ',')) AS library_id
270+
WHERE event_name = 'builder_generated'
271+
AND _TABLE_SUFFIX BETWEEN '20260101' AND '20260131'
272+
GROUP BY library_id
273+
ORDER BY generations DESC
274+
LIMIT 10
275+
```
276+
277+
---
278+
279+
## BigQuery export
280+
281+
**Strongly recommended.** Free at our event volume. Without it, anything beyond stock reports is painful.
282+
283+
Setup:
284+
1. GA4 Admin → BigQuery Links → Link
285+
2. Pick a GCP project, daily export, US multi-region
286+
3. Tables appear at `tanstack.analytics_<property_id>.events_YYYYMMDD` after ~24h
287+
288+
Once enabled, all event properties are queryable — including the ones not registered as custom dimensions. Use SQL for everything dimensional or aggregate-heavy. Use the GA4 UI for funnel exploration and headline numbers.
289+
290+
---
291+
292+
## Adding new events
293+
294+
Anything additive should not require schema migration of the existing taxonomy.
295+
296+
**Add a new partner placement:**
297+
1. Add the value to `PartnerPlacement` in [src/utils/analytics/events.ts](../src/utils/analytics/events.ts)
298+
2. Use it at the call site
299+
3. (Optional) update the placement table in this doc
300+
301+
**Add a new builder action:**
302+
1. Add the value to `BuilderAction`
303+
2. Use it at the `builder_activated` call site
304+
305+
**Add a new event:**
306+
1. Add a new union member to `AnalyticsEvent` with its prop interface
307+
2. Call `trackEvent({ name: '...', props: { ... } })`
308+
3. Register relevant breakdown props as custom dimensions in GA4 admin
309+
4. Document the event in this file
310+
311+
**Don't:** add new properties to existing events without updating both the type definition and this doc. Schema drift in analytics events is the slowest bug to detect.
312+
313+
---
314+
315+
## Code locations
316+
317+
| File | Purpose |
318+
|---|---|
319+
| [src/utils/analytics.ts](../src/utils/analytics.ts) | `trackEvent`, `useTrackedImpression`, `trackPageView`, `getPageType` |
320+
| [src/utils/analytics/events.ts](../src/utils/analytics/events.ts) | Typed event registry — discriminated union of all events |
321+
| [src/utils/analytics/providers/google.ts](../src/utils/analytics/providers/google.ts) | gtag wrapper |
322+
| [src/utils/analytics/types.ts](../src/utils/analytics/types.ts) | Provider interface |
323+
| [src/routes/__root.tsx](../src/routes/__root.tsx) | gtag bootstrap, `PageViewTracker` |
324+
| [netlify.toml](../netlify.toml) | First-party proxy redirects |
325+
326+
---
327+
328+
## Migration history
329+
330+
### 2026-05 — schema v2
331+
Collapsed 28 events into 9. Removed `application_starter_*` event family. Replaced with `builder_*` events. Mode and idea selection moved from standalone events into session-context props on every builder event.
332+
333+
**Events removed entirely** (no replacement):
334+
- `application_starter_library_toggled`, `_integration_toggled`, `_package_manager_toggled`, `_toolchain_toggled` — config exploration depth no longer tracked. Final config is on `builder_generated`.
335+
- `application_starter_continue_clicked`, `_generate_clicked` — intent implied by outcome events.
336+
- `application_starter_login_clicked`, `_value_copied` (auto), `_builder_result_applied`, `_final_partner_in_prompt`, `_final_addon_in_prompt`.
337+
338+
**Events folded into others:**
339+
- `application_starter_action_clicked` (mode_selected) → `mode_used` prop on subsequent events
340+
- `application_starter_idea_selected``idea_used` prop on subsequent events
341+
- `application_starter_login_required``builder_failed[stage=login_blocked]`
342+
- `application_starter_value_copied` (user trigger) → `builder_activated[action=copy_prompt]`
343+
- All other `application_starter_action_clicked` calls → `builder_activated`
344+
- `partner_card_clicked`, `partner_click``partner_clicked`
345+
- `partner_impression`, `partner_detail_viewed``partner_viewed`
346+
- `partners_filter_changed``partner_filter_applied`
347+
- `partner_inquiry_clicked`, `become_partner_clicked``partner_inquiry_started`
348+
349+
Historical data with old event names is still queryable in GA4 and BigQuery. Cutover date: see git log for the migration commit.

.agents/index.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,3 +16,4 @@ TanStack.com marketing site built with TanStack Start.
1616
- [TanStack Patterns](./tanstack-patterns.md): Loaders, server functions, environment shaking
1717
- [UI Style Guide](./ui-style.md): Visual design principles for 2026
1818
- [Workflow](./workflow.md): Build commands, debugging, Playwright
19+
- [Analytics](./analytics.md): GA4 event taxonomy, funnel definition, custom dimensions

0 commit comments

Comments
 (0)