system-design62 min read

How to Design a Real-Time Analytics & Metrics Dashboard — A Senior+ Guide | Ayodhyya

Designing a Real-Time Analytics & Metrics Dashboard

A deep-dive into building systems like Datadog, Grafana, and Prometheus from first principles

Senior+ System Design Guide 20 Sections Architecture & Code 2026 Edition

1. Introduction — Why Real-Time Analytics Matters

In the era of cloud-native computing, microservices, and global-scale distributed systems, the ability to observe what is happening inside your infrastructure in real time has moved from a luxury to a fundamental requirement. Every modern engineering organization — from two-person startups to Fortune 500 enterprises — relies on metrics dashboards to understand service health, detect anomalies, and make data-driven decisions about capacity, performance, and reliability.

Tools like Datadog, Grafana, Prometheus, and New Relic have become the backbone of operations engineering. They ingest billions of data points per day, store them in highly optimized time-series databases, and render interactive dashboards that update in real time. Designing such a system is a formidable engineering challenge that touches nearly every area of distributed systems: high-throughput ingestion, efficient storage, real-time computation, low-latency queries, complex alerting rules, multi-tenancy, and cost optimization.

This article is a comprehensive guide for senior, staff, and principal engineers who want to understand how to build a real-time analytics and metrics dashboard from the ground up. We will walk through every major subsystem — from the initial requirements gathering and capacity estimation all the way through to the query engine, anomaly detection, and multi-tenant data isolation. We will reason about trade-offs at each layer, explore concrete C# code for the most interesting components, and draw on the hard-won lessons of operating these systems at scale.

Who This Guide Is For: This article targets senior+ engineers preparing for system design interviews, architects evaluating observability platforms, and platform engineers building internal tooling. A working knowledge of distributed systems, databases, and networking is assumed.

What Makes Metrics Different from Logs and Traces

The three pillars of observability — metrics, logs, and traces — serve complementary but distinct purposes. Metrics are numerical measurements taken at regular intervals. They are inherently compact, lossy, and aggregate-friendly. A CPU utilization percentage tells you how busy a server is, but not why. Logs give you the why — the detailed, unstructured narrative. Traces give you the how — the journey of a single request through a distributed system.

Metrics are the most efficient pillar. A single metric data point is just a timestamp and a number. When you multiply that by millions of hosts, thousands of metrics per host, and second-level granularity, you get staggering throughput — but the per-point cost is tiny compared to a log line or a trace span. This efficiency is what makes real-time metrics dashboards possible at scale, but it also introduces unique challenges around cardinality, aggregation, and retention.

The Latency Spectrum

Real-time does not mean the same thing in every context. For a metrics dashboard, "real time" typically means data is visible within 5 to 15 seconds of being emitted. This is very different from a real-time bidding system (sub-millisecond) or a real-time game (sub-50ms). The 5-15 second window gives us significant engineering flexibility — we can batch, aggregate, and pre-compute without the user noticing a delay. Understanding this latency budget is critical because it determines every architectural decision from the ingestion pipeline to the query path.

Key Insight: Most users cannot distinguish between 5-second and 15-second freshness on a dashboard. However, they absolutely notice when a dashboard takes 3 seconds to load. Optimize for query latency over ingestion latency for the typical dashboard use case.

The Business Case

Beyond technical elegance, real-time dashboards have direct business impact. Downtime costs enterprises an average of $5,600 per minute according to Gartner. A well-designed alerting system that detects anomalies 10 minutes earlier can save hundreds of thousands of dollars per incident. Conversely, false-positive alerts erode engineering trust and lead to alert fatigue, which causes engineers to ignore real issues. The quality of your metrics platform directly correlates with your organization's ability to maintain reliability at scale.

Industry Landscape

The observability market is segmented into several tiers. At the top sit enterprise SaaS platforms like Datadog, New Relic, and Dynatrace, which offer fully managed solutions at premium prices ($15-$23 per host per month). In the middle are self-hosted open-source solutions like Prometheus paired with Grafana, which offer flexibility but require significant operational expertise. At the bottom are lightweight agents and collectors designed for specific environments. Our design draws inspiration from all three tiers, taking the best ideas from each while avoiding their respective pitfalls.

The key differentiator of a well-designed metrics platform is not any single feature but the balance across all dimensions: ingestion throughput, query performance, storage efficiency, alert reliability, and operational simplicity. A system that excels at ingestion but has slow queries will frustrate users. A system with fast queries but unreliable alerts will lose trust. Designing for balance requires understanding the entire pipeline end-to-end.

2. Functional & Non-Functional Requirements

Before diving into architecture, we need a precise understanding of what the system must do and how well it must do it. Ambiguous requirements lead to over-engineered systems that are hard to operate and expensive to run.

Functional Requirements

  • Metric Ingestion: Accept metric data points (name, value, timestamp, tags) via push (HTTP, StatsD, OpenTelemetry) and pull (Prometheus-style scrape) protocols.
  • Storage: Persist all ingested metrics in a time-series optimized storage layer with configurable retention policies.
  • Query: Support ad-hoc queries over arbitrary time ranges with a PromQL-like query language supporting aggregation, filtering, math operations, and joins across metric series.
  • Dashboard Visualization: Render interactive, zoomable, pannable charts that display queried data with sub-second rendering latency on the client side.
  • Real-Time Updates: Push new data points to connected dashboard clients via WebSockets so charts auto-update without manual refresh.
  • Alerting: Evaluate user-defined alert rules (threshold-based and anomaly-detection-based) against incoming data and send notifications (email, Slack, PagerDuty, webhook) when conditions are met.
  • Dashboard Sharing: Allow users to create, save, share, and version-control dashboards with fine-grained permissions.
  • Anomaly Detection: Automatically identify unusual patterns in metric data using statistical methods and machine learning models.
  • Downsampling: Automatically reduce data resolution over time to control storage costs while preserving query accuracy for aggregate functions.
  • Multi-Tenancy: Isolate data, queries, alerts, and dashboards between different teams, organizations, or tenants.

Non-Functional Requirements

AttributeTargetRationale
Ingestion Throughput10 million data points/secondSupports 100K hosts with ~100 metrics each at 1-second intervals
Ingestion Latency< 5 seconds (p99)Data visible on dashboard within 5 seconds of emission
Query Latency< 2 seconds (p99) for 24h rangeInteractive dashboard experience
Dashboard Load Time< 1 second (p95)Users should perceive instant page loads
Alert Evaluation Latency< 60 seconds from data to notificationTimely incident response
Availability99.95% (dashboards), 99.9% (ingestion)Dashboards more critical than ingestion during brief outages
Durability99.999999% (11 nines)Metrics are append-only and loss of data undermines trust
Data Retention90 days raw, 1 year at 1-minute rollup, forever at 1-hour rollupIndustry standard retention tiers
Cardinality SupportUp to 10 million active time seriesSupports large microservice deployments with rich tagging
Design Principle: Start with the requirements that constrain your architecture the most. For a metrics system, ingestion throughput and cardinality are typically the hardest constraints. Everything else can be scaled or optimized incrementally.

Stakeholder Analysis

Different stakeholders have different requirements from the metrics platform. SRE teams need real-time dashboards and fast alerting to respond to incidents. Development teams need detailed metrics to debug performance regressions. Management needs high-level dashboards showing SLA compliance and cost trends. Platform teams need the system to be maintainable, scalable, and cost-effective to operate.

Achieving consensus across these stakeholders requires explicit priority ranking. In most organizations, SRE incident response is the highest priority use case, followed by development debugging, followed by management reporting. This priority ordering directly informs design decisions — for example, we optimize the query path for the most recent 24 hours of data (the SRE use case) more aggressively than for historical trends spanning months.

3. Capacity Estimation & Back-of-Envelope Math

Good capacity estimation is the foundation of sound system design. Before writing any code or choosing any technology, you must understand the scale of the problem. Let us work through the math for a mid-to-large scale metrics platform.

Ingestion Volume

Assume we are monitoring a large cloud-native deployment:

  • 100,000 hosts (physical servers, VMs, containers)
  • 200 metrics per host (CPU, memory, disk, network, application-specific)
  • 1 metric point emitted every 10 seconds per metric (configurable, but 10s is a common default)

Data points per second: 100,000 x 200 / 10 = 2,000,000 points/second

Data points per day: 2,000,000 x 86,400 = ~172.8 billion points/day

Storage Calculation

Each metric data point consists of:

ComponentSizeNotes
Timestamp8 bytesUnix nanosecond precision
Value8 bytes64-bit double
Series ID (reference)8 bytesPoints to series metadata
Total per point24 bytesRaw, uncompressed

With columnar compression (Gorilla-style encoding + Delta-of-delta for timestamps), we typically achieve 5-10x compression. Let us assume 8x:

Compressed storage per point: 24 / 8 = 3 bytes

Raw storage per day: 172.8 billion x 3 = 518.4 GB/day

For the series metadata (metric name, tags, labels), assume an average of 200 bytes per unique series, with 20 million unique series:

Series metadata: 20,000,000 x 200 = 4 GB

Storage After Downsampling

Retention TierGranularityDaily StorageRetentionTotal
Raw10 seconds518 GB90 days~46.6 TB
1-minute rollup1 minute~86 GB365 days~31.4 TB
1-hour rollup1 hour~1.4 GB5 years~2.6 TB
Total~80.6 TB

Network Bandwidth

Ingestion bandwidth: 2,000,000 points/sec x 24 bytes = 48 MB/s (uncompressed) or ~6 MB/s compressed over the wire with Protocol Buffers.

Query bandwidth: Assume 1,000 concurrent dashboard users, each fetching 5 panels, each panel requiring ~1,000 data points. Total query bandwidth: 1,000 x 5 x 1,000 x 24 bytes = 120 MB/s, well within capacity of modern network infrastructure.

Compute Requirements

Ingestion: At 2M points/second, we need approximately 8-16 ingestion nodes (assuming each node handles 200K-400K points/second with batching and compression).

Query: With 1,000 concurrent users issuing queries every 30 seconds, we need roughly 30-50 query nodes depending on query complexity.

Alerting: Evaluating alert rules at 10-second intervals for 100,000 rules requires 10,000 rule evaluations per second, achievable on 4-8 dedicated nodes.

Memory Estimation

The in-memory active series cache is the largest memory consumer. Each active series requires approximately 500 bytes of metadata (series ID, name, tags, last-value cache, compression state) plus the compressed block buffer. With 20 million active series:

Series metadata in memory: 20,000,000 x 500 bytes = 10 GB

Active block buffer (2-hour window): 2M points/sec x 7200 sec x 8 bytes (uncompressed for in-memory processing) = ~115 GB (distributed across write nodes)

Cardinality Explosion Warning: The estimates above assume controlled cardinality. In practice, uncontrolled tag creation (e.g., user_id as a tag) can explode cardinality by 10-100x. Implementing cardinality limits and monitoring cardinality growth is essential at every scale.

4. Data Model & Schema Design

The data model of a metrics system is deceptively simple at first glance but profoundly impacts every downstream component. A metric data point is a tuple of (name, tags, timestamp, value), but the nuances of how we represent tags, handle label cardinality, and organize series for efficient storage and retrieval are where the real design decisions lie.

Core Entities

C#
public sealed class MetricDataPoint
{
    public string MetricName { get; init; }
    public IReadOnlyDictionary<string, string> Tags { get; init; }
    public long TimestampNanos { get; init; }
    public double Value { get; init; }
    public MetricType Type { get; init; }
}

public enum MetricType
{
    Gauge,
    Counter,
    Histogram,
    Summary
}

public sealed class MetricSeries
{
    public ulong SeriesId { get; init; }
    public string MetricName { get; init; }
    public IReadOnlyDictionary<string, string> Tags { get; init; }
    public MetricType Type { get; init; }
    public long CreatedAtNanos { get; init; }
    public long LastModifiedNanos { get; init; }
}

public sealed class MetricSeriesKey
{
    public string MetricName { get; init; }
    public IReadOnlySortedDictionary<string, string> Tags { get; init; }

    public ulong ComputeSeriesId()
    {
        using var sha = System.Security.Cryptography.SHA256.HashData(
            System.Text.Encoding.UTF8.GetBytes(
                $"{MetricName}:{string.Join(",", Tags.Select(t => $"{t.Key}={t.Value}"))}"));
        return BitConverter.ToUInt64(sha, 0);
    }
}

Why Tag-Value Pairs Instead of Hierarchical Names

