Hands-On Labs · The Capstone · Part 4 of 5

Capstone Part 4 — Observability

This is Part 4 of the five-part capstone running through this whole course: one continuously evolving service, kubestronaut-tracker, carried from a bare git repo through GitOps delivery and a progressive canary rollout, into a mesh with STRICT mutual TLS and Kyverno enforcing admission in Part 3. It runs, it's delivered safely, it's locked down — and nobody watching from outside the mesh can see a single number or a single request path through it. This part gives it eyes: real golden-signal metrics scraped through the exact mTLS wall Part 3 just built, zero-code OpenTelemetry tracing that follows one request across the mesh boundary itself, a dashboard with one panel per signal, and precisely two Prometheus alerts wired to fire — not the twenty a nervous team is tempted to write. Part 5 links straight to the dashboard this part builds.

☺ Explain it like I'm 10

Picture Mission Control after the ship is sealed, pressurized, and locked — every hatch checked, every stowaway kept out. Great news for safety. Terrible news if nobody inside can radio back how fast the engines are running or whether the air is thinning. This part doesn't reopen a single hatch. Instead it does something cleverer: it uses the one tiny porthole the ship's own systems were always allowed to answer questions through — a maintenance panel that was never sealed off, because even a locked-down ship still has to answer a doctor's stethoscope — and reads the vital signs through that. And it tags every message a crew member sends with a ticket number, so if a radio call gets garbled somewhere between the crew cabin and Mission Control, you can trace the exact ticket through every relay it passed, not just guess where it went quiet.

⚠ Where you are arriving from, and where you're headed

Arriving: kubestronaut-tracker running in the tracker namespace, delivered by Argo CD and promoted through an Argo Rollouts canary, with sidecar injection enabled and a PeerAuthentication in mode: STRICT — every hop into any pod in tracker now requires a valid mesh certificate — and Kyverno enforcing three rules cluster-wide: no :latest image tag, the sidecar-injection label present, no root user. Nothing in platform — where Argo CD, the Rollouts controller, istiod, and Kyverno itself all run — is enrolled in the mesh. Leaving this page: kube-prometheus-stack running in platform; a ServiceMonitor that actually survives STRICT mTLS by scraping the one exempted port every meshed pod already exposes; kubestronaut-tracker exposing the four golden signals via prom-client; zero-code OpenTelemetry instrumentation via the OTel Operator; an OpenTelemetryCollector pipeline that receives spans from both the app and the mesh's own Envoy sidecars and forwards one merged trace per request to a small Jaeger instance; a Grafana dashboard checked in as a labeled ConfigMap; and exactly two PrometheusRule alerts. Part 5 links its Backstage entry straight to the dashboard built here.

🐘Your host for this part: Ellie the Elephant — she never drops a metric, a log line, or a trace, and this whole part is her workshop. 🦉 Professor Owl, who built Part 3's mesh, drops by once — the mTLS wall he raised is exactly what this part has to see through, not tear down.

What you're building on, and what this part adds

☺ Like you're 10: The ship is built, sealed, and flying. This part only adds instruments — nothing here reopens a hatch Part 3 closed.

This page assumes the whole world described on the lab track hub and everything Part 3 left behind: a kubestronaut-tracker Deployment and a single-replica Postgres in the tracker namespace, sidecar-injected, sitting behind a STRICT PeerAuthentication; a Kyverno ClusterPolicy in enforce mode rejecting anything with a :latest tag, a missing sidecar-injection label, or a root user; and everything add-on-shaped — Argo CD, the Argo Rollouts controller, istiod, Kyverno — living unmeshed in platform. This page adds four things to that picture, all in platform except the app changes, which land in tracker:

ThingName / shapeIntroduced
Applicationkubestronaut-trackerGET /health, GET /certs, POST /certs/:id/complete, GET /progress, port 3000Part 1
Mesh & policytracker namespace, sidecar-injected, PeerAuthentication STRICT; Kyverno enforcing 3 rules cluster-widePart 3
Metrics stackkube-prometheus-stack — Prometheus, its Operator, Alertmanager, Grafana — in platformPart 4 — this page
Scrape pathServiceMonitor targeting port 15020, path /stats/prometheus — the one port STRICT doesn't touchPart 4 — this page
TracingOTel Operator Instrumentation CR (zero-code) + an OpenTelemetryCollector in platform, exporting to a small Jaeger instancePart 4 — this page
Alert countexactly two, cluster-native: TrackerHighErrorRate, TrackerHighLatencyPart 4 — this page

