Observability stacks
"How would you know it's broken?" comes up in every senior interview, and the answer names tools. Metrics, logs and traces each need collecting, storing and querying, and the choice between running Prometheus and Grafana yourself, buying Datadog, or using your cloud's suite is mostly about cost, scale and who operates it. This guide explains how each stack works, how they differ and which to choose, with a chooser and code you can run.
- Sam "How would you know it's broken?" Which tools do I name?
- Priya It depends on cost, scale and who operates them. Metrics, logs and traces each need collecting, storing and querying, and you can run open-source tools, buy a vendor, or use your cloud's suite.
- Sam What are the three signals?
- Priya Metrics say whether something's wrong and how much, as time series. Logs say exactly what happened in one request. Traces show where the time went across services. OpenTelemetry produces all three with one set of SDKs and a collector, so you can change backends without touching the code.
- Sam How does telemetry flow?
- Priya The SDK records spans, counts requests and stamps the trace ID on every log line, sending them to a collector. The collector batches, filters and waits for each trace to finish so it can keep the errors and slow ones. Each signal goes to a store built for it. And in an incident, a burn-rate alert leads to a trace, and the trace leads to its logs.
- Sam What are the options?
- Priya Prometheus pulls metrics and queries them with PromQL, with Thanos, Mimir or VictoriaMetrics for long-term storage. Grafana draws dashboards, with Loki for logs and Tempo for traces. Jaeger for self-hosted tracing. Vendors like Datadog, New Relic, Dynatrace and Splunk host everything, for a bigger bill. Honeycomb for slicing traces by any field. And the cloud suites: CloudWatch with X-Ray, Google Cloud Observability, and Azure Monitor.
- Sam How do I choose?
- Priya Always instrument with OpenTelemetry. One cloud and a small team: the cloud's suite. Kubernetes and a platform team: the Grafana stack. No time and budget to pay: a vendor. And hard, unknown problems: high-cardinality tracing.
- Priya Your turn. Head sampling keeps ten per cent of traces. What share of the errors does it keep?
- Priya About ten per cent. That's why tail sampling in the collector matters.
- Sam And the code?
- Priya A hundred thousand traces sampled two ways: head sampling loses most errors and slow requests, tail sampling keeps all of them and stores fewer traces.
- Sam Common mistakes?
- Priya User IDs as metric labels. Head sampling only. Logs without trace IDs. Vendor SDKs everywhere. And monitoring that goes down with the system it watches.
1. Three signals, one pipeline
Module 25 teaches what to measure and how to alert; this guide is about the tools.
| Signal | Answers | Stored as | Typical tools |
|---|---|---|---|
| Metrics | Is something wrong, and how much? | Time series: a number per label set per interval | Prometheus, Mimir, Datadog, Cloud Monitoring |
| Logs | What exactly happened in this request? | Timestamped records, structured as JSON | Loki, Elasticsearch, Cloud Logging, CloudWatch Logs |
| Traces | Where did the time go across services? | Spans linked by a trace ID | Tempo, Jaeger, Honeycomb, X-Ray, Cloud Trace |
OpenTelemetry (a CNCF project) is the common way to produce all three: one set of SDKs and a collector that receives, processes and exports to whichever backend you choose. Instrument once, and you can change vendors without touching the code.
2. How telemetry flows
3. The products
OpenTelemetry
SDKs and automatic instrumentation for most languages, a wire protocol (OTLP), and the collector, which batches, filters, samples (including tail sampling) and exports. Supported by every major vendor and cloud.
Best for: instrumenting everything, whatever backend you pick.
Prometheus
Pulls metrics from each target's /metrics endpoint every few seconds into a local time-series database, queried with PromQL, with Alertmanager for routing alerts. One server per cluster; Thanos, Grafana Mimir or VictoriaMetrics add long-term, multi-cluster storage.
Best for: Kubernetes metrics and alerting you run yourself.
Grafana, Loki, Tempo, Mimir
Grafana draws dashboards from many sources. Loki stores logs cheaply by indexing only labels, Tempo stores traces in object storage, and Mimir scales Prometheus metrics. Grafana Cloud runs them for you.
Best for: an open-source stack across all three signals.
Jaeger
A tracing backend with a UI for finding and comparing traces, now built around OpenTelemetry, storing in Cassandra, Elasticsearch, OpenSearch and others.
Best for: self-hosted tracing.
Datadog, New Relic, Dynatrace, Splunk
Hosted platforms covering metrics, logs, traces, real-user monitoring and more, with agents, integrations and alerting. The least to operate, and usually the highest bill at scale, often driven by custom metrics, log volume and hosts.
Best for: teams that would rather pay than operate an observability stack.
Honeycomb
Stores wide, structured events (spans with many attributes) and lets you slice by any field, including high-cardinality ones like user ID, to find which requests are slow and why.
Best for: debugging unknown problems in complex systems.
CloudWatch, Google Cloud Observability, Azure Monitor
Metrics, logs, traces and alerting built into each cloud: CloudWatch with X-Ray on AWS; Cloud Monitoring, Logging and Trace (with Managed Service for Prometheus) on Google Cloud; Azure Monitor with Application Insights on Azure. All accept OpenTelemetry data.
Best for: workloads on one cloud, especially its managed services.
4. Differences at a glance
| Option | Signals | You operate | Cost driven by | High-cardinality queries |
|---|---|---|---|---|
| Prometheus + Grafana | Metrics (+ dashboards) | Everything | Servers and storage | Poor: each label value is a new series |
| Grafana stack (Loki, Tempo, Mimir) | All three | Everything, or Grafana Cloud | Storage (object storage keeps it low) | Logs by label; traces by ID and search |
| Datadog and similar | All three and more | Agents only | Hosts, custom metrics, log GB, spans | Good, at a price |
| Honeycomb | Traces and events | Instrumentation only | Events stored | Excellent |
| Cloud suites | All three | Configuration only | Data ingested and stored | Varies |
5. When to choose which, and why
- Instrument with OpenTelemetry whatever you choose; it keeps the backend a decision you can change.
- One cloud, a small team? The cloud's own suite: nothing to run, and it already sees the managed services.
- Kubernetes and a platform team? Prometheus and Grafana, with Loki and Tempo, and Mimir or Thanos for long-term metrics.
- No time to operate it and budget to pay? A vendor such as Datadog; watch custom metrics and log volume.
- Hard, unknown performance problems across many services? High-cardinality tracing, such as Honeycomb, alongside your metrics.
Try it: choose for your requirements
6. Head and tail sampling, in code
Keeping every trace is expensive, so systems sample. 100,000 traces, 1% with errors and 2% slow. Head sampling decides at the start, before anyone knows how the request will go: keeping 10% keeps only about 10% of the errors and slow requests, the ones you most need. Tail sampling in the collector waits until each trace is complete, keeps every error and slow trace and 1% of the rest: fewer traces stored, and none of the interesting ones lost. The program was run in all three languages; each prints the output below it.
seed = 42
def rand(n):
"""A small, repeatable random generator (Park-Miller), so all three languages draw the same numbers."""
global seed
seed = seed * 16807 % 2147483647
return seed % n
# 100,000 traces: 1 in 100 has an error, 2 in 100 are slow (over 1 s), the rest are fine.
traces = []
for _ in range(100_000):
r = rand(100)
traces.append("error" if r == 0 else "slow" if r <= 2 else "ok")
total = {k: traces.count(k) for k in ("error", "slow", "ok")}
print(f"traces: {len(traces):,} ({total['error']:,} errors, {total['slow']:,} slow)")
# Head sampling: decide at the start, before knowing how the request will go. Keep 10%.
head = [t for t in traces if rand(10) == 0]
# Tail sampling: a collector waits for the whole trace, keeps every error and slow one, and 1% of the rest.
tail = [t for t in traces if t != "ok" or rand(100) == 0]
for name, kept in (("head, 10%", head), ("tail, errors + slow + 1%", tail)):
e, s = kept.count("error"), kept.count("slow")
print(f"{name:26} keeps {len(kept):6,} traces: {e:4,} of {total['error']:,} errors, {s:5,} of {total['slow']:,} slow")
let seed = 42;
// A small, repeatable random generator (Park-Miller), so all three languages draw the same numbers.
function rand(n: number): number {
seed = (seed * 16807) % 2147483647;
return seed % n;
}
const fmt = (n: number, width = 0) => n.toLocaleString("en-US").padStart(width);
// 100,000 traces: 1 in 100 has an error, 2 in 100 are slow (over 1 s), the rest are fine.
const traces: string[] = [];
for (let i = 0; i < 100_000; i++) {
const r = rand(100);
traces.push(r === 0 ? "error" : r <= 2 ? "slow" : "ok");
}
const count = (list: string[], k: string) => list.filter((t) => t === k).length;
const errors = count(traces, "error"), slow = count(traces, "slow");
console.log(`traces: ${fmt(traces.length)} (${fmt(errors)} errors, ${fmt(slow)} slow)`);
// Head sampling: decide at the start, before knowing how the request will go. Keep 10%.
const head = traces.filter(() => rand(10) === 0);
// Tail sampling: a collector waits for the whole trace, keeps every error and slow one, and 1% of the rest.
const tail = traces.filter((t) => t !== "ok" || rand(100) === 0);
for (const [name, kept] of [["head, 10%", head], ["tail, errors + slow + 1%", tail]] as [string, string[]][]) {
console.log(`${name.padEnd(26)} keeps ${fmt(kept.length, 6)} traces: ${fmt(count(kept, "error"), 4)} of ${fmt(errors)} errors, ${fmt(count(kept, "slow"), 5)} of ${fmt(slow)} slow`);
}import java.util.*;
import java.util.stream.*;
public class Main {
static long seed = 42;
// A small, repeatable random generator (Park-Miller), so all three languages draw the same numbers.
static int rand(int n) {
seed = seed * 16807 % 2147483647;
return (int) (seed % n);
}
static long count(List<String> list, String k) { return list.stream().filter(k::equals).count(); }
public static void main(String[] args) {
// 100,000 traces: 1 in 100 has an error, 2 in 100 are slow (over 1 s), the rest are fine.
List<String> traces = new ArrayList<>();
for (int i = 0; i < 100_000; i++) {
int r = rand(100);
traces.add(r == 0 ? "error" : r <= 2 ? "slow" : "ok");
}
long errors = count(traces, "error"), slow = count(traces, "slow");
System.out.println(String.format(Locale.US, "traces: %,d (%,d errors, %,d slow)", traces.size(), errors, slow));
// Head sampling: decide at the start, before knowing how the request will go. Keep 10%.
List<String> head = traces.stream().filter(t -> rand(10) == 0).collect(Collectors.toList());
// Tail sampling: a collector waits for the whole trace, keeps every error and slow one, and 1% of the rest.
List<String> tail = traces.stream().filter(t -> !t.equals("ok") || rand(100) == 0).collect(Collectors.toList());
Object[][] rows = {{"head, 10%", head}, {"tail, errors + slow + 1%", tail}};
for (Object[] row : rows) {
@SuppressWarnings("unchecked") List<String> kept = (List<String>) row[1];
System.out.println(String.format(Locale.US, "%-26s keeps %,6d traces: %,4d of %,d errors, %,5d of %,d slow",
row[0], kept.size(), count(kept, "error"), errors, count(kept, "slow"), slow));
}
}
}traces: 100,000 (1,020 errors, 2,067 slow)
head, 10% keeps 9,966 traces: 117 of 1,020 errors, 180 of 2,067 slow
tail, errors + slow + 1% keeps 4,059 traces: 1,020 of 1,020 errors, 2,067 of 2,067 slow
7. Say it in the interview
- "How would you know it's broken?" Services are instrumented with OpenTelemetry; RED metrics per endpoint go to Prometheus (or the cloud's monitoring), and we alert on SLO burn rates, not CPU. Traces with tail sampling and structured logs carrying the trace ID let us follow one bad request across services.
- "Build or buy?" For a small team on one cloud, the cloud's suite or a vendor; at large scale with a platform team, the open-source Grafana stack on object storage is far cheaper, at the cost of running it.
- "What makes observability expensive?" Cardinality and volume: labels with unbounded values, debug logs in production, and keeping every trace. Control them at the collector.
8. Common mistakes
9. Check yourself
Head sampling keeps 10% of traces. Roughly what share of error traces does it keep?
A team adds user_id as a Prometheus label on a request counter. What happens?
Why instrument with OpenTelemetry rather than a vendor's SDK?
Small team, everything on Google Cloud, little time to operate tools. Which fits?
10. Sources and further reading
Reviewed on 4 November 2026. Pricing and features change; check before relying on them.
| Source | Used for |
|---|---|
| OpenTelemetry concepts | Signals, SDKs and the collector |
| OpenTelemetry: sampling | Head and tail sampling |
| Prometheus overview | Pull model, PromQL, Alertmanager |
| Grafana Loki overview | Label-indexed logs |
| Module 25: Observability | RED, USE, burn-rate alerts, cardinality |