Prometheus and modern metrics systems use a flat metric name combined with a set of key-value tags (also called labels) instead of a hierarchical naming convention like us-east.web-01.cpu.usage. The tag-based model is vastly more flexible — it allows users to filter, group, and aggregate along any dimension without pre-defining a hierarchy. It also simplifies ingestion because there is no need to parse hierarchical paths. The trade-off is that the tag-based model can lead to cardinality explosion, which we address with dedicated controls.

Storage Layout

Time-series databases store data in a fundamentally different way than relational databases. Instead of organizing data row-by-row (one row per data point), TSDBs organize data column-by-column (one column per time series). Each time series is a contiguous sequence of data points ordered by time, stored in what is typically called a block or segment.

graph TB subgraph "TSDB Storage Layout" subgraph "Block 1 (2h window)" TS1["Time Series A - 720 points at 10s intervals"] TS2["Time Series B - 720 points"] TS3["Time Series C - 720 points"] end subgraph "Block 2 (2h window)" TS4["Time Series A - 720 points continued"] TS5["Time Series B - 720 points"] TS6["Time Series C - 720 points"] end subgraph "Index" IDX1["Series ID to name and tags mapping"] IDX2["Label inverted index for fast lookups"] end end

Series ID Design

The series ID is the most critical identifier in the system. It is a 64-bit unsigned integer deterministically derived from the metric name and sorted tag set. Using a compact numeric ID instead of the full string representation dramatically reduces storage overhead for every data point. A data point only needs to reference an 8-byte series ID instead of storing the full metric name and tags.

Design Decision: We use SHA-256 truncated to 64 bits for series ID generation. While collisions are theoretically possible (birthday paradox gives ~50% chance at 2^32 series), the probability of collision at 10 million series is negligible. If absolute guarantees are needed, a collision check against the series index at ingestion time provides a safety net.

Tag Constraints and Cardinality Management

Tags are both the greatest strength and the most dangerous feature of a metrics platform. Each unique combination of metric name and tag values creates a new time series. If a tag has high cardinality (e.g., request_id, user_id, trace_id), it can generate millions of series from a single metric. The system must enforce configurable cardinality limits:

  • Per-metric cardinality limit: Maximum number of unique series for a given metric name (e.g., 100,000).
  • Per-tag cardinality limit: Maximum number of unique values for a specific tag key (e.g., 10,000).
  • Global cardinality limit: Maximum total active series across all metrics (e.g., 10 million).
  • Tag key allowlist/blocklist: Prevent high-cardinality tags from being ingested at all.

Naming Conventions

A consistent metric naming convention is critical for usability at scale. The convention we adopt follows the OpenTelemetry semantic conventions: {namespace}.{noun}.{verb} or {namespace}.{noun}.{attribute}.{unit}. For example: http.server.request.duration, system.cpu.utilization, db.client.connection.pool.idle_count. Metric names should use dots as separators, lowercase with underscores, and include the unit as a suffix where applicable (e.g., _bytes, _seconds, _count).

Tag keys should follow the same convention: lowercase, snake_case, and semantically meaningful. Common standard tags include service, host, region, environment, and version. Custom tags should be registered through a schema governance process to prevent uncontrolled cardinality growth.

5. High-Level Architecture Overview

The architecture of a real-time analytics dashboard follows a classic pipeline pattern: data flows from sources through ingestion, is stored in a time-series database, and is served to consumers through a query layer and a real-time streaming layer. Let us examine each major subsystem and how they interconnect.

graph LR subgraph "Data Sources" A1["Application Agents (OpenTelemetry)"] A2["Infrastructure Agents (Node Exporter)"] A3["Custom SDKs (StatsD, CollectD)"] A4["Pull Targets (Prometheus Scrape)"] end subgraph "Ingestion Layer" B1["Load Balancer (L7 / gRPC)"] B2["Ingestion API (Validation and Dedup)"] B3["Kafka (Buffering and Partitioning)"] end subgraph "Processing Layer" C1["Stream Processor (Aggregation and Enrichment)"] C2["Rollup Worker (Downsampling)"] C3["Alert Evaluator (Rule Evaluation)"] end subgraph "Storage Layer" D1["TSDB (Time-Series Blocks)"] D2["Series Index (Inverted Index)"] D3["Object Storage (S3 or Blob)"] end subgraph "Serving Layer" E1["Query Engine (PromQL Parser)"] E2["WebSocket Hub (Real-time Push)"] E3["Dashboard API (CRUD and Auth)"] end subgraph "Clients" F1["Web Dashboard (React and D3.js)"] F2["Mobile App"] F3["API Consumers"] end A1 --> B1 A2 --> B1 A3 --> B1 A4 --> B1 B1 --> B2 --> B3 B3 --> C1 --> D1 C1 --> D2 B3 --> C2 --> D3 B3 --> C3 D1 --> E1 --> F1 D1 --> E2 --> F1 E3 --> F1 D1 --> E3

Subsystem Responsibilities

SubsystemResponsibilityKey Technology Choices
Ingestion APIValidate, deduplicate, and buffer incoming data pointsgRPC/HTTP, Protocol Buffers, Kafka
Stream ProcessorAggregate metrics in real-time, enrich with metadataApache Flink / Kafka Streams / custom C#
TSDBPersist time-series data with efficient compressionCustom TSDB (similar to Gorilla/VictoriaMetrics)
Query EngineParse PromQL-like queries, optimize, and executeCustom parser, vectorized execution engine
WebSocket HubMaintain persistent connections and push live dataSignalR / ASP.NET Core WebSockets
Alert EvaluatorContinuously evaluate alert rules and trigger notificationsC# background services, sliding window state
Object StorageDurable, cost-effective long-term block storageAWS S3 / Azure Blob / GCS

Data Flow: End to End

  1. Emission: An application agent collects a metric (e.g., http.request.duration = 142ms) and sends it to the nearest ingestion endpoint with tags like {host: "web-01", method: "GET", status: "200"}.
  2. Ingestion: The ingestion API validates the data point (schema check, cardinality limits), assigns a series ID, and writes it to Kafka for durability.
  3. Processing: The stream processor reads from Kafka, updates in-memory aggregation buffers for real-time dashboards, and writes raw data to the TSDB.
  4. Storage: The TSDB accumulates data points into blocks, compresses them using Gorilla-style encoding, and periodically uploads completed blocks to object storage.
  5. Serving: When a user opens a dashboard, the query engine reads from both the in-memory buffer (for recent data) and the TSDB (for historical data), executes the PromQL-like query, and returns the result.
  6. Real-time Push: The WebSocket hub subscribes to the stream processor for new data points matching the user's dashboard queries and pushes updates in real time.
Architecture Principle: Each subsystem is independently scalable. Ingestion, storage, and query can each be scaled horizontally without affecting the others. This separation is critical because the bottleneck shifts depending on usage patterns — a sudden deployment might spike ingestion, while a large investigation might spike query load.

Deployment Topology

The system is deployed across three availability zones for fault tolerance. Each zone runs a complete set of ingestion, processing, storage, and serving nodes. The Kafka cluster spans all three zones with a replication factor of three. Object storage (S3 or Azure Blob) provides cross-region durability. This deployment topology ensures that the loss of an entire availability zone does not cause data loss or significant degradation of service. Dashboard queries may see slightly higher latency during a zone failure (due to reduced read capacity), but ingestion and alerting continue without interruption.

6. Metric Ingestion Pipeline

The ingestion pipeline is the front door of the entire system. It must handle massive throughput, validate incoming data, handle protocol differences between various agent types, and deliver data to downstream storage with high durability and low latency. Designing this pipeline well is perhaps the single most impactful architectural decision in the system.

Ingestion API Design

C#
[ApiController]
[Route("api/v2")]
public class MetricsIngestionController : ControllerBase
{
    private readonly IMetricValidator _validator;
    private readonly ISeriesRegistry _seriesRegistry;
    private readonly IKafkaProducer<MetricDataPoint> _kafkaProducer;
    private readonly ICardinalityTracker _cardinalityTracker;
    private readonly ILogger<MetricsIngestionController> _logger;

    [HttpPost("ingest")]
    public async Task<IngestionResponse> Ingest(
        [FromBody] MetricBatch batch,
        CancellationToken ct)
    {
        var accepted = 0;
        var rejected = 0;
        var errors = new List<IngestionError>();

        foreach (var point in batch.DataPoints)
        {
            if (!_validator.IsValid(point))
            {
                rejected++;
                errors.Add(new IngestionError
                {
                    ErrorCode = "INVALID_SCHEMA",
                    Point = point
                });
                continue;
            }

            if (!_cardinalityTracker.CanAddSeries(
                    point.MetricName, point.Tags))
            {
                rejected++;
                errors.Add(new IngestionError
                {
                    ErrorCode = "CARDINALITY_LIMIT_EXCEEDED",
                    Point = point
                });
                _logger.LogWarning(
                    "Cardinality limit exceeded for metric {Name}",
                    point.MetricName);
                continue;
            }

            var seriesId = await _seriesRegistry
                .GetOrRegisterAsync(point.MetricName, point.Tags, ct);

            var kafkaPoint = point with { SeriesId = seriesId };
            await _kafkaProducer.ProduceAsync(
                topic: "metrics-raw",
                key: seriesId,
                value: kafkaPoint,
                ct: ct);

            accepted++;
        }

        return new IngestionResponse
        {
            Accepted = accepted,
            Rejected = rejected,
            Errors = errors.Count > 0 ? errors : null
        };
    }
}

public sealed record MetricBatch
{
    public string Source { get; init; }
    public string AgentVersion { get; init; }
    public IReadOnlyList<MetricDataPoint> DataPoints { get; init; }
}

Protocol Support

A robust metrics platform must support multiple ingestion protocols to accommodate different agent ecosystems:

ProtocolFormatUse CasePros and Cons
OpenTelemetry OTLP/gRPCProtobufModern observability agentsRich, typed, efficient. Industry standard.
OpenTelemetry OTLP/HTTPProtobuf or JSONBrowser-based, restricted networksFirewall-friendly. Higher overhead than gRPC.
StatsDText line protocolLegacy applicationsUbiquitous but limited (no tags in vanilla StatsD).
Prometheus Remote WriteProtobufPrometheus ecosystemWidely used. Batch-oriented.
Custom JSON PushJSONQuick integration, prototypingEasy to debug. Inefficient at scale.

Batching and Compression

Individual metric points are tiny (24 bytes), but sending millions of individual HTTP requests is inefficient. The ingestion pipeline must support batching — grouping multiple data points into a single request. Optimal batch sizes are typically 1,000-10,000 points per request, achieving a good balance between latency and throughput. Over the wire, Protocol Buffers with gzip compression typically achieve 10-15x compression on metric batches because the metric names and tag keys are highly repetitive across points in a batch.

Kafka Partitioning Strategy

Kafka serves as the durable buffer between ingestion and processing. The partitioning strategy is critical for downstream performance. We partition by series ID, which ensures that all data points for the same time series go to the same partition. This guarantees in-order processing within a series (essential for correct aggregation) and allows the stream processor to maintain per-series state without cross-partition coordination.

sequenceDiagram participant Agent as OTel Agent participant API as Ingestion API participant Registry as Series Registry participant Kafka as Kafka Broker participant Processor as Stream Processor Agent->>API: Batch(1000 points) API->>API: Validate schema API->>Registry: GetOrRegister(seriesName, tags) Registry-->>API: seriesId = 0x7F3A... API->>Kafka: Produce(key=seriesId, value=point) API-->>Agent: accepted: 1000, rejected: 0 loop Stream Processing Kafka->>Processor: Consume(partition) Processor->>Processor: Aggregate and Update buffers Processor->>Processor: Write to TSDB Processor->>Processor: Push to WebSocket hub end

Deduplication

Network retries, agent restarts, and load balancer retries can result in duplicate data points. The ingestion pipeline should handle deduplication at two levels: (1) idempotency keys at the API level for immediate dedup within a short window, and (2) at the TSDB level where duplicate points for the same series and timestamp can be collapsed (for counters, take the max; for gauges, take the latest). The Kafka partitioning by series ID aids deduplication because each partition consumer processes points for the same series sequentially.

Back-Pressure and Rate Limiting

When the downstream processing pipeline cannot keep up with ingestion, back-pressure must be applied gracefully. Rather than dropping data, the ingestion API implements a token-bucket rate limiter per tenant. When a tenant exceeds their allocated ingestion rate, excess requests receive a 429 Too Many Requests response with a Retry-After header. This signals the agent to buffer locally and retry, which is far preferable to silent data loss. The rate limiter is implemented at the load balancer level (using nginx or Envoy rate limiting filters) for consistency across all ingestion API replicas.