Everything below assumes the mesh and policy vocabulary from Istio and Kyverno, and goes deep on two tools already covered on their own pages: Prometheus for the scrape model, relabeling, and alerting rules underneath the Operator's CRDs, and the OpenTelemetry Collector for the five component types and the declared-vs-wired trap this page's own pipeline has to get right. Read those first if ServiceMonitor, PrometheusRule, or OpenTelemetryCollector feel like unfamiliar shapes — this page puts them to work against a real mesh rather than re-explaining them. Platform Engineering's Prometheus page and its OpenTelemetry page cover the same Operator layer from the CNPE's own angle, if that framing helps.

Installing kube-prometheus-stack into platform

☺ Like you're 10: One chart installs the whole instrument panel at once — the gauges, the recorder, and the alarm bell — instead of three separate installs that have to be wired together by hand.

On a Kubernetes platform, hand-writing prometheus.yml the way a plain-VM install would is the wrong layer to work at — the Prometheus page's raw config is what the Operator generates from your CRDs, not what you edit directly. kube-prometheus-stack installs the Prometheus Operator, a Prometheus instance it manages, a dedicated Alertmanager, and Grafana, all pre-wired to each other:

helm repo add prometheus-community https://prometheus-community.github.io/helm-charts
helm repo update

helm upgrade --install kube-prometheus-stack prometheus-community/kube-prometheus-stack \
  --namespace platform --create-namespace \
  --set grafana.adminPassword='...' \
  --set prometheus.prometheusSpec.serviceMonitorSelectorNilUsesHelmValues=false \
  --set prometheus.prometheusSpec.podMonitorSelectorNilUsesHelmValues=false \
  --set prometheus.prometheusSpec.ruleSelectorNilUsesHelmValues=false

kubectl -n platform get pods -l "release=kube-prometheus-stack"
# prometheus-kube-prometheus-stack-prometheus-0   Running
# alertmanager-kube-prometheus-stack-alertmanager-0   Running
# kube-prometheus-stack-grafana-...                   Running

The three *SelectorNilUsesHelmValues=false flags matter more than they look: by default the chart's own Prometheus CR only picks up ServiceMonitor, PodMonitor, and PrometheusRule objects carrying the chart's own release label, which quietly ignores every CRD you write yourself unless you either match that label or flip these three flags to make Prometheus watch every CRD of each kind, cluster-wide. Skip this and the ServiceMonitor two sections down applies cleanly, reports no error anywhere, and simply never gets read — the exact kind of "declared but not wired" trap the Collector page warns about, one CRD layer up.

Scraping through STRICT mTLS: the merged-metrics port Istio already gives you

☺ Like you're 10: Instead of knocking on the ship's front door — which is bolted since Part 3 — you read the vitals off the one maintenance porthole that was never sealed, because even a locked ship still has to let a doctor's stethoscope through.

Istio's own gotcha list names this exact failure first among the four things STRICT mTLS commonly breaks: "a Prometheus instance scraping a meshed namespace from outside the mesh." That's precisely this page's situation — kube-prometheus-stack lives unenrolled in platform, and every pod it wants to scrape in tracker now refuses any caller that can't present a valid mesh certificate. A plain ServiceMonitor pointed at kubestronaut-tracker's own port 3000 gets a connection reset, every single scrape, forever — and Prometheus's /targets page reports it as DOWN with no hint that the fix is anywhere near mTLS.

