Adding Analytics to a Kids App: GA4 + PostHog Without the Mess
We’ve been flying blind. The app has been live for weeks and we have no idea which themes kids actually play, how often they abandon mid-puzzle, or whether anyone has ever hit the level-up screen. Time to fix that.
The goal: wire up Google Analytics 4 for traffic/acquisition and PostHog for behavioral product analytics — without scattering gtag() calls across the codebase.
The Design Constraint: One Module, Two Sinks
The temptation with analytics is to sprinkle SDK calls wherever you need them. You end up with posthog.capture(...) in 12 files and gtag(...) in 15 others, and the day you want to rename an event or swap a provider you’re doing archaeology.
Instead, one file: client/src/lib/analytics.ts. Every event is a typed function. Components import the function, not the SDK.
// ✅ What components do
import { trackPuzzleCompleted } from "../../lib/analytics";
trackPuzzleCompleted({ theme, puzzleId, moves, timeMs, stars, gridSize });
// ❌ What they don't do
import posthog from "posthog-js";
posthog.capture("puzzle_completed", { ... });
gtag("event", "puzzle_completed", { ... });
Both SDKs fire inside the same function. One place to change event names, one place to add properties, one place to disable everything.
Skipping the Script Tag
The standard GA4 setup puts a <script> tag in index.html with the measurement ID hardcoded in the URL:
<script async src="https://www.googletagmanager.com/gtag/js?id=G-XXXXXXX"></script>
We hit a problem immediately: this is a Vite app built into a Docker image. The measurement ID is a VITE_* env var baked in at build time. Vite supports %VITE_GA4_MEASUREMENT_ID% substitution in HTML — but if the variable is missing at build time, you get a literal %VITE_GA4_MEASUREMENT_ID% in the script URL, which silently breaks everything.
The fix: inject the script from analytics.ts itself.
function loadGa4Script(id: string) {
(window as any).dataLayer = (window as any).dataLayer || [];
gtagPush("js", new Date());
gtagPush("config", id);
const script = document.createElement("script");
script.async = true;
script.src = `https://www.googletagmanager.com/gtag/js?id=${id}`;
document.head.appendChild(script);
}
initAnalytics() only calls this if all three env vars are present. In local dev without keys, the whole module is a no-op — no errors, no console noise, nothing.
The Events We Track
Ten events cover everything we care about:
| Event | Trigger |
|---|---|
app_session_start | App mounts |
theme_selected | Kid taps a theme card |
puzzle_started | Board finishes loading (image ready, pieces sliced) |
puzzle_completed | All pieces placed — with moves, time, stars, grid size |
puzzle_abandoned | Back pressed on an in-progress puzzle |
level_up | Stars threshold crossed |
puzzle_replayed | Kid replays a completed puzzle |
photo_puzzle_used | Camera capture triggered |
print_opened | Print activities modal opened |
collection_viewed | Collection page mounted |
One non-obvious distinction: puzzle_started fires from PuzzleBoard.tsx when the board becomes ready, not when the theme card is tapped. Tapping starts a loading screen — the puzzle isn’t actually started until the image loads and pieces are sliced. We use the existing ready state that drives the entrance animation:
useEffect(() => {
if (!ready || !boardRef.current) return;
trackPuzzleStarted(station.id, animal.id);
// ... GSAP entrance animations
}, [ready]);
Similarly, puzzle_abandoned only fires for in-progress puzzles. Pressing back from a completion screen shouldn’t look like abandonment in the data. We track this with a ref:
const puzzleCompletedRef = useRef(false);
// Set on completion
puzzleCompletedRef.current = true;
// In handleBack:
if (puzzle && !puzzleCompletedRef.current && !startAsCompleted) {
trackPuzzleAbandoned(theme, puzzle.id, Date.now() - puzzleStartTimeRef.current);
}
User Identity Without PII
The app has both guest players and logged-in users. PostHog auto-assigns a distinct_id for anonymous sessions. When a user logs in, we call identifyUser(userId) — which passes only the opaque MongoDB _id, no name or email.
export function identifyUser(userId: string): void {
if (!initialized) return;
posthog.identify(userId);
if (ga4Id) gtagPush("config", ga4Id, { user_id: userId });
}
PostHog merges the pre-login anonymous events with the identified user, so we get the full session even when users play as guests before signing up.
On logout, resetUser() calls posthog.reset() to clear the association — important so a shared device doesn’t bleed one user’s identity into another’s session.
This matters especially for a kids app. COPPA compliance means we’re careful about what we send to third parties. Only the opaque ID goes to analytics — never a name, email, or any child data.
Deployment: VITE_* Vars in Docker
Here’s the thing about VITE_* variables: they’re not runtime env vars. Vite replaces them with literal string values during vite build. By the time the app is running in a browser, import.meta.env.VITE_GA4_MEASUREMENT_ID is already replaced with "G-M63BSL8ZYJ" in the bundle. There’s nothing to inject at runtime.
This means K8s secrets and ExternalSecrets are useless for these vars — the server never sees them. The values need to be present when docker build runs.
We store them as GitHub Actions secrets and pass them as --build-arg:
- name: Build and push
env:
VITE_GA4_MEASUREMENT_ID: ${{ secrets.VITE_GA4_MEASUREMENT_ID }}
VITE_POSTHOG_KEY: ${{ secrets.VITE_POSTHOG_KEY }}
VITE_POSTHOG_HOST: ${{ secrets.VITE_POSTHOG_HOST }}
run: |
BUILD_ARGS=""
if [ "${{ matrix.component }}" = "client" ]; then
BUILD_ARGS="--build-arg VITE_GA4_MEASUREMENT_ID=${VITE_GA4_MEASUREMENT_ID} \
--build-arg VITE_POSTHOG_KEY=${VITE_POSTHOG_KEY} \
--build-arg VITE_POSTHOG_HOST=${VITE_POSTHOG_HOST}"
fi
docker build -f ${{ matrix.dockerfile }} ${BUILD_ARGS} -t ${IMAGE}:${TAG} .
The Dockerfile accepts them as ARG and sets them as ENV before the build step:
ARG VITE_GA4_MEASUREMENT_ID
ENV VITE_GA4_MEASUREMENT_ID=$VITE_GA4_MEASUREMENT_ID
ARG VITE_POSTHOG_KEY
ENV VITE_POSTHOG_KEY=$VITE_POSTHOG_KEY
ARG VITE_POSTHOG_HOST=https://us.i.posthog.com
ENV VITE_POSTHOG_HOST=$VITE_POSTHOG_HOST
RUN cd client && bunx vite build
Note the default value for VITE_POSTHOG_HOST — local builds without the arg still produce a valid bundle, the PostHog host is just baked in.
What We’ll Actually Use This For
Short term: completion rates by theme. We expect some themes are much stickier than others — kids will replay Frozen ten times before they touch SpongeBob. Once we have a week of data, we’ll know which themes to expand first in the puzzle pool.
Medium term: funnel from theme_selected → puzzle_started → puzzle_completed. The gap between started and completed tells us where kids are dropping off — is it grid size difficulty or loading time?
Longer term: the level-up data feeds directly into decisions about the leveling curve. Right now the progression from 3×3 to 5×5 grids is tuned by intuition. With time-to-complete by grid size, we can make it data-driven.