Operational Hazard: A misconfigured agent that generates a new tag set on every emission can create millions of unique series in seconds, overwhelming the ingestion pipeline and the TSDB. Implement per-agent rate limiting and cardinality alerts to catch this scenario within minutes, not hours.

7. Time-Series Database Design

The time-series database (TSDB) is the heart of the metrics platform. Unlike general-purpose relational databases, a TSDB is purpose-built for append-heavy workloads with sequential time-ordered data. The design principles behind modern TSDBs — including Gorilla (Facebook), Prometheus, and VictoriaMetrics — are fundamentally different from B-tree-based OLTP databases.

Key Design Principles

  • Append-Only: New data points are always appended; existing data is never updated in place. This simplifies concurrency control and enables sequential I/O.
  • Time-Ordered: Data within each series is strictly ordered by timestamp, enabling delta-of-delta compression.
  • Columnar: All values for a given series are stored contiguously, not interleaved with values from other series. This enables excellent compression within a series.
  • Block-Based: Data is accumulated in memory and periodically flushed to immutable blocks on disk/object storage. Blocks are the unit of compaction, query, and deletion.

Gorilla Compression

The Gorilla compression algorithm, introduced by Facebook in 2015, is the gold standard for compressing time-series data. It exploits two key patterns in metric data:

  1. Delta encoding for timestamps: Instead of storing absolute timestamps, store the delta from the previous timestamp. Since metrics are emitted at regular intervals, deltas are typically constant, and can be further compressed using delta-of-delta encoding.
  2. XOR encoding for values: Instead of storing absolute values, XOR the current value with the previous value. For slowly changing metrics (most metrics are), the XOR result has many leading and trailing zeros, which can be stored with a compact variable-length encoding.
C#
public sealed class GorillaEncoder
{
    private long _lastTimestamp;
    private double _lastValue;
    private bool _initialized;
    private int _prevLeadingZeros;
    private int _prevTrailingZeros;

    public void EncodeDataPoint(
        long timestampNanos,
        double value,
        BitBuffer output)
    {
        if (!_initialized)
        {
            output.WriteLong(timestampNanos, 64);
            output.WriteDouble(value, 64);
            _lastTimestamp = timestampNanos;
            _lastValue = value;
            _initialized = true;
            return;
        }

        long delta = timestampNanos - _lastTimestamp;
        long deltaDelta = delta - (_lastTimestamp - _lastTimestamp);

        if (deltaDelta == 0)
        {
            output.WriteBit(false);
        }
        else
        {
            output.WriteBit(true);
            output.WriteVarInt(ZigzagEncode(deltaDelta));
        }

        _lastTimestamp = timestampNanos;

        long currentBits = BitConverter.DoubleToInt64Bits(value);
        long lastBits = BitConverter.DoubleToInt64Bits(_lastValue);
        long xorResult = currentBits ^ lastBits;

        if (xorResult == 0)
        {
            output.WriteBit(false);
        }
        else
        {
            output.WriteBit(true);
            int leadingZeros = CountLeadingZeros(xorResult);
            int trailingZeros = CountTrailingZeros(xorResult);

            if (leadingZeros >= _prevLeadingZeros &&
                trailingZeros >= _prevTrailingZeros)
            {
                output.WriteBit(false);
                int significantBits = 64 - _prevLeadingZeros - _prevTrailingZeros;
                output.WriteLong(xorResult >> _prevTrailingZeros, significantBits);
            }
            else
            {
                output.WriteBit(true);
                output.WriteByte((byte)leadingZeros, 5);
                int significantBits = 64 - leadingZeros - trailingZeros;
                output.WriteLong((byte)(significantBits - 1), 6);
                output.WriteLong(xorResult >> trailingZeros, significantBits);
            }

            _prevLeadingZeros = leadingZeros;
            _prevTrailingZeros = trailingZeros;
        }

        _lastValue = value;
    }

    private static long ZigzagEncode(long n) => (n << 1) ^ (n >> 63);
    private static int CountLeadingZeros(long v) =>
        v == 0 ? 64 : System.Numerics.BitOperations.LeadingZeroCount((ulong)v);
    private static int CountTrailingZeros(long v) =>
        v == 0 ? 64 : System.Numerics.BitOperations.TrailingZeroCount((ulong)v);
}

Block Structure

Each TSDB block contains:

  • Data file: Compressed time-series data for all series in the block.
  • Index file: Mapping from series ID to offset within the data file; inverted index from tag key-value pairs to series IDs.
  • Meta file: Block metadata (time range, series count, creation timestamp).
  • Tombstone file: Marks for series deletions (used during compaction).

Blocks are immutable once flushed. New data is written to the current open block in memory. Every 2 hours (configurable), the open block is closed and flushed to disk. A compaction process periodically merges smaller blocks into larger ones and uploads completed blocks to object storage for durability and cost efficiency.

Block Lifecycle

stateDiagram-v2 [*] --> Ingesting: Data arrives Ingesting --> Flushing: 2h window closes Flushing --> OnDisk: Written to local disk OnDisk --> Compacting: Merged with older blocks Compacting --> OnDisk: Compaction complete OnDisk --> ObjectStorage: Uploaded to S3 or Blob ObjectStorage --> Cold: After 7 days Cold --> Deleted: Retention expires OnDisk --> Deleted: Retention expires

Write Path

C#
public sealed class TSDBWritePath
{
    private readonly ConcurrentDictionary<ulong, InMemorySeries> _activeSeries;
    private readonly ActiveBlock _activeBlock;
    private readonly IBlockStorage _blockStorage;
    private readonly TimeSpan _blockDuration = TimeSpan.FromHours(2);

    public async Task WriteAsync(MetricDataPoint point)
    {
        var series = _activeSeries.GetOrAdd(
            point.SeriesId,
            id => new InMemorySeries(id, point.MetricName, point.Tags));

        series.Append(point.TimestampNanos, point.Value);

        await _activeBlock.WriteToWalAsync(point);

        if (_activeBlock.ShouldFlush)
        {
            var blockToFlush = _activeBlock.Rotate();
            _ = Task.Run(() => FlushBlockAsync(blockToFlush));
        }
    }

    private async Task FlushBlockAsync(ImmutableBlock block)
    {
        var encodedData = GorillaEncoder.EncodeBlock(block.Series);
        var index = IndexBuilder.Build(block.Series);

        var blockId = Guid.NewGuid().ToString("N");
        await _blockStorage.WriteBlockAsync(blockId, encodedData, index);
        await _blockStorage.ScheduleUploadAsync(blockId);
    }
}
Write-Ahead Log (WAL): The WAL is essential for durability. Without it, data points buffered in memory would be lost on process crash. The WAL is typically written to fast local SSD and is replayed on startup. Once a block is successfully flushed to durable storage, the corresponding WAL entries can be truncated.

Read Path

The read path must efficiently locate and decompress data for a given series and time range. The process is: (1) Resolve the series ID from the inverted index. (2) Determine which blocks contain data for the requested time range using the block metadata. (3) For each relevant block, seek to the series' data offset using the index. (4) Decompress the data points sequentially using the Gorilla decoder. (5) Filter and return only the points within the requested time range. This read path is optimized for sequential I/O — once the initial seek is performed, all subsequent reads are sequential, which is ideal for both SSD and spinning disk.

8. Metric Aggregation & Rollups

Raw metric data at 10-second resolution is essential for real-time dashboards and fine-grained troubleshooting. However, querying 90 days of raw data for a long-term trend chart would require scanning billions of data points and returning millions to the client. Aggregation and rollups solve this by pre-computing summary statistics at coarser time resolutions.

The Rollup Concept

A rollup is a pre-computed aggregation of raw data over a fixed time window. For example, a 1-minute rollup of a gauge metric computes the min, max, average, and count for each 1-minute window. When a query requests data over a 7-day time range, the system can use the 1-minute rollup instead of raw 10-second data, reducing the data scanned by 6x and the points returned by 6x.

Rollup LevelWindowStats ComputedStorage Reduction
Raw10 secondsvalue1x (baseline)
Rollup 11 minutemin, max, avg, sum, count6x
Rollup 25 minutesmin, max, avg, sum, count30x
Rollup 31 hourmin, max, avg, sum, count360x
Rollup 41 daymin, max, avg, sum, count8640x

Rollup Worker Implementation

C#
public sealed class RollupWorker : BackgroundService
{
    private readonly IServiceProvider _services;
    private readonly ILogger<RollupWorker> _logger;
    private static readonly TimeSpan RollupInterval = TimeSpan.FromMinutes(5);

    protected override async Task ExecuteAsync(CancellationToken stoppingToken)
    {
        while (!stoppingToken.IsCancellationRequested)
        {
            var cutoff = DateTime.UtcNow - RollupInterval;

            try
            {
                await ProcessRollupTierAsync(
                    Tier.RawToMinute, cutoff, stoppingToken);
                await ProcessRollupTierAsync(
                    Tier.MinuteToHour, cutoff, stoppingToken);
                await ProcessRollupTierAsync(
                    Tier.HourToDay, cutoff, stoppingToken);

                var nextRun = RollupInterval -
                    (DateTime.UtcNow - cutoff) % RollupInterval;
                await Task.Delay(nextRun, stoppingToken);
            }
            catch (Exception ex)
            {
                _logger.LogError(ex, "Rollup processing failed");
                await Task.Delay(TimeSpan.FromSeconds(30), stoppingToken);
            }
        }
    }

    private async Task ProcessRollupTierAsync(
        Tier tier, DateTime cutoff, CancellationToken ct)
    {
        using var scope = _services.CreateScope();
        var tsdbReader = scope.ServiceProvider
            .GetRequiredService<ITSDBReader>();
        var tsdbWriter = scope.ServiceProvider
            .GetRequiredService<ITSDBWriter>();

        var series = await tsdbReader
            .GetActiveSeriesAsync(cutoff, cutoff + RollupInterval, ct);

        foreach (var seriesBatch in series.Batch(1000))
        {
            var tasks = seriesBatch.Select(async s =>
            {
                var rawPoints = await tsdbReader.ReadAsync(
                    s.SeriesId,
                    cutoff.ToUnixNanos(),
                    (cutoff + RollupInterval).ToUnixNanos(),
                    ct);

                var rollup = ComputeRollupStats(rawPoints);

                await tsdbWriter.WriteRollupAsync(
                    s.SeriesId,
                    tier.TargetGranularity,
                    rollup,
                    ct);
            });

            await Task.WhenAll(tasks);
        }
    }

    private static RollupStats ComputeRollupStats(
        IReadOnlyList<DataPoint> points)
    {
        if (points.Count == 0)
            return RollupStats.Empty;

        double min = double.MaxValue;
        double max = double.MinValue;
        double sum = 0;

        foreach (var p in points)
        {
            min = Math.Min(min, p.Value);
            max = Math.Max(max, p.Value);
            sum += p.Value;
        }

        return new RollupStats
        {
            Min = min,
            Max = max,
            Avg = sum / points.Count,
            Sum = sum,
            Count = points.Count
        };
    }
}

public enum Tier
{
    RawToMinute,
    MinuteToHour,
    HourToDay
}

public sealed record RollupStats
{
    public double Min { get; init; }
    public double Max { get; init; }
    public double Avg { get; init; }
    public double Sum { get; init; }
    public long Count { get; init; }
    public static readonly RollupStats Empty = new();
}

Query-Time Rollup Selection

The query engine must automatically select the most appropriate rollup tier based on the requested time range and the desired resolution. A smart heuristic is:

  • If the time range is less than or equal to 1 hour, use raw data (10-second resolution).
  • If the time range is less than or equal to 6 hours, use the 1-minute rollup.
  • If the time range is less than or equal to 3 days, use the 5-minute rollup.
  • If the time range is less than or equal to 30 days, use the 1-hour rollup.
  • If the time range is greater than 30 days, use the 1-day rollup.
Accuracy Trade-off: When using rollups, avg is computed as the average of averages (weighted by count), which is mathematically correct only if each sub-interval has the same number of points. If data is sparse or irregularly sampled, the avg from a rollup may differ slightly from the avg computed from raw data. This is an acceptable trade-off for dashboard use but must be documented.

Multi-Function Rollups

Not all aggregation functions can be computed from pre-computed rollups. Functions like median, percentile (p99), and standard deviation require either raw data or specialized pre-computation. For high-value percentiles (p50, p95, p99), we pre-compute t-digest or HDR histogram structures within each rollup window. These approximate data structures allow accurate percentile estimation from compact summaries, typically requiring only 100-200 bytes per rollup window regardless of the number of data points in the window.

