Skip to content

Commit 9683550

Browse files
authored
Merge branch 'main' into codex/remove-disable-web-security-argument-from-playwright
2 parents 9f018ad + 48f9c45 commit 9683550

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

47 files changed

+2435
-1030
lines changed

.github/workflows/perf-light.yml

Lines changed: 154 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,111 @@ on:
1111
pull_request: {}
1212

1313
jobs:
14+
playwright-budgets:
15+
runs-on: ubuntu-latest
16+
timeout-minutes: 25
17+
steps:
18+
- uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8
19+
20+
- name: Use Node.js 20
21+
uses: actions/setup-node@60edb5dd545a775178f52524783378180af0d1f8
22+
with:
23+
node-version: '20'
24+
cache: 'npm'
25+
cache-dependency-path: frontend/package-lock.json
26+
27+
- name: Install frontend dependencies
28+
working-directory: frontend
29+
run: npm ci
30+
31+
- name: Install Playwright browsers
32+
working-directory: frontend
33+
run: npx playwright install --with-deps chromium
34+
35+
- name: Start stack
36+
run: |
37+
docker compose --env-file .env.development -f docker-compose.dev.yml up -d --build
38+
39+
- name: Wait for API
40+
run: |
41+
for i in {1..60}; do curl -sf http://localhost:8000/healthcheck && break || sleep 2; done
42+
43+
- name: Wait for Frontend
44+
run: |
45+
for i in {1..60}; do curl -sf http://localhost:3000/models/manifest.json && break || sleep 2; done
46+
47+
- name: Seed backend for perf
48+
env:
49+
BASE_URL: http://localhost:8000
50+
run: |
51+
python - <<'PY'
52+
import json
53+
import os
54+
import urllib.error
55+
import urllib.request
56+
57+
BASE_URL = os.environ.get("BASE_URL", "http://localhost:8000")
58+
59+
def post(path: str, payload: dict) -> dict:
60+
req = urllib.request.Request(
61+
f"{BASE_URL}{path}",
62+
data=json.dumps(payload).encode("utf-8"),
63+
headers={"Content-Type": "application/json"},
64+
)
65+
try:
66+
with urllib.request.urlopen(req, timeout=10) as resp:
67+
return json.loads(resp.read().decode("utf-8"))
68+
except urllib.error.HTTPError as exc:
69+
detail = exc.read().decode("utf-8", "ignore")
70+
raise SystemExit(f"Seed request failed ({exc.code}): {detail}")
71+
72+
material = post(
73+
"/api/materials/",
74+
{"name": "Walnut", "texture_url": None, "cost_per_sq_ft": 12.5},
75+
)
76+
material_id = material.get("id")
77+
if not material_id:
78+
raise SystemExit("Material creation failed; missing id")
79+
80+
post(
81+
"/api/modules/",
82+
{
83+
"name": "Base600",
84+
"width": 600.0,
85+
"height": 720.0,
86+
"depth": 580.0,
87+
"base_price": 100.0,
88+
"material_id": material_id,
89+
},
90+
)
91+
PY
92+
93+
- name: Run Playwright performance budgets
94+
env:
95+
PERF_RESULTS_DIR: ${{ github.workspace }}/artifacts/perf
96+
PERF_BUDGET_CONFIG: ${{ github.workspace }}/perf-budget.yml
97+
run: |
98+
mkdir -p artifacts/perf
99+
cd frontend
100+
npm run test:perf -- --output=playwright-report/perf
101+
102+
- name: Convert Playwright metrics to JUnit
103+
run: node tools/perf/metrics-to-junit.mjs artifacts/perf artifacts/perf/perf-metrics.junit.xml
104+
105+
- name: Upload performance artifacts
106+
if: always()
107+
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02
108+
with:
109+
name: perf-budgets
110+
path: |
111+
artifacts/perf
112+
frontend/playwright-report
113+
114+
- name: Shutdown stack
115+
if: always()
116+
run: |
117+
docker compose --env-file .env.development -f docker-compose.dev.yml down
118+
14119
k6:
15120
runs-on: ubuntu-latest
16121
timeout-minutes: 15
@@ -28,11 +133,33 @@ jobs:
28133
29134
- name: Wait for API
30135
run: |
31-
for i in {1..60}; do curl -sf http://localhost:8000/healthcheck && break || sleep 2; done
136+
success=false
137+
for i in {1..60}; do
138+
if curl -sf http://localhost:8000/healthcheck; then
139+
success=true
140+
break
141+
fi
142+
sleep 2
143+
done
144+
if [ "$success" = false ]; then
145+
echo "API did not become healthy in time" >&2
146+
exit 1
147+
fi
32148
33149
- name: Wait for Frontend
34150
run: |
35-
for i in {1..60}; do curl -sf http://localhost:3000/models/manifest.json && break || sleep 2; done
151+
success=false
152+
for i in {1..60}; do
153+
if curl -sf http://localhost:3000/models/manifest.json; then
154+
success=true
155+
break
156+
fi
157+
sleep 2
158+
done
159+
if [ "$success" = false ]; then
160+
echo "Frontend did not become healthy in time" >&2
161+
exit 1
162+
fi
36163
37164
- name: Seed backend for perf
38165
env:
@@ -98,3 +225,28 @@ PY
98225
if: always()
99226
run: |
100227
docker compose --env-file .env.development -f docker-compose.dev.yml down
228+
229+
canary-metrics:
230+
runs-on: ubuntu-latest
231+
needs:
232+
- playwright-budgets
233+
- k6
234+
steps:
235+
- uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8
236+
237+
- name: Compare canary metrics against budgets
238+
env:
239+
PROMETHEUS_URL: ${{ secrets.PROMETHEUS_URL }}
240+
TEMPO_URL: ${{ secrets.TEMPO_URL }}
241+
GITHUB_BASE_SHA: ${{ github.event.pull_request.base.sha }}
242+
GITHUB_SHA: ${{ github.sha }}
243+
CANARY_RESULTS_DIR: ${{ github.workspace }}/artifacts/canary
244+
run: |
245+
python tools/perf/check-canary-metrics.py
246+
247+
- name: Upload canary metric report
248+
if: always()
249+
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02
250+
with:
251+
name: canary-metrics
252+
path: artifacts/canary

