Observability Platform

1. Overview

Every EMS service emits telemetry through OpenTelemetry to a central collector, which fans the three signals out to a self-hosted Grafana stack running in the observability namespace of the on-premises cluster. Traces land in Tempo, logs in Loki, metrics in the Prometheus that Rancher already operates. One Grafana queries all of them.

This replaced a paid Elastic Cloud backend in August 2026. The decision and the evidence behind it are recorded in ADR-0011; this page describes the design that resulted and how it is configured today.

Two pages sit either side of this one: OpenTelemetry Configuration covers how a service is wired to emit, and Observability Access covers how to read the result.

2. Why this shape

2.1. Vendor-neutral instrumentation, replaceable backends

Services are instrumented with OpenTelemetry and nothing else. No service knows that Loki, Tempo or Prometheus exist — each one exports OTLP to a single collector endpoint, and the collector decides where signals go.

This is the property that made the Elastic migration survivable. Replacing the entire backend changed collector configuration and Helm values; it changed no application code, and no service redeployed to make it happen. A backend the applications knew about would have made the same migration a coordinated release across every service.

2.2. One collector, not per-service integrations

All three signals converge on one OpenTelemetry Collector before they are routed. That gives a single place to apply sampling, filtering, noise suppression and label normalisation, and a single place to change when a destination changes.

It also concentrates risk, which is why the collector’s own health is one of the two things federated out of the cluster — see Watching the watchmen.

2.3. Self-hosted, on the cluster that produces the telemetry

The stack runs on the on-premises workload cluster, on Longhorn storage that was already paid for and largely unused. Telemetry does not leave the cluster in normal operation, retention is bounded by disk rather than by a billing tier, and there is no per-GB ingest charge to ration against.

The alternative — hosting the platform on the Azure management cluster — was measured and rejected: 24 GB of arm64 capacity already running at 147–175% memory overcommit, on per-GB billed Azure Disk. See ADR-0011.

2.4. Charts are mirrored, not consumed from upstream

Every component is deployed from a chart mirrored into an OCI registry under our own namespace, rather than pulled from the publisher’s repository:

source:
  repoURL: registry-1.docker.io
  chart: <our-namespace>/loki
  targetRevision: 7.3.0

Upstream chart repositories withdraw versions and occasionally disappear. Two ArgoCD applications elsewhere in the estate were found unmanageable in August 2026 for exactly those reasons — one pinned to a version the publisher had removed, one pointing at a repository that had begun returning 404. Mirroring means a rebuild does not depend on a third party still publishing what it published last year.

3. Signal flow

observability-topology

3.1. Traces

Services export OTLP traces to the collector. The trace pipeline filters known noise, applies resource attributes, derives RED metrics through the spanmetrics connector, and exports to Tempo.

The spanmetrics connector reads the span stream and emits RED metrics — the three service-level measures that answer "is this service healthy?" without knowing anything about what it does:

Signal What it measures

*R*ate

Requests per second reaching the service, from the count of spans over time. Answers "is it being used, and has the load changed?"

*E*rrors

The proportion of those requests that failed, from the span status. Answers "is it failing, and what share of traffic is affected?" — a rate rather than a count, because ten failures out of ten matters differently from ten out of ten thousand.

*D*uration

How long requests take, as a latency distribution rather than an average. Answers "is it slow?" — and percentiles matter here, because a mean hides the tail where the complaints come from.

RED is the request-driven counterpart to USE (Utilisation, Saturation, Errors), which describes resources — CPU, memory, disk. Both are collected here: RED from the trace stream for services, USE from kube-state-metrics and node exporters for the cluster.

spanmetrics is also the reason no EMS service has a ServiceMonitor. All three measures are derived from the trace stream the services already emit; scraping Actuator as well would land the same measurements a second time under a different label set, at additional cardinality cost.

3.2. Logs — two independent paths

Logs reach Loki two ways, and the distinction matters when a query comes back empty:

Path What it carries Notes

OTLP — service → collector → Loki

Application logs from the Logback OTel appender, as structured records carrying trace_id and span_id

Correlates with traces. Grafana’s Loki datasource turns a traceid in one of these records into a View trace link.

Alloy DaemonSet/var/log/pods → Loki

Whatever the container writes to stdout and stderr, from every pod on the node

Independent of the collector, so it still works if the collector is down. Carries no MDC trace fields, so the trace link does not appear on this path.

The second path is stdout and stderr only. A container writing its real log to a file inside its own filesystem is invisible to it regardless of how correct the query is — a failure mode that has already cost debugging time on the SSH bastion.

A second Alloy instance ships Kubernetes events to Loki, labelled to distinguish them from pod logs.

