Designing a Real-Time Analytics & Metrics Dashboard
A deep-dive into building systems like Datadog, Grafana, and Prometheus from first principles
Table of Contents
- Introduction — Why Real-Time Analytics Matters
- Functional & Non-Functional Requirements
- Capacity Estimation & Back-of-Envelope Math
- Data Model & Schema Design
- High-Level Architecture Overview
- Metric Ingestion Pipeline
- Time-Series Database Design
- Metric Aggregation & Rollups
- Real-Time Streaming via WebSockets
- Dashboard Rendering Engine
- Alerting System — Threshold & Anomaly Detection
- Query Engine — A PromQL-like Language
- Dashboard Sharing & Permissions
- Data Retention & Downsampling
- Multi-Tenancy Architecture
- Reliability, Failure Modes & Resilience
- Cost Estimation & Resource Planning
- Interview Q&A — Top Questions & Answers
- Performance Engineering & Optimization
- Conclusion & Further Reading
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.
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.
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
| Attribute | Target | Rationale |
|---|---|---|
| Ingestion Throughput | 10 million data points/second | Supports 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 range | Interactive dashboard experience |
| Dashboard Load Time | < 1 second (p95) | Users should perceive instant page loads |
| Alert Evaluation Latency | < 60 seconds from data to notification | Timely incident response |
| Availability | 99.95% (dashboards), 99.9% (ingestion) | Dashboards more critical than ingestion during brief outages |
| Durability | 99.999999% (11 nines) | Metrics are append-only and loss of data undermines trust |
| Data Retention | 90 days raw, 1 year at 1-minute rollup, forever at 1-hour rollup | Industry standard retention tiers |
| Cardinality Support | Up to 10 million active time series | Supports large microservice deployments with rich tagging |
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:
| Component | Size | Notes |
|---|---|---|
| Timestamp | 8 bytes | Unix nanosecond precision |
| Value | 8 bytes | 64-bit double |
| Series ID (reference) | 8 bytes | Points to series metadata |
| Total per point | 24 bytes | Raw, 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 Tier | Granularity | Daily Storage | Retention | Total |
|---|---|---|---|---|
| Raw | 10 seconds | 518 GB | 90 days | ~46.6 TB |
| 1-minute rollup | 1 minute | ~86 GB | 365 days | ~31.4 TB |
| 1-hour rollup | 1 hour | ~1.4 GB | 5 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)
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.
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.
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.
Subsystem Responsibilities
| Subsystem | Responsibility | Key Technology Choices |
|---|---|---|
| Ingestion API | Validate, deduplicate, and buffer incoming data points | gRPC/HTTP, Protocol Buffers, Kafka |
| Stream Processor | Aggregate metrics in real-time, enrich with metadata | Apache Flink / Kafka Streams / custom C# |
| TSDB | Persist time-series data with efficient compression | Custom TSDB (similar to Gorilla/VictoriaMetrics) |
| Query Engine | Parse PromQL-like queries, optimize, and execute | Custom parser, vectorized execution engine |
| WebSocket Hub | Maintain persistent connections and push live data | SignalR / ASP.NET Core WebSockets |
| Alert Evaluator | Continuously evaluate alert rules and trigger notifications | C# background services, sliding window state |
| Object Storage | Durable, cost-effective long-term block storage | AWS S3 / Azure Blob / GCS |
Data Flow: End to End
- 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"}. - Ingestion: The ingestion API validates the data point (schema check, cardinality limits), assigns a series ID, and writes it to Kafka for durability.
- Processing: The stream processor reads from Kafka, updates in-memory aggregation buffers for real-time dashboards, and writes raw data to the TSDB.
- Storage: The TSDB accumulates data points into blocks, compresses them using Gorilla-style encoding, and periodically uploads completed blocks to object storage.
- 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.
- 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.
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:
| Protocol | Format | Use Case | Pros and Cons |
|---|---|---|---|
| OpenTelemetry OTLP/gRPC | Protobuf | Modern observability agents | Rich, typed, efficient. Industry standard. |
| OpenTelemetry OTLP/HTTP | Protobuf or JSON | Browser-based, restricted networks | Firewall-friendly. Higher overhead than gRPC. |
| StatsD | Text line protocol | Legacy applications | Ubiquitous but limited (no tags in vanilla StatsD). |
| Prometheus Remote Write | Protobuf | Prometheus ecosystem | Widely used. Batch-oriented. |
| Custom JSON Push | JSON | Quick integration, prototyping | Easy 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.
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.
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:
- 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.
- 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
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);
}
}
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 Level | Window | Stats Computed | Storage Reduction |
|---|---|---|---|
| Raw | 10 seconds | value | 1x (baseline) |
| Rollup 1 | 1 minute | min, max, avg, sum, count | 6x |
| Rollup 2 | 5 minutes | min, max, avg, sum, count | 30x |
| Rollup 3 | 1 hour | min, max, avg, sum, count | 360x |
| Rollup 4 | 1 day | min, max, avg, sum, count | 8640x |
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.
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.
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
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 Type | Best For | Avoid When |
|---|---|---|
| Line Chart | Trends over time, comparing multiple series | More than 20 series (visual clutter) |
| Area Chart | Stacked totals, resource utilization | Negative values, overlapping areas |
| Bar Chart | Discrete comparisons, top-N rankings | High-frequency time series data |
| Heatmap | Distribution over time (latency histograms) | Small number of buckets |
| Stat Panel | Single KPI, current value with trend | Showing trends requires sparkline |
| Gauge | Progress toward a threshold (e.g., disk usage %) | Metrics without clear upper bounds |
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
| Algorithm | Best For | Pros | Cons |
|---|---|---|---|
| Z-Score | Stable metrics with normal distribution | Simple, fast, no training data needed | Sensitive to outliers, assumes normality |
| MAD | Metrics with occasional spikes | Robust to outliers, non-parametric | Less sensitive to subtle shifts |
| EWMA | Trending metrics | Adapts to gradual changes, low memory | Lags behind sudden changes |
| Prophet | Metrics with daily or weekly seasonality | Captures complex patterns, handles gaps | Higher 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.
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
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.
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
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
| Strategy | Isolation Level | Resource Efficiency | Operational Complexity | Best For |
|---|---|---|---|---|
| Separate Cluster | Complete | Low | High | Enterprise tenants with strict compliance |
| Separate Namespace | High | Medium | Medium | SaaS with tiered plans |
| Shared Cluster + Tenant Prefix | Medium | High | Low | Internal multi-team platforms |
| Shared Everything + RBAC | Low (logical) | Very High | Very Low | Small 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.
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
| Failure | Impact | Detection | Mitigation | Recovery |
|---|---|---|---|---|
| Ingestion API crash | Stop accepting new data | Health check failures | Multiple replicas behind LB | Restart pods, replay WAL |
| Kafka broker failure | Buffering stops | Kafka lag alerts | 3+ replicas, replication factor 3 | Automatic leader election |
| TSDB node failure | Cannot read or write | Block flush failures | Replicated blocks, object storage | Rebuild from object storage |
| Object storage outage | Cannot read historical | S3 API errors | Local disk cache, multi-region | Serve from cache |
| WebSocket hub crash | Real-time updates stop | Disconnection spike | Multiple hub instances | Clients reconnect |
| Query engine overload | Dashboard slow or timeout | Latency spikes | Query timeout, admission control | Scale replicas, shed queries |
| Alert evaluator failure | Alerts stop firing | Evaluation gap detection | Leader election | Failover 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.
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
| Component | Instance Type | Count | Monthly Cost per Unit | Monthly Total |
|---|---|---|---|---|
| Ingestion API | 8 vCPU, 16 GB RAM | 8 | $400 | $3,200 |
| Kafka Brokers | 8 vCPU, 32 GB RAM, 2 TB NVMe | 6 | $800 | $4,800 |
| Stream Processors | 16 vCPU, 64 GB RAM | 4 | $1,000 | $4,000 |
| TSDB Nodes (Write) | 16 vCPU, 64 GB RAM, 4 TB NVMe | 8 | $1,200 | $9,600 |
| TSDB Nodes (Read) | 16 vCPU, 64 GB RAM, 2 TB NVMe | 8 | $1,000 | $8,000 |
| Query Engine | 8 vCPU, 32 GB RAM | 16 | $500 | $8,000 |
| WebSocket Hubs | 8 vCPU, 16 GB RAM | 8 | $400 | $3,200 |
| Alert Evaluators | 4 vCPU, 16 GB RAM | 4 | $250 | $1,000 |
| Object Storage (S3) | ~80 TB stored | $0.023/GB | $1,840 | |
| Database (Metadata) | 4 vCPU, 16 GB RAM | 3 | $300 | $900 |
| Load Balancers | Application LBs | $500 | ||
| Networking | Cross-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
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?
Q2: How would you design the alerting system to avoid alert fatigue?
Q3: Why use Kafka instead of directly writing to the TSDB?
Q4: How do you query data efficiently across different retention tiers?
Q5: How would you scale the system to 10x the current throughput?
Q6: How do you handle time synchronization issues across distributed agents?
Q7: How would you implement dashboard variables and template queries?
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
| Operation | Target | Typical | P99 |
|---|---|---|---|
| Ingestion batch write (1K points) | < 10ms | 3ms | 8ms |
| Series ID lookup | < 1ms | 0.1ms | 0.5ms |
| Simple query (single series, 1h range) | < 200ms | 50ms | 150ms |
| Complex query (100 series, 24h range) | < 2s | 400ms | 1.5s |
| WebSocket push latency | < 5s | 1.5s | 3s |
| Dashboard full load (20 panels) | < 2s | 800ms | 1.8s |
| Alert rule evaluation | < 5s | 500ms | 2s |
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
| Decision | Choice | Alternative Considered | Rationale |
|---|---|---|---|
| Ingestion protocol | OpenTelemetry OTLP/gRPC | StatsD, custom JSON | Industry standard, rich semantics, efficient |
| Message buffer | Apache Kafka | Redis Streams, NATS | Proven durability, replay capability, ecosystem |
| TSDB compression | Gorilla (XOR + delta-of-delta) | Simple columnar, LZ4 | Best compression ratio for time-series data |
| Query language | PromQL-compatible | InfluxQL, custom | Largest community, most tooling available |
| WebSocket framework | ASP.NET Core native WebSockets | SignalR, Socket.IO | Lightweight, full control, no magic |
| Anomaly detection | Multi-algorithm (ZScore, MAD, EWMA) | Single algorithm, ML-only | Flexibility for different metric patterns |
| Object storage | AWS S3 / Azure Blob | HDFS, local NAS | Infinite 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.