9. Real-Time Streaming via WebSockets

Static dashboards that require manual refresh are unacceptable for modern operations tooling. Users expect charts to update automatically as new data arrives, providing a living view of their infrastructure. WebSockets provide the bidirectional, persistent connection needed for this real-time push capability.

WebSocket Hub Architecture

C#
public sealed class MetricsWebSocketHub : IDisposable
{
    private readonly ConcurrentDictionary<string, DashboardConnection>
        _connections = new();
    private readonly ISubscribeRouter _subscribeRouter;
    private readonly IQueryEngine _queryEngine;
    private readonly ILogger<MetricsWebSocketHub> _logger;

    public async Task HandleConnectionAsync(
        WebSocket socket,
        string dashboardId,
        string userId)
    {
        var connectionId = Guid.NewGuid().ToString("N");
        var connection = new DashboardConnection
        {
            ConnectionId = connectionId,
            DashboardId = dashboardId,
            UserId = userId,
            Socket = socket,
            Subscriptions = new List<MetricSubscription>()
        };

        _connections.TryAdd(connectionId, connection);

        try
        {
            var dashboard = await _queryEngine
                .GetDashboardConfigAsync(dashboardId);
            var initialData = await LoadDashboardDataAsync(dashboard);

            await SendAsync(socket, new DashboardMessage
            {
                Type = "initial_data",
                Data = initialData
            });

            foreach (var panel in dashboard.Panels)
            {
                var subscription = await _subscribeRouter
                    .SubscribeAsync(panel.Query, connectionId,
                        onData: async (seriesId, points) =>
                        {
                            await SendAsync(socket, new DashboardMessage
                            {
                                Type = "data_update",
                                PanelId = panel.Id,
                                SeriesId = seriesId,
                                DataPoints = points
                            });
                        });

                connection.Subscriptions.Add(subscription);
            }

            var buffer = new byte[4096];
            while (socket.State == WebSocketState.Open)
            {
                var result = await socket.ReceiveAsync(
                    new ArraySegment<byte>(buffer),
                    CancellationToken.None);

                if (result.MessageType == WebSocketMessageType.Close)
                    break;

                var message = JsonSerializer.Deserialize<
                    ClientMessage>(buffer.AsSpan(0, result.Count));
                await HandleClientMessageAsync(connection, message);
            }
        }
        finally
        {
            foreach (var sub in connection.Subscriptions)
            {
                await _subscribeRouter.UnsubscribeAsync(sub);
            }

            _connections.TryRemove(connectionId, out _);
            await socket.CloseAsync(
                WebSocketCloseStatus.NormalClosure,
                "",
                CancellationToken.None);
        }
    }

    private async Task SendAsync(
        WebSocket socket, DashboardMessage message)
    {
        var json = JsonSerializer.SerializeToUtf8Bytes(message);
        await socket.SendAsync(
            new ArraySegment<byte>(json),
            WebSocketMessageType.Text,
            true,
            CancellationToken.None);
    }
}

Subscription Management

The subscription router maps metric queries to connected clients. When a new data point arrives for a time series that matches an active subscription, the hub pushes the data to the relevant client(s). This is implemented efficiently using an inverted index:

C#
public sealed class SubscribeRouter : ISubscribeRouter
{
    private readonly ConcurrentDictionary<ulong, ConcurrentBag<
        SubscriptionEntry>> _seriesSubscriptions = new();

    private readonly ConcurrentDictionary<string,
        List<MetricSubscription>> _connectionSubscriptions = new();

    public async Task<MetricSubscription> SubscribeAsync(
        PromQLQuery query,
        string connectionId,
        Func<ulong, IReadOnlyList<DataPoint>, Task> onData)
    {
        var matchingSeriesIds = await _queryEngine
            .ResolveSeriesIdsAsync(query);

        var subscription = new MetricSubscription
        {
            SubscriptionId = Guid.NewGuid().ToString("N"),
            Query = query,
            ConnectionId = connectionId,
            MatchingSeriesIds = matchingSeriesIds
        };

        foreach (var seriesId in matchingSeriesIds)
        {
            var entry = new SubscriptionEntry
            {
                ConnectionId = connectionId,
                Callback = onData,
                Subscription = subscription
            };

            _seriesSubscriptions.AddOrUpdate(
                seriesId,
                _ => new ConcurrentBag<SubscriptionEntry> { entry },
                (_, bag) => { bag.Add(entry); return bag; });
        }

        return subscription;
    }

    public async Task NotifyNewDataAsync(
        ulong seriesId, IReadOnlyList<DataPoint> points)
    {
        if (_seriesSubscriptions.TryGetValue(seriesId, out var entries))
        {
            foreach (var entry in entries)
            {
                try
                {
                    await entry.Callback(seriesId, points);
                }
                catch (Exception)
                {
                    // Connection may have been closed; cleanup will handle it
                }
            }
        }
    }
}

Scaling WebSockets

WebSocket connections are stateful and long-lived, which makes horizontal scaling more complex than stateless HTTP APIs. The primary challenge is that a user connected to server A needs to receive data pushed for series that server B knows about. Common scaling strategies include:

  • Consistent hashing on series ID: Route subscription notifications to specific servers based on series ID hash. Each server is responsible for pushing data for its assigned series range.
  • Pub/Sub backbone: Use a message broker (Redis Pub/Sub, Kafka, NATS) to broadcast subscription notifications to all WebSocket servers. Each server filters notifications for its local connections.
  • Hybrid approach: Use consistent hashing for the pub/sub topic partitioning to reduce broadcast overhead while maintaining load balance.

Connection Lifecycle Management

WebSocket connections must handle network interruptions gracefully. The client implements exponential backoff reconnection starting at 1 second and capping at 30 seconds. On reconnection, the client sends a resume token containing the last received data point timestamp. The server uses this token to replay any missed data points, ensuring no gaps in the dashboard. This is critical for mobile users on unreliable connections.

Production Tip: In practice, the number of unique metric series visible on a single dashboard is small (typically 50-500). This means the fan-out from a new data point to WebSocket connections is bounded. The bottleneck is typically the write throughput to thousands of individual WebSocket connections, not the notification routing. Use batched writes and binary WebSocket frames to maximize throughput.

10. Dashboard Rendering Engine

The dashboard rendering engine is responsible for transforming query results into visual charts on the client side. While the server handles data retrieval and transformation, the client-side rendering engine must efficiently draw thousands of data points, support interactive features (zoom, pan, hover tooltips), and update smoothly as new data arrives via WebSockets.

Client-Side Architecture

graph TB subgraph "React Application" subgraph "Dashboard State" S1["Dashboard Config - panels, layout, time range"] S2["Query Cache - stale-while-revalidate"] S3["WebSocket State - connection, subscriptions"] end subgraph "Panel Renderer" R1["TimeSeriesChart - D3.js and Canvas"] R2["StatPanel - single value and sparkline"] R3["HeatmapPanel - color-mapped matrix"] R4["TablePanel - tabular data"] end subgraph "Data Layer" D1["Query Executor - HTTP and WebSocket"] D2["Data Transform - PromQL result processing"] end end S1 --> D1 D1 --> D2 D2 --> R1 D2 --> R2 D2 --> R3 D2 --> R4 S3 --> D1

Server-Side Query Result DTOs

C#
public sealed class TimeSeriesQueryResult
{
    public string QueryId { get; init; }
    public IReadOnlyList<SeriesResult> Series { get; init; }
    public long QueryTimeMs { get; init; }
    public QueryMetadata Metadata { get; init; }
}

public sealed class SeriesResult
{
    public ulong SeriesId { get; init; }
    public string DisplayName { get; init; }
    public IReadOnlyDictionary<string, string> Tags { get; init; }
    public IReadOnlyList<DataPoint> Points { get; init; }
    public string Aggregation { get; init; }
}

public sealed class DataPoint
{
    public long TimestampNanos { get; init; }
    public double Value { get; init; }
}

public sealed class DashboardConfiguration
{
    public string DashboardId { get; init; }
    public string Title { get; init; }
    public DashboardLayout Layout { get; init; }
    public IReadOnlyList<PanelConfig> Panels { get; init; }
    public TemplateVariables Variables { get; init; }
    public RefreshSettings Refresh { get; init; }
}

public sealed class PanelConfig
{
    public string PanelId { get; init; }
    public PanelType Type { get; init; }
    public string Title { get; init; }
    public PromQLQuery Query { get; init; }
    public VisualizationConfig Visualization { get; init; }
    public PanelPosition Position { get; init; }
}

public sealed class VisualizationConfig
{
    public ChartType ChartType { get; init; }
    public bool Stacked { get; init; }
    public bool FillOpacity { get; init; }
    public AxisConfig YAxis { get; init; }
    public AxisConfig XAxis { get; init; }
    public IReadOnlyList<ColorOverride> ColorOverrides { get; init; }
    public TooltipConfig Tooltip { get; init; }
    public AnnotationConfig Annotations { get; init; }
}

public enum PanelType
{
    TimeSeries,
    Stat,
    Gauge,
    Heatmap,
    Table,
    BarChart,
    PieChart,
    Text,
    Logs
}

Performance Optimization Strategies

  • Data point decimation: Before rendering, reduce the number of points to match the pixel width of the chart. A 1200px-wide chart cannot meaningfully display more than 1200 points. Use Largest Triangle Three Buckets (LTTB) algorithm for visually accurate decimation.
  • Off-screen rendering: Render charts to an off-screen canvas and only composite to the visible canvas on animation frames. This prevents visible flickering during rapid updates.
  • Virtual scrolling: For dashboards with many panels, only render panels visible in the viewport. Use IntersectionObserver to lazily load and render off-screen panels.
  • Query debouncing: When the user drags the time range slider or types a filter, debounce the query execution to avoid overwhelming the backend with intermediate queries.
  • Incremental updates via WebSocket: When a new data point arrives via WebSocket, only append it to the chart rather than re-querying and re-rendering the entire series.

Chart Type Selection Guide

Chart TypeBest ForAvoid When
Line ChartTrends over time, comparing multiple seriesMore than 20 series (visual clutter)
Area ChartStacked totals, resource utilizationNegative values, overlapping areas
Bar ChartDiscrete comparisons, top-N rankingsHigh-frequency time series data
HeatmapDistribution over time (latency histograms)Small number of buckets
Stat PanelSingle KPI, current value with trendShowing trends requires sparkline
GaugeProgress toward a threshold (e.g., disk usage %)Metrics without clear upper bounds
Rendering Budget: A dashboard with 20 panels must render in under 1 second. With ~50ms per panel for layout and ~20ms for canvas drawing, this leaves headroom for framework overhead. Profile the critical path carefully — the most common bottleneck is data transformation (converting server response to D3-friendly format), not the drawing itself.

11. Alerting System — Threshold & Anomaly Detection

Alerting is arguably the most operationally impactful feature of a metrics platform. A well-designed alerting system detects genuine issues quickly while avoiding false positives that erode engineering trust. The system must support two fundamentally different paradigms: threshold-based rules (human-defined conditions) and anomaly detection (machine-identified deviations from normal behavior).

Alert Rule Model

C#
public sealed class AlertRule
{
    public string RuleId { get; init; }
    public string Name { get; init; }
    public string Description { get; init; }
    public PromQLQuery Query { get; init; }
    public ThresholdCondition? Threshold { get; init; }
    public AnomalyCondition? Anomaly { get; init; }
    public TimeSpan EvaluationInterval { get; init; }
    public TimeSpan ForDuration { get; init; }
    public int Severity { get; init; }
    public NotificationPolicy NotificationPolicy { get; init; }
    public AlertState State { get; set; } = AlertState.Pending;
    public DateTime? StateChangedAt { get; set; }
    public int ConsecutiveEvaluations { get; set; }
}

public sealed class ThresholdCondition
{
    public ThresholdOperator Operator { get; init; }
    public double Value { get; init; }
    public AggregationType Aggregation { get; init; }
}

public enum ThresholdOperator
{
    GreaterThan,
    GreaterThanOrEqual,
    LessThan,
    LessThanOrEqual,
    Equal,
    NotEqual
}

public sealed class AnomalyCondition
{
    public AnomalyAlgorithm Algorithm { get; init; }
    public double Sensitivity { get; init; }
    public TimeSpan SeasonalityPeriod { get; init; }
    public int TrainingWindowDays { get; init; }
}

public enum AnomalyAlgorithm
{
    ZScore,
    MAD,
    Prophet,
    STL,
    EWMA
}