3.3. Metrics

The collector remote-writes OTLP metrics into the Prometheus that Rancher monitoring already operates, rather than introducing Mimir or a second Prometheus. That instance already held a quarter of a million active series with working retention; enabling its remote-write receiver restored the metrics pipeline without adding a component.

Blackbox Exporter probes the estate’s public endpoints from inside the cluster, with the probe list held as Probe custom resources under GitOps.

4. Watching the watchmen

A self-hosted stack cannot report its own death. If the cluster, the collector or Alertmanager stops, everything goes quiet — and quiet is indistinguishable from healthy.

The mitigation deliberately costs no infrastructure. A dedicated collector pipeline federates exactly two stable series from the in-cluster Prometheus to Grafana Cloud’s free tier:

  • ALERTS{alertname="Watchdog"} — proves Prometheus is evaluating rules

  • alertmanager_build_info — proves Alertmanager is up and being scraped

Two Grafana Cloud alert rules then fire on the absence of those series. Detection therefore lives outside the failure domain being watched, which is the whole point.

Three details are deliberate rather than incidental:

  • The heartbeat is its own pipeline, kept separate from application metrics, so that backpressure from telemetry volume cannot drop it and application telemetry cannot reach Grafana Cloud.

  • Per-pod labels are dropped before remote-write. Two stable series is the entire budget; a label that changes on every pod restart turns two series into hundreds and exhausts a free-tier quota.

  • alertmanager_build_info carries the liveness signal rather than up, because the Prometheus receiver synthesises its own up per scrape and a federated metric of the same name collides with it, failing the scrape wholesale while /federate still returns HTTP 200.

4.1. Where the probes watch from

Blackbox Exporter runs inside the cluster it probes. It resolves the public hostname and follows it back to the ingress, so it verifies that the ingress, the service and the application behind it are answering — but the request never leaves the estate’s network.

That leaves a specific blind spot: the cluster is healthy and reporting, and the site is still unreachable from the internet. DNS, the upstream network, or a certificate as seen by an outside client can all fail while every in-cluster probe stays green.

Observation therefore runs in three layers, each catching what the others cannot:

Layer Catches Where it runs

In-cluster probes

Ingress, service and application faults — is the thing behind the hostname answering

Blackbox Exporter with Probe CRs, on the workload cluster

External probes

DNS, upstream network, externally-visible certificate problems — is it reachable from outside

Alloy’s embedded blackbox exporter on the Azure cluster, results to Grafana Cloud

Absence detection

Total loss: cluster, Prometheus, Alertmanager, the collector or the external prober gone

Grafana Cloud, on federated heartbeat series

4.2. The external layer

Alloy on the Azure cluster probes a representative set of the estate’s public endpoints from Azure East US — a different region and a different network path — and writes the results to Grafana Cloud.

Three properties are deliberate:

  • It reuses Alloy rather than deploying a prober. Alloy embeds blackbox_exporter, so the whole layer is a configuration block: no extra image (the Azure nodes are arm64), no Prometheus Operator CRDs, and it rides the remote-write to Grafana Cloud that was already wired for that cluster’s metrics.

  • It alerts in Grafana Cloud, not in the workload cluster’s Alertmanager. An outage that takes out the cluster must not also take out the alert about it — the same reasoning that placed the dead-man’s switch there.

  • It probes every customer-facing hostname, not one per service. The services are multi-tenant and each tenant is reached at its own alias — the registration portal alone answers on registration, wcsc, hnr and hnr-vc (v1) plus registration-v2, wcsc-v2 and members (v2). Each alias carries its own DNS record and its own certificate, so probing one proves nothing about the others: a tenant can be dark on an expired certificate or a missing record while the host the probe happened to pick stays green. Per-host probing is what makes probe_ssl_earliest_cert_expiry and probe_dns_lookup_time_seconds mean anything. Eighteen targets, production only — dev, stage and internal tooling are excluded because they are not customer-facing.

One target needs a non-default check. mcp.idealogic.co.za is OAuth-protected and returns 401 at its root, so it is probed with a module that accepts 401 alongside 2xx. A 401 there has already proved DNS, TLS and liveness; treating it as a failure would have meant a permanently firing alert.

When an external probe fails, the first diagnostic is the in-cluster probe for the same endpoint. If that one is still passing, the fault is in the path to the cluster rather than in the application behind it — which is precisely the distinction the layer exists to make.

A further rule watches for the external results stopping altogether, because a lost vantage point is otherwise indistinguishable from a healthy estate. That is the same silent-failure shape the heartbeat rules address, so it sits in the same group.

5. Component configuration

