Part 2 of a two-part series on building a configurable analytics layer. Part 1 built a provider-agnostic core — a contract plus a fan-out — so product code never touches a vendor SDK. This part moves the last hardcoded pieces (which providers run, and what your events mean) out of the app and onto the server.
TL;DR
- Treat your list of analytics providers as data on the server, not code in the app. The app reads a config at boot and instantiates exactly those providers.
- Adding a whole new analytics SDK becomes a one-file change (write a provider class) plus a one-line registration and one backend entry — no core changes, no call-site changes.
- Define your event taxonomy in the backend too: names, always-on attributes, and a mapping from attributes to runtime data. Renaming an event or adding an attribute is a config edit, not a release.
In Part 1 we made product code vendor-agnostic: it
calls track(...) and a core fans that out to whatever providers are registered. But two things
were still baked into the app: which providers get registered, and what each event is
called. As long as those live in code, every analytics change — a new vendor, a renamed event,
a tweaked attribute — needs an app release. On mobile, that means an app-store review cycle for
what is really just a configuration change.
The principle that fixes this is simple: configuration is data, and data belongs on the server.
Providers as config, not code
Start with the provider list. Instead of hardcoding "we use Vendor A," describe your providers as a small array your backend serves:
// Served by your backend / remote config — NOT compiled into the app
{
"enabled": true,
"analytics": [
{
"name": "primary", // which provider implementation to use
"key": "<api-key>", // credentials for this vendor
"host": "https://...", // where this vendor ingests data
"batchSize": 20, // how many events per flush
"pollDuration": 10000 // flush interval (ms)
}
]
}
At boot, the app reads this config and hands it to the core. The core walks the array and instantiates exactly the providers it names, passing each its slice of config:
// Core: turn config entries into live providers
init(config, providerImplementations) {
this.providers = config.analytics
.map(entry => providerImplementations[entry.name]?.create(entry))
.filter(Boolean);
}
Two useful properties fall out immediately:
- There's a master switch (
enabled) — flip analytics off for a build, a region, or an incident without touching code. - Each provider's operational knobs — batch size, flush interval, host, key — are tuned from the server. No release to change how aggressively events are flushed.
Runtime injection = one-file extensibility
Here's the part that makes the architecture feel effortless. The config names a provider, but the app still has to supply the implementation — the class that wraps the SDK. The app injects a small map of those at startup:
// The app hands the core its available provider implementations
bootstrapAnalytics({
config, // from the server
isEnabled: config.enabled, // master switch
providers: { primary: PrimaryProvider }, // name → implementation
});
So adding an entirely new analytics SDK is three tiny, localized steps:
- Write one class implementing the provider contract from Part 1 (
register,track,screen,identify, …). It owns the SDK and its options. - Register it in the injected map — one line:
{ primary: PrimaryProvider, secondary: SecondaryProvider }. - Add one entry to the backend config array.

Notice what's not in that list: the core doesn't change, and not a single call site
changes. Every track(...) already in your product code immediately starts reaching the new
provider, because the fan-out from Part 1 doesn't care how many providers exist. New SDKs land at
the edges of the system; the center stays still.
That "write one file" property is the practical test of a good plugin architecture. If adding a capability forces you to edit shared code, the abstraction is leaking. Here it doesn't.
Backend-controlled rollout
Once providers are data, a bunch of operational super-powers appear — none of which need an app release:
- Trial a vendor by adding one entry, and pull it just as fast if it disappoints.
- Migrate from one vendor to another by running both in parallel, comparing the data, then dropping the old entry. The fan-out sends every event to both during the overlap.
- Rotate a key or change a host (say, for data residency) by editing config.
- Kill-switch all analytics with the
enabledflag during an incident.
Analytics stops being a code concern and becomes an operational dial you can turn in production.
Event taxonomy from the backend
The provider list isn't the only thing worth moving to the server. Event definitions — names and attributes — are even more valuable there, because they change more often and they're exactly what non-engineers (data, growth, marketing) want to own.
The idea: describe an event in config with two kinds of attributes.
- Static attributes — always attached to this event (e.g. an app name, a build channel).
- Dynamic attributes — a mapping from the attribute name the vendor should see to a field in the runtime data you pass at fire time.
// An event definition, served from the backend
{
"name": "event created",
"staticAttr": { "appName": "Acme" },
"dynamicAttr": { "source": "sourceField" } // vendor attr ← runtime data key
}
A small helper merges the two at the moment you fire the event:
function trackFromConfig(definition, data) {
const attributes = { ...definition.staticAttr };
for (const [attr, dataKey] of Object.entries(definition.dynamicAttr ?? {})) {
if (data?.[dataKey] !== undefined) attributes[attr] = data[dataKey];
}
track(definition.name, attributes); // reuses the same fan-out from Part 1
}
At the call site, product code passes only raw runtime data — it doesn't know the event's name or which attributes end up attached:
trackFromConfig(eventCreatedDefinition, { sourceField: 'creation form' });
The consequences are the good kind:
- Renaming an event or adding an attribute is a backend edit — no release, and every platform picks it up at once.
- The taxonomy stays consistent across web and mobile, because there's one source of truth for it, not one-per-platform.
- Ownership shifts to the people who actually curate the analytics taxonomy, without a code handoff for every tweak.
This is the piece that most "just wrap the SDK" approaches miss, and it's where a lot of the long-term value lives.
Privacy by construction
One rule worth baking in rather than bolting on: never hand a raw user identifier to a vendor. Hash it first, once, when authentication resolves — and do it the same way on every platform so a given user maps to the same id everywhere.
// Once, after auth resolves
identify(hash(userId)); // e.g. a SHA-256 of the id, never the raw value
Because identity flows through the same core and contract as everything else, "always hash" is a single, auditable place — not a convention you hope every call site remembers.
Where this goes next
There's a natural next step once the taxonomy lives in config. Instead of the client even knowing
event names, it can emit stable event ids, and let the backend map each id to whatever name
and shape a given vendor expects. The client says "the user did action #42"; config decides that
this is event created for one tool and Event Created for another. At that point the analytics
taxonomy is fully owned by configuration, and the app is a pure event emitter.
Benefits recap — and a few gotchas
What server-driven analytics buys you:
- Add, swap, trial, or kill a vendor from the server — no app release.
- Adding an SDK is a one-file change with zero call-site edits.
- One event taxonomy for every platform, editable by the people who own it.
- Operational control (batching, kill-switch, key rotation) as config, in production.
Gotchas worth handling up front:
- Initialize once. The core is a long-lived singleton; guard against re-initializing it on every render or navigation.
- Make provider creation idempotent. A provider might be asked to initialize more than once; have it no-op if it's already live.
- Validate the config. It comes from the network, so treat it defensively — a malformed entry should be skipped, not crash analytics (or the app).
Put the two parts together and the shape is a single idea carried to its conclusion: product code expresses intent, a shared core fans it out, and the server decides the rest. Vendors, batching, and even what your events are called all become data you can change without shipping — while your instrumentation, written once, quietly keeps working.
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.