Part 1 of a two-part series on building a configurable analytics layer that outlives any single vendor. This part is about the core idea — depending on a contract instead of an SDK. Part 2 is about making the whole thing server-driven, so you can add a provider or redefine an event without shipping an app.
TL;DR
- Wiring a vendor SDK directly into product code is a trap: it scatters vendor calls across hundreds of call sites and gets reimplemented on every platform.
- Instead, have your app depend on a small analytics contract (an interface), and a tiny core that fans every call out to whatever providers are registered.
- The same core runs on every platform. Product code calls
track(...); it never imports a vendor SDK — so swapping or adding a vendor touches zero call sites.
Almost every app reaches the same fork in the road. You need product analytics, you reach for a vendor's SDK, and the fastest thing to do is call it directly:
import analytics from 'some-vendor-sdk';
// ...scattered across hundreds of files:
analytics.capture('signup_completed', { plan: 'pro' });
analytics.screen('Dashboard');
It works on day one. Then reality arrives:
- Marketing wants a second tool. Now every call site needs a second SDK call.
- The vendor gets too expensive, or a data-residency requirement forces a switch. Now you're find-and-replacing an SDK across the whole codebase.
- You ship on more than one platform. The web app and the mobile app each grow their own copy of the same instrumentation, and the event names quietly drift apart.
None of these are analytics problems. They're coupling problems. The fix is an old idea applied to a new place: depend on a contract, not on an SDK.
The core idea: a contract and a fan-out
Instead of letting product code talk to a vendor, put a thin layer in the middle. That layer knows exactly two things:
- A provider contract — the small set of things any analytics tool can do.
- A list of live providers — and it forwards every call to all of them.
The contract is deliberately tiny. Any analytics SDK on the market can implement it:
// The only thing the core knows about a vendor.
interface AnalyticsProvider {
register(config): void; // initialize the underlying SDK
track(event, attributes?): void; // an event happened
screen(name): void; // a screen was viewed
identify(userId): void; // this is who the user is
setTrait(name, value): void; // set a user property (overwrite)
addTrait(name, value): void; // add to a user property (accumulate)
}
The core itself is just a registry with a fan-out. One call in, N vendor calls out:
class Analytics {
private providers: AnalyticsProvider[] = [];
// (registration covered in Part 2 — for now, assume we have a list)
track(event: string, attributes?: object) {
for (const p of this.providers) p.track(event, attributes);
}
screen(name: string) {
for (const p of this.providers) p.screen(name);
}
// ...identify, setTrait, addTrait — same shape
}
That's the whole trick. Everything good below falls out of these two pieces.
A stable, provider-agnostic call API
Because the core sits in the middle, product code never imports a vendor SDK. It imports a small, stable set of functions:
import { track, screen, setTrait } from '<your-analytics-package>';
track('event created', { source: 'creation form' });
screen('EventDetails');
setTrait('first platform', 'iPhone');
Look at what these call sites don't know: which analytics vendor is running, how many vendors are running, or how any of them are configured. They express intent ("an event was created") and nothing else. That is the entire point.

Once the call sites are vendor-agnostic, the scary changes from the intro become boring:
- Add a second tool? Register another provider. Every existing
tracknow reaches it too — no call-site edits. - Swap vendors? Replace one provider with another behind the same contract. Product code doesn't move.
- Run two in parallel during a migration? Register both and compare. Still no call-site edits.
The instrumentation in your product code becomes an asset that survives vendor decisions, instead of a liability that's hostage to them.
One core, every platform
Here's where the payoff compounds. That core — a contract plus a fan-out — has no platform in it. It doesn't know about the DOM, or a mobile runtime, or any framework. It's just types and a loop.
So it can live in a shared package that every platform depends on. Each platform then adds only a thin shell around it:

The bootstrap is small: initialize the core once, high in the tree, so it lives for the app's
lifetime. The providers differ per platform (a web SDK vs. a mobile SDK), but they implement
the same contract, and product code calls the same functions. An event named
"event created" means the same thing on web and on mobile, because there's one definition of
what the API even is.
Platforms are still allowed to differ at the edges — where they genuinely must. A web build might gate initialization behind a cookie-consent banner; a mobile build might wire the same core up more directly. That's fine: the shared thing is the core and the contract, and the platform-specific thing is the wiring around it. Share the middle, adapt the edges.
A practical note that matters more than it looks: keep the shared core importable without dragging in a platform's UI dependencies. A mobile bundle should be able to pull in the contract and the fan-out without accidentally importing the web app's DOM-bound code. A clean module boundary here keeps each platform's bundle small.
Reuse over reinvention
The most valuable decision we made wasn't a line of code — it was not writing one. When we needed analytics on a new platform, the tempting path was to stand up a fresh analytics stack for it. Instead, we took the core that was already battle-tested on an existing platform and extended it.
Because the core was already platform-free, "extending" it mostly meant adding a thin bootstrap and a provider or two. We got, for free:
- One taxonomy. The same events and attributes across platforms, by construction — not by a shared spreadsheet everyone forgets to update.
- One mental model. An engineer who understands analytics on one platform understands it on all of them.
- Far less code. No second implementation of registration, fan-out, or attribute handling to keep in sync.
What this bought us
- Vendor independence. The choice of analytics tool became a config/wiring decision, not an architectural commitment. Product code is insulated from it.
- A stable instrumentation API. Business code expresses intent; it never touches an SDK. That code is now durable across vendor changes.
- Consistency across platforms, guaranteed by a single shared core rather than discipline.
- A tiny, testable core. A contract and a loop are trivial to unit-test — you can assert that
one
trackfans out to every registered provider with a couple of fakes.
If you take three things from this part:
- Depend on a contract, not an SDK. Put a small interface between product code and any vendor.
- Fan out from one place. A single core that forwards to N providers turns "add/swap a vendor" into a no-op for call sites.
- Keep the core platform-free and reuse it everywhere. The same contract and call API on every platform is worth more than any individual feature.
There's a second half to this story. If which providers run — and even what your events mean — is baked into the app, you still ship a release every time analytics changes. The next part moves all of that to the server: Part 2: Server-Driven Analytics →.
Written by Tushar Dahiya. If this was useful, find more of my writing at tushardahiya.com — more on architecture and cross-platform engineering.
Code in this article is illustrative pseudo-code meant to convey the pattern, not a specific library or vendor.