Skip to main content
Nordlys logo, a drawing of two gray mountains with green northern lights in the background @phpeterle Blog
PT

Back to all posts

Self-Hosted Observability Architecture

Published on by Pedro Peterle · 14 min read

I recently got to improve the observability architecture at the company I work for, Woovi. The whole stack runs self-hosted inside our own Kubernetes cluster, with no dependence on SaaS like Datadog, New Relic or Grafana Cloud. That means higher operating costs, but it guarantees that sensitive data (financial transaction logs, payment traces) never leaves our infrastructure.

In this post I’ll focus on how the pieces of the architecture work, how they connect, and above all how we set up alerts in Grafana so the team gets notified when something goes off track. I’ll leave out the Woovi-specific configuration and stick to concepts that work at any company.

The 3 pillars of observability

First, it helps to know what we’re collecting. There are 3 main types of telemetry:

  • Logs: text messages generated by the application. Useful for understanding what happened at a specific moment.
  • Metrics: numeric values aggregated over time (latency, number of requests, CPU usage). Useful for understanding trends.
  • Traces: a record of a request as it passes through multiple services. Useful for finding bottlenecks in distributed systems.

There’s also an emerging fourth pillar, continuous profiling (e.g. Pyroscope), but we’ll focus on the 3 main ones.

Each pillar needs a different storage backend, because their read and write patterns are very different. Metrics are time series with controlled cardinality, logs are text with high variability, and traces are trees of spans.

Architecture overview

The architecture follows a very common pattern in the Grafana ecosystem: a single entry point receives all the telemetry from the applications and distributes it to the appropriate backends.

flowchart TB
  Apps["Applications<br/>(OTel SDK)"]
  Coletor["Collector (Alloy)<br/>- Receives OTLP<br/>- Scrapes Prometheus<br/>- Collects K8s logs"]
  Loki["Loki<br/>(logs)"]
  Tempo["Tempo<br/>(traces)"]
  Mimir["Mimir<br/>(metrics)"]
  MinIO[("MinIO<br/>self-hosted S3")]
  Grafana["Grafana<br/>read plane + alerting"]
  Notif["Discord / Email / WhatsApp"]

  Apps -->|gRPC or HTTP| Coletor
  Coletor -->|logs| Loki
  Coletor -->|traces| Tempo
  Coletor -->|metrics| Mimir
  Loki <--> MinIO
  Tempo <--> MinIO
  Mimir <--> MinIO
  Loki --> Grafana
  Tempo --> Grafana
  Mimir --> Grafana
  Grafana --> Notif

This entire stack runs inside the same Kubernetes cluster that runs the applications. Long-term storage lives in MinIO (S3 compatible), also self-hosted, so no telemetry data ever leaves our infrastructure.

OpenTelemetry as the standard

The most important decision in the whole architecture is adopting OpenTelemetry (OTel) as the instrumentation standard. Before adopting it, it’s common for each application to have its own way of producing telemetry, with different libraries, different formats and different endpoints.

OTel standardizes that. Applications export telemetry in a format called OTLP (OpenTelemetry Protocol), which can be sent over gRPC or HTTP. The standard environment variables are:

Terminal window
OTEL_EXPORTER_OTLP_ENDPOINT=http://collector:4318
OTEL_EXPORTER_OTLP_PROTOCOL=http/protobuf
OTEL_SERVICE_NAME=meu-servico
OTEL_TRACES_EXPORTER=otlp
OTEL_METRICS_EXPORTER=otlp
OTEL_LOGS_EXPORTER=otlp

With that, every application sends logs, metrics and traces to the same endpoint. Switching backends (moving from a cloud provider to self-hosted, for example) becomes a matter of changing the collector, without touching a single line of application code.

This decoupling is the main benefit of OTel. You aren’t locked into a vendor.

The collector: Grafana Alloy

The collector is the component that receives telemetry and routes it to the right backend. We use Grafana Alloy, a fork of the OpenTelemetry Collector with a few extra features (Prometheus compatibility, Kubernetes log scraping via CRD, etc).

Why put a collector in the middle?

Why not send straight from the application to Loki/Tempo/Mimir? There are several reasons:

  1. Buffering and retry: if the backend is temporarily unavailable, the collector holds on to the data instead of losing it. The application doesn’t have to worry about this.
  2. Enrichment: the collector can add metadata the application doesn’t have (node name, Kubernetes namespace, environment, cluster).
  3. Filtering and sampling: you can decide which traces to keep, drop noisy logs, and aggregate metrics before sending them to storage. All in one central place.
  4. Format conversion: the collector receives OTLP from the application and converts it to each backend’s native format (Loki, Prometheus remote-write, Tempo).
  5. Single point of configuration: switching backends becomes a change to a single file.

Pipelines