The fix doesn't touch Part 3's PeerAuthentication at all. Every sidecar-injected pod already runs an istio-agent that answers on port 15020 — the same port kubelet's own httpGet liveness and readiness probes use, which is why Istio exempts it from mTLS enforcement in the first place: a kubelet probe can't present a mesh certificate either, and the platform can't function if STRICT silently breaks every pod's own health checks. That exemption has a second job: by default the agent also scrapes the application's own /metrics endpoint over localhost — a loopback hop inside the same pod, never crossing the mTLS boundary at all — and merges the result with Envoy's own proxy-level stats, serving the combined set at 15020/stats/prometheus. Point the ServiceMonitor there instead of at the app's own port, and the scrape succeeds under STRICT with zero exceptions carved into the mesh:

# tracker/observability/servicemonitor.yaml
apiVersion: monitoring.coreos.com/v1
kind: ServiceMonitor
metadata:
  name: kubestronaut-tracker
  namespace: tracker
  labels: { release: kube-prometheus-stack }   # picked up even without the Selector flags above
spec:
  selector:
    matchLabels: { app: kubestronaut-tracker }
  namespaceSelector:
    matchNames: [tracker]
  endpoints:
    - port: http-envoy-prom      # the istio-proxy container's own declared port name, 15020
      path: /stats/prometheus
      interval: 15s
      relabelings:
        - action: replace
          targetLabel: app
          replacement: kubestronaut-tracker
Prometheus platform · unmeshed kubestronaut-tracker pod · tracker namespace app container :3000 · /metrics (prom-client) istio-proxy sidecar istio-agent :15020 /stats/prometheus — merged same exemption as kubelet httpGet health probes Envoy stats :15090 feed in too scrape over localhost ✕ direct :3000 — refused, no mesh cert ✓ :15020/stats/prometheus — exempt, merged app metrics + Envoy stats, combined
◆ Key idea

STRICT PeerAuthentication only gates inbound traffic to a meshed workload. It has no opinion at all about traffic leaving a meshed pod toward an unmeshed destination — which is exactly why the OpenTelemetry Collector pipeline built two sections down needs zero mTLS accommodation, even though it also crosses the exact same mesh boundary this scrape does, just in the opposite direction. One boundary, two directions, two completely different rules — and knowing which direction you're crossing is most of debugging either one.

Instrumenting kubestronaut-tracker: the four golden signals as real metrics

☺ Like you're 10: Two counters and a stopwatch, attached to every request, answer "how busy," "how often it fails," and "how fast" — the fourth signal, saturation, Kubernetes already tracks for you.

Traffic, errors, and latency live inside the application and need instrumenting directly; saturation is a pod-level metric kube-prometheus-stack's own kubelet and cAdvisor scrape jobs already collect for every container on the cluster, no application code required. Add prom-client, and label by the Express route pattern, never the resolved path — POST /certs/:id/complete has exactly one distinct route value no matter which certification ID hits it, while the raw path would mint a brand-new, permanently-indexed series per certification, the identical cardinality trap the Prometheus page warns about:

// metrics.js — mounted once, before any route handler
const client = require("prom-client");
client.collectDefaultMetrics({ prefix: "tracker_" });

const httpRequests = new client.Counter({
  name: "http_requests_total",
  help: "Total HTTP requests",
  labelNames: ["route", "method", "status"],
});
const httpDuration = new client.Histogram({
  name: "http_request_duration_seconds",
  help: "HTTP request duration in seconds",
  labelNames: ["route", "method", "status"],
  buckets: [0.05, 0.1, 0.25, 0.5, 1, 2, 5],
});

function metricsMiddleware(req, res, next) {
  const end = httpDuration.startTimer();
  res.on("finish", () => {
    // req.route.path keeps ":id" literal — never the resolved certification ID
    const route = req.route ? req.route.path : "unmatched";
    const labels = { route, method: req.method, status: res.statusCode };
    httpRequests.inc(labels);
    end(labels);
  });
  next();
}

module.exports = { metricsMiddleware, register: client.register };
// index.js — wire it in, next to the existing /health, /certs, /progress routes
const { metricsMiddleware, register } = require("./metrics");
app.use(metricsMiddleware);
app.get("/metrics", async (req, res) => {
  res.set("Content-Type", register.contentType);
  res.end(await register.metrics());
});