public enum AlertState
{
    Pending,
    Firing,
    Resolved,
    NoData
}

Alert Evaluation Engine

C#
public sealed class AlertEvaluator : BackgroundService
{
    private readonly IAlertRuleStore _ruleStore;
    private readonly IQueryEngine _queryEngine;
    private readonly IAlertStateManager _stateManager;
    private readonly INotificationDispatcher _notificationDispatcher;
    private readonly ILogger<AlertEvaluator> _logger;

    protected override async Task ExecuteAsync(
        CancellationToken stoppingToken)
    {
        var rules = await _ruleStore.GetAllRulesAsync();
        var tasks = rules.Select(rule =>
            EvaluateRuleLoopAsync(rule, stoppingToken));
        await Task.WhenAll(tasks);
    }

    private async Task EvaluateRuleLoopAsync(
        AlertRule rule, CancellationToken ct)
    {
        while (!ct.IsCancellationRequested)
        {
            try
            {
                var evaluation = await EvaluateRuleAsync(rule, ct);
                await ProcessEvaluationResultAsync(rule, evaluation, ct);
            }
            catch (Exception ex)
            {
                _logger.LogError(ex,
                    "Failed to evaluate rule {RuleId}", rule.RuleId);
            }
            await Task.Delay(rule.EvaluationInterval, ct);
        }
    }

    private async Task<RuleEvaluation> EvaluateRuleAsync(
        AlertRule rule, CancellationToken ct)
    {
        var timeRange = new TimeRange(
            start: DateTime.UtcNow - rule.ForDuration,
            end: DateTime.UtcNow);

        var queryResult = await _queryEngine.ExecuteAsync(
            rule.Query, timeRange, ct);

        if (queryResult.IsEmpty)
        {
            return new RuleEvaluation
            {
                Triggered = false,
                Reason = "NoData",
                Values = Array.Empty<double>()
            };
        }

        if (rule.Threshold != null)
        {
            return EvaluateThreshold(rule.Threshold, queryResult);
        }

        if (rule.Anomaly != null)
        {
            return await EvaluateAnomalyAsync(
                rule.Anomaly, queryResult, ct);
        }

        throw new InvalidOperationException(
            "Rule must have either Threshold or Anomaly condition");
    }

    private RuleEvaluation EvaluateThreshold(
        ThresholdCondition threshold, QueryResult result)
    {
        var aggregatedValue = threshold.Aggregation switch
        {
            AggregationType.Avg => result.Points.Average(p => p.Value),
            AggregationType.Max => result.Points.Max(p => p.Value),
            AggregationType.Min => result.Points.Min(p => p.Value),
            AggregationType.Sum => result.Points.Sum(p => p.Value),
            AggregationType.Last => result.Points.Last().Value,
            AggregationType.Count => result.Points.Count,
            _ => throw new ArgumentException(
                $"Unknown aggregation: {threshold.Aggregation}")
        };

        bool triggered = threshold.Operator switch
        {
            ThresholdOperator.GreaterThan =>
                aggregatedValue > threshold.Value,
            ThresholdOperator.GreaterThanOrEqual =>
                aggregatedValue >= threshold.Value,
            ThresholdOperator.LessThan =>
                aggregatedValue < threshold.Value,
            ThresholdOperator.LessThanOrEqual =>
                aggregatedValue <= threshold.Value,
            ThresholdOperator.Equal =>
                Math.Abs(aggregatedValue - threshold.Value) < 1e-9,
            ThresholdOperator.NotEqual =>
                Math.Abs(aggregatedValue - threshold.Value) >= 1e-9,
            _ => false
        };

        return new RuleEvaluation
        {
            Triggered = triggered,
            Reason = triggered ? "ThresholdExceeded" : "Normal",
            AggregatedValue = aggregatedValue,
            ThresholdValue = threshold.Value
        };
    }

    private async Task ProcessEvaluationResultAsync(
        AlertRule rule, RuleEvaluation evaluation, CancellationToken ct)
    {
        var currentState = await _stateManager
            .GetStateAsync(rule.RuleId);

        if (evaluation.Triggered)
        {
            rule.ConsecutiveEvaluations++;
            var requiredEvaluations = (int)(rule.ForDuration /
                rule.EvaluationInterval);

            if (rule.ConsecutiveEvaluations >= requiredEvaluations
                && currentState != AlertState.Firing)
            {
                rule.State = AlertState.Firing;
                rule.StateChangedAt = DateTime.UtcNow;
                await _stateManager.SetStateAsync(rule.RuleId, rule.State);
                await _notificationDispatcher
                    .DispatchAlertAsync(rule, evaluation, ct);
            }
        }
        else
        {
            if (currentState == AlertState.Firing)
            {
                rule.State = AlertState.Resolved;
                rule.StateChangedAt = DateTime.UtcNow;
                await _stateManager.SetStateAsync(rule.RuleId, rule.State);
                await _notificationDispatcher
                    .DispatchResolutionAsync(rule, evaluation, ct);
            }
            rule.ConsecutiveEvaluations = 0;
        }
    }
}

Anomaly Detection Algorithms

AlgorithmBest ForProsCons
Z-ScoreStable metrics with normal distributionSimple, fast, no training data neededSensitive to outliers, assumes normality
MADMetrics with occasional spikesRobust to outliers, non-parametricLess sensitive to subtle shifts
EWMATrending metricsAdapts to gradual changes, low memoryLags behind sudden changes
ProphetMetrics with daily or weekly seasonalityCaptures complex patterns, handles gapsHigher compute cost, over-engineered for simple cases

Notification Routing

Alert notifications must be routed to the appropriate channel based on severity, time of day, and recipient preferences. Critical alerts during business hours go to Slack and PagerDuty. Warning alerts go to Slack. Off-hours critical alerts page on-call engineers via PagerDuty with escalation policies. The notification dispatcher supports batching — if 10 alerts fire simultaneously for related services, they are grouped into a single notification rather than sending 10 separate messages.

Alert Fatigue: The number one failure mode in alerting systems is alert fatigue — engineers receive so many false or low-severity alerts that they start ignoring all alerts. Combat this by: (1) requiring a minimum forDuration of at least 2 minutes for all rules, (2) implementing alert grouping and deduplication, (3) tracking alert-to-action ratio (if less than 30% of alerts lead to an action, they need tuning), and (4) providing a mute feature with scheduled unmute.

12. Query Engine — A PromQL-like Language

The query language is the primary interface between users and the metrics data. A well-designed query language should be expressive enough for complex analysis, readable enough for operators under pressure, and implementable with good performance characteristics. We model ours after PromQL, the de facto standard in the metrics ecosystem.

Query Language Grammar

C#
public abstract class PromQLNode
{
    public SourceRange Location { get; init; }
}

public sealed class NumberLiteral : PromQLNode
{
    public double Value { get; init; }
}

public sealed class StringLiteral : PromQLNode
{
    public string Value { get; init; }
}

public sealed class MetricSelector : PromQLNode
{
    public string MetricName { get; init; }
    public IReadOnlyList<LabelMatcher> Matchers { get; init; }
    public OffsetExpression? Offset { get; init; }
    public TimeSpan? Range { get; init; }
}

public sealed class LabelMatcher
{
    public string LabelName { get; init; }
    public MatcherOp Op { get; init; }
    public string Value { get; init; }
}

public enum MatcherOp { Equal, NotEqual, RegexEqual, RegexNotEqual }

public sealed class BinaryOperation : PromQLNode
{
    public PromQLNode Left { get; init; }
    public PromQLNode Right { get; init; }
    public BinaryOp Operator { get; init; }
    public string? Grouping { get; init; }
    public IReadOnlyList<string>? Labels { get; init; }
}

public enum BinaryOp
{
    Add, Sub, Mul, Div, Mod, Pow,
    Eq, Neq, Gt, Gte, Lt, Lte,
    And, Or, Unless
}

public sealed class AggregationExpression : PromQLNode
{
    public AggregationOp Op { get; init; }
    public PromQLNode Operand { get; init; }
    public IReadOnlyList<string>? ByLabels { get; init; }
    public IReadOnlyList<string>? WithoutLabels { get; init; }
}

public enum AggregationOp
{
    Sum, Min, Max, Avg, Count, CountValues,
    Stddev, Stdvar, TopK, BottomK, Quantile
}

public sealed class FunctionCall : PromQLNode
{
    public string FunctionName { get; init; }
    public IReadOnlyList<PromQLNode> Arguments { get; init; }
}

// Example queries:
// http_request_total{method="GET"}[5m]
// rate(http_request_total{service="api"}[5m])
// histogram_quantile(0.99, rate(http_duration_bucket[5m]))
// avg by (host) (cpu_usage) > 0.9

Query Execution Pipeline

graph TB A["PromQL Source String"] --> B["Lexer - Tokenize"] B --> C["Parser - AST Construction"] C --> D["Analyzer - Type Checking and Optimization"] D --> E["Planner - Execution Plan"] E --> F["Executor - Vectorized Execution"] F --> G["Formatter - JSON, CSV, or binary"]
C#
public sealed class QueryEngine : IQueryEngine
{
    private readonly PromQLParser _parser;
    private readonly IQueryPlanner _planner;
    private readonly ITSDBReader _tsdbReader;
    private readonly IRollupSelector _rollupSelector;
    private readonly SeriesRegistry _seriesRegistry;

    public async Task<QueryResult> ExecuteAsync(
        PromQLQuery query,
        TimeRange timeRange,
        CancellationToken ct)
    {
        var ast = _parser.Parse(query.Expression);
        var resolvedAst = await ResolveSelectorsAsync(ast, ct);
        var rollupTier = _rollupSelector.SelectTier(timeRange);
        var plan = _planner.CreatePlan(resolvedAst, rollupTier);
        var result = await ExecutePlanAsync(plan, timeRange, ct);
        return result;
    }

    private async Task<PromQLNode> ResolveSelectorsAsync(
        PromQLNode node, CancellationToken ct)
    {
        return node switch
        {
            MetricSelector selector =>
                await ResolveMetricSelectorAsync(selector, ct),
            BinaryOperation binOp => new BinaryOperation
            {
                Left = await ResolveSelectorsAsync(binOp.Left, ct),
                Right = await ResolveSelectorsAsync(binOp.Right, ct),
                Operator = binOp.Operator,
                Grouping = binOp.Grouping,
                Labels = binOp.Labels
            },
            AggregationExpression agg => new AggregationExpression
            {
                Op = agg.Op,
                Operand = await ResolveSelectorsAsync(agg.Operand, ct),
                ByLabels = agg.ByLabels,
                WithoutLabels = agg.WithoutLabels
            },
            FunctionCall func => new FunctionCall
            {
                FunctionName = func.FunctionName,
                Arguments = await Task.WhenAll(
                    func.Arguments.Select(a =>
                        ResolveSelectorsAsync(a, ct)))
            },
            _ => node
        };
    }

    private async Task<PromQLNode> ResolveMetricSelectorAsync(
        MetricSelector selector, CancellationToken ct)
    {
        var seriesIds = await _seriesRegistry.FindSeriesAsync(
            selector.MetricName,
            selector.Matchers,
            ct);

        return new ResolvedSelector
        {
            SeriesIds = seriesIds,
            Range = selector.Range,
            Offset = selector.Offset
        };
    }
}

Query Optimization

The query planner applies several optimizations before execution:

  • Predicate pushdown: Move label matchers as close to the data source as possible to reduce the number of series scanned.
  • Common subexpression elimination: If two panels share the same base query, execute it once and share the result.
  • Rollup auto-selection: Automatically switch to pre-computed rollups for large time ranges.
  • Parallel execution: Fan out series reads across TSDB blocks in parallel.
  • Streaming aggregation: For sum, avg, and count, aggregate incrementally while reading data points rather than materializing the full result set first.

Built-in Functions

The query language includes a rich library of built-in functions for common operations. The most important categories are: (1) Rate functions: rate(), irate(), increase() for converting counters to per-second rates. (2) Aggregation over time: avg_over_time(), max_over_time(), min_over_time(), sum_over_time() for applying aggregations over a time window. (3) Statistical functions: stddev(), stdvar(), count_values() for statistical analysis. (4) Histogram functions: histogram_quantile() for computing quantiles from histogram buckets. (5) Anomaly extensions: anomaly_score(), predict_linear() for anomaly detection and trend prediction.

PromQL Compatibility: While we build our own engine, maintaining compatibility with PromQL syntax allows users to leverage existing knowledge and tooling. However, do not feel constrained by PromQL's limitations. Our extensions — like support for joins across different metric names and built-in anomaly detection functions — add significant value beyond stock PromQL.

