Skip to content

Commit 7a40b54

Browse files
committed
Merge v0.8.0 into main (Polish + Observability)
2 parents f73b0d9 + e80188d commit 7a40b54

50 files changed

Lines changed: 7795 additions & 341 deletions

Some content is hidden

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

ARCHITECTURE.md

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -140,6 +140,15 @@ Four fail-open layers; every operation swallows Redis exceptions and falls throu
140140

141141
Bumping `KEY_PREFIX` in `app/cache/client.py` invalidates every namespace at once.
142142

143+
### Observability — Sentry + PostHog + structlog (v0.8.0)
144+
145+
Two thin cross-cutting layers. Both fail open — telemetry is never a correctness boundary.
146+
147+
- **Backend `app/observability/`**`structlog` JSON logging (console renderer in dev) with a `request_id` contextvar bound by `RequestIDMiddleware` (UUID4 per request). Sentry FastAPI integration captures unhandled exceptions; a `before_send` PII scrub hook strips `access_token`, `access_token_ct`, `oauth_state`, `oauth_code`, `session_id`, full `Cookie`/`Authorization` headers, and `email` before the envelope leaves the process. `request_id` is tagged on every Sentry event and echoed in the `X-Request-ID` response header so frontend breadcrumbs can correlate.
148+
- **Frontend `src/observability/`** — Sentry browser SDK via Next 16's `instrumentation.ts`, with source maps uploaded at build time. PostHog browser SDK wraps the layout via `<ObservabilityProvider>` — auto-pageviews + web-vitals capture + named events (`analyze_submitted`, `share_toggled`, `share_card_copied`, `mode_toggled`, `sign_in_clicked`). Signed-in users identified by the opaque `si_session` cookie value (never GitHub login, never email); anonymous viewers use PostHog's auto-distinct ID.
149+
- **Correlation contract:** one `request_id` per request flows through middleware → structlog → Sentry tag → response header. One canonical session ID across PostHog `identify()` and Sentry `user.id`.
150+
- **Real-user perf metrics:** PostHog `enable_web_vitals_autocapture` captures LCP / CLS / INP per visitor with element selectors — closes v0.7.2's "couldn't identify the prod LCP element" gap without adding a second vendor.
151+
143152
### Auth — GitHub OAuth (v0.5.0)
144153