Alloy is configured as pipelines. Each pipeline defines a processing chain. A simplified example for traces:

alloy-config.river
otelcol.receiver.otlp "default" {
grpc { endpoint = "0.0.0.0:4317" }
http { endpoint = "0.0.0.0:4318" }
output {
traces = [otelcol.processor.k8sattributes.default.input]
}
}
otelcol.processor.k8sattributes "default" {
output {
traces = [otelcol.processor.batch.default.input]
}
}
otelcol.processor.batch "default" {
output {
traces = [otelcol.exporter.otlp.tempo.input]
}
}
otelcol.exporter.otlp "tempo" {
client {
endpoint = "tempo-distributor:4317"
tls { insecure = true }
}
}

Telemetry flows receiver → processors → exporter. Each component is an independent plugin, and you can build arbitrarily complex pipelines.

Span metrics

An important trick is the span metrics connector. It automatically turns traces into RED metrics (Rate, Errors, Duration). Every time an application sends a trace, the collector computes latency and an error count and exports them as metrics. This is what feeds the latency alerts later on.

spanmetrics.river
otelcol.connector.spanmetrics "default" {
histogram {
explicit {
buckets = ["50ms", "100ms", "250ms", "500ms", "1s", "2s", "5s", "10s"]
}
}
dimension { name = "http.method" }
dimension { name = "http.status_code" }
}

Without it, you’d have to manually instrument every endpoint to emit latency metrics. With the connector, you get them for free from the traces.

Storage backends

Each pillar has a dedicated backend, and they all follow the same architectural pattern: a split between writes, reads and long-term storage in S3.

MinIO: self-hosted S3

Before talking about the backends, it’s worth pointing out where the data actually lives. Loki, Tempo, Mimir and Pyroscope were designed to use S3-compatible object storage for long-term storage. In cloud environments, the natural choice would be Amazon S3, GCS or Azure Blob. But since the whole stack is self-hosted, we run MinIO inside the Kubernetes cluster itself.

MinIO is an object storage server that is 100% compatible with the S3 API. To Loki, Tempo and Mimir, it’s indistinguishable from AWS S3: you just point the endpoint at MinIO’s internal service (minio.minio.svc.cluster.local:9000) and provide the access key/secret key.

loki-values.yaml
storage_config:
aws:
s3: http://access-key:secret-key@minio.minio.svc:9000/loki-chunks
s3forcepathstyle: true

The bucket setup is the usual one: each backend has its own bucket, created before the first install:

loki-chunks # log chunks
loki-ruler # Loki rules
mimir-tsdb # metric blocks
mimir-ruler # Mimir rules
tempo-traces # traces
pyroscope # profiles

Having MinIO in the same cluster brings two direct benefits: very low latency (traffic never leaves the cluster) and predictable cost (you only pay for the disk you provision, with no charges per GB transferred or per PUT/GET request like on AWS S3).

MinIO’s data is persisted on Kubernetes volumes (in our case, a StorageClass with Ceph RBD via CSI). That means even if the MinIO pod restarts, the data stays on disk. For high availability you can run MinIO in distributed mode (with erasure coding across multiple nodes), but for a small to medium cluster a single instance is enough.

From here on, whenever I say “S3”, read it as MinIO running inside the cluster. The interface is the same.

Loki for logs

Loki is “Prometheus for logs”. Instead of indexing the full content of each log line (like Elasticsearch does), it only indexes labels (service, namespace, severity). The log content is stored in compressed chunks in S3.

This brings two benefits:

  • Much lower storage cost (compressed chunks in S3 vs an inverted index on disk).
  • Controlled cardinality, because labels are few and bounded. You can’t use a request_id or trace_id as a label, or the index blows up.

For fields that change on every request (like trace_id), Loki has the concept of structured metadata, which is stored inside the chunk and searchable, but not indexed. You get the best of both: you can filter by trace_id without killing cardinality.

Loki’s golden rule: labels for low-cardinality things (up to a few hundred unique values), structured metadata for everything else.

Tempo for traces

Tempo is a trace backend that stores everything in S3. The clever part is that it has no index over span content. Instead, Tempo relies on the trace_id as the primary key and uses columnar formats (Parquet) for complex queries (TraceQL).

For queries that need to be fast, you can configure dedicated columns with span attributes that show up often in filters (in our case, business IDs). This turns the attribute into a native Parquet column instead of leaving it inside a generic JSON field.

Tempo also has what they call the metrics-generator, which plays a role similar to the collector’s spanmetrics: turning spans into metrics at ingestion time.

Mimir for metrics

Mimir is a distributed Prometheus with storage in S3. The API is 100% compatible with Prometheus, so any tool that speaks PromQL works without modification.

The main difference is scale. Traditional Prometheus runs as a single instance and keeps data on local disk. Mimir scales horizontally (you add replicas as you grow) and keeps months or years of retention in S3, instead of a few days.