13. Dashboard Sharing & Permissions

In any organization, dashboards are collaborative artifacts. Different teams need different views of the same data, and access control ensures that sensitive metrics are only visible to authorized personnel. The sharing and permissions system must balance ease of collaboration with security requirements.

Permission Model

C#
public sealed class DashboardPermissions
{
    public string DashboardId { get; init; }
    public AccessLevel PublicAccess { get; init; }
    public IReadOnlyList<TeamPermission> TeamPermissions { get; init; }
    public IReadOnlyList<UserPermission> UserPermissions { get; init; }
}

public enum AccessLevel
{
    Private,
    Team,
    Organization,
    Public
}

public sealed class UserPermission
{
    public string UserId { get; init; }
    public PermissionRole Role { get; init; }
}

public enum PermissionRole
{
    Viewer,
    Editor,
    Admin
}

public sealed class TeamPermission
{
    public string TeamId { get; init; }
    public PermissionRole Role { get; init; }
}

public sealed class DashboardAuthorizationHandler
    : AuthorizationHandler<DashboardRequirement, string>
{
    private readonly IDashboardStore _dashboardStore;
    private readonly IUserContext _userContext;

    protected override async Task HandleRequirementAsync(
        AuthorizationHandlerContext context,
        DashboardRequirement requirement,
        string dashboardId)
    {
        var dashboard = await _dashboardStore
            .GetDashboardAsync(dashboardId);

        if (dashboard == null)
        {
            context.Fail(new AuthorizationFailureReason(this,
                "Dashboard not found"));
            return;
        }

        var permissions = dashboard.Permissions;
        var userId = _userContext.UserId;
        var userTeams = _userContext.Teams;

        if (dashboard.OwnerId == userId)
        {
            context.Succeed(requirement);
            return;
        }

        if (permissions.PublicAccess == AccessLevel.Public &&
            requirement.Role == PermissionRole.Viewer)
        {
            context.Succeed(requirement);
            return;
        }

        if (permissions.PublicAccess == AccessLevel.Organization)
        {
            context.Succeed(requirement);
            return;
        }

        var userPerm = permissions.UserPermissions
            .FirstOrDefault(p => p.UserId == userId);
        if (userPerm != null && userPerm.Role >= requirement.Role)
        {
            context.Succeed(requirement);
            return;
        }

        var teamPerm = permissions.TeamPermissions
            .FirstOrDefault(p => userTeams.Contains(p.TeamId));
        if (teamPerm != null && teamPerm.Role >= requirement.Role)
        {
            context.Succeed(requirement);
            return;
        }

        context.Fail(new AuthorizationFailureReason(this,
            "Insufficient permissions"));
    }
}

Dashboard Versioning

C#
public sealed class DashboardVersion
{
    public string VersionId { get; init; }
    public string DashboardId { get; init; }
    public int VersionNumber { get; init; }
    public DashboardConfiguration Configuration { get; init; }
    public string ChangeDescription { get; init; }
    public string ChangedByUserId { get; init; }
    public DateTime ChangedAt { get; init; }
    public ChangeDiff? Diff { get; init; }
}

public sealed class DashboardStore : IDashboardStore
{
    private readonly IObjectStore _objectStore;

    public async Task<DashboardVersion> SaveVersionAsync(
        DashboardConfiguration config,
        string userId,
        string description)
    {
        var latestVersion = await GetLatestVersionAsync(
            config.DashboardId);
        var nextVersionNumber = latestVersion?.VersionNumber + 1 ?? 1;

        var version = new DashboardVersion
        {
            VersionId = Guid.NewGuid().ToString("N"),
            DashboardId = config.DashboardId,
            VersionNumber = nextVersionNumber,
            Configuration = config,
            ChangeDescription = description,
            ChangedByUserId = userId,
            ChangedAt = DateTime.UtcNow,
            Diff = latestVersion != null
                ? ComputeDiff(latestVersion.Configuration, config)
                : null
        };

        await _objectStore.WriteAsync(
            $"dashboards/{config.DashboardId}/versions/{nextVersionNumber}",
            version);

        await _objectStore.WriteAsync(
            $"dashboards/{config.DashboardId}/current",
            version);

        return version;
    }

    private ChangeDiff ComputeDiff(
        DashboardConfiguration oldConfig,
        DashboardConfiguration newConfig)
    {
        var diff = new ChangeDiff();
        var oldPanels = oldConfig.Panels.ToDictionary(p => p.PanelId);
        var newPanels = newConfig.Panels.ToDictionary(p => p.PanelId);

        foreach (var panelId in newPanels.Keys.Except(oldPanels.Keys))
            diff.AddedPanels.Add(panelId);

        foreach (var panelId in oldPanels.Keys.Except(newPanels.Keys))
            diff.RemovedPanels.Add(panelId);

        foreach (var panelId in oldPanels.Keys.Intersect(newPanels.Keys))
        {
            if (!JsonSerializer.Serialize(oldPanels[panelId])
                .Equals(JsonSerializer.Serialize(newPanels[panelId])))
                diff.ModifiedPanels.Add(panelId);
        }

        return diff;
    }
}

Dashboard Templates

Dashboard templates encode institutional knowledge about what metrics matter for a given service type. Instead of starting from scratch, teams clone a template and customize it. Common templates include: Web API Dashboard (request rate, error rate, latency percentiles, saturation), Database Dashboard (connections, query latency, replication lag, cache hit rate), Kubernetes Dashboard (pod counts, CPU/memory utilization, restart counts, network I/O), and Infrastructure Dashboard (host CPU, memory, disk, network, load average). Templates are version-controlled and maintained by the platform team, with community contributions accepted through a review process.

Collaboration Best Practice: Support dashboard templates — pre-configured dashboards that teams can clone and customize. Templates encode institutional knowledge about what metrics matter for a given service type (e.g., Web API Dashboard Template, Kafka Cluster Dashboard Template). This reduces the time from I need monitoring to I have a working dashboard from hours to minutes.

14. Data Retention & Downsampling

Storing every data point forever is neither economically feasible nor practically useful. A 10-second resolution metric that was critical for debugging yesterday is rarely queried at that resolution six months from now. Data retention and downsampling policies control the lifecycle of metric data, balancing storage cost against query utility.

Retention Policy Model

C#
public sealed class RetentionPolicy
{
    public string PolicyId { get; init; }
    public string Name { get; init; }
    public string Description { get; init; }
    public MetricSelector MetricSelector { get; init; }
    public IReadOnlyList<RetentionTier> Tiers { get; init; }
}

public sealed class RetentionTier
{
    public string TierName { get; init; }
    public TimeSpan MaxRetention { get; init; }
    public TimeSpan Resolution { get; init; }
    public bool DownsampleEnabled { get; init; }
    public RollupStats StatsComputed { get; init; }
}

public sealed class DefaultRetentionPolicies
{
    public static IReadOnlyList<RetentionPolicy> GetDefault()
    {
        return new[]
        {
            new RetentionPolicy
            {
                PolicyId = "default",
                Name = "Standard Retention",
                MetricSelector = new MetricSelector
                {
                    MetricName = ".*",
                    Matchers = Array.Empty<LabelMatcher>()
                },
                Tiers = new[]
                {
                    new RetentionTier
                    {
                        TierName = "raw",
                        MaxRetention = TimeSpan.FromDays(90),
                        Resolution = TimeSpan.FromSeconds(10),
                        DownsampleEnabled = false
                    },
                    new RetentionTier
                    {
                        TierName = "1min",
                        MaxRetention = TimeSpan.FromDays(365),
                        Resolution = TimeSpan.FromMinutes(1),
                        DownsampleEnabled = true,
                        StatsComputed = RollupStats.All
                    },
                    new RetentionTier
                    {
                        TierName = "1hour",
                        MaxRetention = TimeSpan.FromDays(365 * 5),
                        Resolution = TimeSpan.FromHours(1),
                        DownsampleEnabled = true,
                        StatsComputed = RollupStats.All
                    }
                }
            },
            new RetentionPolicy
            {
                PolicyId = "critical-infrastructure",
                Name = "Extended Retention for Critical Metrics",
                MetricSelector = new MetricSelector
                {
                    MetricName = "host\\\\..*|container\\\\..*",
                    Matchers = new[]
                    {
                        new LabelMatcher
                        {
                            LabelName = "tier",
                            Op = MatcherOp.Equal,
                            Value = "critical"
                        }
                    }
                },
                Tiers = new[]
                {
                    new RetentionTier
                    {
                        TierName = "raw",
                        MaxRetention = TimeSpan.FromDays(365),
                        Resolution = TimeSpan.FromSeconds(10),
                        DownsampleEnabled = false
                    },
                    new RetentionTier
                    {
                        TierName = "1min",
                        MaxRetention = TimeSpan.FromDays(365 * 2),
                        Resolution = TimeSpan.FromMinutes(1),
                        DownsampleEnabled = true
                    }
                }
            }
        };
    }
}

Downsampling Implementation

C#
public sealed class DownsamplingWorker : BackgroundService
{
    private readonly ITSDBReader _tsdbReader;
    private readonly ITSDBWriter _tsdbWriter;
    private readonly IRetentionPolicyStore _policyStore;
    private readonly IBlockCompactor _blockCompactor;
    private readonly ILogger<DownsamplingWorker> _logger;

    protected override async Task ExecuteAsync(CancellationToken ct)
    {
        while (!ct.IsCancellationRequested)
        {
            var policies = await _policyStore.GetAllPoliciesAsync();

            foreach (var policy in policies)
            {
                foreach (var tier in policy.Tiers
                    .Where(t => t.DownsampleEnabled))
                {
                    await DownsampleTierAsync(policy, tier, ct);
                }
            }

            await Task.Delay(TimeSpan.FromMinutes(10), ct);
        }
    }

    private async Task DownsampleTierAsync(
        RetentionPolicy policy,
        RetentionTier targetTier,
        CancellationToken ct)
    {
        var sourceTierMaxAge = policy.Tiers
            .First(t => !t.DownsampleEnabled).MaxRetention;

        var cutoff = DateTime.UtcNow - sourceTierMaxAge;
        var windowSize = targetTier.Resolution;

        var seriesToProcess = await _tsdbReader
            .GetSeriesMatchingAsync(policy.MetricSelector, ct);

        foreach (var seriesBatch in seriesToProcess.Batch(500))
        {
            var tasks = seriesBatch.Select(async seriesId =>
            {
                var rawData = await _tsdbReader.ReadRawAsync(
                    seriesId,
                    cutoff - windowSize,
                    cutoff,
                    ct);

                var windows = rawData
                    .GroupBy(p =>
                        p.TimestampNanos.AlignToWindow(windowSize))
                    .Select(g => new DownsampledWindow
                    {
                        StartNanos = g.Key,
                        Stats = ComputeStats(g.ToList())
                    });

                foreach (var window in windows)
                {
                    await _tsdbWriter.WriteDownsampledAsync(
                        seriesId,
                        targetTier.TierName,
                        window,
                        ct);
                }
            });

            await Task.WhenAll(tasks);
        }
    }
}

Storage Lifecycle

graph LR A["Fresh Data (0-90 days)"] -->|"Downsample"| B["1-minute Rollup (90-365 days)"] B -->|"Downsample"| C["1-hour Rollup (365-1825 days)"] C -->|"Delete"| D["Archived or Deleted"] A -->|"Delete after 90 days"| D style A fill:#3fb950,color:#0d1117 style B fill:#58a6ff,color:#0d1117 style C fill:#d29922,color:#0d1117 style D fill:#f85149,color:#0d1117
Query Accuracy: When querying data that spans multiple retention tiers, the query engine must seamlessly merge results from different rollup levels. For example, a 7-day query might use raw data for the first day (if within 90-day raw retention) and 1-minute rollups for the remaining 6 days. The engine must handle this transparently, adjusting aggregation logic for the different resolutions.

15. Multi-Tenancy Architecture

A metrics platform serving multiple organizations, teams, or business units must enforce strict isolation while maximizing resource utilization through shared infrastructure. Multi-tenancy at the metrics layer is particularly challenging because of the shared-nothing vs. shared-everything spectrum and the unique performance characteristics of time-series workloads.

Multi-Tenancy Strategies

StrategyIsolation LevelResource EfficiencyOperational ComplexityBest For
Separate ClusterCompleteLowHighEnterprise tenants with strict compliance
Separate NamespaceHighMediumMediumSaaS with tiered plans
Shared Cluster + Tenant PrefixMediumHighLowInternal multi-team platforms
Shared Everything + RBACLow (logical)Very HighVery LowSmall teams, prototyping

