How Relay is built.
The architecture of the one-app privacy-first analytics platform, tag manager, and server-side gateway — and the design thinking behind its diagnostics, first-party delivery, and interface. This is the map of the system as actually built.
What Relay is
A privacy-first web-analytics service and tag manager — a single product that competes with both Google Analytics and Google Tag Manager / Stape. One ~2 KB cookieless snippet delivers three things at once:
- a Plausible-style analytics dashboard;
- a full client-side tag-manager container — tags, triggers, and variables authored in a visual builder;
- server-side forwarding to marketing and analytics destinations through a durable outbox.
Everything runs as one always-on ASP.NET Core 10 app, self-hostable in your own Azure or fully offline on a laptop.
Design pillars (locked)
Cookieless / first-party
No cookies, no localStorage, no client identifier. A visitor is a daily-rotating salted hash of IP + UA; the salt is deleted after 2 days — so the privacy guarantee is deletion, not promise. Raw IP and user-agent are never persisted, and email is scrubbed before storage.
Server-side by default
Forwarding goes through a transactional outbox, so "stored but not forwarded" and "forwarded but not stored" are structurally impossible.
One always-on process
A single ASP.NET Core 10 app — no Functions or Static Web Apps — because an ingestion gateway can't tolerate cold-start beacon loss. Single-instance is a v1 invariant (in-memory channel, session and salt caches, outbox lease).
Dual provider
SQLite locally for offline dev (EnsureCreated), Azure SQL serverless in the cloud (Migrate). SQL Server migrations only.
System & component map
The website visitor's browser loads the snippet, which fetches its container and sends beacons to the ingest endpoint. The operator authors, publishes, and views stats in the Blazor dashboard. The outbox dispatcher forwards events, server-side and retried, to each destination.
Browser (wwwroot/)
relay.js ≤2KB loader — dataLayer, SPA hooks, fetches config
relay-runtime.js interpreter — triggers, variables, tags, consent
Relay.Web (ASP.NET Core 10)
Features/Ingestion endpoint, validate, enrich, bot filter, PII scrub,
geo, SiteKeyCache, Channel(50k), EventWriterService
Features/Containers Service, Compiler, Catalog, Cache, PreviewBuffer,
Delivery
Features/Forwarding OutboxDispatcher, Janitor, forwarders
(ga4/debug/meta/webhook), EventMap, RetrySchedule,
ForwardLog, DeliveryTester, DeliveryAlerts
Features/Gateway Google Tag Gateway proxy — /gtag/js /gtm.js
/g/collect (YARP IHttpForwarder)
Features/Diagnostics TraceAssembler + /trace/{id} waterfall,
ReasonCode, TraceVerdict (Tier-0)
Features/Analytics StatsService, DateRangeHelper
Features/Sites SiteService, KeyGenerator, DestinationConfigService
Auth Entra OIDC / DevAuth
Components/ Blazor Server — dashboard + container builder + preview
Data RelayDbContext, Entities, Migrations
Store: SQLite (local) / Azure SQL serverless (cloud)
Tenant resolution is by siteKey in the payload — never the Host header — so the app is multi-host-capable and a customer on their own first-party domain "just works" at the app layer.
Key data flows
Ingestion: beacon → stored → forwarded
- The browser
POSTs/api/event(text/plain,sendBeacon). - The ingest endpoint validates the key and host, filters bots, enriches, scrubs PII, computes the cookieless visitor hash and session — then always returns 202.
- The accepted event is written to an in-memory
Channel(50k). EventWriterServicedrains up to 200 every 500 ms and, in one transaction, writes theEventplus anOutboxItemper enabled destination whoseforwardEventspredicate matches.- The
OutboxDispatcherclaims due rows under a 2-minute lease, groups by destination and visitor, maps each via the event map and per-destination mapper, andPOSTs. - The destination's response classifies the row: 2xx → Sent, 5xx / 429 → retry, 4xx → Dead.
Tag-manager container: author → deliver → run
- The operator edits the draft (tags, triggers, variables) in the Blazor builder and clicks Publish.
- The service validates and compiles the draft to
CompiledJson({{name}} tokens becomevar:/builtin:references), writes an immutableContainerVersion, flipsLiveVersionId, and busts the cache. - The loader fetches
/c/{siteKey}.json(cached, ETag) and injects the runtime with config. - The runtime turns dataLayer events into evaluated triggers, resolves variables, applies the consent gate, and fires tags.
relay_eventtags beacon back into the ingestion flow above.
Deployment
GitHub Actions (deploy.yml) runs the tests, builds the image with az acr build, and updates the Container App. The app talks to Azure SQL serverless (GP_S_Gen5, auto-pause) and authenticates operators via Entra ID.
az containerapp ingress enable -n relay -g relay-rg \
--type external --target-port 8080
re-enable ingress
A single instance (max-replicas 1) enforces the in-memory invariants. SQL serverless won't auto-pause under constant ingest — an honest floor of about $25–30/mo. Local dev is dotnet run (SQLite, DevAuth, debug forwarder) and runs fully offline.
Data model
| Table | Purpose |
|---|---|
| Sites | Site config: custom domain, hash-user-data fields, server ingest key, conversion events, click-id capture. |
| Events | Append-only analytics rows, covering index on (SiteId, OccurredUtc); carries TraceId and skipped-destinations for diagnostics. |
| SiteDestinations | Per-destination config incl. encrypted secrets, forwardEvents predicate, and event map. The http kind adds templated body / envelope / auth, transforms, and variables. |
| OutboxItems | Canonical payload, status / attempts / lease, plus TraceId, response status + PII-scrubbed snippet, and ReasonCode. |
| DailySalts | The rotating visitor-hash salts — deleted after 2 days (the unlinkability guarantee). |
| Containers | One per site: DraftJson + LiveVersionId. |
| ContainerVersions | Immutable compiled container snapshots for publish / rollback / diff. |
| ContainerEnvironments | Named environment + share token + bound version (staging vs. live). |
Feature inventory (GTM / Stape parity)
Analytics
Daily-unique visitors, pageviews, sessions, bounce, duration, top pages / sources / countries / devices / browsers, custom events, live-now, date ranges, and SVG timeseries.
Tag manager
Three tag types (relay_event, image pixel, custom HTML) — all consent-gateable, with setup / cleanup sequencing, compile-time priority, an optional firing schedule (a start / end date window), plus image cache-busting and a safe document.write. Eleven trigger types: pageview, dom_ready, custom_event, click_all, scroll_depth, timer, element_visibility, form_submit, trigger_group, history_change (SPA nav), and js_error. Variables are 18 built-ins plus datalayer, constant, lookup-table, and regex-table — and six client variable types (URL, query, referrer, random, DOM, and JS-var). Authoring covers the visual builder, folders, preview / debug, publish, versions, rollback, version diff, and export / import.
Server-side container
Outbox forwarding to GA4, Meta CAPI, webhook, and debug — plus a generic HTTP forwarder that reaches any vendor by config (templated {{token}} body + envelope + header / query / multi-header auth), with one-click vendor presets. Transformations (redact / set / rename / hash / default / lower / upper), server-side variables used as {{var.NAME}}, and user-data hashing (SHA-256 at forward time and hash-at-ingest, so raw email / phone never persists). Per-destination predicate + visual event map; durable retry, dead-letter, and requeue.
Ingestion paths
The cookieless browser beacon (/api/event) and an authenticated server-to-server endpoint (/api/collect) for offline / CRM / backend conversions — both through the same outbox, scrub, and hash-at-ingest. Coarse geo: country always, region / city with a City database.
Migration & first-party
Import a GTM / Stape container export into a Relay draft with an honest mapped / approximated / skipped parity report. Per-site custom domains (auto-allowed ingest host), a hostname / TLS binding script, and a Google Tag Gateway first-party proxy (gtag.js + GA4 collect via YARP).
Diagnostics
A durable per-action trace (browser → ingest → store → destination), real destination response capture with a classified ReasonCode, a plain-language "is it working?" verdict, free dead-letter alerting plus proactive rules that fire on a dead-row threshold, a silent destination, or a volume anomaly — over webhook and / or SMTP email — dry-run replay, container lint, a GA4 payload linter, duplicate-event and volume-anomaly detection, and privacy-respecting attribution. The delivery log (and, opt-in, the trace log) export to your own Azure Blob storage with tunable retention.
Deliberate non-goals
Some things Relay intentionally does not build, because they would re-implement Google's lock-in or contradict the privacy thesis:
- Google Consent Mode signal plumbing
- gtag / Google client-side tags
- A community Template Gallery with sandboxed-JS custom templates
- The GTM Management API
- Multi-account org hierarchy
Diagnostics
Relay's headline feature is the one thing Google structurally cannot offer: a unified, durable, explainable answer to "what happened to this user action, and why?" — spanning the browser container (which triggers matched, which tags fired or were consent-blocked, what each variable resolved to), the ingest verdict, the durable event, every per-destination decision, and every forward attempt with the destination's real response.
Why Google can't do this
Google's tooling is client-only (GTM Preview, GA4 DebugView), ephemeral (a ~5-minute window that vanishes on reload), siloed (sGTM Preview is a separate console with no link to the client session), and silent (GA4 /collect returns 204 on bad data and never says why). Relay owns the opposite: unified across client + server + destination, durable because it reconstructs from rows that already exist, and explainable because every gate states its reason.
The durable substrate
Relay isn't a debugger built from scratch — it surfaces data it already persists. The load-bearing insight is that the OutboxItem is a durable per-(event, destination) record holding the full payload, attempt count, last error, and timing — the server + destination half of a trace, already on disk. sGTM and Stape keep no such record.
The diagnostics spine
| Phase | What it delivers |
|---|---|
| D1 | Unified trace + decision-explainer: a per-action trace id on the existing beacon, stamped onto the Event and OutboxItem, rendered as a /trace/{id} waterfall from durable rows. |
| D2 | Real response capture + classified ReasonCode — turns "fired" into "fired and GA4 accepted / rejected," killing GA4's silent 204. |
| D4 | Durable replay + then-vs-now diff: re-drive a frozen payload through the real forwarder and diff the original send against today's config. |
| D5 | Preflight config linter + lint-on-publish: proves a predicate that can never match, before traffic is lost. |
| D6 | Tag-Assistant parity: connect-this-browser handshake, a Variables inspector, and an Errors / Consent tab. |
The Tier-0 verdict
On top of the spine sits a plain-language verdict banner: "Your purchase event reached GA4 and Meta." Status color is reserved exclusively for delivery-truth — green means a real destination response said "accepted," bounded honestly ("in the last hour"), and shows "unknown" when the trace ring is cold rather than faking a green check.
Privacy stance. The trace id is a random per-action token, never IP- or UA-derived, with zero re-linking power. No raw IP is ever stored; response snippets are PII-scrubbed and capped at 500 chars. Trace artifacts age out with their event / outbox rows under the existing Janitor, so the 2-day unlinkability guarantee stays intact.
First-party domains & Google Tag Gateway
Three intertwined capabilities that reduce to one idea: serve everything on the customer's own first-party domain, with a one-click setup.
First-party custom domains
Each site can serve relay.js, relay-runtime.js, /c/{siteKey}.json, and /api/event from the customer's own domain (e.g. data.acme.com). First-party means resilience to ad-blockers and third-party-cookie / ITP restrictions — and the data is the customer's. Because the snippet derives its endpoint from document.currentScript.src and tenancy is keyed off the siteKey in the payload, the app is already multi-host-capable; the added pieces are onboarding UX and hostname / TLS binding.
Custom-domain onboarding
- The operator enters
data.acme.comin site settings. - Relay shows the DNS records to add — a
CNAMEto the app FQDN and anasuidTXTverification record. - The operator adds them at their registrar and clicks Verify.
- Relay binds the hostname to the Container App and provisions a free, auto-renewing managed TLS certificate. Status surfaces as Pending DNS → Verifying → Active.
- The install snippet now points at
https://data.acme.com/relay.js.
Google Tag Gateway proxy
On a custom domain, Relay acts as the reverse proxy for Google's first-party mode: requests for the Google tag and GA4 collect are proxied (region-aware) to Google's endpoints via YARP's IHttpForwarder — streaming, with a fixed allowlist of paths (/gtag/js, /gtm.js, /g/collect, /g/s/collect), never an open proxy. The client IP is forwarded so GA4 geo stays correct. Verified live: /gtag/js returns Google's real gtag.js (200) and /g/collect returns GA4's 204.
Privacy stance. Relay's own analytics stay cookieless and never store raw IP. The Google Tag Gateway is a distinct, opt-in feature: enabling it means knowingly running Google's tag first-party and forwarding to Google (including client IP, as Google's gateway requires). This is clearly labeled in the UI and docs as "you are sending data to Google" and is not covered by Relay's cookieless guarantees — keeping the two separated preserves the privacy-first brand.
Interface (UX)
The interface has one job: answer "is my tracking working, and if not, exactly what's wrong and how do I fix it?" out loud, on every screen, in the user's own words — backed by the durable trace Google can't show. It leads with the answer, not the config, and lets anyone drill from verdict → trace → config → fix on the same substrate, re-rendered at four depths by a single skill-tier knob.
Principles
- Lead with the answer, not the config. Tags, triggers, and destinations are one click away, never the front door.
- One substrate, four depths. A single Skill-Tier knob (Owner / Marketer / Analyst / Dev), not per-page "advanced" toggles — Tier 0 sees "reached GA4," Tier 3 sees raw payload / response /
ReasonCode. - The verdict must be trustworthy. Green / amber / red mean delivery-truth only, never decoration, and "unknown" is honest when the data is cold.
- Delete the modal. Editing is inline in a right-hand PeekPanel with a live linter; preview, test, and trace happen in place over SignalR.
- Prove it worked. Every fix flows through the real publish path and offers one-click replay showing then-vs-now.
- Privacy is felt, not claimed. A persistent "cookieless · self-hosted · no raw IP" footprint, with the AI boundary (masked config + selectors / keys only) as an enforced invariant.
Navigate by intent, not by engine
A per-site spine of intent-named stops replaces the old flat noun list:
| Stop | What it is |
|---|---|
| Overview | The verdict home — hero verdict, a "needs attention" stack, a live trace ticker, and stat tiles. |
| Build | Tags, triggers, and variables in one view — a clean vertical flow, earning the FlowMap canvas as a disclosed second step; publish is a visible diff with lint-on-publish blocking dead wires. |
| Live | The observation surface — the trace stream, the promoted TraceWaterfall, connect-this-browser preview, and replay. |
| Settings | Install snippet, custom domain / Gateway, conversion markers, config-as-code, and alerts. |
Cross-cutting and always present: a persistent VerdictBar (site switcher + global health pill + Skill-Tier dial), a global ⌘K command palette, and a context-aware Copilot dock. The /outbox and /trace/{id} deep links keep working via redirects, so the IA change never breaks muscle memory.
Design tokens
The interface evolves the existing stylesheet with no framework. The indigo action accent stays; the verdict triad (good / warn / bad) plus a neutral "unknown" slate is reserved exclusively for delivery-truth. Dark theme is first-class — a deep slate canvas where trace lanes glow like an instrument panel. Monospace is quarantined to ids, payloads, and config-as-code, with tabular-nums on every count and latency. Motion is purposeful and cheap, always signalling a trustworthy state change, and respects prefers-reduced-motion.