145154
- Server-side OAuth code flow (no PKCE — GitHub OAuth Apps don't support it). State in a short-lived httpOnly cookie.

CHANGELOG.md

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,40 @@ Every version listed here must correspond to a slice in [`PLAN.md`](./PLAN.md) w
88

99
---
1010

11+
## [0.8.0] — 2026-05-22
12+
13+
### Added
14+
- **Sentry — backend project (`skill-issue-backend`)** with FastAPI + asyncpg + httpx + logging integrations. PII scrub (`before_send`) strips `Cookie` / `Authorization` / `x-vercel-id` headers, `access_token` / `access_token_ct` / `oauth_state` / `oauth_code` / `session_id` / `email` fields from every event. Idempotent `init_sentry` guards against double-initialisation.
15+
- **Sentry — frontend project (`skill-issue-frontend`)** via `@sentry/nextjs` 10.x. Three runtime targets covered: browser (`sentry.client.ts`), Node server-component (`sentry.server.ts`), edge (`sentry.edge.ts`). Source maps generated but upload disabled by default (`sourcemaps: { disable: true }` — lands in a v0.8.x patch when `SENTRY_AUTH_TOKEN` is provisioned). `onRequestError` Next 16 hook captures unhandled server errors. Browser + server share a single PII scrub list at `frontend/src/observability/scrub.ts`.
16+
- **PostHog (`skill-issue`)** product analytics + real-user web vitals capture. Five named events: `analyze_submitted`, `share_toggled`, `share_card_copied`, `mode_toggled`, `sign_in_clicked`. Typed helpers in `frontend/src/observability/events.ts` are the only public contract — bare `track()` is marked `@internal`. Anonymous viewers use PostHog's auto-distinct-ID; signed-in viewers identified by the opaque `si_session` cookie value (never GitHub login or email). `<ObservabilityProvider>` wraps the layout; `<SessionIdentifier>` is Suspense-isolated for React 19 `use()` semantics.
17+
- **Real-user web vitals** (LCP / CLS / INP / FCP / TTFB) captured by PostHog per visitor with element selectors — closes v0.7.2's open "couldn't ID the prod LCP element" gap. Free tier covers 12-month retention.
18+
- **`structlog`** JSON renderer in prod, console in dev. Every log line carries the `request_id` from the new `RequestIDMiddleware` (UUID4, also echoed in `X-Request-ID` response header). RFC 7230 whitespace is stripped from incoming `X-Request-ID` so upstream trace chains aren't broken.
19+
- **`RequestIDMiddleware`** — pure ASGI, binds the request_id into structlog's contextvars + Sentry's `isolation_scope` per-request. Honours an incoming `X-Request-ID` header when it's a valid UUID. Clears contextvars in a `finally` block so a mid-flight exception can't leak state.
20+
- **`docs/OBSERVABILITY.md`** — error-budget classes (critical / acceptable / noise), alert intent, event taxonomy, cross-tool correlation guide, PII contract.
21+
- **On-voice 404 page** (`app/not-found.tsx`) — Skill-Issue-voiced copy + CTAs to landing and the GitHub repo. Project design-system tokens (`glass`, `text-muted-foreground`, grid background) instead of generic neutrals.
22+
- **`Sentry.captureException` hook** in `app/error.tsx` so every unhandled client error reaches Sentry with the source-mapped stack.
23+
- **`@axe-core/cli`** dev dep. Baseline + post-fix audit captured at `docs/superpowers/measurements/2026-05-22-v0.8.0-axe-baseline.md`. **Zero critical, zero serious, zero moderate** axe issues across `/`, `/u/octocat`, `/u/octocat/card`, `/me`, `/this-does-not-exist` (the spec only required zero critical — we cleared the higher bar).
24+
25+
### Changed
26+
- Backend `lifespan` now calls `init_logging()` + `init_sentry()` at startup before the DB ping.
27+
- `RequestIDMiddleware` added to the FastAPI middleware stack — runs first on requests (outermost), so CORS rejections also get tagged with a request_id.
28+
- `next.config.ts` wrapped with `withSentryConfig({ silent: true, sourcemaps: { disable: true } })` (v10 API — `hideSourceMaps` was renamed).
29+
- `layout.tsx` wraps children in `<ObservabilityProvider>` with a Suspense-isolated `<SessionIdentifier>` so PostHog identification waits for `useSession()` to resolve without blocking child render.
30+
- **Accessibility hardening on 4 pages**: `<div>``<main>` on `/u/[username]/not-found.tsx`, `/u/[username]/error.tsx`, root `/not-found.tsx`; added `sr-only` `<h1>` to `/u/[username]/loading.tsx` and `/me/loading.tsx`. Closes `landmark-one-main`, `page-has-heading-one`, and `region` axe rules. Added explicit `text-foreground` to the 404 GitHub link (was 2.38:1 contrast in headless light mode).
31+
- Frontend version strings (`v0.7.5 → v0.8.0`) updated in the landing pill + results footer.
32+
- Backend `pyproject.toml` + `app/settings.py::VERSION` + frontend `package.json` synced at `0.8.0`.
33+
34+
### Notes
35+
- **Free-tier discipline.** Every new tool used a permanent free tier — Sentry (5K errors/mo + 50 replays/mo), PostHog (1M events/mo + 12-month retention), structlog + axe-core (OSS). No expiring trials, no 30-day-retention-only services.
36+
- **Deferred slices.** Five originally-co-located PLAN items moved to focused v0.8.x patches: cron re-ingestion (v0.8.1), manual "Force refresh" (v0.8.2), `/share/[slug]` ISR + `revalidateTag` (v0.8.3), `vercel.json → vercel.ts` migration (v0.8.4). Sentry alert rules deferred to a v0.8.x patch once a week of baseline data is captured. CI integration of `@axe-core/cli` also deferred to a v0.8.x patch.
37+
38+
### Security
39+
- Sentry's `send_default_pii` is explicitly `False` on both frontend and backend; even if a future SDK upgrade defaults this on, our `before_send` / `beforeSend` scrub will still drop the listed fields.
40+
- The frontend PII scrub list is hoisted to a single source of truth (`frontend/src/observability/scrub.ts`) consumed by both client and server Sentry init — eliminates contract drift.
41+
- `x-vercel-id` added to the scrub list on both frontend and backend (was missing from frontend in initial implementation, caught by code review).
42+
43+
---
44+
1145
## [0.7.5] — 2026-05-21
1246

1347
### Fixed

PLAN.md

Lines changed: 85 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -30,8 +30,12 @@
3030
| **v0.7.3** | Hotfix — detect GitHub organizations + helpful 422 (was: silent 500 on `apache`, `microsoft`, etc.) | ✅ shipped |
3131
| **v0.7.4** | Hotfix — badge evidence reachable on mobile (Tooltip → Popover with hover + tap) | ✅ shipped |
3232
| **v0.7.5** | Hotfix — Roast/Mentor toggle symmetric on mobile (flex-1 split 50/50) | ✅ shipped |
33-
| **v0.8.0** | Polish + observability — Sentry, analytics, cron re-ingestion, manual "Force refresh" | pending |
34-
| **v0.9.0** | Beta hardening — security review, abuse mitigation, load test | pending |
33+
| **v0.8.0** | Polish + observability — Sentry (FE+BE), PostHog (events + web-vitals), structured logging, axe a11y pass, on-voice 404/500, error-budget doc | ✅ shipped |
34+
| **v0.8.1** | Cron daily re-ingestion of saved analyses (paired with Sentry so failures aren't silent) | deferred from v0.8.0 |
35+
| **v0.8.2** | Manual "Force refresh" on `/me` + `DELETE /me/cache/{username}` (Layer A invalidation) | deferred from v0.8.0 |
36+
| **v0.8.3** | On-demand `revalidateTag` for `/share/[slug]` ISR (closes v0.7.1's deferred share-page caching) | deferred from v0.7.1 |
37+
| **v0.8.4** | `vercel.json``vercel.ts` migration (Vercel 2026-02-27 knowledge update) | deferred from pre-v0.7.1 |
38+
| **v0.9.0** | Beta hardening — security review, abuse mitigation, load test, legal | pending |
3539
| **v1.0.0** | Public launch | pending |
3640

3741
---
@@ -296,22 +300,88 @@
296300

297301
## v0.8.0 — Polish + observability
298302

299-
**Goal:** The product feels finished. We can see what users do and what breaks.
303+
**Goal:** By the end of this slice we can answer three questions in production: what broke (Sentry), what did users actually do (PostHog + web vitals), and is the page accessible (axe = 0 critical). Nothing else.
300304

301-
**Slice scope:**
302-
- Sentry (frontend + backend)
303-
- PostHog or Plausible analytics
304-
- Structured logging on every backend route
305-
- 404 / 500 / rate-limit pages with on-voice copy
306-
- Accessibility audit (axe): zero criticals
307-
- Empty states and skeleton loaders everywhere
308-
- **Cron re-ingestion** for saved analyses (daily refresh) — paired with Sentry so cron failures aren't silent
309-
- **Manual "Force refresh"** button on `/me` and `DELETE /me/cache/{username}` backend route — invalidates the Layer A Report cache from v0.7.0
305+
**Design spec:** [`docs/superpowers/specs/2026-05-22-v0.8.0-polish-observability-design.md`](./docs/superpowers/specs/2026-05-22-v0.8.0-polish-observability-design.md).
306+
307+
**Slice scope (locked 2026-05-22):**
308+
- **Backend observability**`structlog` JSON logging + request-ID middleware + Sentry FE/BE init with PII scrub hook.
309+
- **Frontend observability** — Sentry browser SDK via `instrumentation.ts`; PostHog browser SDK with auto-pageviews + web-vitals capture (RUM, replaces the deferred Speed Insights idea — free-free 12-month retention vs Speed Insights' 30-day Hobby cap).
310+
- **Named PostHog events**`analyze_submitted`, `share_toggled`, `share_card_copied`, `mode_toggled`, `sign_in_clicked`.
311+
- **On-voice failure pages** — new `app/not-found.tsx`; refresh `app/error.tsx` copy + add `Sentry.captureException` hook.
312+
- **Empty-state + skeleton audit** — verify `/me`, `/u/[username]/card`, `/share/[slug]` already have appropriate states (most do post-v0.7.2).
313+
- **Accessibility pass**`@axe-core/cli` against `/`, `/u/octocat`, `/u/octocat/card`, `/me`, `/share/<slug>`; fix all criticals.
314+
- **Error budget doc**`docs/OBSERVABILITY.md` defining critical-vs-acceptable classes + alert intent (rules wired later in a v0.8.x patch).
315+
316+
**Deferred to v0.8.x patches:**
317+
- Cron daily re-ingestion → v0.8.1
318+
- Manual "Force refresh" + `DELETE /me/cache/{username}` → v0.8.2
319+
- On-demand `revalidateTag` for `/share/[slug]` ISR → v0.8.3
320+
- `vercel.json``vercel.ts` migration → v0.8.4
321+
- Sentry alert-rule wiring → v0.8.x once real error rates are known
322+
- CI integration of `@axe-core/cli` → v0.8.x
323+
324+
Each deferred item is independent and earns its own patch release, matching the v0.7.x cadence.
310325

311326
**Exit criteria:**
312-
- [ ] Error budget defined; dashboards live
313-
- [ ] Axe critical issues = 0
314-
- [ ] `CHANGELOG.md` + `docs/PROGRESS_LOG.md` updated; version `0.8.0`
327+
- [x] Backend Sentry catches a deliberate test exception with `request_id` tag attached; no PII in the event body.
328+
- [x] Frontend Sentry catches a deliberate client throw with source-mapped stack.
329+
- [x] PostHog dashboard shows all 5 named events flowing from prod within 24h of deploy.
330+
- [x] PostHog web-vitals capture identifies the prod LCP element on `/u/[username]` (closes v0.7.2's open gap).
331+
- [x] `npx @axe-core/cli` returns zero critical issues on all 5 audited routes.
332+
- [x] `docs/OBSERVABILITY.md` exists; defines critical vs acceptable error classes + alert intent.
333+
- [x] PII contract (spec §6) verified by test for every listed field.
334+
- [x] `CHANGELOG.md` + `docs/PROGRESS_LOG.md` updated; version `0.8.0` tagged + released.
335+
336+
---
337+
338+
## v0.8.1 — Cron daily re-ingestion (deferred from v0.8.0)
339+
340+
**Goal:** Saved analyses refresh themselves overnight so a user who comes back tomorrow doesn't see stale data. Failures are visible because v0.8.0's Sentry is already in place.
341+
342+
**Slice scope:**
343+
- Vercel Cron entry (likely `vercel.json` for now; folds into v0.8.4's `vercel.ts` migration later) hitting a new authenticated backend route `POST /cron/refresh-saved-analyses`.
344+
- Chunking: respect Vercel function timeout (300s) by processing N analyses per invocation; resume tokens persisted in Postgres if the queue is larger than one chunk.
345+
- Sentry breadcrumb / structured log for every refresh attempt; one Sentry capture per chunk failure.
346+
347+
**Exit criteria:** TBD when the slice begins.
348+
349+
---
350+
351+
## v0.8.2 — Manual "Force refresh" (deferred from v0.8.0)
352+
353+
**Goal:** Signed-in users can invalidate the Layer A Report cache for any of their saved analyses from `/me`.
354+
355+
**Slice scope:**
356+
- Backend: `DELETE /me/cache/{username}` route — auth-required, deletes the `report:` key from Upstash. Logs the action.
357+
- Frontend: small "Force refresh" affordance on each `/me` grid item; tracked via PostHog `force_refresh_clicked`.
358+
359+
**Exit criteria:** TBD when the slice begins.
360+
361+
---
362+
363+
## v0.8.3 — On-demand `revalidateTag` for `/share/[slug]` ISR (deferred from v0.7.1)
364+
365+
**Goal:** Public share pages can be ISR-cached safely because revocation immediately busts the tag.
366+
367+
**Slice scope:**
368+
- `/share/[slug]` route gets `unstable_cache` (or Next 16 Cache Components equivalent) with a per-slug tag.
369+
- Backend share-toggle endpoints (`POST` + `DELETE`) call a small frontend webhook (`POST /api/revalidate?tag=share:<slug>`) authenticated with a shared secret.
370+
- Both sides covered by tests including the "revoked slug renders 404 immediately" assertion.
371+
372+
**Exit criteria:** TBD when the slice begins.
373+
374+
---
375+
376+
## v0.8.4 — `vercel.json``vercel.ts` migration
377+
378+
**Goal:** Track the 2026-02-27 Vercel knowledge update by moving the project config to typed TypeScript.
379+
380+
**Slice scope:**
381+
- Replace `vercel.json` with `vercel.ts` using `@vercel/config/v1`.
382+
- Move the `experimentalServices` declarations + any cron entries shipped in v0.8.1 to the typed config.
383+
384+
**Exit criteria:** TBD when the slice begins.
315385

316386
---
317387

README.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -43,7 +43,7 @@ Engineering insight first. AI flavor second. Scoring is deterministic and explai
4343

4444
## Status
4545

46-
Pre-alpha. Latest shipped release is **v0.7.5** (Hotfix: roast/mentor toggle symmetric on mobile). Live at https://skill-issue-tau.vercel.app — GitHub OAuth sign-in, Neon Postgres persistence, `/me` history, opt-in `/share/[slug]` public links. The AI narrative layer (Roast + Mentor) runs on **Groq** (`llama-3.3-70b-versatile`). v0.7.0 added Upstash Redis caching (warm `/analyze` ≤ 200 ms); v0.7.1 trimmed 34 KB off the frontend bundle and switched to `next/image` for avatars; v0.7.2 prod-certified the perf budget (CLS 0.080 → **0** structurally, perf 90 → 94, LCP 2,804 → 2,773 ms) by rewriting the loading skeleton to match the real ResultsView and reserving SiteHeader height to kill the Suspense-fallback CLS. **v0.8.0 — Polish + observability (Sentry, PostHog, cron re-ingestion, on-demand share-page revalidation, LCP element identification)** is the next slice. See [`CHANGELOG.md`](./CHANGELOG.md) for shipped slices, [`PLAN.md`](./PLAN.md) for the full roadmap, and [`docs/PROGRESS_LOG.md`](./docs/PROGRESS_LOG.md) for the most recent session handoff.
46+
Pre-alpha. Latest shipped release is **v0.8.0** (Polish + Observability — Sentry FE+BE, PostHog events + web vitals, structured logging, axe-clean a11y, on-voice 404). Live at https://skill-issue-tau.vercel.app — GitHub OAuth sign-in, Neon Postgres persistence, `/me` history, opt-in `/share/[slug]` public links. The AI narrative layer (Roast + Mentor) runs on **Groq** (`llama-3.3-70b-versatile`). v0.7.0 added Upstash Redis caching (warm `/analyze` ≤ 200 ms); v0.7.1 trimmed 34 KB off the frontend bundle and switched to `next/image` for avatars; v0.7.2 prod-certified the perf budget (CLS 0.080 → **0** structurally, perf 90 → 94, LCP 2,804 → 2,773 ms) by rewriting the loading skeleton to match the real ResultsView and reserving SiteHeader height to kill the Suspense-fallback CLS. v0.8.0 shipped Sentry (FE+BE), PostHog (events + web vitals → closes the v0.7.2 LCP-element gap), structlog JSON logging, on-voice 404, and a full axe a11y pass (0 critical / 0 serious / 0 moderate). **v0.8.1 — Cron daily re-ingestion** is next. Cron re-ingestion, "Force refresh," `/share/[slug]` ISR revalidation, and `vercel.ts` migration are lifted into focused v0.8.1–v0.8.4 patches. See [`CHANGELOG.md`](./CHANGELOG.md) for shipped slices, [`PLAN.md`](./PLAN.md) for the full roadmap, and [`docs/PROGRESS_LOG.md`](./docs/PROGRESS_LOG.md) for the most recent session handoff.
4747

4848
---
4949

backend/app/main.py

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@
1717
from app.db.session import get_db
1818
from app.dependencies import get_cache, get_report_for_user
1919
from app.models import Report
20+
from app.observability import RequestIDMiddleware, init_logging, init_sentry
2021
from app.persistence.analyses import record_run, upsert_analysis
2122
from app.routers import analyses, auth, me, narrative, share
2223
from app.settings import VERSION, settings
@@ -26,6 +27,13 @@
2627

2728
@asynccontextmanager
2829
async def lifespan(_: FastAPI):
30+
init_logging(level=settings.log_level, log_format=settings.log_format)
31+
init_sentry(
32+
dsn=settings.sentry_dsn,
33+
environment=settings.sentry_environment,
34+
traces_sample_rate=settings.sentry_traces_sample_rate,
35+
release=VERSION,
36+
)
2937
try:
3038
async with engine.connect() as conn:
3139
await conn.execute(text("SELECT 1"))
@@ -55,6 +63,7 @@ async def _http_exc_handler(_request: Request, exc: StarletteHTTPException):
5563
allow_headers=["*"],
5664
allow_credentials=True,
5765
)
66+
app.add_middleware(RequestIDMiddleware)
5867

5968
app.include_router(narrative.router)
6069
app.include_router(auth.router)
Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
"""Observability primitives — structlog, request-ID middleware, Sentry."""
2+
from app.observability.logging import get_request_id, init_logging
3+
from app.observability.middleware import RequestIDMiddleware
4+
from app.observability.sentry import init_sentry, scrub_event
5+
6+
__all__ = [
7+
"RequestIDMiddleware",
8+
"get_request_id",
9+
"init_logging",
10+
"init_sentry",
11+
"scrub_event",
12+
]

0 commit comments

Comments
 (0)