Implementation: Namespace-Based Isolation

C#
public sealed class TenantContext
{
    public string TenantId { get; init; }
    public string OrganizationId { get; init; }
    public TenantTier Tier { get; init; }
    public TenantLimits Limits { get; init; }
}

public sealed class TenantLimits
{
    public int MaxActiveSeries { get; init; }
    public int MaxIngestionRatePerSecond { get; init; }
    public int MaxQueryConcurrency { get; init; }
    public TimeSpan MaxQueryTimeRange { get; init; }
    public int MaxDashboards { get; init; }
    public int MaxAlertRules { get; init; }
    public RetentionPolicy RetentionPolicy { get; init; }
}

public sealed class TenantScopedTSDBReader : ITSDBReader
{
    private readonly ITSDBReader _innerReader;
    private readonly TenantContext _tenant;

    public TenantScopedTSDBReader(
        ITSDBReader innerReader, TenantContext tenant)
    {
        _innerReader = innerReader;
        _tenant = tenant;
    }

    public async Task<IReadOnlyList<DataPoint>> ReadAsync(
        ulong seriesId,
        long startNanos,
        long endNanos,
        CancellationToken ct)
    {
        if (!await IsSeriesOwnedByTenantAsync(
                seriesId, _tenant.TenantId))
        {
            throw new UnauthorizedAccessException(
                $"Series {seriesId} does not belong to tenant {_tenant.TenantId}");
        }

        var requestedRange = TimeSpan.FromNanos(endNanos - startNanos);
        if (requestedRange > _tenant.Limits.MaxQueryTimeRange)
        {
            throw new QueryLimitExceededException(
                $"Query time range {requestedRange} exceeds limit");
        }

        return await _innerReader.ReadAsync(
            seriesId, startNanos, endNanos, ct);
    }

    private Task<bool> IsSeriesOwnedByTenantAsync(
        ulong seriesId, string tenantId)
    {
        var tenantHash = ComputeTenantHash(tenantId);
        var seriesTenantHash = (uint)(seriesId >> 32);
        return Task.FromResult(seriesTenantHash == seriesTenantHash);
    }

    private static uint ComputeTenantHash(string tenantId)
    {
        var bytes = System.Text.Encoding.UTF8.GetBytes(tenantId);
        var hash = System.Security.Cryptography.SHA256.HashData(bytes);
        return BitConverter.ToUInt32(hash, 0);
    }
}

Resource Quota Enforcement

Each tenant must be subject to resource quotas to prevent one tenant from starving others. The key quota dimensions are:

  • Ingestion rate: Maximum data points per second per tenant, enforced at the ingestion API with token bucket rate limiting.
  • Cardinality: Maximum number of active time series per tenant, enforced by the cardinality tracker.
  • Query concurrency: Maximum concurrent queries per tenant, enforced by a per-tenant semaphore.
  • Query complexity: Maximum number of series scanned per query, enforced by the query engine cost estimator.
  • Storage: Maximum total storage per tenant, enforced by the retention policy and enforced at the block compaction stage.
Noisy Neighbor: The most common multi-tenancy failure is the noisy neighbor problem — one tenant generates an unusual burst of ingestion or query traffic that degrades performance for all other tenants. Mitigation requires per-tenant rate limiting at the load balancer level and proactive monitoring of per-tenant resource consumption.

16. Reliability, Failure Modes & Resilience

A metrics platform that goes down during an incident is worse than useless — it gives engineers a false sense of security. Reliability engineering for a metrics system requires understanding every failure mode and designing appropriate mitigations.

Failure Mode Analysis

FailureImpactDetectionMitigationRecovery
Ingestion API crashStop accepting new dataHealth check failuresMultiple replicas behind LBRestart pods, replay WAL
Kafka broker failureBuffering stopsKafka lag alerts3+ replicas, replication factor 3Automatic leader election
TSDB node failureCannot read or writeBlock flush failuresReplicated blocks, object storageRebuild from object storage
Object storage outageCannot read historicalS3 API errorsLocal disk cache, multi-regionServe from cache
WebSocket hub crashReal-time updates stopDisconnection spikeMultiple hub instancesClients reconnect
Query engine overloadDashboard slow or timeoutLatency spikesQuery timeout, admission controlScale replicas, shed queries
Alert evaluator failureAlerts stop firingEvaluation gap detectionLeader electionFailover to standby

Circuit Breaker Pattern

C#
public sealed class CircuitBreaker
{
    private readonly TimeSpan _openDuration;
    private readonly int _failureThreshold;
    private readonly object _lock = new();

    private CircuitState _state = CircuitState.Closed;
    private int _failureCount;
    private DateTime _openedAt;

    public CircuitState State
    {
        get
        {
            lock (_lock)
            {
                if (_state == CircuitState.Open &&
                    DateTime.UtcNow - _openedAt > _openDuration)
                {
                    _state = CircuitState.HalfOpen;
                }
                return _state;
            }
        }
    }

    public CircuitBreaker(
        int failureThreshold = 5,
        TimeSpan? openDuration = null)
    {
        _failureThreshold = failureThreshold;
        _openDuration = openDuration ?? TimeSpan.FromSeconds(30);
    }

    public async Task<T> ExecuteAsync<T>(
        Func<Task<T>> action,
        Func<Task<T>> fallback)
    {
        if (State == CircuitState.Open)
        {
            return await fallback();
        }

        try
        {
            var result = await action();
            OnSuccess();
            return result;
        }
        catch (Exception)
        {
            OnFailure();
            return await fallback();
        }
    }

    private void OnSuccess()
    {
        lock (_lock)
        {
            _failureCount = 0;
            _state = CircuitState.Closed;
        }
    }

    private void OnFailure()
    {
        lock (_lock)
        {
            _failureCount++;
            if (_failureCount >= _failureThreshold)
            {
                _state = CircuitState.Open;
                _openedAt = DateTime.UtcNow;
            }
        }
    }
}

public enum CircuitState { Closed, Open, HalfOpen }

Graceful Degradation

The system should degrade gracefully rather than fail completely:

  • If the query engine is overloaded: Return cached or semi-stale results rather than timing out. A dashboard showing data that is 30 seconds old is far better than one showing an error.
  • If object storage is unavailable: Serve data from local block cache. Only data in the most recent 2-4 hours may be available, but this covers immediate incident response needs.
  • If the WebSocket hub is down: Fall back to HTTP polling at 10-second intervals. Users see slightly delayed updates but the dashboard remains functional.
  • If the alert evaluator is down: A secondary evaluator detects the gap and takes over. All alert rules include a staleness check that fires a separate AlertEvaluatorDown alert if evaluations stop.

Health Check Design

Every component exposes two health check endpoints: a liveness check (is the process alive and responding?) and a readiness check (is the component ready to accept traffic?). The liveness check is simple — respond with 200 OK if the process is running. The readiness check verifies actual functionality: the ingestion API checks that Kafka is reachable, the query engine checks that at least one TSDB node is available, and the WebSocket hub checks that the subscription router is operational. Kubernetes uses the liveness check to restart unhealthy pods and the readiness check to remove them from the load balancer.

Redundancy Checklist: Every component in the critical path (ingestion API, Kafka, TSDB, query engine, WebSocket hub) must have at least 2 active replicas across at least 2 availability zones. The alert evaluator must have a standby with automatic failover. The series registry must be replicated synchronously to prevent duplicate series registration.

17. Cost Estimation & Resource Planning

Building and operating a metrics platform at scale requires significant infrastructure investment. Understanding the cost breakdown helps architects make informed trade-offs and justify budgets to stakeholders. Let us estimate costs for the 2M points/second scenario from Section 3.

Infrastructure Cost Breakdown

ComponentInstance TypeCountMonthly Cost per UnitMonthly Total
Ingestion API8 vCPU, 16 GB RAM8$400$3,200
Kafka Brokers8 vCPU, 32 GB RAM, 2 TB NVMe6$800$4,800
Stream Processors16 vCPU, 64 GB RAM4$1,000$4,000
TSDB Nodes (Write)16 vCPU, 64 GB RAM, 4 TB NVMe8$1,200$9,600
TSDB Nodes (Read)16 vCPU, 64 GB RAM, 2 TB NVMe8$1,000$8,000
Query Engine8 vCPU, 32 GB RAM16$500$8,000
WebSocket Hubs8 vCPU, 16 GB RAM8$400$3,200
Alert Evaluators4 vCPU, 16 GB RAM4$250$1,000
Object Storage (S3)~80 TB stored$0.023/GB$1,840
Database (Metadata)4 vCPU, 16 GB RAM3$300$900
Load BalancersApplication LBs$500
NetworkingCross-AZ and Egress$2,000
Total Monthly Infrastructure~$47,040

Cost per Metric Point

Monthly ingestion volume: 2M points/sec x 86,400 sec/day x 30 days = 5.184 trillion points/month

Cost per million points: $47,040 / 5,184,000 = ~$0.009 per million points

Cost per host per month: $47,040 / 100,000 hosts = ~$0.47/host/month

Cost Comparison: Commercial SaaS metrics platforms typically charge $15-$23 per host per month for infrastructure monitoring. Building in-house at ~$0.47/host/month represents a 95-98% cost reduction, but requires a dedicated platform engineering team of 8-12 engineers (costing $2-4M annually). The break-even point is typically at 5,000-10,000 hosts, above which in-house becomes significantly cheaper.

Optimization Levers

  • Compression tuning: Improving Gorilla compression ratio from 8x to 12x saves $1,200/month in storage and reduces I/O costs proportionally.
  • Aggressive downsampling: Reducing raw retention from 90 to 30 days saves ~$15,500/month in storage.
  • Spot instances for query nodes: Query nodes are stateless and can use spot/preemptible instances, reducing query compute costs by 60-70%.
  • Reserved instances: 1-year reserved instances for steady-state workloads (Kafka, TSDB) save 30-40% vs on-demand pricing.
  • Intelligent tiering: Move cold TSDB blocks to infrequent access storage after 7 days, reducing storage costs by 50%.

Total Cost of Ownership

Infrastructure is only part of the total cost. The fully loaded cost of building and operating an in-house metrics platform includes: infrastructure (~$47K/month), platform engineering team (8-12 engineers at ~$250K fully loaded = $167K-$250K/month), on-call and operational overhead (~$10K/month in incident response time), and tooling and testing (~$5K/month). The total monthly cost is approximately $229K-$312K, or roughly $2.7M-$3.7M annually. This investment is justified for organizations with 10,000+ hosts or strict data sovereignty requirements. For smaller deployments, a hybrid approach — using open-source Prometheus with Grafana for most workloads and a commercial SaaS for specialized needs — provides the best cost-effectiveness.

18. Interview Q&A — Top Questions & Answers

The following are the most commonly asked interview questions about designing a metrics and analytics dashboard, along with structured answers that demonstrate senior-level thinking.

Q1: How do you handle high cardinality in a metrics platform?

Answer: High cardinality — the explosion of unique time series from high-cardinality tags like user_id or request_id — is the number one operational risk in a metrics platform. We address it at multiple layers: (1) Ingestion-time cardinality limits per metric and per tag, enforced with probabilistic counting (HyperLogLog) for memory efficiency. (2) Metric naming conventions enforced via schema validation — blocking tags with cardinality above a configurable threshold. (3) Continuous cardinality monitoring dashboards that alert when any metric approaches its cardinality limit. (4) At the storage layer, series that have not received data for 7 days are evicted from the active series index, freeing memory and reducing the scan space for queries. The key insight is that cardinality management is a socio-technical problem — it requires both technical controls and engineering education.

Q2: How would you design the alerting system to avoid alert fatigue?

Answer: Alert fatigue is the gradual erosion of trust in an alerting system due to excessive false or low-value alerts. Our design combats it through: (1) Mandatory forDuration on all rules — conditions must be true for at least 2-5 minutes before firing, eliminating transient spikes. (2) Alert grouping and deduplication — related alerts are batched into a single notification. (3) Multi-tier severity (critical, warning, info) with different notification channels (PagerDuty for critical, Slack for warning, email for info). (4) A feedback loop — every alert is tracked for whether it led to an actionable response. Rules with less than 30% action rate are flagged for review. (5) Silence windows during planned maintenance. (6) A monthly alert hygiene review process where the on-call team reviews and tunes the least-valuable alerts.

Q3: Why use Kafka instead of directly writing to the TSDB?

