Architecture & System Design¶
This document describes the system architecture, component interactions, database schema, and Azure infrastructure topology of the vouchfx telemetry backend.
Overview¶
The backend is a lean, stateless ASP.NET Core 8 minimal-API service that:
- Ingests opt-in telemetry batches from the frozen vouchfx engine client
- Parses NDJSON event lines and enforces the privacy-allowlist contract
- Stores events in a PostgreSQL database under daily RANGE partitions
- Maintains data via a background job: partition pre-creation, aged-partition drop, and forget-queue drain
- Serves dashboard views so the telemetry team can inspect aggregate counts and timing distributions
Five-Component Architecture¶
1. Ingest Endpoint (TelemetryEndpoints.cs)¶
Responsibility: HTTP request handling, validation, and routing.
Flow:
- Validates Content-Type: application/x-ndjson
- Validates Idempotency-Key header (64 lowercase hex chars)
- Reads the request body (bounded by Kestrel.Limits.MaxRequestBodySize)
- Extracts NDJSON lines and counts them
- Rejects if line count exceeds MaxBatchLines (default 500)
- Passes lines to the Allowlist Parser for validation
- Returns 2xx on success, 4xx/5xx on error
Key class: TelemetryEndpoints.HandleIngestAsync(…)
2. Allowlist Parser (AllowlistParser.cs)¶
Responsibility: Deserialize and validate the allowlist contract.
Logic:
- Iterates each NDJSON line
- Extracts schemaVersion field; treats missing/non-integer as parse error
- Schema version 1: strict deserialization (rejects unknown fields via UnmappedMemberHandling.Disallow)
- Schema version >1: lenient deserialization (unknown fields tolerated but never stored)
- Returns a ParseResult (union type): Empty, TooManyLines, Bad, or Ok
- The TelemetryEvent record is the allowlist; only its declared properties can bind
Key class: AllowlistParser.Parse(lines, maxLines)
3. Npgsql Repository (NpgsqlTelemetryRepository.cs)¶
Responsibility: Transactional database persistence.
Operations:
Ingest¶
- Attempts
INSERT INTO ingest_batch (idempotency_key, install_id, line_count) VALUES (…) ON CONFLICT DO NOTHING - If 0 rows inserted: batch is a duplicate; return 200 immediately
- Pre-creates daily partitions for each unique
event_timestamp::datein the batch - Bulk-inserts all events via
unnest()withON CONFLICT (install_id, event_timestamp, schema_version) DO NOTHING - Returns the number of new rows inserted
Forget¶
- Enqueues a forget request:
INSERT INTO forget_queue (install_id) VALUES (…) ON CONFLICT DO UPDATE SET requested_at = now(), processed_at = NULL
Ready Check¶
- Executes a simple query to confirm the database is reachable; used by the
/readyzprobe
Key class: NpgsqlTelemetryRepository
4. Partition Manager (PartitionManager.cs)¶
Responsibility: Daily partition lifecycle management (no manual administration required).
Operations (all called by the background job):
EnsurePartitionsAhead¶
- Pre-creates explicit day partitions for the next
PrecreateDaysdays (default 7) - Ensures explicit partitions exist before data arrives, preventing DEFAULT partition pollution
- Calls
SELECT ensure_partitions(current_date, current_date + @precreate)
DropOldPartitions¶
- Drops child partitions whose upper bound is on or before the retention cutoff
- Calls
SELECT drop_old_partitions(@retentionDays) - Never drops the DEFAULT partition (belt-and-suspenders safety)
SweepDefault¶
- Deletes aged rows from the DEFAULT catch-all partition (for clients with broken clocks)
- Calls
SELECT sweep_default(@retentionDays), which returns the row count deleted - Rows are deleted if their
event_timestampis older than the retention window
Key class: PartitionManager
5. Forget Queue Drainer (ForgetQueueDrainer.cs)¶
Responsibility: Process pending deletion requests.
Operation:
- Queries SELECT install_id FROM forget_queue WHERE processed_at IS NULL
- For each row, within a transaction:
1. DELETE FROM telemetry_event WHERE install_id = $1 (crosses partitions automatically)
2. DELETE FROM ingest_batch WHERE install_id = $1 (cleans up dedup records)
3. UPDATE forget_queue SET processed_at = now() WHERE install_id = $1
- Deletion is best-effort one-shot; the backend job is a backstop for client-side failures
Key class: ForgetQueueDrainer
PostgreSQL Schema¶
Target: PostgreSQL 16 on Azure Database for PostgreSQL Flexible Server
Core Tables¶
telemetry_event (Partitioned Parent)¶
RANGE-partitioned by event_timestamp (daily partitions). Stores one row per TelemetryEvent received.
CREATE TABLE telemetry_event (
-- Identity & dedup
install_id uuid NOT NULL,
event_timestamp timestamptz NOT NULL, -- partition key
schema_version int NOT NULL,
-- Server-side audit
received_at timestamptz NOT NULL DEFAULT now(),
-- Versions
tool_version text NOT NULL,
engine_version text NOT NULL,
dotnet_version text NOT NULL,
-- Counts
run_count int NOT NULL,
scenario_count int NOT NULL,
step_pass int NOT NULL,
step_fail int NOT NULL,
step_env_error int NOT NULL,
step_inconclusive int NOT NULL,
scenario_pass int NOT NULL,
scenario_fail int NOT NULL,
scenario_env_error int NOT NULL,
scenario_inconclusive int NOT NULL,
-- Usage maps
step_families jsonb NOT NULL,
step_providers jsonb NOT NULL,
-- Timings (milliseconds)
startup_ms bigint NOT NULL,
time_to_first_test_ms bigint NOT NULL,
PRIMARY KEY (install_id, event_timestamp, schema_version)
)
PARTITION BY RANGE (event_timestamp);
Partitioning strategy:
- Daily child partitions (e.g. telemetry_event_20260628) are created automatically via ensure_partition(d date)
- DEFAULT catch-all partition absorbs rows with clock-skewed or out-of-window timestamps
- Partition upper bounds align with UTC midnight (each partition spans one calendar day in UTC)
- Old partitions are dropped on the daily job when their upper bound is ≥90 days old (configurable)
Why event_timestamp is the partition key (not received_at):
- PostgreSQL requires the partition key to appear in every unique constraint on a partitioned table
- The natural dedup key is (install_id, event_timestamp, schema_version) — the time-unique identity of a run
- Using event_timestamp (the client-reported run-end time) makes this triple a legal PRIMARY KEY
- A re-sent batch lands in the same partition as the original, guaranteeing the PK constraint fires and the duplicate is skipped
Indexes: None explicit (PRIMARY KEY is indexed). Additional indexes (e.g. on received_at for partition purge scans) are not needed at pilot scale.
ingest_batch (NOT Partitioned)¶
Batch-level idempotency records. Primary key is the SHA-256 hex of the request body.
CREATE TABLE ingest_batch (
idempotency_key char(64) NOT NULL,
install_id uuid NULL,
line_count int NOT NULL,
received_at timestamptz NOT NULL DEFAULT now(),
PRIMARY KEY (idempotency_key)
);
CREATE INDEX idx_ingest_batch_received_at
ON ingest_batch (received_at);
CREATE INDEX idx_ingest_batch_install_id
ON ingest_batch (install_id)
WHERE install_id IS NOT NULL;
Retention: Aged-out records (older than DedupRetentionDays, default 35 days) are purged on the daily job. The 35-day window accounts for the client's 30-day outbox cap plus Polly back-off headroom.
forget_queue¶
GDPR deletion requests queued by the /v1/telemetry/forget endpoint.
CREATE TABLE forget_queue (
install_id uuid NOT NULL PRIMARY KEY,
requested_at timestamptz NOT NULL DEFAULT now(),
processed_at timestamptz NULL,
);
CREATE INDEX idx_forget_queue_unprocessed
ON forget_queue (install_id)
WHERE processed_at IS NULL;
Flow:
- New forget request: INSERT … ON CONFLICT DO UPDATE SET requested_at = now(), processed_at = NULL
- Daily job queries WHERE processed_at IS NULL and processes each
- On success, sets processed_at = now()
- If the job crashes mid-deletion, processed_at remains NULL and the next cycle retries
Functions¶
ensure_partition(d date) → void¶
Creates a single-day child partition for day d. Table name is derived solely from the date parameter via to_char(d, 'YYYYMMDD') — no untrusted input reaches the DDL, DDL injection is structurally impossible.
CREATE TABLE IF NOT EXISTS telemetry_event_<YYYYMMDD>
PARTITION OF telemetry_event
FOR VALUES FROM ('<date>') TO ('<date+1>');
Idempotent: running it twice on the same date is a no-op.
ensure_partitions(from_date date, to_date date) → void¶
Calls ensure_partition for each day in [from_date, to_date). Used by the daily job to pre-create 7 days ahead.
drop_old_partitions(retention_days int) → void¶
Drops every direct child partition of telemetry_event whose upper bound satisfies upper_bound ≤ current_date - retention_days.
Identification: Uses pg_get_expr(c.relpartbound, c.oid) to extract the upper-bound timestamp from the partition bound expression, then compares it to the cutoff. Robust across any partition naming scheme (not dependent on the YYYYMMDD suffix).
Safety: Explicitly excludes the DEFAULT partition via pg_partitioned_table.partdefid.
sweep_default(retention_days int) → bigint¶
Deletes rows from the DEFAULT partition whose event_timestamp < now() - make_interval(days => retention_days). Returns the row count deleted. Targets clients with broken clocks that produce timestamps outside the explicit partition range.
Dashboard Views¶
Read-only views for the telemetry team to inspect aggregate statistics without direct table access.
v_step_family_daily¶
Daily usage count per step family:
SELECT
date_trunc('day', event_timestamp) AS day,
kv.key AS family,
SUM(kv.value::int) AS n
FROM telemetry_event,
LATERAL jsonb_each_text(step_families) AS kv
GROUP BY 1, 2;
Example output:
day | family | n
-----------|--------------|---
2026-06-28 | http | 1250
2026-06-28 | db-assert | 450
2026-06-28 | mq-publish | 200
v_step_provider_daily¶
Daily usage count per step provider (technology):
SELECT
date_trunc('day', event_timestamp) AS day,
kv.key AS provider,
SUM(kv.value::int) AS n
FROM telemetry_event,
LATERAL jsonb_each_text(step_providers) AS kv
GROUP BY 1, 2;
v_run_daily¶
Daily rollup: run/scenario counts, verdict sums, timing percentiles, distinct installs:
SELECT
date_trunc('day', event_timestamp) AS day,
COUNT(*) AS events,
SUM(run_count) AS runs,
SUM(scenario_count) AS scenarios,
SUM(step_pass) AS step_pass,
SUM(step_fail) AS step_fail,
SUM(step_env_error) AS step_env_error,
SUM(step_inconclusive) AS step_inconclusive,
SUM(scenario_pass) AS scenario_pass,
SUM(scenario_fail) AS scenario_fail,
SUM(scenario_env_error) AS scenario_env_error,
SUM(scenario_inconclusive) AS scenario_inconclusive,
percentile_cont(0.5) WITHIN GROUP (ORDER BY startup_ms) AS startup_ms_p50,
percentile_cont(0.95) WITHIN GROUP (ORDER BY startup_ms) AS startup_ms_p95,
percentile_cont(0.5) WITHIN GROUP (ORDER BY time_to_first_test_ms) AS ttft_ms_p50,
percentile_cont(0.95) WITHIN GROUP (ORDER BY time_to_first_test_ms) AS ttft_ms_p95,
COUNT(DISTINCT install_id) AS distinct_installs
FROM telemetry_event
GROUP BY 1;
Background Maintenance Job¶
Implementation: RetentionHostedService (ASP.NET Core IHostedService)
Interval: Configurable via VOUCHFX_TELEMETRY_JOB_INTERVAL_HOURS (default 24; runs immediately at startup, then every 24 hours)
Concurrency control: PostgreSQL advisory lock (constant key 152152152). Only one replica runs maintenance per cycle. If the lock cannot be acquired, the cycle is skipped and the next replica waits.
Sequence per cycle:
1. Acquire advisory lock (if it fails, skip this cycle)
2. Run steps (independently; per-step failures are logged but do not block remaining steps):
- EnsurePartitionsAhead(PrecreateDays) — pre-create 7 days ahead
- DropOldPartitions(RetentionDays) — drop partitions ≥90 days old
- SweepDefault(RetentionDays) — delete aged rows from DEFAULT partition
- ForgetDrain() — process forget_queue
- PurgeIngestBatch(DedupRetentionDays) — purge dedup records ≥35 days old
3. Release advisory lock
Replica requirements: - minReplicas = 1 (mandatory). The job is an in-process timer; if all replicas are scaled to zero, the timer never fires and old partitions are never dropped. - Container Apps Consumption workload: ~$15–20/month for a single idle replica
Azure Infrastructure Topology¶
Deployed via Bicep IaC (deploy/main.bicep and child modules) to an Azure resource group.
Components¶
User-Assigned Managed Identity (UAMI)¶
- Created before all other resources to resolve circular dependencies
- Used by the Container App to authenticate to Azure Key Vault (no credentials in logs or process environment)
- Granted
Key Vault Secrets Userrole on the Key Vault
Log Analytics Workspace¶
- Receives Container App logs, metrics, and diagnostics
- Retention configurable; enables alerting and log queries for operational visibility
Container Apps Environment (Managed Environment)¶
- Shared compute and networking fabric for Container Apps
- Integrates with Log Analytics for observability
PostgreSQL Flexible Server (Managed Database)¶
SKU options:
- Development: Standard_B1ms (Burstable, 1 vCore, 2 GiB RAM, default 32 GiB storage)
- Production: Customizable (default: Standard_B2s Burstable; GeneralPurpose recommended for sustained load)
Network: Public access (firewall rule: "allow Azure services"). Hardened production delta: VNet integration + private endpoint + password-less Entra MI auth.
TLS: HTTPS-required firewall rule enforced (sslmode=require in connection strings).
Availability: - High-availability (zone-redundant standby replica) available but not deployed in pilot (cost/complexity trade-off) - Single-replica pilot configuration is suitable for opt-in telemetry (tolerable availability for non-critical data)
Backup: Azure-managed geo-redundant backups (7-day retention by default)
Key Vault¶
- Stores sensitive secrets:
ingest-tokens: Bearer tokens (comma-separated viareadEnvironmentVariable())db-connection-string: Full Npgsql connection string- Secrets are never logged, displayed in deployment history, or written to parameter files
- Versionless secret URIs enable automatic secret rotation without Container App redeployment
- Access: Only the Container App's UAMI can read these secrets (via Key Vault Secrets User role)
Container App¶
Image: vfxtelregistry.azurecr.io/vouchfx-telemetry-backend:<tag>
Replica count:
- minReplicas = 1 (for background job)
- maxReplicas = 3 (default; configurable)
Ingress:
- External (platform provisions public HTTPS endpoint)
- allowInsecure = false (HTTPS-only)
- Target port 8080 (non-privileged)
- TLS certificate managed by the platform
Environment variables:
Secrets (from Key Vault):
- VOUCHFX_TELEMETRY_INGEST_TOKENS ← ingest-tokens secret
- ConnectionStrings__Telemetry ← db-connection secret
Non-secret tunables:
- VOUCHFX_TELEMETRY_MAX_BODY_BYTES (default 2097152)
- VOUCHFX_TELEMETRY_MAX_BATCH_LINES (default 500)
- VOUCHFX_TELEMETRY_RATE_PERMITS (default 120)
- VOUCHFX_TELEMETRY_RATE_WINDOW_SECONDS (default 60)
- VOUCHFX_TELEMETRY_RETENTION_DAYS (default 90)
- VOUCHFX_TELEMETRY_PRECREATE_DAYS (default 7)
- VOUCHFX_TELEMETRY_DEDUP_RETENTION_DAYS (default 35)
- VOUCHFX_TELEMETRY_JOB_INTERVAL_HOURS (default 24)
- ASPNETCORE_URLS (fixed: http://+:8080)
Health probes:
- Liveness: GET /healthz (30s initial delay, 30s period, 5s timeout, 3 failure threshold)
- Readiness: GET /readyz (10s initial delay, 10s period, 5s timeout, 3 failure threshold)
Resources per replica: - CPU: 0.5 cores - Memory: 1 GiB
Deployment Process¶
See deploy.yml for the full workflow. Summary:
- GitHub Actions checks out the repo
- Builds the .NET service (
dotnet publish) - Builds and pushes the Docker image to ACR (
az acr build) - Validates Bicep parameters (reads secrets from env vars via
readEnvironmentVariable()) - Deploys Bicep IaC (
az deployment group create --template-file deploy/main.bicep --parameters @deploy/parameters/dev.bicepparam) - Bicep provisions all Azure resources and injects secrets from GitHub via Key Vault
The Bicep parameter files (dev.bicepparam, prod.bicepparam) define resource sizes and configuration; secrets are never written to files, only injected at deploy time via environment variables.
Separation from the Engine Repository¶
This is a separate GitHub repository (not vendored in vouchfx). Rationale:
- Deployment credentials: Backend secrets (Key Vault, ACR credentials) are separate from engine secrets
- Deployment cadence: Backend release cycle is independent of engine releases
- Team ownership: Infra team can manage backend deployment without engine code access
- Binary compatibility: Backend image versioning (SHA/semver tags) is decoupled from engine versioning
- Compliance: Easier to audit and control who has access to production secrets
The engine client is inert until a backend endpoint and token are configured. Configuration is driven by optional environment variables (VOUCHFX_TELEMETRY_ENDPOINT, VOUCHFX_TELEMETRY_TOKEN) that users provide when opting in.