Redeploy through the same Argo CD sync Part 3 left running, and confirm the scrape end to end before touching anything else — /targets in Prometheus's own UI should show the kubestronaut-tracker job as UP, and a real query should return real numbers:

# traffic: requests per second, by route
sum(rate(http_requests_total{job="kubestronaut-tracker"}[5m])) by (route)

# errors: error rate as a fraction of total requests
sum(rate(http_requests_total{job="kubestronaut-tracker", status=~"5.."}[5m]))
/
sum(rate(http_requests_total{job="kubestronaut-tracker"}[5m]))

# latency: p99 across the pod, from the histogram
histogram_quantile(0.99,
  sum(rate(http_request_duration_seconds_bucket{job="kubestronaut-tracker"}[5m])) by (le)
)

Zero-code tracing: the OpenTelemetry Operator's Instrumentation CRD

☺ Like you're 10: Instead of importing a tracing library into your own code, you stick one annotation on the pod, and something else quietly slips the library in for you before your code even starts.

The Collector page mentions the OTel Operator's second CRD in passing — Instrumentation, which injects auto-instrumentation into an annotated pod's init container without a single import statement in kubestronaut-tracker's own source. Install the Operator (it needs cert-manager for its webhooks, exactly as the Collector page's own install walkthrough covers), then declare one namespace-scoped profile:

# tracker/observability/instrumentation.yaml
apiVersion: opentelemetry.io/v1alpha1
kind: Instrumentation
metadata:
  name: tracker-instrumentation
  namespace: tracker
spec:
  exporter:
    endpoint: http://otel-collector.platform.svc:4317   # crosses the mesh boundary OUTBOUND — no mTLS wrinkle
  propagators: [tracecontext, baggage]
  sampler:
    type: parentbased_traceidratio
    argument: "1"       # 100% for this lab; a real fleet tunes this down hard
# one annotation on the Deployment's pod template — the whole opt-in
metadata:
  annotations:
    instrumentation.opentelemetry.io/inject-nodejs: "true"

Redeploy, and the Operator's mutating webhook adds an init container that copies the Node.js auto-instrumentation agent into the pod and sets NODE_OPTIONS to load it before index.js ever runs — http, express, and the Postgres client library all get instrumented automatically, each database call showing up as its own child span with zero lines added to metrics.js or index.js. The propagators line matters as much as the injection itself: it's what makes the app read an incoming traceparent header from the mesh's ingress hop and continue that same trace, rather than quietly starting a brand-new one at the app boundary every single time.

A Collector pipeline that catches both the app's spans and the mesh's own

☺ Like you're 10: The app writes its own diary page about the request; separately, every relay station along the way writes its own note too — and gluing all those pages together by their shared ticket number is what "one trace" actually means.

Auto-instrumentation gets the application's own spans out via OTLP. It says nothing about the two Envoy sidecars the request also passes through on its way in and out of the mesh — and those hops are exactly where a real production trace earns its keep, because a request that looks fine inside the app but times out at a sidecar is invisible without them. Istio's Telemetry API can point Envoy's own tracing straight at an OTLP endpoint, no extra code anywhere:

# platform/observability/istio-telemetry.yaml
apiVersion: telemetry.istio.io/v1
kind: Telemetry
metadata:
  name: mesh-default-tracing
  namespace: istio-system
spec:
  tracing:
    - providers:
        - name: otel-tracing
  # the otel-tracing provider name is defined once, in istiod's own MeshConfig,
  # pointing at otel-collector.platform.svc:4317 — the same Collector below

Both sources — the app's own OTLP export and Envoy's own OTLP export — land on one OpenTelemetryCollector in platform, which does the simplest thing a Collector can do: receive, batch, forward:

apiVersion: opentelemetry.io/v1beta1
kind: OpenTelemetryCollector
metadata:
  name: otel-collector
  namespace: platform
