Skip to content

Design decisions

A running log of decisions made during design, with the reasoning. When tempted to change one of these, read the original reasoning first — most are intentional trade-offs, not oversights.


ADR-001: Three-package monorepo (core / browser / server) — superseded by ADR-012

Section titled “ADR-001: Three-package monorepo (core / browser / server) — superseded by ADR-012”

Status. Superseded by ADR-012. Kept here for the historical reasoning. The package names referenced below (@trackbridge/core, @trackbridge/browser, @trackbridge/server) no longer exist as separate packages.

Decision. Ship as @trackbridge/core, @trackbridge/browser, @trackbridge/server rather than a single package.

Reasoning. Browser and server have different runtime targets (DOM vs Node), different bundling needs (platform: 'browser' vs platform: 'node'), and different dependencies (gtag types vs Google Ads API client). Bundling them together would force users to ship Node-only code to the browser or browser-only code to Node. Splitting via subpath exports (@trackbridge/sdk/browser and @trackbridge/sdk/server) was considered but rejected — it complicates the build and confuses bundlers in some configurations. Three packages is cleaner.

Trade-off. Users install two packages instead of one. Worth it.


ADR-002: Linked versioning across all three packages — superseded by ADR-012

Section titled “ADR-002: Linked versioning across all three packages — superseded by ADR-012”

Status. Superseded by ADR-012. Single-package shipping makes version skew impossible by construction.

Decision. All three packages release at the same version, always (Changesets linked config).

Reasoning. Browser and server depend on @trackbridge/core for normalization parity. If a user installs @trackbridge/browser@0.3.1 and @trackbridge/server@0.2.7, they could end up with subtly different normalization behavior — silent dual-send failure. Linked versioning makes it impossible to mix incompatible versions.

Trade-off. Patch bumps to one package bump all three. Slightly noisier release log. Not worth the correctness risk.


Decision. Use tsup (esbuild-based) for all package builds.

Reasoning. Fast, minimal config, ships dual ESM+CJS + types out of the box. tshy was the runner-up but has more opinionated defaults that fight against monorepo patterns. Raw tsc requires more config for dual-format output.

Revisit if. Build times become an issue (unlikely at this scale), or bundle-size optimizations beyond tree-shaking become necessary.


Decision. Strict TypeScript + Prettier only. No ESLint configuration.

Reasoning. Strict TS catches the bugs that matter (noUncheckedIndexedAccess, noImplicitOverride, etc.). ESLint configs are a maintenance tax that grows over time. The no-floating-promises rule is the one genuinely useful lint rule — TS strict doesn’t catch it — but the cost of maintaining a full ESLint config to get one rule isn’t worth it at this stage.

Revisit if. Bugs start landing that a lint rule would have caught and TS doesn’t.


ADR-005: transactionId auto-generated when missing, dual-send disabled

Section titled “ADR-005: transactionId auto-generated when missing, dual-send disabled”

Decision. When trackConversion() is called without transactionId, generate a UUID v4 and use it for the call. Disable dual-send for that call and emit a warning.

Reasoning. The alternative — making transactionId strictly required — is purer but hurts the “first 5 minutes” experience for simple cases (lead form, newsletter signup) where the developer doesn’t have a natural transaction ID. Auto-generation lets these cases work. But auto-generation on both sides would generate different IDs, causing silent double-counting — strictly worse than not dual-sending at all. So the rule is: auto-generated → no dual-send, with a loud warning.

Trade-off. Users who don’t read the warning may be surprised that single-call usage doesn’t dual-send. Acceptable because the warning is loud and the docs are clear.


Section titled “ADR-006: Click identifier auto-capture with built-in cookie helper”

Decision. Browser SDK auto-captures gclid / gbraid / wbraid from URL params and writes them to first-party cookies (_tb_gclid, etc.) by default. Configurable via clickIdentifierStorage: 'cookie' | 'memory' | 'none'.

Reasoning. Manual click ID capture is the second thing devs get wrong (after enhanced conversions hashing). Making it automatic by default removes a class of bugs and is a clear DX win over alternatives.

Why first-party cookies and not Google’s _gcl_aw. Avoids conflicts with gtag if both are running, makes debugging clearer (we know who set the cookie), doesn’t overwrite existing state.

