What You'll Build
In this exercise you will instrument the IPL analytics platform with the complete observability stack — Prometheus metrics via FastAPI instrumentation, OpenTelemetry traces sent to Jaeger, and log correlation via structured JSON logging with trace_id fields — and then migrate the platform's deployment workflow to GitOps using Argo CD. By the end, every API request will produce a correlated metric, trace, and log entry, and every cluster change will flow through a Git commit that Argo CD applies automatically. You will then generate a traffic spike using a load test, observe the HPA scaling event in Grafana, trace one slow request end-to-end in Jaeger from the Nginx IngressController span through the FastAPI span to the PostgreSQL query span, and confirm that the Prometheus alert for high p99 latency fires during the spike and resolves automatically afterward.
Prerequisites
- kube-prometheus-stack installed and running in the monitoring namespace — verify with `kubectl get pods -n monitoring` showing Prometheus, Grafana, and Alertmanager all Running.
- Argo CD installed in the argocd namespace — verify with `kubectl get pods -n argocd` and `argocd login <argocd-server>` working before beginning.
- The IPL platform manifests committed to a Git repository that Argo CD can access — create a manifests repository on GitHub and push all current k8s/ directory YAML files before starting the GitOps migration.
- The `prometheus-fastapi-instrumentator` and `opentelemetry-sdk` packages added to requirements.txt in the IPL API source repository, and a new image built, scanned, and pushed to ECR.
- Jaeger all-in-one deployed in the monitoring namespace for trace storage: `kubectl apply -f https://github.com/jaegertracing/jaeger-operator/releases/latest/download/jaeger-operator.yaml -n monitoring`.
Step 1 — Foundation
Instrument the FastAPI application with Prometheus metrics and OpenTelemetry tracing, build and push the instrumented image to ECR, then update the API Deployment manifest in the manifests repository and commit the image tag change. Verify that Prometheus is scraping the new `/metrics` endpoint and that trace spans appear in Jaeger before proceeding to the GitOps setup.
# Step 1: Instrument FastAPI + update manifest + verify scraping
# ── 1. Update main.py with instrumentation ────────────────────────────────
cat >> main.py << 'EOF'
from prometheus_fastapi_instrumentator import Instrumentator
from opentelemetry import trace
from opentelemetry.sdk.trace import TracerProvider
from opentelemetry.sdk.trace.export import BatchSpanProcessor
from opentelemetry.exporter.otlp.proto.grpc.trace_exporter import OTLPSpanExporter
from opentelemetry.instrumentation.fastapi import FastAPIInstrumentor
import logging, json
# Structured JSON logging with trace_id correlation
logging.basicConfig(
level=logging.INFO,
format='{"time":"%(asctime)s","level":"%(levelname)s","message":"%(message)s","trace_id":"%(trace_id)s"}',
)
# OpenTelemetry provider
provider = TracerProvider()
provider.add_span_processor(BatchSpanProcessor(
OTLPSpanExporter(endpoint="http://jaeger-collector.monitoring.svc.cluster.local:4317")
))
trace.set_tracer_provider(provider)
FastAPIInstrumentor.instrument_app(app)
Instrumentator().instrument(app).expose(app, include_in_schema=False)
tracer = trace.get_tracer(__name__)
@app.get("/batters/{player_id}", response_model=BatterStats)
async def get_batter(player_id: str) -> BatterStats:
with tracer.start_as_current_span("get_batter") as span:
span.set_attribute("player_id", player_id)
if player_id not in BATTERS:
span.set_attribute("error", True)
raise HTTPException(status_code=404, detail=f"Player '{player_id}' not found")
return BATTERS[player_id]
EOF
# ── 2. Build and push instrumented image ──────────────────────────────────
GIT_SHA=$(git rev-parse --short HEAD)
IMAGE="123456789.dkr.ecr.ap-south-1.amazonaws.com/ipl-scorecard:sha-$GIT_SHA"
docker buildx build --tag $IMAGE --push .
trivy image --exit-code 1 --severity CRITICAL,HIGH $IMAGE
cosign sign --yes $IMAGE # supply chain signing
# ── 3. Update image tag in manifests repository ───────────────────────────
cd ../ipl-platform-k8s
sed -i "s|ipl-scorecard:sha-.*|ipl-scorecard:sha-$GIT_SHA|g" k8s/production/deployments/ipl-api-rollout.yaml
git add k8s/production/deployments/ipl-api-rollout.yaml
git commit -m "ci: update ipl-api to sha-$GIT_SHA with OTel instrumentation"
git push
# ── 4. Watch Argo CD detect and apply the change ─────────────────────────
argocd app watch ipl-platform
# Health: Healthy
# Sync Status: Synced
# Last Sync: sha-$GIT_SHA (ci: update ipl-api to sha-$GIT_SHA...)
# ── 5. Verify Prometheus is scraping metrics ─────────────────────────────
kubectl port-forward -n monitoring svc/kube-prometheus-stack-prometheus 9090:9090 &
sleep 3
# Check targets in Prometheus UI or via API
curl -s "http://localhost:9090/api/v1/targets?state=active" | python3 -c "import sys,json; targets=json.load(sys.stdin)['data']['activeTargets'];
[print(t['labels']['job'], t['health']) for t in targets if 'ipl-api' in str(t['labels'])]"
# Expected: ipl-api up
# Verify metric exists
curl -s "http://localhost:9090/api/v1/query?query=http_requests_total{app='ipl-api'}" | python3 -c "import sys,json; d=json.load(sys.stdin); print(len(d['data']['result']), 'time series found')"
# Expected: > 0 time series found ✓Step 2 — Core Logic
Set up Argo CD with the manifests repository and create the Application that watches the production directory. Verify the initial sync completes successfully and the Application shows Synced+Healthy. Then apply a deliberate drift by manually editing the API replica count and confirm that Argo CD's selfHeal reverts the change within the reconciliation window.
# Step 2: Argo CD Application + GitOps drift detection
# ── Create Argo CD Application ────────────────────────────────────────────
argocd app create ipl-platform --repo https://github.com/org/ipl-platform-k8s.git --path k8s/production --dest-server https://kubernetes.default.svc --dest-namespace production --sync-policy automated --auto-prune --self-heal --revision main
# Trigger initial sync
argocd app sync ipl-platform --timeout 300
# Verify all resources are synced
argocd app get ipl-platform
# Expected: Status: Synced, Health: Healthy
# ── Demonstrate selfHeal: create deliberate drift ─────────────────────────
echo "Current replicas:"
kubectl get deployment ipl-api -n production -o jsonpath='{.spec.replicas}'
echo "Creating drift: scaling to 10 replicas manually..."
kubectl scale deployment ipl-api --replicas=10 -n production
echo "Waiting for Argo CD selfHeal to revert..."
sleep 180 # wait up to 3 minutes for reconciliation
echo "Replicas after selfHeal:"
kubectl get deployment ipl-api -n production -o jsonpath='{.spec.replicas}'
# Expected: 3 (reverted to Git value) ✓
# ── Import Grafana dashboards via GitOps ──────────────────────────────────
# Add dashboard ConfigMaps to manifests repo
cp k8s/dashboards/ipl-api-dashboard.json ../ipl-platform-k8s/k8s/production/dashboards/
git -C ../ipl-platform-k8s add k8s/production/dashboards/
git -C ../ipl-platform-k8s commit -m "feat: add IPL API Grafana dashboard"
git -C ../ipl-platform-k8s push
# Argo CD picks up the new ConfigMap and Grafana sidecar imports the dashboard
sleep 120
kubectl get configmap -n monitoring -l grafana_dashboard=1 | grep ipl-api
# Expected: ipl-api-dashboard ConfigMap present ✓
# Access Grafana dashboard
kubectl port-forward -n monitoring svc/kube-prometheus-stack-grafana 3000:80 &
echo "Grafana: http://localhost:3000 (admin / prom-operator)" Step 3 — Integration & Enhancement
Run a load test, observe the complete observability response — HPA scaling in Grafana metrics, a slow request in Jaeger traces, and the alert firing and resolving in Alertmanager — then confirm the PrometheusRule alert for high p99 latency fired and resolved correctly. This end-to-end observability test is the verification that the three-signal model provides a complete view of the platform's behaviour under stress.
# Step 3: Load test + observability verification
# ── Run load test that triggers HPA scaling ───────────────────────────────
echo "Starting load test..."
kubectl run load-test --image=williamyeh/wrk --rm --restart=Never -n production -- -t4 -c200 -d90s http://ipl-api.production.svc.cluster.local:8000/batters &
# ── Observe metrics in real time ──────────────────────────────────────────
echo "Watching HPA during load test..."
kubectl get hpa ipl-api-hpa -n production -w &
HPA_PID=$!
echo "Watching Pod count during load test..."
kubectl get pods -l app=ipl-api -n production -w &
POD_PID=$!
sleep 90 # wait for load test to complete
kill $HPA_PID $POD_PID 2>/dev/null
# ── Verify Grafana shows the scaling event ────────────────────────────────
echo "Checking Prometheus for scaling metrics..."
curl -s "http://localhost:9090/api/v1/query_range?query=kube_deployment_status_replicas_ready{namespace='production',deployment='ipl-api'}&start=$(date -d '-5 minutes' +%s)&end=$(date +%s)&step=15" | python3 -c "import sys,json; d=json.load(sys.stdin);
vals=[float(v[1]) for v in d['data']['result'][0]['values']]
print(f'Min replicas during test: {min(vals):.0f}, Max: {max(vals):.0f}')"
# Expected: Max should be > 2 (HPA scaled up) ✓
# ── Check for a trace in Jaeger ───────────────────────────────────────────
kubectl port-forward -n monitoring svc/jaeger-query 16686:16686 &
sleep 3
# Query Jaeger API for recent traces
curl -s "http://localhost:16686/api/traces?service=ipl-api&limit=5&lookback=10m" | python3 -c "import sys,json; d=json.load(sys.stdin);
print(f'Found {len(d["data"])} traces')
if d['data']:
t = d['data'][0]
spans = t['spans']
print(f'First trace: {len(spans)} spans')
for s in spans:
print(f' {s["operationName"]}: {s["duration"]/1000:.1f}ms')
"
# Expected: traces with spans for HTTP request + database query ✓
# ── Verify alert fired during load test ───────────────────────────────────
kubectl port-forward -n monitoring svc/kube-prometheus-stack-alertmanager 9093:9093 &
sleep 3
curl -s "http://localhost:9093/api/v1/alerts" | python3 -c "import sys,json; d=json.load(sys.stdin);
for alert in d['data']:
if 'IPLApi' in alert.get('labels',{}).get('alertname',''):
print(f'Alert: {alert["labels"]["alertname"]} state={alert["status"]["state"]}')"
# Expected: IPLApiHighP99Latency state=resolved (alert fired and resolved) ✓ Step 4 — Testing & Verification
# Final exercise checklist
echo "=== M6 Exercise — Observability + GitOps Verification ==="
# 1. Prometheus scraping API metrics
echo -n "1. Prometheus scraping ipl-api: "
METRIC_COUNT=$(curl -s "http://localhost:9090/api/v1/query?query=http_requests_total{app='ipl-api'}" | python3 -c "import sys,json; d=json.load(sys.stdin); print(len(d['data']['result']))")
[ "${METRIC_COUNT:-0}" -gt "0" ] && echo "PASS ✓ ($METRIC_COUNT series)" || echo "FAIL ✗"
# 2. Traces appearing in Jaeger
echo -n "2. Traces appearing in Jaeger: "
TRACE_COUNT=$(curl -s "http://localhost:16686/api/traces?service=ipl-api&limit=1&lookback=1h" | python3 -c "import sys,json; d=json.load(sys.stdin); print(len(d['data']))")
[ "${TRACE_COUNT:-0}" -gt "0" ] && echo "PASS ✓" || echo "FAIL ✗"
# 3. Argo CD Application Synced
echo -n "3. Argo CD Application Synced: "
SYNC=$(argocd app get ipl-platform -o json 2>/dev/null | python3 -c "import sys,json; d=json.load(sys.stdin); print(d['status']['sync']['status'])")
[ "$SYNC" = "Synced" ] && echo "PASS ✓" || echo "FAIL ✗ (status: $SYNC)"
# 4. SelfHeal reverted manual scale
echo -n "4. SelfHeal working (replicas=3): "
REPLICAS=$(kubectl get deployment ipl-api -n production -o jsonpath='{.spec.replicas}')
[ "$REPLICAS" = "3" ] && echo "PASS ✓" || echo "FAIL ✗ (got: $REPLICAS)"
# 5. HPA scaled during load test
echo -n "5. HPA scaled during load test: "
kubectl describe hpa ipl-api-hpa -n production | grep -q "SuccessfulRescale" && echo "PASS ✓" || echo "FAIL ✗"
# 6. PrometheusRule alert exists
echo -n "6. PrometheusRule alert configured: "
kubectl get prometheusrule ipl-api-alerts -n monitoring > /dev/null 2>&1 && echo "PASS ✓" || echo "FAIL ✗"
echo "=== Exercise verification complete ===" Warning: When migrating a running cluster to GitOps management with `prune: true` and `selfHeal: true`, verify that the manifests repository contains a complete and accurate representation of every resource in the target namespace before enabling Argo CD's automated sync. Any resource in the cluster that is absent from the Git repository will be deleted by `prune: true` on the first automated sync — this includes resources created by Helm charts not tracked in the manifests repo, manually created debug Pods, and any other resource that was created outside of Git. Run the first sync manually (`argocd app sync --dry-run`) to preview what would be pruned before enabling automated sync with prune.
- The complete observability loop — metric spike in Grafana, corresponding slow trace in Jaeger, correlated log entry with trace_id — provides the evidence needed to diagnose any production incident without guesswork.
- GitOps selfHeal provides empirical evidence that the Git repository is authoritative: manual replica count changes are reverted within the reconciliation window, proving the control loop is functioning.
- PrometheusRule alerts should fire during the load test and resolve automatically afterward — a failure to fire indicates the PromQL expression is incorrect; a failure to resolve indicates the resolution window needs adjustment.
- The image update workflow (CI builds → pushes → updates manifest tag → Git commits → Argo CD applies) is the production deployment pipeline; any deviation from this workflow (manual kubectl apply) will be reverted by selfHeal.
- Structured JSON logging with trace_id correlation bridges the gap between traces (which show request latency) and logs (which show error details) — the trace_id in the log entry links the two signals for a specific failing request.
- The complete platform at the end of M6 Exercise has all five production-readiness properties: secure (RBAC+PSA+NetworkPolicies), observable (metrics+traces+logs), automated deployment (GitOps), resilient (multi-AZ+HPA), and governed (Kyverno policies).