As deployed in the observability namespace, unless noted. Chart versions move; treat this table as the shape rather than the current pin, and read the ArgoCD applications in the cluster GitOps repository for authoritative values.

Component Configuration

Loki

Single-binary deployment, one replica, filesystem storage on a 200 GiB Longhorn volume. Retention enabled at 720h (30 days) with a filesystem delete-request store. The read/write/backend components of the distributed mode are scaled to zero — this is a single-tenant estate, not a multi-tenant service.

Tempo

Single instance, OTLP receiver, 720h retention on a 100 GiB Longhorn volume (longhorn-replica-1).

Grafana

Entra ID (Azure AD) sign-in with open self-provisioning at the Viewer role, and viewers_can_edit enabled so a Viewer gets Explore and Drilldown without gaining write access to shared dashboards. Five datasources: Prometheus (default), Loki, Tempo, Alertmanager, and a Grafana Cloud Prometheus for the Azure cluster. Dashboards are provisioned by the sidecar from labelled ConfigMaps across two namespaces, foldered by annotation.

OpenTelemetry Collector

Four pipelines — traces to Tempo, metrics by remote-write to Prometheus, logs to Loki, and the isolated heartbeat to Grafana Cloud. Memory ceilings are nested so the container limit sits above the collector’s own limit_mib, preserving graceful backpressure.

Alloy (logs)

DaemonSet; each member tails only pods scheduled on its own node. Applies CRI parsing, drops known probe noise, joins multi-line Java stack traces into single entries for the EMS namespaces, and attaches severity as structured metadata rather than a stream label — a level label would multiply stream cardinality by its value count for no query benefit.

Alloy (events)

Deployment; ships Kubernetes events to Loki under a distinguishing source label.

Prometheus + Alertmanager

Rancher-managed, in cattle-monitoring-system. Remote-write receiver enabled to accept the collector’s metrics. The Alertmanager routing tree is held in a committed Secret rather than in AlertmanagerConfig CRs or chart values — a CR can only contribute a namespace-scoped sub-route, so it cannot own the top-level route, and chart values would turn every routing change into a Helm upgrade of the alerting stack.

Blackbox Exporter

HTTP probes against public endpoints, driven by Probe custom resources held in Git. In-cluster only — the external equivalent runs on the Azure cluster, see The external layer.

5.1. Alert routing

Alerts are Prometheus- and Alertmanager-managed; Grafana’s own unified alerting holds no rules. Routing is first-match, with info and none severities routed to a null receiver — on a three-node estate they are noise — and inhibition rules written only where both alerts genuinely share a join label. A rule whose equal labels are absent on one side never fires and looks identical to one that has simply had no cause to.

Note that a rule engine other than Prometheus — the Loki ruler, for instance — reaches Alertmanager directly and never appears in Prometheus’s ALERTS metric. Anything counting alerts from ALERTS alone will under-report. See Observability Access.

6. The second cluster

The Azure cluster that hosts Rancher ships metrics only, through the k8s-monitoring chart, to Grafana Cloud’s free tier — not to the self-hosted stack. No logs, no traces.

It also carries the external probes described above, which is the one place it observes the other cluster rather than itself.

That is a cost decision, not an oversight: AKS log volume would dwarf the free tier, and the question that cluster has to answer is narrow — is Rancher up, is ArgoCD syncing, are the nodes under pressure, is cert-manager renewing. The series budget is scoped hard against a 10,000-series ceiling and measured against Grafana Cloud rather than estimated from the agent’s own WAL counters, which over-report.

The practical consequence for anyone querying: an empty Loki result for an Azure workload is correct behaviour. Only the on-premises cluster’s logs reach Loki.

7. Deliberately not built

Not adopted Why

A ServiceMonitor per EMS service

spanmetrics already derives RED metrics from the trace stream. Scraping Actuator lands the same measurements under a second label set.

Mimir, or a second Prometheus

The existing Rancher Prometheus had working retention and capacity. Enabling its remote-write receiver solved the metrics gap without a new component.

A dedicated alert UI (Karma)

Grafana’s Alertmanager datasource provides alert listing and silence management in a UI already deployed and already behind Entra sign-in. A second tool would mean a second authentication surface for a subset of the same function.

On-call scheduling (GoAlert)

Its value is rota mechanics — escalation, acknowledgement, hand-over — against what is currently a single-operator estate. The problem-list need is met by a dashboard.

Migrating Rancher’s bundled dashboards

They are chart-owned and regenerate on every upgrade. The upstream kubernetes-mixin set is declared as labelled ConfigMaps instead, so it stays current and under GitOps; copying them would create a fork that goes stale silently.