spec:
  mode: deployment
  config:
    receivers:
      otlp:
        protocols:
          grpc: { endpoint: 0.0.0.0:4317 }
          http: { endpoint: 0.0.0.0:4318 }
    processors:
      memory_limiter:            # ALWAYS first — see the Collector page's own gotcha list
        check_interval: 1s
        limit_percentage: 80
        spike_limit_percentage: 25
      batch: { timeout: 5s }      # ALWAYS last
    exporters:
      otlp/jaeger:
        endpoint: jaeger-collector.platform.svc:4317
        tls: { insecure: true }
      debug:
        verbosity: detailed
    service:
      pipelines:
        traces:
          receivers:  [otlp]
          processors: [memory_limiter, batch]
          exporters:  [otlp/jaeger]   # forgetting this line is the whole page's classic bug
# a minimal all-in-one Jaeger, throwaway-lab-grade — a real fleet runs Jaeger's own separated collector/query/storage
kubectl -n platform create deployment jaeger \
  --image=jaegertracing/all-in-one:1.60 --port=16686
kubectl -n platform expose deployment jaeger --port=16686 --target-port=16686 --name=jaeger-query
kubectl -n platform expose deployment jaeger --port=4317 --target-port=4317 --name=jaeger-collector
client ingress sidecar Envoy · span ① starts trace STRICT-checked hop kubestronaut-tracker pod istio-proxy · span ② inbound hop app · spans ③④ handler + Postgres child auto-instrumented, no code Postgres egress hop Envoy · span ⑤ outbound OTel Collector platform · receives + batches Jaeger OTLP export, both sources Five spans, one trace ID, one place they all end up.
⚠ If Jaeger shows app spans but never Envoy's

The single most common cause is the Telemetry resource's provider name not matching what MeshConfig's extensionProviders actually defines, or the resource living in the wrong namespace — a mesh-wide Telemetry belongs in istio-system, not tracker. Confirm with istioctl proxy-config bootstrap <pod> -n tracker | grep -A5 tracing before assuming the Collector is at fault; a Collector missing spans it was never sent isn't a Collector bug.

A dashboard with one panel per golden signal, not twenty

☺ Like you're 10: Four gauges on one panel, checked into the same repo as everything else, so the dashboard travels with the cluster instead of living only in one person's browser tab.

The kube-prometheus-stack chart's built-in sidecar watches for any ConfigMap carrying the label grafana_dashboard: "1" and mounts it straight into Grafana's provisioning directory — no click-and-forget dashboard, just a file Argo CD reconciles like any other manifest:

# platform/observability/tracker-dashboard.yaml
apiVersion: v1
kind: ConfigMap
metadata:
  name: kubestronaut-tracker-dashboard
  namespace: platform
  labels: { grafana_dashboard: "1" }
data:
  tracker.json: |
    {
      "title": "kubestronaut-tracker — golden signals",
      "panels": [
        { "title": "Request rate by status",
          "targets": [{ "expr": "sum(rate(http_requests_total{job=\"kubestronaut-tracker\"}[5m])) by (status)" }] },
        { "title": "Error rate",
          "targets": [{ "expr": "sum(rate(http_requests_total{job=\"kubestronaut-tracker\",status=~\"5..\"}[5m])) / sum(rate(http_requests_total{job=\"kubestronaut-tracker\"}[5m]))" }] },
        { "title": "p99 latency by route",
          "targets": [{ "expr": "histogram_quantile(0.99, sum(rate(http_request_duration_seconds_bucket{job=\"kubestronaut-tracker\"}[5m])) by (le, route))" }] },
        { "title": "Pod memory (saturation)",
          "targets": [{ "expr": "container_memory_working_set_bytes{namespace=\"tracker\",container=\"app\"}" }] }
      ]
    }

That fourth panel — saturation — costs zero application code, exactly as the Collector page's own agent-vs-gateway framing predicts: container_memory_working_set_bytes comes from cAdvisor, which kube-prometheus-stack already scrapes for every pod on the cluster the moment it's installed. Four panels, not a wall of them — the same restraint the alert count below insists on, applied to a dashboard instead of a page.

Two PrometheusRule alerts, not twenty

☺ Like you're 10: Only two warning lights are wired to interrupt anyone. Everything else lives on the dashboard above, for a human to check on their own schedule.

On the Operator layer, an alert is a Kubernetes object, not a line in a rule_files path — but the alerting principles underneath are exactly the ones the raw Prometheus page teaches: page on symptoms a user actually feels, and stop at two:

