Documentation

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.

Architecture

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.

Architecture

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.

Architecture

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.

Architecture

Key data flows

Ingestion: beacon → stored → forwarded

  1. The browser POSTs /api/event (text/plain, sendBeacon).
  2. The ingest endpoint validates the key and host, filters bots, enriches, scrubs PII, computes the cookieless visitor hash and session — then always returns 202.
  3. The accepted event is written to an in-memory Channel(50k).
  4. EventWriterService drains up to 200 every 500 ms and, in one transaction, writes the Event plus an OutboxItem per enabled destination whose forwardEvents predicate matches.
  5. The OutboxDispatcher claims due rows under a 2-minute lease, groups by destination and visitor, maps each via the event map and per-destination mapper, and POSTs.
  6. The destination's response classifies the row: 2xx → Sent, 5xx / 429 → retry, 4xx → Dead.

Tag-manager container: author → deliver → run

  1. The operator edits the draft (tags, triggers, variables) in the Blazor builder and clicks Publish.
  2. The service validates and compiles the draft to CompiledJson ({{name}} tokens become var: / builtin: references), writes an immutable ContainerVersion, flips LiveVersionId, and busts the cache.
  3. The loader fetches /c/{siteKey}.json (cached, ETag) and injects the runtime with config.
  4. The runtime turns dataLayer events into evaluated triggers, resolves variables, applies the consent gate, and fires tags. relay_event tags beacon back into the ingestion flow above.
Architecture

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.

Architecture

Data model

TablePurpose
SitesSite config: custom domain, hash-user-data fields, server ingest key, conversion events, click-id capture.
EventsAppend-only analytics rows, covering index on (SiteId, OccurredUtc); carries TraceId and skipped-destinations for diagnostics.
SiteDestinationsPer-destination config incl. encrypted secrets, forwardEvents predicate, and event map. The http kind adds templated body / envelope / auth, transforms, and variables.
OutboxItemsCanonical payload, status / attempts / lease, plus TraceId, response status + PII-scrubbed snippet, and ReasonCode.
DailySaltsThe rotating visitor-hash salts — deleted after 2 days (the unlinkability guarantee).
ContainersOne per site: DraftJson + LiveVersionId.
ContainerVersionsImmutable compiled container snapshots for publish / rollback / diff.
ContainerEnvironmentsNamed environment + share token + bound version (staging vs. live).
Architecture

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.

Architecture

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
Design system

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

PhaseWhat it delivers
D1Unified 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.
D2Real response capture + classified ReasonCode — turns "fired" into "fired and GA4 accepted / rejected," killing GA4's silent 204.
D4Durable replay + then-vs-now diff: re-drive a frozen payload through the real forwarder and diff the original send against today's config.
D5Preflight config linter + lint-on-publish: proves a predicate that can never match, before traffic is lost.
D6Tag-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.

Design system

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

  1. The operator enters data.acme.com in site settings.
  2. Relay shows the DNS records to add — a CNAME to the app FQDN and an asuid TXT verification record.
  3. The operator adds them at their registrar and clicks Verify.
  4. Relay binds the hostname to the Container App and provisions a free, auto-renewing managed TLS certificate. Status surfaces as Pending DNS → Verifying → Active.
  5. 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.

Design system

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:

StopWhat it is
OverviewThe verdict home — hero verdict, a "needs attention" stack, a live trace ticker, and stat tiles.
BuildTags, 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.
LiveThe observation surface — the trace stream, the promoted TraceWaterfall, connect-this-browser preview, and replay.
SettingsInstall 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.

See it live What's new