We use the remote-write protocol to send metrics from Alloy to Mimir:

remote-write.river
prometheus.remote_write "mimir" {
endpoint {
url = "http://mimir-gateway/api/v1/push"
}
}

To understand which metric types exist (Counter, Gauge, Histogram, Summary) and how to pick the right one when instrumenting, I wrote a companion post: Prometheus metrics on /metrics and when to use each one.

Grafana as the read plane

Grafana is the single entry point for visualization. It connects to the 3 backends as separate datasources:

values-datasources.yaml
datasources:
- name: Mimir
type: prometheus
uid: prometheus
isDefault: true
url: http://mimir-gateway/prometheus
- name: Loki
type: loki
uid: loki
url: http://loki-gateway
- name: Tempo
type: tempo
uid: tempo
url: http://tempo-query-frontend:3200

Correlation between datasources

The coolest part of Grafana is automatic correlation across the 3 pillars. With the datasources configured correctly, you can:

  • Logs → Traces: click a log and open the matching trace, using the trace_id in the structured fields.
  • Traces → Logs: click a span and see the logs it produced, filtered by service.name and the span’s time window.
  • Metrics → Traces: click a latency spike on a dashboard and open the traces from that period (Exemplars).

This is configured in Loki’s derivedFields and Tempo’s tracesToLogsV2:

values-datasources.yaml
- name: Loki
jsonData:
derivedFields:
- name: trace_id
matcherType: regex
matcherRegex: '"trace_id":"([a-f0-9]+)"'
url: '$${__value.raw}'
datasourceUid: tempo
- name: Tempo
jsonData:
tracesToLogsV2:
datasourceUid: loki
tags:
- key: service.name
value: service_name

This correlation is what makes debugging in production so much faster. You go from a metric to the trace, and from the trace to the logs, in seconds.

Sidecar for dashboards

Grafana has two ways to provision dashboards: through the UI (which saves to its internal SQLite) or from files (file-based provisioning). The second is better because it lets you version dashboards in Git, but it has a problem: you have to roll out Grafana every time you add a dashboard.

The solution is the sidecar pattern with kiwigrid/k8s-sidecar. It runs as a container alongside Grafana, watches the Kubernetes cluster for ConfigMaps with a specific label, and when it finds one, copies its content into a Grafana folder. Grafana in turn watches that folder and loads the new dashboards in real time.

grafana-values.yaml
sidecar:
dashboards:
enabled: true
label: grafana_dashboard
labelValue: '1'
folderAnnotation: grafana_folder
searchNamespace: ALL

From then on, any ConfigMap in the cluster with the label grafana_dashboard: "1" automatically becomes a dashboard. To create a new dashboard, all you need is:

meu-dashboard.yaml
apiVersion: v1
kind: ConfigMap
metadata:
name: meu-dashboard
labels:
grafana_dashboard: '1'
annotations:
grafana_folder: 'Aplicacoes'
data:
dashboard.json: |
{ ... dashboard json ... }

Apply it with kubectl apply -f and the dashboard shows up in Grafana within seconds. No rollout, no restart. The whole team can contribute dashboards without touching Grafana’s configuration.

The same pattern works for datasources and plugins, you just change the label.

Alerts: the most important part

Collecting telemetry without alerts is just a pretty dashboard. The real value comes from getting notified when something is off, without having to keep staring at dashboards.

Grafana has a unified alerting system (Unified Alerting) that replaced the traditional Alertmanager. It has 3 main concepts:

  1. Rules: the rule that defines what to monitor (a PromQL/LogQL/TraceQL query + a threshold).
  2. Contact points: where to send the notification (email, Discord, WhatsApp, PagerDuty, webhook).
  3. Notification policies: how to route each alert to the right contact points based on labels.

Defining rules

An alert rule is a query that runs at defined intervals. If the result crosses the threshold for more than X minutes, the alert fires.

Example alert for p95 latency:

values-alerting.yaml
groups:
- name: latency
folder: Latency
interval: 1m
rules:
- title: API checkout p95 > 5s
condition: C
data:
- refId: A
datasourceUid: prometheus
model:
expr: |
histogram_quantile(0.95,
sum by (le) (
rate(traces_spanmetrics_duration_milliseconds_bucket{
service_name="api",
span_name="POST /checkout"
}[5m])
)
)
- refId: B
datasourceUid: __expr__
model:
type: reduce
expression: A
reducer: last
- refId: C
datasourceUid: __expr__
model:
type: threshold
expression: B
conditions:
- evaluator:
type: gt
params: [5000]
for: 5m
labels:
severity: critical
annotations:
summary: 'Checkout p95 latency above 5s'