# platform/observability/tracker-alerts.yaml
apiVersion: monitoring.coreos.com/v1
kind: PrometheusRule
metadata:
  name: kubestronaut-tracker-alerts
  namespace: platform
  labels: { release: kube-prometheus-stack }
spec:
  groups:
    - name: kubestronaut-tracker.alerts
      rules:
        - alert: TrackerHighErrorRate
          expr: |
            sum(rate(http_requests_total{job="kubestronaut-tracker", status=~"5.."}[5m]))
            /
            sum(rate(http_requests_total{job="kubestronaut-tracker"}[5m])) > 0.05
          for: 10m
          labels: { severity: page }
          annotations:
            summary: "kubestronaut-tracker error rate above 5% for 10m"

        - alert: TrackerHighLatency
          expr: |
            histogram_quantile(0.99,
              sum(rate(http_request_duration_seconds_bucket{job="kubestronaut-tracker"}[5m])) by (le)
            ) > 1.5
          for: 10m
          labels: { severity: page }
          annotations:
            summary: "kubestronaut-tracker p99 latency above 1.5s for 10m"

Nothing here watches pod memory, CPU, or restart count directly — those are causes, not symptoms, and they already have a panel above for a human to notice on their own schedule. If a genuinely new failure mode later slips past these two, the honest fix is a better symptom alert, never a growing pile of cause-based ones.

Watching an alert actually transition, and reading the trace back

☺ Like you're 10: Break it on purpose, on a day when breaking it doesn't matter, so the first time you watch an alert fire and a trace explain why isn't during a real incident.

Prove the whole pipeline at once by injecting a real fault — point kubestronaut-tracker's DB connection string at a host that doesn't exist and redeploy through Argo CD the same way Part 3 taught:

# edit the DB host in the app's ConfigMap, commit, let Argo CD sync it in
kubectl -n tracker get configmap kubestronaut-tracker-config -o yaml
# ...change DB_HOST to something unreachable, commit to the repo, wait for sync...

# generate traffic against POST /certs/:id/complete — every one now 5xxs

# watch the alert move: inactive -> pending -> firing
watch -n 5 'kubectl -n platform exec deploy/kube-prometheus-stack-alertmanager -- \
  wget -qO- localhost:9093/api/v2/alerts | jq ".[].labels.alertname"'

Confirm it sits in pending for close to the full 10-minute window before firing — flap suppression working as designed. Then pull one failing trace out of Jaeger for the same time window and read it end to end: spans ① and ② (the ingress and inbound sidecar hops) show healthy latency, span ③ (the app handler) shows the request arriving fine, and the Postgres child span is exactly where the trace goes red — the same failure the dashboard's error-rate panel and the TrackerHighErrorRate alert are reporting from a completely different angle, all three agreeing on the same five minutes. Revert the DB host and confirm the alert clears back to inactive before moving on.

✎ Try it

Do the same drill a second way — instead of a bad DB host, drop kubestronaut-tracker's memory limit low enough that it gets OOMKilled under light load. Watch TrackerHighLatency fire (requests queue behind restarts) rather than TrackerHighErrorRate, and notice that this time the trace itself is the tell — spans simply stop arriving mid-request instead of completing red. Two different failures, two different signatures, same two alerts and the same trace tooling catching both. Drill — Trace a Broken Telemetry Pipeline is a guided version of diagnosing exactly this kind of gap under time pressure.

What "done" looks like for Part 4

☺ Like you're 10: A ship that shows its own vital signs, keeps one traceable ticket per crew request even through a locked hatch, and rings exactly two bells — never more, never fewer.

At the end of this part, kube-prometheus-stack watches kubestronaut-tracker through the one port STRICT mTLS was always going to leave open; the app exposes real golden-signal metrics via prom-client; zero-code OpenTelemetry instrumentation and Istio's own Envoy tracing both export to one Collector, which forwards a single, correlated, five-span trace per request to Jaeger; a four-panel dashboard lives as a labeled ConfigMap next to everything else Argo CD reconciles; and exactly two PrometheusRule alerts are wired and proven to transition. Nothing here is thrown away — Part 5 reads today's work directly:

