From 7569c400d3f944477eb0112c4d10ed4059e0d08a Mon Sep 17 00:00:00 2001 From: Abhinaysai Kamineni Date: Wed, 10 Jun 2026 12:52:23 -0400 Subject: [PATCH] Complete gap closure 2/2: metrics, load test, staging smoke, and a11y. MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds Prometheus /metrics, k6 load script, staging smoke validator, axe Playwright audit, copilot→assist CSS rename, and MemoryGraph a11y fix. Co-authored-by: Cursor --- .github/workflows/ci.yml | 3 + README.md | 1 + api/main.py | 33 +++- api/metrics.py | 50 ++++++ docs/PRODUCTION_READINESS.md | 27 +-- frontend/e2e/a11y.spec.ts | 24 +++ frontend/e2e/journeys.spec.ts | 2 +- frontend/package-lock.json | 24 +++ frontend/package.json | 1 + frontend/src/App.tsx | 2 +- .../components/assistant/AssistantPanel.tsx | 10 +- frontend/src/components/layout/MobileNav.tsx | 2 +- .../src/components/memory/MemoryGraph.tsx | 4 +- frontend/src/styles/intelligence.css | 18 +- frontend/src/styles/tokens.css | 2 +- scripts/load/k6_query.js | 50 ++++++ scripts/staging_smoke.py | 154 ++++++++++++++++++ tests/api/test_main.py | 8 + tests/scripts/test_staging_smoke.py | 40 +++++ 19 files changed, 422 insertions(+), 33 deletions(-) create mode 100644 api/metrics.py create mode 100644 frontend/e2e/a11y.spec.ts create mode 100644 scripts/load/k6_query.js create mode 100644 scripts/staging_smoke.py create mode 100644 tests/scripts/test_staging_smoke.py diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index bb4aac4..8112175 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -43,6 +43,9 @@ jobs: - name: Demo seed script (dry-run, no Neo4j) run: python scripts/seed_demo.py --dry-run + - name: Staging smoke script (dry-run) + run: python scripts/staging_smoke.py --dry-run + frontend: runs-on: ubuntu-latest timeout-minutes: 15 diff --git a/README.md b/README.md index fa19a77..9574372 100644 --- a/README.md +++ b/README.md @@ -173,6 +173,7 @@ This brings up Kafka, Neo4j, Redis, Postgres, applies graph migrations, writes t | Method | Path | Purpose | |--------|------|---------| | `GET` | `/health` | Liveness and dependency checks (Neo4j, Redis) | +| `GET` | `/metrics` | Prometheus exposition (request + query latency histograms) | | `POST` | `/query` | Decision search (Neo4j full-text + optional Qdrant merge when `CORTEX_SEMANTIC_ENABLED=true`) | | `POST` | `/inject` | Ranked context for agents | | `GET` | `/contradictions/pending` | Pending contradiction review items (`workspace_id` query param; `X-Cortex-Roles` for RBAC) | diff --git a/api/main.py b/api/main.py index 426ac87..0ea14a3 100644 --- a/api/main.py +++ b/api/main.py @@ -19,11 +19,12 @@ from contextlib import asynccontextmanager import structlog -from fastapi import FastAPI, HTTPException, Request, status +from fastapi import FastAPI, HTTPException, Request, Response, status from fastapi.middleware.cors import CORSMiddleware from fastapi.responses import JSONResponse from api.contradictions import router as contradictions_router +from api.metrics import record_http_request, record_query, render_metrics from api.decisions import router as decisions_router from api.deps import RolesDep, memory, set_memory_service from api.memory import MemoryService @@ -97,6 +98,22 @@ async def lifespan(app: FastAPI) -> AsyncIterator[None]: app.include_router(remember_router) +@app.middleware("http") +async def prometheus_middleware(request: Request, call_next): # type: ignore[no-untyped-def] + """Record request latency for Prometheus (skip scrape endpoint).""" + if request.url.path == "/metrics": + return await call_next(request) + t0 = time.perf_counter() + response = await call_next(request) + record_http_request( + method=request.method, + endpoint=request.url.path, + status=response.status_code, + duration_s=time.perf_counter() - t0, + ) + return response + + # ───────────────────────────────────────────────────────────────────────────── # Dependency check helpers # ───────────────────────────────────────────────────────────────────────────── @@ -159,6 +176,18 @@ async def health() -> HealthResponse: ) +@app.get( + "/metrics", + summary="Prometheus metrics", + tags=["ops"], + include_in_schema=False, +) +async def metrics() -> Response: + """Prometheus exposition format for scrape targets.""" + payload, content_type = render_metrics() + return Response(content=payload, media_type=content_type) + + @app.post( "/query", response_model=QueryResponse, @@ -197,6 +226,7 @@ async def query( ) results = [DecisionResult(**record) for record in records] except Exception as exc: + record_query(status="error", duration_s=time.time() - t0) log.error("query.failed", error=str(exc), query=request.query[:100]) raise HTTPException( status_code=status.HTTP_503_SERVICE_UNAVAILABLE, @@ -204,6 +234,7 @@ async def query( ) from exc latency_ms = round((time.time() - t0) * 1000, 2) + record_query(status="ok", duration_s=latency_ms / 1000) log.info( "query.complete", result_count=len(results), diff --git a/api/metrics.py b/api/metrics.py new file mode 100644 index 0000000..a6f98de --- /dev/null +++ b/api/metrics.py @@ -0,0 +1,50 @@ +"""Prometheus metrics for the Cortex API. + +Exposed at ``GET /metrics`` for Prometheus scrape (see infrastructure/docker/prometheus.yml). +""" + +from __future__ import annotations + +from prometheus_client import CONTENT_TYPE_LATEST, Counter, Histogram, generate_latest + +HTTP_REQUESTS = Counter( + "cortex_http_requests_total", + "Total HTTP requests", + ["method", "endpoint", "status"], +) + +HTTP_LATENCY = Histogram( + "cortex_http_request_duration_seconds", + "HTTP request latency in seconds", + ["method", "endpoint"], + buckets=(0.005, 0.01, 0.025, 0.05, 0.1, 0.25, 0.5, 1.0, 2.5, 5.0, 10.0), +) + +QUERY_REQUESTS = Counter( + "cortex_query_requests_total", + "Total /query requests", + ["status"], +) + +QUERY_LATENCY = Histogram( + "cortex_query_duration_seconds", + "/query handler latency in seconds", + buckets=(0.01, 0.025, 0.05, 0.1, 0.25, 0.5, 1.0, 2.5, 5.0, 10.0), +) + + +def record_http_request(*, method: str, endpoint: str, status: int, duration_s: float) -> None: + """Record generic HTTP request metrics.""" + HTTP_REQUESTS.labels(method, endpoint, str(status)).inc() + HTTP_LATENCY.labels(method, endpoint).observe(duration_s) + + +def record_query(*, status: str, duration_s: float) -> None: + """Record /query-specific metrics.""" + QUERY_REQUESTS.labels(status).inc() + QUERY_LATENCY.observe(duration_s) + + +def render_metrics() -> tuple[bytes, str]: + """Return Prometheus exposition payload and content type.""" + return generate_latest(), CONTENT_TYPE_LATEST diff --git a/docs/PRODUCTION_READINESS.md b/docs/PRODUCTION_READINESS.md index 35396a8..d02065a 100644 --- a/docs/PRODUCTION_READINESS.md +++ b/docs/PRODUCTION_READINESS.md @@ -8,7 +8,7 @@ ## Executive summary -Cortex is **merge-ready with caveats**. Backend test coverage is strong (279 tests, 82% coverage). Frontend builds cleanly with 6 Vitest tests and Playwright smoke coverage. Demo seed data expanded **5.5×** (2 → 11 base decisions, up to **110** at enterprise scale). Uncommitted UI changes (Assist rename, bug report section, light-mode removal) should be committed before opening the final PR. +Cortex is **merge-ready with caveats**. Backend test coverage is strong (284 tests, 82% coverage). Frontend builds cleanly with 6 Vitest tests and Playwright smoke coverage. Demo seed data expanded **5.5×** (2 → 11 base decisions, up to **110** at enterprise scale). Uncommitted UI changes (Assist rename, bug report section, light-mode removal) should be committed before opening the final PR. **Recommendation:** Merge to `main` after one commit bundling pending frontend + validation changes, then run Neo4j-backed smoke in staging. @@ -20,7 +20,7 @@ Cortex is **merge-ready with caveats**. Backend test coverage is strong (279 tes | Suite | Result | Count | |-------|--------|-------| -| Python `pytest tests/` | ✅ Pass | 279 tests | +| Python `pytest tests/` | ✅ Pass | 284 tests | | Coverage (`fail-under=70`) | ✅ Pass | 82.39% | | Frontend `npm test` (Vitest) | ✅ Pass | 6 tests | | Frontend `npm run build` | ✅ Pass | TypeScript + Vite | @@ -110,14 +110,19 @@ curl -X POST localhost:8000/query -H "Content-Type: application/json" \ - [x] Query benchmark script — `scripts/benchmark_query.py` + `tests/scripts/test_benchmark_query.py` - [x] Memory resilience tests — `tests/api/test_memory_resilience.py` (mocked Redis/Neo4j degradation) -**Still open:** +**Completed (gap-closure pass 2/2, 2026-06-10):** + +- [x] Rename internal CSS `copilot-*` → `assist-*` (tokens, panel, topbar, mobile nav) +- [x] Load testing — `scripts/load/k6_query.js` (k6 concurrent `/query`) +- [x] Live staging validation — `scripts/staging_smoke.py` (health + query + optional benchmark) +- [x] Accessibility — `@axe-core/playwright` audit in `frontend/e2e/a11y.spec.ts` +- [x] Observability — `GET /metrics` Prometheus endpoint (`api/metrics.py`) + +**Still open (post-MVP):** -- Rename internal CSS `copilot-*` classes → `assist-*` (cosmetic) - Consider `scripts/` → `tools/` consolidation (low priority) -- **Load testing** — k6/Locust for concurrent `/query` (not blocking MVP) -- **Live staging validation** — Neo4j + Redis + Kafka stack, enterprise seed, retrieval latency -- **Accessibility** — automated axe audit (manual landmarks covered in E2E) -- **Observability** — APM/metrics wiring beyond structlog JSON +- Run k6 + staging smoke against a live Neo4j/Redis/Kafka deployment in CI (manual staging job) +- Full APM tracing (OpenTelemetry) beyond Prometheus counters/histograms --- @@ -158,7 +163,7 @@ Already present and correct: `.env`, `node_modules/`, `__pycache__/`, `.pytest_c | Dimension | Rating | Evidence | |-----------|--------|----------| -| Backend API | 🟢 Ready | 279 tests, health endpoint, RBAC at graph layer | +| Backend API | 🟢 Ready | 284 tests, health + `/metrics`, RBAC at graph layer | | Graph / Neo4j | 🟢 Ready | Migrations, writer, query service, lineage fix | | Pipeline | 🟡 Staging | Unit + integration mocked; needs live Kafka demo | | Frontend | 🟢 Ready | Build passes, responsive CSS, mobile nav | @@ -195,7 +200,7 @@ Already present and correct: `.env`, `node_modules/`, `__pycache__/`, `.pytest_c ## 8. Final merge checklist -- [x] All Python tests pass (279) +- [x] All Python tests pass (284) - [x] Coverage ≥ 70% - [x] Frontend build + unit tests pass - [x] Demo seed 5×+ expansion with scale presets @@ -227,7 +232,7 @@ Already present and correct: `.env`, `node_modules/`, `__pycache__/`, `.pytest_c ### Test plan -- [x] `pytest tests/` — 279 passed +- [x] `pytest tests/` — 284 passed - [ ] `cd frontend && npm test && npm run build` - [ ] `python scripts/seed_demo.py --dry-run --scale enterprise` - [ ] `npm run test:e2e` (with API + frontend dev servers) diff --git a/frontend/e2e/a11y.spec.ts b/frontend/e2e/a11y.spec.ts new file mode 100644 index 0000000..5aa63e8 --- /dev/null +++ b/frontend/e2e/a11y.spec.ts @@ -0,0 +1,24 @@ +import { expect, test } from "@playwright/test"; +import AxeBuilder from "@axe-core/playwright"; +import { dismissOnboarding, installApiMocks } from "./fixtures"; + +test.beforeEach(async ({ page }) => { + await dismissOnboarding(page); + await installApiMocks(page); +}); + +test.describe("Accessibility audit", () => { + test("home view has no serious axe violations", async ({ page }) => { + await page.goto("/"); + await expect(page.locator("main#main")).toBeVisible(); + + const results = await new AxeBuilder({ page }) + .disableRules(["color-contrast"]) + .analyze(); + + const serious = results.violations.filter( + (v) => v.impact === "serious" || v.impact === "critical", + ); + expect(serious).toEqual([]); + }); +}); diff --git a/frontend/e2e/journeys.spec.ts b/frontend/e2e/journeys.spec.ts index 5d8410a..60169ae 100644 --- a/frontend/e2e/journeys.spec.ts +++ b/frontend/e2e/journeys.spec.ts @@ -25,7 +25,7 @@ test.describe("Critical user journeys", () => { test("Assist panel opens and accepts a mocked search", async ({ page }) => { await page.setViewportSize({ width: 390, height: 844 }); await page.goto("/"); - await page.locator(".mobile-nav__item--copilot").click(); + await page.locator(".mobile-nav__item--assist").click(); await expect(page.getByRole("complementary", { name: /cortex assist/i })).toBeVisible(); await expect(page.getByRole("heading", { name: /cortex assist/i })).toBeVisible(); diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 2184746..7baa26e 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -12,6 +12,7 @@ "react-dom": "^18.3.1" }, "devDependencies": { + "@axe-core/playwright": "^4.10.1", "@playwright/test": "^1.49.1", "@testing-library/jest-dom": "^6.6.3", "@testing-library/react": "^16.0.1", @@ -52,6 +53,19 @@ "dev": true, "license": "ISC" }, + "node_modules/@axe-core/playwright": { + "version": "4.11.3", + "resolved": "https://registry.npmjs.org/@axe-core/playwright/-/playwright-4.11.3.tgz", + "integrity": "sha512-h/kfksv4F0cVIDlKpT4700OehdRgpvuVskuQ2nb7/JmtWUXpe9ftHAPtwyXGvVSsa6SJ64A9ER7Zrzc/sIvC4w==", + "dev": true, + "license": "MPL-2.0", + "dependencies": { + "axe-core": "~4.11.4" + }, + "peerDependencies": { + "playwright-core": ">= 1.0.0" + } + }, "node_modules/@babel/code-frame": { "version": "7.29.0", "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.0.tgz", @@ -1672,6 +1686,16 @@ "dev": true, "license": "MIT" }, + "node_modules/axe-core": { + "version": "4.11.4", + "resolved": "https://registry.npmjs.org/axe-core/-/axe-core-4.11.4.tgz", + "integrity": "sha512-KunSNx+TVpkAw/6ULfhnx+HWRecjqZGTOyquAoWHYLRSdK1tB5Ihce1ZW+UY3fj33bYAFWPu7W/GRSmmrCGuxA==", + "dev": true, + "license": "MPL-2.0", + "engines": { + "node": ">=4" + } + }, "node_modules/baseline-browser-mapping": { "version": "2.10.29", "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.29.tgz", diff --git a/frontend/package.json b/frontend/package.json index cef05dd..19f6c5a 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -17,6 +17,7 @@ "react-dom": "^18.3.1" }, "devDependencies": { + "@axe-core/playwright": "^4.10.1", "@playwright/test": "^1.49.1", "@testing-library/jest-dom": "^6.6.3", "@testing-library/react": "^16.0.1", diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 981d74f..baf3c8f 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -64,7 +64,7 @@ function TopbarActions() {