.gitignore

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,7 @@ yarn-error.log*
3636
.next/
3737
out/
3838
.cache/
39+
artifacts/
3940

4041
# Env files (keep example)
4142
.env

backend/api/config.py

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,17 @@ class Settings(BaseSettings):
1616
default=3000,
1717
description="Port for the frontend service",
1818
)
19+
api_write_token: str | None = Field(
20+
default=None,
21+
description="Bearer token required for write-protected API routes.",
22+
repr=False,
23+
)
24+
25+
# Admin / write access token
26+
api_write_token: str = Field(
27+
default="",
28+
description="Optional bearer token required for write/admin endpoints.",
29+
)
1930

2031
# Database and integration settings
2132
database_url: str = Field(
@@ -34,6 +45,11 @@ class Settings(BaseSettings):
3445
default="",
3546
description="Hygraph access token",
3647
)
48+
api_write_token: str = Field(
49+
default="",
50+
description="Bearer token required for privileged write routes",
51+
repr=False,
52+
)
3753
hygraph_webhook_secret: str = Field(
3854
description=(
3955
"Shared secret used to verify Hygraph webhook signatures."

backend/api/main.py

Lines changed: 39 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,6 @@
1111
from opentelemetry.instrumentation.fastapi import FastAPIInstrumentor
1212
from opentelemetry.trace import Status, StatusCode
1313
from starlette.middleware.cors import CORSMiddleware
14-
from starlette.middleware.base import BaseHTTPMiddleware
1514

1615
from api.config import get_settings
1716
from api.instrumentation_boot import setup_instrumentation
@@ -24,10 +23,17 @@
2423
from api.routes_sync import router as sync_router
2524
from api.routes_observability import router as observability_router
2625
from prometheus_client import CONTENT_TYPE_LATEST, generate_latest
26+
from prometheus_fastapi_instrumentator import Instrumentator
2727
from api.metrics import observe_http_request
2828

2929
setup_instrumentation()
3030

31+
if bootstrap_instrumentation is not None: # pragma: no branch - simple guard
32+
try:
33+
bootstrap_instrumentation()
34+
except Exception: # pragma: no cover - defensive guard
35+
logger.exception("Failed to bootstrap OpenTelemetry instrumentation")
36+
3137
# Initialize settings
3238
settings = get_settings()
3339

@@ -67,37 +73,44 @@ def _resolve_route_label(request: Request) -> str:
6773
return request.url.path or "unknown"
6874

6975

70-
class PrometheusInstrumentationMiddleware(BaseHTTPMiddleware):
71-
async def dispatch(self, request: Request, call_next): # type: ignore[override]
72-
if request.url.path == "/metrics":
73-
return await call_next(request)
74-
75-
start = perf_counter()
76-
try:
77-
response = await call_next(request)
78-
except Exception:
79-
duration = perf_counter() - start
80-
observe_http_request(
81-
service="backend",
82-
route=_resolve_route_label(request),
83-
method=request.method,
84-
status="500",
85-
duration_seconds=duration,
86-
)
87-
raise
88-
76+
@app.middleware("http")
77+
async def prometheus_instrumentation_middleware(request: Request, call_next):
78+
if request.url.path == "/metrics":
79+
return await call_next(request)
80+
81+
start = perf_counter()
82+
try:
83+
response = await call_next(request)
84+
except HTTPException as exc:
8985
duration = perf_counter() - start
9086
observe_http_request(
9187
service="backend",
9288
route=_resolve_route_label(request),
9389
method=request.method,
94-
status=str(response.status_code),
90+
status=str(exc.status_code),
9591
duration_seconds=duration,
9692
)
97-
return response
98-
99-
100-
app.add_middleware(PrometheusInstrumentationMiddleware)
93+
raise
94+
except Exception:
95+
duration = perf_counter() - start
96+
observe_http_request(
97+
service="backend",
98+
route=_resolve_route_label(request),
99+
method=request.method,
100+
status="500",
101+
duration_seconds=duration,
102+
)
103+
raise
104+
105+
duration = perf_counter() - start
106+
observe_http_request(
107+
service="backend",
108+
route=_resolve_route_label(request),
109+
method=request.method,
110+
status=str(response.status_code),
111+
duration_seconds=duration,
112+
)
113+
return response
101114

102115
# Include API router
103116
app.include_router(api_router)
@@ -195,4 +208,4 @@ async def metrics() -> Dict[str, str]:
195208

196209
return {
197210
"detail": "Metrics are exported via OpenTelemetry OTLP; no local payload is available.",
198-
}
211+
}

backend/api/metrics.py

Lines changed: 53 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,36 @@
11
from __future__ import annotations
2+
3+
from typing import Any
4+
25
from prometheus_client import Counter, Histogram
36

7+
_otel_meter: Any | None = None
8+
_http_request_counter: Any | None = None
9+
_http_request_duration_histogram: Any | None = None
10+
_web_vitals_lcp_histogram: Any | None = None
11+
12+
try: # pragma: no cover - optional dependency guard
13+
from opentelemetry import metrics as otel_metrics
14+
except ImportError: # pragma: no cover - optional dependency guard
15+
otel_metrics = None # type: ignore[assignment]
16+
else:
17+
_otel_meter = otel_metrics.get_meter(__name__)
18+
_http_request_counter = _otel_meter.create_counter(
19+
"http.server.request.count",
20+
description="HTTP requests processed by the backend",
21+
unit="1",
22+
)
23+
_http_request_duration_histogram = _otel_meter.create_histogram(
24+
"http.server.duration",
25+
description="Duration of HTTP requests handled by the backend",
26+
unit="s",
27+
)
28+
_web_vitals_lcp_histogram = _otel_meter.create_histogram(
29+
"frontend.web_vitals.lcp",
30+
description="Largest Contentful Paint reported from the frontend",
31+
unit="s",
32+
)
33+
434
REQUEST_LATENCY_BUCKETS = (
535
0.005,
636
0.01,
@@ -86,21 +116,41 @@ def observe_http_request(
86116
service: str,
87117
route: str,
88118
method: str,
89-
status: str,
119+
status_code: int | str,
90120
duration_seconds: float,
91121
) -> None:
92122
"""Record a single HTTP request observation."""
93123

124+
status_label = str(status_code)
94125
http_requests_total.labels(
95-
service=service, route=route, method=method, status=status
126+
service=service, route=route, method=method, status=status_label
96127
).inc()
97128
http_request_duration_seconds.labels(
98-
service=service, route=route, method=method, status=status
129+
service=service, route=route, method=method, status=status_label
99130
).observe(duration_seconds)
100131

132+
if _http_request_counter is not None or _http_request_duration_histogram is not None:
133+
attributes: dict[str, str | int] = {
134+
"service.name": service,
135+
"http.route": route,
136+
"http.method": method,
137+
}
138+
try:
139+
attributes["http.status_code"] = int(status_code)
140+
except (TypeError, ValueError):
141+
# Skip the status code attribute if it cannot be coerced to an int.
142+
pass
143+
144+
if _http_request_counter is not None:
145+
_http_request_counter.add(1, attributes)
146+
if _http_request_duration_histogram is not None:
147+
_http_request_duration_histogram.record(duration_seconds, attributes)
148+
101149

102150
def observe_lcp(*, app: str, seconds: float) -> None:
103151
"""Record a Largest Contentful Paint measurement in seconds."""
104152

105153
web_vitals_lcp.labels(app=app).observe(seconds)
154+
if _web_vitals_lcp_histogram is not None:
155+
_web_vitals_lcp_histogram.record(seconds, {"app": app})
106156

0 commit comments

Comments
 (0)