PartWhat it does with today's instrumentation
5 — The PortalIts catalog-info.yaml links straight to the Grafana dashboard built here, so anyone browsing the Backstage catalog finds kubestronaut-tracker's live vital signs without asking you directly
🎬 At Mission Control
🐘

Ellie: kubestronaut-tracker can finally tell me three things about itself: how busy, how broken, how slow. And now I can follow one request's whole trip, mesh hops included.

🦊

Foxy: Wait — Prometheus lives outside the mesh and the app lives inside it, behind STRICT mTLS. How does any of this actually reach it?

🦉

Professor Owl: Through the one door I never locked, Foxy — port 15020. Every sidecar answers kubelet's health checks there, unauthenticated, or the platform couldn't function at all. Ellie's scrape just walks through that same open door.

👺

Gizmo: Or — simpler — just set PeerAuthentication back to PERMISSIVE for the whole namespace. Then Prometheus can hit port 3000 directly, no funny business. 🤑

🐢

Timmy the Turtle: That undoes every hour Owl put into Part 3, Gizmo. We didn't build STRICT mTLS to punch a hole in it the moment it's inconvenient — we built it, then found the one door it was always supposed to leave open.

🐘

Ellie: Which is the whole lesson. Observability's job here wasn't to weaken Part 3 — it was to see through it, cleanly, using exactly the exemption Istio already ships for this.

Milestones

☺ Like you're 10: Tick each box only once you've actually watched it happen on your own cluster, not because the step "sounds right."

Work these in order — each depends on the cluster state from the one before. Progress saves in this browser.

0 / 12 milestones complete
1Confirm Part 3's mesh and policy state is actually in place
Check that tracker is sidecar-injected, its PeerAuthentication is STRICT, and Kyverno's three-rule ClusterPolicy is in Enforce — nothing new to write here.
Done when: istioctl x describe pod <tracker-pod> -n tracker reports STRICT, and kubectl get cpol -o wide shows the policy's action as Enforce.
Concept: Istio · Kyverno
2Install kube-prometheus-stack into platform
Run the Helm install above with all three *SelectorNilUsesHelmValues=false flags set.
Done when: the Prometheus, Alertmanager, and Grafana pods in platform all show Running.
Concept: Prometheus · Grafana
3Add the counter, histogram, and /metrics endpoint to kubestronaut-tracker
Wire in metrics.js as shown, labeling by req.route.path — never the resolved certification ID.
Done when: kubectl -n tracker exec <pod> -- curl -s localhost:3000/metrics returns http_requests_total lines.
4Apply the ServiceMonitor targeting port 15020, path /stats/prometheus
Apply servicemonitor.yaml exactly as shown — not a plain-port scrape, which STRICT refuses outright.
Done when: /targets in Prometheus's own UI shows the kubestronaut-tracker job as UP.
5Confirm the golden-signal PromQL returns real numbers
Generate a little traffic against the app, then run the traffic, error-rate, and p99-latency queries from this page.
Done when: all three queries return non-empty series, not "no data."
Concept: PromQL
6Install the OTel Operator and apply the Instrumentation CR
Install cert-manager, the OTel Operator, then instrumentation.yaml, then annotate the Deployment for zero-code Node.js injection.
Done when: kubectl -n tracker describe pod <pod> shows an init container copying the auto-instrumentation agent in.
7Deploy the OpenTelemetryCollector and a throwaway Jaeger instance
Apply the Collector CR and the Jaeger all-in-one from this page, both in platform.
Done when: kubectl -n platform port-forward svc/jaeger-query 16686 and Jaeger's UI loads with no traces yet — before the next step, that's expected.
8Wire Envoy's own tracing via the Telemetry resource
Apply istio-telemetry.yaml in istio-system, pointed at the same Collector's OTLP endpoint.
Done when: a request to kubestronaut-tracker produces a Jaeger trace with 5 spans — 2 Envoy hops, an app handler, and a Postgres child span.
Concept: Istio · trace context propagation
9Commit the four-panel dashboard and the two PrometheusRule alerts
Apply tracker-dashboard.yaml and tracker-alerts.yaml, both through the same Argo CD sync as everything else.
Done when: "kubestronaut-tracker — golden signals" renders in Grafana with real data, and /rules in Prometheus shows both alerts as inactive.
10Inject a real fault and watch an alert transition
Point DB_HOST at an unreachable host, redeploy, generate traffic, and watch inactive → pending → firing in Alertmanager.
Done when: you've personally watched the full transition once, then reverted the fault and watched it clear back to inactive.
Concept: Flap suppression via for
11Pull the failing trace and find where it turns red
Open Jaeger for the same time window as the fired alert and read the five-span trace end to end.
Done when: you can point at the exact span — the Postgres child span, for the DB-host fault — where the trace and the alert agree.
Concept: correlating metrics and traces
12Say out loud what state you're leaving for Part 5
Confirm: metrics scraping through the mTLS wall, a five-span trace per request, a four-panel dashboard, two proven alerts.
Done when: you can describe this state without looking anything up — it's the exact starting point Part 5 assumes.
🐢 Timmy's checkpoint