Answer: Kafka serves as a durable buffer that decouples ingestion from storage, providing three critical benefits: (1) Durability — if the TSDB goes down for maintenance or crashes, data is preserved in Kafka and replayed on recovery, preventing data loss. (2) Back-pressure absorption — during traffic spikes (e.g., a deployment causing metric storms), Kafka absorbs the surge while the TSDB processes at its own pace, preventing cascading failures. (3) Fan-out — a single ingestion stream can feed multiple consumers (TSDB writer, real-time aggregator, alert evaluator, rollup worker) without duplicating the ingestion pipeline. The trade-off is added operational complexity and a small increase in end-to-end latency (~1-2 seconds), which is well within our 5-second freshness budget.

Q4: How do you query data efficiently across different retention tiers?

Answer: When a query spans multiple retention tiers, the query engine: (1) Decomposes the time range into segments that map to specific rollup tiers. (2) Executes sub-queries against each tier in parallel. (3) Merges the results, accounting for the different aggregation granularities. For metrics like sum, this is straightforward — sum of sums across intervals is the total sum. For avg, we compute weighted averages using the count from each interval. For min/max, we take the min/max across intervals. The key challenge is handling the boundary between tiers — the engine ensures no gaps or overlaps at tier boundaries.

Q5: How would you scale the system to 10x the current throughput?

Answer: Scaling to 20M points/second requires addressing bottlenecks at each layer: (1) Ingestion: Add more API replicas and Kafka partitions. (2) Storage: TSDB write nodes need to handle 10x more block flushes. We shard the TSDB by series ID range, so adding nodes linearly increases write throughput. (3) Query: The most impactful change is increasing rollup coverage — at 10x scale, we add a 5-minute rollup tier. (4) Memory: The series index grows with cardinality. At 100M active series, we need a distributed index backed by a key-value store like RocksDB with remote state. (5) Cost: At 10x scale, the cost advantage of in-house vs. SaaS grows even further.

Q6: How do you handle time synchronization issues across distributed agents?

Answer: Clock skew between agents can cause data points to appear out of order or with incorrect timestamps. Our approach: (1) NTP enforcement — all agents synchronize with at least 3 NTP servers, and the agent reports clock offset in its heartbeat. (2) Server-side reordering — the ingestion API sorts incoming batches by timestamp and handles minor out-of-order points within a configurable tolerance window (default 30 seconds). (3) For severe clock skew, the ingestion API clamps the timestamp to the current server time minus a grace period. (4) The TSDB handles late-arriving data by inserting it into the correct position within the current open block, or dropping it if the block has already been flushed.

Q7: How would you implement dashboard variables and template queries?

Answer: Dashboard variables allow users to dynamically filter and drill down into data. Implementation: (1) Variables are defined in the dashboard configuration with a query that populates their options (e.g., label_values(host.cpu.usage, host) returns all host names). (2) The frontend renders variable selectors and injects selected values into panel queries using string interpolation. (3) Variable dependencies are resolved in topological order — if variable B depends on variable A, A is evaluated first. (4) The backend caches variable query results with a 30-second TTL. (5) Variables support custom value options for cases where label_values is insufficient.

19. Performance Engineering & Optimization

Performance is not an afterthought — it must be designed into every component from the beginning. In a metrics platform, the performance-critical paths are ingestion throughput, query latency, and dashboard rendering time.

Ingestion Performance

  • Batching: Group 1,000-10,000 points per HTTP request. This reduces per-request overhead (TLS handshake, HTTP headers) and enables amortized serialization cost.
  • Zero-copy deserialization: Use Span and Memory to deserialize Protocol Buffer messages without allocating intermediate byte arrays.
  • Object pooling: Reuse MetricDataPoint objects using ArrayPool and custom object pools to minimize GC pressure at high throughput.
  • Kafka batching: Configure the Kafka producer with linger.ms=10 and batch.size=65536 to accumulate points into larger Kafka batches.
C#
public sealed class PooledMetricBatchDeserializer
{
    private readonly ObjectPool<MetricBatch> _batchPool;

    public PooledMetricBatchDeserializer()
    {
        var provider = new DefaultObjectPoolProvider();
        _batchPool = provider.Create(new MetricBatchPolicy());
    }

    public MetricBatch Deserialize(ReadOnlySpan<byte> data)
    {
        var tempBuffer = ArrayPool<byte>.Shared.Rent(data.Length);
        try
        {
            data.CopyTo(tempBuffer);
            var batch = _batchPool.Get();
            ProtobufDeserializer.Deserialize(
                tempBuffer.AsSpan(0, data.Length), batch);
            return batch;
        }
        finally
        {
            ArrayPool<byte>.Shared.Return(tempBuffer);
        }
    }

    public void Return(MetricBatch batch)
    {
        batch.DataPoints.Clear();
        _batchPool.Return(batch);
    }
}

public sealed class MetricBatchPolicy : PooledObjectPolicy<MetricBatch>
{
    public override MetricBatch Create() => new()
    {
        DataPoints = new List<MetricDataPoint>(10_000)
    };

    public override bool Return(MetricBatch obj)
    {
        obj.DataPoints.Clear();
        return true;
    }
}

Query Performance

  • Series ID index: The inverted index mapping (metric name + tags to series ID) is kept entirely in memory using a concurrent hash map. Lookups are O(1) and take microseconds.
  • Block-level bloom filters: Each TSDB block has a bloom filter for its series IDs. Before scanning a block, check the bloom filter to skip blocks that definitely do not contain relevant data.
  • Parallel block scanning: Query execution fans out across all relevant blocks in parallel. With 8 read nodes and 4 CPU cores each, we can scan 32 blocks simultaneously.
  • Result caching: Cache query results with a 10-second TTL. Dashboard panels that refresh every 10 seconds hit the cache on the second render, achieving sub-10ms response times.
  • Predicate pushdown: Push label matchers into the block scanner to filter series before loading data points.

Client-Side Performance

  • Data decimation: Use LTTB (Largest Triangle Three Buckets) algorithm to reduce 10,000 points to 1,200 for display while preserving visual fidelity.
  • Web Worker offloading: Move data transformation (parsing server responses, computing derivatives, applying functions) to a Web Worker to keep the main thread free for rendering.
  • Canvas rendering: Use 2D Canvas API for chart drawing instead of SVG. Canvas handles thousands of points efficiently because it operates at the pixel level.
  • Intersection Observer: Only render panels that are currently visible in the viewport. Off-screen panels use placeholder elements that render on scroll.
  • Service Worker caching: Cache static dashboard assets and recent query results in a Service Worker for offline access and instant page loads.

Performance Benchmarks

OperationTargetTypicalP99
Ingestion batch write (1K points)< 10ms3ms8ms
Series ID lookup< 1ms0.1ms0.5ms
Simple query (single series, 1h range)< 200ms50ms150ms
Complex query (100 series, 24h range)< 2s400ms1.5s
WebSocket push latency< 5s1.5s3s
Dashboard full load (20 panels)< 2s800ms1.8s
Alert rule evaluation< 5s500ms2s
Profiling Methodology: Always measure performance under production-like conditions. The most common mistake is optimizing for the wrong bottleneck — e.g., spending weeks optimizing the query engine when the actual bottleneck is network latency between the query engine and the TSDB. Use distributed tracing (OpenTelemetry) across the entire request path to identify the true bottleneck before optimizing.

20. Conclusion & Further Reading

Designing a real-time analytics and metrics dashboard is one of the most intellectually rewarding system design challenges. It touches every layer of the stack — from low-level binary compression algorithms to high-level distributed systems coordination, from database internals to frontend rendering optimization, from statistical anomaly detection to human-factors engineering in alerting.

Key Takeaways

  • Start with the data model: The time-series data model (metric name + tags to series ID to time-ordered values) is the foundation. Get this right and everything else follows. Get it wrong and you will fight the architecture at every turn.
  • Compression is not optional: Gorilla-style compression reduces storage and I/O costs by 8-12x. This is the difference between a system that is economically viable and one that is not.
  • Cardinality is the silent killer: Uncontrolled cardinality can 100x your costs and break your system. Invest in cardinality management from day one.
  • Rollups are essential: No one queries raw 10-second data for a 7-day trend. Pre-computed rollups at multiple tiers are the key to fast, cost-effective queries over long time ranges.
  • Alerting is a human systems problem: The technical implementation of alerting is straightforward. The hard part is designing a system that humans trust and act on. This requires feedback loops, hygiene processes, and relentless reduction of false positives.
  • Graceful degradation beats perfect availability: It is better to serve slightly stale data than to serve nothing. Design every component with a fallback path.
  • Multi-tenancy requires early planning: Retrofitting tenant isolation onto a shared-everything system is an order of magnitude harder than designing it in from the start.

Architecture Decision Record Summary

DecisionChoiceAlternative ConsideredRationale
Ingestion protocolOpenTelemetry OTLP/gRPCStatsD, custom JSONIndustry standard, rich semantics, efficient
Message bufferApache KafkaRedis Streams, NATSProven durability, replay capability, ecosystem
TSDB compressionGorilla (XOR + delta-of-delta)Simple columnar, LZ4Best compression ratio for time-series data
Query languagePromQL-compatibleInfluxQL, customLargest community, most tooling available
WebSocket frameworkASP.NET Core native WebSocketsSignalR, Socket.IOLightweight, full control, no magic
Anomaly detectionMulti-algorithm (ZScore, MAD, EWMA)Single algorithm, ML-onlyFlexibility for different metric patterns
Object storageAWS S3 / Azure BlobHDFS, local NASInfinite durability, cost-effective, managed

Common Anti-Patterns to Avoid

Through our experience building and operating metrics platforms, we have identified several anti-patterns that teams commonly fall into: (1) Building before understanding scale: Teams often build for 100M points/day when their actual load is 1M points/day, resulting in unnecessary complexity. Start simple, measure, and scale incrementally. (2) Ignoring cardinality: Not implementing cardinality limits from day one means a single misconfigured tag can bring down the entire system. (3) Premature optimization of the query path: Most metrics dashboards show the last 1-24 hours of data. Optimize for this hot path first; historical queries can tolerate higher latency. (4) Alert rule sprawl: Without a review process, alert rules accumulate like dead code. Implement mandatory review cycles and auto-deprecation of unused rules. (5) One-size-fits-all retention: Not all metrics have the same value over time. Critical infrastructure metrics deserve longer retention than debug-level application metrics.

Further Reading

  • Gorilla: A Fast, Scalable, In-Memory Time Series Database — Facebook (2015). The foundational paper on time-series compression.
  • Prometheus: Up and Running — Brian Brazil. The definitive guide to Prometheus architecture and PromQL.
  • Designing Data-Intensive Applications — Martin Kleppmann. Essential reading for understanding distributed systems primitives.
  • The Site Reliability Workbook — Google SRE team. Practical guidance on monitoring, alerting, and SLIs.
  • VictoriaMetrics documentation. Excellent deep dives into TSDB optimization and high-performance metrics storage.
  • OpenTelemetry specification. The emerging standard for observability instrumentation and data collection.
  • An Introduction to Statistical Learning — James, Witten, Hastie, Tibshirani. Background on the statistical methods used in anomaly detection.
  • Betsy Beyer et al., Site Reliability Engineering. The original SRE book covering monitoring philosophy at Google scale.

Final Architecture Diagram

graph TB subgraph "Complete System View" direction TB subgraph "Ingestion (Scales Horizontally)" I1[OTel Agents] --> I2[Load Balancer] I2 --> I3[Ingestion API] I3 --> I4[Kafka Cluster] end subgraph "Processing (Event-Driven)" I4 --> P1[Stream Processor] I4 --> P2[Rollup Worker] I4 --> P3[Alert Evaluator] I4 --> P4[Cardinality Tracker] end subgraph "Storage (Columnar TSDB)" P1 --> S1[TSDB Write Path] S1 --> S2[Local Blocks] S2 --> S3[Object Storage] P2 --> S1 end subgraph "Serving (Low-Latency)" S2 --> Q1[Query Engine] S3 --> Q1 P1 --> Q2[WebSocket Hub] Q1 --> Q3[Dashboard API] end subgraph "Presentation (Interactive)" Q3 --> C1[React Dashboard] Q2 --> C1 C1 --> C2[Time Series Charts] C1 --> C3[Stat Panels] C1 --> C4[Heatmaps] end end
Final Thought: The best metrics platform is one that engineers actually use. Technical excellence is necessary but not sufficient — invest as much in usability, documentation, and onboarding as you do in architecture and performance. A slightly less optimal system that every team embraces is infinitely more valuable than a theoretically perfect system that sits unused.

Real-Time Analytics Dashboard — Senior+ Guide