Why SameSite=Lax. Needed so cookies survive the redirect from the ad click. Strict would break the whole point.

Trade-off. One more set of cookies on the user’s site. Documented in Cookies.


Decision. When consentMode: 'v2' and ad_storage consent is unknown or denied, click identifier values stay in memory only. Cookies are only written if/when consent is granted.

Reasoning. Writing cookies before consent is a GDPR violation. Memory-only is the correct fallback. The state machine is documented in Deduplication & transactionId.


ADR-008: Two methods (trackConversion, trackEvent), not one unified track()

Section titled “ADR-008: Two methods (trackConversion, trackEvent), not one unified track()”

Decision. Conversions and GA4 events have separate methods.

Reasoning. They have meaningfully different shapes — conversions need transactionId and userData for enhanced conversions, events need clientId on the server for session continuity, and they hit different APIs (Google Ads vs GA4 Measurement Protocol). Forcing them through one method either makes the unified type miserable (lots of conditional fields) or hides the differences in ways that bite users later. Two methods, autocomplete is sharper, mental model matches Google’s own.


ADR-009: GA4 server-side support in v1, not v1.1

Section titled “ADR-009: GA4 server-side support in v1, not v1.1”

Decision. Ship full GA4 Measurement Protocol support in v1.

Reasoning. Skipping it would make v1 feel half-built. MP is well-documented, and the clientId continuity problem is one Trackbridge can solve nicely (forcing it as a required field on the server). Worth the additional implementation effort to ship a complete v1.


ADR-010: _gl cross-domain linker NOT in v1

Section titled “ADR-010: _gl cross-domain linker NOT in v1”

Decision. Single-domain only for v1. Cross-domain _gl linker is v1.1+.

Reasoning. Real complexity (URL rewriting on outbound links, parsing on inbound), needed by a minority of users (mostly enterprise multi-domain setups). Document the limitation and ship without it. Add later based on user demand.


ADR-011: 24-hour dedup window not solved at SDK level

Section titled “ADR-011: 24-hour dedup window not solved at SDK level”

Decision. SDK does not attempt to handle the case where server-side conversions fire >24h after client-side ones (outside Google’s dedup window).

Reasoning. The SDK has no way to know whether a given server call is “in time” — only Google’s backend does. Trying to implement this client-side would require persisting all client conversions and inspecting them at server-call time, which is invasive and still wouldn’t be authoritative. The Trackbridge Dashboard (post-v1) is the right place to surface this — it has both sides’ timestamps and can flag suspicious patterns.


ADR-012: Single package with subpath exports (supersedes ADR-001 + ADR-002)

Section titled “ADR-012: Single package with subpath exports (supersedes ADR-001 + ADR-002)”

Decision. Collapse the three packages into a single @trackbridge/sdk with subpath exports — @trackbridge/sdk/browser, @trackbridge/sdk/server, @trackbridge/sdk/core, and @trackbridge/sdk/next (plus /next/server).

Reasoning. ADR-001 was right that browser and server have different runtime targets, but wrong that subpath exports complicate the build in practice. Modern bundlers (Vite, Webpack 5, Rollup, esbuild, Bun) all resolve exports cleanly, and tsup ships per-entry ESM+CJS+types out of the box. The DX cost of “install two packages, keep their versions linked manually if you ever break the linked-versioning convention” turned out to be larger than the build complexity — every user asked the same questions, and version-skew bugs (the failure mode ADR-002 was guarding against) cannot occur if there is only one version to install.

The new layout: src/core/, src/browser/, src/server/, src/next/, src/next/server/. Each directory is a tsup entry. Tree-shaking ensures the browser side never pulls in Node-only code at bundle time. The package.json exports field lists each subpath explicitly so bundlers and TypeScript both resolve them.

Trade-off. Slightly larger npm tarball than installing a single side would have been (the dist/ for the unused side ships too). Not measurable in practice — bundlers strip the unused entry. Fully outweighed by the single-install DX and the impossibility of version skew.

Migration. Old: import { createBrowserTracker } from '@trackbridge/browser'. New: import { createBrowserTracker } from '@trackbridge/sdk/browser'. Same for server and core. Public API and runtime behavior unchanged.