1. Why does a plain ServiceMonitor pointed at kubestronaut-tracker's own port fail under STRICT mTLS, and what port fixes it without touching Part 3's PeerAuthentication at all? 2. Why does the OTel Collector pipeline built in this part need zero mTLS accommodation, even though it also crosses the mesh boundary? 3. What would go wrong within a few weeks if the metrics middleware labeled requests by the resolved URL instead of req.route.path? 4. Where do the app's own OTLP spans come from versus the mesh's own spans, and what has to be true for both to land in one correlated trace? 5. Of the four golden signals, which one required zero application code, and why?

Check your answers
  1. Every hop into a pod in tracker now requires a valid mesh certificate, and an unmeshed Prometheus in platform can't present one — the direct scrape is refused outright. Port 15020, the istio-agent's merged-metrics endpoint, is exempt for the same reason kubelet's own health probes are: the platform can't function if STRICT silently breaks its own probes, and Prometheus's scrape rides that exact same exemption.
  2. STRICT PeerAuthentication only gates inbound traffic to a meshed workload. The Collector pipeline carries traffic outbound from the meshed tracker pod to the unmeshed Collector in platform — a direction STRICT was never built to restrict in the first place.
  3. The resolved URL bakes in the specific certification ID from POST /certs/:id/complete, so every distinct ID would mint a brand-new, permanently-indexed time series. Within weeks that unbounded cardinality would bloat Prometheus's in-memory index and risk OOM-killing a perfectly healthy server — the same trap the raw Prometheus page warns about for any high-cardinality label.
  4. The app's spans come from the OTel Operator's zero-code auto-instrumentation, exported via OTLP straight from the app container. The mesh's own spans come from Envoy, exported via Istio's Telemetry resource pointed at the same Collector. Both landing in one correlated trace requires the app to actually propagate the incoming traceparent header rather than starting a fresh trace at the app boundary — which is exactly what the Instrumentation CR's propagators field turns on.
  5. Saturation — pod memory and CPU already get scraped by kube-prometheus-stack's own kubelet/cAdvisor jobs for every container on the cluster, so the dashboard's saturation panel needed zero lines of application code, unlike traffic, errors, and latency, which all live inside the app and had to be instrumented directly.

Part 4 gave kubestronaut-tracker eyes it can keep even behind a locked mesh: real golden-signal metrics scraped through the one door STRICT mTLS was always going to leave open, one correlated trace per request spanning both the app and the mesh's own Envoy hops, a four-panel dashboard checked in as code, and two proven alerts. Continue to Capstone Part 5 — The Portal, where this dashboard gets linked from a Backstage catalog entry anyone on a team could find without asking you directly. Or step back to the full lab track to see how this part fits the rest of the capstone, revisit Prometheus, Grafana, the OpenTelemetry Collector, and Istio for the concepts behind what you just built, and go diagnose a deliberately broken pipeline under time pressure in Drill — Trace a Broken Telemetry Pipeline.