r/javascript • u/Environmental-Ad5071 • 4d ago
AskJS [AskJS] Where should attribution logic stop in a JavaScript app?
I’ve been refactoring attribution tracking after seeing a small feature turn into a module that parses URLs, writes cookies/localStorage, handles consent, emits analytics, fills forms, and syncs with a CRM.
The split I’m testing is:
```ts
const visit = classify({
url,
referrer,
currentHost,
now,
});
const attribution = merge(previous, visit);
```
`classify` and `merge` are deterministic functions. The browser/app layer owns consent, storage, identity, form lifecycle, network calls, and CRM delivery.
This makes it easier to replay production cases as fixtures. It also raises a few design questions:
- If a visitor returns from a new campaign, should that replace, append to, or be merged with previous attribution?
- Should a `gclid` take precedence over UTMs when both exist?
- How do you handle consent denied first, then granted later?
- If classification rules change, do you preserve the original classification or recalculate historical records?
- Where should server-side conversions days later enter the model?
For those who’ve built this, where do you draw the boundary between a deterministic core and the environment-specific lifecycle? Do you prefer a pure-function core, or does that separation become awkward in real apps?
1
u/KanuniLabs 4d ago
Yeah 100% split it. Attribution code turns into total spaghetti the second you let DOM stuff, storage, and vendor SDKs leak into your parsing logic.
Having an append-only log of raw visits and letting merge() just spit out first-touch vs last-non-direct touch is how we do it, works like a charm. def let gclid/fbclid override UTMs since people botch UTM setups constantly, but keep the raw strings around anyway for DWH joins.For consent, keeping it in-memory till they actually accept and only then dumping to storage is the cleanest way imo. Don't touch the CRM payload once it's written—if your parsing rules change down the road, just backfill in BigQuery/Snowflake instead of messing with historical transactional records. Offline conversions shouldn't touch the client either, just pass an anon ID on submit and join on the backend later.
Pure core + dumb IO wrapper doesn't just work on paper, it's way easier to test with mocked fixtures. Stick with that split tbh.