Analytics Is Architecture: Stop Sprinkling track() Everywhere

Jul 27, 2026
11 min read
Analytics Is Architecture: Stop Sprinkling track() Everywhere

This cluster has been about how the parts of an app communicate — event buses for ambient signals, feature flags for toggling behavior. Both articles kept circling the same lesson: cross-cutting concerns want a typed contract and a single home, not a scatter of stringly-typed calls. Analytics is the purest example of that lesson, and the one almost everyone gets wrong first — myself included, years ago.

Here's what analytics looks like in most codebases I've opened. A track('button_click') in one component. An analytics.logEvent('Button Clicked') in another. A raw window.gtag('event', 'cta') inline in a third because someone was in a hurry. Event names that mean the same thing spelled three ways. Payloads where one place sends userId and another sends user_id and a third helpfully includes the user's email into a third-party vendor. It works, in the sense that events show up somewhere. But it's un-auditable, un-typed, vendor-locked, and a privacy incident waiting to happen.

That's not a tracking problem. That's an architecture problem.

Analytics is a cross-cutting concern

Analytics has the same shape as logging, i18n, and error reporting: it touches almost every part of the app, but it belongs to none of them. And the smeared-component article named exactly this failure mode — logic that should live in one layer instead gets spread thin across the whole UI until you can't change it in one place. A track() call baked into a button's onClick is analytics logic living in the wrong layer. The button's job is to be a button. Knowing that clicking it should emit a "checkout started" event to three vendors is not the button's concern.

If you can't answer "what does this app track, and where does that data go?" by opening one folder, your analytics isn't a layer — it's a rash.

The fix is the same as every other cross-cutting concern in this series: pull it into a dedicated layer with a clear, typed boundary, and let the rest of the app talk to that boundary instead of to a vendor SDK.

A typed event catalog

Step one is to stop passing strings and start declaring events, once, with typed payloads. This is the same move that tamed the event bus: a catalog the compiler knows about.

// the single source of truth for everything the app can track type AnalyticsEvents = { 'checkout_started': { cartValueCents: number; itemCount: number }; 'checkout_completed': { orderId: string; amountCents: number }; 'search_performed': { query: string; resultCount: number }; 'signup_completed': { plan: 'free' | 'pro' | 'team' }; }; export function track<K extends keyof AnalyticsEvents>( event: K, properties: AnalyticsEvents[K], ) { analyticsBus.emit(event, properties); }

Now track('checkout_started', { cartValueCents: 4200, itemCount: 3 }) autocompletes, the payload is type-checked, and a misspelled event name is a compile error instead of a silently-dropped data point six weeks of dashboards later. AnalyticsEvents is the catalog — one file that answers "what does this product measure?" That question having a single, readable answer is worth more than any individual event.

Decouple your code from the vendor

Step two is the part that pays off for years: your product code should emit domain events, and a separate adapter should decide what to do with them. Product code says "checkout started." It does not know or care whether that goes to Amplitude, GA4, Segment, a data warehouse, or all four.

interface AnalyticsSink { send(event: string, properties: Record<string, unknown>): void; } // each vendor is an adapter — swappable, testable, isolated const amplitudeSink: AnalyticsSink = { send: (event, props) => amplitude.track(event, props), }; // one subscriber wires the typed catalog to whatever sinks are configured export function initAnalytics(sinks: AnalyticsSink[]) { analyticsBus.onAny((event, properties) => { for (const sink of sinks) sink.send(event, properties); }); }

This is dependency injection applied to tracking: the product depends on an abstraction (track), and the concrete vendor is injected at the edge. Swap Amplitude for Segment by changing one adapter — not by grep-and-replacing three hundred call sites. Add a second vendor without touching a single component. Run tests with a fake sink that just records calls, so you can assert "checkout_completed fired once with this payload" without any network. And notice this is a textbook good use of the event bus from earlier in the cluster: the emitter (product code) genuinely should not know who's listening (the vendors), so the indirection is real decoupling, not hidden coupling.

Governance is the whole point, not an afterthought

Once analytics is a layer, the things that were impossible when it was scattered become easy — and these are the things that actually matter when the company grows up:

  • Naming. One catalog means one convention. No more Button Clicked versus button_click versus btnClick for the same action.
  • PII control. A single choke point is where you enforce "no emails, no raw tokens, no free-text that might contain personal data." You can scrub, allowlist, or type-forbid sensitive fields in one place. When legal asks "are we sending any personal data to this vendor," you have a file to point at instead of a codebase to spelunk.
  • Auditing. GDPR, CCPA, a privacy review — all of them ask "what do you collect and where does it go?" With a catalog and adapters, that's a five-minute answer. Sprinkled track() calls make it a five-day investigation.

None of that governance is possible when tracking is smeared across components. It's trivial when tracking flows through one typed layer. That's the real argument for the architecture — not elegance, but the fact that a scattered approach becomes a liability the moment anyone with a compliance question walks in.

Closing the cluster

That wraps this run on communication between the parts of an app, and the three pieces rhyme on purpose. Event buses, feature flags, and analytics all start life as innocent one-liners sprinkled through the UI — emit, if (flag), track — and all three rot the same way: stringly-typed, un-auditable, impossible to change in one place. And all three are fixed the same way: a typed contract, a single home, and a boundary the rest of the app talks to instead of reaching past. Communication between parts done well isn't ambient magic. It's explicit, typed, discoverable contracts — the exact opposite of "anything can talk to anything."

Next, the series turns to a short but overdue detour — the panic over React re-renders, why rendering is the framework's superpower rather than its disease, and the architecture people quietly break trying to avoid it. Right after that it gets into how an app looks and feels at an architectural level, starting with theming and dark mode. That's where we go.

If your app tracks analytics, try the one-folder test right now: can you open a single place and read the full list of what you collect and where it's sent? If the honest answer is "no, it's everywhere," you've found your next refactor — and I'd like to hear how scattered it turned out to be.

Telegram

More than a blog post

I share frontend news and the reasoning behind it throughout the day. Pick the language that feels natural to you.

Need to discuss your project? Get in touch.