Notice that the alert uses traces_spanmetrics_duration_milliseconds_bucket, which is exactly the metric the collector generates from traces. Without instrumenting anything in the application, we can alert on latency per endpoint.

Contact points

Contact points define where the notification goes. We set up multiple channels for different severities:

values-contactpoints.yaml
contactPoints:
- name: discord-alerts
receivers:
- type: discord
settings:
url: ${DISCORD_WEBHOOK_URL}
- name: whatsapp-alerts
receivers:
- type: webhook
settings:
url: ${EVOLUTION_API_URL}
httpMethod: POST
- name: email-alerts
receivers:
- type: email
settings:
addresses: oncall@empresa.com

WhatsApp in particular is interesting because Grafana has no native integration, but you can use a webhook that calls an API like Evolution API (also self-hosted), which sends messages to a WhatsApp group.

Notification policies: routing by severity

This is the most important architectural part. We don’t want every alert going to every channel. A low-priority queue alert doesn’t need to wake anyone up at 3 AM.

The solution is to build a routing hierarchy based on labels:

values-contactpoints.yaml
policies:
- receiver: discord-alerts
group_by: [alertname, namespace]
routes:
- matchers: [notify=email-only]
receiver: email-alerts
repeat_interval: 168h
- matchers: [severity=~"critical|emergency"]
continue: true
receiver: whatsapp-alerts
mute_time_intervals: [nighttime]
- matchers: [severity=~"critical|emergency"]
continue: true
receiver: email-alerts
- matchers: [severity=~"critical|emergency"]
receiver: discord-alerts

The logic is clearer visually:

flowchart TB
  Alerta["Alert fired"] --> Match{severity?}
  Match -->|warning| Discord["Discord"]
  Match -->|critical / emergency| FanOut[/"fan-out (continue: true)"/]
  Match -->|notify=email-only| EmailOnly["Email<br/>repeat_interval: 168h"]
  FanOut --> Discord
  FanOut --> Email["Email"]
  FanOut --> WppCheck{time of day?}
  WppCheck -->|01h-11h UTC| Mute["muted<br/>(mute time)"]
  WppCheck -->|outside the window| Whatsapp["WhatsApp"]

The logic is:

  • By default, every alert lands in Discord (low noise, the team sees it when they’re online).
  • If the severity is critical or emergency, it fires on 3 channels at once (Discord, Email, WhatsApp).
  • WhatsApp has a mute time overnight for alerts that can wait, but Discord and Email keep firing.
  • Alerts tagged with notify=email-only (e.g. infra warnings) only go to email, with a long repeat_interval.

continue: true is important: without it, the alert stops at the first match. With it, routing keeps evaluating the other policies.

Severity matters

The severity vocabulary we use is:

  • warning: something needs attention, but it isn’t breaking production. Goes to Discord only.
  • critical: something is broken or about to break. Notifies every channel.
  • emergency: production is down. Notifies every channel, with no mute time.

The difference between critical and emergency comes down to whether the overnight mute time is respected. A database saturating at 3 AM can be critical (it waits until morning), but a site that’s down is emergency (it wakes up whoever needs to be woken up).

YAML anchors to avoid duplication

Since every alert rule has a repetitive structure (data, reduce expression, threshold), we use YAML anchors to reuse blocks:

values-alerting.yaml
x-anchors:
- &latency-base
condition: C
for: 5m
isPaused: false
- &reduce-data
refId: B
datasourceUid: __expr__
model:
type: reduce
expression: A
reducer: last
groups:
- name: latency
rules:
- <<: *latency-base
title: API checkout p95 > 5s
data:
- refId: A
... # query
- <<: *reduce-data
- refId: C
... # threshold

This cuts down the amount of YAML drastically and avoids inconsistencies between rules.

What’s out of scope for this post

For anyone who wants to go deeper, a few topics that would deserve a post of their own:

  • Continuous profiling with Pyroscope (the fourth pillar)
  • Automatic service graphs from traces
  • Exemplars (linking a specific point of a metric to the trace that produced it)
  • Cost monitoring: how to measure and control telemetry cost (cardinality, retention)
  • High availability: replicas, anti-affinity, zone-aware replication

Conclusion

Building a self-hosted observability stack takes work, but it has clear benefits:

  • Predictable cost (you pay for storage and compute, not per GB ingested).
  • Privacy (sensitive data never leaves your infra).
  • Flexibility (you can customize any part of the stack).

The standard Grafana ecosystem architecture (OTel SDK → collector → Loki/Tempo/Mimir → Grafana) is robust, well documented, and works at any scale. The sidecar pattern for dashboards and the unified alerting system make operating it much simpler than it looks at first glance.

The most important part of the whole stack isn’t collection, it’s alerting. Without well-configured alerts, observability is just a cost. With well-configured alerts, it’s the difference between finding a problem before your customer does or after.

References