GMI · TECHNOLOGY OBSERVATORY // ALL SYSTEMS NOMINAL
ENGINEERED BY LEOPARD DATA

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.

ATTRIBUTION PATHS
rendering diagram…
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;
A Reddit ad click can land on any of three rendering surfaces. All of them persist the Reddit click id to a shared origin-scoped store, so a conversion that happens many navigations later can still be attributed to the ad that produced it.

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.

SurfaceBuilt ByRole
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:

ConversionDerived IdShared Key
SignUpgmi-signup-{AccountID}Account identity
Leadgmi-lead-{AccountID}Account identity
Purchasegmi-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 false rather 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.

CONVERSION LIFECYCLE
rendering diagram…
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
A signup reported by both paths. The browser derives the conversion id from the API response and the server derives it from the row it just wrote, so the two agree without passing an identifier between them.

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.

EventTriggerReported ByCarries Value
PageVisitEvery document load and SPA route changePixel
ViewContentFree stock-grade page viewedPixel
SignUpAccount row createdPixel + API
LeadPayment method stored; account billablePixel + API
PurchaseReport job queued for generationPixel + APIYes

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.