Skip to content

Commit eb77e3b

Browse files
committed
feat: add observability and smoke tests
1 parent b18bea1 commit eb77e3b

File tree

7 files changed

+143
-13
lines changed

7 files changed

+143
-13
lines changed

.github/workflows/smoke-tests.yml

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
name: Smoke Tests
2+
3+
on:
4+
workflow_dispatch:
5+
6+
jobs:
7+
smoke-tests:
8+
runs-on: ubuntu-latest
9+
steps:
10+
- name: Checkout repository
11+
uses: actions/checkout@v4
12+
13+
- name: Set up Docker Buildx
14+
uses: docker/setup-buildx-action@v3
15+
16+
- name: Run smoke tests
17+
run: |
18+
COMPOSE_FILE=docker-compose.yml ./scripts/smoke-tests.sh

Makefile

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,10 @@ build:
1212

1313
test:
1414
go test ./...
15+
python3 -m pytest
16+
17+
smoke:
18+
COMPOSE_FILE=docker-compose.yml ./scripts/smoke-tests.sh
1519

1620
# Formatting targets
1721
format: format-go format-python

app/main.py

Lines changed: 36 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -1,26 +1,50 @@
1-
from flask import Flask, request, jsonify
1+
import logging
2+
import time
3+
from contextlib import contextmanager
4+
5+
from flask import Flask, jsonify, request
6+
7+
from app.telemetry import setup as telemetry_setup
8+
29

310
app = Flask(__name__)
11+
telemetry_setup(app, service_name="keep-app")
12+
13+
14+
@contextmanager
15+
def record_route_metrics(route: str):
16+
start = time.perf_counter()
17+
try:
18+
yield
19+
finally:
20+
duration_ms = (time.perf_counter() - start) * 1000
21+
logging.getLogger(__name__).info(
22+
"route completed",
23+
extra={"route": route, "duration_ms": round(duration_ms, 2)},
24+
)
425

526

627
@app.route("/health")
728
def health():
8-
return {"status": "ok"}
29+
with record_route_metrics("health"):
30+
return {"status": "ok"}
931

1032

1133
@app.route("/")
1234
def index():
13-
cert_subject = request.headers.get("X-Client-Subject", "unknown")
14-
if cert_subject.startswith("Subject="):
15-
cert_subject = cert_subject.replace("Subject=", "", 1)
16-
device_id = request.headers.get("X-Device-ID", "unknown")
17-
return (
18-
f"Hello from keep protected app!\n"
19-
f"Client cert subject: {cert_subject}\n"
20-
f"Device ID: {device_id}\n"
21-
)
35+
with record_route_metrics("index"):
36+
cert_subject = request.headers.get("X-Client-Subject", "unknown") or "unknown"
37+
if cert_subject.startswith("Subject="):
38+
cert_subject = cert_subject.replace("Subject=", "", 1)
39+
device_id = request.headers.get("X-Device-ID", "unknown") or "unknown"
40+
return (
41+
f"Hello from keep protected app!\n"
42+
f"Client cert subject: {cert_subject}\n"
43+
f"Device ID: {device_id}\n"
44+
)
2245

2346

2447
@app.route("/step-up", methods=["POST"])
2548
def step_up():
26-
return jsonify({"status": "step-up required"}), 202
49+
with record_route_metrics("step_up"):
50+
return jsonify({"status": "step-up required"}), 202

app/requirements.txt

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,7 @@
11
Flask==3.0.3
22
gunicorn==21.2.0
33
requests==2.32.3
4+
opentelemetry-sdk==1.26.0
5+
opentelemetry-exporter-otlp-proto-http==1.26.0
6+
opentelemetry-instrumentation-flask==0.47b0
7+
prometheus-client==0.20.0

app/telemetry.py

Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,55 @@
1+
from __future__ import annotations
2+
3+
import logging
4+
import os
5+
from typing import Optional
6+
7+
from opentelemetry import metrics, trace
8+
from opentelemetry.exporter.otlp.proto.http.metric_exporter import OTLPMetricExporter
9+
from opentelemetry.exporter.otlp.proto.http.trace_exporter import OTLPSpanExporter
10+
from opentelemetry.instrumentation.flask import FlaskInstrumentor
11+
from opentelemetry.sdk.metrics import MeterProvider
12+
from opentelemetry.sdk.metrics.export import PeriodicExportingMetricReader
13+
from opentelemetry.sdk.resources import Resource
14+
from opentelemetry.sdk.trace import TracerProvider
15+
from opentelemetry.sdk.trace.export import BatchSpanProcessor
16+
17+
_logger = logging.getLogger(__name__)
18+
19+
20+
def _resource(service_name: str, environment: str) -> Resource:
21+
return Resource.create({
22+
"service.name": service_name,
23+
"deployment.environment": environment,
24+
})
25+
26+
27+
def init_tracing(service_name: str, environment: str, endpoint: str, insecure: bool) -> None:
28+
exporter = OTLPSpanExporter(endpoint=endpoint or None, insecure=insecure)
29+
tracer_provider = TracerProvider(resource=_resource(service_name, environment))
30+
tracer_provider.add_span_processor(BatchSpanProcessor(exporter))
31+
trace.set_tracer_provider(tracer_provider)
32+
33+
34+
def init_metrics(service_name: str, environment: str, endpoint: str, insecure: bool) -> None:
35+
exporter = OTLPMetricExporter(endpoint=endpoint or None, insecure=insecure)
36+
reader = PeriodicExportingMetricReader(exporter)
37+
provider = MeterProvider(resource=_resource(service_name, environment), metric_readers=[reader])
38+
metrics.set_meter_provider(provider)
39+
40+
41+
def instrument_flask_app(app) -> None:
42+
FlaskInstrumentor().instrument_app(app)
43+
44+
45+
def setup(app, service_name: str, environment: Optional[str] = None) -> None:
46+
environment = environment or os.getenv("APP_ENV", "dev")
47+
endpoint = os.getenv("OTEL_EXPORTER_OTLP_ENDPOINT", "")
48+
insecure = os.getenv("OTEL_EXPORTER_OTLP_INSECURE", "true").lower() == "true"
49+
50+
try:
51+
init_tracing(service_name, environment, endpoint, insecure)
52+
init_metrics(service_name, environment, endpoint, insecure)
53+
instrument_flask_app(app)
54+
except Exception: # pragma: no cover - best effort initialization
55+
_logger.exception("failed to initialize telemetry")

app/tests/test_main.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import pytest
2-
from main import app
2+
3+
from app.main import app
34

45

56
@pytest.fixture

scripts/smoke-tests.sh

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
#!/usr/bin/env bash
2+
set -euo pipefail
3+
4+
COMPOSE_FILE=${COMPOSE_FILE:-docker-compose.yml}
5+
6+
echo "Starting smoke test environment using ${COMPOSE_FILE}"
7+
docker compose -f "${COMPOSE_FILE}" up --build -d
8+
9+
cleanup() {
10+
echo "Stopping smoke test environment"
11+
docker compose -f "${COMPOSE_FILE}" down -v
12+
}
13+
14+
trap cleanup EXIT
15+
16+
echo "Waiting for services to become healthy"
17+
sleep 10
18+
19+
echo "Running health checks"
20+
docker compose -f "${COMPOSE_FILE}" exec app curl -sf http://localhost:5000/health
21+
docker compose -f "${COMPOSE_FILE}" exec authz curl -sf http://localhost:8080/health || true
22+
docker compose -f "${COMPOSE_FILE}" exec inventory curl -sf http://localhost:8080/health
23+
24+
echo "Smoke tests completed successfully"

0 commit comments

Comments
 (0)