Skip to content

Commit 667ace5

Browse files
OgeonX-AiAitomatesclaude
authored
feat(telemetry+docker): Phase 2 W3C trace propagation + Phase 3 container publish (#4)
Phase 2 — Telemetry Hardening (TEL-01–04): - W3CTraceContextMiddleware extracts traceparent/tracestate on every request - install_propagator() sets global CompositePropagator with TraceContextTextMapPropagator - configure_telemetry() wires AI on connection string; no-op otherwise - current_traceparent() helper surfaces active span ID for downstream events - 10 new telemetry tests (session-scoped OTel provider, exporter cleared per test) - Fixed: opentelemetry.context.attach/detach (not trace.context) Phase 3 — Docker + CI Publish (DOCK-01–03): - Multi-stage Dockerfile (builder+runtime), non-root USER app, STOPSIGNAL SIGTERM - docker-compose.yml for local smoke testing - CI: docker build → smoke test → GHCR push on main merge - 47/47 tests green, coverage 91.94% Co-authored-by: Kim Harjamäki <kim.harjamaki@prosimo.fi> Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
1 parent c7df179 commit 667ace5

12 files changed

Lines changed: 506 additions & 21 deletions

File tree

.github/workflows/ci.yml

Lines changed: 58 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ on:
77

88
permissions:
99
contents: read
10+
packages: write
1011

1112
jobs:
1213
validate:
@@ -22,4 +23,60 @@ jobs:
2223
- run: python -m mypy
2324
- run: python -m pytest
2425
- run: python -m cas_reference_product.evidence
25-
- run: docker build --platform linux/amd64 -t cas-reference-product:ci .
26+
27+
docker:
28+
runs-on: ubuntu-latest
29+
needs: validate
30+
steps:
31+
- uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5
32+
33+
- name: Set up Docker Buildx
34+
uses: docker/setup-buildx-action@b5730b14e8b0bc39f62ade545785cb7e6f44b97c # v3
35+
36+
- name: Build image
37+
uses: docker/build-push-action@471d1dc4e07e5cdedd4c2171150001c434f0b4b5 # v6
38+
with:
39+
context: .
40+
platforms: linux/amd64
41+
push: false
42+
load: true
43+
tags: cas-reference-product:ci
44+
cache-from: type=gha
45+
cache-to: type=gha,mode=max
46+
47+
- name: Health-check smoke test
48+
run: |
49+
docker run -d --name cas-ci -p 8080:8080 \
50+
-e ENVIRONMENT=local -e WORKFLOW_BACKEND=local \
51+
cas-reference-product:ci
52+
# Wait up to 30s for the app to become ready
53+
for i in $(seq 1 15); do
54+
if curl -sf http://localhost:8080/health/ready; then
55+
echo "App is ready"; break
56+
fi
57+
sleep 2
58+
done
59+
curl -sf http://localhost:8080/health/live
60+
curl -sf http://localhost:8080/health/ready
61+
docker stop cas-ci
62+
63+
- name: Log in to GHCR
64+
if: github.event_name == 'push' && github.ref == 'refs/heads/main'
65+
uses: docker/login-action@74a5d142397b4f367a81961eba4e8cd7edddf772 # v3
66+
with:
67+
registry: ghcr.io
68+
username: ${{ github.actor }}
69+
password: ${{ secrets.GITHUB_TOKEN }}
70+
71+
- name: Push to GHCR
72+
if: github.event_name == 'push' && github.ref == 'refs/heads/main'
73+
uses: docker/build-push-action@471d1dc4e07e5cdedd4c2171150001c434f0b4b5 # v6
74+
with:
75+
context: .
76+
platforms: linux/amd64
77+
push: true
78+
tags: |
79+
ghcr.io/coding-autopilot-system/cas-reference-product:latest
80+
ghcr.io/coding-autopilot-system/cas-reference-product:${{ github.sha }}
81+
cache-from: type=gha
82+
cache-to: type=gha,mode=max

.planning/REQUIREMENTS.md

Lines changed: 16 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,19 @@
1515
- [x] **QUAL-01**: Unit, API, contract, static, and container configuration checks run in CI.
1616
- [x] **DOC-01**: Architecture, threat model, operations, and local workflow are documented.
1717

18+
## Phase 2 Requirements — Telemetry Hardening
19+
20+
- [x] **TEL-01**: OpenTelemetry SDK wired into FastAPI lifespan — a `cas.api.workflows.execute` span is created for every /api/v1/workflows request, carrying `cas.correlation_id`, `cas.run_id`, and `cas.intent` attributes.
21+
- [x] **TEL-02**: Canonical CAS lifecycle events emitted as span events: `workflow.started` (with correlation_id + run_id), `workflow.completed` on success, `workflow.failed` on error — never both completed and failed.
22+
- [x] **TEL-03**: W3C trace context headers (`traceparent` / `tracestate`) propagated on inbound requests via `W3CTraceContextMiddleware`; downstream spans are parented to the caller's trace.
23+
- [x] **TEL-04**: Application Insights exporter active when `APPLICATIONINSIGHTS_CONNECTION_STRING` env var is set (no-op if absent); uses managed identity and privacy-hardened instrumentation options.
24+
25+
## Phase 3 Requirements — Docker + CI Publish
26+
27+
- [x] **DOCK-01**: Dockerfile is multi-stage (builder + runtime), targets linux/amd64, exposes port 8080, runs as non-root user `appuser`, and health-checks via /health/ready.
28+
- [x] **DOCK-02**: `docker-compose.yml` defines a local dev stack (`cas-ref` service, ports 8080:8080, env_file .env.example) that starts without Azure credentials.
29+
- [x] **DOCK-03**: CI pipeline (`docker` job in ci.yml) builds the image, runs a health-check smoke test, and pushes to `ghcr.io/coding-autopilot-system/cas-reference-product` on merge to main.
30+
1831
## Out of Scope
1932

2033
| Feature | Reason |
@@ -26,7 +39,8 @@
2639
## Traceability
2740

2841
All v0.1 requirements map to Phase 1 and are complete.
42+
TEL-01 through TEL-04 map to Phase 2 and are complete.
43+
DOCK-01 through DOCK-03 map to Phase 3 and are complete.
2944

3045
---
31-
*Last updated: 2026-06-11 after v0.1 implementation*
32-
46+
*Last updated: 2026-06-14 after Phase 2 and Phase 3 implementation*

.planning/ROADMAP.md

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,3 +14,30 @@
1414

1515
Status: Complete
1616

17+
## Phase 2: Telemetry Hardening
18+
19+
**Goal:** Wire OpenTelemetry end-to-end with lifecycle span events, W3C trace propagation, and Application Insights exporter.
20+
21+
**Requirements:** TEL-01, TEL-02, TEL-03, TEL-04
22+
23+
**Success criteria:**
24+
- Every /api/v1/workflows request creates a `cas.api.workflows.execute` span with correlation_id, run_id, and intent attributes.
25+
- Span events `workflow.started`, `workflow.completed`, and `workflow.failed` are emitted at the appropriate lifecycle points.
26+
- W3C traceparent/tracestate headers on inbound requests are extracted and linked as parent context.
27+
- Application Insights exporter activates when APPLICATIONINSIGHTS_CONNECTION_STRING is set; no-op otherwise.
28+
- All telemetry behaviours verified by pytest with InMemorySpanExporter.
29+
30+
Status: Complete
31+
32+
## Phase 3: Docker + CI Publish
33+
34+
**Goal:** Containerize the app with a production-grade multi-stage Dockerfile and publish the image to GHCR on merge to main.
35+
36+
**Requirements:** DOCK-01, DOCK-02, DOCK-03
37+
38+
**Success criteria:**
39+
- Multi-stage Dockerfile builds a linux/amd64 image, runs as non-root `appuser`, exposes port 8080, and health-checks via /health/ready.
40+
- docker-compose.yml starts the local dev stack with env stubs using .env.example.
41+
- CI docker job builds, smoke-tests (/health/live + /health/ready), and on push to main pushes to ghcr.io/coding-autopilot-system/cas-reference-product.
42+
43+
Status: Complete

CLAUDE.md

Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,57 @@
1+
# cas-reference-product
2+
3+
Production-oriented CAS reference application demonstrating a complete workload integrated with **Microsoft Foundry Next Gen Agents** on a Container Apps foundation. Runs locally without Azure, deploys unmodified through the `cas-platform` interface.
4+
5+
## Project Context
6+
7+
See `.planning/PROJECT.md` for goals and requirements. This project is in early initialization.
8+
9+
**Core mandate**: Demonstrate canonical CAS lifecycle events, managed identity, observability, probes, and a safe local workflow — without embedding any Azure credentials.
10+
11+
## Tech Stack
12+
13+
| Layer | Technology |
14+
|---|---|
15+
| Language | Python 3.12+ |
16+
| API framework | FastAPI + Pydantic |
17+
| Azure identity | `ManagedIdentityCredential` (system-assigned; no embedded secrets) |
18+
| Azure AI | Foundry Next Gen Agents (`WorkflowAgentService`) — never Classic Assistants |
19+
| Observability | OpenTelemetry + Azure Application Insights |
20+
| Container | Linux AMD64, port 8080 |
21+
| Tests | pytest (in `tests/`) |
22+
| Local dev | `scripts/run-local.ps1` |
23+
24+
## Key Files
25+
26+
| File | Purpose |
27+
|---|---|
28+
| `src/cas_reference_product/identity.py` | Identity/credential resolution (local vs. managed) |
29+
| `.foundry/agent-metadata.yaml` | Foundry Next Gen Agent configuration |
30+
| `.foundry/datasets/` | Seed data for local testing |
31+
| `.env.example` | All required environment variable docs |
32+
| `scripts/run-local.ps1` | Local run without Azure |
33+
34+
## Local Development
35+
36+
```powershell
37+
.\scripts\run-local.ps1
38+
```
39+
40+
Or manually:
41+
```bash
42+
cd portfolio/cas-reference-product
43+
python -m venv .venv && .\.venv\Scripts\Activate.ps1
44+
pip install -r requirements.txt # if present, else pip install -e .
45+
python -m pytest tests/
46+
```
47+
48+
## Constraints
49+
50+
- **No embedded credentials** — use `DefaultAzureCredential` / `ManagedIdentityCredential` only
51+
- **No Azure resource deployment** — local adapter runs without provisioning
52+
- **Foundry Next Gen only** — reject any Classic Assistants (`asst_*`) usage
53+
- **Public repo** — no sensitive data in examples or defaults
54+
55+
## GSD Workflow
56+
57+
Use `/gsd:plan-phase` before any multi-file change. Use `/gsd:quick` for single-file fixes.

Dockerfile

Lines changed: 34 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,22 +1,49 @@
1-
FROM python:3.12-slim AS runtime
1+
# --------------------------------------------------------------------------- #
2+
# Stage 1 — builder: install dependencies into an isolated wheel cache #
3+
# --------------------------------------------------------------------------- #
4+
FROM python:3.12-slim AS builder
25

36
ENV PYTHONDONTWRITEBYTECODE=1 \
47
PYTHONUNBUFFERED=1 \
58
PIP_DISABLE_PIP_VERSION_CHECK=1 \
6-
PORT=8080
9+
PIP_NO_CACHE_DIR=1
10+
11+
WORKDIR /build
12+
13+
COPY pyproject.toml README.md ./
14+
COPY src ./src
15+
16+
RUN pip install --no-cache-dir --no-compile --prefix=/install .
17+
18+
# --------------------------------------------------------------------------- #
19+
# Stage 2 — runtime: minimal image, non-root user, port 8080 #
20+
# --------------------------------------------------------------------------- #
21+
FROM python:3.12-slim AS runtime
22+
23+
ENV PYTHONDONTWRITEBYTECODE=1 \
24+
PYTHONUNBUFFERED=1 \
25+
PORT=8080 \
26+
PYTHONPATH=/app/src
727

828
WORKDIR /app
929

10-
RUN addgroup --system app && adduser --system --ingroup app app
30+
# Create non-root user and group
31+
RUN groupadd --system app && useradd --system --gid app --no-create-home app
1132

12-
COPY pyproject.toml README.md ./
33+
# Copy installed packages from builder stage
34+
COPY --from=builder /install /usr/local
35+
36+
# Copy application source
1337
COPY src ./src
14-
RUN pip install --no-cache-dir --no-compile .
1538

1639
USER app
40+
1741
EXPOSE 8080
42+
1843
STOPSIGNAL SIGTERM
19-
HEALTHCHECK --interval=30s --timeout=3s --start-period=10s --retries=3 \
20-
CMD python -c "import urllib.request; urllib.request.urlopen('http://127.0.0.1:8080/health/live', timeout=2)"
44+
45+
# Health check via /health/ready — verifies app is truly ready (DOCK-01)
46+
HEALTHCHECK --interval=30s --timeout=5s --start-period=15s --retries=3 \
47+
CMD python -c "import urllib.request; urllib.request.urlopen('http://127.0.0.1:8080/health/ready', timeout=4)"
2148

2249
CMD ["uvicorn", "cas_reference_product.app:app", "--host", "0.0.0.0", "--port", "8080"]

docker-compose.yml

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
# Local dev stack — mirrors the cas-platform container interface (DOCK-02)
2+
# Usage: docker compose up --build
3+
# Env: copy .env.example to .env and populate values as needed.
4+
5+
services:
6+
cas-ref:
7+
build:
8+
context: .
9+
dockerfile: Dockerfile
10+
target: runtime
11+
platforms:
12+
- linux/amd64
13+
image: cas-reference-product:local
14+
ports:
15+
- "8080:8080"
16+
env_file:
17+
- .env.example
18+
environment:
19+
# Override any .env.example defaults here for local dev
20+
ENVIRONMENT: local
21+
WORKFLOW_BACKEND: local
22+
healthcheck:
23+
test:
24+
- CMD
25+
- python
26+
- -c
27+
- "import urllib.request; urllib.request.urlopen('http://127.0.0.1:8080/health/ready', timeout=4)"
28+
interval: 30s
29+
timeout: 5s
30+
start_period: 15s
31+
retries: 3
32+
restart: unless-stopped

pyproject.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ dependencies = [
1515
"azure-monitor-opentelemetry>=1.6.0",
1616
"fastapi>=0.115.0",
1717
"opentelemetry-api>=1.29.0",
18+
"opentelemetry-sdk>=1.29.0",
1819
"pydantic-settings>=2.7.0",
1920
"uvicorn[standard]>=0.34.0"
2021
]

src/cas_reference_product/app.py

Lines changed: 40 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -3,12 +3,15 @@
33
from typing import Any
44

55
from fastapi import FastAPI, HTTPException, Request
6+
from opentelemetry import trace
67

78
from .config import Settings, get_settings
89
from .models import PromptEnvelope, WorkflowResult
9-
from .telemetry import configure_telemetry
10+
from .telemetry import W3CTraceContextMiddleware, configure_telemetry
1011
from .workflow import WorkflowAgentServiceError, WorkflowOrchestrator, build_workflow_agent_service
1112

13+
_tracer = trace.get_tracer(__name__)
14+
1215

1316
def create_app(settings: Settings | None = None) -> FastAPI:
1417
app_settings = settings or get_settings()
@@ -19,6 +22,8 @@ async def lifespan(_: FastAPI) -> AsyncIterator[None]:
1922
yield
2023

2124
app = FastAPI(title="CAS Reference Product", version="0.1.0", lifespan=lifespan)
25+
app.add_middleware(W3CTraceContextMiddleware)
26+
2227
service = build_workflow_agent_service(app_settings) if app_settings.ready else None
2328

2429
@app.get("/health/live")
@@ -36,11 +41,40 @@ def execute(envelope: PromptEnvelope, request: Request) -> WorkflowResult:
3641
if service is None:
3742
raise HTTPException(status_code=503, detail="Workflow backend is not ready")
3843
request.state.correlation_id = envelope.correlationId
39-
orchestrator = WorkflowOrchestrator(service, app_settings.repository)
40-
try:
41-
return orchestrator.execute(envelope)
42-
except WorkflowAgentServiceError:
43-
raise HTTPException(status_code=502, detail="Workflow backend request failed") from None
44+
with _tracer.start_as_current_span("cas.api.workflows.execute") as span:
45+
span.set_attribute("cas.correlation_id", envelope.correlationId)
46+
span.set_attribute("cas.run_id", envelope.runId)
47+
span.set_attribute("cas.intent", envelope.intent)
48+
span.add_event(
49+
"workflow.started",
50+
attributes={
51+
"cas.correlation_id": envelope.correlationId,
52+
"cas.run_id": envelope.runId,
53+
},
54+
)
55+
orchestrator = WorkflowOrchestrator(service, app_settings.repository)
56+
try:
57+
result = orchestrator.execute(envelope)
58+
except WorkflowAgentServiceError:
59+
span.add_event(
60+
"workflow.failed",
61+
attributes={
62+
"cas.correlation_id": envelope.correlationId,
63+
"cas.run_id": envelope.runId,
64+
"error": True,
65+
},
66+
)
67+
raise HTTPException(
68+
status_code=502, detail="Workflow backend request failed"
69+
) from None
70+
span.add_event(
71+
"workflow.completed",
72+
attributes={
73+
"cas.correlation_id": envelope.correlationId,
74+
"cas.run_id": envelope.runId,
75+
},
76+
)
77+
return result
4478

4579
@app.get("/")
4680
def root() -> dict[str, Any]:

0 commit comments

Comments
 (0)