Conversion Tracking & Attribution
A dual-path measurement layer over Google Analytics 4, Google Ads, and Reddit Ads — browser pixel plus server-to-server Conversions API — spanning three separate rendering surfaces, with deterministic deduplication so a single signup is never counted twice.
flowchart LR
AD([Reddit Ad Click<br/>rdt_cid on landing URL]):::ext
SSR[Prerendered Public Pages<br/>Gmi.ClientWeb.Ssr]:::app
SEO[Free Grade Pages<br/>generated HTML artifacts]:::app
APP[Blazor WASM Application<br/>signed-in product]:::app
LS[(localStorage<br/>persisted rdt_cid)]:::data
PX[Reddit Pixel<br/>PageVisit - ViewContent<br/>SignUp - Lead - Purchase]:::svc
API[Azure Functions API<br/>conversion tracker]:::svc
CAPI[Reddit Conversions API<br/>ads-api.reddit.com]:::ext
RDT[Reddit Attribution<br/>dedup on conversion_id]:::ext
AD --> SSR
AD --> SEO
AD --> APP
SSR --> LS
SEO --> LS
APP --> LS
SSR --> PX
SEO --> PX
APP --> PX
LS -.click id forwarded.-> API
APP -.conversion written.-> API
PX -->|browser path| RDT
API --> CAPI
CAPI -->|server path| RDT
classDef app fill:#1f2630,stroke:#C0894E,color:#F3EFE8;
classDef svc fill:#241c14,stroke:#E2B583,color:#F3EFE8;
classDef data fill:#22201a,stroke:#C0894E,color:#EBD3AE;
classDef ext fill:#1b1f27,stroke:#5A616B,color:#C4C0B8;
Why Two Paths
A browser pixel alone is a lossy instrument. It misses conversions to ad blockers, to Safari's Intelligent Tracking Prevention, and to users who close the tab before the tracking beacon flushes. Industry loss rates of 10–30% are ordinary.
The server has the opposite problem and the opposite strength: it cannot see the ad click, but it is the only component that knows authoritatively that an account was created or a report was billed. Neither path is sufficient alone.
Grade My Investments runs both against the Reddit Ads pixel and its Conversions API. The browser reports what it saw; the API reports what actually happened in the database. Where they overlap, Reddit collapses them into one conversion.
Three Rendering Surfaces, One Origin
The client platform is not a single application. Public marketing pages are statically prerendered, the free stock-grade pages are generated HTML artifacts built on a report VM, and the signed-in product is a Blazor WebAssembly SPA. All three serve from the same origin but are built by different pipelines.
This matters because ad clicks land on the public and SEO pages, while conversions happen in the WASM app. The tracking tag had to be installed in three independent places, and the click id had to survive the crossing.
| Surface | Built By | Role |
|---|---|---|
| Prerendered public pages | Gmi.ClientWeb.Ssr |
Reddit ad landing, PageVisit |
| Free grade pages | Gmi.SeoPageBuilder.Console |
Organic landing, ViewContent |
| Blazor WASM application | Gmi.ClientWeb.Blazor |
SignUp, Lead, Purchase |
Because a single-page application only produces one document load, the SPA router subscribes to location changes and reports each in-app navigation explicitly. Without that, the entire signed-in journey would appear as one page view.
Deterministic Deduplication
When both paths report the same conversion, the ad platform collapses them on a shared conversion id. The obvious implementation — generate a random id in the browser and send it to the server — is fragile: any dropped request silently double-counts revenue, and the failure is invisible in reporting.
Instead the ids are derived from identifiers both sides already hold independently. Nothing has to survive a round trip:
| Conversion | Derived Id | Shared Key |
|---|---|---|
| SignUp | gmi-signup-{AccountID} | Account identity |
| Lead | gmi-lead-{AccountID} | Account identity |
| Purchase | gmi-report-{JobID} | Report job |
The browser computes the id from the API response; the server computes it from the row it just wrote. They agree by construction.
Match Keys & Hashing
Third-party cookies are disappearing, so attribution increasingly depends on hashed identity matching. Identifiers are SHA-256 hashed to lowercase hex, but normalization must happen before hashing or the same person produces different digests and never matches.
- Email — trimmed and lowercased, then hashed
- Phone — reduced to digits only, then hashed
- External id — stable OIDC subject, not a database key
- IP — taken from the forwarded-for chain, port stripped
The external id is deliberately the identity-provider subject rather than the internal account id, so it is stable from the first anonymous touch through account creation.
Failure Isolation
Marketing measurement is the lowest-value work in any request that contains it. The architecture enforces that ordering rather than trusting callers to remember it:
- The client returns
falserather than throwing — no tracking failure can propagate - Server calls are dispatched fire-and-forget; no user waits on an ad network
- Browser helpers degrade to no-ops when the tag is blocked
- Absent credentials disable the whole path, so non-production never writes to live pixels
Transient failures retry with exponential backoff on rate limiting and server errors; client errors do not retry, because a malformed payload will never succeed.
Content Security Policy
Every tag is an explicit exception to a restrictive default-deny policy. Adding a measurement vendor means widening script-src for its loader and img-src plus connect-src for its collection endpoints — in each host document independently.
A missed directive does not produce an error page. It produces silence: the tag loads, the beacon is blocked, and reporting shows zero conversions with no obvious cause. Verifying the actual network trace after deployment is part of the change, not an optional follow-up.
sequenceDiagram
autonumber
participant U as Visitor
participant B as Reddit Pixel
participant A as Azure Functions API
participant R as Reddit
U->>B: Arrives from Reddit ad with rdt_cid
B->>B: Persist rdt_cid to localStorage
B->>R: PageVisit
Note over U,B: Many navigations later
U->>A: Submits account creation
A->>A: Write account row
A-->>B: Created with account id
B->>R: Pixel SignUp with derived conversion id
A->>R: Conversions API SignUp with same id
Note over R: Both reports carry one id<br/>counted a single time
Instrumented Funnel
Reddit's standard event names are used verbatim so they are recognized without a translation layer, and each is anchored to a database write rather than a UI interaction — a conversion means a row exists, not that a button was clicked.
| Event | Trigger | Reported By | Carries Value |
|---|---|---|---|
PageVisit | Every document load and SPA route change | Pixel | — |
ViewContent | Free stock-grade page viewed | Pixel | — |
SignUp | Account row created | Pixel + API | — |
Lead | Payment method stored; account billable | Pixel + API | — |
Purchase | Report job queued for generation | Pixel + API | Yes |
Revenue is reported as a decimal in major currency units. Where the server cannot determine the exact billed amount — a discounted report, where the pricing logic lives in a different code path — it omits the value entirely rather than reporting a pre-discount figure. An absent number is recoverable from the browser's report; a wrong one silently corrupts return-on-ad-spend for everything downstream.
The same discipline governs privacy: the platform's own analytics see the funnel, but investment data, symbol lists, report contents, grades, card details, and phone numbers are never sent to any advertising partner.