From ab8a48dff2dce7ed5614f5c63d484cccfabf01c5 Mon Sep 17 00:00:00 2001 From: Artur Shiriev Date: Thu, 25 Jun 2026 19:45:25 +0300 Subject: [PATCH 1/3] chore(planning): adopt canonical convention v1.0.0 Vendor the canonical planning index.py + templates, slim every bundle's frontmatter to the lean form (summary only on specs; no frontmatter on plans; status+summary on decisions; date/slug derived from names), merge the three multi-PR bundles' per-PR plan files into one plan.md each, and update planning/README.md to the lean frontmatter prose. Wire the validator into CI: add the check-planning recipe and run planning/index.py --check in lint-ci. Add architecture/README.md (truth home intro + capability list + promotion rule) and pin the applied convention version in planning/.convention-version. Co-Authored-By: Claude Opus 4.8 (1M context) --- Justfile | 7 +- architecture/README.md | 27 + planning/.convention-version | 1 + planning/README.md | 42 +- planning/_templates/change.md | 9 +- planning/_templates/decision.md | 3 - planning/_templates/design.md | 9 +- planning/_templates/plan.md | 12 +- .../design.md | 7 - .../plan-pr1-crit1-redoc-root-path.md | 246 -- .../plan-pr2-crit2-otel-shutdown.md | 325 -- .../plan-pr3-crit3-idempotent-teardown.md | 486 --- .../plan-pr4-des4-des5-small-cleanups.md | 350 -- .../plan-pr5-des3-config-method-semantics.md | 292 -- .../plan-pr6-des2-otel-fields-mixin.md | 294 -- .../plan-pr7-des1-generic-instruments.md | 671 ---- .../plan.md | 2695 ++++++++++++++ .../design.md | 7 - .../plan.md | 7 - .../design.md | 7 - .../plan.md | 7 - .../design.md | 7 - .../plan-pr10-test-gap-fill.md | 382 -- .../plan-pr11-logging-cleanup.md | 628 ---- .../plan-pr12-base-layer-cleanup.md | 340 -- .../plan-pr13-frozen-setattr.md | 525 --- .../plan-pr14-faststream-timeout.md | 314 -- .../plan-pr15-naming-pass.md | 393 -- .../plan-pr16-post-retro-hygiene.md | 119 - .../plan-pr8-low-1-2-sentry-micro.md | 222 -- .../plan-pr9-otel-touch-ups.md | 343 -- .../2026-06-01.03-deferred-refactors/plan.md | 3305 +++++++++++++++++ .../design.md | 7 - .../plan.md | 7 - .../2026-06-05.01-bug-audit-v2/design.md | 7 - .../plan-pr1-lifecycle.md | 1083 ------ .../plan-pr2-config-security.md | 1003 ----- .../plan-pr3-hygiene-ci.md | 419 --- .../2026-06-05.01-bug-audit-v2/plan.md | 2520 +++++++++++++ .../design.md | 7 - .../2026-06-09.01-mkdocs-github-pages/plan.md | 7 - .../design.md | 7 - .../design.md | 7 - .../plan.md | 8 - .../design.md | 7 - .../plan.md | 8 - .../design.md | 7 - .../plan.md | 8 - .../2026-06-24-keep-per-instrument-axis.md | 3 - ...6-06-24-teardown-marker-accepted-limits.md | 3 - planning/index.py | 132 +- 51 files changed, 8685 insertions(+), 8647 deletions(-) create mode 100644 architecture/README.md create mode 100644 planning/.convention-version delete mode 100644 planning/changes/2026-05-31.01-audit-implementation/plan-pr1-crit1-redoc-root-path.md delete mode 100644 planning/changes/2026-05-31.01-audit-implementation/plan-pr2-crit2-otel-shutdown.md delete mode 100644 planning/changes/2026-05-31.01-audit-implementation/plan-pr3-crit3-idempotent-teardown.md delete mode 100644 planning/changes/2026-05-31.01-audit-implementation/plan-pr4-des4-des5-small-cleanups.md delete mode 100644 planning/changes/2026-05-31.01-audit-implementation/plan-pr5-des3-config-method-semantics.md delete mode 100644 planning/changes/2026-05-31.01-audit-implementation/plan-pr6-des2-otel-fields-mixin.md delete mode 100644 planning/changes/2026-05-31.01-audit-implementation/plan-pr7-des1-generic-instruments.md create mode 100644 planning/changes/2026-05-31.01-audit-implementation/plan.md delete mode 100644 planning/changes/2026-06-01.03-deferred-refactors/plan-pr10-test-gap-fill.md delete mode 100644 planning/changes/2026-06-01.03-deferred-refactors/plan-pr11-logging-cleanup.md delete mode 100644 planning/changes/2026-06-01.03-deferred-refactors/plan-pr12-base-layer-cleanup.md delete mode 100644 planning/changes/2026-06-01.03-deferred-refactors/plan-pr13-frozen-setattr.md delete mode 100644 planning/changes/2026-06-01.03-deferred-refactors/plan-pr14-faststream-timeout.md delete mode 100644 planning/changes/2026-06-01.03-deferred-refactors/plan-pr15-naming-pass.md delete mode 100644 planning/changes/2026-06-01.03-deferred-refactors/plan-pr16-post-retro-hygiene.md delete mode 100644 planning/changes/2026-06-01.03-deferred-refactors/plan-pr8-low-1-2-sentry-micro.md delete mode 100644 planning/changes/2026-06-01.03-deferred-refactors/plan-pr9-otel-touch-ups.md create mode 100644 planning/changes/2026-06-01.03-deferred-refactors/plan.md delete mode 100644 planning/changes/2026-06-05.01-bug-audit-v2/plan-pr1-lifecycle.md delete mode 100644 planning/changes/2026-06-05.01-bug-audit-v2/plan-pr2-config-security.md delete mode 100644 planning/changes/2026-06-05.01-bug-audit-v2/plan-pr3-hygiene-ci.md create mode 100644 planning/changes/2026-06-05.01-bug-audit-v2/plan.md diff --git a/Justfile b/Justfile index ac4c482..b326538 100644 --- a/Justfile +++ b/Justfile @@ -15,11 +15,16 @@ lint-ci: uv run ruff format --check uv run ruff check --no-fix uv run ty check + uv run python planning/index.py --check -# Print the planning change index (grouped by status) to stdout. +# Print the planning change index (flat, newest-first) to stdout. index: uv run python planning/index.py +# Validate planning bundles + decisions; CI runs this. +check-planning: + uv run python planning/index.py --check + test *args: uv run --no-sync pytest {{ args }} diff --git a/architecture/README.md b/architecture/README.md new file mode 100644 index 0000000..7e3d13b --- /dev/null +++ b/architecture/README.md @@ -0,0 +1,27 @@ +# Architecture + +The living, code-current truth about **what `lite-bootstrap` does now** — one +file per capability, written as prose and dated by git. This is the truth home: +the present-tense companion to `planning/changes/`, which records *how it got +there*. + +## Capabilities + +- [`config-model.md`](config-model.md) — frozen `BaseConfig` hierarchy, framework + configs via multiple inheritance, `from_dict`/`from_object` semantics, the + `UNSET` sentinel, and the `__post_init__` cascade invariant. +- [`instruments.md`](instruments.md) — `BaseInstrument` lifecycle, the instrument + catalog, the optional-dependency guard, why instruments are non-frozen, the + cross-instrument integrations (Logging↔Sentry, OTel↔Logging, Pyroscope↔OTel), + and OpenTelemetry's single-instance-per-process constraint. +- [`bootstrappers.md`](bootstrappers.md) — the `BaseBootstrapper` hierarchy, skip + ordering at construction, the instrument registry + idempotent teardown, summary + logging, the teardown-on-shutdown attach seam, and the `_lite_bootstrap_*` + app-tagging sentinel convention. + +## Promotion rule + +When a change alters a capability's behavior, **hand-edit the matching +`architecture/.md` in the same PR** as the code. That promotion — +reviewed in the same diff, never deferred to a post-merge step — is what keeps +these files true. Code that changes without it silently rots the truth home. diff --git a/planning/.convention-version b/planning/.convention-version new file mode 100644 index 0000000..3eefcb9 --- /dev/null +++ b/planning/.convention-version @@ -0,0 +1 @@ +1.0.0 diff --git a/planning/README.md b/planning/README.md index 075ab36..c4efd05 100644 --- a/planning/README.md +++ b/planning/README.md @@ -6,10 +6,11 @@ at the repo root; this directory records *how it got there*. ## Conventions -> This section is the portable convention — identical across the -> modern-python repos. The generated change listing (`just index`) and the `## Other` pointers below are repo-local. To adopt elsewhere, -> copy this section plus [`_templates/`](_templates/) and point that repo's -> `CLAUDE.md` Workflow + truth home at it. +> This section is the portable convention, sourced from the canonical repo +> [`lesnik512/planning-convention`](https://github.com/lesnik512/planning-convention) +> (applied version in [`.convention-version`](.convention-version)). To update +> it, run that repo's `APPLY.md` flow. The generated change index (`just index`) +> and the `## Other` pointers below are repo-local. ### Two axes, never mixed @@ -32,10 +33,11 @@ A change is a folder `changes/YYYY-MM-DD.NN-/`: (`.01`, `.02`, …) that breaks same-date ties so the timeline sorts stably. - `` — kebab-case description, not a story ID. -`summary` is written when the change is created (it is the change's -one-liner). The implementing PR then sets `status: shipped` and fills `pr` -and `outcome` **in the branch**, alongside the code and the `architecture/` -promotion — no post-merge bookkeeping, no folder move. +`summary` is written when the change is created (the intent one-liner) and +**finalized at ship** to state the realized result — set in the implementing +PR, alongside the code and the `architecture/` promotion. No post-merge +bookkeeping, no folder move. `date` and `slug` are never written — they are +read from the bundle's directory name. ### Three lanes @@ -66,19 +68,25 @@ Templates live in [`_templates/`](_templates/). ### Frontmatter -`design.md` / `change.md`: `status` (draft|approved|shipped|superseded), -`date`, `slug`, `summary` (single line), `supersedes`, `superseded_by`, `pr`, -`outcome`. `plan.md`: `status`, `date`, `slug`, `spec`, `pr`. -`decisions/*.md`: `status` (accepted|superseded), `date`, `slug`, `summary`, -`supersedes`, `superseded_by`, `pr`. Files in -`architecture/` carry **no** frontmatter — living prose, dated by git. +`date` and `slug` are **derived from the directory / file name** — never +repeated in frontmatter. So: + +- `design.md` / `change.md`: `summary` (single line) only. +- `plan.md`: **no frontmatter** — its identity is the bundle directory. +- `decisions/*.md`: `status` (accepted|superseded), `summary`, and optional + `supersedes` / `superseded_by`. +- Files in `architecture/` carry **no** frontmatter — living prose, dated by git. + +**`summary`** is one line: written at creation as the intent, then **finalized +at ship** to state the realized result — what shipped and its effect. It is the +only field the index renders. ## Index The listing is **generated**, not maintained — run `just index` to print it: -changes grouped by `status` (In progress / Shipped / Superseded), then -decisions newest-first. The frontmatter in each bundle / decision file is the -single source of truth; there is no committed copy to drift. +changes flat, newest-first, then decisions newest-first. The frontmatter in +each bundle / decision file is the single source of truth; there is no +committed copy to drift. ## Other diff --git a/planning/_templates/change.md b/planning/_templates/change.md index 7ffec26..d4c8962 100644 --- a/planning/_templates/change.md +++ b/planning/_templates/change.md @@ -1,12 +1,5 @@ --- -status: draft -date: YYYY-MM-DD -slug: my-change -summary: One line — shown in the generated index. Fill at ship time. -supersedes: null -superseded_by: null -pr: null -outcome: null +summary: One line — shown in the generated index. Written at creation; finalize at ship to state the realized result. --- # Change: One-line capitalized title diff --git a/planning/_templates/decision.md b/planning/_templates/decision.md index 940fb37..45ccaf0 100644 --- a/planning/_templates/decision.md +++ b/planning/_templates/decision.md @@ -1,11 +1,8 @@ --- status: accepted # accepted | superseded -date: YYYY-MM-DD -slug: my-decision summary: One line — shown in `just index`. supersedes: null superseded_by: null -pr: null # PR/commit where the decision was made or recorded --- # One-line capitalized title diff --git a/planning/_templates/design.md b/planning/_templates/design.md index b9e11c9..d63e22d 100644 --- a/planning/_templates/design.md +++ b/planning/_templates/design.md @@ -1,12 +1,5 @@ --- -status: draft -date: YYYY-MM-DD -slug: my-change -summary: One line — shown in the generated index. Fill at ship time. -supersedes: null -superseded_by: null -pr: null -outcome: null +summary: One line — shown in the generated index. Written at creation; finalize at ship to state the realized result. --- # Design: One-line capitalized title diff --git a/planning/_templates/plan.md b/planning/_templates/plan.md index f2b90e8..132d720 100644 --- a/planning/_templates/plan.md +++ b/planning/_templates/plan.md @@ -1,11 +1,3 @@ ---- -status: draft -date: YYYY-MM-DD -slug: my-change -spec: my-change -pr: null ---- - # — implementation plan > **For agentic workers:** REQUIRED SUB-SKILL: Use @@ -46,9 +38,7 @@ in the spec. ```bash git add path/to/file.py - git commit -m ": - - Co-Authored-By: Claude Opus 4.7 (1M context) " + git commit -m ": " ``` --- diff --git a/planning/changes/2026-05-31.01-audit-implementation/design.md b/planning/changes/2026-05-31.01-audit-implementation/design.md index 9f434bc..cd08a33 100644 --- a/planning/changes/2026-05-31.01-audit-implementation/design.md +++ b/planning/changes/2026-05-31.01-audit-implementation/design.md @@ -1,12 +1,5 @@ --- -status: shipped -date: 2026-05-31 -slug: audit-implementation summary: Criticals (CRIT-1..3) + design issues (DES-1..5) + paired tests across seven sequenced PRs. -supersedes: null -superseded_by: null -pr: null -outcome: "shipped as #89–#95" --- # Audit Implementation Sequencing diff --git a/planning/changes/2026-05-31.01-audit-implementation/plan-pr1-crit1-redoc-root-path.md b/planning/changes/2026-05-31.01-audit-implementation/plan-pr1-crit1-redoc-root-path.md deleted file mode 100644 index 7f9d105..0000000 --- a/planning/changes/2026-05-31.01-audit-implementation/plan-pr1-crit1-redoc-root-path.md +++ /dev/null @@ -1,246 +0,0 @@ -# PR1: Redoc `root_path` Fix Implementation Plan - -> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. - -**Goal:** Make the offline-docs `redoc_html` handler honor `root_path`, so redoc loads its JS and OpenAPI spec correctly when the FastAPI app is mounted behind a reverse proxy. Add a regression test that fails on `main` and passes after the fix. - -**Architecture:** The `enable_offline_docs` helper installs three handlers — one for swagger, one for swagger oauth2 redirect, one for redoc. The swagger handler already reads `request.scope["root_path"]` and prefixes asset/OpenAPI URLs. The redoc handler does not. Make the redoc handler match the swagger pattern. No public API change. - -**Tech Stack:** FastAPI, Starlette, pytest, fastapi.testclient. - -**Parent spec:** `docs/superpowers/specs/2026-05-31-audit-implementation-sequencing.md` (PR1 section). -**Parent audit:** `docs/superpowers/specs/2026-05-31-bug-refactor-audit.md` (CRIT-1, TEST-1). - ---- - -## File Structure - -Two existing files modified. No new files. - -- Modify: `lite_bootstrap/helpers/fastapi_helpers.py:52-58` — change `redoc_html` signature and URL construction. -- Modify: `tests/test_fastapi_offline_docs.py:32-43` — extend `test_fastapi_offline_docs_root_path` to fetch redoc and assert prefixing. - ---- - -## Task 1: Create branch - -**Files:** -- (no files; git branch only) - -- [ ] **Step 1: Create the feature branch** - -From `main` (clean working tree expected): - -```bash -git checkout main -git pull --ff-only origin main -git checkout -b fix/crit-1-redoc-root-path -``` - -Expected: `Switched to a new branch 'fix/crit-1-redoc-root-path'`. - ---- - -## Task 2: Add the failing regression test - -**Files:** -- Modify: `tests/test_fastapi_offline_docs.py:32-43` - -The existing `test_fastapi_offline_docs_root_path` exercises swagger under `root_path` but never fetches redoc. Extend it to fetch redoc and assert that both the redoc JS URL and the OpenAPI URL in the rendered HTML carry the `/some-root-path` prefix. The default `redoc_url` for a FastAPI app is `/redoc` (no override in the test setup), and the default `openapi_url` is `/openapi.json`. - -- [ ] **Step 1: Modify `test_fastapi_offline_docs_root_path`** - -Replace the existing function body (lines 33-43) with: - -```python -def test_fastapi_offline_docs_root_path() -> None: - app: FastAPI = FastAPI(title="Tests", root_path="/some-root-path", docs_url="/custom_docs") - enable_offline_docs(app, static_path="/static") - - with TestClient(app, root_path="/some-root-path") as client: - response = client.get("/custom_docs") - assert response.status_code == HTTPStatus.OK - assert "/some-root-path/static/swagger-ui.css" in response.text - assert "/some-root-path/static/swagger-ui-bundle.js" in response.text - - response = client.get("/some-root-path/static/swagger-ui.css") - assert response.status_code == HTTPStatus.OK - - response = client.get("/redoc") - assert response.status_code == HTTPStatus.OK - assert "/some-root-path/static/redoc.standalone.js" in response.text - assert "/some-root-path/openapi.json" in response.text -``` - -The four added lines: the redoc GET, the status assertion, the redoc JS URL assertion, the OpenAPI URL assertion. - -- [ ] **Step 2: Run the test and verify it FAILS** - -Run: - -```bash -just test -- tests/test_fastapi_offline_docs.py::test_fastapi_offline_docs_root_path -v -``` - -Expected: **FAIL** with an assertion error on one of the two new asserts — most likely -`assert "/some-root-path/static/redoc.standalone.js" in response.text` fails because the -rendered HTML contains `/static/redoc.standalone.js` (no `/some-root-path/` prefix). - -If the test does not fail, stop and investigate — either the assertion is wrong, or the bug -isn't present (which would mean the audit is stale). - ---- - -## Task 3: Implement the redoc fix - -**Files:** -- Modify: `lite_bootstrap/helpers/fastapi_helpers.py:52-58` - -The swagger handler at lines 37-46 is the pattern to mirror: it takes `request: Request`, -reads `root_path` from the ASGI scope, and prefixes asset URLs and the OpenAPI URL. - -- [ ] **Step 1: Replace the `redoc_html` handler** - -Replace lines 52-58 of `lite_bootstrap/helpers/fastapi_helpers.py` with: - -```python - @app.get(redoc_url, include_in_schema=False) - async def redoc_html(request: Request) -> HTMLResponse: - root_path = request.scope.get("root_path", "").rstrip("/") - return get_redoc_html( - openapi_url=f"{root_path}{app_openapi_url}", - title=f"{app.title} - ReDoc", - redoc_js_url=f"{root_path}{static_path}/redoc.standalone.js", - ) -``` - -Notes: -- `Request` is already imported at line 9. -- `root_path` handling matches the swagger handler exactly (`request.scope.get("root_path", "").rstrip("/")`). -- Both `openapi_url` and `redoc_js_url` get the prefix. The audit (CRIT-1) flagged the JS URL; the OpenAPI URL has the same bug — the test in Task 2 catches both. - -- [ ] **Step 2: Run the previously-failing test and verify it PASSES** - -Run: - -```bash -just test -- tests/test_fastapi_offline_docs.py::test_fastapi_offline_docs_root_path -v -``` - -Expected: **PASS**. - -- [ ] **Step 3: Run the full offline-docs test file** - -Run: - -```bash -just test -- tests/test_fastapi_offline_docs.py -v -``` - -Expected: all three tests PASS — `test_fastapi_offline_docs`, -`test_fastapi_offline_docs_root_path`, `test_fastapi_offline_docs_raises_without_openapi_url`. - -This confirms the change didn't break the no-`root_path` path or the error path. - -- [ ] **Step 4: Run the full test suite** - -Run: - -```bash -just test -``` - -Expected: all tests PASS with no new failures. - -- [ ] **Step 5: Run lint** - -Run: - -```bash -just lint -``` - -Expected: no errors. The change is small and follows existing patterns, so ruff, eof-fixer, -and `ty check` should all pass. - -- [ ] **Step 6: Commit** - -Stage both modified files explicitly: - -```bash -git add lite_bootstrap/helpers/fastapi_helpers.py tests/test_fastapi_offline_docs.py -git commit -m "$(cat <<'EOF' -fix: honor root_path in offline-docs redoc handler - -The swagger handler in enable_offline_docs already reads root_path from -the ASGI scope and prefixes asset/OpenAPI URLs. The redoc handler did -not, so redoc 404'd on its JS and OpenAPI spec when the FastAPI app -ran behind a reverse proxy. Mirror the swagger pattern: take Request, -read root_path, prefix both redoc_js_url and openapi_url. - -Extends test_fastapi_offline_docs_root_path to fetch redoc and assert -both URLs carry the prefix — the test fails on the prior code. - -Closes CRIT-1, TEST-1 from the audit. -EOF -)" -``` - -Expected: commit succeeds. (No pre-commit hooks are configured in this repo — see `.pre-commit-config.yaml` absence in repo root.) - ---- - -## Task 4: Push and open PR - -**Files:** -- (no files; git push + gh) - -- [ ] **Step 1: Push the branch** - -```bash -git push -u origin fix/crit-1-redoc-root-path -``` - -Expected: branch published; gh CLI may print a PR-creation URL. - -- [ ] **Step 2: Open the PR** - -```bash -gh pr create --title "fix: honor root_path in offline-docs redoc handler" --body "$(cat <<'EOF' -## Summary -- Redoc handler in `enable_offline_docs` now reads `root_path` from the ASGI scope and prefixes both `redoc_js_url` and `openapi_url`, matching the existing swagger handler pattern. -- Existing `test_fastapi_offline_docs_root_path` extended to fetch redoc and assert both URLs carry the `root_path` prefix. Test fails on `main`, passes on this branch. - -Closes CRIT-1 and TEST-1 from `docs/superpowers/specs/2026-05-31-bug-refactor-audit.md`. - -## Test plan -- [x] `just test -- tests/test_fastapi_offline_docs.py -v` — three tests pass. -- [x] `just test` — full suite passes. -- [x] `just lint` — clean. -- [ ] Reviewer: confirm the diff matches the swagger handler's `root_path` pattern. -EOF -)" -``` - -Expected: PR created; PR URL printed. - ---- - -## Self-Review - -Spec coverage check against `2026-05-31-audit-implementation-sequencing.md`, PR1 section: - -| Spec item | Task | -|-----------|------| -| Refactor `redoc_html` to accept `Request` | Task 3, Step 1 | -| Read `root_path` from `request.scope` and rstrip | Task 3, Step 1 | -| Prepend prefix to `redoc_js_url` | Task 3, Step 1 | -| Prepend prefix to `openapi_url` (also missing it) | Task 3, Step 1 | -| Extend `test_fastapi_offline_docs_root_path` to fetch redoc and assert prefix | Task 2, Step 1 | -| Verification: `just test` passes | Task 3, Steps 3-4 | -| Branch name `fix/crit-1-redoc-root-path` | Task 1, Step 1 | - -All spec items covered. No placeholders. Method signatures (`redoc_html(request: Request)`, -`request.scope.get("root_path", "")`) are consistent across Task 2's test expectations -and Task 3's implementation. The test asserts on `/some-root-path/static/redoc.standalone.js` -and `/some-root-path/openapi.json`; the implementation produces those exact strings. diff --git a/planning/changes/2026-05-31.01-audit-implementation/plan-pr2-crit2-otel-shutdown.md b/planning/changes/2026-05-31.01-audit-implementation/plan-pr2-crit2-otel-shutdown.md deleted file mode 100644 index a44bede..0000000 --- a/planning/changes/2026-05-31.01-audit-implementation/plan-pr2-crit2-otel-shutdown.md +++ /dev/null @@ -1,325 +0,0 @@ -# PR2: OpenTelemetry Tracer Provider Shutdown Implementation Plan - -> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. - -**Goal:** Make `OpenTelemetryInstrument.teardown()` shut down the `TracerProvider` that `bootstrap()` created. Today the provider goes out of scope and any spans buffered in `BatchSpanProcessor` are not flushed — trace data loss on graceful shutdown. Add a regression test that fails on `main` and passes after the fix. - -**Architecture:** `OpenTelemetryInstrument` is a frozen, slots-enabled dataclass. The existing codebase pattern for stashing mutable runtime state on a frozen dataclass uses an init-false private field plus `object.__setattr__` (see `LoggingInstrument._logger_factory`). Mirror that: add `_tracer_provider`, store the provider via `object.__setattr__` at the end of `bootstrap()`, call `self._tracer_provider.shutdown()` after the existing `uninstrument()` loop in `teardown()`, reset to `None`. - -**Tech Stack:** Python 3.10+, OpenTelemetry SDK (`opentelemetry-sdk`, `opentelemetry-api`), pytest, `unittest.mock.patch.object`. - -**Parent spec:** `docs/superpowers/specs/2026-05-31-audit-implementation-sequencing.md` (PR2 section). -**Parent audit:** `docs/superpowers/specs/2026-05-31-bug-refactor-audit.md` (CRIT-2, TEST-2). - ---- - -## File Structure - -Two existing files modified. No new files. - -- Modify: `lite_bootstrap/instruments/opentelemetry_instrument.py:76-135` — add `_tracer_provider` field on the dataclass, store the provider in `bootstrap()`, shut it down in `teardown()`. -- Modify: `tests/instruments/test_opentelemetry_instrument.py` — add a new test asserting `shutdown` is invoked. - ---- - -## Locked decisions (from sequencing spec) - -- **Storage pattern:** `object.__setattr__` (preserves `frozen=True`; matches `LoggingInstrument._logger_factory`). -- **Field name:** `_tracer_provider` (underscore = internal state, mirrors `_logger_factory`). -- **Test access:** read the private `_tracer_provider` attribute directly with `# noqa: SLF001`. Other tests in the file already use patch-based introspection (`patch("lite_bootstrap.instruments.opentelemetry_instrument.set_tracer_provider")`), so private-attribute access for verification is consistent with the existing test style. - ---- - -## Task 1: Create branch - -**Files:** (no files; git only) - -- [ ] **Step 1: Branch off `main`** - -```bash -git checkout main -git pull --ff-only origin main -git checkout -b fix/crit-2-otel-shutdown -``` - -Expected: `Switched to a new branch 'fix/crit-2-otel-shutdown'`. - -If PR1 (`fix/crit-1-redoc-root-path`) has not yet merged into `main`, that's fine — PR2's changes touch a different file and there will be no conflict. Branch from current `main` regardless. - ---- - -## Task 2: Add the failing regression test - -**Files:** -- Modify: `tests/instruments/test_opentelemetry_instrument.py` - -The current test file has two tests that bootstrap + teardown but assert nothing about shutdown behavior. Add a third test that bootstraps the instrument, captures the stored `TracerProvider`, patches its `shutdown` method, runs teardown, and asserts the patch was invoked exactly once. - -- [ ] **Step 1: Add imports and the new test** - -The existing imports are: - -```python -from lite_bootstrap.instruments.opentelemetry_instrument import ( - InstrumentorWithParams, - OpentelemetryConfig, - OpenTelemetryInstrument, -) -from tests.conftest import CustomInstrumentor -``` - -Add `from unittest.mock import patch` at the top (after any stdlib imports, before the project imports — ruff isort will handle ordering on save, but write it correctly the first time): - -```python -from unittest.mock import patch - -from lite_bootstrap.instruments.opentelemetry_instrument import ( - InstrumentorWithParams, - OpentelemetryConfig, - OpenTelemetryInstrument, -) -from tests.conftest import CustomInstrumentor -``` - -Append this new test to the end of the file (after `test_opentelemetry_instrument_empty_instruments`): - -```python -def test_opentelemetry_instrument_teardown_shuts_down_tracer_provider() -> None: - instrument = OpenTelemetryInstrument( - bootstrap_config=OpentelemetryConfig(opentelemetry_log_traces=True), - ) - instrument.bootstrap() - tracer_provider = instrument._tracer_provider # noqa: SLF001 - assert tracer_provider is not None - - with patch.object(tracer_provider, "shutdown") as mock_shutdown: - instrument.teardown() - - mock_shutdown.assert_called_once_with() - assert instrument._tracer_provider is None # noqa: SLF001 -``` - -This test asserts three things: -1. The instrument exposes its tracer provider as `_tracer_provider` after bootstrap. -2. Teardown calls `shutdown()` on that provider exactly once. -3. Teardown resets `_tracer_provider` to `None` so a subsequent bootstrap starts clean. - -- [ ] **Step 2: Run the new test and verify it FAILS** - -```bash -just test -- tests/instruments/test_opentelemetry_instrument.py::test_opentelemetry_instrument_teardown_shuts_down_tracer_provider -v -``` - -Expected: **FAIL** with `AttributeError: 'OpenTelemetryInstrument' object has no attribute '_tracer_provider'` (or similar — the attribute doesn't exist yet on the dataclass). - -If the test passes, stop and investigate — either the attribute already exists (which would mean someone else implemented the fix already) or the assertion is wrong. - ---- - -## Task 3: Implement the shutdown fix - -**Files:** -- Modify: `lite_bootstrap/instruments/opentelemetry_instrument.py` - -The current `OpenTelemetryInstrument` dataclass (lines 76-80) has no init-false field for the provider. Add one. Then update `bootstrap()` to stash the locally-created provider on the instance, and update `teardown()` to shut it down and clear the field. - -- [ ] **Step 1: Add `_tracer_provider` field to the dataclass** - -Current dataclass body (lines 76-80): - -```python -@dataclasses.dataclass(kw_only=True, slots=True, frozen=True) -class OpenTelemetryInstrument(BaseInstrument): - bootstrap_config: OpentelemetryConfig - not_ready_message = "opentelemetry_endpoint is empty and opentelemetry_log_traces is False" - missing_dependency_message = "opentelemetry is not installed" -``` - -Replace with: - -```python -@dataclasses.dataclass(kw_only=True, slots=True, frozen=True) -class OpenTelemetryInstrument(BaseInstrument): - bootstrap_config: OpentelemetryConfig - not_ready_message = "opentelemetry_endpoint is empty and opentelemetry_log_traces is False" - missing_dependency_message = "opentelemetry is not installed" - _tracer_provider: "TracerProvider | None" = dataclasses.field( - default_factory=lambda: None, init=False, repr=False, compare=False - ) -``` - -Notes: -- The string annotation `"TracerProvider | None"` is a forward reference. `TracerProvider` is imported conditionally inside `if import_checker.is_opentelemetry_installed:` at module top (line 17), so the string form avoids NameError if opentelemetry isn't installed. -- `default_factory=lambda: None` matches the `LoggingInstrument._logger_factory` precedent. Do not use `default=None` — the existing codebase normalized on `default_factory=lambda: None` for these fields (see commit `8db9be3`). -- `init=False, repr=False, compare=False` matches the precedent: this is internal runtime state, not part of the instrument's identity. - -- [ ] **Step 2: Stash the provider in `bootstrap()`** - -Current `bootstrap()` ends at line 128 with the instrumentor loop. Just before that loop, after `set_tracer_provider(tracer_provider)` (line 107) and the span-processor setup (lines 108-120), add the stash. The simplest placement: right after `set_tracer_provider(tracer_provider)`. - -Current code around line 106-107: - -```python - tracer_provider = TracerProvider(resource=resource) - set_tracer_provider(tracer_provider) -``` - -Replace with: - -```python - tracer_provider = TracerProvider(resource=resource) - set_tracer_provider(tracer_provider) - object.__setattr__(self, "_tracer_provider", tracer_provider) -``` - -This makes the locally-constructed provider reachable from `teardown()`. - -- [ ] **Step 3: Shut down the provider in `teardown()`** - -Current `teardown()` (lines 130-135): - -```python - def teardown(self) -> None: - for one_instrumentor in self.bootstrap_config.opentelemetry_instrumentors: - if isinstance(one_instrumentor, InstrumentorWithParams): - one_instrumentor.instrumentor.uninstrument(**one_instrumentor.additional_params) - else: - one_instrumentor.uninstrument() -``` - -Replace with: - -```python - def teardown(self) -> None: - for one_instrumentor in self.bootstrap_config.opentelemetry_instrumentors: - if isinstance(one_instrumentor, InstrumentorWithParams): - one_instrumentor.instrumentor.uninstrument(**one_instrumentor.additional_params) - else: - one_instrumentor.uninstrument() - if self._tracer_provider is not None: - self._tracer_provider.shutdown() - object.__setattr__(self, "_tracer_provider", None) -``` - -Order matters: uninstrument first (so instrumentors release any references to the provider), then shutdown (so the provider flushes buffered spans and disposes of processors). - -- [ ] **Step 4: Run the previously-failing test and verify it PASSES** - -```bash -just test -- tests/instruments/test_opentelemetry_instrument.py::test_opentelemetry_instrument_teardown_shuts_down_tracer_provider -v -``` - -Expected: **PASS**. - -- [ ] **Step 5: Run the full OTel test file** - -```bash -just test -- tests/instruments/test_opentelemetry_instrument.py -v -``` - -Expected: all three tests PASS — the two existing tests should be unaffected by the change. - -- [ ] **Step 6: Run the full test suite** - -```bash -just test -``` - -Expected: all tests PASS. The change touches a hot code path used by every framework bootstrapper's OTel integration (FastAPI, Litestar, FastStream, Free) — the bootstrapper-level tests will all exercise the new shutdown call. Watch for any unexpected failures in `test_fastapi_bootstrap.py`, `test_litestar_bootstrap.py`, `test_faststream_bootstrap.py`, `test_free_bootstrap.py`. - -If any pre-existing test fails because of double-shutdown (a teardown getting called twice somewhere — Litestar registers `self.teardown` on `on_shutdown`), that's CRIT-3 territory and will be handled in PR3. Note the failure in your report but do not attempt to fix CRIT-3 in this PR. If you see a `RuntimeError: TracerProvider has already been shut down` (or similar) in a test that wasn't failing before, that's the signal — flag it and continue. - -- [ ] **Step 7: Run lint** - -```bash -just lint -``` - -Expected: no errors. The `# noqa: SLF001` in the test handles private-member-access; everything else follows existing patterns. - -- [ ] **Step 8: Commit** - -Stage both modified files explicitly: - -```bash -git add lite_bootstrap/instruments/opentelemetry_instrument.py tests/instruments/test_opentelemetry_instrument.py -git commit -m "$(cat <<'EOF' -fix: shut down TracerProvider in OpenTelemetryInstrument.teardown - -The instrument's bootstrap() created a TracerProvider, registered span -processors against it, and called set_tracer_provider — but never stored -a reference. teardown() only uninstrumented the instrumentors; the -provider was never shut down. Spans buffered in BatchSpanProcessor were -lost on graceful shutdown. - -Stash the provider on the instrument via object.__setattr__ (mirroring -the LoggingInstrument._logger_factory pattern for runtime state on a -frozen dataclass), shut it down after the uninstrument loop, reset the -field to None so a subsequent bootstrap starts clean. - -Regression test asserts shutdown is called exactly once on the stored -provider and the field is reset. - -Closes CRIT-2, TEST-2 from the audit. -EOF -)" -``` - -Expected: commit succeeds. - ---- - -## Task 4: Push and open PR - -**Files:** (no files; git push + gh) - -- [ ] **Step 1: Push the branch** - -```bash -git push -u origin fix/crit-2-otel-shutdown -``` - -Expected: branch published. - -- [ ] **Step 2: Open the PR** - -```bash -gh pr create --title "fix: shut down TracerProvider in OpenTelemetryInstrument.teardown" --body "$(cat <<'EOF' -## Summary -- `OpenTelemetryInstrument.bootstrap()` now stashes the `TracerProvider` it created on the instance (via `object.__setattr__`, matching `LoggingInstrument._logger_factory`). -- `teardown()` calls `self._tracer_provider.shutdown()` after the existing instrumentor uninstrument loop, then resets the field to `None`. Buffered spans in `BatchSpanProcessor` are now flushed on graceful shutdown. -- New regression test `test_opentelemetry_instrument_teardown_shuts_down_tracer_provider` patches `shutdown` on the stored provider and asserts it's invoked exactly once. Test fails on `main`, passes on this branch. - -Closes CRIT-2 and TEST-2 from an internal audit of the codebase. - -## Test plan -- [x] `just test -- tests/instruments/test_opentelemetry_instrument.py -v` — three tests pass. -- [x] `just test` — full suite passes. -- [x] `just lint` — clean. -- [ ] Reviewer: confirm the field placement and `object.__setattr__` usage match the `LoggingInstrument._logger_factory` precedent. -EOF -)" -``` - -Expected: PR created; URL printed. - ---- - -## Self-Review - -**Spec coverage check** against the sequencing spec (PR2 section) and audit (CRIT-2, TEST-2): - -| Spec item | Task | -|-----------|------| -| Store `TracerProvider` on instance via `object.__setattr__` | Task 3, Step 2 | -| Declare `_tracer_provider: "TracerProvider \| None"` init-false field | Task 3, Step 1 | -| Call `shutdown()` in `teardown()` after `uninstrument()` loop | Task 3, Step 3 | -| Reset field to `None` after shutdown | Task 3, Step 3 | -| Add test asserting `shutdown` is invoked | Task 2, Step 1 | -| Test uses `patch.object` (mock approach, not real BatchSpanProcessor) | Task 2, Step 1 | -| Branch name `fix/crit-2-otel-shutdown` | Task 1, Step 1 | -| Verification: `just test` + `just lint` pass | Task 3, Steps 6-7 | - -All spec items covered. No placeholders. Field-name (`_tracer_provider`) and assertion-text consistency holds across Task 2 (test expectations) and Task 3 (implementation). - -**Cross-PR awareness:** Task 3 Step 6 notes that pre-existing tests could surface CRIT-3 (double-teardown) once the shutdown call exists. That failure mode is explicitly out of scope for this PR — flag and continue, do not attempt to fix it here. diff --git a/planning/changes/2026-05-31.01-audit-implementation/plan-pr3-crit3-idempotent-teardown.md b/planning/changes/2026-05-31.01-audit-implementation/plan-pr3-crit3-idempotent-teardown.md deleted file mode 100644 index 7606e82..0000000 --- a/planning/changes/2026-05-31.01-audit-implementation/plan-pr3-crit3-idempotent-teardown.md +++ /dev/null @@ -1,486 +0,0 @@ -# PR3: Idempotent Teardown Implementation Plan - -> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. - -**Goal:** Make `BaseBootstrapper.teardown()` idempotent so a second call returns immediately without re-tearing-down instruments. Concretely fixes the Litestar and FastStream cases where the bootstrapper registers `self.teardown` on a framework shutdown hook *and* gets called manually — today, second teardown re-invokes every instrument's `teardown()`, which is unsafe because many instruments aren't idempotent themselves. - -While we're touching teardown robustness, also bundle the `try/finally` follow-up that PR2's code review flagged: in both `OpenTelemetryInstrument` and `LoggingInstrument`, a raise inside the shutdown call leaves the cached reference non-None. Wrap both in `try/finally` so the cached reference is reset regardless of whether shutdown succeeded. - -**Architecture:** Three small behavioral changes — one perimeter guard (the bootstrapper-level idempotency check) and two defense-in-depth changes (instrument-level cleanup robustness). Three new regression tests, one per change. No new files; no API changes. - -**Tech Stack:** Python 3.10+, pytest, `unittest.mock.patch.object`, `unittest.mock.MagicMock`. - -**Parent spec:** `docs/superpowers/specs/2026-05-31-audit-implementation-sequencing.md` (PR3 section). -**Parent audit:** `docs/superpowers/specs/2026-05-31-bug-refactor-audit.md` (CRIT-3, TEST-3). -**Follow-up from:** PR2 code review (https://github.com/modern-python/lite-bootstrap/pull/90 — "Known follow-up" section in the PR body). - ---- - -## File Structure - -Three production files modified; three test files modified. - -- Modify: `lite_bootstrap/bootstrappers/base.py:82-93` — add idempotency guard at top of `BaseBootstrapper.teardown()`. -- Modify: `lite_bootstrap/instruments/opentelemetry_instrument.py` — wrap `self._tracer_provider.shutdown()` in `try/finally` so the field resets even on raise. -- Modify: `lite_bootstrap/instruments/logging_instrument.py` — wrap `self._logger_factory.close_handlers()` in `try/finally` so the field resets even on raise. -- Modify: `tests/test_free_bootstrap.py` — add idempotency test using mocked instruments. -- Modify: `tests/instruments/test_opentelemetry_instrument.py` — add `try/finally` regression test using `side_effect=RuntimeError`. -- Modify: `tests/instruments/test_logging_instrument.py` — add `try/finally` regression test using `side_effect=RuntimeError`. - ---- - -## Locked decisions - -- **Bundling:** All three changes ship in one commit/PR. They're all "teardown robustness" with high cohesion; PR2's reviewer explicitly suggested doing the `try/finally` work alongside CRIT-3. -- **Test placement:** Idempotency test goes in `test_free_bootstrap.py` (matches sequencing-spec decision; co-located with existing `test_teardown_error_isolation` and `test_teardown_error_aggregates_all_failures`). Instrument-level tests go in the per-instrument test files. -- **Deferred:** A Litestar-specific test exercising the manual-teardown + `on_shutdown`-fires-teardown path is *not* included. The `test_free_bootstrap.py` idempotency test pins the contract; the Litestar path is downstream of that contract. - ---- - -## Task 1: Create branch - -**Files:** (no files; git only) - -- [ ] **Step 1: Branch off `main`** - -```bash -git checkout main -git pull --ff-only origin main -git checkout -b fix/crit-3-idempotent-teardown -``` - -Expected: `Switched to a new branch 'fix/crit-3-idempotent-teardown'`. - -If PR2 (`fix/crit-2-otel-shutdown`) has not yet merged into `main`, that's a real problem for this PR — Task 4 (the OTel `try/finally`) depends on the `_tracer_provider` field that PR2 introduced. **Verify before starting that `lite_bootstrap/instruments/opentelemetry_instrument.py` contains the `_tracer_provider` field declaration.** If it doesn't, stop and report — PR2 must merge first. - -```bash -grep -n "_tracer_provider" lite_bootstrap/instruments/opentelemetry_instrument.py -``` - -Expected output: at least three matches (field declaration, `bootstrap()` setattr, `teardown()` reference). If zero matches, PR2 is not merged — stop. - ---- - -## Task 2: Add three failing regression tests - -Add all three failing tests before any production-code change, so the TDD red→green transition is visible per test. - -### Test A: `BaseBootstrapper.teardown()` is idempotent - -**File:** `tests/test_free_bootstrap.py` - -The existing file already imports `MagicMock` from `unittest.mock`. No new imports needed. - -- [ ] **Step 1: Append new test to `tests/test_free_bootstrap.py`** - -Add at the end of the file (after `test_free_bootstrapper_with_missing_instrument_dependency`): - -```python -def test_teardown_is_idempotent(free_bootstrapper_config: FreeBootstrapperConfig) -> None: - bootstrapper = FreeBootstrapper(bootstrap_config=free_bootstrapper_config) - bootstrapper.bootstrap() - - first = MagicMock() - second = MagicMock() - bootstrapper.instruments = [first, second] - - bootstrapper.teardown() - bootstrapper.teardown() - - first.teardown.assert_called_once() - second.teardown.assert_called_once() - assert not bootstrapper.is_bootstrapped -``` - -The contract: after the first `teardown()`, `is_bootstrapped` is False; the second call must observe that and return immediately, so each mocked instrument's `teardown()` is called exactly once across both bootstrapper-level calls. - -- [ ] **Step 2: Run the test and verify it FAILS** - -```bash -just test -- tests/test_free_bootstrap.py::test_teardown_is_idempotent -v -``` - -Expected: **FAIL** because the current `BaseBootstrapper.teardown()` doesn't check `is_bootstrapped`. Both mocked instruments will have `teardown()` called twice. The assertion `first.teardown.assert_called_once()` raises: - -``` -AssertionError: Expected 'teardown' to have been called once. Called 2 times. -``` - -### Test B: `OpenTelemetryInstrument.teardown()` resets `_tracer_provider` when `shutdown()` raises - -**File:** `tests/instruments/test_opentelemetry_instrument.py` - -The file (after PR2's merge) already imports `patch` from `unittest.mock`. Need to add `pytest` import. - -- [ ] **Step 3: Add `pytest` to the test file imports** - -Current top of file: - -```python -from unittest.mock import patch - -from lite_bootstrap.instruments.opentelemetry_instrument import ( - InstrumentorWithParams, - OpentelemetryConfig, - OpenTelemetryInstrument, -) -from tests.conftest import CustomInstrumentor -``` - -Replace with: - -```python -from unittest.mock import patch - -import pytest - -from lite_bootstrap.instruments.opentelemetry_instrument import ( - InstrumentorWithParams, - OpentelemetryConfig, - OpenTelemetryInstrument, -) -from tests.conftest import CustomInstrumentor -``` - -`pytest` goes in the third-party group, between stdlib and first-party. - -- [ ] **Step 4: Append new test to the file** - -Append at the end: - -```python -def test_opentelemetry_instrument_teardown_resets_tracer_provider_when_shutdown_raises() -> None: - instrument = OpenTelemetryInstrument( - bootstrap_config=OpentelemetryConfig(opentelemetry_log_traces=True), - ) - instrument.bootstrap() - tracer_provider = instrument._tracer_provider # noqa: SLF001 - assert tracer_provider is not None - - with patch.object(tracer_provider, "shutdown", side_effect=RuntimeError("boom")): - with pytest.raises(RuntimeError, match="boom"): - instrument.teardown() - - assert instrument._tracer_provider is None # noqa: SLF001 -``` - -Contract: even if `shutdown()` raises, the cached reference must be cleared so the instrument can be re-bootstrapped cleanly. - -- [ ] **Step 5: Run the test and verify it FAILS** - -```bash -just test -- tests/instruments/test_opentelemetry_instrument.py::test_opentelemetry_instrument_teardown_resets_tracer_provider_when_shutdown_raises -v -``` - -Expected: **FAIL** because the current `teardown()` has `shutdown()` then `setattr(None)` as two sequential statements (no `try/finally`); the RuntimeError propagates, the reset never runs, and the final `assert instrument._tracer_provider is None` fails. - -### Test C: `LoggingInstrument.teardown()` resets `_logger_factory` when `close_handlers()` raises - -**File:** `tests/instruments/test_logging_instrument.py` - -Need to add `pytest` and `patch` imports. - -- [ ] **Step 6: Add `patch` and `pytest` to the test file imports** - -Current top: - -```python -import logging -from io import StringIO - -import structlog -from opentelemetry.trace import get_tracer - -from lite_bootstrap.instruments.logging_instrument import LoggingConfig, LoggingInstrument, MemoryLoggerFactory -from lite_bootstrap.instruments.opentelemetry_instrument import OpentelemetryConfig, OpenTelemetryInstrument -from tests.conftest import LoggingMock -``` - -Replace with: - -```python -import logging -from io import StringIO -from unittest.mock import patch - -import pytest -import structlog -from opentelemetry.trace import get_tracer - -from lite_bootstrap.instruments.logging_instrument import LoggingConfig, LoggingInstrument, MemoryLoggerFactory -from lite_bootstrap.instruments.opentelemetry_instrument import OpentelemetryConfig, OpenTelemetryInstrument -from tests.conftest import LoggingMock -``` - -- [ ] **Step 7: Append new test to the file** - -Append at the end: - -```python -def test_logging_instrument_teardown_resets_factory_when_close_handlers_raises() -> None: - instrument = LoggingInstrument( - bootstrap_config=LoggingConfig(logging_buffer_capacity=0), - ) - instrument.bootstrap() - factory = instrument._logger_factory # noqa: SLF001 - assert factory is not None - - with patch.object(factory, "close_handlers", side_effect=RuntimeError("boom")): - with pytest.raises(RuntimeError, match="boom"): - instrument.teardown() - - assert instrument._logger_factory is None # noqa: SLF001 -``` - -Contract: same shape as Test B — the cached factory reference must be cleared even when `close_handlers()` raises. - -- [ ] **Step 8: Run the test and verify it FAILS** - -```bash -just test -- tests/instruments/test_logging_instrument.py::test_logging_instrument_teardown_resets_factory_when_close_handlers_raises -v -``` - -Expected: **FAIL** because the current `LoggingInstrument.teardown()` has `close_handlers()` then `setattr(None)` as two sequential statements; the RuntimeError propagates, the reset never runs, the final `assert instrument._logger_factory is None` fails. - ---- - -## Task 3: Implement the three fixes - -### Fix 1: Idempotency guard on `BaseBootstrapper.teardown()` - -- [ ] **Step 1: Add guard at top of `BaseBootstrapper.teardown()`** - -**File:** `lite_bootstrap/bootstrappers/base.py:82-93` - -Current code: - -```python - def teardown(self) -> None: - self.is_bootstrapped = False - errors: list[tuple[str, BaseException]] = [] - for one_instrument in reversed(self.instruments): - try: - one_instrument.teardown() - except Exception as e: # noqa: BLE001, PERF203 - name = type(one_instrument).__name__ - logger.warning(f"Error tearing down {name}: {e}") - errors.append((name, e)) - if errors: - raise TeardownError(errors) from errors[0][1] -``` - -Replace with: - -```python - def teardown(self) -> None: - if not self.is_bootstrapped: - return - self.is_bootstrapped = False - errors: list[tuple[str, BaseException]] = [] - for one_instrument in reversed(self.instruments): - try: - one_instrument.teardown() - except Exception as e: # noqa: BLE001, PERF203 - name = type(one_instrument).__name__ - logger.warning(f"Error tearing down {name}: {e}") - errors.append((name, e)) - if errors: - raise TeardownError(errors) from errors[0][1] -``` - -Only the two-line guard at top is added. Everything else is byte-identical. - -### Fix 2: `try/finally` in `OpenTelemetryInstrument.teardown()` - -- [ ] **Step 2: Wrap `shutdown()` in `try/finally`** - -**File:** `lite_bootstrap/instruments/opentelemetry_instrument.py` - -Current `teardown()` (after PR2): - -```python - def teardown(self) -> None: - for one_instrumentor in self.bootstrap_config.opentelemetry_instrumentors: - if isinstance(one_instrumentor, InstrumentorWithParams): - one_instrumentor.instrumentor.uninstrument(**one_instrumentor.additional_params) - else: - one_instrumentor.uninstrument() - if self._tracer_provider is not None: - self._tracer_provider.shutdown() - object.__setattr__(self, "_tracer_provider", None) -``` - -Replace the last block (the `if self._tracer_provider is not None:` block) with: - -```python - if self._tracer_provider is not None: - try: - self._tracer_provider.shutdown() - finally: - object.__setattr__(self, "_tracer_provider", None) -``` - -### Fix 3: `try/finally` in `LoggingInstrument.teardown()` - -- [ ] **Step 3: Wrap `close_handlers()` in `try/finally`** - -**File:** `lite_bootstrap/instruments/logging_instrument.py:202-211` - -Current code: - -```python - def teardown(self) -> None: - structlog.reset_defaults() - root_logger = logging.getLogger() - for h in root_logger.handlers[:]: - root_logger.removeHandler(h) - h.close() - root_logger.setLevel(logging.WARNING) - if self._logger_factory is not None: - self._logger_factory.close_handlers() - object.__setattr__(self, "_logger_factory", None) -``` - -Replace the last block with: - -```python - if self._logger_factory is not None: - try: - self._logger_factory.close_handlers() - finally: - object.__setattr__(self, "_logger_factory", None) -``` - -### Verification - -- [ ] **Step 4: Run the three new tests and verify each PASSES** - -```bash -just test -- tests/test_free_bootstrap.py::test_teardown_is_idempotent tests/instruments/test_opentelemetry_instrument.py::test_opentelemetry_instrument_teardown_resets_tracer_provider_when_shutdown_raises tests/instruments/test_logging_instrument.py::test_logging_instrument_teardown_resets_factory_when_close_handlers_raises -v -``` - -Expected: all three PASS. - -- [ ] **Step 5: Run the full test suite** - -```bash -just test -``` - -Expected: all tests PASS. The idempotency guard is additive and shouldn't change any existing behavior — existing tests call `teardown()` exactly once and that path is unchanged. The two `try/finally` changes are non-observable to callers who don't trigger the exception path. - -Watch carefully for failures in the existing teardown-error tests in `test_free_bootstrap.py` (`test_teardown_error_isolation`, `test_teardown_error_aggregates_all_failures`) — these tests deliberately make instruments raise during teardown and assert on the resulting `TeardownError`. The guard doesn't affect them because they only call `teardown()` once. Confirm they still pass. - -- [ ] **Step 6: Run lint** - -```bash -just lint -``` - -Expected: no errors. - -- [ ] **Step 7: Commit** - -Stage the six modified files explicitly: - -```bash -git add \ - lite_bootstrap/bootstrappers/base.py \ - lite_bootstrap/instruments/opentelemetry_instrument.py \ - lite_bootstrap/instruments/logging_instrument.py \ - tests/test_free_bootstrap.py \ - tests/instruments/test_opentelemetry_instrument.py \ - tests/instruments/test_logging_instrument.py -git commit -m "$(cat <<'EOF' -fix: make teardown idempotent and exception-safe - -BaseBootstrapper.teardown() now returns immediately when not bootstrapped. -This fixes Litestar and FastStream, both of which register self.teardown -on a framework shutdown hook while also being callable manually — a user -who explicitly calls teardown() would otherwise re-invoke every instrument's -teardown when the framework shutdown fired second. Most instruments are -not idempotent themselves. - -Also wrap the shutdown calls inside OpenTelemetryInstrument and -LoggingInstrument in try/finally so the cached _tracer_provider / -_logger_factory references are reset even when shutdown raises. Without -this, a failed shutdown leaves the instrument in a state where a second -bootstrap reuses a stale reference. The bootstrapper-level guard -prevents the immediate symptom but doesn't help when instruments are -used standalone. - -Regression tests: -- test_teardown_is_idempotent: bootstrap, teardown twice, assert each - instrument's teardown was called exactly once. -- test_opentelemetry_instrument_teardown_resets_tracer_provider_when_shutdown_raises: - patch shutdown to raise, assert field resets to None. -- test_logging_instrument_teardown_resets_factory_when_close_handlers_raises: - patch close_handlers to raise, assert field resets to None. - -Closes CRIT-3 and TEST-3 from the audit. Resolves the try/finally -follow-up flagged in PR #90's code review. -EOF -)" -``` - ---- - -## Task 4: Push and open PR - -**Files:** (no files; git push + gh) - -- [ ] **Step 1: Push the branch** - -```bash -git push -u origin fix/crit-3-idempotent-teardown -``` - -- [ ] **Step 2: Open the PR** - -```bash -gh pr create --title "fix: make teardown idempotent and exception-safe" --body "$(cat <<'EOF' -## Summary -- `BaseBootstrapper.teardown()` now returns immediately when `not self.is_bootstrapped`. Fixes the Litestar/FastStream case where manual teardown + framework shutdown hook both fire, double-tearing-down instruments (many of which aren't idempotent). -- `OpenTelemetryInstrument.teardown()` and `LoggingInstrument.teardown()` wrap their shutdown calls in `try/finally` so the cached internal-state references reset even when shutdown raises. Resolves the follow-up flagged in PR #90's code review. - -Three regression tests added — one per fix — all fail on `main` and pass on this branch. - -Closes CRIT-3 and TEST-3 from an internal audit of the codebase. - -## Test plan -- [x] `just test -- tests/test_free_bootstrap.py -v` — all teardown tests pass. -- [x] `just test -- tests/instruments/test_opentelemetry_instrument.py -v` — all OTel tests pass. -- [x] `just test -- tests/instruments/test_logging_instrument.py -v` — all logging tests pass. -- [x] `just test` — full suite passes. -- [x] `just lint` — clean. -- [ ] Reviewer: confirm the bootstrapper guard placement (top of teardown, before `is_bootstrapped = False`) — order matters so the second call observes `is_bootstrapped` as `False` from the first call and returns immediately. - -🤖 Generated with [Claude Code](https://claude.com/claude-code) -EOF -)" -``` - ---- - -## Self-Review - -**Spec coverage check** against the sequencing spec (PR3 section), audit (CRIT-3, TEST-3), and PR #90's review follow-up: - -| Spec item | Task | -|-----------|------| -| Guard at top of `BaseBootstrapper.teardown()` (`if not self.is_bootstrapped: return`) | Task 3, Step 1 | -| Test: bootstrap → teardown → teardown again, assert no double-invoke | Task 2 Test A (Steps 1-2) | -| Test placement in `test_free_bootstrap.py` | Task 2 Test A, Step 1 | -| Branch name `fix/crit-3-idempotent-teardown` | Task 1, Step 1 | -| OTel `try/finally` (follow-up from PR2 review) | Task 3, Step 2 | -| Logging `try/finally` (follow-up from PR2 review) | Task 3, Step 3 | -| OTel regression test for shutdown-raises | Task 2 Test B (Steps 3-5) | -| Logging regression test for close_handlers-raises | Task 2 Test C (Steps 6-8) | -| Verification: `just test` + `just lint` clean | Task 3, Steps 5-6 | - -All spec items covered. No placeholders. Field-name consistency holds (`_tracer_provider`, `_logger_factory`, `is_bootstrapped`) across tests and implementations. Each `try/finally` uses the same shape: `try: ; finally: object.__setattr__(self, "", None)`. - -**Deferred (not in this PR):** -- Litestar-specific test exercising the manual-teardown + `on_shutdown` path. -- FastStream-specific test of the same pattern. -- Adding a `try/finally` review across other instruments (none currently store cached internal state that needs resetting on teardown). diff --git a/planning/changes/2026-05-31.01-audit-implementation/plan-pr4-des4-des5-small-cleanups.md b/planning/changes/2026-05-31.01-audit-implementation/plan-pr4-des4-des5-small-cleanups.md deleted file mode 100644 index c724c6b..0000000 --- a/planning/changes/2026-05-31.01-audit-implementation/plan-pr4-des4-des5-small-cleanups.md +++ /dev/null @@ -1,350 +0,0 @@ -# PR4: Sentry `skip_sentry` Leak Fix + Dead `is_X_installed` Conjuncts Cleanup - -> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. - -**Goal:** Ship two small audit cleanups in one PR: - -- **DES-4:** Add `"skip_sentry"` to `IGNORED_STRUCTLOG_ATTRIBUTES` so the flag stops leaking into Sentry's `contexts.structlog` when set to falsy (the function already suppresses the event for truthy values, but doesn't strip the field for falsy ones). -- **DES-5:** Delete the dead `and import_checker.is_X_installed` conjuncts from four instruments' `is_ready()` methods. They're provably unreachable: `_register_or_skip` runs `check_dependencies()` before instantiating the instrument; if `check_dependencies()` returns False, `is_ready()` is never called. - -**Architecture:** Five files modified — four production deletions/additions and one test case. No new abstractions. No API changes. - -**Tech Stack:** Python 3.10+, pytest parametrized tests, sentry_sdk types. - -**Parent spec:** `docs/superpowers/specs/2026-05-31-audit-implementation-sequencing.md` (PR4 section). -**Parent audit:** `docs/superpowers/specs/2026-05-31-bug-refactor-audit.md` (DES-4, DES-5). - ---- - -## File Structure - -Four production files modified; one test file modified. - -- Modify: `lite_bootstrap/instruments/sentry_instrument.py` — add `"skip_sentry"` to `IGNORED_STRUCTLOG_ATTRIBUTES` (DES-4); drop dead conjunct from `SentryInstrument.is_ready()` (DES-5). -- Modify: `lite_bootstrap/instruments/logging_instrument.py:139-140` — drop dead conjunct from `LoggingInstrument.is_ready()` (DES-5). -- Modify: `lite_bootstrap/instruments/opentelemetry_instrument.py:82-86` — drop dead conjunct from `OpenTelemetryInstrument.is_ready()` (DES-5). -- Modify: `lite_bootstrap/instruments/pyroscope_instrument.py:28-29` — drop dead conjunct from `PyroscopeInstrument.is_ready()` (DES-5). -- Modify: `tests/instruments/test_sentry_instrument.py` — add parametrize case to `TestSentryEnrichEventFromStructlog::test_modify` covering the `skip_sentry=False` case. - ---- - -## Locked decisions - -- **Bundling DES-4 + DES-5:** They're independent in scope but both trivial and both touch instrument files. Reviewing them together is cheaper than two PRs. -- **DES-4 test placement:** Extend the existing `test_modify` parametrize block in `TestSentryEnrichEventFromStructlog`. Same shape as adjacent cases; no new test method. -- **DES-5 testing:** No new tests. Pure dead-code deletion. The existing test suite already exercises the `is_ready()` paths via the framework integration tests; any breakage shows up there. - ---- - -## Task 1: Create branch - -**Files:** (no files; git only) - -- [ ] **Step 1: Branch off `main`** - -```bash -git checkout main -git pull --ff-only origin main -git checkout -b fix/des-4-5-small-cleanups -``` - -Expected: `Switched to a new branch 'fix/des-4-5-small-cleanups'`. - -If PR3 (`fix/crit-3-idempotent-teardown`) has not yet merged, that's fine — PR4 touches different files. Branch from current `main` regardless. - ---- - -## Task 2: Add the failing regression test (DES-4) - -**File:** `tests/instruments/test_sentry_instrument.py` - -The file already has a class `TestSentryEnrichEventFromStructlog` with a parametrized `test_modify` method. Add a third case to its parametrize list that covers the `skip_sentry=False` scenario. - -- [ ] **Step 1: Add the new parametrize case** - -Current `test_modify` (around line 92 of the file) has two cases in its parametrize list. The list looks like: - -```python - @pytest.mark.parametrize( - ("event_before", "event_after"), - [ - ( - {"logentry": {"formatted": '{"event": "event name"}'}, "contexts": {}}, - {"logentry": {"formatted": "event name"}, "contexts": {}}, - ), - ( - { - "logentry": { - "formatted": '{"event": "event name", "timestamp": 1, "level": "error", "logger": "event.logger", "tracing": {}, "foo": "bar"}' # noqa: E501 - }, - "contexts": {}, - }, - { - "logentry": {"formatted": "event name"}, - "contexts": {"structlog": {"foo": "bar"}}, - }, - ), - ], - ) - def test_modify(self, event_before: "sentry_types.Event", event_after: "sentry_types.Event") -> None: - assert enrich_sentry_event_from_structlog_log(event_before, {}) == event_after -``` - -Add a third tuple to the parametrize list, after the existing two cases (preserving trailing comma in the list): - -```python - ( - { - "logentry": { - "formatted": '{"event": "event name", "skip_sentry": false, "foo": "bar"}' - }, - "contexts": {}, - }, - { - "logentry": {"formatted": "event name"}, - "contexts": {"structlog": {"foo": "bar"}}, - }, - ), -``` - -The contract: when a structlog payload contains `skip_sentry=false` (a falsy value that doesn't trigger event suppression), the resulting `contexts.structlog` should contain `{"foo": "bar"}` only — `skip_sentry` should be stripped. - -- [ ] **Step 2: Run the test and verify it FAILS** - -```bash -just test -- 'tests/instruments/test_sentry_instrument.py::TestSentryEnrichEventFromStructlog::test_modify' -v -``` - -Expected: one of the three parametrize cases (the new one) **FAILS** because the current `IGNORED_STRUCTLOG_ATTRIBUTES` set doesn't include `"skip_sentry"`. The actual `contexts.structlog` will be `{"skip_sentry": False, "foo": "bar"}`, which doesn't equal the expected `{"foo": "bar"}`. The other two cases should still PASS. - -If the new case passes, stop and investigate — either the assertion is wrong, or the bug isn't present. - ---- - -## Task 3: Implement all changes - -Five small edits across four production files. Apply them all, run tests, lint, commit. - -### Fix 1 (DES-4): Strip `skip_sentry` from Sentry context - -- [ ] **Step 1: Add `"skip_sentry"` to `IGNORED_STRUCTLOG_ATTRIBUTES`** - -**File:** `lite_bootstrap/instruments/sentry_instrument.py:19-21` - -Current: - -```python -IGNORED_STRUCTLOG_ATTRIBUTES: typing.Final = frozenset( - {"event", "level", "logger", "tracing", "timestamp", "exception"} -) -``` - -Replace with: - -```python -IGNORED_STRUCTLOG_ATTRIBUTES: typing.Final = frozenset( - {"event", "level", "logger", "tracing", "timestamp", "exception", "skip_sentry"} -) -``` - -### Fix 2 (DES-5): Drop dead conjunct from `SentryInstrument.is_ready()` - -- [ ] **Step 2: Simplify `SentryInstrument.is_ready()`** - -**File:** `lite_bootstrap/instruments/sentry_instrument.py:100-101` - -Current: - -```python - def is_ready(self) -> bool: - return bool(self.bootstrap_config.sentry_dsn) and import_checker.is_sentry_installed -``` - -Replace with: - -```python - def is_ready(self) -> bool: - return bool(self.bootstrap_config.sentry_dsn) -``` - -### Fix 3 (DES-5): Drop dead conjunct from `LoggingInstrument.is_ready()` - -- [ ] **Step 3: Simplify `LoggingInstrument.is_ready()`** - -**File:** `lite_bootstrap/instruments/logging_instrument.py:139-140` - -Current: - -```python - def is_ready(self) -> bool: - return self.bootstrap_config.logging_enabled and import_checker.is_structlog_installed -``` - -Replace with: - -```python - def is_ready(self) -> bool: - return self.bootstrap_config.logging_enabled -``` - -### Fix 4 (DES-5): Drop dead conjunct from `OpenTelemetryInstrument.is_ready()` - -- [ ] **Step 4: Simplify `OpenTelemetryInstrument.is_ready()`** - -**File:** `lite_bootstrap/instruments/opentelemetry_instrument.py:82-86` - -Current: - -```python - def is_ready(self) -> bool: - return ( - bool(self.bootstrap_config.opentelemetry_endpoint or self.bootstrap_config.opentelemetry_log_traces) - and import_checker.is_opentelemetry_installed - ) -``` - -Replace with: - -```python - def is_ready(self) -> bool: - return bool(self.bootstrap_config.opentelemetry_endpoint or self.bootstrap_config.opentelemetry_log_traces) -``` - -### Fix 5 (DES-5): Drop dead conjunct from `PyroscopeInstrument.is_ready()` - -- [ ] **Step 5: Simplify `PyroscopeInstrument.is_ready()`** - -**File:** `lite_bootstrap/instruments/pyroscope_instrument.py:28-29` - -Current: - -```python - def is_ready(self) -> bool: - return bool(self.bootstrap_config.pyroscope_endpoint) and import_checker.is_pyroscope_installed -``` - -Replace with: - -```python - def is_ready(self) -> bool: - return bool(self.bootstrap_config.pyroscope_endpoint) -``` - -### Verify and commit - -- [ ] **Step 6: Run the previously-failing test, verify PASS** - -```bash -just test -- 'tests/instruments/test_sentry_instrument.py::TestSentryEnrichEventFromStructlog' -v -``` - -Expected: all three `test_modify` cases PASS. - -- [ ] **Step 7: Run the full test suite** - -```bash -just test -``` - -Expected: all tests PASS. The dead-conjunct deletions are provably no-ops at runtime (the `_register_or_skip` flow in `bootstrappers/base.py` checks `check_dependencies()` before any `is_ready()` call), so existing tests that exercise the missing-dependency path — e.g., `test_fastapi_bootstrapper_with_missing_instrument_dependency`, `test_litestar_bootstrapper_with_missing_instrument_dependency`, `test_free_bootstrapper_with_missing_instrument_dependency` — should still pass unchanged. If any of those fail, stop and investigate: the invariant we're relying on may not hold somewhere. - -- [ ] **Step 8: Run lint** - -```bash -just lint -``` - -Expected: no errors. The four `import_checker` references being removed leave the import statement still used elsewhere in each file (e.g., `bootstrap()` methods), so no unused-import warnings should fire. Confirm. - -If a file ends up with `from lite_bootstrap import import_checker` no longer referenced anywhere, ruff `F401` will flag it. In that case, also remove the import. Most likely candidate is `pyroscope_instrument.py` (verify by reading the file). - -Actually, all four instrument files use `import_checker` in their `check_dependencies()` method as well, so the import will remain needed. Just confirm with `just lint`. - -- [ ] **Step 9: Commit (stage exactly 5 files)** - -```bash -git add \ - lite_bootstrap/instruments/sentry_instrument.py \ - lite_bootstrap/instruments/logging_instrument.py \ - lite_bootstrap/instruments/opentelemetry_instrument.py \ - lite_bootstrap/instruments/pyroscope_instrument.py \ - tests/instruments/test_sentry_instrument.py -git commit -m "$(cat <<'EOF' -fix: strip skip_sentry from Sentry context; drop dead is_X_installed conjuncts - -DES-4: enrich_sentry_event_from_structlog_log was already returning None -(suppressing the event) when skip_sentry was truthy, but for falsy values -(False, missing, "") the flag itself was not stripped from the structlog -payload before it was attached to event["contexts"]["structlog"]. Add -"skip_sentry" to IGNORED_STRUCTLOG_ATTRIBUTES so the field never leaks -into Sentry context noise. Regression test added as a parametrize case -on the existing test_modify. - -DES-5: each affected instrument's is_ready() returned ` and -import_checker.is_X_installed`. The conjunct is provably dead: BaseBootstrapper -calls check_dependencies() in _register_or_skip before instantiating the -instrument, and only invokes is_ready() if check_dependencies() returned True. -Drop the redundant conjunct from SentryInstrument, LoggingInstrument, -OpenTelemetryInstrument, and PyroscopeInstrument. Behavior is unchanged. - -Closes DES-4 and DES-5 from the audit. -EOF -)" -``` - ---- - -## Task 4: Push and open PR - -**Files:** (no files; git push + gh) - -- [ ] **Step 1: Push the branch** - -```bash -git push -u origin fix/des-4-5-small-cleanups -``` - -- [ ] **Step 2: Open the PR** - -```bash -gh pr create --title "fix: strip skip_sentry from Sentry context; drop dead is_X_installed conjuncts" --body "$(cat <<'EOF' -## Summary -Two small audit cleanups bundled: - -- **DES-4 (Sentry):** \`skip_sentry\` was already triggering event suppression when truthy, but for falsy values (False/missing/"") the flag itself wasn't stripped from the structlog payload and ended up as noise in \`event["contexts"]["structlog"]\`. Add \`"skip_sentry"\` to \`IGNORED_STRUCTLOG_ATTRIBUTES\`. Regression test added as a parametrize case on the existing \`test_modify\`. -- **DES-5 (dead conjuncts):** Four instruments' \`is_ready()\` methods ended with \`and import_checker.is_X_installed\`. That conjunct is provably unreachable — \`BaseBootstrapper._register_or_skip\` calls \`check_dependencies()\` first and only invokes \`is_ready()\` if it returned True. Behavior is unchanged. Cleanup makes the lifecycle easier to reason about. - -Closes DES-4 and DES-5 from an internal audit. - -## Test plan -- [x] \`just test -- tests/instruments/test_sentry_instrument.py -v\` — pass. -- [x] \`just test\` — full suite passes. -- [x] \`just lint\` — clean (no unused-import warnings from the conjunct removals). -- [ ] Reviewer: confirm the invariant claim — that \`is_ready()\` is only called after \`check_dependencies()\` has returned True — by reading \`bootstrappers/base.py:44-64\`. - -🤖 Generated with [Claude Code](https://claude.com/claude-code) -EOF -)" -``` - ---- - -## Self-Review - -**Spec coverage check** against the sequencing spec (PR4 section) and audit (DES-4, DES-5): - -| Spec item | Task | -|-----------|------| -| Add `"skip_sentry"` to `IGNORED_STRUCTLOG_ATTRIBUTES` | Task 3, Step 1 | -| Regression test asserting `skip_sentry` doesn't appear in context | Task 2, Step 1 | -| Delete dead conjunct from `SentryInstrument.is_ready()` | Task 3, Step 2 | -| Delete dead conjunct from `LoggingInstrument.is_ready()` | Task 3, Step 3 | -| Delete dead conjunct from `OpenTelemetryInstrument.is_ready()` | Task 3, Step 4 | -| Delete dead conjunct from `PyroscopeInstrument.is_ready()` | Task 3, Step 5 | -| Branch name `fix/des-4-5-small-cleanups` | Task 1, Step 1 | -| Verification: `just test` + `just lint` pass | Task 3, Steps 7-8 | - -All spec items covered. No placeholders. Parametrize-case shape matches adjacent cases byte-for-byte except for the payload values. - -**Deferred:** -- Documenting the lifecycle invariant on `BaseInstrument` (mentioned as "optional" in the sequencing spec) — skip for now to keep the PR focused. Worth noting somewhere later (a `CONTRIBUTING.md`, or class docstrings as part of REF-3). diff --git a/planning/changes/2026-05-31.01-audit-implementation/plan-pr5-des3-config-method-semantics.md b/planning/changes/2026-05-31.01-audit-implementation/plan-pr5-des3-config-method-semantics.md deleted file mode 100644 index 22756ae..0000000 --- a/planning/changes/2026-05-31.01-audit-implementation/plan-pr5-des3-config-method-semantics.md +++ /dev/null @@ -1,292 +0,0 @@ -# PR5: Document and Pin `BaseConfig.from_dict` / `from_object` Semantics - -> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. - -**Goal:** Document the intentional asymmetry between `BaseConfig.from_dict` and `BaseConfig.from_object` (the audit's DES-3 finding) and pin it with regression tests. No behavior change. The current "skip None" behavior in `from_object` is preserved (locked decision from the sequencing spec). - -**Architecture:** Pure documentation + test PR. Add one-line docstrings to the two classmethods explaining their semantics; add four pinning tests in `tests/test_config.py` that lock in the current contract. No TDD red→green here — the tests pass today; their value is preventing future regressions that "unify" the methods without realizing the asymmetry is intentional. - -**Tech Stack:** Python 3.10+ dataclasses, pytest. - -**Parent spec:** `docs/superpowers/specs/2026-05-31-audit-implementation-sequencing.md` (PR5 section). -**Parent audit:** `docs/superpowers/specs/2026-05-31-bug-refactor-audit.md` (DES-3, TEST-5, TEST-6). - ---- - -## File Structure - -Two files modified. - -- Modify: `lite_bootstrap/instruments/base.py:16-29` — add one-line docstrings to `from_dict` and `from_object`. -- Modify: `tests/test_config.py` — add four pinning tests. - ---- - -## Locked decisions (from sequencing spec) - -- **`from_object` semantics:** Keep current "skip None" behavior. Document it. Pin with tests. Minimal change; preserves any user code that depends on it. -- **No TDD:** This PR documents and pins existing behavior. The new tests are pinning tests, not TDD red→green tests. They will pass before and after the docstring additions. Their value is preventing future regressions, not driving a bug fix. -- **Docstring length:** One short line each. Project style is terse (no docstrings on most code; one-line docstrings on the exception classes). Multi-paragraph docstrings would be inconsistent. - ---- - -## Task 1: Create branch - -**Files:** (no files; git only) - -- [ ] **Step 1: Branch off `main`** - -```bash -git checkout main -git pull --ff-only origin main -git checkout -b fix/des-3-config-method-semantics -``` - -Expected: `Switched to a new branch 'fix/des-3-config-method-semantics'`. - -If PR4 has not yet merged, that's fine — PR5 touches different files. - ---- - -## Task 2: Add docstrings and pinning tests, verify, commit - -### Step 1: Add docstrings to `BaseConfig.from_dict` and `from_object` - -**File:** `lite_bootstrap/instruments/base.py:16-29` - -Current code: - -```python - @classmethod - def from_dict(cls, data: dict[str, typing.Any]) -> typing_extensions.Self: - field_names = {f.name for f in dataclasses.fields(cls)} - return cls(**{k: v for k, v in data.items() if k in field_names}) - - @classmethod - def from_object(cls, obj: object) -> typing_extensions.Self: - prepared_data = {} - field_names = {f.name for f in dataclasses.fields(cls)} - - for field in field_names: - if (value := getattr(obj, field, None)) is not None: - prepared_data[field] = value - return cls(**prepared_data) -``` - -Replace with: - -```python - @classmethod - def from_dict(cls, data: dict[str, typing.Any]) -> typing_extensions.Self: - """Build a config from a dict; unknown keys are silently dropped, explicit None overrides defaults.""" - field_names = {f.name for f in dataclasses.fields(cls)} - return cls(**{k: v for k, v in data.items() if k in field_names}) - - @classmethod - def from_object(cls, obj: object) -> typing_extensions.Self: - """Build a config by merging non-None attributes from obj; None or missing attributes fall back to defaults.""" - field_names = {f.name for f in dataclasses.fields(cls)} - prepared_data = {field: value for field in field_names if (value := getattr(obj, field, None)) is not None} - return cls(**prepared_data) -``` - -Notes: -- Two docstring additions. -- The body of `from_object` is also condensed from a 5-line imperative form to a single comprehension. **Functionally identical.** The walrus-operator-inside-comprehension form is a more idiomatic match for the "filter non-None" intent and matches the dict-comprehension already used by `from_dict`. The condensation is a quality cleanup; verify behavior with the new pinning tests. - -If the reviewer pushes back on the body condensation, the alternative is to leave the body as-is and only add the docstring. The docstring is the spec-required change; the body cleanup is opportunistic. - -### Step 2: Add four pinning tests to `tests/test_config.py` - -**File:** `tests/test_config.py` - -The file currently has two tests (`test_config_from_dict`, `test_config_from_object`). Append the four new tests at the end of the file. - -Current top of file: - -```python -import dataclasses - -from lite_bootstrap import FastAPIConfig -from lite_bootstrap.instruments.base import BaseConfig -from tests.conftest import CustomInstrumentor -``` - -No new imports needed. - -Append at the end of the file: - -```python -def test_from_object_skips_none_attribute() -> None: - @dataclasses.dataclass - class Source: - service_name: str | None = None - service_version: str = "2.0.0" - - config = BaseConfig.from_object(Source()) - assert config.service_name == "micro-service" - assert config.service_version == "2.0.0" - - -def test_from_object_skips_missing_attribute() -> None: - class Source: - pass - - config = BaseConfig.from_object(Source()) - assert config.service_name == "micro-service" - assert config.service_version == "1.0.0" - assert config.service_debug is True - - -def test_from_object_preserves_falsy_values() -> None: - @dataclasses.dataclass - class Source: - service_name: str = "" - service_debug: bool = False - - config = BaseConfig.from_object(Source()) - assert config.service_name == "" - assert config.service_debug is False - - -def test_from_dict_drops_unknown_keys_silently() -> None: - config = BaseConfig.from_dict({"service_name": "test", "unknown_key": "value"}) - assert config.service_name == "test" - assert config.service_version == "1.0.0" -``` - -Contracts pinned: -- `test_from_object_skips_none_attribute` — explicit `None` attribute on source falls back to dataclass default. -- `test_from_object_skips_missing_attribute` — missing attribute on source falls back to dataclass default. -- `test_from_object_preserves_falsy_values` — empty string and `False` are not stripped (they're not `None`). -- `test_from_dict_drops_unknown_keys_silently` — unknown keys don't raise; known keys are honored. - -### Step 3: Run the new tests, verify PASS - -These tests pin existing behavior; they should pass before and after the docstring additions. - -```bash -just test -- tests/test_config.py -v -``` - -Expected: all six tests in `tests/test_config.py` PASS (two pre-existing + four new). - -If any of the four new tests fails, stop and investigate — the audit's claim about `from_object` behavior may be inaccurate, or the docstring body condensation may have introduced a regression. - -### Step 4: Run the full test suite - -```bash -just test -``` - -Expected: all tests PASS. Total should be 88 (84 prior + 4 new). - -### Step 5: Run lint - -```bash -just lint -``` - -Expected: no errors. The dict-comprehension form may trigger ruff's preference for one style or another — confirm. If ruff auto-formats the comprehension, accept the formatting and re-stage. - -### Step 6: Commit - -Stage both modified files explicitly: - -```bash -git add lite_bootstrap/instruments/base.py tests/test_config.py -git commit -m "$(cat <<'EOF' -docs: document and pin BaseConfig.from_dict / from_object semantics - -The two builder classmethods on BaseConfig have intentionally asymmetric -semantics that aren't obvious from reading the code: - -- from_dict includes any key present in the dict (explicit None overrides - the default); unknown keys are silently dropped. -- from_object includes only attributes whose value is not None; attributes - set to None or missing entirely fall back to the dataclass default. - Falsy non-None values (False, "", []) are preserved. - -Add one-line docstrings capturing each method's contract. Condense the -from_object body to a single dict-comprehension matching from_dict's style; -behavior is identical (verified by the new pinning tests). - -Add four pinning tests on top of the two pre-existing tests: -- test_from_object_skips_none_attribute -- test_from_object_skips_missing_attribute -- test_from_object_preserves_falsy_values -- test_from_dict_drops_unknown_keys_silently - -Closes DES-3, TEST-5, TEST-6 from the audit. -EOF -)" -``` - -Expected: commit succeeds. - ---- - -## Task 3: Push and open PR - -**Files:** (no files; git push + gh) - -- [ ] **Step 1: Push the branch** - -```bash -git push -u origin fix/des-3-config-method-semantics -``` - -- [ ] **Step 2: Open the PR** - -```bash -gh pr create --title "docs: document and pin BaseConfig.from_dict / from_object semantics" --body "$(cat <<'EOF' -## Summary -Document the intentional asymmetry between \`BaseConfig.from_dict\` and \`BaseConfig.from_object\` (DES-3 from an internal audit): - -- \`from_dict\` includes any key present in the dict (explicit \`None\` overrides defaults); unknown keys are silently dropped. -- \`from_object\` includes only non-\`None\` attributes (\`None\` or missing falls back to dataclass defaults); falsy non-\`None\` values are preserved. - -Each method gains a one-line docstring capturing its contract. The \`from_object\` body is condensed to a single dict-comprehension matching \`from_dict\`'s style; behavior is identical and locked in by the new pinning tests. - -Four pinning tests added (TEST-5, TEST-6 from the audit): -- \`test_from_object_skips_none_attribute\` -- \`test_from_object_skips_missing_attribute\` -- \`test_from_object_preserves_falsy_values\` -- \`test_from_dict_drops_unknown_keys_silently\` - -These pass before and after the docstring additions — they pin existing behavior to prevent future regressions where someone "unifies" the two methods without realizing the asymmetry is intentional. - -Closes DES-3, TEST-5, TEST-6 from an internal audit. - -## Test plan -- [x] \`just test -- tests/test_config.py -v\` — six tests pass. -- [x] \`just test\` — full suite passes (88 expected). -- [x] \`just lint\` — clean. -- [ ] Reviewer: confirm the \`from_object\` body condensation (5 lines → 1 dict-comprehension) is functionally identical. If you'd rather see the docstring change land without the body cleanup, request a revert. - -🤖 Generated with [Claude Code](https://claude.com/claude-code) -EOF -)" -``` - ---- - -## Self-Review - -**Spec coverage check** against the sequencing spec (PR5 section) and audit (DES-3, TEST-5, TEST-6): - -| Spec item | Task | -|-----------|------| -| Docstring on `from_dict` describing semantics | Task 2, Step 1 | -| Docstring on `from_object` describing semantics | Task 2, Step 1 | -| Test: `from_object` with `None` attribute falls back to default | Task 2, Step 2 (test_from_object_skips_none_attribute) | -| Test: `from_object` with missing attribute falls back to default | Task 2, Step 2 (test_from_object_skips_missing_attribute) | -| Test: `from_object` preserves falsy non-None | Task 2, Step 2 (test_from_object_preserves_falsy_values) | -| Test: `from_dict` drops unknown keys silently | Task 2, Step 2 (test_from_dict_drops_unknown_keys_silently) | -| Branch name `fix/des-3-config-method-semantics` | Task 1, Step 1 | -| Verification: `just test` + `just lint` clean | Task 2, Steps 4-5 | - -All spec items covered. No placeholders. Test names and contracts are consistent with the audit's TEST-5 and TEST-6 descriptions. - -**Caveats noted in PR description:** -- Body condensation in `from_object` is an opportunistic cleanup, not spec-required. If the reviewer prefers a docstring-only change, the body cleanup can be reverted with a one-character edit. diff --git a/planning/changes/2026-05-31.01-audit-implementation/plan-pr6-des2-otel-fields-mixin.md b/planning/changes/2026-05-31.01-audit-implementation/plan-pr6-des2-otel-fields-mixin.md deleted file mode 100644 index 286487a..0000000 --- a/planning/changes/2026-05-31.01-audit-implementation/plan-pr6-des2-otel-fields-mixin.md +++ /dev/null @@ -1,294 +0,0 @@ -# PR6: Extract OpenTelemetry Service Fields Mixin - -> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. - -**Goal:** Extract `opentelemetry_service_name` and `opentelemetry_namespace` into a shared mixin dataclass so both `OpentelemetryConfig` and `PyroscopeConfig` inherit from it instead of duplicating the field declarations (the audit's DES-2 finding). Today the two configs declare these fields identically; in the framework configs they survive only because Python's MRO picks one and the defaults happen to match. The mixin makes the shared identity explicit. - -**Architecture:** Pure refactor PR. New tiny dataclass `OpenTelemetryServiceFieldsConfig(BaseConfig)` in `opentelemetry_instrument.py`. Both `OpentelemetryConfig` and `PyroscopeConfig` inherit from it instead of `BaseConfig`. The two duplicate field declarations are removed. No behavior change; existing tests verify MRO continues to resolve correctly across the four framework configs (`FreeBootstrapperConfig`, `FastAPIConfig`, `LitestarConfig`, `FastStreamConfig`) that inherit from both. - -**Tech Stack:** Python 3.10+ dataclasses with `kw_only=True, frozen=True`. - -**Parent spec:** `docs/superpowers/specs/2026-05-31-audit-implementation-sequencing.md` (PR6 section). -**Parent audit:** `docs/superpowers/specs/2026-05-31-bug-refactor-audit.md` (DES-2). - ---- - -## File Structure - -Two files modified. No new files. - -- Modify: `lite_bootstrap/instruments/opentelemetry_instrument.py` — declare `OpenTelemetryServiceFieldsConfig` mixin before `OpentelemetryConfig`; change `OpentelemetryConfig` to inherit from the mixin; remove the two duplicate field declarations. -- Modify: `lite_bootstrap/instruments/pyroscope_instrument.py` — add an import for `OpenTelemetryServiceFieldsConfig`; change `PyroscopeConfig` to inherit from the mixin; remove the two duplicate field declarations. - ---- - -## Locked decisions (from sequencing spec) - -- **Mixin location:** Inline in `opentelemetry_instrument.py`. Fewer files; the mixin is small; `pyroscope_instrument` already imports otel-adjacent symbols (via the SpanProcessor integration in the OTel module). -- **Mixin name:** `OpenTelemetryServiceFieldsConfig`. -- **No new tests:** This is a pure refactor. Existing tests — particularly `test_pyroscope_standalone_config_accepts_otel_fields` in `tests/instruments/test_pyroscope_instrument.py` — already exercise the inheritance path. If those pass, MRO is still working. - ---- - -## Cross-module dependency note - -After this PR, `pyroscope_instrument.py` will import `OpenTelemetryServiceFieldsConfig` from `opentelemetry_instrument.py`. This is a new module-level dependency direction (pyroscope → opentelemetry). Verify there's no circular import: - -- `opentelemetry_instrument.py` imports the `pyroscope` *package* (external) inside an `if import_checker.is_pyroscope_installed:` guard. It does NOT import `lite_bootstrap.instruments.pyroscope_instrument`. -- After PR6, `pyroscope_instrument.py` will import from `lite_bootstrap.instruments.opentelemetry_instrument`. No cycle. - ---- - -## Task 1: Create branch - -**Files:** (no files; git only) - -- [ ] **Step 1: Branch off `main`** - -```bash -git checkout main -git pull --ff-only origin main -git checkout -b fix/des-2-otel-fields-mixin -``` - -Expected: `Switched to a new branch 'fix/des-2-otel-fields-mixin'`. - ---- - -## Task 2: Apply the refactor, verify, commit - -### Step 1: Add the mixin to `opentelemetry_instrument.py` and update `OpentelemetryConfig` - -**File:** `lite_bootstrap/instruments/opentelemetry_instrument.py` - -Current `OpentelemetryConfig` (around lines 35-49): - -```python -@dataclasses.dataclass(kw_only=True, frozen=True) -class OpentelemetryConfig(BaseConfig): - opentelemetry_service_name: str | None = None - opentelemetry_container_name: str | None = dataclasses.field( - default_factory=lambda: os.environ.get("HOSTNAME") or None - ) - opentelemetry_endpoint: str | None = None - opentelemetry_namespace: str | None = None - opentelemetry_insecure: bool = True - opentelemetry_instrumentors: list[typing.Union[InstrumentorWithParams, "BaseInstrumentor"]] = dataclasses.field( - default_factory=list - ) - opentelemetry_log_traces: bool = False - opentelemetry_generate_health_check_spans: bool = True -``` - -Replace with (add the mixin class **before** `OpentelemetryConfig`, then change `OpentelemetryConfig` to inherit from it and remove the two duplicate field declarations): - -```python -@dataclasses.dataclass(kw_only=True, frozen=True) -class OpenTelemetryServiceFieldsConfig(BaseConfig): - opentelemetry_service_name: str | None = None - opentelemetry_namespace: str | None = None - - -@dataclasses.dataclass(kw_only=True, frozen=True) -class OpentelemetryConfig(OpenTelemetryServiceFieldsConfig): - opentelemetry_container_name: str | None = dataclasses.field( - default_factory=lambda: os.environ.get("HOSTNAME") or None - ) - opentelemetry_endpoint: str | None = None - opentelemetry_insecure: bool = True - opentelemetry_instrumentors: list[typing.Union[InstrumentorWithParams, "BaseInstrumentor"]] = dataclasses.field( - default_factory=list - ) - opentelemetry_log_traces: bool = False - opentelemetry_generate_health_check_spans: bool = True -``` - -Two changes: -1. New `OpenTelemetryServiceFieldsConfig` dataclass declared above `OpentelemetryConfig` with `opentelemetry_service_name` and `opentelemetry_namespace`. -2. `OpentelemetryConfig` parent changed from `BaseConfig` to `OpenTelemetryServiceFieldsConfig`; the two fields it used to declare are removed. - -### Step 2: Update `PyroscopeConfig` in `pyroscope_instrument.py` - -**File:** `lite_bootstrap/instruments/pyroscope_instrument.py` - -Current top of file: - -```python -import dataclasses -import typing - -from lite_bootstrap import import_checker -from lite_bootstrap.instruments.base import BaseConfig, BaseInstrument - - -if import_checker.is_pyroscope_installed: - import pyroscope -``` - -Replace with (add `OpenTelemetryServiceFieldsConfig` import; drop the now-unused `BaseConfig` import): - -```python -import dataclasses -import typing - -from lite_bootstrap import import_checker -from lite_bootstrap.instruments.base import BaseInstrument -from lite_bootstrap.instruments.opentelemetry_instrument import OpenTelemetryServiceFieldsConfig - - -if import_checker.is_pyroscope_installed: - import pyroscope -``` - -**Verify before staging:** is `BaseConfig` still used elsewhere in this file? Search with `grep "BaseConfig" lite_bootstrap/instruments/pyroscope_instrument.py`. If the only use was in the `PyroscopeConfig` parent (which is being changed to `OpenTelemetryServiceFieldsConfig`), drop the import. If it's used elsewhere, keep it. - -Based on the current file structure, `BaseConfig` is only used as the `PyroscopeConfig` parent — drop the import. `just lint` will catch any mistake (F401 unused import or F821 undefined name). - -Current `PyroscopeConfig` (around lines 12-19): - -```python -@dataclasses.dataclass(kw_only=True, frozen=True) -class PyroscopeConfig(BaseConfig): - pyroscope_endpoint: str | None = None - pyroscope_sample_rate: int = 100 - pyroscope_tags: dict[str, str] = dataclasses.field(default_factory=dict) - pyroscope_additional_params: dict[str, typing.Any] = dataclasses.field(default_factory=dict) - opentelemetry_service_name: str | None = None - opentelemetry_namespace: str | None = None -``` - -Replace with: - -```python -@dataclasses.dataclass(kw_only=True, frozen=True) -class PyroscopeConfig(OpenTelemetryServiceFieldsConfig): - pyroscope_endpoint: str | None = None - pyroscope_sample_rate: int = 100 - pyroscope_tags: dict[str, str] = dataclasses.field(default_factory=dict) - pyroscope_additional_params: dict[str, typing.Any] = dataclasses.field(default_factory=dict) -``` - -Two changes: -1. Parent changed from `BaseConfig` to `OpenTelemetryServiceFieldsConfig`. -2. The two duplicate field declarations (`opentelemetry_service_name`, `opentelemetry_namespace`) removed. - -### Step 3: Run the OTel + Pyroscope test files - -```bash -just test -- tests/instruments/test_opentelemetry_instrument.py tests/instruments/test_pyroscope_instrument.py -v -``` - -Expected: all tests PASS. Watch specifically for: - -- `test_pyroscope_standalone_config_accepts_otel_fields` — this is THE key test for the mixin's correctness. It constructs `PyroscopeConfig(service_name="fallback", pyroscope_endpoint=..., opentelemetry_service_name="otel-name", opentelemetry_namespace="my-ns")`. If MRO breaks, this test fails first. -- `test_pyroscope_bootstrap_uses_opentelemetry_service_name` and `test_pyroscope_bootstrap_merges_namespace_tag` — these exercise the shared fields via `FreeBootstrapperConfig`. - -### Step 4: Run the full test suite - -```bash -just test -``` - -Expected: all tests PASS (89 total). The framework configs all inherit from both `OpentelemetryConfig` and `PyroscopeConfig`; with the mixin, Python's MRO resolves the shared fields once via diamond inheritance. If any framework config test fails (e.g., `test_fastapi_bootstrap`, `test_litestar_bootstrap`, `test_faststream_bootstrap`, `test_free_bootstrap`), STOP and investigate — MRO interaction is the most likely cause. - -### Step 5: Run lint - -```bash -just lint -``` - -Expected: no errors. Watch for: - -- F401 unused import warnings on the `BaseConfig` import in `pyroscope_instrument.py` (should be already removed per Step 2). -- F401 unused import warnings on the `OpenTelemetryServiceFieldsConfig` import (should be referenced in `class PyroscopeConfig(...)`). -- `ty check` should be clean — the inheritance change is type-correct. - -### Step 6: Commit - -Stage both modified files: - -```bash -git add lite_bootstrap/instruments/opentelemetry_instrument.py lite_bootstrap/instruments/pyroscope_instrument.py -git commit -m "$(cat <<'EOF' -refactor: extract OpenTelemetryServiceFieldsConfig mixin - -opentelemetry_service_name and opentelemetry_namespace were declared -identically on both OpentelemetryConfig and PyroscopeConfig. In the four -framework configs (Free, FastAPI, Litestar, FastStream) that inherit -from both parents, Python's MRO happened to pick one declaration; the -fact that defaults matched is what kept behavior consistent. Without -the mixin, drifting defaults on one side would silently misbehave on -the framework configs. - -Extract OpenTelemetryServiceFieldsConfig(BaseConfig) — a tiny mixin -declaring just those two fields. Both OpentelemetryConfig and -PyroscopeConfig now inherit from it (no longer from BaseConfig -directly). The duplicate declarations are removed. - -PyroscopeConfig's standalone use case (without OpentelemetryConfig in -the MRO) is preserved — exercised by -test_pyroscope_standalone_config_accepts_otel_fields. - -Closes DES-2 from the audit. -EOF -)" -``` - -Expected: commit succeeds. - ---- - -## Task 3: Push and open PR - -**Files:** (no files; git push + gh) - -- [ ] **Step 1: Push the branch** - -```bash -git push -u origin fix/des-2-otel-fields-mixin -``` - -- [ ] **Step 2: Open the PR** - -```bash -gh pr create --title "refactor: extract OpenTelemetryServiceFieldsConfig mixin" --body "$(cat <<'EOF' -## Summary -\`opentelemetry_service_name\` and \`opentelemetry_namespace\` were declared identically on both \`OpentelemetryConfig\` and \`PyroscopeConfig\`. In the four framework configs (Free, FastAPI, Litestar, FastStream) that inherit from both parents, Python's MRO happened to pick one declaration; the fact that defaults matched is what kept behavior consistent. Without the mixin, drifting defaults on one side would silently misbehave on the framework configs. - -Extract \`OpenTelemetryServiceFieldsConfig(BaseConfig)\` — a tiny mixin declaring just those two fields. Both \`OpentelemetryConfig\` and \`PyroscopeConfig\` now inherit from it (no longer from \`BaseConfig\` directly). The duplicate declarations are removed. - -No behavior change. Existing tests verify MRO continues to resolve correctly, especially \`test_pyroscope_standalone_config_accepts_otel_fields\` (the key test for the mixin's correctness) and the framework-level integration tests. - -Closes DES-2 from an internal audit. - -## Test plan -- [x] \`just test -- tests/instruments/test_opentelemetry_instrument.py tests/instruments/test_pyroscope_instrument.py -v\` — pass. -- [x] \`just test\` — full suite 89/89. -- [x] \`just lint\` — clean. -- [ ] Reviewer: confirm the new dependency direction (\`pyroscope_instrument\` → \`opentelemetry_instrument\`) is acceptable and doesn't introduce a circular import (it doesn't — \`opentelemetry_instrument\` imports the \`pyroscope\` package, not \`pyroscope_instrument\`). - -🤖 Generated with [Claude Code](https://claude.com/claude-code) -EOF -)" -``` - ---- - -## Self-Review - -**Spec coverage check** against the sequencing spec (PR6 section) and audit (DES-2): - -| Spec item | Task | -|-----------|------| -| Declare `OpenTelemetryServiceFieldsConfig(BaseConfig)` mixin with the two fields | Task 2, Step 1 | -| Mixin inlined in `opentelemetry_instrument.py` | Task 2, Step 1 | -| `OpentelemetryConfig` inherits from mixin; duplicate fields removed | Task 2, Step 1 | -| `PyroscopeConfig` inherits from mixin (via import); duplicate fields removed | Task 2, Step 2 | -| Verify MRO still works via existing tests (especially `test_pyroscope_standalone_config_accepts_otel_fields`) | Task 2, Steps 3-4 | -| Branch name `fix/des-2-otel-fields-mixin` | Task 1, Step 1 | -| Verification: `just test` + `just lint` clean | Task 2, Steps 4-5 | - -All spec items covered. No placeholders. The mixin's name and the import path in `pyroscope_instrument.py` are consistent. - -**Deferred:** -- Renaming `OpentelemetryConfig` to `OpenTelemetryConfig` (capital `T`) for capitalization consistency with the new mixin name — out of scope; would be a separate naming-cleanup PR with deprecation aliases. diff --git a/planning/changes/2026-05-31.01-audit-implementation/plan-pr7-des1-generic-instruments.md b/planning/changes/2026-05-31.01-audit-implementation/plan-pr7-des1-generic-instruments.md deleted file mode 100644 index beffa12..0000000 --- a/planning/changes/2026-05-31.01-audit-implementation/plan-pr7-des1-generic-instruments.md +++ /dev/null @@ -1,671 +0,0 @@ -# PR7: Make `BaseInstrument` Generic; Delete Pure-Annotation Subclasses - -> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. - -**Goal:** Close the audit's DES-1 finding by deleting the four pure type-annotation framework subclasses that exist solely to narrow `bootstrap_config`. Make `BaseInstrument` generic in its config type so the base instruments can be parameterized (`LoggingInstrument(BaseInstrument[LoggingConfig])`); framework subclasses with real behavior keep their existing structure. - -**Architecture revision from the sequencing spec:** The locked decision in the sequencing spec called for "dropping redundant `bootstrap_config:` annotations on kept subclasses." Working through the Python typing implications, this would require a **two-level generic** pattern (each base instrument carries its own bounded `TypeVar` so framework subclasses can re-parameterize). The complexity isn't worth it — the annotations on framework subclasses with real `bootstrap()` overrides are **not** redundant under a single-level-generic design; they provide essential type narrowing for framework-specific field access (e.g., `self.bootstrap_config.application` inside `FastAPICorsInstrument.bootstrap()`). - -This plan implements the **simpler single-level-generic approach**: `BaseInstrument` is generic; each base instrument is concretely parameterized; framework subclasses with real bootstrap code keep their `bootstrap_config: FrameworkConfig` annotations. The deletion target remains the same — only the four pure-annotation subclasses go. This delivers the audit's stated goal (eliminating boilerplate) without the two-level-generic complexity. - -**Tech Stack:** Python 3.10+ generics (`typing.TypeVar`, `typing.Generic`), frozen dataclasses with `slots=True`. - -**Parent spec:** `docs/superpowers/specs/2026-05-31-audit-implementation-sequencing.md` (PR7 section). -**Parent audit:** `docs/superpowers/specs/2026-05-31-bug-refactor-audit.md` (DES-1). - ---- - -## File Structure - -12 files modified. - -**Instruments (9 files — make each base instrument concretely parameterize the new generic `BaseInstrument`):** -- Modify: `lite_bootstrap/instruments/base.py` — `BaseInstrument` becomes generic `[ConfigT]`. -- Modify: `lite_bootstrap/instruments/cors_instrument.py` — `CorsInstrument(BaseInstrument[CorsConfig])`. -- Modify: `lite_bootstrap/instruments/healthchecks_instrument.py` — `HealthChecksInstrument(BaseInstrument[HealthChecksConfig])`. -- Modify: `lite_bootstrap/instruments/logging_instrument.py` — `LoggingInstrument(BaseInstrument[LoggingConfig])`. -- Modify: `lite_bootstrap/instruments/opentelemetry_instrument.py` — `OpenTelemetryInstrument(BaseInstrument[OpentelemetryConfig])`. -- Modify: `lite_bootstrap/instruments/prometheus_instrument.py` — `PrometheusInstrument(BaseInstrument[PrometheusConfig])`. -- Modify: `lite_bootstrap/instruments/pyroscope_instrument.py` — `PyroscopeInstrument(BaseInstrument[PyroscopeConfig])`. -- Modify: `lite_bootstrap/instruments/sentry_instrument.py` — `SentryInstrument(BaseInstrument[SentryConfig])`. -- Modify: `lite_bootstrap/instruments/swagger_instrument.py` — `SwaggerInstrument(BaseInstrument[SwaggerConfig])`. - -In each of the above, the existing `bootstrap_config: ` field declaration is **removed** — it's now provided by the generic parent. - -**Bootstrappers (3 files — delete the 4 pure-annotation subclasses, update `instruments_types` lists to reference base names):** -- Modify: `lite_bootstrap/bootstrappers/fastapi_bootstrapper.py` — delete `FastAPILoggingInstrument` and `FastAPISentryInstrument`; replace their entries in `instruments_types` with `LoggingInstrument` / `SentryInstrument`. -- Modify: `lite_bootstrap/bootstrappers/litestar_bootstrapper.py` — delete `LitestarSentryInstrument`; replace entry with `SentryInstrument`. -- Modify: `lite_bootstrap/bootstrappers/faststream_bootstrapper.py` — delete `FastStreamSentryInstrument`; replace entry with `SentryInstrument`. - -The `FreeBootstrapper` already uses the base instrument names directly — no changes needed there. - -**Framework subclasses with real bootstrap code stay AS-IS** (their `bootstrap_config: ` annotations are still useful for type narrowing). The list (15 subclasses kept): - -- FastAPI: `FastAPICorsInstrument`, `FastAPIHealthChecksInstrument`, `FastAPIOpenTelemetryInstrument`, `FastAPIPrometheusInstrument`, `FastAPISwaggerInstrument`. -- Litestar: `LitestarCorsInstrument`, `LitestarHealthChecksInstrument`, `LitestarLoggingInstrument`, `LitestarOpenTelemetryInstrument`, `LitestarPrometheusInstrument`, `LitestarSwaggerInstrument`. -- FastStream: `FastStreamHealthChecksInstrument`, `FastStreamLoggingInstrument`, `FastStreamOpenTelemetryInstrument`, `FastStreamPrometheusInstrument`. - ---- - -## Locked decisions (revised from sequencing spec) - -- **Single-level generic:** `BaseInstrument` is generic; base instruments concretely parameterize. **Revised** from the sequencing spec's two-level approach. -- **Kept framework subclasses retain `bootstrap_config: ` annotations.** **Revised** from "drop redundant annotations." Under single-level-generic, these are not redundant — they provide essential type narrowing for framework field access. -- **Deletions unchanged:** the four pure-annotation subclasses (`FastAPILoggingInstrument`, `FastAPISentryInstrument`, `LitestarSentryInstrument`, `FastStreamSentryInstrument`) still get deleted. -- **Pyroscope handling:** `PyroscopeInstrument` is used as-is across all bootstrappers (no framework subclass). It still gets parameterized as `PyroscopeInstrument(BaseInstrument[PyroscopeConfig])` for symmetry. -- **No new tests.** This is a behavior-preserving refactor; existing tests verify correctness. - ---- - -## Cross-cutting concerns to verify during implementation - -1. **Generic + slots + frozen dataclass interaction.** Python 3.10 has historically had subtle issues with `@dataclasses.dataclass(slots=True)` combined with `typing.Generic`. If `just test` or `just lint` (ty check) surfaces errors related to slot conflicts or generic metaclass issues on `BaseInstrument`, fall back to dropping `slots=True` from `BaseInstrument` only. Document the change. - -2. **Field declaration removal.** When we remove `bootstrap_config: ` from each base instrument's body, the dataclass machinery should inherit the parent's `bootstrap_config: ConfigT` declaration. Verify by running `just test` after each instrument file change — any broken instantiation will surface immediately. - -3. **`instruments_types` ClassVar typing.** `BaseBootstrapper.instruments_types: typing.ClassVar[list[type[BaseInstrument]]]` — after `BaseInstrument` becomes generic, this becomes `list[type[BaseInstrument[Any]]]` semantically. Should still work because `type[BaseInstrument]` accepts subclasses regardless of parameterization. If `ty check` complains, may need to adjust the annotation. - -4. **`abc.ABC` + `typing.Generic`.** `BaseInstrument(abc.ABC, typing.Generic[ConfigT])` requires the MRO to be: BaseInstrument → ABC → Generic → object. Both `abc.ABC` and `typing.Generic` are designed to work together, but verify the order doesn't break dataclass machinery. - ---- - -## Task 1: Create branch - -**Files:** (no files; git only) - -- [ ] **Step 1: Branch off `main`** - -```bash -git checkout main -git pull --ff-only origin main -git checkout -b refactor/des-1-generic-instruments -``` - -Expected: `Switched to a new branch 'refactor/des-1-generic-instruments'`. - -This is the only PR in the sequence that uses the `refactor/` branch prefix (not `fix/`), because it's the only one that's a pure refactor with no fix component. - ---- - -## Task 2: Make `BaseInstrument` generic - -**File:** `lite_bootstrap/instruments/base.py` - -- [ ] **Step 1: Add the `ConfigT` TypeVar and parameterize `BaseInstrument`** - -Current (lines 1-46): - -```python -import abc -import dataclasses -import typing - -import typing_extensions - - -@dataclasses.dataclass(kw_only=True, slots=True, frozen=True) -class BaseConfig: - ... - - -@dataclasses.dataclass(kw_only=True, slots=True, frozen=True) -class BaseInstrument(abc.ABC): - bootstrap_config: BaseConfig - not_ready_message = "" - missing_dependency_message = "" - - def bootstrap(self) -> None: ... # noqa: B027 - - def teardown(self) -> None: ... # noqa: B027 - - def is_ready(self) -> bool: - return True - - @staticmethod - def check_dependencies() -> bool: - return True -``` - -Replace the `BaseInstrument` block (lines 30-45) with: - -```python -ConfigT = typing.TypeVar("ConfigT", bound=BaseConfig) - - -@dataclasses.dataclass(kw_only=True, slots=True, frozen=True) -class BaseInstrument(abc.ABC, typing.Generic[ConfigT]): - bootstrap_config: ConfigT - not_ready_message = "" - missing_dependency_message = "" - - def bootstrap(self) -> None: ... # noqa: B027 - - def teardown(self) -> None: ... # noqa: B027 - - def is_ready(self) -> bool: - return True - - @staticmethod - def check_dependencies() -> bool: - return True -``` - -Two changes: -1. Add `ConfigT = typing.TypeVar("ConfigT", bound=BaseConfig)` between the two dataclasses. -2. `BaseInstrument` now inherits from `abc.ABC, typing.Generic[ConfigT]`; the `bootstrap_config` field type changes from `BaseConfig` to `ConfigT`. - -- [ ] **Step 2: Quick smoke check** - -```bash -just test -- tests/test_free_bootstrap.py -v -``` - -Expected: PASS. The `FreeBootstrapper` uses the base instruments directly with `FreeBootstrapperConfig`; if generic+slots+frozen has an issue, this test surfaces it first. - -If this test fails with a slots/generic-related error, fall back to dropping `slots=True` from `BaseInstrument`: - -```python -@dataclasses.dataclass(kw_only=True, frozen=True) -class BaseInstrument(abc.ABC, typing.Generic[ConfigT]): - ... -``` - -Re-run the test. Note the change in the eventual commit message if so. - ---- - -## Task 3: Parameterize each base instrument - -For each instrument file, change the class signature from `class XInstrument(BaseInstrument):` to `class XInstrument(BaseInstrument[XConfig]):` and remove the redundant `bootstrap_config: XConfig` field declaration from the class body. - -### Step 1: `lite_bootstrap/instruments/cors_instrument.py` - -Current (around lines 17-25): - -```python -@dataclasses.dataclass(kw_only=True, slots=True, frozen=True) -class CorsInstrument(BaseInstrument): - bootstrap_config: CorsConfig - not_ready_message = "cors_allowed_origins or cors_allowed_origin_regex must be provided" - - def is_ready(self) -> bool: - return bool(self.bootstrap_config.cors_allowed_origins) or bool( - self.bootstrap_config.cors_allowed_origin_regex, - ) -``` - -Replace with: - -```python -@dataclasses.dataclass(kw_only=True, slots=True, frozen=True) -class CorsInstrument(BaseInstrument[CorsConfig]): - not_ready_message = "cors_allowed_origins or cors_allowed_origin_regex must be provided" - - def is_ready(self) -> bool: - return bool(self.bootstrap_config.cors_allowed_origins) or bool( - self.bootstrap_config.cors_allowed_origin_regex, - ) -``` - -Single change: parameterize the base, drop the redundant `bootstrap_config: CorsConfig` field. - -### Step 2: `lite_bootstrap/instruments/healthchecks_instrument.py` - -Current (around lines 29-38): - -```python -@dataclasses.dataclass(kw_only=True, slots=True, frozen=True) -class HealthChecksInstrument(BaseInstrument): - bootstrap_config: HealthChecksConfig - not_ready_message = "health_checks_enabled is False" - - def is_ready(self) -> bool: - return self.bootstrap_config.health_checks_enabled - - def render_health_check_data(self) -> HealthCheckTypedDict: - return self.bootstrap_config.health_check_data -``` - -Replace with: - -```python -@dataclasses.dataclass(kw_only=True, slots=True, frozen=True) -class HealthChecksInstrument(BaseInstrument[HealthChecksConfig]): - not_ready_message = "health_checks_enabled is False" - - def is_ready(self) -> bool: - return self.bootstrap_config.health_checks_enabled - - def render_health_check_data(self) -> HealthCheckTypedDict: - return self.bootstrap_config.health_check_data -``` - -### Step 3: `lite_bootstrap/instruments/logging_instrument.py` - -Current (around lines 117-145): - -```python -@dataclasses.dataclass(kw_only=True, slots=True, frozen=True) -class LoggingInstrument(BaseInstrument): - bootstrap_config: LoggingConfig - not_ready_message = "logging_enabled is False" - missing_dependency_message = "structlog is not installed" - _logger_factory: "MemoryLoggerFactory | None" = dataclasses.field( - default_factory=lambda: None, init=False, repr=False, compare=False - ) - ... -``` - -Change only the class signature and remove the `bootstrap_config: LoggingConfig` line: - -```python -@dataclasses.dataclass(kw_only=True, slots=True, frozen=True) -class LoggingInstrument(BaseInstrument[LoggingConfig]): - not_ready_message = "logging_enabled is False" - missing_dependency_message = "structlog is not installed" - _logger_factory: "MemoryLoggerFactory | None" = dataclasses.field( - default_factory=lambda: None, init=False, repr=False, compare=False - ) - ... -``` - -Everything below the field declarations stays unchanged. - -### Step 4: `lite_bootstrap/instruments/opentelemetry_instrument.py` - -Current (around lines 80-90): - -```python -@dataclasses.dataclass(kw_only=True, slots=True, frozen=True) -class OpenTelemetryInstrument(BaseInstrument): - bootstrap_config: OpentelemetryConfig - not_ready_message = "opentelemetry_endpoint is empty and opentelemetry_log_traces is False" - missing_dependency_message = "opentelemetry is not installed" - _tracer_provider: "TracerProvider | None" = dataclasses.field( - default_factory=lambda: None, init=False, repr=False, compare=False - ) - ... -``` - -Replace the class signature and drop `bootstrap_config: OpentelemetryConfig`: - -```python -@dataclasses.dataclass(kw_only=True, slots=True, frozen=True) -class OpenTelemetryInstrument(BaseInstrument[OpentelemetryConfig]): - not_ready_message = "opentelemetry_endpoint is empty and opentelemetry_log_traces is False" - missing_dependency_message = "opentelemetry is not installed" - _tracer_provider: "TracerProvider | None" = dataclasses.field( - default_factory=lambda: None, init=False, repr=False, compare=False - ) - ... -``` - -Rest of the class unchanged. - -### Step 5: `lite_bootstrap/instruments/prometheus_instrument.py` - -Current (around lines 13-21): - -```python -@dataclasses.dataclass(kw_only=True, slots=True, frozen=True) -class PrometheusInstrument(BaseInstrument): - bootstrap_config: PrometheusConfig - not_ready_message = "prometheus_metrics_path is empty or not valid" - - def is_ready(self) -> bool: - return bool(self.bootstrap_config.prometheus_metrics_path) and is_valid_path( - self.bootstrap_config.prometheus_metrics_path - ) -``` - -Replace with: - -```python -@dataclasses.dataclass(kw_only=True, slots=True, frozen=True) -class PrometheusInstrument(BaseInstrument[PrometheusConfig]): - not_ready_message = "prometheus_metrics_path is empty or not valid" - - def is_ready(self) -> bool: - return bool(self.bootstrap_config.prometheus_metrics_path) and is_valid_path( - self.bootstrap_config.prometheus_metrics_path - ) -``` - -### Step 6: `lite_bootstrap/instruments/pyroscope_instrument.py` - -Current (around lines 21-46, after PR6's mixin landed): - -```python -@dataclasses.dataclass(kw_only=True, slots=True, frozen=True) -class PyroscopeInstrument(BaseInstrument): - bootstrap_config: PyroscopeConfig - not_ready_message = "pyroscope_endpoint is empty" - missing_dependency_message = "pyroscope is not installed" - - def is_ready(self) -> bool: - return bool(self.bootstrap_config.pyroscope_endpoint) - ... -``` - -Replace class signature and drop the field: - -```python -@dataclasses.dataclass(kw_only=True, slots=True, frozen=True) -class PyroscopeInstrument(BaseInstrument[PyroscopeConfig]): - not_ready_message = "pyroscope_endpoint is empty" - missing_dependency_message = "pyroscope is not installed" - - def is_ready(self) -> bool: - return bool(self.bootstrap_config.pyroscope_endpoint) - ... -``` - -Rest unchanged. - -### Step 7: `lite_bootstrap/instruments/sentry_instrument.py` - -Current (around lines 94-105): - -```python -@dataclasses.dataclass(kw_only=True, slots=True, frozen=True) -class SentryInstrument(BaseInstrument): - bootstrap_config: SentryConfig - not_ready_message = "sentry_dsn is empty" - missing_dependency_message = "sentry_sdk is not installed" - - def is_ready(self) -> bool: - return bool(self.bootstrap_config.sentry_dsn) - ... -``` - -Replace: - -```python -@dataclasses.dataclass(kw_only=True, slots=True, frozen=True) -class SentryInstrument(BaseInstrument[SentryConfig]): - not_ready_message = "sentry_dsn is empty" - missing_dependency_message = "sentry_sdk is not installed" - - def is_ready(self) -> bool: - return bool(self.bootstrap_config.sentry_dsn) - ... -``` - -### Step 8: `lite_bootstrap/instruments/swagger_instrument.py` - -Current (around lines 13-16): - -```python -@dataclasses.dataclass(kw_only=True, slots=True, frozen=True) -class SwaggerInstrument(BaseInstrument): - bootstrap_config: SwaggerConfig -``` - -Replace with: - -```python -@dataclasses.dataclass(kw_only=True, slots=True, frozen=True) -class SwaggerInstrument(BaseInstrument[SwaggerConfig]): - pass -``` - -The body becomes empty — replace with `pass`. This is the smallest instrument class. - -### Step 9: Intermediate verification - -After all nine instrument files are updated: - -```bash -just test -- tests/instruments/ -v -just test -- tests/test_free_bootstrap.py -v -``` - -Expected: all pass. The instrument-level tests + the free-bootstrapper test exercise every base instrument directly. If any fail, debug before moving to Task 4. - ---- - -## Task 4: Delete the four pure-annotation framework subclasses - -### Step 1: `lite_bootstrap/bootstrappers/fastapi_bootstrapper.py` - -Locate `FastAPILoggingInstrument` (around lines 111-113): - -```python -@dataclasses.dataclass(kw_only=True, frozen=True) -class FastAPILoggingInstrument(LoggingInstrument): - bootstrap_config: FastAPIConfig -``` - -Delete the entire block. - -Locate `FastAPISentryInstrument` (around lines 141-143): - -```python -@dataclasses.dataclass(kw_only=True, frozen=True) -class FastAPISentryInstrument(SentryInstrument): - bootstrap_config: FastAPIConfig -``` - -Delete the entire block. - -In `FastAPIBootstrapper.instruments_types`: - -```python - instruments_types: typing.ClassVar = [ - FastAPICorsInstrument, - FastAPIOpenTelemetryInstrument, - PyroscopeInstrument, - FastAPISentryInstrument, # ← replace with SentryInstrument - FastAPIHealthChecksInstrument, - FastAPILoggingInstrument, # ← replace with LoggingInstrument - FastAPIPrometheusInstrument, - FastAPISwaggerInstrument, - ] -``` - -Replace `FastAPISentryInstrument` with `SentryInstrument` and `FastAPILoggingInstrument` with `LoggingInstrument`. - -### Step 2: `lite_bootstrap/bootstrappers/litestar_bootstrapper.py` - -Locate `LitestarSentryInstrument` (around lines 199-201): - -```python -@dataclasses.dataclass(kw_only=True, frozen=True) -class LitestarSentryInstrument(SentryInstrument): - bootstrap_config: LitestarConfig -``` - -Delete the entire block. - -In `LitestarBootstrapper.instruments_types`, replace `LitestarSentryInstrument` with `SentryInstrument`. - -### Step 3: `lite_bootstrap/bootstrappers/faststream_bootstrapper.py` - -Locate `FastStreamSentryInstrument` (around lines 135-137): - -```python -@dataclasses.dataclass(kw_only=True, frozen=True) -class FastStreamSentryInstrument(SentryInstrument): - bootstrap_config: FastStreamConfig -``` - -Delete the entire block. - -In `FastStreamBootstrapper.instruments_types`, replace `FastStreamSentryInstrument` with `SentryInstrument`. - -### Step 4: Verify imports - -After the deletions, the framework bootstrappers no longer reference the deleted classes by name. Any imports of `FastAPILoggingInstrument` / `FastAPISentryInstrument` / `LitestarSentryInstrument` / `FastStreamSentryInstrument` from other test files or modules would break. - -Run a sanity grep: - -```bash -grep -rn "FastAPILoggingInstrument\|FastAPISentryInstrument\|LitestarSentryInstrument\|FastStreamSentryInstrument" . -``` - -Expected: only matches in the plan/spec docs (which describe the deletion). Zero matches in `lite_bootstrap/` or `tests/`. If any test file imports a deleted class, update it to use the base class name. - ---- - -## Task 5: Verify and commit - -- [ ] **Step 1: Run the full test suite** - -```bash -just test -``` - -Expected: 89/89 pass. No behavior change should result from this refactor. - -If any framework integration test (`test_fastapi_bootstrap`, `test_litestar_bootstrap`, `test_faststream_bootstrap`) fails, the most likely cause is type-narrowing surprise on a framework subclass that needs to access framework-specific config fields. Verify the kept framework subclasses still declare `bootstrap_config: ` where they override `bootstrap()`. - -- [ ] **Step 2: Run lint** - -```bash -just lint -``` - -Expected: clean. Watch for: - -- F401 unused-import warnings — possible if a bootstrapper imports something it no longer uses after the deletions. -- `ty check` complaints about the new generic parameterization — should be silent, but if not, narrow the issue and address. - -- [ ] **Step 3: Sanity-check the diff** - -```bash -git diff --stat -``` - -Expected: 12 files changed. Approximate line counts (rough): -- `base.py`: +4 / -1 (TypeVar + Generic + bootstrap_config type) -- 8 instrument files: roughly -1 line each (drop the redundant field declaration) -- 3 bootstrapper files: ~-3 lines per deleted subclass + 1-2 line changes in `instruments_types` - -Total net: roughly -10 to -20 lines. - -- [ ] **Step 4: Commit** - -Stage exactly the 12 modified files: - -```bash -git add \ - lite_bootstrap/instruments/base.py \ - lite_bootstrap/instruments/cors_instrument.py \ - lite_bootstrap/instruments/healthchecks_instrument.py \ - lite_bootstrap/instruments/logging_instrument.py \ - lite_bootstrap/instruments/opentelemetry_instrument.py \ - lite_bootstrap/instruments/prometheus_instrument.py \ - lite_bootstrap/instruments/pyroscope_instrument.py \ - lite_bootstrap/instruments/sentry_instrument.py \ - lite_bootstrap/instruments/swagger_instrument.py \ - lite_bootstrap/bootstrappers/fastapi_bootstrapper.py \ - lite_bootstrap/bootstrappers/litestar_bootstrapper.py \ - lite_bootstrap/bootstrappers/faststream_bootstrapper.py -git commit -m "$(cat <<'EOF' -refactor: make BaseInstrument generic; delete pure-annotation subclasses - -Closes DES-1: the four pure type-annotation framework subclasses -(FastAPILoggingInstrument, FastAPISentryInstrument, LitestarSentryInstrument, -FastStreamSentryInstrument) existed only to narrow bootstrap_config. They -contributed no behavior. - -Make BaseInstrument generic in ConfigT (TypeVar bound to BaseConfig). -Each base instrument concretely parameterizes the generic -(LoggingInstrument(BaseInstrument[LoggingConfig]), etc.) and drops its -own redundant bootstrap_config field declaration — the type is now -provided by the parent's generic parameter. - -Delete the four pure-annotation framework subclasses. In the three -affected bootstrappers' instruments_types lists, replace the deleted -class references with the base names (SentryInstrument, LoggingInstrument). - -Framework subclasses that override bootstrap() with real framework-specific -logic (FastAPICorsInstrument, LitestarLoggingInstrument, etc. — 15 in -total) keep their existing structure including the bootstrap_config: - annotation. These annotations are NOT redundant under -the single-level-generic design adopted here — they provide essential -type narrowing for framework field access (e.g., -self.bootstrap_config.application inside FastAPICorsInstrument.bootstrap). - -This deviates from the sequencing spec's "drop redundant annotations -on kept subclasses" decision: a two-level-generic design would be needed -to make those annotations truly redundant, and the complexity isn't -worth it. The audit's stated goal (eliminate the pure-annotation -boilerplate) is fully addressed regardless. - -No behavior change. Existing tests verify correctness. - -Closes DES-1 from the audit. -EOF -)" -``` - ---- - -## Task 6: Push and open PR - -- [ ] **Step 1: Push the branch** - -```bash -git push -u origin refactor/des-1-generic-instruments -``` - -- [ ] **Step 2: Open the PR** - -```bash -gh pr create --title "refactor: make BaseInstrument generic; delete pure-annotation subclasses" --body "$(cat <<'EOF' -## Summary -Closes DES-1 from an internal audit: the four pure type-annotation framework subclasses (`FastAPILoggingInstrument`, `FastAPISentryInstrument`, `LitestarSentryInstrument`, `FastStreamSentryInstrument`) existed only to narrow `bootstrap_config`. They contributed no behavior. - -- `BaseInstrument` is now generic in `ConfigT` (`TypeVar` bound to `BaseConfig`). -- Each base instrument concretely parameterizes the generic (`LoggingInstrument(BaseInstrument[LoggingConfig])`, etc.) and drops its redundant `bootstrap_config` field declaration — the type is provided by the parent. -- The four pure-annotation framework subclasses are deleted. The three affected bootstrappers' `instruments_types` lists now reference the base names directly (`SentryInstrument`, `LoggingInstrument`). -- Framework subclasses that override `bootstrap()` with framework-specific logic (15 in total) keep their existing structure. - -No behavior change. Existing tests verify correctness. - -## Deviation from sequencing spec -The sequencing spec called for "dropping redundant `bootstrap_config:` annotations on kept subclasses." Under a single-level-generic design those annotations are NOT redundant — they provide essential type narrowing for framework field access (e.g. `self.bootstrap_config.application` in `FastAPICorsInstrument.bootstrap()`). Making them truly redundant would require a two-level-generic design (each base instrument with its own bounded `TypeVar`); the complexity isn't worth the marginal cleanup. The audit's stated goal — eliminating the pure-annotation boilerplate — is fully addressed regardless. - -## Test plan -- [x] `just test` — full suite passes (89/89). -- [x] `just lint` — clean. -- [ ] Reviewer: confirm the generic + slots + frozen dataclass combination works on Python 3.10. If `BaseInstrument`'s `slots=True` had to be dropped during implementation, the commit message will note it. -- [ ] Reviewer: confirm the kept framework subclasses (`FastAPICorsInstrument`, `LitestarLoggingInstrument`, etc.) still access framework-specific config fields correctly. - -🤖 Generated with [Claude Code](https://claude.com/claude-code) -EOF -)" -``` - ---- - -## Self-Review - -**Spec coverage check** against the sequencing spec (PR7 section) and audit (DES-1): - -| Spec item | Task | -|-----------|------| -| `BaseInstrument` becomes generic in `ConfigT` (bound to `BaseConfig`) | Task 2, Step 1 | -| Each base instrument concretely parameterizes | Task 3, Steps 1-8 | -| Each base instrument drops its redundant `bootstrap_config:` field declaration | Task 3, Steps 1-8 | -| Delete `FastAPILoggingInstrument` | Task 4, Step 1 | -| Delete `FastAPISentryInstrument` | Task 4, Step 1 | -| Delete `LitestarSentryInstrument` | Task 4, Step 2 | -| Delete `FastStreamSentryInstrument` | Task 4, Step 3 | -| Update `FastAPIBootstrapper.instruments_types` to reference base names | Task 4, Step 1 | -| Update `LitestarBootstrapper.instruments_types` | Task 4, Step 2 | -| Update `FastStreamBootstrapper.instruments_types` | Task 4, Step 3 | -| Branch name `refactor/des-1-generic-instruments` | Task 1, Step 1 | -| Verification: `just test` + `just lint` clean | Task 5, Steps 1-2 | - -**Deviations from sequencing spec (documented in commit and PR body):** -- Single-level generic rather than two-level. -- Kept framework subclasses retain their `bootstrap_config:` annotations. - -**Deferred:** -- REF-1 (`_build_excluded_urls` duplication between FastAPI and Litestar OTel instruments) — fold into a quick PR8 if PR7's diff stays manageable; otherwise separate follow-up. -- REF-2 (dead defensive check in `LitestarLoggingInstrument.bootstrap()`) — same. -- REF-5 (collapse near-empty `swagger_instrument.py` and `prometheus_instrument.py` base files) — out of scope. - -These three are explicitly noted in the sequencing spec as follow-ups to PR7. Decide once PR7's actual diff lands whether to bundle them or split. diff --git a/planning/changes/2026-05-31.01-audit-implementation/plan.md b/planning/changes/2026-05-31.01-audit-implementation/plan.md new file mode 100644 index 0000000..2e33b10 --- /dev/null +++ b/planning/changes/2026-05-31.01-audit-implementation/plan.md @@ -0,0 +1,2695 @@ +# 31.01-audit-implementation — implementation plan + +> Multi-PR plan: this change shipped as a sequence of PRs. Each section below was an independent per-PR plan; they are preserved verbatim here as the bundle's single `plan.md` (the spec is [`design.md`](./design.md)). + + +--- + +# PR1: Redoc `root_path` Fix Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Make the offline-docs `redoc_html` handler honor `root_path`, so redoc loads its JS and OpenAPI spec correctly when the FastAPI app is mounted behind a reverse proxy. Add a regression test that fails on `main` and passes after the fix. + +**Architecture:** The `enable_offline_docs` helper installs three handlers — one for swagger, one for swagger oauth2 redirect, one for redoc. The swagger handler already reads `request.scope["root_path"]` and prefixes asset/OpenAPI URLs. The redoc handler does not. Make the redoc handler match the swagger pattern. No public API change. + +**Tech Stack:** FastAPI, Starlette, pytest, fastapi.testclient. + +**Parent spec:** `docs/superpowers/specs/2026-05-31-audit-implementation-sequencing.md` (PR1 section). +**Parent audit:** `docs/superpowers/specs/2026-05-31-bug-refactor-audit.md` (CRIT-1, TEST-1). + +--- + +## File Structure + +Two existing files modified. No new files. + +- Modify: `lite_bootstrap/helpers/fastapi_helpers.py:52-58` — change `redoc_html` signature and URL construction. +- Modify: `tests/test_fastapi_offline_docs.py:32-43` — extend `test_fastapi_offline_docs_root_path` to fetch redoc and assert prefixing. + +--- + +## Task 1: Create branch + +**Files:** +- (no files; git branch only) + +- [ ] **Step 1: Create the feature branch** + +From `main` (clean working tree expected): + +```bash +git checkout main +git pull --ff-only origin main +git checkout -b fix/crit-1-redoc-root-path +``` + +Expected: `Switched to a new branch 'fix/crit-1-redoc-root-path'`. + +--- + +## Task 2: Add the failing regression test + +**Files:** +- Modify: `tests/test_fastapi_offline_docs.py:32-43` + +The existing `test_fastapi_offline_docs_root_path` exercises swagger under `root_path` but never fetches redoc. Extend it to fetch redoc and assert that both the redoc JS URL and the OpenAPI URL in the rendered HTML carry the `/some-root-path` prefix. The default `redoc_url` for a FastAPI app is `/redoc` (no override in the test setup), and the default `openapi_url` is `/openapi.json`. + +- [ ] **Step 1: Modify `test_fastapi_offline_docs_root_path`** + +Replace the existing function body (lines 33-43) with: + +```python +def test_fastapi_offline_docs_root_path() -> None: + app: FastAPI = FastAPI(title="Tests", root_path="/some-root-path", docs_url="/custom_docs") + enable_offline_docs(app, static_path="/static") + + with TestClient(app, root_path="/some-root-path") as client: + response = client.get("/custom_docs") + assert response.status_code == HTTPStatus.OK + assert "/some-root-path/static/swagger-ui.css" in response.text + assert "/some-root-path/static/swagger-ui-bundle.js" in response.text + + response = client.get("/some-root-path/static/swagger-ui.css") + assert response.status_code == HTTPStatus.OK + + response = client.get("/redoc") + assert response.status_code == HTTPStatus.OK + assert "/some-root-path/static/redoc.standalone.js" in response.text + assert "/some-root-path/openapi.json" in response.text +``` + +The four added lines: the redoc GET, the status assertion, the redoc JS URL assertion, the OpenAPI URL assertion. + +- [ ] **Step 2: Run the test and verify it FAILS** + +Run: + +```bash +just test -- tests/test_fastapi_offline_docs.py::test_fastapi_offline_docs_root_path -v +``` + +Expected: **FAIL** with an assertion error on one of the two new asserts — most likely +`assert "/some-root-path/static/redoc.standalone.js" in response.text` fails because the +rendered HTML contains `/static/redoc.standalone.js` (no `/some-root-path/` prefix). + +If the test does not fail, stop and investigate — either the assertion is wrong, or the bug +isn't present (which would mean the audit is stale). + +--- + +## Task 3: Implement the redoc fix + +**Files:** +- Modify: `lite_bootstrap/helpers/fastapi_helpers.py:52-58` + +The swagger handler at lines 37-46 is the pattern to mirror: it takes `request: Request`, +reads `root_path` from the ASGI scope, and prefixes asset URLs and the OpenAPI URL. + +- [ ] **Step 1: Replace the `redoc_html` handler** + +Replace lines 52-58 of `lite_bootstrap/helpers/fastapi_helpers.py` with: + +```python + @app.get(redoc_url, include_in_schema=False) + async def redoc_html(request: Request) -> HTMLResponse: + root_path = request.scope.get("root_path", "").rstrip("/") + return get_redoc_html( + openapi_url=f"{root_path}{app_openapi_url}", + title=f"{app.title} - ReDoc", + redoc_js_url=f"{root_path}{static_path}/redoc.standalone.js", + ) +``` + +Notes: +- `Request` is already imported at line 9. +- `root_path` handling matches the swagger handler exactly (`request.scope.get("root_path", "").rstrip("/")`). +- Both `openapi_url` and `redoc_js_url` get the prefix. The audit (CRIT-1) flagged the JS URL; the OpenAPI URL has the same bug — the test in Task 2 catches both. + +- [ ] **Step 2: Run the previously-failing test and verify it PASSES** + +Run: + +```bash +just test -- tests/test_fastapi_offline_docs.py::test_fastapi_offline_docs_root_path -v +``` + +Expected: **PASS**. + +- [ ] **Step 3: Run the full offline-docs test file** + +Run: + +```bash +just test -- tests/test_fastapi_offline_docs.py -v +``` + +Expected: all three tests PASS — `test_fastapi_offline_docs`, +`test_fastapi_offline_docs_root_path`, `test_fastapi_offline_docs_raises_without_openapi_url`. + +This confirms the change didn't break the no-`root_path` path or the error path. + +- [ ] **Step 4: Run the full test suite** + +Run: + +```bash +just test +``` + +Expected: all tests PASS with no new failures. + +- [ ] **Step 5: Run lint** + +Run: + +```bash +just lint +``` + +Expected: no errors. The change is small and follows existing patterns, so ruff, eof-fixer, +and `ty check` should all pass. + +- [ ] **Step 6: Commit** + +Stage both modified files explicitly: + +```bash +git add lite_bootstrap/helpers/fastapi_helpers.py tests/test_fastapi_offline_docs.py +git commit -m "$(cat <<'EOF' +fix: honor root_path in offline-docs redoc handler + +The swagger handler in enable_offline_docs already reads root_path from +the ASGI scope and prefixes asset/OpenAPI URLs. The redoc handler did +not, so redoc 404'd on its JS and OpenAPI spec when the FastAPI app +ran behind a reverse proxy. Mirror the swagger pattern: take Request, +read root_path, prefix both redoc_js_url and openapi_url. + +Extends test_fastapi_offline_docs_root_path to fetch redoc and assert +both URLs carry the prefix — the test fails on the prior code. + +Closes CRIT-1, TEST-1 from the audit. +EOF +)" +``` + +Expected: commit succeeds. (No pre-commit hooks are configured in this repo — see `.pre-commit-config.yaml` absence in repo root.) + +--- + +## Task 4: Push and open PR + +**Files:** +- (no files; git push + gh) + +- [ ] **Step 1: Push the branch** + +```bash +git push -u origin fix/crit-1-redoc-root-path +``` + +Expected: branch published; gh CLI may print a PR-creation URL. + +- [ ] **Step 2: Open the PR** + +```bash +gh pr create --title "fix: honor root_path in offline-docs redoc handler" --body "$(cat <<'EOF' +## Summary +- Redoc handler in `enable_offline_docs` now reads `root_path` from the ASGI scope and prefixes both `redoc_js_url` and `openapi_url`, matching the existing swagger handler pattern. +- Existing `test_fastapi_offline_docs_root_path` extended to fetch redoc and assert both URLs carry the `root_path` prefix. Test fails on `main`, passes on this branch. + +Closes CRIT-1 and TEST-1 from `docs/superpowers/specs/2026-05-31-bug-refactor-audit.md`. + +## Test plan +- [x] `just test -- tests/test_fastapi_offline_docs.py -v` — three tests pass. +- [x] `just test` — full suite passes. +- [x] `just lint` — clean. +- [ ] Reviewer: confirm the diff matches the swagger handler's `root_path` pattern. +EOF +)" +``` + +Expected: PR created; PR URL printed. + +--- + +## Self-Review + +Spec coverage check against `2026-05-31-audit-implementation-sequencing.md`, PR1 section: + +| Spec item | Task | +|-----------|------| +| Refactor `redoc_html` to accept `Request` | Task 3, Step 1 | +| Read `root_path` from `request.scope` and rstrip | Task 3, Step 1 | +| Prepend prefix to `redoc_js_url` | Task 3, Step 1 | +| Prepend prefix to `openapi_url` (also missing it) | Task 3, Step 1 | +| Extend `test_fastapi_offline_docs_root_path` to fetch redoc and assert prefix | Task 2, Step 1 | +| Verification: `just test` passes | Task 3, Steps 3-4 | +| Branch name `fix/crit-1-redoc-root-path` | Task 1, Step 1 | + +All spec items covered. No placeholders. Method signatures (`redoc_html(request: Request)`, +`request.scope.get("root_path", "")`) are consistent across Task 2's test expectations +and Task 3's implementation. The test asserts on `/some-root-path/static/redoc.standalone.js` +and `/some-root-path/openapi.json`; the implementation produces those exact strings. + + +--- + +# PR2: OpenTelemetry Tracer Provider Shutdown Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Make `OpenTelemetryInstrument.teardown()` shut down the `TracerProvider` that `bootstrap()` created. Today the provider goes out of scope and any spans buffered in `BatchSpanProcessor` are not flushed — trace data loss on graceful shutdown. Add a regression test that fails on `main` and passes after the fix. + +**Architecture:** `OpenTelemetryInstrument` is a frozen, slots-enabled dataclass. The existing codebase pattern for stashing mutable runtime state on a frozen dataclass uses an init-false private field plus `object.__setattr__` (see `LoggingInstrument._logger_factory`). Mirror that: add `_tracer_provider`, store the provider via `object.__setattr__` at the end of `bootstrap()`, call `self._tracer_provider.shutdown()` after the existing `uninstrument()` loop in `teardown()`, reset to `None`. + +**Tech Stack:** Python 3.10+, OpenTelemetry SDK (`opentelemetry-sdk`, `opentelemetry-api`), pytest, `unittest.mock.patch.object`. + +**Parent spec:** `docs/superpowers/specs/2026-05-31-audit-implementation-sequencing.md` (PR2 section). +**Parent audit:** `docs/superpowers/specs/2026-05-31-bug-refactor-audit.md` (CRIT-2, TEST-2). + +--- + +## File Structure + +Two existing files modified. No new files. + +- Modify: `lite_bootstrap/instruments/opentelemetry_instrument.py:76-135` — add `_tracer_provider` field on the dataclass, store the provider in `bootstrap()`, shut it down in `teardown()`. +- Modify: `tests/instruments/test_opentelemetry_instrument.py` — add a new test asserting `shutdown` is invoked. + +--- + +## Locked decisions (from sequencing spec) + +- **Storage pattern:** `object.__setattr__` (preserves `frozen=True`; matches `LoggingInstrument._logger_factory`). +- **Field name:** `_tracer_provider` (underscore = internal state, mirrors `_logger_factory`). +- **Test access:** read the private `_tracer_provider` attribute directly with `# noqa: SLF001`. Other tests in the file already use patch-based introspection (`patch("lite_bootstrap.instruments.opentelemetry_instrument.set_tracer_provider")`), so private-attribute access for verification is consistent with the existing test style. + +--- + +## Task 1: Create branch + +**Files:** (no files; git only) + +- [ ] **Step 1: Branch off `main`** + +```bash +git checkout main +git pull --ff-only origin main +git checkout -b fix/crit-2-otel-shutdown +``` + +Expected: `Switched to a new branch 'fix/crit-2-otel-shutdown'`. + +If PR1 (`fix/crit-1-redoc-root-path`) has not yet merged into `main`, that's fine — PR2's changes touch a different file and there will be no conflict. Branch from current `main` regardless. + +--- + +## Task 2: Add the failing regression test + +**Files:** +- Modify: `tests/instruments/test_opentelemetry_instrument.py` + +The current test file has two tests that bootstrap + teardown but assert nothing about shutdown behavior. Add a third test that bootstraps the instrument, captures the stored `TracerProvider`, patches its `shutdown` method, runs teardown, and asserts the patch was invoked exactly once. + +- [ ] **Step 1: Add imports and the new test** + +The existing imports are: + +```python +from lite_bootstrap.instruments.opentelemetry_instrument import ( + InstrumentorWithParams, + OpentelemetryConfig, + OpenTelemetryInstrument, +) +from tests.conftest import CustomInstrumentor +``` + +Add `from unittest.mock import patch` at the top (after any stdlib imports, before the project imports — ruff isort will handle ordering on save, but write it correctly the first time): + +```python +from unittest.mock import patch + +from lite_bootstrap.instruments.opentelemetry_instrument import ( + InstrumentorWithParams, + OpentelemetryConfig, + OpenTelemetryInstrument, +) +from tests.conftest import CustomInstrumentor +``` + +Append this new test to the end of the file (after `test_opentelemetry_instrument_empty_instruments`): + +```python +def test_opentelemetry_instrument_teardown_shuts_down_tracer_provider() -> None: + instrument = OpenTelemetryInstrument( + bootstrap_config=OpentelemetryConfig(opentelemetry_log_traces=True), + ) + instrument.bootstrap() + tracer_provider = instrument._tracer_provider # noqa: SLF001 + assert tracer_provider is not None + + with patch.object(tracer_provider, "shutdown") as mock_shutdown: + instrument.teardown() + + mock_shutdown.assert_called_once_with() + assert instrument._tracer_provider is None # noqa: SLF001 +``` + +This test asserts three things: +1. The instrument exposes its tracer provider as `_tracer_provider` after bootstrap. +2. Teardown calls `shutdown()` on that provider exactly once. +3. Teardown resets `_tracer_provider` to `None` so a subsequent bootstrap starts clean. + +- [ ] **Step 2: Run the new test and verify it FAILS** + +```bash +just test -- tests/instruments/test_opentelemetry_instrument.py::test_opentelemetry_instrument_teardown_shuts_down_tracer_provider -v +``` + +Expected: **FAIL** with `AttributeError: 'OpenTelemetryInstrument' object has no attribute '_tracer_provider'` (or similar — the attribute doesn't exist yet on the dataclass). + +If the test passes, stop and investigate — either the attribute already exists (which would mean someone else implemented the fix already) or the assertion is wrong. + +--- + +## Task 3: Implement the shutdown fix + +**Files:** +- Modify: `lite_bootstrap/instruments/opentelemetry_instrument.py` + +The current `OpenTelemetryInstrument` dataclass (lines 76-80) has no init-false field for the provider. Add one. Then update `bootstrap()` to stash the locally-created provider on the instance, and update `teardown()` to shut it down and clear the field. + +- [ ] **Step 1: Add `_tracer_provider` field to the dataclass** + +Current dataclass body (lines 76-80): + +```python +@dataclasses.dataclass(kw_only=True, slots=True, frozen=True) +class OpenTelemetryInstrument(BaseInstrument): + bootstrap_config: OpentelemetryConfig + not_ready_message = "opentelemetry_endpoint is empty and opentelemetry_log_traces is False" + missing_dependency_message = "opentelemetry is not installed" +``` + +Replace with: + +```python +@dataclasses.dataclass(kw_only=True, slots=True, frozen=True) +class OpenTelemetryInstrument(BaseInstrument): + bootstrap_config: OpentelemetryConfig + not_ready_message = "opentelemetry_endpoint is empty and opentelemetry_log_traces is False" + missing_dependency_message = "opentelemetry is not installed" + _tracer_provider: "TracerProvider | None" = dataclasses.field( + default_factory=lambda: None, init=False, repr=False, compare=False + ) +``` + +Notes: +- The string annotation `"TracerProvider | None"` is a forward reference. `TracerProvider` is imported conditionally inside `if import_checker.is_opentelemetry_installed:` at module top (line 17), so the string form avoids NameError if opentelemetry isn't installed. +- `default_factory=lambda: None` matches the `LoggingInstrument._logger_factory` precedent. Do not use `default=None` — the existing codebase normalized on `default_factory=lambda: None` for these fields (see commit `8db9be3`). +- `init=False, repr=False, compare=False` matches the precedent: this is internal runtime state, not part of the instrument's identity. + +- [ ] **Step 2: Stash the provider in `bootstrap()`** + +Current `bootstrap()` ends at line 128 with the instrumentor loop. Just before that loop, after `set_tracer_provider(tracer_provider)` (line 107) and the span-processor setup (lines 108-120), add the stash. The simplest placement: right after `set_tracer_provider(tracer_provider)`. + +Current code around line 106-107: + +```python + tracer_provider = TracerProvider(resource=resource) + set_tracer_provider(tracer_provider) +``` + +Replace with: + +```python + tracer_provider = TracerProvider(resource=resource) + set_tracer_provider(tracer_provider) + object.__setattr__(self, "_tracer_provider", tracer_provider) +``` + +This makes the locally-constructed provider reachable from `teardown()`. + +- [ ] **Step 3: Shut down the provider in `teardown()`** + +Current `teardown()` (lines 130-135): + +```python + def teardown(self) -> None: + for one_instrumentor in self.bootstrap_config.opentelemetry_instrumentors: + if isinstance(one_instrumentor, InstrumentorWithParams): + one_instrumentor.instrumentor.uninstrument(**one_instrumentor.additional_params) + else: + one_instrumentor.uninstrument() +``` + +Replace with: + +```python + def teardown(self) -> None: + for one_instrumentor in self.bootstrap_config.opentelemetry_instrumentors: + if isinstance(one_instrumentor, InstrumentorWithParams): + one_instrumentor.instrumentor.uninstrument(**one_instrumentor.additional_params) + else: + one_instrumentor.uninstrument() + if self._tracer_provider is not None: + self._tracer_provider.shutdown() + object.__setattr__(self, "_tracer_provider", None) +``` + +Order matters: uninstrument first (so instrumentors release any references to the provider), then shutdown (so the provider flushes buffered spans and disposes of processors). + +- [ ] **Step 4: Run the previously-failing test and verify it PASSES** + +```bash +just test -- tests/instruments/test_opentelemetry_instrument.py::test_opentelemetry_instrument_teardown_shuts_down_tracer_provider -v +``` + +Expected: **PASS**. + +- [ ] **Step 5: Run the full OTel test file** + +```bash +just test -- tests/instruments/test_opentelemetry_instrument.py -v +``` + +Expected: all three tests PASS — the two existing tests should be unaffected by the change. + +- [ ] **Step 6: Run the full test suite** + +```bash +just test +``` + +Expected: all tests PASS. The change touches a hot code path used by every framework bootstrapper's OTel integration (FastAPI, Litestar, FastStream, Free) — the bootstrapper-level tests will all exercise the new shutdown call. Watch for any unexpected failures in `test_fastapi_bootstrap.py`, `test_litestar_bootstrap.py`, `test_faststream_bootstrap.py`, `test_free_bootstrap.py`. + +If any pre-existing test fails because of double-shutdown (a teardown getting called twice somewhere — Litestar registers `self.teardown` on `on_shutdown`), that's CRIT-3 territory and will be handled in PR3. Note the failure in your report but do not attempt to fix CRIT-3 in this PR. If you see a `RuntimeError: TracerProvider has already been shut down` (or similar) in a test that wasn't failing before, that's the signal — flag it and continue. + +- [ ] **Step 7: Run lint** + +```bash +just lint +``` + +Expected: no errors. The `# noqa: SLF001` in the test handles private-member-access; everything else follows existing patterns. + +- [ ] **Step 8: Commit** + +Stage both modified files explicitly: + +```bash +git add lite_bootstrap/instruments/opentelemetry_instrument.py tests/instruments/test_opentelemetry_instrument.py +git commit -m "$(cat <<'EOF' +fix: shut down TracerProvider in OpenTelemetryInstrument.teardown + +The instrument's bootstrap() created a TracerProvider, registered span +processors against it, and called set_tracer_provider — but never stored +a reference. teardown() only uninstrumented the instrumentors; the +provider was never shut down. Spans buffered in BatchSpanProcessor were +lost on graceful shutdown. + +Stash the provider on the instrument via object.__setattr__ (mirroring +the LoggingInstrument._logger_factory pattern for runtime state on a +frozen dataclass), shut it down after the uninstrument loop, reset the +field to None so a subsequent bootstrap starts clean. + +Regression test asserts shutdown is called exactly once on the stored +provider and the field is reset. + +Closes CRIT-2, TEST-2 from the audit. +EOF +)" +``` + +Expected: commit succeeds. + +--- + +## Task 4: Push and open PR + +**Files:** (no files; git push + gh) + +- [ ] **Step 1: Push the branch** + +```bash +git push -u origin fix/crit-2-otel-shutdown +``` + +Expected: branch published. + +- [ ] **Step 2: Open the PR** + +```bash +gh pr create --title "fix: shut down TracerProvider in OpenTelemetryInstrument.teardown" --body "$(cat <<'EOF' +## Summary +- `OpenTelemetryInstrument.bootstrap()` now stashes the `TracerProvider` it created on the instance (via `object.__setattr__`, matching `LoggingInstrument._logger_factory`). +- `teardown()` calls `self._tracer_provider.shutdown()` after the existing instrumentor uninstrument loop, then resets the field to `None`. Buffered spans in `BatchSpanProcessor` are now flushed on graceful shutdown. +- New regression test `test_opentelemetry_instrument_teardown_shuts_down_tracer_provider` patches `shutdown` on the stored provider and asserts it's invoked exactly once. Test fails on `main`, passes on this branch. + +Closes CRIT-2 and TEST-2 from an internal audit of the codebase. + +## Test plan +- [x] `just test -- tests/instruments/test_opentelemetry_instrument.py -v` — three tests pass. +- [x] `just test` — full suite passes. +- [x] `just lint` — clean. +- [ ] Reviewer: confirm the field placement and `object.__setattr__` usage match the `LoggingInstrument._logger_factory` precedent. +EOF +)" +``` + +Expected: PR created; URL printed. + +--- + +## Self-Review + +**Spec coverage check** against the sequencing spec (PR2 section) and audit (CRIT-2, TEST-2): + +| Spec item | Task | +|-----------|------| +| Store `TracerProvider` on instance via `object.__setattr__` | Task 3, Step 2 | +| Declare `_tracer_provider: "TracerProvider \| None"` init-false field | Task 3, Step 1 | +| Call `shutdown()` in `teardown()` after `uninstrument()` loop | Task 3, Step 3 | +| Reset field to `None` after shutdown | Task 3, Step 3 | +| Add test asserting `shutdown` is invoked | Task 2, Step 1 | +| Test uses `patch.object` (mock approach, not real BatchSpanProcessor) | Task 2, Step 1 | +| Branch name `fix/crit-2-otel-shutdown` | Task 1, Step 1 | +| Verification: `just test` + `just lint` pass | Task 3, Steps 6-7 | + +All spec items covered. No placeholders. Field-name (`_tracer_provider`) and assertion-text consistency holds across Task 2 (test expectations) and Task 3 (implementation). + +**Cross-PR awareness:** Task 3 Step 6 notes that pre-existing tests could surface CRIT-3 (double-teardown) once the shutdown call exists. That failure mode is explicitly out of scope for this PR — flag and continue, do not attempt to fix it here. + + +--- + +# PR3: Idempotent Teardown Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Make `BaseBootstrapper.teardown()` idempotent so a second call returns immediately without re-tearing-down instruments. Concretely fixes the Litestar and FastStream cases where the bootstrapper registers `self.teardown` on a framework shutdown hook *and* gets called manually — today, second teardown re-invokes every instrument's `teardown()`, which is unsafe because many instruments aren't idempotent themselves. + +While we're touching teardown robustness, also bundle the `try/finally` follow-up that PR2's code review flagged: in both `OpenTelemetryInstrument` and `LoggingInstrument`, a raise inside the shutdown call leaves the cached reference non-None. Wrap both in `try/finally` so the cached reference is reset regardless of whether shutdown succeeded. + +**Architecture:** Three small behavioral changes — one perimeter guard (the bootstrapper-level idempotency check) and two defense-in-depth changes (instrument-level cleanup robustness). Three new regression tests, one per change. No new files; no API changes. + +**Tech Stack:** Python 3.10+, pytest, `unittest.mock.patch.object`, `unittest.mock.MagicMock`. + +**Parent spec:** `docs/superpowers/specs/2026-05-31-audit-implementation-sequencing.md` (PR3 section). +**Parent audit:** `docs/superpowers/specs/2026-05-31-bug-refactor-audit.md` (CRIT-3, TEST-3). +**Follow-up from:** PR2 code review (https://github.com/modern-python/lite-bootstrap/pull/90 — "Known follow-up" section in the PR body). + +--- + +## File Structure + +Three production files modified; three test files modified. + +- Modify: `lite_bootstrap/bootstrappers/base.py:82-93` — add idempotency guard at top of `BaseBootstrapper.teardown()`. +- Modify: `lite_bootstrap/instruments/opentelemetry_instrument.py` — wrap `self._tracer_provider.shutdown()` in `try/finally` so the field resets even on raise. +- Modify: `lite_bootstrap/instruments/logging_instrument.py` — wrap `self._logger_factory.close_handlers()` in `try/finally` so the field resets even on raise. +- Modify: `tests/test_free_bootstrap.py` — add idempotency test using mocked instruments. +- Modify: `tests/instruments/test_opentelemetry_instrument.py` — add `try/finally` regression test using `side_effect=RuntimeError`. +- Modify: `tests/instruments/test_logging_instrument.py` — add `try/finally` regression test using `side_effect=RuntimeError`. + +--- + +## Locked decisions + +- **Bundling:** All three changes ship in one commit/PR. They're all "teardown robustness" with high cohesion; PR2's reviewer explicitly suggested doing the `try/finally` work alongside CRIT-3. +- **Test placement:** Idempotency test goes in `test_free_bootstrap.py` (matches sequencing-spec decision; co-located with existing `test_teardown_error_isolation` and `test_teardown_error_aggregates_all_failures`). Instrument-level tests go in the per-instrument test files. +- **Deferred:** A Litestar-specific test exercising the manual-teardown + `on_shutdown`-fires-teardown path is *not* included. The `test_free_bootstrap.py` idempotency test pins the contract; the Litestar path is downstream of that contract. + +--- + +## Task 1: Create branch + +**Files:** (no files; git only) + +- [ ] **Step 1: Branch off `main`** + +```bash +git checkout main +git pull --ff-only origin main +git checkout -b fix/crit-3-idempotent-teardown +``` + +Expected: `Switched to a new branch 'fix/crit-3-idempotent-teardown'`. + +If PR2 (`fix/crit-2-otel-shutdown`) has not yet merged into `main`, that's a real problem for this PR — Task 4 (the OTel `try/finally`) depends on the `_tracer_provider` field that PR2 introduced. **Verify before starting that `lite_bootstrap/instruments/opentelemetry_instrument.py` contains the `_tracer_provider` field declaration.** If it doesn't, stop and report — PR2 must merge first. + +```bash +grep -n "_tracer_provider" lite_bootstrap/instruments/opentelemetry_instrument.py +``` + +Expected output: at least three matches (field declaration, `bootstrap()` setattr, `teardown()` reference). If zero matches, PR2 is not merged — stop. + +--- + +## Task 2: Add three failing regression tests + +Add all three failing tests before any production-code change, so the TDD red→green transition is visible per test. + +### Test A: `BaseBootstrapper.teardown()` is idempotent + +**File:** `tests/test_free_bootstrap.py` + +The existing file already imports `MagicMock` from `unittest.mock`. No new imports needed. + +- [ ] **Step 1: Append new test to `tests/test_free_bootstrap.py`** + +Add at the end of the file (after `test_free_bootstrapper_with_missing_instrument_dependency`): + +```python +def test_teardown_is_idempotent(free_bootstrapper_config: FreeBootstrapperConfig) -> None: + bootstrapper = FreeBootstrapper(bootstrap_config=free_bootstrapper_config) + bootstrapper.bootstrap() + + first = MagicMock() + second = MagicMock() + bootstrapper.instruments = [first, second] + + bootstrapper.teardown() + bootstrapper.teardown() + + first.teardown.assert_called_once() + second.teardown.assert_called_once() + assert not bootstrapper.is_bootstrapped +``` + +The contract: after the first `teardown()`, `is_bootstrapped` is False; the second call must observe that and return immediately, so each mocked instrument's `teardown()` is called exactly once across both bootstrapper-level calls. + +- [ ] **Step 2: Run the test and verify it FAILS** + +```bash +just test -- tests/test_free_bootstrap.py::test_teardown_is_idempotent -v +``` + +Expected: **FAIL** because the current `BaseBootstrapper.teardown()` doesn't check `is_bootstrapped`. Both mocked instruments will have `teardown()` called twice. The assertion `first.teardown.assert_called_once()` raises: + +``` +AssertionError: Expected 'teardown' to have been called once. Called 2 times. +``` + +### Test B: `OpenTelemetryInstrument.teardown()` resets `_tracer_provider` when `shutdown()` raises + +**File:** `tests/instruments/test_opentelemetry_instrument.py` + +The file (after PR2's merge) already imports `patch` from `unittest.mock`. Need to add `pytest` import. + +- [ ] **Step 3: Add `pytest` to the test file imports** + +Current top of file: + +```python +from unittest.mock import patch + +from lite_bootstrap.instruments.opentelemetry_instrument import ( + InstrumentorWithParams, + OpentelemetryConfig, + OpenTelemetryInstrument, +) +from tests.conftest import CustomInstrumentor +``` + +Replace with: + +```python +from unittest.mock import patch + +import pytest + +from lite_bootstrap.instruments.opentelemetry_instrument import ( + InstrumentorWithParams, + OpentelemetryConfig, + OpenTelemetryInstrument, +) +from tests.conftest import CustomInstrumentor +``` + +`pytest` goes in the third-party group, between stdlib and first-party. + +- [ ] **Step 4: Append new test to the file** + +Append at the end: + +```python +def test_opentelemetry_instrument_teardown_resets_tracer_provider_when_shutdown_raises() -> None: + instrument = OpenTelemetryInstrument( + bootstrap_config=OpentelemetryConfig(opentelemetry_log_traces=True), + ) + instrument.bootstrap() + tracer_provider = instrument._tracer_provider # noqa: SLF001 + assert tracer_provider is not None + + with patch.object(tracer_provider, "shutdown", side_effect=RuntimeError("boom")): + with pytest.raises(RuntimeError, match="boom"): + instrument.teardown() + + assert instrument._tracer_provider is None # noqa: SLF001 +``` + +Contract: even if `shutdown()` raises, the cached reference must be cleared so the instrument can be re-bootstrapped cleanly. + +- [ ] **Step 5: Run the test and verify it FAILS** + +```bash +just test -- tests/instruments/test_opentelemetry_instrument.py::test_opentelemetry_instrument_teardown_resets_tracer_provider_when_shutdown_raises -v +``` + +Expected: **FAIL** because the current `teardown()` has `shutdown()` then `setattr(None)` as two sequential statements (no `try/finally`); the RuntimeError propagates, the reset never runs, and the final `assert instrument._tracer_provider is None` fails. + +### Test C: `LoggingInstrument.teardown()` resets `_logger_factory` when `close_handlers()` raises + +**File:** `tests/instruments/test_logging_instrument.py` + +Need to add `pytest` and `patch` imports. + +- [ ] **Step 6: Add `patch` and `pytest` to the test file imports** + +Current top: + +```python +import logging +from io import StringIO + +import structlog +from opentelemetry.trace import get_tracer + +from lite_bootstrap.instruments.logging_instrument import LoggingConfig, LoggingInstrument, MemoryLoggerFactory +from lite_bootstrap.instruments.opentelemetry_instrument import OpentelemetryConfig, OpenTelemetryInstrument +from tests.conftest import LoggingMock +``` + +Replace with: + +```python +import logging +from io import StringIO +from unittest.mock import patch + +import pytest +import structlog +from opentelemetry.trace import get_tracer + +from lite_bootstrap.instruments.logging_instrument import LoggingConfig, LoggingInstrument, MemoryLoggerFactory +from lite_bootstrap.instruments.opentelemetry_instrument import OpentelemetryConfig, OpenTelemetryInstrument +from tests.conftest import LoggingMock +``` + +- [ ] **Step 7: Append new test to the file** + +Append at the end: + +```python +def test_logging_instrument_teardown_resets_factory_when_close_handlers_raises() -> None: + instrument = LoggingInstrument( + bootstrap_config=LoggingConfig(logging_buffer_capacity=0), + ) + instrument.bootstrap() + factory = instrument._logger_factory # noqa: SLF001 + assert factory is not None + + with patch.object(factory, "close_handlers", side_effect=RuntimeError("boom")): + with pytest.raises(RuntimeError, match="boom"): + instrument.teardown() + + assert instrument._logger_factory is None # noqa: SLF001 +``` + +Contract: same shape as Test B — the cached factory reference must be cleared even when `close_handlers()` raises. + +- [ ] **Step 8: Run the test and verify it FAILS** + +```bash +just test -- tests/instruments/test_logging_instrument.py::test_logging_instrument_teardown_resets_factory_when_close_handlers_raises -v +``` + +Expected: **FAIL** because the current `LoggingInstrument.teardown()` has `close_handlers()` then `setattr(None)` as two sequential statements; the RuntimeError propagates, the reset never runs, the final `assert instrument._logger_factory is None` fails. + +--- + +## Task 3: Implement the three fixes + +### Fix 1: Idempotency guard on `BaseBootstrapper.teardown()` + +- [ ] **Step 1: Add guard at top of `BaseBootstrapper.teardown()`** + +**File:** `lite_bootstrap/bootstrappers/base.py:82-93` + +Current code: + +```python + def teardown(self) -> None: + self.is_bootstrapped = False + errors: list[tuple[str, BaseException]] = [] + for one_instrument in reversed(self.instruments): + try: + one_instrument.teardown() + except Exception as e: # noqa: BLE001, PERF203 + name = type(one_instrument).__name__ + logger.warning(f"Error tearing down {name}: {e}") + errors.append((name, e)) + if errors: + raise TeardownError(errors) from errors[0][1] +``` + +Replace with: + +```python + def teardown(self) -> None: + if not self.is_bootstrapped: + return + self.is_bootstrapped = False + errors: list[tuple[str, BaseException]] = [] + for one_instrument in reversed(self.instruments): + try: + one_instrument.teardown() + except Exception as e: # noqa: BLE001, PERF203 + name = type(one_instrument).__name__ + logger.warning(f"Error tearing down {name}: {e}") + errors.append((name, e)) + if errors: + raise TeardownError(errors) from errors[0][1] +``` + +Only the two-line guard at top is added. Everything else is byte-identical. + +### Fix 2: `try/finally` in `OpenTelemetryInstrument.teardown()` + +- [ ] **Step 2: Wrap `shutdown()` in `try/finally`** + +**File:** `lite_bootstrap/instruments/opentelemetry_instrument.py` + +Current `teardown()` (after PR2): + +```python + def teardown(self) -> None: + for one_instrumentor in self.bootstrap_config.opentelemetry_instrumentors: + if isinstance(one_instrumentor, InstrumentorWithParams): + one_instrumentor.instrumentor.uninstrument(**one_instrumentor.additional_params) + else: + one_instrumentor.uninstrument() + if self._tracer_provider is not None: + self._tracer_provider.shutdown() + object.__setattr__(self, "_tracer_provider", None) +``` + +Replace the last block (the `if self._tracer_provider is not None:` block) with: + +```python + if self._tracer_provider is not None: + try: + self._tracer_provider.shutdown() + finally: + object.__setattr__(self, "_tracer_provider", None) +``` + +### Fix 3: `try/finally` in `LoggingInstrument.teardown()` + +- [ ] **Step 3: Wrap `close_handlers()` in `try/finally`** + +**File:** `lite_bootstrap/instruments/logging_instrument.py:202-211` + +Current code: + +```python + def teardown(self) -> None: + structlog.reset_defaults() + root_logger = logging.getLogger() + for h in root_logger.handlers[:]: + root_logger.removeHandler(h) + h.close() + root_logger.setLevel(logging.WARNING) + if self._logger_factory is not None: + self._logger_factory.close_handlers() + object.__setattr__(self, "_logger_factory", None) +``` + +Replace the last block with: + +```python + if self._logger_factory is not None: + try: + self._logger_factory.close_handlers() + finally: + object.__setattr__(self, "_logger_factory", None) +``` + +### Verification + +- [ ] **Step 4: Run the three new tests and verify each PASSES** + +```bash +just test -- tests/test_free_bootstrap.py::test_teardown_is_idempotent tests/instruments/test_opentelemetry_instrument.py::test_opentelemetry_instrument_teardown_resets_tracer_provider_when_shutdown_raises tests/instruments/test_logging_instrument.py::test_logging_instrument_teardown_resets_factory_when_close_handlers_raises -v +``` + +Expected: all three PASS. + +- [ ] **Step 5: Run the full test suite** + +```bash +just test +``` + +Expected: all tests PASS. The idempotency guard is additive and shouldn't change any existing behavior — existing tests call `teardown()` exactly once and that path is unchanged. The two `try/finally` changes are non-observable to callers who don't trigger the exception path. + +Watch carefully for failures in the existing teardown-error tests in `test_free_bootstrap.py` (`test_teardown_error_isolation`, `test_teardown_error_aggregates_all_failures`) — these tests deliberately make instruments raise during teardown and assert on the resulting `TeardownError`. The guard doesn't affect them because they only call `teardown()` once. Confirm they still pass. + +- [ ] **Step 6: Run lint** + +```bash +just lint +``` + +Expected: no errors. + +- [ ] **Step 7: Commit** + +Stage the six modified files explicitly: + +```bash +git add \ + lite_bootstrap/bootstrappers/base.py \ + lite_bootstrap/instruments/opentelemetry_instrument.py \ + lite_bootstrap/instruments/logging_instrument.py \ + tests/test_free_bootstrap.py \ + tests/instruments/test_opentelemetry_instrument.py \ + tests/instruments/test_logging_instrument.py +git commit -m "$(cat <<'EOF' +fix: make teardown idempotent and exception-safe + +BaseBootstrapper.teardown() now returns immediately when not bootstrapped. +This fixes Litestar and FastStream, both of which register self.teardown +on a framework shutdown hook while also being callable manually — a user +who explicitly calls teardown() would otherwise re-invoke every instrument's +teardown when the framework shutdown fired second. Most instruments are +not idempotent themselves. + +Also wrap the shutdown calls inside OpenTelemetryInstrument and +LoggingInstrument in try/finally so the cached _tracer_provider / +_logger_factory references are reset even when shutdown raises. Without +this, a failed shutdown leaves the instrument in a state where a second +bootstrap reuses a stale reference. The bootstrapper-level guard +prevents the immediate symptom but doesn't help when instruments are +used standalone. + +Regression tests: +- test_teardown_is_idempotent: bootstrap, teardown twice, assert each + instrument's teardown was called exactly once. +- test_opentelemetry_instrument_teardown_resets_tracer_provider_when_shutdown_raises: + patch shutdown to raise, assert field resets to None. +- test_logging_instrument_teardown_resets_factory_when_close_handlers_raises: + patch close_handlers to raise, assert field resets to None. + +Closes CRIT-3 and TEST-3 from the audit. Resolves the try/finally +follow-up flagged in PR #90's code review. +EOF +)" +``` + +--- + +## Task 4: Push and open PR + +**Files:** (no files; git push + gh) + +- [ ] **Step 1: Push the branch** + +```bash +git push -u origin fix/crit-3-idempotent-teardown +``` + +- [ ] **Step 2: Open the PR** + +```bash +gh pr create --title "fix: make teardown idempotent and exception-safe" --body "$(cat <<'EOF' +## Summary +- `BaseBootstrapper.teardown()` now returns immediately when `not self.is_bootstrapped`. Fixes the Litestar/FastStream case where manual teardown + framework shutdown hook both fire, double-tearing-down instruments (many of which aren't idempotent). +- `OpenTelemetryInstrument.teardown()` and `LoggingInstrument.teardown()` wrap their shutdown calls in `try/finally` so the cached internal-state references reset even when shutdown raises. Resolves the follow-up flagged in PR #90's code review. + +Three regression tests added — one per fix — all fail on `main` and pass on this branch. + +Closes CRIT-3 and TEST-3 from an internal audit of the codebase. + +## Test plan +- [x] `just test -- tests/test_free_bootstrap.py -v` — all teardown tests pass. +- [x] `just test -- tests/instruments/test_opentelemetry_instrument.py -v` — all OTel tests pass. +- [x] `just test -- tests/instruments/test_logging_instrument.py -v` — all logging tests pass. +- [x] `just test` — full suite passes. +- [x] `just lint` — clean. +- [ ] Reviewer: confirm the bootstrapper guard placement (top of teardown, before `is_bootstrapped = False`) — order matters so the second call observes `is_bootstrapped` as `False` from the first call and returns immediately. + +🤖 Generated with [Claude Code](https://claude.com/claude-code) +EOF +)" +``` + +--- + +## Self-Review + +**Spec coverage check** against the sequencing spec (PR3 section), audit (CRIT-3, TEST-3), and PR #90's review follow-up: + +| Spec item | Task | +|-----------|------| +| Guard at top of `BaseBootstrapper.teardown()` (`if not self.is_bootstrapped: return`) | Task 3, Step 1 | +| Test: bootstrap → teardown → teardown again, assert no double-invoke | Task 2 Test A (Steps 1-2) | +| Test placement in `test_free_bootstrap.py` | Task 2 Test A, Step 1 | +| Branch name `fix/crit-3-idempotent-teardown` | Task 1, Step 1 | +| OTel `try/finally` (follow-up from PR2 review) | Task 3, Step 2 | +| Logging `try/finally` (follow-up from PR2 review) | Task 3, Step 3 | +| OTel regression test for shutdown-raises | Task 2 Test B (Steps 3-5) | +| Logging regression test for close_handlers-raises | Task 2 Test C (Steps 6-8) | +| Verification: `just test` + `just lint` clean | Task 3, Steps 5-6 | + +All spec items covered. No placeholders. Field-name consistency holds (`_tracer_provider`, `_logger_factory`, `is_bootstrapped`) across tests and implementations. Each `try/finally` uses the same shape: `try: ; finally: object.__setattr__(self, "", None)`. + +**Deferred (not in this PR):** +- Litestar-specific test exercising the manual-teardown + `on_shutdown` path. +- FastStream-specific test of the same pattern. +- Adding a `try/finally` review across other instruments (none currently store cached internal state that needs resetting on teardown). + + +--- + +# PR4: Sentry `skip_sentry` Leak Fix + Dead `is_X_installed` Conjuncts Cleanup + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Ship two small audit cleanups in one PR: + +- **DES-4:** Add `"skip_sentry"` to `IGNORED_STRUCTLOG_ATTRIBUTES` so the flag stops leaking into Sentry's `contexts.structlog` when set to falsy (the function already suppresses the event for truthy values, but doesn't strip the field for falsy ones). +- **DES-5:** Delete the dead `and import_checker.is_X_installed` conjuncts from four instruments' `is_ready()` methods. They're provably unreachable: `_register_or_skip` runs `check_dependencies()` before instantiating the instrument; if `check_dependencies()` returns False, `is_ready()` is never called. + +**Architecture:** Five files modified — four production deletions/additions and one test case. No new abstractions. No API changes. + +**Tech Stack:** Python 3.10+, pytest parametrized tests, sentry_sdk types. + +**Parent spec:** `docs/superpowers/specs/2026-05-31-audit-implementation-sequencing.md` (PR4 section). +**Parent audit:** `docs/superpowers/specs/2026-05-31-bug-refactor-audit.md` (DES-4, DES-5). + +--- + +## File Structure + +Four production files modified; one test file modified. + +- Modify: `lite_bootstrap/instruments/sentry_instrument.py` — add `"skip_sentry"` to `IGNORED_STRUCTLOG_ATTRIBUTES` (DES-4); drop dead conjunct from `SentryInstrument.is_ready()` (DES-5). +- Modify: `lite_bootstrap/instruments/logging_instrument.py:139-140` — drop dead conjunct from `LoggingInstrument.is_ready()` (DES-5). +- Modify: `lite_bootstrap/instruments/opentelemetry_instrument.py:82-86` — drop dead conjunct from `OpenTelemetryInstrument.is_ready()` (DES-5). +- Modify: `lite_bootstrap/instruments/pyroscope_instrument.py:28-29` — drop dead conjunct from `PyroscopeInstrument.is_ready()` (DES-5). +- Modify: `tests/instruments/test_sentry_instrument.py` — add parametrize case to `TestSentryEnrichEventFromStructlog::test_modify` covering the `skip_sentry=False` case. + +--- + +## Locked decisions + +- **Bundling DES-4 + DES-5:** They're independent in scope but both trivial and both touch instrument files. Reviewing them together is cheaper than two PRs. +- **DES-4 test placement:** Extend the existing `test_modify` parametrize block in `TestSentryEnrichEventFromStructlog`. Same shape as adjacent cases; no new test method. +- **DES-5 testing:** No new tests. Pure dead-code deletion. The existing test suite already exercises the `is_ready()` paths via the framework integration tests; any breakage shows up there. + +--- + +## Task 1: Create branch + +**Files:** (no files; git only) + +- [ ] **Step 1: Branch off `main`** + +```bash +git checkout main +git pull --ff-only origin main +git checkout -b fix/des-4-5-small-cleanups +``` + +Expected: `Switched to a new branch 'fix/des-4-5-small-cleanups'`. + +If PR3 (`fix/crit-3-idempotent-teardown`) has not yet merged, that's fine — PR4 touches different files. Branch from current `main` regardless. + +--- + +## Task 2: Add the failing regression test (DES-4) + +**File:** `tests/instruments/test_sentry_instrument.py` + +The file already has a class `TestSentryEnrichEventFromStructlog` with a parametrized `test_modify` method. Add a third case to its parametrize list that covers the `skip_sentry=False` scenario. + +- [ ] **Step 1: Add the new parametrize case** + +Current `test_modify` (around line 92 of the file) has two cases in its parametrize list. The list looks like: + +```python + @pytest.mark.parametrize( + ("event_before", "event_after"), + [ + ( + {"logentry": {"formatted": '{"event": "event name"}'}, "contexts": {}}, + {"logentry": {"formatted": "event name"}, "contexts": {}}, + ), + ( + { + "logentry": { + "formatted": '{"event": "event name", "timestamp": 1, "level": "error", "logger": "event.logger", "tracing": {}, "foo": "bar"}' # noqa: E501 + }, + "contexts": {}, + }, + { + "logentry": {"formatted": "event name"}, + "contexts": {"structlog": {"foo": "bar"}}, + }, + ), + ], + ) + def test_modify(self, event_before: "sentry_types.Event", event_after: "sentry_types.Event") -> None: + assert enrich_sentry_event_from_structlog_log(event_before, {}) == event_after +``` + +Add a third tuple to the parametrize list, after the existing two cases (preserving trailing comma in the list): + +```python + ( + { + "logentry": { + "formatted": '{"event": "event name", "skip_sentry": false, "foo": "bar"}' + }, + "contexts": {}, + }, + { + "logentry": {"formatted": "event name"}, + "contexts": {"structlog": {"foo": "bar"}}, + }, + ), +``` + +The contract: when a structlog payload contains `skip_sentry=false` (a falsy value that doesn't trigger event suppression), the resulting `contexts.structlog` should contain `{"foo": "bar"}` only — `skip_sentry` should be stripped. + +- [ ] **Step 2: Run the test and verify it FAILS** + +```bash +just test -- 'tests/instruments/test_sentry_instrument.py::TestSentryEnrichEventFromStructlog::test_modify' -v +``` + +Expected: one of the three parametrize cases (the new one) **FAILS** because the current `IGNORED_STRUCTLOG_ATTRIBUTES` set doesn't include `"skip_sentry"`. The actual `contexts.structlog` will be `{"skip_sentry": False, "foo": "bar"}`, which doesn't equal the expected `{"foo": "bar"}`. The other two cases should still PASS. + +If the new case passes, stop and investigate — either the assertion is wrong, or the bug isn't present. + +--- + +## Task 3: Implement all changes + +Five small edits across four production files. Apply them all, run tests, lint, commit. + +### Fix 1 (DES-4): Strip `skip_sentry` from Sentry context + +- [ ] **Step 1: Add `"skip_sentry"` to `IGNORED_STRUCTLOG_ATTRIBUTES`** + +**File:** `lite_bootstrap/instruments/sentry_instrument.py:19-21` + +Current: + +```python +IGNORED_STRUCTLOG_ATTRIBUTES: typing.Final = frozenset( + {"event", "level", "logger", "tracing", "timestamp", "exception"} +) +``` + +Replace with: + +```python +IGNORED_STRUCTLOG_ATTRIBUTES: typing.Final = frozenset( + {"event", "level", "logger", "tracing", "timestamp", "exception", "skip_sentry"} +) +``` + +### Fix 2 (DES-5): Drop dead conjunct from `SentryInstrument.is_ready()` + +- [ ] **Step 2: Simplify `SentryInstrument.is_ready()`** + +**File:** `lite_bootstrap/instruments/sentry_instrument.py:100-101` + +Current: + +```python + def is_ready(self) -> bool: + return bool(self.bootstrap_config.sentry_dsn) and import_checker.is_sentry_installed +``` + +Replace with: + +```python + def is_ready(self) -> bool: + return bool(self.bootstrap_config.sentry_dsn) +``` + +### Fix 3 (DES-5): Drop dead conjunct from `LoggingInstrument.is_ready()` + +- [ ] **Step 3: Simplify `LoggingInstrument.is_ready()`** + +**File:** `lite_bootstrap/instruments/logging_instrument.py:139-140` + +Current: + +```python + def is_ready(self) -> bool: + return self.bootstrap_config.logging_enabled and import_checker.is_structlog_installed +``` + +Replace with: + +```python + def is_ready(self) -> bool: + return self.bootstrap_config.logging_enabled +``` + +### Fix 4 (DES-5): Drop dead conjunct from `OpenTelemetryInstrument.is_ready()` + +- [ ] **Step 4: Simplify `OpenTelemetryInstrument.is_ready()`** + +**File:** `lite_bootstrap/instruments/opentelemetry_instrument.py:82-86` + +Current: + +```python + def is_ready(self) -> bool: + return ( + bool(self.bootstrap_config.opentelemetry_endpoint or self.bootstrap_config.opentelemetry_log_traces) + and import_checker.is_opentelemetry_installed + ) +``` + +Replace with: + +```python + def is_ready(self) -> bool: + return bool(self.bootstrap_config.opentelemetry_endpoint or self.bootstrap_config.opentelemetry_log_traces) +``` + +### Fix 5 (DES-5): Drop dead conjunct from `PyroscopeInstrument.is_ready()` + +- [ ] **Step 5: Simplify `PyroscopeInstrument.is_ready()`** + +**File:** `lite_bootstrap/instruments/pyroscope_instrument.py:28-29` + +Current: + +```python + def is_ready(self) -> bool: + return bool(self.bootstrap_config.pyroscope_endpoint) and import_checker.is_pyroscope_installed +``` + +Replace with: + +```python + def is_ready(self) -> bool: + return bool(self.bootstrap_config.pyroscope_endpoint) +``` + +### Verify and commit + +- [ ] **Step 6: Run the previously-failing test, verify PASS** + +```bash +just test -- 'tests/instruments/test_sentry_instrument.py::TestSentryEnrichEventFromStructlog' -v +``` + +Expected: all three `test_modify` cases PASS. + +- [ ] **Step 7: Run the full test suite** + +```bash +just test +``` + +Expected: all tests PASS. The dead-conjunct deletions are provably no-ops at runtime (the `_register_or_skip` flow in `bootstrappers/base.py` checks `check_dependencies()` before any `is_ready()` call), so existing tests that exercise the missing-dependency path — e.g., `test_fastapi_bootstrapper_with_missing_instrument_dependency`, `test_litestar_bootstrapper_with_missing_instrument_dependency`, `test_free_bootstrapper_with_missing_instrument_dependency` — should still pass unchanged. If any of those fail, stop and investigate: the invariant we're relying on may not hold somewhere. + +- [ ] **Step 8: Run lint** + +```bash +just lint +``` + +Expected: no errors. The four `import_checker` references being removed leave the import statement still used elsewhere in each file (e.g., `bootstrap()` methods), so no unused-import warnings should fire. Confirm. + +If a file ends up with `from lite_bootstrap import import_checker` no longer referenced anywhere, ruff `F401` will flag it. In that case, also remove the import. Most likely candidate is `pyroscope_instrument.py` (verify by reading the file). + +Actually, all four instrument files use `import_checker` in their `check_dependencies()` method as well, so the import will remain needed. Just confirm with `just lint`. + +- [ ] **Step 9: Commit (stage exactly 5 files)** + +```bash +git add \ + lite_bootstrap/instruments/sentry_instrument.py \ + lite_bootstrap/instruments/logging_instrument.py \ + lite_bootstrap/instruments/opentelemetry_instrument.py \ + lite_bootstrap/instruments/pyroscope_instrument.py \ + tests/instruments/test_sentry_instrument.py +git commit -m "$(cat <<'EOF' +fix: strip skip_sentry from Sentry context; drop dead is_X_installed conjuncts + +DES-4: enrich_sentry_event_from_structlog_log was already returning None +(suppressing the event) when skip_sentry was truthy, but for falsy values +(False, missing, "") the flag itself was not stripped from the structlog +payload before it was attached to event["contexts"]["structlog"]. Add +"skip_sentry" to IGNORED_STRUCTLOG_ATTRIBUTES so the field never leaks +into Sentry context noise. Regression test added as a parametrize case +on the existing test_modify. + +DES-5: each affected instrument's is_ready() returned ` and +import_checker.is_X_installed`. The conjunct is provably dead: BaseBootstrapper +calls check_dependencies() in _register_or_skip before instantiating the +instrument, and only invokes is_ready() if check_dependencies() returned True. +Drop the redundant conjunct from SentryInstrument, LoggingInstrument, +OpenTelemetryInstrument, and PyroscopeInstrument. Behavior is unchanged. + +Closes DES-4 and DES-5 from the audit. +EOF +)" +``` + +--- + +## Task 4: Push and open PR + +**Files:** (no files; git push + gh) + +- [ ] **Step 1: Push the branch** + +```bash +git push -u origin fix/des-4-5-small-cleanups +``` + +- [ ] **Step 2: Open the PR** + +```bash +gh pr create --title "fix: strip skip_sentry from Sentry context; drop dead is_X_installed conjuncts" --body "$(cat <<'EOF' +## Summary +Two small audit cleanups bundled: + +- **DES-4 (Sentry):** \`skip_sentry\` was already triggering event suppression when truthy, but for falsy values (False/missing/"") the flag itself wasn't stripped from the structlog payload and ended up as noise in \`event["contexts"]["structlog"]\`. Add \`"skip_sentry"\` to \`IGNORED_STRUCTLOG_ATTRIBUTES\`. Regression test added as a parametrize case on the existing \`test_modify\`. +- **DES-5 (dead conjuncts):** Four instruments' \`is_ready()\` methods ended with \`and import_checker.is_X_installed\`. That conjunct is provably unreachable — \`BaseBootstrapper._register_or_skip\` calls \`check_dependencies()\` first and only invokes \`is_ready()\` if it returned True. Behavior is unchanged. Cleanup makes the lifecycle easier to reason about. + +Closes DES-4 and DES-5 from an internal audit. + +## Test plan +- [x] \`just test -- tests/instruments/test_sentry_instrument.py -v\` — pass. +- [x] \`just test\` — full suite passes. +- [x] \`just lint\` — clean (no unused-import warnings from the conjunct removals). +- [ ] Reviewer: confirm the invariant claim — that \`is_ready()\` is only called after \`check_dependencies()\` has returned True — by reading \`bootstrappers/base.py:44-64\`. + +🤖 Generated with [Claude Code](https://claude.com/claude-code) +EOF +)" +``` + +--- + +## Self-Review + +**Spec coverage check** against the sequencing spec (PR4 section) and audit (DES-4, DES-5): + +| Spec item | Task | +|-----------|------| +| Add `"skip_sentry"` to `IGNORED_STRUCTLOG_ATTRIBUTES` | Task 3, Step 1 | +| Regression test asserting `skip_sentry` doesn't appear in context | Task 2, Step 1 | +| Delete dead conjunct from `SentryInstrument.is_ready()` | Task 3, Step 2 | +| Delete dead conjunct from `LoggingInstrument.is_ready()` | Task 3, Step 3 | +| Delete dead conjunct from `OpenTelemetryInstrument.is_ready()` | Task 3, Step 4 | +| Delete dead conjunct from `PyroscopeInstrument.is_ready()` | Task 3, Step 5 | +| Branch name `fix/des-4-5-small-cleanups` | Task 1, Step 1 | +| Verification: `just test` + `just lint` pass | Task 3, Steps 7-8 | + +All spec items covered. No placeholders. Parametrize-case shape matches adjacent cases byte-for-byte except for the payload values. + +**Deferred:** +- Documenting the lifecycle invariant on `BaseInstrument` (mentioned as "optional" in the sequencing spec) — skip for now to keep the PR focused. Worth noting somewhere later (a `CONTRIBUTING.md`, or class docstrings as part of REF-3). + + +--- + +# PR5: Document and Pin `BaseConfig.from_dict` / `from_object` Semantics + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Document the intentional asymmetry between `BaseConfig.from_dict` and `BaseConfig.from_object` (the audit's DES-3 finding) and pin it with regression tests. No behavior change. The current "skip None" behavior in `from_object` is preserved (locked decision from the sequencing spec). + +**Architecture:** Pure documentation + test PR. Add one-line docstrings to the two classmethods explaining their semantics; add four pinning tests in `tests/test_config.py` that lock in the current contract. No TDD red→green here — the tests pass today; their value is preventing future regressions that "unify" the methods without realizing the asymmetry is intentional. + +**Tech Stack:** Python 3.10+ dataclasses, pytest. + +**Parent spec:** `docs/superpowers/specs/2026-05-31-audit-implementation-sequencing.md` (PR5 section). +**Parent audit:** `docs/superpowers/specs/2026-05-31-bug-refactor-audit.md` (DES-3, TEST-5, TEST-6). + +--- + +## File Structure + +Two files modified. + +- Modify: `lite_bootstrap/instruments/base.py:16-29` — add one-line docstrings to `from_dict` and `from_object`. +- Modify: `tests/test_config.py` — add four pinning tests. + +--- + +## Locked decisions (from sequencing spec) + +- **`from_object` semantics:** Keep current "skip None" behavior. Document it. Pin with tests. Minimal change; preserves any user code that depends on it. +- **No TDD:** This PR documents and pins existing behavior. The new tests are pinning tests, not TDD red→green tests. They will pass before and after the docstring additions. Their value is preventing future regressions, not driving a bug fix. +- **Docstring length:** One short line each. Project style is terse (no docstrings on most code; one-line docstrings on the exception classes). Multi-paragraph docstrings would be inconsistent. + +--- + +## Task 1: Create branch + +**Files:** (no files; git only) + +- [ ] **Step 1: Branch off `main`** + +```bash +git checkout main +git pull --ff-only origin main +git checkout -b fix/des-3-config-method-semantics +``` + +Expected: `Switched to a new branch 'fix/des-3-config-method-semantics'`. + +If PR4 has not yet merged, that's fine — PR5 touches different files. + +--- + +## Task 2: Add docstrings and pinning tests, verify, commit + +### Step 1: Add docstrings to `BaseConfig.from_dict` and `from_object` + +**File:** `lite_bootstrap/instruments/base.py:16-29` + +Current code: + +```python + @classmethod + def from_dict(cls, data: dict[str, typing.Any]) -> typing_extensions.Self: + field_names = {f.name for f in dataclasses.fields(cls)} + return cls(**{k: v for k, v in data.items() if k in field_names}) + + @classmethod + def from_object(cls, obj: object) -> typing_extensions.Self: + prepared_data = {} + field_names = {f.name for f in dataclasses.fields(cls)} + + for field in field_names: + if (value := getattr(obj, field, None)) is not None: + prepared_data[field] = value + return cls(**prepared_data) +``` + +Replace with: + +```python + @classmethod + def from_dict(cls, data: dict[str, typing.Any]) -> typing_extensions.Self: + """Build a config from a dict; unknown keys are silently dropped, explicit None overrides defaults.""" + field_names = {f.name for f in dataclasses.fields(cls)} + return cls(**{k: v for k, v in data.items() if k in field_names}) + + @classmethod + def from_object(cls, obj: object) -> typing_extensions.Self: + """Build a config by merging non-None attributes from obj; None or missing attributes fall back to defaults.""" + field_names = {f.name for f in dataclasses.fields(cls)} + prepared_data = {field: value for field in field_names if (value := getattr(obj, field, None)) is not None} + return cls(**prepared_data) +``` + +Notes: +- Two docstring additions. +- The body of `from_object` is also condensed from a 5-line imperative form to a single comprehension. **Functionally identical.** The walrus-operator-inside-comprehension form is a more idiomatic match for the "filter non-None" intent and matches the dict-comprehension already used by `from_dict`. The condensation is a quality cleanup; verify behavior with the new pinning tests. + +If the reviewer pushes back on the body condensation, the alternative is to leave the body as-is and only add the docstring. The docstring is the spec-required change; the body cleanup is opportunistic. + +### Step 2: Add four pinning tests to `tests/test_config.py` + +**File:** `tests/test_config.py` + +The file currently has two tests (`test_config_from_dict`, `test_config_from_object`). Append the four new tests at the end of the file. + +Current top of file: + +```python +import dataclasses + +from lite_bootstrap import FastAPIConfig +from lite_bootstrap.instruments.base import BaseConfig +from tests.conftest import CustomInstrumentor +``` + +No new imports needed. + +Append at the end of the file: + +```python +def test_from_object_skips_none_attribute() -> None: + @dataclasses.dataclass + class Source: + service_name: str | None = None + service_version: str = "2.0.0" + + config = BaseConfig.from_object(Source()) + assert config.service_name == "micro-service" + assert config.service_version == "2.0.0" + + +def test_from_object_skips_missing_attribute() -> None: + class Source: + pass + + config = BaseConfig.from_object(Source()) + assert config.service_name == "micro-service" + assert config.service_version == "1.0.0" + assert config.service_debug is True + + +def test_from_object_preserves_falsy_values() -> None: + @dataclasses.dataclass + class Source: + service_name: str = "" + service_debug: bool = False + + config = BaseConfig.from_object(Source()) + assert config.service_name == "" + assert config.service_debug is False + + +def test_from_dict_drops_unknown_keys_silently() -> None: + config = BaseConfig.from_dict({"service_name": "test", "unknown_key": "value"}) + assert config.service_name == "test" + assert config.service_version == "1.0.0" +``` + +Contracts pinned: +- `test_from_object_skips_none_attribute` — explicit `None` attribute on source falls back to dataclass default. +- `test_from_object_skips_missing_attribute` — missing attribute on source falls back to dataclass default. +- `test_from_object_preserves_falsy_values` — empty string and `False` are not stripped (they're not `None`). +- `test_from_dict_drops_unknown_keys_silently` — unknown keys don't raise; known keys are honored. + +### Step 3: Run the new tests, verify PASS + +These tests pin existing behavior; they should pass before and after the docstring additions. + +```bash +just test -- tests/test_config.py -v +``` + +Expected: all six tests in `tests/test_config.py` PASS (two pre-existing + four new). + +If any of the four new tests fails, stop and investigate — the audit's claim about `from_object` behavior may be inaccurate, or the docstring body condensation may have introduced a regression. + +### Step 4: Run the full test suite + +```bash +just test +``` + +Expected: all tests PASS. Total should be 88 (84 prior + 4 new). + +### Step 5: Run lint + +```bash +just lint +``` + +Expected: no errors. The dict-comprehension form may trigger ruff's preference for one style or another — confirm. If ruff auto-formats the comprehension, accept the formatting and re-stage. + +### Step 6: Commit + +Stage both modified files explicitly: + +```bash +git add lite_bootstrap/instruments/base.py tests/test_config.py +git commit -m "$(cat <<'EOF' +docs: document and pin BaseConfig.from_dict / from_object semantics + +The two builder classmethods on BaseConfig have intentionally asymmetric +semantics that aren't obvious from reading the code: + +- from_dict includes any key present in the dict (explicit None overrides + the default); unknown keys are silently dropped. +- from_object includes only attributes whose value is not None; attributes + set to None or missing entirely fall back to the dataclass default. + Falsy non-None values (False, "", []) are preserved. + +Add one-line docstrings capturing each method's contract. Condense the +from_object body to a single dict-comprehension matching from_dict's style; +behavior is identical (verified by the new pinning tests). + +Add four pinning tests on top of the two pre-existing tests: +- test_from_object_skips_none_attribute +- test_from_object_skips_missing_attribute +- test_from_object_preserves_falsy_values +- test_from_dict_drops_unknown_keys_silently + +Closes DES-3, TEST-5, TEST-6 from the audit. +EOF +)" +``` + +Expected: commit succeeds. + +--- + +## Task 3: Push and open PR + +**Files:** (no files; git push + gh) + +- [ ] **Step 1: Push the branch** + +```bash +git push -u origin fix/des-3-config-method-semantics +``` + +- [ ] **Step 2: Open the PR** + +```bash +gh pr create --title "docs: document and pin BaseConfig.from_dict / from_object semantics" --body "$(cat <<'EOF' +## Summary +Document the intentional asymmetry between \`BaseConfig.from_dict\` and \`BaseConfig.from_object\` (DES-3 from an internal audit): + +- \`from_dict\` includes any key present in the dict (explicit \`None\` overrides defaults); unknown keys are silently dropped. +- \`from_object\` includes only non-\`None\` attributes (\`None\` or missing falls back to dataclass defaults); falsy non-\`None\` values are preserved. + +Each method gains a one-line docstring capturing its contract. The \`from_object\` body is condensed to a single dict-comprehension matching \`from_dict\`'s style; behavior is identical and locked in by the new pinning tests. + +Four pinning tests added (TEST-5, TEST-6 from the audit): +- \`test_from_object_skips_none_attribute\` +- \`test_from_object_skips_missing_attribute\` +- \`test_from_object_preserves_falsy_values\` +- \`test_from_dict_drops_unknown_keys_silently\` + +These pass before and after the docstring additions — they pin existing behavior to prevent future regressions where someone "unifies" the two methods without realizing the asymmetry is intentional. + +Closes DES-3, TEST-5, TEST-6 from an internal audit. + +## Test plan +- [x] \`just test -- tests/test_config.py -v\` — six tests pass. +- [x] \`just test\` — full suite passes (88 expected). +- [x] \`just lint\` — clean. +- [ ] Reviewer: confirm the \`from_object\` body condensation (5 lines → 1 dict-comprehension) is functionally identical. If you'd rather see the docstring change land without the body cleanup, request a revert. + +🤖 Generated with [Claude Code](https://claude.com/claude-code) +EOF +)" +``` + +--- + +## Self-Review + +**Spec coverage check** against the sequencing spec (PR5 section) and audit (DES-3, TEST-5, TEST-6): + +| Spec item | Task | +|-----------|------| +| Docstring on `from_dict` describing semantics | Task 2, Step 1 | +| Docstring on `from_object` describing semantics | Task 2, Step 1 | +| Test: `from_object` with `None` attribute falls back to default | Task 2, Step 2 (test_from_object_skips_none_attribute) | +| Test: `from_object` with missing attribute falls back to default | Task 2, Step 2 (test_from_object_skips_missing_attribute) | +| Test: `from_object` preserves falsy non-None | Task 2, Step 2 (test_from_object_preserves_falsy_values) | +| Test: `from_dict` drops unknown keys silently | Task 2, Step 2 (test_from_dict_drops_unknown_keys_silently) | +| Branch name `fix/des-3-config-method-semantics` | Task 1, Step 1 | +| Verification: `just test` + `just lint` clean | Task 2, Steps 4-5 | + +All spec items covered. No placeholders. Test names and contracts are consistent with the audit's TEST-5 and TEST-6 descriptions. + +**Caveats noted in PR description:** +- Body condensation in `from_object` is an opportunistic cleanup, not spec-required. If the reviewer prefers a docstring-only change, the body cleanup can be reverted with a one-character edit. + + +--- + +# PR6: Extract OpenTelemetry Service Fields Mixin + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Extract `opentelemetry_service_name` and `opentelemetry_namespace` into a shared mixin dataclass so both `OpentelemetryConfig` and `PyroscopeConfig` inherit from it instead of duplicating the field declarations (the audit's DES-2 finding). Today the two configs declare these fields identically; in the framework configs they survive only because Python's MRO picks one and the defaults happen to match. The mixin makes the shared identity explicit. + +**Architecture:** Pure refactor PR. New tiny dataclass `OpenTelemetryServiceFieldsConfig(BaseConfig)` in `opentelemetry_instrument.py`. Both `OpentelemetryConfig` and `PyroscopeConfig` inherit from it instead of `BaseConfig`. The two duplicate field declarations are removed. No behavior change; existing tests verify MRO continues to resolve correctly across the four framework configs (`FreeBootstrapperConfig`, `FastAPIConfig`, `LitestarConfig`, `FastStreamConfig`) that inherit from both. + +**Tech Stack:** Python 3.10+ dataclasses with `kw_only=True, frozen=True`. + +**Parent spec:** `docs/superpowers/specs/2026-05-31-audit-implementation-sequencing.md` (PR6 section). +**Parent audit:** `docs/superpowers/specs/2026-05-31-bug-refactor-audit.md` (DES-2). + +--- + +## File Structure + +Two files modified. No new files. + +- Modify: `lite_bootstrap/instruments/opentelemetry_instrument.py` — declare `OpenTelemetryServiceFieldsConfig` mixin before `OpentelemetryConfig`; change `OpentelemetryConfig` to inherit from the mixin; remove the two duplicate field declarations. +- Modify: `lite_bootstrap/instruments/pyroscope_instrument.py` — add an import for `OpenTelemetryServiceFieldsConfig`; change `PyroscopeConfig` to inherit from the mixin; remove the two duplicate field declarations. + +--- + +## Locked decisions (from sequencing spec) + +- **Mixin location:** Inline in `opentelemetry_instrument.py`. Fewer files; the mixin is small; `pyroscope_instrument` already imports otel-adjacent symbols (via the SpanProcessor integration in the OTel module). +- **Mixin name:** `OpenTelemetryServiceFieldsConfig`. +- **No new tests:** This is a pure refactor. Existing tests — particularly `test_pyroscope_standalone_config_accepts_otel_fields` in `tests/instruments/test_pyroscope_instrument.py` — already exercise the inheritance path. If those pass, MRO is still working. + +--- + +## Cross-module dependency note + +After this PR, `pyroscope_instrument.py` will import `OpenTelemetryServiceFieldsConfig` from `opentelemetry_instrument.py`. This is a new module-level dependency direction (pyroscope → opentelemetry). Verify there's no circular import: + +- `opentelemetry_instrument.py` imports the `pyroscope` *package* (external) inside an `if import_checker.is_pyroscope_installed:` guard. It does NOT import `lite_bootstrap.instruments.pyroscope_instrument`. +- After PR6, `pyroscope_instrument.py` will import from `lite_bootstrap.instruments.opentelemetry_instrument`. No cycle. + +--- + +## Task 1: Create branch + +**Files:** (no files; git only) + +- [ ] **Step 1: Branch off `main`** + +```bash +git checkout main +git pull --ff-only origin main +git checkout -b fix/des-2-otel-fields-mixin +``` + +Expected: `Switched to a new branch 'fix/des-2-otel-fields-mixin'`. + +--- + +## Task 2: Apply the refactor, verify, commit + +### Step 1: Add the mixin to `opentelemetry_instrument.py` and update `OpentelemetryConfig` + +**File:** `lite_bootstrap/instruments/opentelemetry_instrument.py` + +Current `OpentelemetryConfig` (around lines 35-49): + +```python +@dataclasses.dataclass(kw_only=True, frozen=True) +class OpentelemetryConfig(BaseConfig): + opentelemetry_service_name: str | None = None + opentelemetry_container_name: str | None = dataclasses.field( + default_factory=lambda: os.environ.get("HOSTNAME") or None + ) + opentelemetry_endpoint: str | None = None + opentelemetry_namespace: str | None = None + opentelemetry_insecure: bool = True + opentelemetry_instrumentors: list[typing.Union[InstrumentorWithParams, "BaseInstrumentor"]] = dataclasses.field( + default_factory=list + ) + opentelemetry_log_traces: bool = False + opentelemetry_generate_health_check_spans: bool = True +``` + +Replace with (add the mixin class **before** `OpentelemetryConfig`, then change `OpentelemetryConfig` to inherit from it and remove the two duplicate field declarations): + +```python +@dataclasses.dataclass(kw_only=True, frozen=True) +class OpenTelemetryServiceFieldsConfig(BaseConfig): + opentelemetry_service_name: str | None = None + opentelemetry_namespace: str | None = None + + +@dataclasses.dataclass(kw_only=True, frozen=True) +class OpentelemetryConfig(OpenTelemetryServiceFieldsConfig): + opentelemetry_container_name: str | None = dataclasses.field( + default_factory=lambda: os.environ.get("HOSTNAME") or None + ) + opentelemetry_endpoint: str | None = None + opentelemetry_insecure: bool = True + opentelemetry_instrumentors: list[typing.Union[InstrumentorWithParams, "BaseInstrumentor"]] = dataclasses.field( + default_factory=list + ) + opentelemetry_log_traces: bool = False + opentelemetry_generate_health_check_spans: bool = True +``` + +Two changes: +1. New `OpenTelemetryServiceFieldsConfig` dataclass declared above `OpentelemetryConfig` with `opentelemetry_service_name` and `opentelemetry_namespace`. +2. `OpentelemetryConfig` parent changed from `BaseConfig` to `OpenTelemetryServiceFieldsConfig`; the two fields it used to declare are removed. + +### Step 2: Update `PyroscopeConfig` in `pyroscope_instrument.py` + +**File:** `lite_bootstrap/instruments/pyroscope_instrument.py` + +Current top of file: + +```python +import dataclasses +import typing + +from lite_bootstrap import import_checker +from lite_bootstrap.instruments.base import BaseConfig, BaseInstrument + + +if import_checker.is_pyroscope_installed: + import pyroscope +``` + +Replace with (add `OpenTelemetryServiceFieldsConfig` import; drop the now-unused `BaseConfig` import): + +```python +import dataclasses +import typing + +from lite_bootstrap import import_checker +from lite_bootstrap.instruments.base import BaseInstrument +from lite_bootstrap.instruments.opentelemetry_instrument import OpenTelemetryServiceFieldsConfig + + +if import_checker.is_pyroscope_installed: + import pyroscope +``` + +**Verify before staging:** is `BaseConfig` still used elsewhere in this file? Search with `grep "BaseConfig" lite_bootstrap/instruments/pyroscope_instrument.py`. If the only use was in the `PyroscopeConfig` parent (which is being changed to `OpenTelemetryServiceFieldsConfig`), drop the import. If it's used elsewhere, keep it. + +Based on the current file structure, `BaseConfig` is only used as the `PyroscopeConfig` parent — drop the import. `just lint` will catch any mistake (F401 unused import or F821 undefined name). + +Current `PyroscopeConfig` (around lines 12-19): + +```python +@dataclasses.dataclass(kw_only=True, frozen=True) +class PyroscopeConfig(BaseConfig): + pyroscope_endpoint: str | None = None + pyroscope_sample_rate: int = 100 + pyroscope_tags: dict[str, str] = dataclasses.field(default_factory=dict) + pyroscope_additional_params: dict[str, typing.Any] = dataclasses.field(default_factory=dict) + opentelemetry_service_name: str | None = None + opentelemetry_namespace: str | None = None +``` + +Replace with: + +```python +@dataclasses.dataclass(kw_only=True, frozen=True) +class PyroscopeConfig(OpenTelemetryServiceFieldsConfig): + pyroscope_endpoint: str | None = None + pyroscope_sample_rate: int = 100 + pyroscope_tags: dict[str, str] = dataclasses.field(default_factory=dict) + pyroscope_additional_params: dict[str, typing.Any] = dataclasses.field(default_factory=dict) +``` + +Two changes: +1. Parent changed from `BaseConfig` to `OpenTelemetryServiceFieldsConfig`. +2. The two duplicate field declarations (`opentelemetry_service_name`, `opentelemetry_namespace`) removed. + +### Step 3: Run the OTel + Pyroscope test files + +```bash +just test -- tests/instruments/test_opentelemetry_instrument.py tests/instruments/test_pyroscope_instrument.py -v +``` + +Expected: all tests PASS. Watch specifically for: + +- `test_pyroscope_standalone_config_accepts_otel_fields` — this is THE key test for the mixin's correctness. It constructs `PyroscopeConfig(service_name="fallback", pyroscope_endpoint=..., opentelemetry_service_name="otel-name", opentelemetry_namespace="my-ns")`. If MRO breaks, this test fails first. +- `test_pyroscope_bootstrap_uses_opentelemetry_service_name` and `test_pyroscope_bootstrap_merges_namespace_tag` — these exercise the shared fields via `FreeBootstrapperConfig`. + +### Step 4: Run the full test suite + +```bash +just test +``` + +Expected: all tests PASS (89 total). The framework configs all inherit from both `OpentelemetryConfig` and `PyroscopeConfig`; with the mixin, Python's MRO resolves the shared fields once via diamond inheritance. If any framework config test fails (e.g., `test_fastapi_bootstrap`, `test_litestar_bootstrap`, `test_faststream_bootstrap`, `test_free_bootstrap`), STOP and investigate — MRO interaction is the most likely cause. + +### Step 5: Run lint + +```bash +just lint +``` + +Expected: no errors. Watch for: + +- F401 unused import warnings on the `BaseConfig` import in `pyroscope_instrument.py` (should be already removed per Step 2). +- F401 unused import warnings on the `OpenTelemetryServiceFieldsConfig` import (should be referenced in `class PyroscopeConfig(...)`). +- `ty check` should be clean — the inheritance change is type-correct. + +### Step 6: Commit + +Stage both modified files: + +```bash +git add lite_bootstrap/instruments/opentelemetry_instrument.py lite_bootstrap/instruments/pyroscope_instrument.py +git commit -m "$(cat <<'EOF' +refactor: extract OpenTelemetryServiceFieldsConfig mixin + +opentelemetry_service_name and opentelemetry_namespace were declared +identically on both OpentelemetryConfig and PyroscopeConfig. In the four +framework configs (Free, FastAPI, Litestar, FastStream) that inherit +from both parents, Python's MRO happened to pick one declaration; the +fact that defaults matched is what kept behavior consistent. Without +the mixin, drifting defaults on one side would silently misbehave on +the framework configs. + +Extract OpenTelemetryServiceFieldsConfig(BaseConfig) — a tiny mixin +declaring just those two fields. Both OpentelemetryConfig and +PyroscopeConfig now inherit from it (no longer from BaseConfig +directly). The duplicate declarations are removed. + +PyroscopeConfig's standalone use case (without OpentelemetryConfig in +the MRO) is preserved — exercised by +test_pyroscope_standalone_config_accepts_otel_fields. + +Closes DES-2 from the audit. +EOF +)" +``` + +Expected: commit succeeds. + +--- + +## Task 3: Push and open PR + +**Files:** (no files; git push + gh) + +- [ ] **Step 1: Push the branch** + +```bash +git push -u origin fix/des-2-otel-fields-mixin +``` + +- [ ] **Step 2: Open the PR** + +```bash +gh pr create --title "refactor: extract OpenTelemetryServiceFieldsConfig mixin" --body "$(cat <<'EOF' +## Summary +\`opentelemetry_service_name\` and \`opentelemetry_namespace\` were declared identically on both \`OpentelemetryConfig\` and \`PyroscopeConfig\`. In the four framework configs (Free, FastAPI, Litestar, FastStream) that inherit from both parents, Python's MRO happened to pick one declaration; the fact that defaults matched is what kept behavior consistent. Without the mixin, drifting defaults on one side would silently misbehave on the framework configs. + +Extract \`OpenTelemetryServiceFieldsConfig(BaseConfig)\` — a tiny mixin declaring just those two fields. Both \`OpentelemetryConfig\` and \`PyroscopeConfig\` now inherit from it (no longer from \`BaseConfig\` directly). The duplicate declarations are removed. + +No behavior change. Existing tests verify MRO continues to resolve correctly, especially \`test_pyroscope_standalone_config_accepts_otel_fields\` (the key test for the mixin's correctness) and the framework-level integration tests. + +Closes DES-2 from an internal audit. + +## Test plan +- [x] \`just test -- tests/instruments/test_opentelemetry_instrument.py tests/instruments/test_pyroscope_instrument.py -v\` — pass. +- [x] \`just test\` — full suite 89/89. +- [x] \`just lint\` — clean. +- [ ] Reviewer: confirm the new dependency direction (\`pyroscope_instrument\` → \`opentelemetry_instrument\`) is acceptable and doesn't introduce a circular import (it doesn't — \`opentelemetry_instrument\` imports the \`pyroscope\` package, not \`pyroscope_instrument\`). + +🤖 Generated with [Claude Code](https://claude.com/claude-code) +EOF +)" +``` + +--- + +## Self-Review + +**Spec coverage check** against the sequencing spec (PR6 section) and audit (DES-2): + +| Spec item | Task | +|-----------|------| +| Declare `OpenTelemetryServiceFieldsConfig(BaseConfig)` mixin with the two fields | Task 2, Step 1 | +| Mixin inlined in `opentelemetry_instrument.py` | Task 2, Step 1 | +| `OpentelemetryConfig` inherits from mixin; duplicate fields removed | Task 2, Step 1 | +| `PyroscopeConfig` inherits from mixin (via import); duplicate fields removed | Task 2, Step 2 | +| Verify MRO still works via existing tests (especially `test_pyroscope_standalone_config_accepts_otel_fields`) | Task 2, Steps 3-4 | +| Branch name `fix/des-2-otel-fields-mixin` | Task 1, Step 1 | +| Verification: `just test` + `just lint` clean | Task 2, Steps 4-5 | + +All spec items covered. No placeholders. The mixin's name and the import path in `pyroscope_instrument.py` are consistent. + +**Deferred:** +- Renaming `OpentelemetryConfig` to `OpenTelemetryConfig` (capital `T`) for capitalization consistency with the new mixin name — out of scope; would be a separate naming-cleanup PR with deprecation aliases. + + +--- + +# PR7: Make `BaseInstrument` Generic; Delete Pure-Annotation Subclasses + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Close the audit's DES-1 finding by deleting the four pure type-annotation framework subclasses that exist solely to narrow `bootstrap_config`. Make `BaseInstrument` generic in its config type so the base instruments can be parameterized (`LoggingInstrument(BaseInstrument[LoggingConfig])`); framework subclasses with real behavior keep their existing structure. + +**Architecture revision from the sequencing spec:** The locked decision in the sequencing spec called for "dropping redundant `bootstrap_config:` annotations on kept subclasses." Working through the Python typing implications, this would require a **two-level generic** pattern (each base instrument carries its own bounded `TypeVar` so framework subclasses can re-parameterize). The complexity isn't worth it — the annotations on framework subclasses with real `bootstrap()` overrides are **not** redundant under a single-level-generic design; they provide essential type narrowing for framework-specific field access (e.g., `self.bootstrap_config.application` inside `FastAPICorsInstrument.bootstrap()`). + +This plan implements the **simpler single-level-generic approach**: `BaseInstrument` is generic; each base instrument is concretely parameterized; framework subclasses with real bootstrap code keep their `bootstrap_config: FrameworkConfig` annotations. The deletion target remains the same — only the four pure-annotation subclasses go. This delivers the audit's stated goal (eliminating boilerplate) without the two-level-generic complexity. + +**Tech Stack:** Python 3.10+ generics (`typing.TypeVar`, `typing.Generic`), frozen dataclasses with `slots=True`. + +**Parent spec:** `docs/superpowers/specs/2026-05-31-audit-implementation-sequencing.md` (PR7 section). +**Parent audit:** `docs/superpowers/specs/2026-05-31-bug-refactor-audit.md` (DES-1). + +--- + +## File Structure + +12 files modified. + +**Instruments (9 files — make each base instrument concretely parameterize the new generic `BaseInstrument`):** +- Modify: `lite_bootstrap/instruments/base.py` — `BaseInstrument` becomes generic `[ConfigT]`. +- Modify: `lite_bootstrap/instruments/cors_instrument.py` — `CorsInstrument(BaseInstrument[CorsConfig])`. +- Modify: `lite_bootstrap/instruments/healthchecks_instrument.py` — `HealthChecksInstrument(BaseInstrument[HealthChecksConfig])`. +- Modify: `lite_bootstrap/instruments/logging_instrument.py` — `LoggingInstrument(BaseInstrument[LoggingConfig])`. +- Modify: `lite_bootstrap/instruments/opentelemetry_instrument.py` — `OpenTelemetryInstrument(BaseInstrument[OpentelemetryConfig])`. +- Modify: `lite_bootstrap/instruments/prometheus_instrument.py` — `PrometheusInstrument(BaseInstrument[PrometheusConfig])`. +- Modify: `lite_bootstrap/instruments/pyroscope_instrument.py` — `PyroscopeInstrument(BaseInstrument[PyroscopeConfig])`. +- Modify: `lite_bootstrap/instruments/sentry_instrument.py` — `SentryInstrument(BaseInstrument[SentryConfig])`. +- Modify: `lite_bootstrap/instruments/swagger_instrument.py` — `SwaggerInstrument(BaseInstrument[SwaggerConfig])`. + +In each of the above, the existing `bootstrap_config: ` field declaration is **removed** — it's now provided by the generic parent. + +**Bootstrappers (3 files — delete the 4 pure-annotation subclasses, update `instruments_types` lists to reference base names):** +- Modify: `lite_bootstrap/bootstrappers/fastapi_bootstrapper.py` — delete `FastAPILoggingInstrument` and `FastAPISentryInstrument`; replace their entries in `instruments_types` with `LoggingInstrument` / `SentryInstrument`. +- Modify: `lite_bootstrap/bootstrappers/litestar_bootstrapper.py` — delete `LitestarSentryInstrument`; replace entry with `SentryInstrument`. +- Modify: `lite_bootstrap/bootstrappers/faststream_bootstrapper.py` — delete `FastStreamSentryInstrument`; replace entry with `SentryInstrument`. + +The `FreeBootstrapper` already uses the base instrument names directly — no changes needed there. + +**Framework subclasses with real bootstrap code stay AS-IS** (their `bootstrap_config: ` annotations are still useful for type narrowing). The list (15 subclasses kept): + +- FastAPI: `FastAPICorsInstrument`, `FastAPIHealthChecksInstrument`, `FastAPIOpenTelemetryInstrument`, `FastAPIPrometheusInstrument`, `FastAPISwaggerInstrument`. +- Litestar: `LitestarCorsInstrument`, `LitestarHealthChecksInstrument`, `LitestarLoggingInstrument`, `LitestarOpenTelemetryInstrument`, `LitestarPrometheusInstrument`, `LitestarSwaggerInstrument`. +- FastStream: `FastStreamHealthChecksInstrument`, `FastStreamLoggingInstrument`, `FastStreamOpenTelemetryInstrument`, `FastStreamPrometheusInstrument`. + +--- + +## Locked decisions (revised from sequencing spec) + +- **Single-level generic:** `BaseInstrument` is generic; base instruments concretely parameterize. **Revised** from the sequencing spec's two-level approach. +- **Kept framework subclasses retain `bootstrap_config: ` annotations.** **Revised** from "drop redundant annotations." Under single-level-generic, these are not redundant — they provide essential type narrowing for framework field access. +- **Deletions unchanged:** the four pure-annotation subclasses (`FastAPILoggingInstrument`, `FastAPISentryInstrument`, `LitestarSentryInstrument`, `FastStreamSentryInstrument`) still get deleted. +- **Pyroscope handling:** `PyroscopeInstrument` is used as-is across all bootstrappers (no framework subclass). It still gets parameterized as `PyroscopeInstrument(BaseInstrument[PyroscopeConfig])` for symmetry. +- **No new tests.** This is a behavior-preserving refactor; existing tests verify correctness. + +--- + +## Cross-cutting concerns to verify during implementation + +1. **Generic + slots + frozen dataclass interaction.** Python 3.10 has historically had subtle issues with `@dataclasses.dataclass(slots=True)` combined with `typing.Generic`. If `just test` or `just lint` (ty check) surfaces errors related to slot conflicts or generic metaclass issues on `BaseInstrument`, fall back to dropping `slots=True` from `BaseInstrument` only. Document the change. + +2. **Field declaration removal.** When we remove `bootstrap_config: ` from each base instrument's body, the dataclass machinery should inherit the parent's `bootstrap_config: ConfigT` declaration. Verify by running `just test` after each instrument file change — any broken instantiation will surface immediately. + +3. **`instruments_types` ClassVar typing.** `BaseBootstrapper.instruments_types: typing.ClassVar[list[type[BaseInstrument]]]` — after `BaseInstrument` becomes generic, this becomes `list[type[BaseInstrument[Any]]]` semantically. Should still work because `type[BaseInstrument]` accepts subclasses regardless of parameterization. If `ty check` complains, may need to adjust the annotation. + +4. **`abc.ABC` + `typing.Generic`.** `BaseInstrument(abc.ABC, typing.Generic[ConfigT])` requires the MRO to be: BaseInstrument → ABC → Generic → object. Both `abc.ABC` and `typing.Generic` are designed to work together, but verify the order doesn't break dataclass machinery. + +--- + +## Task 1: Create branch + +**Files:** (no files; git only) + +- [ ] **Step 1: Branch off `main`** + +```bash +git checkout main +git pull --ff-only origin main +git checkout -b refactor/des-1-generic-instruments +``` + +Expected: `Switched to a new branch 'refactor/des-1-generic-instruments'`. + +This is the only PR in the sequence that uses the `refactor/` branch prefix (not `fix/`), because it's the only one that's a pure refactor with no fix component. + +--- + +## Task 2: Make `BaseInstrument` generic + +**File:** `lite_bootstrap/instruments/base.py` + +- [ ] **Step 1: Add the `ConfigT` TypeVar and parameterize `BaseInstrument`** + +Current (lines 1-46): + +```python +import abc +import dataclasses +import typing + +import typing_extensions + + +@dataclasses.dataclass(kw_only=True, slots=True, frozen=True) +class BaseConfig: + ... + + +@dataclasses.dataclass(kw_only=True, slots=True, frozen=True) +class BaseInstrument(abc.ABC): + bootstrap_config: BaseConfig + not_ready_message = "" + missing_dependency_message = "" + + def bootstrap(self) -> None: ... # noqa: B027 + + def teardown(self) -> None: ... # noqa: B027 + + def is_ready(self) -> bool: + return True + + @staticmethod + def check_dependencies() -> bool: + return True +``` + +Replace the `BaseInstrument` block (lines 30-45) with: + +```python +ConfigT = typing.TypeVar("ConfigT", bound=BaseConfig) + + +@dataclasses.dataclass(kw_only=True, slots=True, frozen=True) +class BaseInstrument(abc.ABC, typing.Generic[ConfigT]): + bootstrap_config: ConfigT + not_ready_message = "" + missing_dependency_message = "" + + def bootstrap(self) -> None: ... # noqa: B027 + + def teardown(self) -> None: ... # noqa: B027 + + def is_ready(self) -> bool: + return True + + @staticmethod + def check_dependencies() -> bool: + return True +``` + +Two changes: +1. Add `ConfigT = typing.TypeVar("ConfigT", bound=BaseConfig)` between the two dataclasses. +2. `BaseInstrument` now inherits from `abc.ABC, typing.Generic[ConfigT]`; the `bootstrap_config` field type changes from `BaseConfig` to `ConfigT`. + +- [ ] **Step 2: Quick smoke check** + +```bash +just test -- tests/test_free_bootstrap.py -v +``` + +Expected: PASS. The `FreeBootstrapper` uses the base instruments directly with `FreeBootstrapperConfig`; if generic+slots+frozen has an issue, this test surfaces it first. + +If this test fails with a slots/generic-related error, fall back to dropping `slots=True` from `BaseInstrument`: + +```python +@dataclasses.dataclass(kw_only=True, frozen=True) +class BaseInstrument(abc.ABC, typing.Generic[ConfigT]): + ... +``` + +Re-run the test. Note the change in the eventual commit message if so. + +--- + +## Task 3: Parameterize each base instrument + +For each instrument file, change the class signature from `class XInstrument(BaseInstrument):` to `class XInstrument(BaseInstrument[XConfig]):` and remove the redundant `bootstrap_config: XConfig` field declaration from the class body. + +### Step 1: `lite_bootstrap/instruments/cors_instrument.py` + +Current (around lines 17-25): + +```python +@dataclasses.dataclass(kw_only=True, slots=True, frozen=True) +class CorsInstrument(BaseInstrument): + bootstrap_config: CorsConfig + not_ready_message = "cors_allowed_origins or cors_allowed_origin_regex must be provided" + + def is_ready(self) -> bool: + return bool(self.bootstrap_config.cors_allowed_origins) or bool( + self.bootstrap_config.cors_allowed_origin_regex, + ) +``` + +Replace with: + +```python +@dataclasses.dataclass(kw_only=True, slots=True, frozen=True) +class CorsInstrument(BaseInstrument[CorsConfig]): + not_ready_message = "cors_allowed_origins or cors_allowed_origin_regex must be provided" + + def is_ready(self) -> bool: + return bool(self.bootstrap_config.cors_allowed_origins) or bool( + self.bootstrap_config.cors_allowed_origin_regex, + ) +``` + +Single change: parameterize the base, drop the redundant `bootstrap_config: CorsConfig` field. + +### Step 2: `lite_bootstrap/instruments/healthchecks_instrument.py` + +Current (around lines 29-38): + +```python +@dataclasses.dataclass(kw_only=True, slots=True, frozen=True) +class HealthChecksInstrument(BaseInstrument): + bootstrap_config: HealthChecksConfig + not_ready_message = "health_checks_enabled is False" + + def is_ready(self) -> bool: + return self.bootstrap_config.health_checks_enabled + + def render_health_check_data(self) -> HealthCheckTypedDict: + return self.bootstrap_config.health_check_data +``` + +Replace with: + +```python +@dataclasses.dataclass(kw_only=True, slots=True, frozen=True) +class HealthChecksInstrument(BaseInstrument[HealthChecksConfig]): + not_ready_message = "health_checks_enabled is False" + + def is_ready(self) -> bool: + return self.bootstrap_config.health_checks_enabled + + def render_health_check_data(self) -> HealthCheckTypedDict: + return self.bootstrap_config.health_check_data +``` + +### Step 3: `lite_bootstrap/instruments/logging_instrument.py` + +Current (around lines 117-145): + +```python +@dataclasses.dataclass(kw_only=True, slots=True, frozen=True) +class LoggingInstrument(BaseInstrument): + bootstrap_config: LoggingConfig + not_ready_message = "logging_enabled is False" + missing_dependency_message = "structlog is not installed" + _logger_factory: "MemoryLoggerFactory | None" = dataclasses.field( + default_factory=lambda: None, init=False, repr=False, compare=False + ) + ... +``` + +Change only the class signature and remove the `bootstrap_config: LoggingConfig` line: + +```python +@dataclasses.dataclass(kw_only=True, slots=True, frozen=True) +class LoggingInstrument(BaseInstrument[LoggingConfig]): + not_ready_message = "logging_enabled is False" + missing_dependency_message = "structlog is not installed" + _logger_factory: "MemoryLoggerFactory | None" = dataclasses.field( + default_factory=lambda: None, init=False, repr=False, compare=False + ) + ... +``` + +Everything below the field declarations stays unchanged. + +### Step 4: `lite_bootstrap/instruments/opentelemetry_instrument.py` + +Current (around lines 80-90): + +```python +@dataclasses.dataclass(kw_only=True, slots=True, frozen=True) +class OpenTelemetryInstrument(BaseInstrument): + bootstrap_config: OpentelemetryConfig + not_ready_message = "opentelemetry_endpoint is empty and opentelemetry_log_traces is False" + missing_dependency_message = "opentelemetry is not installed" + _tracer_provider: "TracerProvider | None" = dataclasses.field( + default_factory=lambda: None, init=False, repr=False, compare=False + ) + ... +``` + +Replace the class signature and drop `bootstrap_config: OpentelemetryConfig`: + +```python +@dataclasses.dataclass(kw_only=True, slots=True, frozen=True) +class OpenTelemetryInstrument(BaseInstrument[OpentelemetryConfig]): + not_ready_message = "opentelemetry_endpoint is empty and opentelemetry_log_traces is False" + missing_dependency_message = "opentelemetry is not installed" + _tracer_provider: "TracerProvider | None" = dataclasses.field( + default_factory=lambda: None, init=False, repr=False, compare=False + ) + ... +``` + +Rest of the class unchanged. + +### Step 5: `lite_bootstrap/instruments/prometheus_instrument.py` + +Current (around lines 13-21): + +```python +@dataclasses.dataclass(kw_only=True, slots=True, frozen=True) +class PrometheusInstrument(BaseInstrument): + bootstrap_config: PrometheusConfig + not_ready_message = "prometheus_metrics_path is empty or not valid" + + def is_ready(self) -> bool: + return bool(self.bootstrap_config.prometheus_metrics_path) and is_valid_path( + self.bootstrap_config.prometheus_metrics_path + ) +``` + +Replace with: + +```python +@dataclasses.dataclass(kw_only=True, slots=True, frozen=True) +class PrometheusInstrument(BaseInstrument[PrometheusConfig]): + not_ready_message = "prometheus_metrics_path is empty or not valid" + + def is_ready(self) -> bool: + return bool(self.bootstrap_config.prometheus_metrics_path) and is_valid_path( + self.bootstrap_config.prometheus_metrics_path + ) +``` + +### Step 6: `lite_bootstrap/instruments/pyroscope_instrument.py` + +Current (around lines 21-46, after PR6's mixin landed): + +```python +@dataclasses.dataclass(kw_only=True, slots=True, frozen=True) +class PyroscopeInstrument(BaseInstrument): + bootstrap_config: PyroscopeConfig + not_ready_message = "pyroscope_endpoint is empty" + missing_dependency_message = "pyroscope is not installed" + + def is_ready(self) -> bool: + return bool(self.bootstrap_config.pyroscope_endpoint) + ... +``` + +Replace class signature and drop the field: + +```python +@dataclasses.dataclass(kw_only=True, slots=True, frozen=True) +class PyroscopeInstrument(BaseInstrument[PyroscopeConfig]): + not_ready_message = "pyroscope_endpoint is empty" + missing_dependency_message = "pyroscope is not installed" + + def is_ready(self) -> bool: + return bool(self.bootstrap_config.pyroscope_endpoint) + ... +``` + +Rest unchanged. + +### Step 7: `lite_bootstrap/instruments/sentry_instrument.py` + +Current (around lines 94-105): + +```python +@dataclasses.dataclass(kw_only=True, slots=True, frozen=True) +class SentryInstrument(BaseInstrument): + bootstrap_config: SentryConfig + not_ready_message = "sentry_dsn is empty" + missing_dependency_message = "sentry_sdk is not installed" + + def is_ready(self) -> bool: + return bool(self.bootstrap_config.sentry_dsn) + ... +``` + +Replace: + +```python +@dataclasses.dataclass(kw_only=True, slots=True, frozen=True) +class SentryInstrument(BaseInstrument[SentryConfig]): + not_ready_message = "sentry_dsn is empty" + missing_dependency_message = "sentry_sdk is not installed" + + def is_ready(self) -> bool: + return bool(self.bootstrap_config.sentry_dsn) + ... +``` + +### Step 8: `lite_bootstrap/instruments/swagger_instrument.py` + +Current (around lines 13-16): + +```python +@dataclasses.dataclass(kw_only=True, slots=True, frozen=True) +class SwaggerInstrument(BaseInstrument): + bootstrap_config: SwaggerConfig +``` + +Replace with: + +```python +@dataclasses.dataclass(kw_only=True, slots=True, frozen=True) +class SwaggerInstrument(BaseInstrument[SwaggerConfig]): + pass +``` + +The body becomes empty — replace with `pass`. This is the smallest instrument class. + +### Step 9: Intermediate verification + +After all nine instrument files are updated: + +```bash +just test -- tests/instruments/ -v +just test -- tests/test_free_bootstrap.py -v +``` + +Expected: all pass. The instrument-level tests + the free-bootstrapper test exercise every base instrument directly. If any fail, debug before moving to Task 4. + +--- + +## Task 4: Delete the four pure-annotation framework subclasses + +### Step 1: `lite_bootstrap/bootstrappers/fastapi_bootstrapper.py` + +Locate `FastAPILoggingInstrument` (around lines 111-113): + +```python +@dataclasses.dataclass(kw_only=True, frozen=True) +class FastAPILoggingInstrument(LoggingInstrument): + bootstrap_config: FastAPIConfig +``` + +Delete the entire block. + +Locate `FastAPISentryInstrument` (around lines 141-143): + +```python +@dataclasses.dataclass(kw_only=True, frozen=True) +class FastAPISentryInstrument(SentryInstrument): + bootstrap_config: FastAPIConfig +``` + +Delete the entire block. + +In `FastAPIBootstrapper.instruments_types`: + +```python + instruments_types: typing.ClassVar = [ + FastAPICorsInstrument, + FastAPIOpenTelemetryInstrument, + PyroscopeInstrument, + FastAPISentryInstrument, # ← replace with SentryInstrument + FastAPIHealthChecksInstrument, + FastAPILoggingInstrument, # ← replace with LoggingInstrument + FastAPIPrometheusInstrument, + FastAPISwaggerInstrument, + ] +``` + +Replace `FastAPISentryInstrument` with `SentryInstrument` and `FastAPILoggingInstrument` with `LoggingInstrument`. + +### Step 2: `lite_bootstrap/bootstrappers/litestar_bootstrapper.py` + +Locate `LitestarSentryInstrument` (around lines 199-201): + +```python +@dataclasses.dataclass(kw_only=True, frozen=True) +class LitestarSentryInstrument(SentryInstrument): + bootstrap_config: LitestarConfig +``` + +Delete the entire block. + +In `LitestarBootstrapper.instruments_types`, replace `LitestarSentryInstrument` with `SentryInstrument`. + +### Step 3: `lite_bootstrap/bootstrappers/faststream_bootstrapper.py` + +Locate `FastStreamSentryInstrument` (around lines 135-137): + +```python +@dataclasses.dataclass(kw_only=True, frozen=True) +class FastStreamSentryInstrument(SentryInstrument): + bootstrap_config: FastStreamConfig +``` + +Delete the entire block. + +In `FastStreamBootstrapper.instruments_types`, replace `FastStreamSentryInstrument` with `SentryInstrument`. + +### Step 4: Verify imports + +After the deletions, the framework bootstrappers no longer reference the deleted classes by name. Any imports of `FastAPILoggingInstrument` / `FastAPISentryInstrument` / `LitestarSentryInstrument` / `FastStreamSentryInstrument` from other test files or modules would break. + +Run a sanity grep: + +```bash +grep -rn "FastAPILoggingInstrument\|FastAPISentryInstrument\|LitestarSentryInstrument\|FastStreamSentryInstrument" . +``` + +Expected: only matches in the plan/spec docs (which describe the deletion). Zero matches in `lite_bootstrap/` or `tests/`. If any test file imports a deleted class, update it to use the base class name. + +--- + +## Task 5: Verify and commit + +- [ ] **Step 1: Run the full test suite** + +```bash +just test +``` + +Expected: 89/89 pass. No behavior change should result from this refactor. + +If any framework integration test (`test_fastapi_bootstrap`, `test_litestar_bootstrap`, `test_faststream_bootstrap`) fails, the most likely cause is type-narrowing surprise on a framework subclass that needs to access framework-specific config fields. Verify the kept framework subclasses still declare `bootstrap_config: ` where they override `bootstrap()`. + +- [ ] **Step 2: Run lint** + +```bash +just lint +``` + +Expected: clean. Watch for: + +- F401 unused-import warnings — possible if a bootstrapper imports something it no longer uses after the deletions. +- `ty check` complaints about the new generic parameterization — should be silent, but if not, narrow the issue and address. + +- [ ] **Step 3: Sanity-check the diff** + +```bash +git diff --stat +``` + +Expected: 12 files changed. Approximate line counts (rough): +- `base.py`: +4 / -1 (TypeVar + Generic + bootstrap_config type) +- 8 instrument files: roughly -1 line each (drop the redundant field declaration) +- 3 bootstrapper files: ~-3 lines per deleted subclass + 1-2 line changes in `instruments_types` + +Total net: roughly -10 to -20 lines. + +- [ ] **Step 4: Commit** + +Stage exactly the 12 modified files: + +```bash +git add \ + lite_bootstrap/instruments/base.py \ + lite_bootstrap/instruments/cors_instrument.py \ + lite_bootstrap/instruments/healthchecks_instrument.py \ + lite_bootstrap/instruments/logging_instrument.py \ + lite_bootstrap/instruments/opentelemetry_instrument.py \ + lite_bootstrap/instruments/prometheus_instrument.py \ + lite_bootstrap/instruments/pyroscope_instrument.py \ + lite_bootstrap/instruments/sentry_instrument.py \ + lite_bootstrap/instruments/swagger_instrument.py \ + lite_bootstrap/bootstrappers/fastapi_bootstrapper.py \ + lite_bootstrap/bootstrappers/litestar_bootstrapper.py \ + lite_bootstrap/bootstrappers/faststream_bootstrapper.py +git commit -m "$(cat <<'EOF' +refactor: make BaseInstrument generic; delete pure-annotation subclasses + +Closes DES-1: the four pure type-annotation framework subclasses +(FastAPILoggingInstrument, FastAPISentryInstrument, LitestarSentryInstrument, +FastStreamSentryInstrument) existed only to narrow bootstrap_config. They +contributed no behavior. + +Make BaseInstrument generic in ConfigT (TypeVar bound to BaseConfig). +Each base instrument concretely parameterizes the generic +(LoggingInstrument(BaseInstrument[LoggingConfig]), etc.) and drops its +own redundant bootstrap_config field declaration — the type is now +provided by the parent's generic parameter. + +Delete the four pure-annotation framework subclasses. In the three +affected bootstrappers' instruments_types lists, replace the deleted +class references with the base names (SentryInstrument, LoggingInstrument). + +Framework subclasses that override bootstrap() with real framework-specific +logic (FastAPICorsInstrument, LitestarLoggingInstrument, etc. — 15 in +total) keep their existing structure including the bootstrap_config: + annotation. These annotations are NOT redundant under +the single-level-generic design adopted here — they provide essential +type narrowing for framework field access (e.g., +self.bootstrap_config.application inside FastAPICorsInstrument.bootstrap). + +This deviates from the sequencing spec's "drop redundant annotations +on kept subclasses" decision: a two-level-generic design would be needed +to make those annotations truly redundant, and the complexity isn't +worth it. The audit's stated goal (eliminate the pure-annotation +boilerplate) is fully addressed regardless. + +No behavior change. Existing tests verify correctness. + +Closes DES-1 from the audit. +EOF +)" +``` + +--- + +## Task 6: Push and open PR + +- [ ] **Step 1: Push the branch** + +```bash +git push -u origin refactor/des-1-generic-instruments +``` + +- [ ] **Step 2: Open the PR** + +```bash +gh pr create --title "refactor: make BaseInstrument generic; delete pure-annotation subclasses" --body "$(cat <<'EOF' +## Summary +Closes DES-1 from an internal audit: the four pure type-annotation framework subclasses (`FastAPILoggingInstrument`, `FastAPISentryInstrument`, `LitestarSentryInstrument`, `FastStreamSentryInstrument`) existed only to narrow `bootstrap_config`. They contributed no behavior. + +- `BaseInstrument` is now generic in `ConfigT` (`TypeVar` bound to `BaseConfig`). +- Each base instrument concretely parameterizes the generic (`LoggingInstrument(BaseInstrument[LoggingConfig])`, etc.) and drops its redundant `bootstrap_config` field declaration — the type is provided by the parent. +- The four pure-annotation framework subclasses are deleted. The three affected bootstrappers' `instruments_types` lists now reference the base names directly (`SentryInstrument`, `LoggingInstrument`). +- Framework subclasses that override `bootstrap()` with framework-specific logic (15 in total) keep their existing structure. + +No behavior change. Existing tests verify correctness. + +## Deviation from sequencing spec +The sequencing spec called for "dropping redundant `bootstrap_config:` annotations on kept subclasses." Under a single-level-generic design those annotations are NOT redundant — they provide essential type narrowing for framework field access (e.g. `self.bootstrap_config.application` in `FastAPICorsInstrument.bootstrap()`). Making them truly redundant would require a two-level-generic design (each base instrument with its own bounded `TypeVar`); the complexity isn't worth the marginal cleanup. The audit's stated goal — eliminating the pure-annotation boilerplate — is fully addressed regardless. + +## Test plan +- [x] `just test` — full suite passes (89/89). +- [x] `just lint` — clean. +- [ ] Reviewer: confirm the generic + slots + frozen dataclass combination works on Python 3.10. If `BaseInstrument`'s `slots=True` had to be dropped during implementation, the commit message will note it. +- [ ] Reviewer: confirm the kept framework subclasses (`FastAPICorsInstrument`, `LitestarLoggingInstrument`, etc.) still access framework-specific config fields correctly. + +🤖 Generated with [Claude Code](https://claude.com/claude-code) +EOF +)" +``` + +--- + +## Self-Review + +**Spec coverage check** against the sequencing spec (PR7 section) and audit (DES-1): + +| Spec item | Task | +|-----------|------| +| `BaseInstrument` becomes generic in `ConfigT` (bound to `BaseConfig`) | Task 2, Step 1 | +| Each base instrument concretely parameterizes | Task 3, Steps 1-8 | +| Each base instrument drops its redundant `bootstrap_config:` field declaration | Task 3, Steps 1-8 | +| Delete `FastAPILoggingInstrument` | Task 4, Step 1 | +| Delete `FastAPISentryInstrument` | Task 4, Step 1 | +| Delete `LitestarSentryInstrument` | Task 4, Step 2 | +| Delete `FastStreamSentryInstrument` | Task 4, Step 3 | +| Update `FastAPIBootstrapper.instruments_types` to reference base names | Task 4, Step 1 | +| Update `LitestarBootstrapper.instruments_types` | Task 4, Step 2 | +| Update `FastStreamBootstrapper.instruments_types` | Task 4, Step 3 | +| Branch name `refactor/des-1-generic-instruments` | Task 1, Step 1 | +| Verification: `just test` + `just lint` clean | Task 5, Steps 1-2 | + +**Deviations from sequencing spec (documented in commit and PR body):** +- Single-level generic rather than two-level. +- Kept framework subclasses retain their `bootstrap_config:` annotations. + +**Deferred:** +- REF-1 (`_build_excluded_urls` duplication between FastAPI and Litestar OTel instruments) — fold into a quick PR8 if PR7's diff stays manageable; otherwise separate follow-up. +- REF-2 (dead defensive check in `LitestarLoggingInstrument.bootstrap()`) — same. +- REF-5 (collapse near-empty `swagger_instrument.py` and `prometheus_instrument.py` base files) — out of scope. + +These three are explicitly noted in the sequencing spec as follow-ups to PR7. Decide once PR7's actual diff lands whether to bundle them or split. diff --git a/planning/changes/2026-06-01.01-instrument-skip-rework/design.md b/planning/changes/2026-06-01.01-instrument-skip-rework/design.md index 29f251d..1e10e36 100644 --- a/planning/changes/2026-06-01.01-instrument-skip-rework/design.md +++ b/planning/changes/2026-06-01.01-instrument-skip-rework/design.md @@ -1,12 +1,5 @@ --- -status: shipped -date: 2026-06-01 -slug: instrument-skip-rework summary: Replace `InstrumentNotReadyWarning` with a pre-instantiation config check + summary log. -supersedes: null -superseded_by: stdlib-logging-and-build-summary -pr: null -outcome: shipped (partially superseded; see stdlib-logging-and-build-summary) --- # Design: Instrument Skip Rework — Replace `InstrumentNotReadyWarning` With Pre-Instantiation Config Check + Summary Log diff --git a/planning/changes/2026-06-01.01-instrument-skip-rework/plan.md b/planning/changes/2026-06-01.01-instrument-skip-rework/plan.md index 27fa69b..a34fea6 100644 --- a/planning/changes/2026-06-01.01-instrument-skip-rework/plan.md +++ b/planning/changes/2026-06-01.01-instrument-skip-rework/plan.md @@ -1,10 +1,3 @@ ---- -status: shipped -date: 2026-06-01 -slug: instrument-skip-rework -spec: instrument-skip-rework -pr: null ---- # Instrument Skip Rework Implementation Plan > **Note (2026-06-02):** the `_get_logger()` fresh-per-call decision documented below was revised by `docs/superpowers/specs/2026-06-02-stdlib-logging-and-build-summary-design.md`. The summary-log goal is unchanged; the implementation switched to stdlib `logging` with a public `build_summary()` method. diff --git a/planning/changes/2026-06-01.02-fastmcp-bootstrapper/design.md b/planning/changes/2026-06-01.02-fastmcp-bootstrapper/design.md index 943655a..8ebbf98 100644 --- a/planning/changes/2026-06-01.02-fastmcp-bootstrapper/design.md +++ b/planning/changes/2026-06-01.02-fastmcp-bootstrapper/design.md @@ -1,12 +1,5 @@ --- -status: shipped -date: 2026-06-01 -slug: fastmcp-bootstrapper summary: New `FastMcpBootstrapper` mirroring microbootstrap's fastmcp support. -supersedes: null -superseded_by: null -pr: null -outcome: shipped --- # FastMCP Bootstrapper Design diff --git a/planning/changes/2026-06-01.02-fastmcp-bootstrapper/plan.md b/planning/changes/2026-06-01.02-fastmcp-bootstrapper/plan.md index 11fceec..8e8a935 100644 --- a/planning/changes/2026-06-01.02-fastmcp-bootstrapper/plan.md +++ b/planning/changes/2026-06-01.02-fastmcp-bootstrapper/plan.md @@ -1,10 +1,3 @@ ---- -status: shipped -date: 2026-06-01 -slug: fastmcp-bootstrapper -spec: fastmcp-bootstrapper -pr: null ---- # FastMCP Bootstrapper Implementation Plan > **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. diff --git a/planning/changes/2026-06-01.03-deferred-refactors/design.md b/planning/changes/2026-06-01.03-deferred-refactors/design.md index fe3ea78..879ef9f 100644 --- a/planning/changes/2026-06-01.03-deferred-refactors/design.md +++ b/planning/changes/2026-06-01.03-deferred-refactors/design.md @@ -1,12 +1,5 @@ --- -status: shipped -date: 2026-06-01 -slug: deferred-refactors summary: The 20 deferred items from the 2026-05-31 audit (REF/TEST/LOW) across eight PRs. -supersedes: null -superseded_by: null -pr: null -outcome: "shipped as #96–#103" --- # Deferred Refactors Sequencing diff --git a/planning/changes/2026-06-01.03-deferred-refactors/plan-pr10-test-gap-fill.md b/planning/changes/2026-06-01.03-deferred-refactors/plan-pr10-test-gap-fill.md deleted file mode 100644 index 12ed615..0000000 --- a/planning/changes/2026-06-01.03-deferred-refactors/plan-pr10-test-gap-fill.md +++ /dev/null @@ -1,382 +0,0 @@ -# PR10: Test Gap Fill (TEST-4 + TEST-7) - -> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. - -**Goal:** Add standalone test files for the four instruments that today are only covered transitively via bootstrapper integration tests (`CorsInstrument`, `HealthChecksInstrument`, `PrometheusInstrument`, `SwaggerInstrument`) and add negative tests for `helpers.path.is_valid_path`. Pure additions; zero production code changes. - -**Architecture:** 5 new test files, no production changes. - -**Tech Stack:** Python 3.10+, pytest, parametrized tests. - -**Parent spec:** `docs/superpowers/specs/2026-06-01-deferred-refactors-sequencing.md` (PR10 section). -**Parent audit:** `docs/superpowers/specs/2026-05-31-bug-refactor-audit.md` (TEST-4, TEST-7). - ---- - -## File Structure - -5 new test files. No existing files modified. - -- Create: `tests/instruments/test_cors_instrument.py` -- Create: `tests/instruments/test_healthchecks_instrument.py` -- Create: `tests/instruments/test_prometheus_instrument.py` -- Create: `tests/instruments/test_swagger_instrument.py` -- Create: `tests/test_path.py` - ---- - -## Locked decisions - -- **Pure additions:** No production code changes. The existing transitive coverage via bootstrapper integration tests is fine; standalone tests just localize regression diagnosis. -- **Test style:** Match the existing simple-function test style in `tests/instruments/test_pyroscope_instrument.py` and `test_opentelemetry_instrument.py` (no fixtures, no shared setup, one function per behavior). - ---- - -## Task 1: Create branch - -**Files:** (no files; git only) - -- [ ] **Step 1: Branch off `main`** - -```bash -git checkout main -git pull --ff-only origin main -git checkout -b fix/test-4-7-gaps -``` - -Expected: `Switched to a new branch 'fix/test-4-7-gaps'`. - ---- - -## Task 2: Create the five test files, verify, commit - -### Step 1: Create `tests/instruments/test_cors_instrument.py` - -```python -from lite_bootstrap.instruments.cors_instrument import CorsConfig, CorsInstrument - - -def test_cors_instrument_not_ready_without_origins_or_regex() -> None: - instrument = CorsInstrument(bootstrap_config=CorsConfig()) - assert not instrument.is_ready() - assert instrument.not_ready_message == "cors_allowed_origins or cors_allowed_origin_regex must be provided" - - -def test_cors_instrument_ready_with_origins() -> None: - instrument = CorsInstrument(bootstrap_config=CorsConfig(cors_allowed_origins=["http://test"])) - assert instrument.is_ready() - - -def test_cors_instrument_ready_with_regex() -> None: - instrument = CorsInstrument(bootstrap_config=CorsConfig(cors_allowed_origin_regex=r"https?://.*")) - assert instrument.is_ready() - - -def test_cors_instrument_ready_with_both() -> None: - instrument = CorsInstrument( - bootstrap_config=CorsConfig( - cors_allowed_origins=["http://test"], - cors_allowed_origin_regex=r"https?://.*", - ), - ) - assert instrument.is_ready() - - -def test_cors_instrument_config_defaults() -> None: - config = CorsConfig() - assert config.cors_allowed_origins == [] - assert config.cors_allowed_methods == [] - assert config.cors_allowed_headers == [] - assert config.cors_exposed_headers == [] - assert config.cors_allowed_credentials is False - assert config.cors_allowed_origin_regex is None - assert config.cors_max_age == 600 - - -def test_cors_check_dependencies() -> None: - assert CorsInstrument.check_dependencies() is True -``` - -### Step 2: Create `tests/instruments/test_healthchecks_instrument.py` - -```python -from lite_bootstrap.instruments.healthchecks_instrument import HealthChecksConfig, HealthChecksInstrument - - -def test_healthchecks_instrument_ready_by_default() -> None: - instrument = HealthChecksInstrument(bootstrap_config=HealthChecksConfig()) - assert instrument.is_ready() - - -def test_healthchecks_instrument_not_ready_when_disabled() -> None: - instrument = HealthChecksInstrument(bootstrap_config=HealthChecksConfig(health_checks_enabled=False)) - assert not instrument.is_ready() - assert instrument.not_ready_message == "health_checks_enabled is False" - - -def test_healthchecks_render_data_default() -> None: - instrument = HealthChecksInstrument(bootstrap_config=HealthChecksConfig()) - data = instrument.render_health_check_data() - assert data == { - "service_version": "1.0.0", - "service_name": "micro-service", - "health_status": True, - } - - -def test_healthchecks_render_data_custom() -> None: - instrument = HealthChecksInstrument( - bootstrap_config=HealthChecksConfig(service_name="my-svc", service_version="2.0.0"), - ) - data = instrument.render_health_check_data() - assert data == { - "service_version": "2.0.0", - "service_name": "my-svc", - "health_status": True, - } - - -def test_healthchecks_config_defaults() -> None: - config = HealthChecksConfig() - assert config.health_checks_enabled is True - assert config.health_checks_path == "/health/" - assert config.health_checks_include_in_schema is False - - -def test_healthchecks_check_dependencies() -> None: - assert HealthChecksInstrument.check_dependencies() is True -``` - -### Step 3: Create `tests/instruments/test_prometheus_instrument.py` - -```python -from lite_bootstrap.instruments.prometheus_instrument import PrometheusConfig, PrometheusInstrument - - -def test_prometheus_instrument_ready_with_default_path() -> None: - instrument = PrometheusInstrument(bootstrap_config=PrometheusConfig()) - assert instrument.is_ready() - - -def test_prometheus_instrument_not_ready_with_empty_path() -> None: - instrument = PrometheusInstrument(bootstrap_config=PrometheusConfig(prometheus_metrics_path="")) - assert not instrument.is_ready() - assert instrument.not_ready_message == "prometheus_metrics_path is empty or not valid" - - -def test_prometheus_instrument_not_ready_with_invalid_path() -> None: - # No leading slash → invalid per is_valid_path regex. - instrument = PrometheusInstrument(bootstrap_config=PrometheusConfig(prometheus_metrics_path="metrics")) - assert not instrument.is_ready() - - -def test_prometheus_instrument_ready_with_custom_valid_path() -> None: - instrument = PrometheusInstrument( - bootstrap_config=PrometheusConfig(prometheus_metrics_path="/custom-metrics/"), - ) - assert instrument.is_ready() - - -def test_prometheus_config_defaults() -> None: - config = PrometheusConfig() - assert config.prometheus_metrics_path == "/metrics" - assert config.prometheus_metrics_include_in_schema is False - - -def test_prometheus_check_dependencies() -> None: - assert PrometheusInstrument.check_dependencies() is True -``` - -### Step 4: Create `tests/instruments/test_swagger_instrument.py` - -```python -from lite_bootstrap.instruments.swagger_instrument import SwaggerConfig, SwaggerInstrument - - -def test_swagger_instrument_ready_by_default() -> None: - instrument = SwaggerInstrument(bootstrap_config=SwaggerConfig()) - assert instrument.is_ready() - - -def test_swagger_config_defaults() -> None: - config = SwaggerConfig() - assert config.swagger_static_path == "/static" - assert config.swagger_path == "/docs" - assert config.swagger_offline_docs is False - - -def test_swagger_check_dependencies() -> None: - assert SwaggerInstrument.check_dependencies() is True -``` - -### Step 5: Create `tests/test_path.py` - -```python -import pytest - -from lite_bootstrap.helpers.path import is_valid_path - - -@pytest.mark.parametrize( - "path", - [ - "/metrics", - "/health/", - "/api/v1/users", - "/foo.bar", - "/foo_bar", - "/foo-bar", - "/a", - "/a/", - ], -) -def test_is_valid_path_accepts_valid(path: str) -> None: - assert is_valid_path(path) is True - - -@pytest.mark.parametrize( - "path", - [ - "", - "foo", - "foo/", - "/foo bar", - "/foo?bar", - "/foo#bar", - "/", - "//foo", - "/foo//bar", - ], -) -def test_is_valid_path_rejects_invalid(path: str) -> None: - assert is_valid_path(path) is False -``` - -Notes on the rejected cases: -- `""` — empty string fails the `^(/...)+/?$` pattern. -- `"foo"`, `"foo/"` — no leading `/`. -- `"/foo bar"` — space is not in `[a-zA-Z0-9._-]`. -- `"/foo?bar"`, `"/foo#bar"` — `?` and `#` are not in the charset. -- `"/"` — no segment after the slash; the regex requires `[a-zA-Z0-9._-]+`. -- `"//foo"`, `"/foo//bar"` — empty segments not allowed by the `+` quantifier. - -The regex DOES accept `/..` and `/../foo` because `.` is in the charset; the `is_valid_path` function does not block path traversal. That's a pre-existing design decision (out of scope here). - -### Step 6: Run the new test files - -```bash -just test -- tests/instruments/test_cors_instrument.py tests/instruments/test_healthchecks_instrument.py tests/instruments/test_prometheus_instrument.py tests/instruments/test_swagger_instrument.py tests/test_path.py -v -``` - -Expected: all new tests PASS on first run. They're pure additions verifying current behavior. - -If any test fails, investigate before continuing. The most likely cause is a wrong assertion (e.g., default value), not a real bug. - -### Step 7: Run the full test suite - -```bash -just test -``` - -Expected: total count goes from 89 to roughly 89 + (6+6+6+3+17) = ~127. All pass. - -Approximate breakdown of new tests: -- `test_cors_instrument.py`: 6 tests -- `test_healthchecks_instrument.py`: 6 tests -- `test_prometheus_instrument.py`: 6 tests -- `test_swagger_instrument.py`: 3 tests -- `test_path.py`: 17 parametrized cases across 2 functions - -### Step 8: Run lint - -```bash -just lint -``` - -Expected: clean. No production changes mean no lint-rule complications. - -### Step 9: Commit - -Stage exactly the five new files: - -```bash -git add \ - tests/instruments/test_cors_instrument.py \ - tests/instruments/test_healthchecks_instrument.py \ - tests/instruments/test_prometheus_instrument.py \ - tests/instruments/test_swagger_instrument.py \ - tests/test_path.py -git commit -m "$(cat <<'EOF' -test: add standalone instrument tests and is_valid_path negative tests - -TEST-4: Add tests/instruments/test_{cors,healthchecks,prometheus,swagger}_instrument.py -covering is_ready() across valid/invalid configurations, -not_ready_message content, render_health_check_data output shape, -config defaults, and check_dependencies. These instruments were -previously only covered transitively via bootstrapper integration -tests, which made regressions noisier to diagnose. - -TEST-7: Add tests/test_path.py with parametrized cases for -helpers.path.is_valid_path — both valid forms (default paths used by -the prometheus/swagger/healthchecks instruments) and invalid forms -(empty, no leading slash, spaces, special chars, empty segments). - -Closes TEST-4 and TEST-7 from the audit. Pure additions; no production -code changed. -EOF -)" -``` - ---- - -## Task 3: Push and open PR - -- [ ] **Step 1: Push the branch** - -```bash -git push -u origin fix/test-4-7-gaps -``` - -- [ ] **Step 2: Open the PR** - -```bash -gh pr create --title "test: add standalone instrument tests and is_valid_path negative tests" --body "$(cat <<'EOF' -## Summary -Pure test additions; no production changes. - -- **TEST-4:** Standalone test files for the four instruments previously only covered transitively via bootstrapper integration tests: - - \`tests/instruments/test_cors_instrument.py\` — \`is_ready\` matrix (origins-only, regex-only, both, neither), \`not_ready_message\`, config defaults, \`check_dependencies\`. - - \`tests/instruments/test_healthchecks_instrument.py\` — enabled/disabled, \`render_health_check_data\` output shape, defaults. - - \`tests/instruments/test_prometheus_instrument.py\` — valid/invalid/empty paths, defaults. - - \`tests/instruments/test_swagger_instrument.py\` — instantiation, defaults. -- **TEST-7:** \`tests/test_path.py\` — parametrized cases for \`is_valid_path\` covering valid forms (\`/metrics\`, \`/health/\`, multi-segment, special chars in the allowed set) and invalid forms (empty, no leading slash, spaces, \`?\`/\`#\`, empty segments). - -Closes TEST-4 and TEST-7 from an internal audit. - -## Test plan -- [x] \`just test\` — full suite passes (89 prior + ~37 new ≈ 126). -- [x] \`just lint\` — clean. -- [ ] Reviewer: confirm the rejected-path list in \`test_path.py\` matches the intended contract. (The regex DOES accept \`/..\` because \`.\` is in the allowed charset — pre-existing design decision; not tested as a "valid" or "invalid" case.) - -🤖 Generated with [Claude Code](https://claude.com/claude-code) -EOF -)" -``` - ---- - -## Self-Review - -**Spec coverage check** against the sequencing spec (PR10 section) and audit (TEST-4, TEST-7): - -| Spec item | Task | -|-----------|------| -| `tests/instruments/test_cors_instrument.py` | Task 2, Step 1 | -| `tests/instruments/test_healthchecks_instrument.py` | Task 2, Step 2 | -| `tests/instruments/test_prometheus_instrument.py` | Task 2, Step 3 | -| `tests/instruments/test_swagger_instrument.py` | Task 2, Step 4 | -| `tests/test_path.py` with negative cases | Task 2, Step 5 | -| Branch name `fix/test-4-7-gaps` | Task 1, Step 1 | -| Verification: `just test` + `just lint` clean | Task 2, Steps 6-8 | - -All spec items covered. No placeholders. Test code matches the existing simple-function style in the codebase. The Prometheus test uses both the default `/metrics` path (valid) and `"metrics"` (invalid — no leading slash) to exercise both branches of `is_valid_path`'s logic without depending on `tests/test_path.py`. diff --git a/planning/changes/2026-06-01.03-deferred-refactors/plan-pr11-logging-cleanup.md b/planning/changes/2026-06-01.03-deferred-refactors/plan-pr11-logging-cleanup.md deleted file mode 100644 index 68a086b..0000000 --- a/planning/changes/2026-06-01.03-deferred-refactors/plan-pr11-logging-cleanup.md +++ /dev/null @@ -1,628 +0,0 @@ -# PR11: Logging Cleanup + Lifecycle Test (REF-4 + REF-2 + LOW-6 + LOW-8 + LOW-9 + TEST-8) - -> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. - -**Goal:** The biggest PR of the deferred-refactors sequence. Bundles six logging-area items: -- **REF-4**: Split `logging_instrument.py` (212 lines) into a new `logging_factory.py` (factory + serializer + protocols) plus a slimmer `logging_instrument.py` (config + instrument + tracer injection). -- **REF-2**: Delete the dead `if import_checker.is_structlog_installed and import_checker.is_litestar_installed:` defensive check in `LitestarLoggingInstrument.bootstrap()`. -- **LOW-6**: Wrap `MemoryLoggerFactory`'s 4 logging-config kwargs into an internal `_MemoryLoggerFactoryConfig` dataclass. -- **LOW-8**: Add a docstring to `LoggingInstrument._unset_handlers` documenting that the mutation is permanent. -- **LOW-9**: Add a docstring to `LoggingInstrument.teardown()` documenting that root logger level is unconditionally reset to WARNING. -- **TEST-8**: Add a bootstrap→teardown→bootstrap→teardown lifecycle replay test. - -**Architecture:** One new module file, one production refactor (the split), one cross-file dead-code removal, two docstring additions, one test update, one new test. Backward compatibility is preserved by re-exporting moved symbols from `logging_instrument.py`. - -**Tech Stack:** Python 3.10+ dataclasses, structlog, pytest. - -**Parent spec:** `docs/superpowers/specs/2026-06-01-deferred-refactors-sequencing.md` (PR11 section). -**Parent audit:** `docs/superpowers/specs/2026-05-31-bug-refactor-audit.md` (REF-2, REF-4, LOW-6, LOW-8, LOW-9, TEST-8). - ---- - -## File Structure - -5 files touched. - -- Create: `lite_bootstrap/instruments/logging_factory.py` — `MemoryLoggerFactory`, `_MemoryLoggerFactoryConfig`, `_serialize_log_with_orjson_to_string`, `AddressProtocol`, `RequestProtocol`, `ScopeType`. -- Modify: `lite_bootstrap/instruments/logging_instrument.py` — slim to `LoggingConfig`, `LoggingInstrument`, `tracer_injection`; re-import the moved symbols for backward compat; add LOW-8 / LOW-9 docstrings. -- Modify: `lite_bootstrap/bootstrappers/litestar_bootstrapper.py` — REF-2: delete the dead defensive check, unindent the body. -- Modify: `tests/instruments/test_logging_instrument.py` — update `MemoryLoggerFactory` instantiations to use `_MemoryLoggerFactoryConfig`; add `test_logging_instrument_lifecycle_replay`. - ---- - -## Locked decisions (from sequencing spec) - -- **Internal config dataclass for `MemoryLoggerFactory`:** Prefix `_MemoryLoggerFactoryConfig` (underscore = internal). Not exported from `__init__.py`. Tests import it via the underscore-prefixed name. -- **Backward compat for moved symbols:** `MemoryLoggerFactory`, `AddressProtocol`, `RequestProtocol`, `ScopeType` are re-imported into `logging_instrument.py` so existing `from lite_bootstrap.instruments.logging_instrument import MemoryLoggerFactory` imports continue to work. -- **No PLR2004 noqa for magic-value test assertions.** If ruff complains about a numeric literal in a test assertion (e.g., `assert config.x == 10`), extract the value to a named local variable. See PR10's `expected_max_age = 600` pattern. - ---- - -## Task 1: Create branch - -**Files:** (no files; git only) - -- [ ] **Step 1: Branch off `main`** - -```bash -git checkout main -git pull --ff-only origin main -git checkout -b refactor/ref-4-logging-cleanup -``` - -Expected: `Switched to a new branch 'refactor/ref-4-logging-cleanup'`. - ---- - -## Task 2: Apply all six changes, verify, commit - -The order of steps below is important — start with the file split (steps 1-3), then dead code removal (step 4), then docstrings (step 5), then test updates (steps 6-7), then verify. - -### Step 1: Create `lite_bootstrap/instruments/logging_factory.py` - -New file with full contents: - -```python -import dataclasses -import logging -import logging.handlers -import sys -import typing - -import orjson - -from lite_bootstrap import import_checker - - -ScopeType = typing.MutableMapping[str, typing.Any] - - -class AddressProtocol(typing.Protocol): - host: str - port: int - - -class RequestProtocol(typing.Protocol): - client: AddressProtocol - scope: ScopeType - method: str - - -@dataclasses.dataclass(kw_only=True, slots=True, frozen=True) -class _MemoryLoggerFactoryConfig: - logging_buffer_capacity: int - logging_flush_level: int - logging_log_level: int - log_stream: typing.Any = sys.stdout # noqa: ANN401 - - -def _serialize_log_with_orjson_to_string(value: typing.Any, **kwargs: typing.Any) -> str: # noqa: ANN401 - return orjson.dumps(value, **kwargs).decode() - - -if import_checker.is_structlog_installed: - import structlog - - class MemoryLoggerFactory(structlog.stdlib.LoggerFactory): - def __init__( - self, - *args: typing.Any, # noqa: ANN401 - config: "_MemoryLoggerFactoryConfig", - **kwargs: typing.Any, # noqa: ANN401 - ) -> None: - super().__init__(*args, **kwargs) - self.config = config - self._created_handlers: list[tuple[logging.Logger, logging.handlers.MemoryHandler]] = [] - - def __call__(self, *args: typing.Any) -> logging.Logger: # noqa: ANN401 - logger: typing.Final = super().__call__(*args) - stream_handler: typing.Final = logging.StreamHandler(stream=self.config.log_stream) - handler: typing.Final = logging.handlers.MemoryHandler( - capacity=self.config.logging_buffer_capacity, - flushLevel=self.config.logging_flush_level, - target=stream_handler, - ) - logger.addHandler(handler) - logger.setLevel(self.config.logging_log_level) - logger.propagate = False - self._created_handlers.append((logger, handler)) - return logger - - def close_handlers(self) -> None: - for created_logger, handler in self._created_handlers: - created_logger.removeHandler(handler) - created_logger.propagate = True - target = handler.target - handler.close() - if target is not None: - target.close() - self._created_handlers.clear() -``` - -Notes on the structlog gate: -- `MemoryLoggerFactory` MUST be inside the gate because it inherits from `structlog.stdlib.LoggerFactory`. -- `_MemoryLoggerFactoryConfig` and `_serialize_log_with_orjson_to_string` are OUTSIDE the gate — they have no structlog dependency (just stdlib + orjson, both unconditional). This matters because `logging_instrument.py` imports them at module top; if they were gated, the import would fail when structlog isn't installed. -- `ScopeType`, `AddressProtocol`, `RequestProtocol` are unconditional (no structlog dependency). - -### Step 2: Rewrite `lite_bootstrap/instruments/logging_instrument.py` - -Replace the full file contents with: - -```python -import dataclasses -import logging -import logging.handlers -import sys -import typing - -from lite_bootstrap import import_checker -from lite_bootstrap.instruments.base import BaseConfig, BaseInstrument -from lite_bootstrap.instruments.logging_factory import ( - AddressProtocol, - RequestProtocol, - ScopeType, - _MemoryLoggerFactoryConfig, - _serialize_log_with_orjson_to_string, -) - - -if typing.TYPE_CHECKING: - from lite_bootstrap.instruments.logging_factory import MemoryLoggerFactory - from structlog.typing import EventDict, WrappedLogger - - -if import_checker.is_structlog_installed: - import structlog - - from lite_bootstrap.instruments.logging_factory import MemoryLoggerFactory - - -if import_checker.is_opentelemetry_installed: - from opentelemetry import trace - - def tracer_injection(_: "WrappedLogger", __: str, event_dict: "EventDict") -> "EventDict": - current_span = trace.get_current_span() - if not current_span.is_recording(): - event_dict["tracing"] = {} - return event_dict - - current_span_context = current_span.get_span_context() - event_dict["tracing"] = { - "span_id": trace.format_span_id(current_span_context.span_id), - "trace_id": trace.format_trace_id(current_span_context.trace_id), - } - return event_dict - -else: # pragma: no cover - - def tracer_injection(_: "WrappedLogger", __: str, event_dict: "EventDict") -> "EventDict": - return event_dict - - -__all__ = [ - "AddressProtocol", - "LoggingConfig", - "LoggingInstrument", - "MemoryLoggerFactory", - "RequestProtocol", - "ScopeType", - "tracer_injection", -] - - -@dataclasses.dataclass(kw_only=True, frozen=True) -class LoggingConfig(BaseConfig): - logging_log_level: int = logging.INFO - logging_flush_level: int = logging.ERROR - logging_buffer_capacity: int = 10 - logging_extra_processors: list[typing.Any] = dataclasses.field(default_factory=list) - logging_unset_handlers: list[str] = dataclasses.field( - default_factory=list, - ) - logging_time_stamper: "structlog.processors.TimeStamper | None" = None - logging_enabled: bool = True - - -@dataclasses.dataclass(kw_only=True, slots=True, frozen=True) -class LoggingInstrument(BaseInstrument[LoggingConfig]): - not_ready_message = "logging_enabled is False" - missing_dependency_message = "structlog is not installed" - _logger_factory: "MemoryLoggerFactory | None" = dataclasses.field( - default_factory=lambda: None, init=False, repr=False, compare=False - ) - - @property - def structlog_pre_chain_processors(self) -> list[typing.Any]: - return [ - structlog.stdlib.add_log_level, - structlog.stdlib.add_logger_name, - tracer_injection, - structlog.stdlib.PositionalArgumentsFormatter(), - self.bootstrap_config.logging_time_stamper or structlog.processors.TimeStamper(fmt="iso"), - structlog.processors.StackInfoRenderer(), - structlog.processors.format_exc_info, - structlog.processors.UnicodeDecoder(), - ] - - def is_ready(self) -> bool: - return self.bootstrap_config.logging_enabled - - @staticmethod - def check_dependencies() -> bool: - return import_checker.is_structlog_installed - - def _unset_handlers(self) -> None: - """Clear handlers on the named loggers. Mutation is permanent; teardown() does not restore.""" - for unset_handlers_logger in self.bootstrap_config.logging_unset_handlers: - logging.getLogger(unset_handlers_logger).handlers = [] - - @property - def structlog_processors(self) -> list[typing.Any]: - return [ - structlog.stdlib.filter_by_level, - *self.structlog_pre_chain_processors, - *self.bootstrap_config.logging_extra_processors, - structlog.processors.JSONRenderer(serializer=_serialize_log_with_orjson_to_string), - ] - - @property - def memory_logger_factory(self) -> "MemoryLoggerFactory": - cached: MemoryLoggerFactory | None = self._logger_factory - if cached is None: - cached = MemoryLoggerFactory( - config=_MemoryLoggerFactoryConfig( - logging_buffer_capacity=self.bootstrap_config.logging_buffer_capacity, - logging_flush_level=self.bootstrap_config.logging_flush_level, - logging_log_level=self.bootstrap_config.logging_log_level, - ), - ) - object.__setattr__(self, "_logger_factory", cached) - return cached - - def _configure_structlog_loggers(self) -> None: - structlog.configure( - processors=self.structlog_processors, - context_class=dict, - logger_factory=self.memory_logger_factory, - wrapper_class=structlog.stdlib.BoundLogger, - cache_logger_on_first_use=True, - ) - - def _configure_foreign_loggers(self) -> None: - root_logger: typing.Final = logging.getLogger() - stream_handler: typing.Final = logging.StreamHandler(sys.stdout) - stream_handler.setFormatter( - structlog.stdlib.ProcessorFormatter( - foreign_pre_chain=self.structlog_pre_chain_processors, - processors=[ - structlog.stdlib.ProcessorFormatter.remove_processors_meta, - *self.bootstrap_config.logging_extra_processors, - structlog.processors.JSONRenderer(serializer=_serialize_log_with_orjson_to_string), - ], - logger=root_logger, - ) - ) - root_logger.addHandler(stream_handler) - root_logger.setLevel(self.bootstrap_config.logging_log_level) - - def bootstrap(self) -> None: - self._unset_handlers() - self._configure_structlog_loggers() - self._configure_foreign_loggers() - - def teardown(self) -> None: - """Reset structlog and root logger. Root logger level is unconditionally set to WARNING; pre-existing user configuration is overwritten.""" - structlog.reset_defaults() - root_logger = logging.getLogger() - for h in root_logger.handlers[:]: - root_logger.removeHandler(h) - h.close() - root_logger.setLevel(logging.WARNING) - if self._logger_factory is not None: - try: - self._logger_factory.close_handlers() - finally: - object.__setattr__(self, "_logger_factory", None) -``` - -Key differences from the original: -- Module-top imports from `logging_factory`: `AddressProtocol`, `RequestProtocol`, `ScopeType`, `_MemoryLoggerFactoryConfig`, `_serialize_log_with_orjson_to_string` (all unconditional, no structlog dependency). -- `MemoryLoggerFactory` import is gated — at module top in `TYPE_CHECKING` block (for annotations) and inside `if import_checker.is_structlog_installed:` (for runtime use). This mirrors the original module's lazy-resolution pattern: `MemoryLoggerFactory` is only referenced at runtime when structlog is installed. -- `__all__` explicitly re-exports the moved public symbols (`AddressProtocol`, `MemoryLoggerFactory`, `RequestProtocol`, `ScopeType`) for backward compatibility. Consumers who try to access `MemoryLoggerFactory` without structlog will get `AttributeError` — same behavior as the original module. -- `_unset_handlers` and `teardown` now have one-line docstrings (LOW-8, LOW-9). -- `memory_logger_factory` property constructs `MemoryLoggerFactory` with a `_MemoryLoggerFactoryConfig` (LOW-6). -- All `MemoryLoggerFactory`/factory-internal code is gone — sourced from the new module. - -### Step 3: Verify the split with a quick smoke test - -```bash -just test -- tests/instruments/test_logging_instrument.py -v -``` - -Expected: existing tests may fail because they construct `MemoryLoggerFactory(logging_buffer_capacity=..., ...)` with the old signature. We'll fix those in Step 6 below. For now, just verify that imports resolve cleanly (no `ImportError`). - -If you see `ImportError`, the file split is broken — investigate before proceeding. - -### Step 4: REF-2 — delete dead defensive check in `LitestarLoggingInstrument.bootstrap` - -**File:** `lite_bootstrap/bootstrappers/litestar_bootstrapper.py` - -Locate `LitestarLoggingInstrument.bootstrap`. Current: - -```python - def bootstrap(self) -> None: - self._unset_handlers() - if import_checker.is_structlog_installed and import_checker.is_litestar_installed: - self.bootstrap_config.application_config.plugins.append( - StructlogPlugin( - config=StructlogConfig( - structlog_logging_config=StructLoggingConfig( - processors=self.structlog_processors, - logger_factory=self.memory_logger_factory, - wrapper_class=structlog.stdlib.BoundLogger, - cache_logger_on_first_use=True, - pretty_print_tty=False, - standard_lib_logging_config=None, - ), - ), - ) - ) - self._configure_foreign_loggers() -``` - -Replace with (delete the `if` line and the matching dedent — the body unindents one level): - -```python - def bootstrap(self) -> None: - self._unset_handlers() - self.bootstrap_config.application_config.plugins.append( - StructlogPlugin( - config=StructlogConfig( - structlog_logging_config=StructLoggingConfig( - processors=self.structlog_processors, - logger_factory=self.memory_logger_factory, - wrapper_class=structlog.stdlib.BoundLogger, - cache_logger_on_first_use=True, - pretty_print_tty=False, - standard_lib_logging_config=None, - ), - ), - ) - ) - self._configure_foreign_loggers() -``` - -The `if import_checker.is_structlog_installed and import_checker.is_litestar_installed:` check is dead — the instrument couldn't have been registered without both packages installed. - -### Step 5: Update tests to use `_MemoryLoggerFactoryConfig` - -**File:** `tests/instruments/test_logging_instrument.py` - -There are two tests (`test_memory_logger_factory_info`, `test_memory_logger_factory_error`) that instantiate `MemoryLoggerFactory` directly. Both need to be updated to use the new config-based API. - -Locate the top-of-file imports. The current imports look like: - -```python -import logging -from io import StringIO - -import structlog -from opentelemetry.trace import get_tracer - -from lite_bootstrap.instruments.logging_instrument import LoggingConfig, LoggingInstrument, MemoryLoggerFactory -from lite_bootstrap.instruments.opentelemetry_instrument import OpentelemetryConfig, OpenTelemetryInstrument -from tests.conftest import LoggingMock -``` - -(After PR3 the file also has `from unittest.mock import patch` and `import pytest`; preserve those.) - -Add `_MemoryLoggerFactoryConfig` to the imports. Either import from `logging_factory` directly (more explicit) or from `logging_instrument` (which already re-imports it). Use the direct path for clarity: - -```python -from lite_bootstrap.instruments.logging_factory import _MemoryLoggerFactoryConfig -``` - -Place this after the `from lite_bootstrap.instruments.logging_instrument import ...` line. - -Locate `test_memory_logger_factory_info`. Current: - -```python -def test_memory_logger_factory_info() -> None: - test_capacity = 10 - test_flush_level = logging.ERROR - test_stream = StringIO() - - logger_factory = MemoryLoggerFactory( - logging_buffer_capacity=test_capacity, - logging_flush_level=test_flush_level, - logging_log_level=logging.INFO, - log_stream=test_stream, - ) - ... -``` - -Replace the `MemoryLoggerFactory(...)` call with: - -```python - logger_factory = MemoryLoggerFactory( - config=_MemoryLoggerFactoryConfig( - logging_buffer_capacity=test_capacity, - logging_flush_level=test_flush_level, - logging_log_level=logging.INFO, - log_stream=test_stream, - ), - ) -``` - -Do the same in `test_memory_logger_factory_error`. - -### Step 6: Add lifecycle replay test (TEST-8) - -In the same file, append a new test: - -```python -def test_logging_instrument_lifecycle_replay(logging_mock: LoggingMock) -> None: - instrument = LoggingInstrument( - bootstrap_config=LoggingConfig( - logging_buffer_capacity=0, - logging_extra_processors=[logging_mock], - ), - ) - try: - instrument.bootstrap() - instrument.teardown() - instrument.bootstrap() - logger = structlog.getLogger(__name__) - logger.info("after replay") - assert any(e.get("event") == "after replay" for e in logging_mock.entries) - finally: - instrument.teardown() -``` - -Contract: bootstrap → teardown → bootstrap must succeed without raising. After the second bootstrap, the instrument is functional (new logger entries flow through `logging_mock`). The final `teardown()` in `finally` ensures global state is cleaned up regardless of test outcome. - -### Step 7: Run the full logging test file - -```bash -just test -- tests/instruments/test_logging_instrument.py -v -``` - -Expected: all tests PASS, including the two updated factory tests and the new lifecycle replay test. - -If any test fails, investigate. The most likely cause is a typo in the `_MemoryLoggerFactoryConfig` construction. - -### Step 8: Run the full test suite - -```bash -just test -``` - -Expected: 127/127 PASS (after PR10 brought the total to 127, this PR adds 1 test → 128). Watch for failures in the framework integration tests — particularly `test_fastapi_bootstrap`, `test_litestar_bootstrap`, `test_faststream_bootstrap` — which exercise the full logging instrument lifecycle. - -### Step 9: Run lint - -```bash -just lint -``` - -Expected: clean. The file split and dataclass-config refactor shouldn't introduce lint issues. - -**Important — PLR2004 policy:** If ruff flags any numeric literal in test assertions with PLR2004 (magic value), DO NOT add `# noqa: PLR2004`. Extract the value to a named local variable. Example: `expected_capacity = 10; assert factory.config.logging_buffer_capacity == expected_capacity`. - -### Step 10: Commit - -Stage all five touched files: - -```bash -git add \ - lite_bootstrap/instruments/logging_factory.py \ - lite_bootstrap/instruments/logging_instrument.py \ - lite_bootstrap/bootstrappers/litestar_bootstrapper.py \ - tests/instruments/test_logging_instrument.py -git commit -m "$(cat <<'EOF' -refactor: split logging module + cleanup + lifecycle test - -REF-4: Split lite_bootstrap/instruments/logging_instrument.py (212 -lines, four concerns) into: -- logging_factory.py: MemoryLoggerFactory, _MemoryLoggerFactoryConfig, - _serialize_log_with_orjson_to_string, AddressProtocol, - RequestProtocol, ScopeType. Single structlog-conditional gate at - module top. -- logging_instrument.py: LoggingConfig, LoggingInstrument, - tracer_injection. Re-imports the moved public symbols - (MemoryLoggerFactory, AddressProtocol, RequestProtocol, ScopeType) - for backward compatibility — existing imports from - lite_bootstrap.instruments.logging_instrument continue to work. - -LOW-6: MemoryLoggerFactory.__init__ now takes a single -_MemoryLoggerFactoryConfig dataclass instead of four logging-config -kwargs. The dataclass is underscore-prefixed (internal); tests import -it explicitly when constructing the factory directly. - -REF-2: Delete the dead `if import_checker.is_structlog_installed and -import_checker.is_litestar_installed:` defensive check in -LitestarLoggingInstrument.bootstrap. The check is unreachable — -the instrument couldn't have been registered without both packages -installed. - -LOW-8: Add a docstring to LoggingInstrument._unset_handlers documenting -that the mutation is permanent (teardown does not restore handlers). - -LOW-9: Add a docstring to LoggingInstrument.teardown documenting that -root logger level is unconditionally reset to WARNING. - -TEST-8: Add test_logging_instrument_lifecycle_replay exercising -bootstrap → teardown → bootstrap → teardown and verifying the -instrument is functional after the second bootstrap. - -No external behavior change. Updated factory tests to use the new -config-based constructor. - -Closes REF-2, REF-4, LOW-6, LOW-8, LOW-9, TEST-8 from the audit. -EOF -)" -``` - ---- - -## Task 3: Push and open PR - -- [ ] **Step 1: Push the branch** - -```bash -git push -u origin refactor/ref-4-logging-cleanup -``` - -- [ ] **Step 2: Open the PR** - -```bash -gh pr create --title "refactor: split logging module + cleanup + lifecycle test" --body "$(cat <<'EOF' -## Summary -The biggest PR of the deferred-refactors sequence — six logging-area items bundled: - -- **REF-4:** Split `logging_instrument.py` (212 lines, four concerns) into a new `logging_factory.py` (MemoryLoggerFactory + serializer + protocols + the new internal `_MemoryLoggerFactoryConfig`) and a slimmer `logging_instrument.py` (config + instrument + tracer injection). Single structlog-conditional gate at the new module's top, vs three separate gates in the old layout. Backward compatibility: the moved public symbols are re-imported into `logging_instrument.py`, so existing `from lite_bootstrap.instruments.logging_instrument import MemoryLoggerFactory` continues to work. -- **REF-2:** Deleted the dead `if import_checker.is_structlog_installed and import_checker.is_litestar_installed:` check in `LitestarLoggingInstrument.bootstrap()`. Unreachable code (the instrument couldn't have been registered without both packages installed). -- **LOW-6:** `MemoryLoggerFactory.__init__` now takes a single `_MemoryLoggerFactoryConfig` dataclass instead of four logging-config kwargs. Underscore-prefixed because it's internal; tests import it directly. -- **LOW-8:** Added a docstring to `LoggingInstrument._unset_handlers` documenting that the mutation is permanent — teardown does NOT restore handlers. -- **LOW-9:** Added a docstring to `LoggingInstrument.teardown()` documenting that root logger level is unconditionally reset to `WARNING`. -- **TEST-8:** New `test_logging_instrument_lifecycle_replay` exercises bootstrap → teardown → bootstrap → teardown and verifies the instrument is functional after the second bootstrap. - -No external behavior change. Two existing factory tests updated to use the new config-based constructor. - -Closes REF-2, REF-4, LOW-6, LOW-8, LOW-9, TEST-8 from an internal audit. - -## Test plan -- [x] `just test -- tests/instruments/test_logging_instrument.py -v` — pass (including updated factory tests + new lifecycle replay). -- [x] `just test` — 128/128 (127 prior + 1 new). -- [x] `just lint` — clean. -- [ ] Reviewer: confirm the backward-compat re-imports in `logging_instrument.py` cover all the public symbols (MemoryLoggerFactory, AddressProtocol, RequestProtocol, ScopeType). -- [ ] Reviewer: confirm the test update for the new config-based `MemoryLoggerFactory` constructor is correct. - -🤖 Generated with [Claude Code](https://claude.com/claude-code) -EOF -)" -``` - ---- - -## Self-Review - -**Spec coverage check** against the sequencing spec (PR11 section) and audit: - -| Spec item | Task | -|-----------|------| -| REF-4: Split `logging_instrument.py` into `logging_factory.py` + slimmer `logging_instrument.py` | Task 2, Steps 1-2 | -| REF-2: Delete dead defensive check in `LitestarLoggingInstrument.bootstrap` | Task 2, Step 4 | -| LOW-6: `MemoryLoggerFactory` takes `_MemoryLoggerFactoryConfig` | Task 2, Steps 1, 2, 5 | -| LOW-8: docstring on `_unset_handlers` | Task 2, Step 2 | -| LOW-9: docstring on `teardown` | Task 2, Step 2 | -| TEST-8: lifecycle replay test | Task 2, Step 6 | -| Branch name `refactor/ref-4-logging-cleanup` | Task 1, Step 1 | -| Verification: `just test` + `just lint` clean | Task 2, Steps 8-9 | -| PLR2004 noqa NOT used; extract constants instead | Task 2, Step 9 (callout) | - -All spec items covered. No placeholders. Backward compatibility for moved public symbols is explicitly handled via re-imports in `logging_instrument.py` with `__all__` listing them. - -**Risk notes:** -- The file split is the highest-risk change. If imports break anywhere in the codebase or tests, this PR's full suite run catches it. -- The MemoryLoggerFactory constructor change is API-breaking for direct users of `MemoryLoggerFactory(logging_buffer_capacity=..., ...)`. Since `MemoryLoggerFactory` is publicly exported but its internals are framework-level (users rarely construct it directly outside tests), the impact is small. Documented in the commit message. diff --git a/planning/changes/2026-06-01.03-deferred-refactors/plan-pr12-base-layer-cleanup.md b/planning/changes/2026-06-01.03-deferred-refactors/plan-pr12-base-layer-cleanup.md deleted file mode 100644 index 0766e78..0000000 --- a/planning/changes/2026-06-01.03-deferred-refactors/plan-pr12-base-layer-cleanup.md +++ /dev/null @@ -1,340 +0,0 @@ -# PR12: Base Layer Cleanup (REF-3 + REF-5) - -> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. - -**Goal:** -- **REF-3**: Drop `abc.ABC` from `BaseInstrument`. After PR7 made the class generic and removed the `# noqa: B027` suppressions, the `abc.ABC` parent serves no purpose — all four methods (`bootstrap`, `teardown`, `is_ready`, `check_dependencies`) are concrete no-ops with sensible defaults. Removing it clarifies the class's role as a regular generic base. -- **REF-5**: Add a one-line module docstring to `swagger_instrument.py` and `prometheus_instrument.py` explaining that these files are config holders; framework-specific behavior lives in the bootstrapper subclasses. Per the locked decision in the sequencing spec, KEEP these files as separate modules (don't collapse). - -**Architecture:** Three files, three small edits. No behavior change. - -**Tech Stack:** Python 3.10+ dataclasses, generics. - -**Parent spec:** `docs/superpowers/specs/2026-06-01-deferred-refactors-sequencing.md` (PR12 section). -**Parent audit:** `docs/superpowers/specs/2026-05-31-bug-refactor-audit.md` (REF-3, REF-5). - ---- - -## File Structure - -Three files modified. - -- Modify: `lite_bootstrap/instruments/base.py` — drop `abc.ABC` from `BaseInstrument`; remove the `import abc`. -- Modify: `lite_bootstrap/instruments/swagger_instrument.py` — add module docstring. -- Modify: `lite_bootstrap/instruments/prometheus_instrument.py` — add module docstring. - ---- - -## Locked decisions (from sequencing spec) - -- **REF-3 scope:** Only `BaseInstrument` loses `abc.ABC`. `BaseBootstrapper` (in `lite_bootstrap/bootstrappers/base.py`) keeps `abc.ABC` because it HAS abstract methods (`not_ready_message`, `_prepare_application`, `is_ready`). -- **REF-5 scope:** Keep `swagger_instrument.py` and `prometheus_instrument.py` as separate files (don't collapse). Add module docstrings explaining the split. - ---- - -## Task 1: Create branch - -**Files:** (no files; git only) - -- [ ] **Step 1: Branch off `main`** - -```bash -git checkout main -git pull --ff-only origin main -git checkout -b refactor/ref-3-5-base-layer -``` - -Expected: `Switched to a new branch 'refactor/ref-3-5-base-layer'`. - ---- - -## Task 2: Apply both changes, verify, commit - -### Step 1: REF-3 — drop `abc.ABC` from `BaseInstrument` - -**File:** `lite_bootstrap/instruments/base.py` - -Current file: - -```python -import abc -import dataclasses -import typing - -import typing_extensions - - -@dataclasses.dataclass(kw_only=True, slots=True, frozen=True) -class BaseConfig: - ... - - -ConfigT = typing.TypeVar("ConfigT", bound=BaseConfig) - - -@dataclasses.dataclass(kw_only=True, slots=True, frozen=True) -class BaseInstrument(abc.ABC, typing.Generic[ConfigT]): - bootstrap_config: ConfigT - not_ready_message = "" - missing_dependency_message = "" - - def bootstrap(self) -> None: ... - - def teardown(self) -> None: ... - - def is_ready(self) -> bool: - return True - - @staticmethod - def check_dependencies() -> bool: - return True -``` - -Two changes: - -1. Remove `import abc` from the top (it's no longer used in this file). -2. Change `class BaseInstrument(abc.ABC, typing.Generic[ConfigT]):` to `class BaseInstrument(typing.Generic[ConfigT]):`. - -After: - -```python -import dataclasses -import typing - -import typing_extensions - - -@dataclasses.dataclass(kw_only=True, slots=True, frozen=True) -class BaseConfig: - ... - - -ConfigT = typing.TypeVar("ConfigT", bound=BaseConfig) - - -@dataclasses.dataclass(kw_only=True, slots=True, frozen=True) -class BaseInstrument(typing.Generic[ConfigT]): - bootstrap_config: ConfigT - not_ready_message = "" - missing_dependency_message = "" - - def bootstrap(self) -> None: ... - - def teardown(self) -> None: ... - - def is_ready(self) -> bool: - return True - - @staticmethod - def check_dependencies() -> bool: - return True -``` - -`BaseConfig` is preserved unchanged (it never used `abc.ABC`). - -### Step 2: REF-5 — add docstring to `swagger_instrument.py` - -**File:** `lite_bootstrap/instruments/swagger_instrument.py` - -Current file: - -```python -import dataclasses - -from lite_bootstrap.instruments.base import BaseConfig, BaseInstrument - - -@dataclasses.dataclass(kw_only=True, frozen=True) -class SwaggerConfig(BaseConfig): - swagger_static_path: str = "/static" - swagger_path: str = "/docs" - swagger_offline_docs: bool = False - - -@dataclasses.dataclass(kw_only=True, slots=True, frozen=True) -class SwaggerInstrument(BaseInstrument[SwaggerConfig]): - pass -``` - -Add a module docstring at the top: - -```python -"""Swagger config and minimal base instrument; framework-specific behavior lives in the bootstrapper subclasses.""" - -import dataclasses - -from lite_bootstrap.instruments.base import BaseConfig, BaseInstrument - - -@dataclasses.dataclass(kw_only=True, frozen=True) -class SwaggerConfig(BaseConfig): - swagger_static_path: str = "/static" - swagger_path: str = "/docs" - swagger_offline_docs: bool = False - - -@dataclasses.dataclass(kw_only=True, slots=True, frozen=True) -class SwaggerInstrument(BaseInstrument[SwaggerConfig]): - pass -``` - -Single addition: the module docstring on line 1. - -### Step 3: REF-5 — add docstring to `prometheus_instrument.py` - -**File:** `lite_bootstrap/instruments/prometheus_instrument.py` - -Current file: - -```python -import dataclasses - -from lite_bootstrap.helpers.path import is_valid_path -from lite_bootstrap.instruments.base import BaseConfig, BaseInstrument - - -@dataclasses.dataclass(kw_only=True, frozen=True) -class PrometheusConfig(BaseConfig): - prometheus_metrics_path: str = "/metrics" - prometheus_metrics_include_in_schema: bool = False - - -@dataclasses.dataclass(kw_only=True, slots=True, frozen=True) -class PrometheusInstrument(BaseInstrument[PrometheusConfig]): - not_ready_message = "prometheus_metrics_path is empty or not valid" - - def is_ready(self) -> bool: - return bool(self.bootstrap_config.prometheus_metrics_path) and is_valid_path( - self.bootstrap_config.prometheus_metrics_path - ) -``` - -Add a module docstring: - -```python -"""Prometheus config and readiness check; framework-specific bootstrap lives in the bootstrapper subclasses.""" - -import dataclasses - -from lite_bootstrap.helpers.path import is_valid_path -from lite_bootstrap.instruments.base import BaseConfig, BaseInstrument - - -@dataclasses.dataclass(kw_only=True, frozen=True) -class PrometheusConfig(BaseConfig): - ... -``` - -(Rest of the file unchanged.) - -### Step 4: Run the full test suite - -```bash -just test -``` - -Expected: 128/128 PASS. REF-3 is a metaclass/MRO change but `abc.ABC` was unused (no abstract methods); dropping it should be invisible at runtime. REF-5 is pure docstring additions. - -Watch for surprises in: -- `tests/test_free_bootstrap.py` — exercises the base instrument lifecycle directly. -- Framework integration tests — instantiate instruments via the bootstrapper chain. - -If anything fails, the most likely cause is some `isinstance(..., abc.ABC)` check somewhere, or a place that relies on the `abc.ABC` metaclass. Search the codebase: `grep -rn "abc\.ABC\|isinstance.*ABC" lite_bootstrap/ tests/`. If only `bootstrappers/base.py` matches (BaseBootstrapper still uses ABC), all good. - -### Step 5: Run lint - -```bash -just lint -``` - -Expected: clean. Watch for `F401` warning on the removed `import abc` — should not fire if the import was actually removed. - -### Step 6: Commit - -Stage exactly three files: - -```bash -git add \ - lite_bootstrap/instruments/base.py \ - lite_bootstrap/instruments/swagger_instrument.py \ - lite_bootstrap/instruments/prometheus_instrument.py -git commit -m "$(cat <<'EOF' -refactor: drop unused abc.ABC from BaseInstrument; document config holders - -REF-3: BaseInstrument inherited from abc.ABC but defined no abstract -methods. After PR7 made the class generic and removed the # noqa: B027 -suppressions, abc.ABC serves no purpose — all four methods -(bootstrap, teardown, is_ready, check_dependencies) are concrete -no-ops with sensible defaults. Drop the abc.ABC parent and the now- -unused `import abc`. - -BaseBootstrapper still uses abc.ABC (it has real abstract methods: -not_ready_message, _prepare_application, is_ready) and is unchanged. - -REF-5: Add one-line module docstrings to swagger_instrument.py and -prometheus_instrument.py explaining that these files hold config and -minimal base logic; framework-specific bootstrap behavior lives in -the bootstrapper subclasses (FastAPISwaggerInstrument, etc.). These -files were left as separate modules per the locked decision in the -deferred-refactors sequencing spec. - -No behavior change. - -Closes REF-3 and REF-5 from the audit. -EOF -)" -``` - ---- - -## Task 3: Push and open PR - -- [ ] **Step 1: Push the branch** - -```bash -git push -u origin refactor/ref-3-5-base-layer -``` - -- [ ] **Step 2: Open the PR** - -```bash -gh pr create --title "refactor: drop unused abc.ABC from BaseInstrument; document config holders" --body "$(cat <<'EOF' -## Summary -Two small base-layer cleanups: - -- **REF-3:** `BaseInstrument` inherited from `abc.ABC` but defined no abstract methods. After PR7 made the class generic and removed the `# noqa: B027` suppressions, `abc.ABC` serves no purpose — all four methods (`bootstrap`, `teardown`, `is_ready`, `check_dependencies`) are concrete no-ops with sensible defaults. Drop the `abc.ABC` parent and the now-unused `import abc`. `BaseBootstrapper` still uses `abc.ABC` (it has real abstract methods) and is unchanged. -- **REF-5:** Added one-line module docstrings to `swagger_instrument.py` and `prometheus_instrument.py` explaining that these files hold config and minimal base logic; framework-specific bootstrap behavior lives in the bootstrapper subclasses. Kept as separate modules per the locked decision in the deferred-refactors sequencing spec. - -No behavior change. - -Closes REF-3 and REF-5 from an internal audit. - -## Test plan -- [x] `just test` — 128/128. -- [x] `just lint` — clean. -- [ ] Reviewer: confirm no `isinstance(..., abc.ABC)` check anywhere in the codebase relies on `BaseInstrument`'s ABC parent. - -🤖 Generated with [Claude Code](https://claude.com/claude-code) -EOF -)" -``` - ---- - -## Self-Review - -**Spec coverage check** against the sequencing spec (PR12 section) and audit (REF-3, REF-5): - -| Spec item | Task | -|-----------|------| -| REF-3: drop `abc.ABC` from `BaseInstrument`; drop unused `import abc` | Task 2, Step 1 | -| REF-3: keep `abc.ABC` on `BaseBootstrapper` (not touched) | Task 2, Step 1 (out of scope confirmation) | -| REF-5: docstring on `swagger_instrument.py` | Task 2, Step 2 | -| REF-5: docstring on `prometheus_instrument.py` | Task 2, Step 3 | -| REF-5: keep both files separate (don't collapse) | Locked decisions section | -| Branch name `refactor/ref-3-5-base-layer` | Task 1, Step 1 | -| Verification: `just test` + `just lint` clean | Task 2, Steps 4-5 | - -All spec items covered. No placeholders. - -**Risk:** Low. Both REF-3 and REF-5 are essentially metadata/documentation changes. The only theoretical risk is a downstream consumer relying on `isinstance(inst, abc.ABC)` checks on `BaseInstrument` instances — vanishingly unlikely in practice. The test suite catches it if anything's broken. diff --git a/planning/changes/2026-06-01.03-deferred-refactors/plan-pr13-frozen-setattr.md b/planning/changes/2026-06-01.03-deferred-refactors/plan-pr13-frozen-setattr.md deleted file mode 100644 index e79c9d7..0000000 --- a/planning/changes/2026-06-01.03-deferred-refactors/plan-pr13-frozen-setattr.md +++ /dev/null @@ -1,525 +0,0 @@ -# PR13: Drop `frozen=True` From Instruments + FastAPIConfig Default Cleanup (REF-6 + LOW-4) - -> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. - -**Goal:** Two related-but-distinct cleanups. - -- **REF-6**: Drop `frozen=True` from the instrument hierarchy. Python's dataclass rules force a cascade: dropping `frozen=True` from `LoggingInstrument` requires dropping it from `BaseInstrument`, which requires dropping it from every other instrument subclass. 23 dataclass declarations across 12 files lose `frozen=True`. In `LoggingInstrument` and `OpenTelemetryInstrument`, the four `object.__setattr__(self, "_x", value)` workarounds for cached runtime state become plain `self._x = value` assignments. **Configs stay frozen** — only instruments lose `frozen=True`. - -- **LOW-4**: `FastAPIConfig.application` currently uses `default=None` + `# ty: ignore[invalid-assignment]` because the field is typed `fastapi.FastAPI` (non-Optional). Replace with a proper sentinel-type pattern: introduce `UnsetType` + `UNSET` in `lite_bootstrap/types.py` (a sentinel class with a singleton instance), type the field as `fastapi.FastAPI | UnsetType`, default to `UNSET`, and replace the truthiness check in `__post_init__` with `isinstance(self.application, UnsetType)`. Add a `_narrow_app(config)` helper at module scope that asserts the type and returns the narrowed value; FastAPI framework instruments call `_narrow_app(self.bootstrap_config)` instead of `self.bootstrap_config.application` directly. Drops the `# ty: ignore`. FastAPIConfig stays frozen — `object.__setattr__(self, "application", ...)` in `__post_init__` remains because the freeze bypass is the only way to mutate a frozen field after construction; a code comment documents the rationale. - -**Architecture:** Largest mechanical refactor in the deferred-refactors sequence. The cascade is purely mechanical — every change is `frozen=True` → (delete). The setattr replacements and LOW-4 sentinel are the only meaningful diffs. - -**Tech Stack:** Python 3.10+ dataclasses, frozen-inheritance rules. - -**Parent spec:** `docs/superpowers/specs/2026-06-01-deferred-refactors-sequencing.md` (PR13 section). -**Parent audit:** `docs/superpowers/specs/2026-05-31-bug-refactor-audit.md` (REF-6, LOW-4). - ---- - -## Important context: Why the cascade - -Python's `@dataclasses.dataclass` enforces that **a non-frozen dataclass cannot inherit from a frozen one** (and vice versa). The check fires at class definition time as `TypeError`. - -Today's instrument hierarchy: -- `BaseInstrument(frozen=True)` -- 8 base instrument subclasses, all `frozen=True` -- 15 framework instrument subclasses inheriting from the base instruments, all `frozen=True` - -To drop `frozen=True` from `LoggingInstrument` (REF-6's stated target), `BaseInstrument` must also drop it, which forces every other subclass to drop it too. There's no surgical option. - -The sequencing spec's PR13 section said "drop `frozen=True` from `LoggingInstrument` and `OpenTelemetryInstrument`" — that was incorrect; the cascade is required. This plan implements the cascade. - ---- - -## File Structure - -12 files modified. - -**Instrument modules (9 files):** -- `lite_bootstrap/instruments/base.py` — `BaseInstrument` loses `frozen=True`. -- `lite_bootstrap/instruments/cors_instrument.py` — `CorsInstrument` loses `frozen=True`. -- `lite_bootstrap/instruments/healthchecks_instrument.py` — `HealthChecksInstrument` loses `frozen=True`. -- `lite_bootstrap/instruments/logging_instrument.py` — `LoggingInstrument` loses `frozen=True`; 2 `object.__setattr__` calls become direct assignment. -- `lite_bootstrap/instruments/opentelemetry_instrument.py` — `OpenTelemetryInstrument` loses `frozen=True`; 2 `object.__setattr__` calls become direct assignment. -- `lite_bootstrap/instruments/prometheus_instrument.py` — `PrometheusInstrument` loses `frozen=True`. -- `lite_bootstrap/instruments/pyroscope_instrument.py` — `PyroscopeInstrument` loses `frozen=True`. -- `lite_bootstrap/instruments/sentry_instrument.py` — `SentryInstrument` loses `frozen=True`. -- `lite_bootstrap/instruments/swagger_instrument.py` — `SwaggerInstrument` loses `frozen=True`. - -**Bootstrapper modules (3 files):** -- `lite_bootstrap/bootstrappers/fastapi_bootstrapper.py` — 5 framework instruments lose `frozen=True` + LOW-4 sentinel-type pattern on `FastAPIConfig.application` + `_narrow_app` helper + every FastAPI instrument bootstrap calls `_narrow_app(self.bootstrap_config)` for the app reference. -- `lite_bootstrap/bootstrappers/litestar_bootstrapper.py` — 6 framework instruments lose `frozen=True`. -- `lite_bootstrap/bootstrappers/faststream_bootstrapper.py` — 4 framework instruments lose `frozen=True`. - -**Shared types (1 file):** -- `lite_bootstrap/types.py` — add `UnsetType` class + `UNSET: typing.Final[UnsetType]` singleton. Reusable sentinel for fields that distinguish "not passed" from "explicitly None". - ---- - -## Locked decisions - -- **Cascade scope:** Drop `frozen=True` from `BaseInstrument` and ALL 22 instrument subclasses. Configs stay frozen. Confirmed by the user after the constraint surfaced. -- **LOW-4 pattern:** Proper `UnsetType` sentinel class in `lite_bootstrap/types.py`, used via `isinstance(value, UnsetType)`. Honest to the type checker (no `typing.cast` lie). Adds a `_narrow_app` helper that wraps the assert/return narrowing for callers. Revised from the original plan's `typing.cast("fastapi.FastAPI", object())` pattern — the spec was updated retroactively to match what was built. -- **`FastAPIConfig` stays frozen:** Confirmed by user. The `object.__setattr__(self, "application", ...)` in `__post_init__` remains; a one-line code comment documents the rationale (frozen for user-facing immutability; bypass needed because `application` is constructed using other config fields). -- **No new tests.** The full existing test suite verifies behavior preservation; pure-refactor changes should not affect runtime semantics other than enabling future direct mutation (which we don't exercise). - ---- - -## Cascade list (23 dataclass declarations) - -For traceability, every dataclass decorator changing from `@dataclasses.dataclass(kw_only=True, slots=True, frozen=True)` to `@dataclasses.dataclass(kw_only=True, slots=True)` (or `@dataclasses.dataclass(kw_only=True, frozen=True)` to `@dataclasses.dataclass(kw_only=True)`): - -| # | Class | File | -|---|-------|------| -| 1 | `BaseInstrument` | `instruments/base.py` | -| 2 | `CorsInstrument` | `instruments/cors_instrument.py` | -| 3 | `HealthChecksInstrument` | `instruments/healthchecks_instrument.py` | -| 4 | `LoggingInstrument` | `instruments/logging_instrument.py` | -| 5 | `OpenTelemetryInstrument` | `instruments/opentelemetry_instrument.py` | -| 6 | `PrometheusInstrument` | `instruments/prometheus_instrument.py` | -| 7 | `PyroscopeInstrument` | `instruments/pyroscope_instrument.py` | -| 8 | `SentryInstrument` | `instruments/sentry_instrument.py` | -| 9 | `SwaggerInstrument` | `instruments/swagger_instrument.py` | -| 10 | `FastAPICorsInstrument` | `bootstrappers/fastapi_bootstrapper.py` | -| 11 | `FastAPIHealthChecksInstrument` | `bootstrappers/fastapi_bootstrapper.py` | -| 12 | `FastAPIOpenTelemetryInstrument` | `bootstrappers/fastapi_bootstrapper.py` | -| 13 | `FastAPIPrometheusInstrument` | `bootstrappers/fastapi_bootstrapper.py` | -| 14 | `FastAPISwaggerInstrument` | `bootstrappers/fastapi_bootstrapper.py` | -| 15 | `LitestarCorsInstrument` | `bootstrappers/litestar_bootstrapper.py` | -| 16 | `LitestarHealthChecksInstrument` | `bootstrappers/litestar_bootstrapper.py` | -| 17 | `LitestarLoggingInstrument` | `bootstrappers/litestar_bootstrapper.py` | -| 18 | `LitestarOpenTelemetryInstrument` | `bootstrappers/litestar_bootstrapper.py` | -| 19 | `LitestarPrometheusInstrument` | `bootstrappers/litestar_bootstrapper.py` | -| 20 | `LitestarSwaggerInstrument` | `bootstrappers/litestar_bootstrapper.py` | -| 21 | `FastStreamHealthChecksInstrument` | `bootstrappers/faststream_bootstrapper.py` | -| 22 | `FastStreamLoggingInstrument` | `bootstrappers/faststream_bootstrapper.py` | -| 23 | `FastStreamOpenTelemetryInstrument` | `bootstrappers/faststream_bootstrapper.py` | -| 24 | `FastStreamPrometheusInstrument` | `bootstrappers/faststream_bootstrapper.py` | - -(Yes, that's 24 — `LitestarSwaggerInstrument` is on the list because it inherits from `SwaggerInstrument`. All 24 actually need updating to keep the cascade consistent.) - -**Configs are NOT in this list.** `BaseConfig`, `LoggingConfig`, `SentryConfig`, `OpentelemetryConfig`, `PyroscopeConfig`, `CorsConfig`, `HealthChecksConfig`, `PrometheusConfig`, `SwaggerConfig`, `OpenTelemetryServiceFieldsConfig`, `FastAPIConfig`, `LitestarConfig`, `FastStreamConfig`, `FreeBootstrapperConfig` — all stay frozen. - ---- - -## Task 1: Create branch - -**Files:** (no files; git only) - -- [ ] **Step 1: Branch off `main`** - -```bash -git checkout main -git pull --ff-only origin main -git checkout -b refactor/ref-6-frozen-setattr -``` - -Expected: `Switched to a new branch 'refactor/ref-6-frozen-setattr'`. - ---- - -## Task 2: Drop `frozen=True` from the cascade - -For each file below, the change pattern is identical: locate each instrument's `@dataclasses.dataclass(...)` decorator and remove `, frozen=True` (or `frozen=True,` if it's not last). Configs are untouched. - -### Step 1: `lite_bootstrap/instruments/base.py` - -Locate `BaseInstrument`. Change: - -```python -@dataclasses.dataclass(kw_only=True, slots=True, frozen=True) -class BaseInstrument(typing.Generic[ConfigT]): -``` - -to: - -```python -@dataclasses.dataclass(kw_only=True, slots=True) -class BaseInstrument(typing.Generic[ConfigT]): -``` - -`BaseConfig` (above it) stays untouched — keep `frozen=True`. - -### Step 2: `lite_bootstrap/instruments/cors_instrument.py` - -`CorsInstrument` decorator: drop `frozen=True`. - -### Step 3: `lite_bootstrap/instruments/healthchecks_instrument.py` - -`HealthChecksInstrument` decorator: drop `frozen=True`. - -### Step 4: `lite_bootstrap/instruments/logging_instrument.py` - -`LoggingInstrument` decorator: drop `frozen=True`. - -Then replace 2 `object.__setattr__` calls with direct assignment: - -In `memory_logger_factory` property (around line 109): -```python -# Before: -object.__setattr__(self, "_logger_factory", cached) -# After: -self._logger_factory = cached -``` - -In `teardown` method: -```python -# Before: -try: - self._logger_factory.close_handlers() -finally: - object.__setattr__(self, "_logger_factory", None) - -# After: -try: - self._logger_factory.close_handlers() -finally: - self._logger_factory = None -``` - -### Step 5: `lite_bootstrap/instruments/opentelemetry_instrument.py` - -`OpenTelemetryInstrument` decorator: drop `frozen=True`. - -Then replace 2 `object.__setattr__` calls: - -In `bootstrap()`: -```python -# Before: -object.__setattr__(self, "_tracer_provider", tracer_provider) -# After: -self._tracer_provider = tracer_provider -``` - -In `teardown()`: -```python -# Before: -try: - self._tracer_provider.shutdown() -finally: - object.__setattr__(self, "_tracer_provider", None) - -# After: -try: - self._tracer_provider.shutdown() -finally: - self._tracer_provider = None -``` - -### Step 6: `lite_bootstrap/instruments/prometheus_instrument.py` - -`PrometheusInstrument` decorator: drop `frozen=True`. - -### Step 7: `lite_bootstrap/instruments/pyroscope_instrument.py` - -`PyroscopeInstrument` decorator: drop `frozen=True`. - -### Step 8: `lite_bootstrap/instruments/sentry_instrument.py` - -`SentryInstrument` decorator: drop `frozen=True`. - -### Step 9: `lite_bootstrap/instruments/swagger_instrument.py` - -`SwaggerInstrument` decorator: drop `frozen=True`. - -### Step 10: `lite_bootstrap/bootstrappers/fastapi_bootstrapper.py` — 5 framework instruments - -Drop `frozen=True` from the decorators of: -- `FastAPICorsInstrument` -- `FastAPIHealthChecksInstrument` -- `FastAPIOpenTelemetryInstrument` -- `FastAPIPrometheusInstrument` -- `FastAPISwaggerInstrument` - -Each uses `@dataclasses.dataclass(kw_only=True, frozen=True)` (no `slots=True`). After: `@dataclasses.dataclass(kw_only=True)`. - -`FastAPIConfig` stays untouched here (frozen). The LOW-4 sentinel change comes in Task 3. - -### Step 11: `lite_bootstrap/bootstrappers/litestar_bootstrapper.py` — 6 framework instruments - -Drop `frozen=True` from: -- `LitestarCorsInstrument` -- `LitestarHealthChecksInstrument` -- `LitestarLoggingInstrument` -- `LitestarOpenTelemetryInstrument` -- `LitestarPrometheusInstrument` -- `LitestarSwaggerInstrument` - -`LitestarConfig` stays frozen. - -### Step 12: `lite_bootstrap/bootstrappers/faststream_bootstrapper.py` — 4 framework instruments - -Drop `frozen=True` from: -- `FastStreamHealthChecksInstrument` -- `FastStreamLoggingInstrument` -- `FastStreamOpenTelemetryInstrument` -- `FastStreamPrometheusInstrument` - -`FastStreamConfig` stays frozen. - -### Step 13: Quick verification - -```bash -just test -``` - -Expected: 128/128 PASS. If anything fails, the most likely cause is a `frozen=True` left in one of the 24 classes (Python's TypeError surfaces immediately on import). - -Run a sanity grep to confirm no instrument-class declaration still has `frozen=True`: - -```bash -grep -rn "frozen=True" lite_bootstrap/instruments/ lite_bootstrap/bootstrappers/ | grep -v "Config" -``` - -Expected: zero matches. The `grep -v "Config"` filters out config classes (which should still have `frozen=True`). - ---- - -## Task 3: LOW-4 — FastAPIConfig sentinel pattern - -**File:** `lite_bootstrap/bootstrappers/fastapi_bootstrapper.py` - -### Step 1: Add module-level sentinel and update `FastAPIConfig` - -Locate the top of the file (after the conditional imports for fastapi). Add this constant right after the `if import_checker.is_fastapi_installed:` block: - -```python -if import_checker.is_fastapi_installed: - import fastapi - from fastapi.middleware.cors import CORSMiddleware - from fastapi.routing import _merge_lifespan_context - -# ... other conditional imports stay as they are ... - -_UNSET_FASTAPI_APP: typing.Final = typing.cast("fastapi.FastAPI", object()) -``` - -`typing.cast(...)` is a runtime no-op (returns the second argument). The type checker sees `_UNSET_FASTAPI_APP` as `fastapi.FastAPI`; at runtime it's a unique `object()` sentinel. `typing.Final` prevents accidental reassignment. - -The string-quoted `"fastapi.FastAPI"` in the cast lets the line evaluate even when `fastapi` isn't installed (cast doesn't look up the type at runtime). - -### Step 2: Update `FastAPIConfig.application` field declaration and `__post_init__` - -Locate `FastAPIConfig`. Current: - -```python -@dataclasses.dataclass(kw_only=True, slots=True, frozen=True) -class FastAPIConfig( - CorsConfig, - ... -): - application: "fastapi.FastAPI" = dataclasses.field(default=None) # ty: ignore[invalid-assignment] - application_kwargs: dict[str, typing.Any] = dataclasses.field(default_factory=dict) - ... - - def __post_init__(self) -> None: - if not import_checker.is_fastapi_installed: - msg = "fastapi is not installed" - raise ConfigurationError(msg) - - if not self.application: - object.__setattr__( - self, "application", fastapi.FastAPI(docs_url=self.swagger_path, **self.application_kwargs) - ) - elif self.application_kwargs: - warnings.warn("application_kwargs must be used without application", stacklevel=2) - - self.application.title = self.service_name - self.application.debug = self.service_debug - self.application.version = self.service_version -``` - -Change two things: - -1. Replace `application: "fastapi.FastAPI" = dataclasses.field(default=None) # ty: ignore[invalid-assignment]` with `application: "fastapi.FastAPI" = _UNSET_FASTAPI_APP`. Drop the `# ty: ignore`. - -2. In `__post_init__`, replace `if not self.application:` with `if self.application is _UNSET_FASTAPI_APP:`. Identity check instead of truthiness — clearer intent. - -After: - -```python -@dataclasses.dataclass(kw_only=True, slots=True, frozen=True) -class FastAPIConfig( - CorsConfig, - ... -): - application: "fastapi.FastAPI" = _UNSET_FASTAPI_APP - application_kwargs: dict[str, typing.Any] = dataclasses.field(default_factory=dict) - ... - - def __post_init__(self) -> None: - if not import_checker.is_fastapi_installed: - msg = "fastapi is not installed" - raise ConfigurationError(msg) - - if self.application is _UNSET_FASTAPI_APP: - object.__setattr__( - self, "application", fastapi.FastAPI(docs_url=self.swagger_path, **self.application_kwargs) - ) - elif self.application_kwargs: - warnings.warn("application_kwargs must be used without application", stacklevel=2) - - self.application.title = self.service_name - self.application.debug = self.service_debug - self.application.version = self.service_version -``` - -The `object.__setattr__` for `self.application` stays — `FastAPIConfig` is still frozen. - -The `self.application.title = ...` lines below also stay — they mutate the `fastapi.FastAPI` instance (which isn't frozen), not the `FastAPIConfig` dataclass. - ---- - -## Task 4: Verify and commit - -### Step 1: Run the full test suite - -```bash -just test -``` - -Expected: 128/128 PASS. The cascade is mechanical and should not affect runtime behavior. Watch for surprises in: - -- All framework integration tests (FastAPI, Litestar, FastStream, Free) — they exercise the full instrument lifecycle. -- `test_logging_instrument_lifecycle_replay` (from PR11) — confirms the `_logger_factory` direct-assignment doesn't break the replay cycle. -- `test_opentelemetry_instrument_teardown_shuts_down_tracer_provider` (from PR2) — confirms the `_tracer_provider` direct-assignment doesn't break shutdown. - -If any test fails because something now mutates an instrument unexpectedly, that's a real bug surfaced by the refactor (frozen was masking it). Stop and investigate. - -### Step 2: Run lint - -```bash -just lint -``` - -Expected: clean. The `# ty: ignore[invalid-assignment]` is gone from `FastAPIConfig.application`. No new lint warnings should appear. - -### Step 3: Verify the cascade - -```bash -grep -n "frozen=True" lite_bootstrap/instruments/*.py lite_bootstrap/bootstrappers/*.py -``` - -Expected matches: ONLY on config classes (`BaseConfig`, `LoggingConfig`, `SentryConfig`, `OpentelemetryConfig`, `PyroscopeConfig`, `CorsConfig`, `HealthChecksConfig`, `PrometheusConfig`, `SwaggerConfig`, `OpenTelemetryServiceFieldsConfig`, `FastAPIConfig`, `LitestarConfig`, `FastStreamConfig`, `FreeBootstrapperConfig`). - -No instrument class should match. If one does, that's a missed cascade entry — fix it before committing. - -### Step 4: Commit - -Stage exactly the 12 modified files: - -```bash -git add \ - lite_bootstrap/instruments/base.py \ - lite_bootstrap/instruments/cors_instrument.py \ - lite_bootstrap/instruments/healthchecks_instrument.py \ - lite_bootstrap/instruments/logging_instrument.py \ - lite_bootstrap/instruments/opentelemetry_instrument.py \ - lite_bootstrap/instruments/prometheus_instrument.py \ - lite_bootstrap/instruments/pyroscope_instrument.py \ - lite_bootstrap/instruments/sentry_instrument.py \ - lite_bootstrap/instruments/swagger_instrument.py \ - lite_bootstrap/bootstrappers/fastapi_bootstrapper.py \ - lite_bootstrap/bootstrappers/litestar_bootstrapper.py \ - lite_bootstrap/bootstrappers/faststream_bootstrapper.py -git commit -m "$(cat <<'EOF' -refactor: drop frozen=True from instruments; sentinel for FastAPIConfig.application - -REF-6: LoggingInstrument and OpenTelemetryInstrument cached mutable -runtime state (_logger_factory, _tracer_provider) via -object.__setattr__ workarounds because the instruments were declared -frozen=True. The frozen claim was partly false — those two fields -mutated freely under the hood. - -Python's dataclass rules forbid surgically dropping frozen=True from -a subclass while the parent remains frozen (TypeError at class -definition). The fix cascades through BaseInstrument and all 22 -instrument subclasses: drop frozen=True from each. Configs stay -frozen — only instruments lose immutability. The 4 object.__setattr__ -call sites in LoggingInstrument and OpenTelemetryInstrument -(bootstrap-cache + teardown-reset for each) become plain self._x = ... -assignments. - -LOW-4: FastAPIConfig.application declared default=None with a -# ty: ignore[invalid-assignment] because the field is typed -fastapi.FastAPI (non-Optional). Replace with a typed sentinel: -_UNSET_FASTAPI_APP: typing.Final = typing.cast("fastapi.FastAPI", object()) -The cast suppresses the type lie; the sentinel makes __post_init__'s -identity check (is _UNSET_FASTAPI_APP) clearer than the prior -truthiness check (not self.application). FastAPIConfig stays frozen, -so the object.__setattr__(self, "application", ...) in __post_init__ -remains — only the default and the check change. - -No behavior change. 128/128 tests pass. - -Closes REF-6 and LOW-4 from the audit. -EOF -)" -``` - ---- - -## Task 5: Push and open PR - -- [ ] **Step 1: Push the branch** - -```bash -git push -u origin refactor/ref-6-frozen-setattr -``` - -- [ ] **Step 2: Open the PR** - -```bash -gh pr create --title "refactor: drop frozen=True from instruments; sentinel for FastAPIConfig.application" --body "$(cat <<'EOF' -## Summary -Two related cleanups in one PR: - -- **REF-6 cascade:** `LoggingInstrument` and `OpenTelemetryInstrument` cached mutable runtime state via `object.__setattr__` workarounds because they were declared `frozen=True`. Python's dataclass rules forbid surgically dropping `frozen=True` from one subclass while the parent stays frozen — the cascade is required. `BaseInstrument` and all 22 instrument subclasses lose `frozen=True`. The 4 `object.__setattr__` call sites in `LoggingInstrument` and `OpenTelemetryInstrument` become plain `self._x = ...` assignments. -- **LOW-4:** `FastAPIConfig.application` used `default=None` with a `# ty: ignore`. Replaced with a typed sentinel `_UNSET_FASTAPI_APP: typing.Final = typing.cast("fastapi.FastAPI", object())`. The `__post_init__` check changes from `if not self.application:` to `if self.application is _UNSET_FASTAPI_APP:`. `FastAPIConfig` stays frozen — the existing `object.__setattr__(self, "application", ...)` in `__post_init__` remains. - -**Configs are unchanged.** All `*Config` classes keep `frozen=True`. Only instrument classes lose immutability. - -12 files modified; 24 dataclass declarations lose `frozen=True`; 4 `object.__setattr__` call sites simplified; 1 `# ty: ignore` removed. - -No behavior change. 128/128 tests pass. - -Closes REF-6 and LOW-4 from an internal audit. - -## Test plan -- [x] `just test` — 128/128. -- [x] `just lint` — clean (no `# ty: ignore` left in FastAPIConfig). -- [x] `grep -n "frozen=True" lite_bootstrap/instruments/ lite_bootstrap/bootstrappers/` — only config classes match. -- [ ] Reviewer: confirm `LoggingInstrument`'s and `OpenTelemetryInstrument`'s direct assignments preserve the `try/finally` exception safety from PR3. - -## Why the cascade -Python's `@dataclasses.dataclass` enforces that a non-frozen dataclass cannot inherit from a frozen one (and vice versa) — `TypeError` at class definition. To drop `frozen=True` from `LoggingInstrument` (REF-6's stated target), `BaseInstrument` must also drop it, which propagates to every other instrument subclass. The sequencing spec's PR13 section called for a surgical 2-class change; the actual cascade is 24 classes. - -🤖 Generated with [Claude Code](https://claude.com/claude-code) -EOF -)" -``` - ---- - -## Self-Review - -**Spec coverage check** against the sequencing spec (PR13 section) and audit (REF-6, LOW-4): - -| Spec item | Task | -|-----------|------| -| Drop `frozen=True` from instrument hierarchy (cascade) | Task 2, Steps 1-12 | -| Replace `object.__setattr__` in `LoggingInstrument` with direct assignment | Task 2, Step 4 | -| Replace `object.__setattr__` in `OpenTelemetryInstrument` with direct assignment | Task 2, Step 5 | -| Configs stay frozen | Locked decisions + Task 4 Step 3 verification | -| LOW-4: replace `default=None` + `# ty: ignore` with sentinel | Task 3 | -| Branch name `refactor/ref-6-frozen-setattr` | Task 1, Step 1 | -| Verification: `just test` + `just lint` clean | Task 4, Steps 1-2 | - -All spec items covered. No placeholders. - -**Risk:** Medium. The cascade is mechanical but touches many classes. The chief risk is a missed entry — the verification grep at Task 4 Step 3 catches that. - -The behavioral risk is essentially zero: nothing in the codebase currently mutates an instrument after construction except the 4 `object.__setattr__` calls being replaced. Tests verify the cycles still work. - -**Deviation from sequencing spec:** Locked decision said "drop frozen=True from LoggingInstrument and OpenTelemetryInstrument" — implementation required the full 24-class cascade. Documented in the commit message and PR body. The sequencing spec should be updated retroactively (out of scope for this PR; can be a follow-up doc commit). diff --git a/planning/changes/2026-06-01.03-deferred-refactors/plan-pr14-faststream-timeout.md b/planning/changes/2026-06-01.03-deferred-refactors/plan-pr14-faststream-timeout.md deleted file mode 100644 index 705a3c8..0000000 --- a/planning/changes/2026-06-01.03-deferred-refactors/plan-pr14-faststream-timeout.md +++ /dev/null @@ -1,314 +0,0 @@ -# PR14: Configurable FastStream Broker Health-Check Timeout (REF-7) - -> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. - -**Goal:** `FastStreamHealthChecksInstrument._define_health_status` calls `broker.ping(timeout=5)` with a hardcoded 5-second timeout. For users with slow brokers (large Redis clusters, message queues with cold connections), this is a footgun. Add a `faststream_health_check_broker_timeout: float = 5.0` field on `FastStreamConfig` and wire it through. - -**Architecture:** Pure additive config change. New field with backward-compatible default; existing callers see no behavior difference. One new regression test. - -**Tech Stack:** Python 3.10+ dataclasses, faststream, `unittest.mock.AsyncMock`. - -**Parent spec:** `docs/superpowers/specs/2026-06-01-deferred-refactors-sequencing.md` (PR14 section). -**Parent audit:** `docs/superpowers/specs/2026-05-31-bug-refactor-audit.md` (REF-7). - ---- - -## File Structure - -Two files modified. - -- Modify: `lite_bootstrap/bootstrappers/faststream_bootstrapper.py` — add `faststream_health_check_broker_timeout: float = 5.0` to `FastStreamConfig`; update `FastStreamHealthChecksInstrument._define_health_status` to use it. -- Modify: `tests/test_faststream_bootstrap.py` — add `test_faststream_health_check_uses_configured_broker_timeout` exercising a non-default timeout. - ---- - -## Locked decisions (from sequencing spec) - -- **Field placement:** `FastStreamConfig`, NOT shared `HealthChecksConfig`. The timeout is FastStream-shaped (it's specifically about a message broker ping), so it belongs on the FastStream-specific config alongside other FastStream-only fields (`faststream_log_level`, `opentelemetry_middleware_cls`, etc.). Putting it on `HealthChecksConfig` would pollute FastAPI/Litestar configs with an unused field. -- **Default `5.0`:** Preserves existing behavior. Users who don't set the field see no change. -- **Field name:** `faststream_health_check_broker_timeout`. Prefixed `faststream_` for consistency with the other FastStream-specific fields on this config. - ---- - -## Task 1: Create branch - -**Files:** (no files; git only) - -- [ ] **Step 1: Branch off `main`** - -```bash -git checkout main -git pull --ff-only origin main -git checkout -b fix/ref-7-faststream-timeout -``` - -Expected: `Switched to a new branch 'fix/ref-7-faststream-timeout'`. - ---- - -## Task 2: Add the failing regression test - -**File:** `tests/test_faststream_bootstrap.py` - -The existing file uses `RedisBroker` fixtures and `TestClient` from starlette to exercise the health check. We'll spy on `broker.ping` to capture the timeout argument. - -### Step 1: Update imports - -Current top of file: - -```python -import logging -import typing - -import faststream.asgi -import pytest -import structlog -from faststream._internal.broker import BrokerUsecase -from faststream._internal.logger.params_storage import ManualLoggerStorage -from faststream.redis import RedisBroker, TestRedisBroker -... -``` - -Add `dataclasses` (stdlib) and `from unittest.mock import AsyncMock, patch` (stdlib). After: - -```python -import dataclasses -import logging -import typing -from unittest.mock import AsyncMock, patch - -import faststream.asgi -import pytest -import structlog -from faststream._internal.broker import BrokerUsecase -from faststream._internal.logger.params_storage import ManualLoggerStorage -from faststream.redis import RedisBroker, TestRedisBroker -... -``` - -(Preserve any existing imports between these — only adding the three new lines in the right import groups.) - -### Step 2: Append the new test - -At the end of the file, add: - -```python -async def test_faststream_health_check_uses_configured_broker_timeout(broker: RedisBroker) -> None: - expected_timeout = 12.5 - config = dataclasses.replace( - build_faststream_config(broker=broker), - faststream_health_check_broker_timeout=expected_timeout, - ) - bootstrapper = FastStreamBootstrapper(bootstrap_config=config) - application = bootstrapper.bootstrap() - try: - with ( - patch.object(broker, "ping", new=AsyncMock(return_value=True)) as mock_ping, - TestClient(app=application) as test_client, - ): - response = test_client.get(config.health_checks_path) - assert response.status_code == status.HTTP_200_OK - mock_ping.assert_called_once_with(timeout=expected_timeout) - finally: - bootstrapper.teardown() -``` - -Contract: -- `dataclasses.replace` on a `FastStreamConfig` (still `frozen=True` post-PR13) creates a new instance overriding only the timeout. -- `patch.object(broker, "ping", new=AsyncMock(return_value=True))` replaces `broker.ping` with an async mock that returns `True` (healthy). -- `TestClient(app=application).get(config.health_checks_path)` triggers the health check, which calls `await broker.ping(timeout=...)`. -- `mock_ping.assert_called_once_with(timeout=expected_timeout)` asserts the configured value reached the broker. - -Note: `expected_timeout = 12.5` extracts the magic value into a named local — per the no-`PLR2004`-noqa policy established in PR10. - -### Step 3: Run the test and verify it FAILS - -```bash -just test -- 'tests/test_faststream_bootstrap.py::test_faststream_health_check_uses_configured_broker_timeout' -v -``` - -Expected: **FAIL** in one of two ways: -- `AttributeError: 'FastStreamConfig' object has no attribute 'faststream_health_check_broker_timeout'` (the field doesn't exist yet on the config). -- Or: `AssertionError: expected call: ping(timeout=12.5)\nactual call: ping(timeout=5)` (if the field is somehow on the config but the instrument still hardcodes `5`). - -Either way, the test should NOT pass before the fix. If it does, stop and investigate. - ---- - -## Task 3: Implement the fix - -**File:** `lite_bootstrap/bootstrappers/faststream_bootstrapper.py` - -### Step 1: Add field to `FastStreamConfig` - -Locate `FastStreamConfig`. Current: - -```python -@dataclasses.dataclass(kw_only=True, slots=True, frozen=True) -class FastStreamConfig( - HealthChecksConfig, LoggingConfig, OpentelemetryConfig, PrometheusConfig, PyroscopeConfig, SentryConfig -): - application: "AsgiFastStream" = dataclasses.field(default_factory=_make_asgi_faststream) - opentelemetry_middleware_cls: type[FastStreamTelemetryMiddlewareProtocol] | None = None - prometheus_middleware_cls: type[FastStreamPrometheusMiddlewareProtocol] | None = None - faststream_log_level: int = logging.WARNING -``` - -Add the new field at the end of the body: - -```python -@dataclasses.dataclass(kw_only=True, slots=True, frozen=True) -class FastStreamConfig( - HealthChecksConfig, LoggingConfig, OpentelemetryConfig, PrometheusConfig, PyroscopeConfig, SentryConfig -): - application: "AsgiFastStream" = dataclasses.field(default_factory=_make_asgi_faststream) - opentelemetry_middleware_cls: type[FastStreamTelemetryMiddlewareProtocol] | None = None - prometheus_middleware_cls: type[FastStreamPrometheusMiddlewareProtocol] | None = None - faststream_log_level: int = logging.WARNING - faststream_health_check_broker_timeout: float = 5.0 -``` - -Single additive change. - -### Step 2: Use the field in `FastStreamHealthChecksInstrument._define_health_status` - -Locate `_define_health_status`. Current: - -```python - async def _define_health_status(self) -> bool: - if not self.bootstrap_config.application or not self.bootstrap_config.application.broker: - return False - - return await self.bootstrap_config.application.broker.ping(timeout=5) -``` - -Replace the hardcoded `timeout=5` with `timeout=self.bootstrap_config.faststream_health_check_broker_timeout`: - -```python - async def _define_health_status(self) -> bool: - if not self.bootstrap_config.application or not self.bootstrap_config.application.broker: - return False - - return await self.bootstrap_config.application.broker.ping( - timeout=self.bootstrap_config.faststream_health_check_broker_timeout, - ) -``` - -The expression is long enough that ruff will likely format it as multi-line (as shown). If ruff formats differently, accept its choice. - -### Step 3: Run the new test, verify PASS - -```bash -just test -- 'tests/test_faststream_bootstrap.py::test_faststream_health_check_uses_configured_broker_timeout' -v -``` - -Expected: PASS. - -### Step 4: Run the full FastStream test file - -```bash -just test -- tests/test_faststream_bootstrap.py -v -``` - -Expected: all tests PASS. Watch the existing `test_faststream_bootstrap` (which exercises the health check via a real broker connection) — the default `5.0` is identical to the prior hardcoded `5`, so behavior should be unchanged. - -### Step 5: Run the full test suite - -```bash -just test -``` - -Expected: 129/129 (128 prior + 1 new). - -### Step 6: Run lint - -```bash -just lint -``` - -Expected: clean. The new field's `: float = 5.0` annotation should not trigger any ruff complaints; the test's `expected_timeout = 12.5` named-local pattern avoids PLR2004. - -### Step 7: Commit - -Stage the two modified files explicitly: - -```bash -git add \ - lite_bootstrap/bootstrappers/faststream_bootstrapper.py \ - tests/test_faststream_bootstrap.py -git commit -m "$(cat <<'EOF' -feat: configurable broker ping timeout for FastStream health check - -FastStreamHealthChecksInstrument._define_health_status called -broker.ping(timeout=5) with a hardcoded 5-second timeout. For users -with slow brokers (large Redis clusters under load, message queues -with cold connections), this is a footgun. - -Add faststream_health_check_broker_timeout: float = 5.0 to -FastStreamConfig. Default preserves the existing behavior; users can -now override. - -The field lives on FastStreamConfig (not the shared HealthChecksConfig) -because the timeout is FastStream-shaped — it's specifically about a -message broker ping, not a generic concern that FastAPI/Litestar -health checks would share. - -Regression test patches broker.ping to assert the configured timeout -value reaches it. - -Closes REF-7 from the audit. -EOF -)" -``` - ---- - -## Task 4: Push and open PR - -- [ ] **Step 1: Push the branch** - -```bash -git push -u origin fix/ref-7-faststream-timeout -``` - -- [ ] **Step 2: Open the PR** - -```bash -gh pr create --title "feat: configurable broker ping timeout for FastStream health check" --body "$(cat <<'EOF' -## Summary -- Added `faststream_health_check_broker_timeout: float = 5.0` to `FastStreamConfig`. -- `FastStreamHealthChecksInstrument._define_health_status` now reads from the config instead of the previously hardcoded `timeout=5`. -- New regression test patches `broker.ping` and asserts the configured value reaches it. - -Default preserves existing behavior — pure-additive config option. Users with slow brokers can now bump the timeout without forking the library. - -Closes REF-7 from an internal audit. - -## Test plan -- [x] `just test -- 'tests/test_faststream_bootstrap.py::test_faststream_health_check_uses_configured_broker_timeout' -v` — pass. -- [x] `just test` — 129/129. -- [x] `just lint` — clean. -- [ ] Reviewer: confirm the field lives on `FastStreamConfig` (not `HealthChecksConfig`) — this was the locked decision in the sequencing spec because the timeout is FastStream-shaped. - -🤖 Generated with [Claude Code](https://claude.com/claude-code) -EOF -)" -``` - ---- - -## Self-Review - -**Spec coverage check** against the sequencing spec (PR14 section) and audit (REF-7): - -| Spec item | Task | -|-----------|------| -| Add `faststream_health_check_broker_timeout: float = 5.0` to `FastStreamConfig` | Task 3, Step 1 | -| Update `_define_health_status` to use the field instead of hardcoded `5` | Task 3, Step 2 | -| Add a regression test asserting the configured timeout reaches `broker.ping` | Task 2, Step 2 | -| Field on `FastStreamConfig`, NOT `HealthChecksConfig` (locked decision Q5) | Task 3, Step 1 | -| Branch name `fix/ref-7-faststream-timeout` | Task 1, Step 1 | -| Verification: `just test` + `just lint` clean | Task 3, Steps 5-6 | -| `expected_timeout = 12.5` named local (no PLR2004 noqa) | Task 2, Step 2 | - -All spec items covered. No placeholders. Risk: low — additive change with a backward-compatible default. diff --git a/planning/changes/2026-06-01.03-deferred-refactors/plan-pr15-naming-pass.md b/planning/changes/2026-06-01.03-deferred-refactors/plan-pr15-naming-pass.md deleted file mode 100644 index 4613b85..0000000 --- a/planning/changes/2026-06-01.03-deferred-refactors/plan-pr15-naming-pass.md +++ /dev/null @@ -1,393 +0,0 @@ -# PR15: Naming Pass — `Opentelemetry`→`OpenTelemetry` + `FreeBootstrapperConfig`→`FreeConfig` - -> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. - -**Goal:** The final PR of the deferred-refactors sequence. Two API-surface renames with silent backward-compatibility aliases. - -- **Bonus (Otel capitalization)**: `OpentelemetryConfig` → `OpenTelemetryConfig` to match `OpenTelemetryServiceFieldsConfig` (introduced in PR6) and conventional OpenTelemetry capitalization. -- **LOW-7**: `FreeBootstrapperConfig` → `FreeConfig` for consistency with sibling configs (`FastAPIConfig`, `LitestarConfig`, `FastStreamConfig` — none carry the `Bootstrapper` infix). - -Backward compat: silent aliases (`OpentelemetryConfig = OpenTelemetryConfig`, `FreeBootstrapperConfig = FreeConfig`) at module level plus a `FreeBootstrapperConfig` re-export in `lite_bootstrap/__init__.py`. Existing user code that imports the old names continues to work unchanged. - -**Architecture:** Mechanical renames across 11 files (7 production + 4 test). The aliases are simple assignments — same class object, so `isinstance(x, OldName)` and `isinstance(x, NewName)` are interchangeable. - -**Tech Stack:** Python 3.10+ dataclasses, module-level aliases. - -**Parent spec:** `docs/superpowers/specs/2026-06-01-deferred-refactors-sequencing.md` (PR15 section). -**Parent audit:** `docs/superpowers/specs/2026-05-31-bug-refactor-audit.md` (LOW-7). - ---- - -## File Structure - -11 files modified, no new files. - -**Production (7 files):** -- `lite_bootstrap/instruments/opentelemetry_instrument.py` — rename `OpentelemetryConfig` → `OpenTelemetryConfig`; add silent alias. -- `lite_bootstrap/bootstrappers/free_bootstrapper.py` — rename `FreeBootstrapperConfig` → `FreeConfig`; add silent alias; update internal references. -- `lite_bootstrap/bootstrappers/fastapi_bootstrapper.py` — update import and inheritance to `OpenTelemetryConfig`. -- `lite_bootstrap/bootstrappers/litestar_bootstrapper.py` — same. -- `lite_bootstrap/bootstrappers/faststream_bootstrapper.py` — same. -- `lite_bootstrap/__init__.py` — export both `FreeConfig` (canonical) and `FreeBootstrapperConfig` (alias) in `__all__`. - -**Tests (4 files):** -- `tests/test_free_bootstrap.py` — update internal usages to `FreeConfig`. -- `tests/instruments/test_opentelemetry_instrument.py` — update usages to `OpenTelemetryConfig`. -- `tests/instruments/test_logging_instrument.py` — update usages to `OpenTelemetryConfig`. -- `tests/instruments/test_pyroscope_instrument.py` — update usages to `FreeConfig`. - ---- - -## Locked decisions (from sequencing spec, Q2 + Q7) - -- **Silent aliases** (no warn-on-access). Library is small; warn-on-access is overkill for two renames. -- **Both names in `__init__.py`** for `FreeBootstrapperConfig`/`FreeConfig`. `OpentelemetryConfig` is not in `__init__.py` today, so the module-level alias suffices. -- **Update internal references and tests to the new names.** Aliases serve external users, not internal code. Aliases also exercise the new public API via tests. -- **Alias is a class assignment, not a subclass:** `OpentelemetryConfig = OpenTelemetryConfig` — same class object. `isinstance(x, OpentelemetryConfig) is isinstance(x, OpenTelemetryConfig)`. No `__init_subclass__` surprises, no MRO churn. - ---- - -## Cross-cutting concerns - -1. **Pickling.** Class identity is preserved by the alias (same object). New pickles use `OpenTelemetryConfig.__qualname__`. Old pickles (made before the rename, containing `OpentelemetryConfig` in their serialized form) still unpickle because the alias keeps the name resolvable in the module namespace. No data migration needed. - -2. **Import ordering.** The alias line MUST come AFTER the class definition. Standard Python — but easy to get wrong if reordering imports. - -3. **No PLR2004 noqa.** No new magic-value assertions in this PR. - ---- - -## Task 1: Create branch - -**Files:** (no files; git only) - -- [ ] **Step 1: Branch off `main`** - -```bash -git checkout main -git pull --ff-only origin main -git checkout -b refactor/low-7-naming -``` - -Expected: `Switched to a new branch 'refactor/low-7-naming'`. - ---- - -## Task 2: Rename `OpentelemetryConfig` → `OpenTelemetryConfig` - -### Step 1: Rename in `lite_bootstrap/instruments/opentelemetry_instrument.py` - -Locate the class definition (after `OpenTelemetryServiceFieldsConfig` from PR6): - -```python -@dataclasses.dataclass(kw_only=True, frozen=True) -class OpentelemetryConfig(OpenTelemetryServiceFieldsConfig): - ... -``` - -Change `class OpentelemetryConfig` → `class OpenTelemetryConfig`. - -Locate `OpenTelemetryInstrument`'s generic parameter: - -```python -class OpenTelemetryInstrument(BaseInstrument[OpentelemetryConfig]): -``` - -Change to `BaseInstrument[OpenTelemetryConfig]`. - -Locate any other reference to `OpentelemetryConfig` in the file (e.g., field type annotations, function signatures) — there shouldn't be many; the symbol mostly appears in the class definition and the instrument generic. - -**Add the alias at the very end of the file**, after all class declarations: - -```python -# Backward-compatible alias preserved for users importing the old (lowercase t) spelling. -OpentelemetryConfig = OpenTelemetryConfig -``` - -### Step 2: Update inheritance + imports in three framework bootstrappers - -**File:** `lite_bootstrap/bootstrappers/fastapi_bootstrapper.py` - -Locate the import line: - -```python -from lite_bootstrap.instruments.opentelemetry_instrument import OpentelemetryConfig, OpenTelemetryInstrument -``` - -Change `OpentelemetryConfig` → `OpenTelemetryConfig`. - -Locate `FastAPIConfig`'s inheritance: - -```python -class FastAPIConfig( - CorsConfig, - HealthChecksConfig, - LoggingConfig, - OpentelemetryConfig, # ← change to OpenTelemetryConfig - ... -``` - -Change `OpentelemetryConfig` → `OpenTelemetryConfig`. - -**File:** `lite_bootstrap/bootstrappers/litestar_bootstrapper.py` — same two changes. - -**File:** `lite_bootstrap/bootstrappers/faststream_bootstrapper.py` — same two changes. - -### Step 3: Update tests - -**Files:** `tests/instruments/test_opentelemetry_instrument.py`, `tests/instruments/test_logging_instrument.py` - -In each test file, change every `OpentelemetryConfig` reference (in imports and constructor calls) to `OpenTelemetryConfig`. Use Edit's `replace_all=true` for safety: - -```python -# Before: -from lite_bootstrap.instruments.opentelemetry_instrument import OpentelemetryConfig, ... - -# After: -from lite_bootstrap.instruments.opentelemetry_instrument import OpenTelemetryConfig, ... -``` - -And similarly for constructor calls like `OpentelemetryConfig(...)` → `OpenTelemetryConfig(...)`. - -### Step 4: Smoke test after the Otel rename - -```bash -just test -- tests/instruments/test_opentelemetry_instrument.py tests/instruments/test_logging_instrument.py -v -``` - -Expected: all PASS. If anything fails with `NameError`, check that all references were updated. - ---- - -## Task 3: Rename `FreeBootstrapperConfig` → `FreeConfig` - -### Step 1: Rename in `lite_bootstrap/bootstrappers/free_bootstrapper.py` - -Current class: - -```python -@dataclasses.dataclass(kw_only=True, slots=True, frozen=True) -class FreeBootstrapperConfig(LoggingConfig, OpentelemetryConfig, PyroscopeConfig, SentryConfig): ... -``` - -Two changes here: -1. Rename to `class FreeConfig(LoggingConfig, OpenTelemetryConfig, PyroscopeConfig, SentryConfig): ...` (also picks up the Otel rename from Task 2). -2. Update the bootstrapper: - -```python -class FreeBootstrapper(BaseBootstrapper[None]): - ... - instruments_types: typing.ClassVar = [...] - bootstrap_config: FreeBootstrapperConfig # ← change to FreeConfig - not_ready_message = "" - ... - def __init__(self, bootstrap_config: FreeBootstrapperConfig) -> None: # ← change to FreeConfig - super().__init__(bootstrap_config) -``` - -Replace both `FreeBootstrapperConfig` occurrences with `FreeConfig`. - -**Add the alias at the end of the file:** - -```python -# Backward-compatible alias preserved for users importing the old name. -FreeBootstrapperConfig = FreeConfig -``` - -### Step 2: Update `lite_bootstrap/__init__.py` - -Current: - -```python -from lite_bootstrap.bootstrappers.free_bootstrapper import FreeBootstrapper, FreeBootstrapperConfig -... -__all__ = [ - ... - "FreeBootstrapper", - "FreeBootstrapperConfig", - ... -] -``` - -Change to: - -```python -from lite_bootstrap.bootstrappers.free_bootstrapper import FreeBootstrapper, FreeBootstrapperConfig, FreeConfig -... -__all__ = [ - ... - "FreeBootstrapper", - "FreeBootstrapperConfig", - "FreeConfig", - ... -] -``` - -Both names exported. Alphabetical order in `__all__` keeps `FreeBootstrapperConfig` before `FreeConfig`. Add `FreeConfig` after `FreeBootstrapperConfig`. - -### Step 3: Update tests - -**File:** `tests/test_free_bootstrap.py` - -Change every `FreeBootstrapperConfig` → `FreeConfig` (in imports, fixture annotations, constructor calls). Use Edit's `replace_all=true`. - -**File:** `tests/instruments/test_pyroscope_instrument.py` - -The pyroscope tests use `FreeBootstrapperConfig` to exercise the inheritance-through-Free path. Change references to `FreeConfig`. - -### Step 4: Smoke test after the Free rename - -```bash -just test -- tests/test_free_bootstrap.py tests/instruments/test_pyroscope_instrument.py -v -``` - -Expected: all PASS. - ---- - -## Task 4: Verify everything, commit - -### Step 1: Run the full test suite - -```bash -just test -``` - -Expected: 129/129 PASS. No behavior change; just symbol renames. - -### Step 2: Verify the aliases work for external imports - -```bash -uv run python -c "from lite_bootstrap import FreeBootstrapperConfig, FreeConfig; assert FreeBootstrapperConfig is FreeConfig; print('FreeConfig alias OK')" -uv run python -c "from lite_bootstrap.instruments.opentelemetry_instrument import OpentelemetryConfig, OpenTelemetryConfig; assert OpentelemetryConfig is OpenTelemetryConfig; print('OpenTelemetryConfig alias OK')" -``` - -Both should print `... OK`. If either fails, the alias is broken. - -### Step 3: Run lint - -```bash -just lint -``` - -Expected: clean. Watch for: -- `ruff format` may reorder imports — accept its formatting. -- `ty` should be happy with both names since they're the same class. - -### Step 4: Sanity grep — verify no leftover old-name references in internal code - -```bash -grep -rn "OpentelemetryConfig\|FreeBootstrapperConfig" lite_bootstrap/ tests/ --include="*.py" | grep -v "alias\|backward" -``` - -Expected: ONLY the two alias-definition lines (`OpentelemetryConfig = OpenTelemetryConfig` and `FreeBootstrapperConfig = FreeConfig`) PLUS the `__init__.py` import/export entries (3 matches total for `FreeBootstrapperConfig`: import line, `__all__` entry, alias line; 1 match total for `OpentelemetryConfig`: the alias line). - -If any other `*.py` file has a reference to the old names outside these alias contexts, that's a missed update — fix it. - -### Step 5: Commit - -Stage all 11 files explicitly: - -```bash -git add \ - lite_bootstrap/instruments/opentelemetry_instrument.py \ - lite_bootstrap/bootstrappers/free_bootstrapper.py \ - lite_bootstrap/bootstrappers/fastapi_bootstrapper.py \ - lite_bootstrap/bootstrappers/litestar_bootstrapper.py \ - lite_bootstrap/bootstrappers/faststream_bootstrapper.py \ - lite_bootstrap/__init__.py \ - tests/test_free_bootstrap.py \ - tests/instruments/test_opentelemetry_instrument.py \ - tests/instruments/test_logging_instrument.py \ - tests/instruments/test_pyroscope_instrument.py -git commit -m "$(cat <<'EOF' -refactor: rename OpentelemetryConfig → OpenTelemetryConfig; FreeBootstrapperConfig → FreeConfig - -Two API-surface renames with silent backward-compatibility aliases. - -OpentelemetryConfig → OpenTelemetryConfig: matches the conventional -OpenTelemetry capitalization and the OpenTelemetryServiceFieldsConfig -mixin introduced in PR6. Module-level alias `OpentelemetryConfig = -OpenTelemetryConfig` preserves existing imports. Not exported from -__init__.py (wasn't before either). - -FreeBootstrapperConfig → FreeConfig: matches the sibling configs -(FastAPIConfig, LitestarConfig, FastStreamConfig — none carry the -"Bootstrapper" infix). Module-level alias plus `FreeBootstrapperConfig` -re-export in __init__.py preserves existing public imports. - -Internal references and tests updated to the new canonical names. -Aliases are simple class assignments — same class object, so -isinstance(x, OldName) and isinstance(x, NewName) are interchangeable. -Old pickles continue to unpickle via the alias. - -No behavior change. 129/129 tests pass. - -Closes LOW-7 from the audit. Also closes the bonus Otel capitalization -item surfaced during PR6's code review. -EOF -)" -``` - ---- - -## Task 5: Push and open PR - -- [ ] **Step 1: Push the branch** - -```bash -git push -u origin refactor/low-7-naming -``` - -- [ ] **Step 2: Open the PR** - -```bash -gh pr create --title "refactor: rename OpentelemetryConfig → OpenTelemetryConfig; FreeBootstrapperConfig → FreeConfig" --body "$(cat <<'EOF' -## Summary -The final PR of the deferred-refactors sequence. Two API-surface renames with silent backward-compatibility aliases: - -- **`OpentelemetryConfig` → `OpenTelemetryConfig`** — matches the conventional OpenTelemetry capitalization and the `OpenTelemetryServiceFieldsConfig` mixin from PR6. Module-level alias preserves existing imports. Not exported from `__init__.py` (wasn't before). -- **`FreeBootstrapperConfig` → `FreeConfig`** — matches the sibling configs (`FastAPIConfig`, `LitestarConfig`, `FastStreamConfig` — none carry the `Bootstrapper` infix). Module-level alias plus `FreeBootstrapperConfig` re-export in `__init__.py` preserves existing public imports. - -Internal references and tests updated to the new canonical names. Aliases are simple class assignments — same class object, so `isinstance(x, OldName)` and `isinstance(x, NewName)` are interchangeable. Old pickles continue to unpickle via the alias. - -No behavior change. 129/129 tests pass. - -Closes LOW-7 from an internal audit. Also closes the bonus Otel capitalization item surfaced during PR6's code review. - -## Test plan -- [x] `just test` — 129/129. -- [x] `just lint` — clean. -- [x] Aliases verified working (`isinstance(x, OldName) is isinstance(x, NewName)` for both renames). -- [ ] Reviewer: confirm the aliases are simple class assignments (not subclasses), so isinstance behavior is fully preserved. - -🤖 Generated with [Claude Code](https://claude.com/claude-code) -EOF -)" -``` - ---- - -## Self-Review - -**Spec coverage check** against the sequencing spec (PR15 section) and audit (LOW-7): - -| Spec item | Task | -|-----------|------| -| Rename `OpentelemetryConfig` → `OpenTelemetryConfig` | Task 2, Step 1 | -| Add silent alias `OpentelemetryConfig = OpenTelemetryConfig` | Task 2, Step 1 | -| Rename `FreeBootstrapperConfig` → `FreeConfig` | Task 3, Step 1 | -| Add silent alias `FreeBootstrapperConfig = FreeConfig` | Task 3, Step 1 | -| Export both names from `__init__.py` | Task 3, Step 2 | -| Update internal references in framework bootstrappers | Task 2, Step 2 | -| Update tests to use new canonical names | Tasks 2 Step 3 + 3 Step 3 | -| Verify aliases work (`is` identity) | Task 4, Step 2 | -| Sanity grep for missed references | Task 4, Step 4 | -| Branch name `refactor/low-7-naming` | Task 1, Step 1 | -| Verification: `just test` + `just lint` clean | Task 4, Steps 1, 3 | - -All spec items covered. No placeholders. - -**Risk:** Low. Aliases preserve every existing import. The mechanical rename is well-scoped (11 files, predictable changes). The sanity grep at Task 4 Step 4 catches any miss. - -**Why this is the last PR:** With this merged, all 8 deferred-refactor PRs (PR8-15) close every audit finding except those explicitly marked out-of-scope in the sequencing spec. The audit becomes fully resolved. diff --git a/planning/changes/2026-06-01.03-deferred-refactors/plan-pr16-post-retro-hygiene.md b/planning/changes/2026-06-01.03-deferred-refactors/plan-pr16-post-retro-hygiene.md deleted file mode 100644 index 579ef3e..0000000 --- a/planning/changes/2026-06-01.03-deferred-refactors/plan-pr16-post-retro-hygiene.md +++ /dev/null @@ -1,119 +0,0 @@ -# PR16: Post-Retro Hygiene (uv_build upper bound + Pyroscope endpoint assert) - -**Goal:** Two small hygiene items surfaced during retrospective action-item work. - -**Files:** -- `pyproject.toml` — add upper bound to `uv_build` to silence the every-`just lint` warning -- `lite_bootstrap/instruments/pyroscope_instrument.py` — add a runtime assert on `pyroscope_endpoint` to document the `is_ready()`-enforced invariant - -**Parent docs:** Surfaced in the [audit retrospective](../../retros/2026-06-01-audit-implementation-retro.md). Neither is an audit finding; both noticed during the retro action-item work (`just lint` warning persistence + Pyright's `reportArgumentType` on pyroscope's `server_address`). - -This is the first PR using the [lightweight plan template](../templates/lightweight-plan-template.md). Eat your own dog food. - ---- - -## Diff - -### `pyproject.toml` - -```python -# Before: -[build-system] -requires = ["uv_build"] -build-backend = "uv_build" - -# After: -[build-system] -requires = ["uv_build<0.12"] -build-backend = "uv_build" -``` - -The upper bound aligns with the warning's own suggestion (`Without bounding the uv_build version, the source distribution will break when a future, breaking version of uv_build is released. ...such as <0.12`). Pinning to <0.12 matches the major version we're on; the next breaking change is the next major. - -### `lite_bootstrap/instruments/pyroscope_instrument.py` - -In `PyroscopeInstrument.bootstrap()`, add an assert at the top documenting the precondition that `is_ready()` enforces: - -```python -# Before: -def bootstrap(self) -> None: - namespace = self.bootstrap_config.opentelemetry_namespace - tags = ({"service_namespace": namespace} if namespace else {}) | self.bootstrap_config.pyroscope_tags - pyroscope.configure( - application_name=self.bootstrap_config.opentelemetry_service_name or self.bootstrap_config.service_name, - server_address=self.bootstrap_config.pyroscope_endpoint, - sample_rate=self.bootstrap_config.pyroscope_sample_rate, - tags=tags, - **self.bootstrap_config.pyroscope_additional_params, - ) - -# After: -def bootstrap(self) -> None: - # is_ready() guarantees pyroscope_endpoint is set; assert documents the precondition - # for type narrowing and for direct callers that bypass the bootstrapper. - assert self.bootstrap_config.pyroscope_endpoint is not None - namespace = self.bootstrap_config.opentelemetry_namespace - tags = ({"service_namespace": namespace} if namespace else {}) | self.bootstrap_config.pyroscope_tags - pyroscope.configure( - application_name=self.bootstrap_config.opentelemetry_service_name or self.bootstrap_config.service_name, - server_address=self.bootstrap_config.pyroscope_endpoint, - sample_rate=self.bootstrap_config.pyroscope_sample_rate, - tags=tags, - **self.bootstrap_config.pyroscope_additional_params, - ) -``` - -Why an assert (not a cast): -- The invariant is real: `is_ready()` returns `bool(self.bootstrap_config.pyroscope_endpoint)`, and `BaseBootstrapper._register_or_skip` doesn't call `bootstrap()` if `is_ready()` returned False. -- `assert` runs at runtime and catches direct-bypass callers (e.g., `PyroscopeInstrument(config).bootstrap()` without going through a bootstrapper) with a clear `AssertionError` instead of a confusing pyroscope-side TypeError. -- The project allows `assert` (S101 is in ruff ignores). -- `ty` and Pyright both narrow `str | None` → `str` after the assert. - -No new test. The existing `test_pyroscope_instrument_bootstrap_and_teardown` covers the bootstrap path. - ---- - -## Verification - -1. `grep -n "uv_build" pyproject.toml` — confirm exactly one match, with `<0.12`. -2. `just lint` — the "missing upper bound on uv_build" warning should be gone. Lint stays clean otherwise. -3. `just test -- tests/instruments/test_pyroscope_instrument.py -v` — all pyroscope tests pass (the existing bootstrap test exercises the new assert path). -4. `just test` — full suite 129/129. - -### Pre-flight grep (template requirement) - -```bash -grep -rn "uv_build" pyproject.toml -grep -rn "self\.bootstrap_config\.pyroscope_endpoint" lite_bootstrap/instruments/pyroscope_instrument.py -``` - -Expected: -- `pyproject.toml` shows 2 matches (the `requires` line and `build-backend` line) — only the first changes. -- `pyroscope_instrument.py` shows 2 matches (the `is_ready` check at line ~28 and the `server_address` reference at line ~39); the assert addition is between them. - -## Commit - -```bash -git add pyproject.toml lite_bootstrap/instruments/pyroscope_instrument.py -git commit -m "$(cat <<'EOF' -chore: pin uv_build upper bound; assert pyroscope_endpoint precondition - -uv_build: silence the `just lint` warning about missing upper bound by -pinning to <0.12 (matches the warning's own suggestion). - -Pyroscope: add `assert self.bootstrap_config.pyroscope_endpoint is not None` -at the top of bootstrap(). This documents the precondition that is_ready() -already enforces and narrows the type for both ty and Pyright (was the -only remaining real Pyright complaint after the post-retro suppressions -landed). Direct callers that bypass the bootstrapper now get a clear -AssertionError instead of a TypeError from pyroscope. - -Both items surfaced during retro action-item work. First PR using the -lightweight plan template. -EOF -)" -``` - -## PR - -Branch: `chore/post-retro-hygiene`. Push, open via `gh pr create`. No reviewer asks beyond "diff looks right." diff --git a/planning/changes/2026-06-01.03-deferred-refactors/plan-pr8-low-1-2-sentry-micro.md b/planning/changes/2026-06-01.03-deferred-refactors/plan-pr8-low-1-2-sentry-micro.md deleted file mode 100644 index 198c06b..0000000 --- a/planning/changes/2026-06-01.03-deferred-refactors/plan-pr8-low-1-2-sentry-micro.md +++ /dev/null @@ -1,222 +0,0 @@ -# PR8: Sentry Micro-Fixes (LOW-1 + LOW-2) - -> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. - -**Goal:** Two small idiom/typing fixes in `sentry_instrument.py`: -- LOW-1: Replace `if not callback:` (sloppy callable truthiness check) with `if callback is None:` in `wrap_before_send_callbacks`. -- LOW-2: Replace `SentryConfig.sentry_before_send`'s degenerate `Callable[[Any, Any], Any | None] | None` annotation with the proper `sentry_types.EventProcessor | None`. - -Both are tiny; no behavior change. - -**Architecture:** Single-file edit. Two unrelated micro-fixes bundled because both touch the same module and reviewing them separately would cost more than reviewing them together. - -**Tech Stack:** Python 3.10+, sentry_sdk types (`sentry_sdk._types.EventProcessor`). - -**Parent spec:** `docs/superpowers/specs/2026-06-01-deferred-refactors-sequencing.md` (PR8 section). -**Parent audit:** `docs/superpowers/specs/2026-05-31-bug-refactor-audit.md` (LOW-1, LOW-2). - ---- - -## File Structure - -One file modified. - -- Modify: `lite_bootstrap/instruments/sentry_instrument.py` — two changes (lines 36 and 81-82). - ---- - -## Locked decisions - -- **No new tests.** Existing Sentry tests (`tests/instruments/test_sentry_instrument.py`) cover the affected code paths. LOW-1 is an idiom change with identical runtime behavior for the only realistic input (callables and `None`); LOW-2 is type-only. - ---- - -## Task 1: Create branch - -**Files:** (no files; git only) - -- [ ] **Step 1: Branch off `main`** - -```bash -git checkout main -git pull --ff-only origin main -git checkout -b fix/low-1-2-sentry-micro -``` - -Expected: `Switched to a new branch 'fix/low-1-2-sentry-micro'`. - ---- - -## Task 2: Apply two micro-fixes, verify, commit - -### Step 1: Fix LOW-2 (`sentry_before_send` typing) - -**File:** `lite_bootstrap/instruments/sentry_instrument.py:36` - -Current line: - -```python - sentry_before_send: typing.Callable[[typing.Any, typing.Any], typing.Any | None] | None = None -``` - -This annotation is degenerate: `typing.Any | None` collapses to `typing.Any` (since `Any` is the top type for type-checking purposes), so the outer `| None` is the only meaningful nullability. Functionally the annotation reduces to `Callable[..., Any] | None`. - -The file already imports the proper type under `TYPE_CHECKING` at lines 10-12: - -```python -if typing.TYPE_CHECKING: - from sentry_sdk import _types as sentry_types - from sentry_sdk.integrations import Integration -``` - -And `wrap_before_send_callbacks` (later in the same file) already uses `"sentry_types.EventProcessor"` in its own signature: - -```python -def wrap_before_send_callbacks( - *callbacks: typing.Optional["sentry_types.EventProcessor"], -) -> "sentry_types.EventProcessor": -``` - -So `sentry_types.EventProcessor` is already in scope. Replace line 36 with: - -```python - sentry_before_send: "sentry_types.EventProcessor | None" = None -``` - -The string annotation form avoids a `NameError` at runtime when `sentry_sdk` isn't installed (since `sentry_types` lives under the `TYPE_CHECKING` block). - -### Step 2: Fix LOW-1 (`if not callback:` → `if callback is None:`) - -**File:** `lite_bootstrap/instruments/sentry_instrument.py:80-82` - -Current code: - -```python - def run_before_send( - event: "sentry_types.Event", hint: "sentry_types.Hint" - ) -> typing.Optional["sentry_types.Event"]: - for callback in callbacks: - if not callback: - continue - - temp_event = callback(event, hint) - ... -``` - -The `if not callback:` is checking truthiness, but Python callables are always truthy unless they define a custom `__bool__`. The intent is clearly "skip None entries." Replace `if not callback:` with `if callback is None:` to match the intent: - -```python - def run_before_send( - event: "sentry_types.Event", hint: "sentry_types.Hint" - ) -> typing.Optional["sentry_types.Event"]: - for callback in callbacks: - if callback is None: - continue - - temp_event = callback(event, hint) - ... -``` - -Only the conditional changes; the rest of the function is unchanged. - -### Step 3: Run the Sentry test file - -```bash -just test -- tests/instruments/test_sentry_instrument.py -v -``` - -Expected: all tests PASS. The existing tests cover both `enrich_sentry_event_from_structlog_log` (which goes through `wrap_before_send_callbacks`) and the `SentryInstrument.bootstrap()` path that uses `sentry_before_send`. - -### Step 4: Run the full test suite - -```bash -just test -``` - -Expected: 89/89 (or whatever the current count is after PR7 — should be 89). No behavior change should affect any test. - -### Step 5: Run lint - -```bash -just lint -``` - -Expected: clean. The `# ty: ignore` removal opportunity (if the original line had one — check during implementation) should also clear without warnings. - -### Step 6: Commit - -Stage the single modified file: - -```bash -git add lite_bootstrap/instruments/sentry_instrument.py -git commit -m "$(cat <<'EOF' -fix: tighten Sentry idiom and typing micro-issues - -LOW-1: wrap_before_send_callbacks used `if not callback:` to skip None -entries in *callbacks. Callables are always truthy unless they define -__bool__, so the truthiness check is semantically wrong even if it -happens to work. Use `if callback is None:` to match the intent. - -LOW-2: SentryConfig.sentry_before_send was annotated -`Callable[[Any, Any], Any | None] | None`. The inner `Any | None` -collapses to `Any`, so the annotation reduces to -`Callable[..., Any] | None` — the union adds nothing. Use the proper -`sentry_types.EventProcessor | None` instead (already imported under -TYPE_CHECKING in the same file). - -No behavior change. - -Closes LOW-1 and LOW-2 from the audit. -EOF -)" -``` - ---- - -## Task 3: Push and open PR - -- [ ] **Step 1: Push the branch** - -```bash -git push -u origin fix/low-1-2-sentry-micro -``` - -- [ ] **Step 2: Open the PR** - -```bash -gh pr create --title "fix: tighten Sentry idiom and typing micro-issues" --body "$(cat <<'EOF' -## Summary -Two micro-fixes in \`sentry_instrument.py\`: - -- **LOW-1:** \`wrap_before_send_callbacks\` used \`if not callback:\` to skip \`None\` entries. Callables are always truthy unless they define \`__bool__\`, so the truthiness check is semantically wrong (happens to work, but obscures the intent). Use \`if callback is None:\` instead. -- **LOW-2:** \`SentryConfig.sentry_before_send\` was annotated \`Callable[[Any, Any], Any | None] | None\`. The inner \`Any | None\` collapses to \`Any\`, so the annotation reduces to \`Callable[..., Any] | None\` — the union adds nothing. Use the proper \`sentry_types.EventProcessor | None\` (already imported under \`TYPE_CHECKING\` in the same file). - -No behavior change. No new tests — existing Sentry tests cover both paths. - -Closes LOW-1 and LOW-2 from an internal audit. - -## Test plan -- [x] \`just test -- tests/instruments/test_sentry_instrument.py -v\` — pass. -- [x] \`just test\` — full suite passes. -- [x] \`just lint\` — clean. - -🤖 Generated with [Claude Code](https://claude.com/claude-code) -EOF -)" -``` - ---- - -## Self-Review - -**Spec coverage check** against the sequencing spec (PR8 section) and audit (LOW-1, LOW-2): - -| Spec item | Task | -|-----------|------| -| LOW-1: `if not callback:` → `if callback is None:` | Task 2, Step 2 | -| LOW-2: `sentry_before_send` typing → `sentry_types.EventProcessor \| None` | Task 2, Step 1 | -| Branch name `fix/low-1-2-sentry-micro` | Task 1, Step 1 | -| Verification: `just test` + `just lint` clean | Task 2, Steps 3-5 | -| Single-file diff | Task 2, Step 6 | - -All spec items covered. No placeholders. Smallest PR in the deferred-refactors sequence; both edits are pre-existing patterns already used elsewhere in the same file (string-quoted forward reference for the type; explicit `is None` checks in other modules). diff --git a/planning/changes/2026-06-01.03-deferred-refactors/plan-pr9-otel-touch-ups.md b/planning/changes/2026-06-01.03-deferred-refactors/plan-pr9-otel-touch-ups.md deleted file mode 100644 index 86ca319..0000000 --- a/planning/changes/2026-06-01.03-deferred-refactors/plan-pr9-otel-touch-ups.md +++ /dev/null @@ -1,343 +0,0 @@ -# PR9: OTel Instrument Touch-Ups (REF-1 + LOW-3 + LOW-5) - -> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. - -**Goal:** Three small OTel-area cleanups: -- **REF-1**: Hoist the identical `_build_excluded_urls()` method from `FastAPIOpenTelemetryInstrument` and `LitestarOpenTelemetryInstrument` (character-for-character duplicates) to the base `OpenTelemetryInstrument`. Use `getattr` with safe defaults so the method works even when the base instrument runs against a config without the framework-specific fields. -- **LOW-3**: Change `_format_span`'s line terminator from `os.linesep` to literal `"\n"` (OTel SDK convention; `os.linesep` produces `\r\n` on Windows). -- **LOW-5**: Add a one-line comment on `LitestarOpenTelemetryInstrumentationMiddleware._otel_apps` explaining the `id(next_app)` cache assumption. - -**Architecture:** Three independent micro-changes, all in OTel-area files. No new tests; existing framework integration tests verify the hoist's behavior preservation. - -**Tech Stack:** Python 3.10+, OpenTelemetry SDK. - -**Parent spec:** `docs/superpowers/specs/2026-06-01-deferred-refactors-sequencing.md` (PR9 section). -**Parent audit:** `docs/superpowers/specs/2026-05-31-bug-refactor-audit.md` (REF-1, LOW-3, LOW-5). - ---- - -## File Structure - -Three files modified. - -- Modify: `lite_bootstrap/instruments/opentelemetry_instrument.py` — add `_build_excluded_urls` to the base `OpenTelemetryInstrument`; change `os.linesep` → `"\n"`. -- Modify: `lite_bootstrap/bootstrappers/fastapi_bootstrapper.py` — delete the now-redundant `_build_excluded_urls` override. -- Modify: `lite_bootstrap/bootstrappers/litestar_bootstrapper.py` — delete the now-redundant `_build_excluded_urls` override; add a one-line comment on `_otel_apps`. - ---- - -## Locked decisions (from sequencing spec) - -- **REF-1 resolution:** `_build_excluded_urls` becomes a method on the base `OpenTelemetryInstrument` and reads via `getattr` with safe defaults. Framework subclasses no longer override it. -- **No new tests:** The framework integration tests (`test_fastapi_bootstrap`, `test_litestar_bootstrap`) exercise `_build_excluded_urls` via the `OpenTelemetryInstrument.bootstrap()` → instrumentor middleware chain. Hoisting + getattr is a behavior-preserving move. - ---- - -## Task 1: Create branch - -**Files:** (no files; git only) - -- [ ] **Step 1: Branch off `main`** - -```bash -git checkout main -git pull --ff-only origin main -git checkout -b refactor/ref-1-otel-touch-ups -``` - -Expected: `Switched to a new branch 'refactor/ref-1-otel-touch-ups'`. - ---- - -## Task 2: Apply the three changes, verify, commit - -### Step 1: REF-1 — hoist `_build_excluded_urls` to the base - -**File:** `lite_bootstrap/instruments/opentelemetry_instrument.py` - -Locate the `OpenTelemetryInstrument` class (around lines 77-145 after PR7 + PR3 + PR2 landed). Add the `_build_excluded_urls` method to the class body. The natural place is after `check_dependencies()` and before `bootstrap()`. Insert: - -```python - def _build_excluded_urls(self) -> set[str]: - excluded_urls: set[str] = set(getattr(self.bootstrap_config, "opentelemetry_excluded_urls", [])) - prometheus_path = getattr(self.bootstrap_config, "prometheus_metrics_path", None) - if prometheus_path: - excluded_urls.add(prometheus_path) - if not self.bootstrap_config.opentelemetry_generate_health_check_spans: - health_path = getattr(self.bootstrap_config, "health_checks_path", None) - if health_path: - excluded_urls.add(health_path) - return excluded_urls -``` - -Notes on the `getattr` defaults: -- `opentelemetry_excluded_urls` lives on `FastAPIConfig` and `LitestarConfig` (framework-specific). Default `[]` so the set construction is a no-op when the field is absent. -- `prometheus_metrics_path` lives on `PrometheusConfig` (inherited by FastAPI/Litestar but not by `FreeBootstrapperConfig`). Default `None`; the `if prometheus_path:` guard skips the add when absent. -- `health_checks_path` lives on `HealthChecksConfig` (same inheritance pattern). Default `None`; same guard. -- `opentelemetry_generate_health_check_spans` is on the base `OpentelemetryConfig` (always present); no getattr needed. - -This matches the existing FastAPI/Litestar override behavior exactly when the framework fields are present, and produces a safe empty set when they're absent. - -### Step 2: Delete `_build_excluded_urls` override from `FastAPIOpenTelemetryInstrument` - -**File:** `lite_bootstrap/bootstrappers/fastapi_bootstrapper.py:120-126` - -Locate `FastAPIOpenTelemetryInstrument`. The class currently looks like: - -```python -@dataclasses.dataclass(kw_only=True, frozen=True) -class FastAPIOpenTelemetryInstrument(OpenTelemetryInstrument): - bootstrap_config: FastAPIConfig - - def _build_excluded_urls(self) -> set[str]: - excluded_urls = set(self.bootstrap_config.opentelemetry_excluded_urls) - excluded_urls.add(self.bootstrap_config.prometheus_metrics_path) - if not self.bootstrap_config.opentelemetry_generate_health_check_spans: - excluded_urls.add(self.bootstrap_config.health_checks_path) - - return excluded_urls - - def bootstrap(self) -> None: - super().bootstrap() - FastAPIInstrumentor.instrument_app( - app=self.bootstrap_config.application, - tracer_provider=get_tracer_provider(), - excluded_urls=",".join(self._build_excluded_urls()), - ) - - def teardown(self) -> None: - FastAPIInstrumentor.uninstrument_app(self.bootstrap_config.application) - super().teardown() -``` - -Delete the `_build_excluded_urls` method body (lines 120-126 in the original layout — exact line numbers may have shifted after PR7). The class becomes: - -```python -@dataclasses.dataclass(kw_only=True, frozen=True) -class FastAPIOpenTelemetryInstrument(OpenTelemetryInstrument): - bootstrap_config: FastAPIConfig - - def bootstrap(self) -> None: - super().bootstrap() - FastAPIInstrumentor.instrument_app( - app=self.bootstrap_config.application, - tracer_provider=get_tracer_provider(), - excluded_urls=",".join(self._build_excluded_urls()), - ) - - def teardown(self) -> None: - FastAPIInstrumentor.uninstrument_app(self.bootstrap_config.application) - super().teardown() -``` - -`self._build_excluded_urls()` now resolves to the inherited base method. - -### Step 3: Delete `_build_excluded_urls` override from `LitestarOpenTelemetryInstrument` - -**File:** `lite_bootstrap/bootstrappers/litestar_bootstrapper.py:181-187` - -Locate `LitestarOpenTelemetryInstrument`. The class currently has: - -```python -@dataclasses.dataclass(kw_only=True, frozen=True) -class LitestarOpenTelemetryInstrument(OpenTelemetryInstrument): - bootstrap_config: LitestarConfig - - def _build_excluded_urls(self) -> set[str]: - excluded_urls = set(self.bootstrap_config.opentelemetry_excluded_urls) - excluded_urls.add(self.bootstrap_config.prometheus_metrics_path) - if not self.bootstrap_config.opentelemetry_generate_health_check_spans: - excluded_urls.add(self.bootstrap_config.health_checks_path) - - return excluded_urls - - def bootstrap(self) -> None: - super().bootstrap() - self.bootstrap_config.application_config.middleware.append( - LitestarOpenTelemetryInstrumentationMiddleware( - tracer_provider=get_tracer_provider(), - excluded_urls=self._build_excluded_urls(), - ) - ) -``` - -Delete the `_build_excluded_urls` method body. The class becomes: - -```python -@dataclasses.dataclass(kw_only=True, frozen=True) -class LitestarOpenTelemetryInstrument(OpenTelemetryInstrument): - bootstrap_config: LitestarConfig - - def bootstrap(self) -> None: - super().bootstrap() - self.bootstrap_config.application_config.middleware.append( - LitestarOpenTelemetryInstrumentationMiddleware( - tracer_provider=get_tracer_provider(), - excluded_urls=self._build_excluded_urls(), - ) - ) -``` - -### Step 4: LOW-3 — change `os.linesep` to `"\n"` - -**File:** `lite_bootstrap/instruments/opentelemetry_instrument.py:25-26` - -Current `_format_span`: - -```python -def _format_span(readable_span: "ReadableSpan") -> str: - return typing.cast("str", readable_span.to_json(indent=None)) + os.linesep -``` - -Replace `os.linesep` with `"\n"`: - -```python -def _format_span(readable_span: "ReadableSpan") -> str: - return typing.cast("str", readable_span.to_json(indent=None)) + "\n" -``` - -**Important:** Do NOT remove the `import os` at the top of the file. `os.environ.get("HOSTNAME")` is still used in the `opentelemetry_container_name` default_factory. - -### Step 5: LOW-5 — add comment on `_otel_apps` cache - -**File:** `lite_bootstrap/bootstrappers/litestar_bootstrapper.py` - -Locate the `LitestarOpenTelemetryInstrumentationMiddleware.__init__` (around lines 76-79): - -```python -class LitestarOpenTelemetryInstrumentationMiddleware(ASGIMiddleware): - def __init__(self, tracer_provider: "TracerProvider", excluded_urls: set[str]) -> None: - self._tracer_provider = tracer_provider - self._excluded_urls = ",".join(excluded_urls) - self._otel_apps: dict[int, ASGIApp] = {} -``` - -Add a comment on the `_otel_apps` line: - -```python -class LitestarOpenTelemetryInstrumentationMiddleware(ASGIMiddleware): - def __init__(self, tracer_provider: "TracerProvider", excluded_urls: set[str]) -> None: - self._tracer_provider = tracer_provider - self._excluded_urls = ",".join(excluded_urls) - # Cache keyed by id(next_app); Litestar's ASGI app instances are stable for - # the middleware lifetime, so id-reuse-after-GC isn't a concern. - self._otel_apps: dict[int, ASGIApp] = {} -``` - -### Step 6: Verify with the OTel + framework test files - -```bash -just test -- tests/instruments/test_opentelemetry_instrument.py tests/test_fastapi_bootstrap.py tests/test_litestar_bootstrap.py -v -``` - -Expected: all pass. Particularly watch the FastAPI/Litestar bootstrap tests — they exercise the full bootstrap chain that calls `_build_excluded_urls`. - -### Step 7: Run the full test suite - -```bash -just test -``` - -Expected: 89/89 PASS. No behavior change. - -### Step 8: Run lint - -```bash -just lint -``` - -Expected: clean. Watch for: -- F401 unused import warnings if `os` somehow appears unused (it shouldn't — verify). -- Any ruff complaint about the new base-class method. - -### Step 9: Commit - -Stage exactly the three modified files: - -```bash -git add \ - lite_bootstrap/instruments/opentelemetry_instrument.py \ - lite_bootstrap/bootstrappers/fastapi_bootstrapper.py \ - lite_bootstrap/bootstrappers/litestar_bootstrapper.py -git commit -m "$(cat <<'EOF' -refactor: OTel instrument touch-ups (REF-1, LOW-3, LOW-5) - -REF-1: _build_excluded_urls() was character-for-character identical in -FastAPIOpenTelemetryInstrument and LitestarOpenTelemetryInstrument. -Hoist to the base OpenTelemetryInstrument and use getattr with safe -defaults so the method works when the base instrument runs against a -config without the framework-specific fields (opentelemetry_excluded_urls, -prometheus_metrics_path, health_checks_path). - -LOW-3: _format_span used os.linesep, which produces \r\n on Windows. -The OTel SDK convention is plain \n; change to a literal. - -LOW-5: Add a one-line comment on -LitestarOpenTelemetryInstrumentationMiddleware._otel_apps documenting -the id(next_app) cache assumption (ASGI app instances are stable for -the middleware lifetime). - -No behavior change. - -Closes REF-1, LOW-3, LOW-5 from the audit. -EOF -)" -``` - ---- - -## Task 3: Push and open PR - -- [ ] **Step 1: Push the branch** - -```bash -git push -u origin refactor/ref-1-otel-touch-ups -``` - -- [ ] **Step 2: Open the PR** - -```bash -gh pr create --title "refactor: OTel instrument touch-ups (REF-1, LOW-3, LOW-5)" --body "$(cat <<'EOF' -## Summary -Three small OTel-area cleanups: - -- **REF-1:** \`_build_excluded_urls()\` was character-for-character identical in \`FastAPIOpenTelemetryInstrument\` and \`LitestarOpenTelemetryInstrument\`. Hoist to the base \`OpenTelemetryInstrument\` and use \`getattr\` with safe defaults so the method works when the base instrument runs against a config without the framework-specific fields (\`opentelemetry_excluded_urls\`, \`prometheus_metrics_path\`, \`health_checks_path\`). -- **LOW-3:** \`_format_span\` used \`os.linesep\` (produces \`\\r\\n\` on Windows). OTel SDK convention is plain \`\\n\`; switched to a literal. -- **LOW-5:** Added a one-line comment on \`LitestarOpenTelemetryInstrumentationMiddleware._otel_apps\` documenting the \`id(next_app)\` cache assumption. - -No behavior change. No new tests — existing framework integration tests verify the hoist's behavior preservation. - -Closes REF-1, LOW-3, LOW-5 from an internal audit. - -## Test plan -- [x] \`just test -- tests/instruments/test_opentelemetry_instrument.py tests/test_fastapi_bootstrap.py tests/test_litestar_bootstrap.py -v\` — pass. -- [x] \`just test\` — 89/89. -- [x] \`just lint\` — clean. -- [ ] Reviewer: confirm the \`getattr\` defaults in the hoisted \`_build_excluded_urls\` match the pre-hoist behavior for FastAPI/Litestar (\`opentelemetry_excluded_urls\` default \`[]\`, paths default \`None\` with truthy guard). - -🤖 Generated with [Claude Code](https://claude.com/claude-code) -EOF -)" -``` - ---- - -## Self-Review - -**Spec coverage check** against the sequencing spec (PR9 section) and audit (REF-1, LOW-3, LOW-5): - -| Spec item | Task | -|-----------|------| -| Hoist `_build_excluded_urls` to base with getattr defaults | Task 2, Step 1 | -| Delete `_build_excluded_urls` override from FastAPI | Task 2, Step 2 | -| Delete `_build_excluded_urls` override from Litestar | Task 2, Step 3 | -| `_format_span` newline change | Task 2, Step 4 | -| `_otel_apps` id() cache comment | Task 2, Step 5 | -| Branch name `refactor/ref-1-otel-touch-ups` | Task 1, Step 1 | -| Verification: `just test` + `just lint` clean | Task 2, Steps 6-8 | - -All spec items covered. No placeholders. The hoisted `_build_excluded_urls`'s `getattr` defaults match the pre-hoist behavior: - -- `opentelemetry_excluded_urls` default `[]` → `set([])` → empty set (same as the original `set(...)` over the empty list when the field has a default). -- `prometheus_metrics_path` truthy check ensures `None` is not added; `set.add(None)` would be a real bug. -- `health_checks_path` same truthy check. diff --git a/planning/changes/2026-06-01.03-deferred-refactors/plan.md b/planning/changes/2026-06-01.03-deferred-refactors/plan.md new file mode 100644 index 0000000..84914c8 --- /dev/null +++ b/planning/changes/2026-06-01.03-deferred-refactors/plan.md @@ -0,0 +1,3305 @@ +# 01.03-deferred-refactors — implementation plan + +> Multi-PR plan: this change shipped as a sequence of PRs. Each section below was an independent per-PR plan; they are preserved verbatim here as the bundle's single `plan.md` (the spec is [`design.md`](./design.md)). + + +--- + +# PR8: Sentry Micro-Fixes (LOW-1 + LOW-2) + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Two small idiom/typing fixes in `sentry_instrument.py`: +- LOW-1: Replace `if not callback:` (sloppy callable truthiness check) with `if callback is None:` in `wrap_before_send_callbacks`. +- LOW-2: Replace `SentryConfig.sentry_before_send`'s degenerate `Callable[[Any, Any], Any | None] | None` annotation with the proper `sentry_types.EventProcessor | None`. + +Both are tiny; no behavior change. + +**Architecture:** Single-file edit. Two unrelated micro-fixes bundled because both touch the same module and reviewing them separately would cost more than reviewing them together. + +**Tech Stack:** Python 3.10+, sentry_sdk types (`sentry_sdk._types.EventProcessor`). + +**Parent spec:** `docs/superpowers/specs/2026-06-01-deferred-refactors-sequencing.md` (PR8 section). +**Parent audit:** `docs/superpowers/specs/2026-05-31-bug-refactor-audit.md` (LOW-1, LOW-2). + +--- + +## File Structure + +One file modified. + +- Modify: `lite_bootstrap/instruments/sentry_instrument.py` — two changes (lines 36 and 81-82). + +--- + +## Locked decisions + +- **No new tests.** Existing Sentry tests (`tests/instruments/test_sentry_instrument.py`) cover the affected code paths. LOW-1 is an idiom change with identical runtime behavior for the only realistic input (callables and `None`); LOW-2 is type-only. + +--- + +## Task 1: Create branch + +**Files:** (no files; git only) + +- [ ] **Step 1: Branch off `main`** + +```bash +git checkout main +git pull --ff-only origin main +git checkout -b fix/low-1-2-sentry-micro +``` + +Expected: `Switched to a new branch 'fix/low-1-2-sentry-micro'`. + +--- + +## Task 2: Apply two micro-fixes, verify, commit + +### Step 1: Fix LOW-2 (`sentry_before_send` typing) + +**File:** `lite_bootstrap/instruments/sentry_instrument.py:36` + +Current line: + +```python + sentry_before_send: typing.Callable[[typing.Any, typing.Any], typing.Any | None] | None = None +``` + +This annotation is degenerate: `typing.Any | None` collapses to `typing.Any` (since `Any` is the top type for type-checking purposes), so the outer `| None` is the only meaningful nullability. Functionally the annotation reduces to `Callable[..., Any] | None`. + +The file already imports the proper type under `TYPE_CHECKING` at lines 10-12: + +```python +if typing.TYPE_CHECKING: + from sentry_sdk import _types as sentry_types + from sentry_sdk.integrations import Integration +``` + +And `wrap_before_send_callbacks` (later in the same file) already uses `"sentry_types.EventProcessor"` in its own signature: + +```python +def wrap_before_send_callbacks( + *callbacks: typing.Optional["sentry_types.EventProcessor"], +) -> "sentry_types.EventProcessor": +``` + +So `sentry_types.EventProcessor` is already in scope. Replace line 36 with: + +```python + sentry_before_send: "sentry_types.EventProcessor | None" = None +``` + +The string annotation form avoids a `NameError` at runtime when `sentry_sdk` isn't installed (since `sentry_types` lives under the `TYPE_CHECKING` block). + +### Step 2: Fix LOW-1 (`if not callback:` → `if callback is None:`) + +**File:** `lite_bootstrap/instruments/sentry_instrument.py:80-82` + +Current code: + +```python + def run_before_send( + event: "sentry_types.Event", hint: "sentry_types.Hint" + ) -> typing.Optional["sentry_types.Event"]: + for callback in callbacks: + if not callback: + continue + + temp_event = callback(event, hint) + ... +``` + +The `if not callback:` is checking truthiness, but Python callables are always truthy unless they define a custom `__bool__`. The intent is clearly "skip None entries." Replace `if not callback:` with `if callback is None:` to match the intent: + +```python + def run_before_send( + event: "sentry_types.Event", hint: "sentry_types.Hint" + ) -> typing.Optional["sentry_types.Event"]: + for callback in callbacks: + if callback is None: + continue + + temp_event = callback(event, hint) + ... +``` + +Only the conditional changes; the rest of the function is unchanged. + +### Step 3: Run the Sentry test file + +```bash +just test -- tests/instruments/test_sentry_instrument.py -v +``` + +Expected: all tests PASS. The existing tests cover both `enrich_sentry_event_from_structlog_log` (which goes through `wrap_before_send_callbacks`) and the `SentryInstrument.bootstrap()` path that uses `sentry_before_send`. + +### Step 4: Run the full test suite + +```bash +just test +``` + +Expected: 89/89 (or whatever the current count is after PR7 — should be 89). No behavior change should affect any test. + +### Step 5: Run lint + +```bash +just lint +``` + +Expected: clean. The `# ty: ignore` removal opportunity (if the original line had one — check during implementation) should also clear without warnings. + +### Step 6: Commit + +Stage the single modified file: + +```bash +git add lite_bootstrap/instruments/sentry_instrument.py +git commit -m "$(cat <<'EOF' +fix: tighten Sentry idiom and typing micro-issues + +LOW-1: wrap_before_send_callbacks used `if not callback:` to skip None +entries in *callbacks. Callables are always truthy unless they define +__bool__, so the truthiness check is semantically wrong even if it +happens to work. Use `if callback is None:` to match the intent. + +LOW-2: SentryConfig.sentry_before_send was annotated +`Callable[[Any, Any], Any | None] | None`. The inner `Any | None` +collapses to `Any`, so the annotation reduces to +`Callable[..., Any] | None` — the union adds nothing. Use the proper +`sentry_types.EventProcessor | None` instead (already imported under +TYPE_CHECKING in the same file). + +No behavior change. + +Closes LOW-1 and LOW-2 from the audit. +EOF +)" +``` + +--- + +## Task 3: Push and open PR + +- [ ] **Step 1: Push the branch** + +```bash +git push -u origin fix/low-1-2-sentry-micro +``` + +- [ ] **Step 2: Open the PR** + +```bash +gh pr create --title "fix: tighten Sentry idiom and typing micro-issues" --body "$(cat <<'EOF' +## Summary +Two micro-fixes in \`sentry_instrument.py\`: + +- **LOW-1:** \`wrap_before_send_callbacks\` used \`if not callback:\` to skip \`None\` entries. Callables are always truthy unless they define \`__bool__\`, so the truthiness check is semantically wrong (happens to work, but obscures the intent). Use \`if callback is None:\` instead. +- **LOW-2:** \`SentryConfig.sentry_before_send\` was annotated \`Callable[[Any, Any], Any | None] | None\`. The inner \`Any | None\` collapses to \`Any\`, so the annotation reduces to \`Callable[..., Any] | None\` — the union adds nothing. Use the proper \`sentry_types.EventProcessor | None\` (already imported under \`TYPE_CHECKING\` in the same file). + +No behavior change. No new tests — existing Sentry tests cover both paths. + +Closes LOW-1 and LOW-2 from an internal audit. + +## Test plan +- [x] \`just test -- tests/instruments/test_sentry_instrument.py -v\` — pass. +- [x] \`just test\` — full suite passes. +- [x] \`just lint\` — clean. + +🤖 Generated with [Claude Code](https://claude.com/claude-code) +EOF +)" +``` + +--- + +## Self-Review + +**Spec coverage check** against the sequencing spec (PR8 section) and audit (LOW-1, LOW-2): + +| Spec item | Task | +|-----------|------| +| LOW-1: `if not callback:` → `if callback is None:` | Task 2, Step 2 | +| LOW-2: `sentry_before_send` typing → `sentry_types.EventProcessor \| None` | Task 2, Step 1 | +| Branch name `fix/low-1-2-sentry-micro` | Task 1, Step 1 | +| Verification: `just test` + `just lint` clean | Task 2, Steps 3-5 | +| Single-file diff | Task 2, Step 6 | + +All spec items covered. No placeholders. Smallest PR in the deferred-refactors sequence; both edits are pre-existing patterns already used elsewhere in the same file (string-quoted forward reference for the type; explicit `is None` checks in other modules). + + +--- + +# PR9: OTel Instrument Touch-Ups (REF-1 + LOW-3 + LOW-5) + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Three small OTel-area cleanups: +- **REF-1**: Hoist the identical `_build_excluded_urls()` method from `FastAPIOpenTelemetryInstrument` and `LitestarOpenTelemetryInstrument` (character-for-character duplicates) to the base `OpenTelemetryInstrument`. Use `getattr` with safe defaults so the method works even when the base instrument runs against a config without the framework-specific fields. +- **LOW-3**: Change `_format_span`'s line terminator from `os.linesep` to literal `"\n"` (OTel SDK convention; `os.linesep` produces `\r\n` on Windows). +- **LOW-5**: Add a one-line comment on `LitestarOpenTelemetryInstrumentationMiddleware._otel_apps` explaining the `id(next_app)` cache assumption. + +**Architecture:** Three independent micro-changes, all in OTel-area files. No new tests; existing framework integration tests verify the hoist's behavior preservation. + +**Tech Stack:** Python 3.10+, OpenTelemetry SDK. + +**Parent spec:** `docs/superpowers/specs/2026-06-01-deferred-refactors-sequencing.md` (PR9 section). +**Parent audit:** `docs/superpowers/specs/2026-05-31-bug-refactor-audit.md` (REF-1, LOW-3, LOW-5). + +--- + +## File Structure + +Three files modified. + +- Modify: `lite_bootstrap/instruments/opentelemetry_instrument.py` — add `_build_excluded_urls` to the base `OpenTelemetryInstrument`; change `os.linesep` → `"\n"`. +- Modify: `lite_bootstrap/bootstrappers/fastapi_bootstrapper.py` — delete the now-redundant `_build_excluded_urls` override. +- Modify: `lite_bootstrap/bootstrappers/litestar_bootstrapper.py` — delete the now-redundant `_build_excluded_urls` override; add a one-line comment on `_otel_apps`. + +--- + +## Locked decisions (from sequencing spec) + +- **REF-1 resolution:** `_build_excluded_urls` becomes a method on the base `OpenTelemetryInstrument` and reads via `getattr` with safe defaults. Framework subclasses no longer override it. +- **No new tests:** The framework integration tests (`test_fastapi_bootstrap`, `test_litestar_bootstrap`) exercise `_build_excluded_urls` via the `OpenTelemetryInstrument.bootstrap()` → instrumentor middleware chain. Hoisting + getattr is a behavior-preserving move. + +--- + +## Task 1: Create branch + +**Files:** (no files; git only) + +- [ ] **Step 1: Branch off `main`** + +```bash +git checkout main +git pull --ff-only origin main +git checkout -b refactor/ref-1-otel-touch-ups +``` + +Expected: `Switched to a new branch 'refactor/ref-1-otel-touch-ups'`. + +--- + +## Task 2: Apply the three changes, verify, commit + +### Step 1: REF-1 — hoist `_build_excluded_urls` to the base + +**File:** `lite_bootstrap/instruments/opentelemetry_instrument.py` + +Locate the `OpenTelemetryInstrument` class (around lines 77-145 after PR7 + PR3 + PR2 landed). Add the `_build_excluded_urls` method to the class body. The natural place is after `check_dependencies()` and before `bootstrap()`. Insert: + +```python + def _build_excluded_urls(self) -> set[str]: + excluded_urls: set[str] = set(getattr(self.bootstrap_config, "opentelemetry_excluded_urls", [])) + prometheus_path = getattr(self.bootstrap_config, "prometheus_metrics_path", None) + if prometheus_path: + excluded_urls.add(prometheus_path) + if not self.bootstrap_config.opentelemetry_generate_health_check_spans: + health_path = getattr(self.bootstrap_config, "health_checks_path", None) + if health_path: + excluded_urls.add(health_path) + return excluded_urls +``` + +Notes on the `getattr` defaults: +- `opentelemetry_excluded_urls` lives on `FastAPIConfig` and `LitestarConfig` (framework-specific). Default `[]` so the set construction is a no-op when the field is absent. +- `prometheus_metrics_path` lives on `PrometheusConfig` (inherited by FastAPI/Litestar but not by `FreeBootstrapperConfig`). Default `None`; the `if prometheus_path:` guard skips the add when absent. +- `health_checks_path` lives on `HealthChecksConfig` (same inheritance pattern). Default `None`; same guard. +- `opentelemetry_generate_health_check_spans` is on the base `OpentelemetryConfig` (always present); no getattr needed. + +This matches the existing FastAPI/Litestar override behavior exactly when the framework fields are present, and produces a safe empty set when they're absent. + +### Step 2: Delete `_build_excluded_urls` override from `FastAPIOpenTelemetryInstrument` + +**File:** `lite_bootstrap/bootstrappers/fastapi_bootstrapper.py:120-126` + +Locate `FastAPIOpenTelemetryInstrument`. The class currently looks like: + +```python +@dataclasses.dataclass(kw_only=True, frozen=True) +class FastAPIOpenTelemetryInstrument(OpenTelemetryInstrument): + bootstrap_config: FastAPIConfig + + def _build_excluded_urls(self) -> set[str]: + excluded_urls = set(self.bootstrap_config.opentelemetry_excluded_urls) + excluded_urls.add(self.bootstrap_config.prometheus_metrics_path) + if not self.bootstrap_config.opentelemetry_generate_health_check_spans: + excluded_urls.add(self.bootstrap_config.health_checks_path) + + return excluded_urls + + def bootstrap(self) -> None: + super().bootstrap() + FastAPIInstrumentor.instrument_app( + app=self.bootstrap_config.application, + tracer_provider=get_tracer_provider(), + excluded_urls=",".join(self._build_excluded_urls()), + ) + + def teardown(self) -> None: + FastAPIInstrumentor.uninstrument_app(self.bootstrap_config.application) + super().teardown() +``` + +Delete the `_build_excluded_urls` method body (lines 120-126 in the original layout — exact line numbers may have shifted after PR7). The class becomes: + +```python +@dataclasses.dataclass(kw_only=True, frozen=True) +class FastAPIOpenTelemetryInstrument(OpenTelemetryInstrument): + bootstrap_config: FastAPIConfig + + def bootstrap(self) -> None: + super().bootstrap() + FastAPIInstrumentor.instrument_app( + app=self.bootstrap_config.application, + tracer_provider=get_tracer_provider(), + excluded_urls=",".join(self._build_excluded_urls()), + ) + + def teardown(self) -> None: + FastAPIInstrumentor.uninstrument_app(self.bootstrap_config.application) + super().teardown() +``` + +`self._build_excluded_urls()` now resolves to the inherited base method. + +### Step 3: Delete `_build_excluded_urls` override from `LitestarOpenTelemetryInstrument` + +**File:** `lite_bootstrap/bootstrappers/litestar_bootstrapper.py:181-187` + +Locate `LitestarOpenTelemetryInstrument`. The class currently has: + +```python +@dataclasses.dataclass(kw_only=True, frozen=True) +class LitestarOpenTelemetryInstrument(OpenTelemetryInstrument): + bootstrap_config: LitestarConfig + + def _build_excluded_urls(self) -> set[str]: + excluded_urls = set(self.bootstrap_config.opentelemetry_excluded_urls) + excluded_urls.add(self.bootstrap_config.prometheus_metrics_path) + if not self.bootstrap_config.opentelemetry_generate_health_check_spans: + excluded_urls.add(self.bootstrap_config.health_checks_path) + + return excluded_urls + + def bootstrap(self) -> None: + super().bootstrap() + self.bootstrap_config.application_config.middleware.append( + LitestarOpenTelemetryInstrumentationMiddleware( + tracer_provider=get_tracer_provider(), + excluded_urls=self._build_excluded_urls(), + ) + ) +``` + +Delete the `_build_excluded_urls` method body. The class becomes: + +```python +@dataclasses.dataclass(kw_only=True, frozen=True) +class LitestarOpenTelemetryInstrument(OpenTelemetryInstrument): + bootstrap_config: LitestarConfig + + def bootstrap(self) -> None: + super().bootstrap() + self.bootstrap_config.application_config.middleware.append( + LitestarOpenTelemetryInstrumentationMiddleware( + tracer_provider=get_tracer_provider(), + excluded_urls=self._build_excluded_urls(), + ) + ) +``` + +### Step 4: LOW-3 — change `os.linesep` to `"\n"` + +**File:** `lite_bootstrap/instruments/opentelemetry_instrument.py:25-26` + +Current `_format_span`: + +```python +def _format_span(readable_span: "ReadableSpan") -> str: + return typing.cast("str", readable_span.to_json(indent=None)) + os.linesep +``` + +Replace `os.linesep` with `"\n"`: + +```python +def _format_span(readable_span: "ReadableSpan") -> str: + return typing.cast("str", readable_span.to_json(indent=None)) + "\n" +``` + +**Important:** Do NOT remove the `import os` at the top of the file. `os.environ.get("HOSTNAME")` is still used in the `opentelemetry_container_name` default_factory. + +### Step 5: LOW-5 — add comment on `_otel_apps` cache + +**File:** `lite_bootstrap/bootstrappers/litestar_bootstrapper.py` + +Locate the `LitestarOpenTelemetryInstrumentationMiddleware.__init__` (around lines 76-79): + +```python +class LitestarOpenTelemetryInstrumentationMiddleware(ASGIMiddleware): + def __init__(self, tracer_provider: "TracerProvider", excluded_urls: set[str]) -> None: + self._tracer_provider = tracer_provider + self._excluded_urls = ",".join(excluded_urls) + self._otel_apps: dict[int, ASGIApp] = {} +``` + +Add a comment on the `_otel_apps` line: + +```python +class LitestarOpenTelemetryInstrumentationMiddleware(ASGIMiddleware): + def __init__(self, tracer_provider: "TracerProvider", excluded_urls: set[str]) -> None: + self._tracer_provider = tracer_provider + self._excluded_urls = ",".join(excluded_urls) + # Cache keyed by id(next_app); Litestar's ASGI app instances are stable for + # the middleware lifetime, so id-reuse-after-GC isn't a concern. + self._otel_apps: dict[int, ASGIApp] = {} +``` + +### Step 6: Verify with the OTel + framework test files + +```bash +just test -- tests/instruments/test_opentelemetry_instrument.py tests/test_fastapi_bootstrap.py tests/test_litestar_bootstrap.py -v +``` + +Expected: all pass. Particularly watch the FastAPI/Litestar bootstrap tests — they exercise the full bootstrap chain that calls `_build_excluded_urls`. + +### Step 7: Run the full test suite + +```bash +just test +``` + +Expected: 89/89 PASS. No behavior change. + +### Step 8: Run lint + +```bash +just lint +``` + +Expected: clean. Watch for: +- F401 unused import warnings if `os` somehow appears unused (it shouldn't — verify). +- Any ruff complaint about the new base-class method. + +### Step 9: Commit + +Stage exactly the three modified files: + +```bash +git add \ + lite_bootstrap/instruments/opentelemetry_instrument.py \ + lite_bootstrap/bootstrappers/fastapi_bootstrapper.py \ + lite_bootstrap/bootstrappers/litestar_bootstrapper.py +git commit -m "$(cat <<'EOF' +refactor: OTel instrument touch-ups (REF-1, LOW-3, LOW-5) + +REF-1: _build_excluded_urls() was character-for-character identical in +FastAPIOpenTelemetryInstrument and LitestarOpenTelemetryInstrument. +Hoist to the base OpenTelemetryInstrument and use getattr with safe +defaults so the method works when the base instrument runs against a +config without the framework-specific fields (opentelemetry_excluded_urls, +prometheus_metrics_path, health_checks_path). + +LOW-3: _format_span used os.linesep, which produces \r\n on Windows. +The OTel SDK convention is plain \n; change to a literal. + +LOW-5: Add a one-line comment on +LitestarOpenTelemetryInstrumentationMiddleware._otel_apps documenting +the id(next_app) cache assumption (ASGI app instances are stable for +the middleware lifetime). + +No behavior change. + +Closes REF-1, LOW-3, LOW-5 from the audit. +EOF +)" +``` + +--- + +## Task 3: Push and open PR + +- [ ] **Step 1: Push the branch** + +```bash +git push -u origin refactor/ref-1-otel-touch-ups +``` + +- [ ] **Step 2: Open the PR** + +```bash +gh pr create --title "refactor: OTel instrument touch-ups (REF-1, LOW-3, LOW-5)" --body "$(cat <<'EOF' +## Summary +Three small OTel-area cleanups: + +- **REF-1:** \`_build_excluded_urls()\` was character-for-character identical in \`FastAPIOpenTelemetryInstrument\` and \`LitestarOpenTelemetryInstrument\`. Hoist to the base \`OpenTelemetryInstrument\` and use \`getattr\` with safe defaults so the method works when the base instrument runs against a config without the framework-specific fields (\`opentelemetry_excluded_urls\`, \`prometheus_metrics_path\`, \`health_checks_path\`). +- **LOW-3:** \`_format_span\` used \`os.linesep\` (produces \`\\r\\n\` on Windows). OTel SDK convention is plain \`\\n\`; switched to a literal. +- **LOW-5:** Added a one-line comment on \`LitestarOpenTelemetryInstrumentationMiddleware._otel_apps\` documenting the \`id(next_app)\` cache assumption. + +No behavior change. No new tests — existing framework integration tests verify the hoist's behavior preservation. + +Closes REF-1, LOW-3, LOW-5 from an internal audit. + +## Test plan +- [x] \`just test -- tests/instruments/test_opentelemetry_instrument.py tests/test_fastapi_bootstrap.py tests/test_litestar_bootstrap.py -v\` — pass. +- [x] \`just test\` — 89/89. +- [x] \`just lint\` — clean. +- [ ] Reviewer: confirm the \`getattr\` defaults in the hoisted \`_build_excluded_urls\` match the pre-hoist behavior for FastAPI/Litestar (\`opentelemetry_excluded_urls\` default \`[]\`, paths default \`None\` with truthy guard). + +🤖 Generated with [Claude Code](https://claude.com/claude-code) +EOF +)" +``` + +--- + +## Self-Review + +**Spec coverage check** against the sequencing spec (PR9 section) and audit (REF-1, LOW-3, LOW-5): + +| Spec item | Task | +|-----------|------| +| Hoist `_build_excluded_urls` to base with getattr defaults | Task 2, Step 1 | +| Delete `_build_excluded_urls` override from FastAPI | Task 2, Step 2 | +| Delete `_build_excluded_urls` override from Litestar | Task 2, Step 3 | +| `_format_span` newline change | Task 2, Step 4 | +| `_otel_apps` id() cache comment | Task 2, Step 5 | +| Branch name `refactor/ref-1-otel-touch-ups` | Task 1, Step 1 | +| Verification: `just test` + `just lint` clean | Task 2, Steps 6-8 | + +All spec items covered. No placeholders. The hoisted `_build_excluded_urls`'s `getattr` defaults match the pre-hoist behavior: + +- `opentelemetry_excluded_urls` default `[]` → `set([])` → empty set (same as the original `set(...)` over the empty list when the field has a default). +- `prometheus_metrics_path` truthy check ensures `None` is not added; `set.add(None)` would be a real bug. +- `health_checks_path` same truthy check. + + +--- + +# PR10: Test Gap Fill (TEST-4 + TEST-7) + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Add standalone test files for the four instruments that today are only covered transitively via bootstrapper integration tests (`CorsInstrument`, `HealthChecksInstrument`, `PrometheusInstrument`, `SwaggerInstrument`) and add negative tests for `helpers.path.is_valid_path`. Pure additions; zero production code changes. + +**Architecture:** 5 new test files, no production changes. + +**Tech Stack:** Python 3.10+, pytest, parametrized tests. + +**Parent spec:** `docs/superpowers/specs/2026-06-01-deferred-refactors-sequencing.md` (PR10 section). +**Parent audit:** `docs/superpowers/specs/2026-05-31-bug-refactor-audit.md` (TEST-4, TEST-7). + +--- + +## File Structure + +5 new test files. No existing files modified. + +- Create: `tests/instruments/test_cors_instrument.py` +- Create: `tests/instruments/test_healthchecks_instrument.py` +- Create: `tests/instruments/test_prometheus_instrument.py` +- Create: `tests/instruments/test_swagger_instrument.py` +- Create: `tests/test_path.py` + +--- + +## Locked decisions + +- **Pure additions:** No production code changes. The existing transitive coverage via bootstrapper integration tests is fine; standalone tests just localize regression diagnosis. +- **Test style:** Match the existing simple-function test style in `tests/instruments/test_pyroscope_instrument.py` and `test_opentelemetry_instrument.py` (no fixtures, no shared setup, one function per behavior). + +--- + +## Task 1: Create branch + +**Files:** (no files; git only) + +- [ ] **Step 1: Branch off `main`** + +```bash +git checkout main +git pull --ff-only origin main +git checkout -b fix/test-4-7-gaps +``` + +Expected: `Switched to a new branch 'fix/test-4-7-gaps'`. + +--- + +## Task 2: Create the five test files, verify, commit + +### Step 1: Create `tests/instruments/test_cors_instrument.py` + +```python +from lite_bootstrap.instruments.cors_instrument import CorsConfig, CorsInstrument + + +def test_cors_instrument_not_ready_without_origins_or_regex() -> None: + instrument = CorsInstrument(bootstrap_config=CorsConfig()) + assert not instrument.is_ready() + assert instrument.not_ready_message == "cors_allowed_origins or cors_allowed_origin_regex must be provided" + + +def test_cors_instrument_ready_with_origins() -> None: + instrument = CorsInstrument(bootstrap_config=CorsConfig(cors_allowed_origins=["http://test"])) + assert instrument.is_ready() + + +def test_cors_instrument_ready_with_regex() -> None: + instrument = CorsInstrument(bootstrap_config=CorsConfig(cors_allowed_origin_regex=r"https?://.*")) + assert instrument.is_ready() + + +def test_cors_instrument_ready_with_both() -> None: + instrument = CorsInstrument( + bootstrap_config=CorsConfig( + cors_allowed_origins=["http://test"], + cors_allowed_origin_regex=r"https?://.*", + ), + ) + assert instrument.is_ready() + + +def test_cors_instrument_config_defaults() -> None: + config = CorsConfig() + assert config.cors_allowed_origins == [] + assert config.cors_allowed_methods == [] + assert config.cors_allowed_headers == [] + assert config.cors_exposed_headers == [] + assert config.cors_allowed_credentials is False + assert config.cors_allowed_origin_regex is None + assert config.cors_max_age == 600 + + +def test_cors_check_dependencies() -> None: + assert CorsInstrument.check_dependencies() is True +``` + +### Step 2: Create `tests/instruments/test_healthchecks_instrument.py` + +```python +from lite_bootstrap.instruments.healthchecks_instrument import HealthChecksConfig, HealthChecksInstrument + + +def test_healthchecks_instrument_ready_by_default() -> None: + instrument = HealthChecksInstrument(bootstrap_config=HealthChecksConfig()) + assert instrument.is_ready() + + +def test_healthchecks_instrument_not_ready_when_disabled() -> None: + instrument = HealthChecksInstrument(bootstrap_config=HealthChecksConfig(health_checks_enabled=False)) + assert not instrument.is_ready() + assert instrument.not_ready_message == "health_checks_enabled is False" + + +def test_healthchecks_render_data_default() -> None: + instrument = HealthChecksInstrument(bootstrap_config=HealthChecksConfig()) + data = instrument.render_health_check_data() + assert data == { + "service_version": "1.0.0", + "service_name": "micro-service", + "health_status": True, + } + + +def test_healthchecks_render_data_custom() -> None: + instrument = HealthChecksInstrument( + bootstrap_config=HealthChecksConfig(service_name="my-svc", service_version="2.0.0"), + ) + data = instrument.render_health_check_data() + assert data == { + "service_version": "2.0.0", + "service_name": "my-svc", + "health_status": True, + } + + +def test_healthchecks_config_defaults() -> None: + config = HealthChecksConfig() + assert config.health_checks_enabled is True + assert config.health_checks_path == "/health/" + assert config.health_checks_include_in_schema is False + + +def test_healthchecks_check_dependencies() -> None: + assert HealthChecksInstrument.check_dependencies() is True +``` + +### Step 3: Create `tests/instruments/test_prometheus_instrument.py` + +```python +from lite_bootstrap.instruments.prometheus_instrument import PrometheusConfig, PrometheusInstrument + + +def test_prometheus_instrument_ready_with_default_path() -> None: + instrument = PrometheusInstrument(bootstrap_config=PrometheusConfig()) + assert instrument.is_ready() + + +def test_prometheus_instrument_not_ready_with_empty_path() -> None: + instrument = PrometheusInstrument(bootstrap_config=PrometheusConfig(prometheus_metrics_path="")) + assert not instrument.is_ready() + assert instrument.not_ready_message == "prometheus_metrics_path is empty or not valid" + + +def test_prometheus_instrument_not_ready_with_invalid_path() -> None: + # No leading slash → invalid per is_valid_path regex. + instrument = PrometheusInstrument(bootstrap_config=PrometheusConfig(prometheus_metrics_path="metrics")) + assert not instrument.is_ready() + + +def test_prometheus_instrument_ready_with_custom_valid_path() -> None: + instrument = PrometheusInstrument( + bootstrap_config=PrometheusConfig(prometheus_metrics_path="/custom-metrics/"), + ) + assert instrument.is_ready() + + +def test_prometheus_config_defaults() -> None: + config = PrometheusConfig() + assert config.prometheus_metrics_path == "/metrics" + assert config.prometheus_metrics_include_in_schema is False + + +def test_prometheus_check_dependencies() -> None: + assert PrometheusInstrument.check_dependencies() is True +``` + +### Step 4: Create `tests/instruments/test_swagger_instrument.py` + +```python +from lite_bootstrap.instruments.swagger_instrument import SwaggerConfig, SwaggerInstrument + + +def test_swagger_instrument_ready_by_default() -> None: + instrument = SwaggerInstrument(bootstrap_config=SwaggerConfig()) + assert instrument.is_ready() + + +def test_swagger_config_defaults() -> None: + config = SwaggerConfig() + assert config.swagger_static_path == "/static" + assert config.swagger_path == "/docs" + assert config.swagger_offline_docs is False + + +def test_swagger_check_dependencies() -> None: + assert SwaggerInstrument.check_dependencies() is True +``` + +### Step 5: Create `tests/test_path.py` + +```python +import pytest + +from lite_bootstrap.helpers.path import is_valid_path + + +@pytest.mark.parametrize( + "path", + [ + "/metrics", + "/health/", + "/api/v1/users", + "/foo.bar", + "/foo_bar", + "/foo-bar", + "/a", + "/a/", + ], +) +def test_is_valid_path_accepts_valid(path: str) -> None: + assert is_valid_path(path) is True + + +@pytest.mark.parametrize( + "path", + [ + "", + "foo", + "foo/", + "/foo bar", + "/foo?bar", + "/foo#bar", + "/", + "//foo", + "/foo//bar", + ], +) +def test_is_valid_path_rejects_invalid(path: str) -> None: + assert is_valid_path(path) is False +``` + +Notes on the rejected cases: +- `""` — empty string fails the `^(/...)+/?$` pattern. +- `"foo"`, `"foo/"` — no leading `/`. +- `"/foo bar"` — space is not in `[a-zA-Z0-9._-]`. +- `"/foo?bar"`, `"/foo#bar"` — `?` and `#` are not in the charset. +- `"/"` — no segment after the slash; the regex requires `[a-zA-Z0-9._-]+`. +- `"//foo"`, `"/foo//bar"` — empty segments not allowed by the `+` quantifier. + +The regex DOES accept `/..` and `/../foo` because `.` is in the charset; the `is_valid_path` function does not block path traversal. That's a pre-existing design decision (out of scope here). + +### Step 6: Run the new test files + +```bash +just test -- tests/instruments/test_cors_instrument.py tests/instruments/test_healthchecks_instrument.py tests/instruments/test_prometheus_instrument.py tests/instruments/test_swagger_instrument.py tests/test_path.py -v +``` + +Expected: all new tests PASS on first run. They're pure additions verifying current behavior. + +If any test fails, investigate before continuing. The most likely cause is a wrong assertion (e.g., default value), not a real bug. + +### Step 7: Run the full test suite + +```bash +just test +``` + +Expected: total count goes from 89 to roughly 89 + (6+6+6+3+17) = ~127. All pass. + +Approximate breakdown of new tests: +- `test_cors_instrument.py`: 6 tests +- `test_healthchecks_instrument.py`: 6 tests +- `test_prometheus_instrument.py`: 6 tests +- `test_swagger_instrument.py`: 3 tests +- `test_path.py`: 17 parametrized cases across 2 functions + +### Step 8: Run lint + +```bash +just lint +``` + +Expected: clean. No production changes mean no lint-rule complications. + +### Step 9: Commit + +Stage exactly the five new files: + +```bash +git add \ + tests/instruments/test_cors_instrument.py \ + tests/instruments/test_healthchecks_instrument.py \ + tests/instruments/test_prometheus_instrument.py \ + tests/instruments/test_swagger_instrument.py \ + tests/test_path.py +git commit -m "$(cat <<'EOF' +test: add standalone instrument tests and is_valid_path negative tests + +TEST-4: Add tests/instruments/test_{cors,healthchecks,prometheus,swagger}_instrument.py +covering is_ready() across valid/invalid configurations, +not_ready_message content, render_health_check_data output shape, +config defaults, and check_dependencies. These instruments were +previously only covered transitively via bootstrapper integration +tests, which made regressions noisier to diagnose. + +TEST-7: Add tests/test_path.py with parametrized cases for +helpers.path.is_valid_path — both valid forms (default paths used by +the prometheus/swagger/healthchecks instruments) and invalid forms +(empty, no leading slash, spaces, special chars, empty segments). + +Closes TEST-4 and TEST-7 from the audit. Pure additions; no production +code changed. +EOF +)" +``` + +--- + +## Task 3: Push and open PR + +- [ ] **Step 1: Push the branch** + +```bash +git push -u origin fix/test-4-7-gaps +``` + +- [ ] **Step 2: Open the PR** + +```bash +gh pr create --title "test: add standalone instrument tests and is_valid_path negative tests" --body "$(cat <<'EOF' +## Summary +Pure test additions; no production changes. + +- **TEST-4:** Standalone test files for the four instruments previously only covered transitively via bootstrapper integration tests: + - \`tests/instruments/test_cors_instrument.py\` — \`is_ready\` matrix (origins-only, regex-only, both, neither), \`not_ready_message\`, config defaults, \`check_dependencies\`. + - \`tests/instruments/test_healthchecks_instrument.py\` — enabled/disabled, \`render_health_check_data\` output shape, defaults. + - \`tests/instruments/test_prometheus_instrument.py\` — valid/invalid/empty paths, defaults. + - \`tests/instruments/test_swagger_instrument.py\` — instantiation, defaults. +- **TEST-7:** \`tests/test_path.py\` — parametrized cases for \`is_valid_path\` covering valid forms (\`/metrics\`, \`/health/\`, multi-segment, special chars in the allowed set) and invalid forms (empty, no leading slash, spaces, \`?\`/\`#\`, empty segments). + +Closes TEST-4 and TEST-7 from an internal audit. + +## Test plan +- [x] \`just test\` — full suite passes (89 prior + ~37 new ≈ 126). +- [x] \`just lint\` — clean. +- [ ] Reviewer: confirm the rejected-path list in \`test_path.py\` matches the intended contract. (The regex DOES accept \`/..\` because \`.\` is in the allowed charset — pre-existing design decision; not tested as a "valid" or "invalid" case.) + +🤖 Generated with [Claude Code](https://claude.com/claude-code) +EOF +)" +``` + +--- + +## Self-Review + +**Spec coverage check** against the sequencing spec (PR10 section) and audit (TEST-4, TEST-7): + +| Spec item | Task | +|-----------|------| +| `tests/instruments/test_cors_instrument.py` | Task 2, Step 1 | +| `tests/instruments/test_healthchecks_instrument.py` | Task 2, Step 2 | +| `tests/instruments/test_prometheus_instrument.py` | Task 2, Step 3 | +| `tests/instruments/test_swagger_instrument.py` | Task 2, Step 4 | +| `tests/test_path.py` with negative cases | Task 2, Step 5 | +| Branch name `fix/test-4-7-gaps` | Task 1, Step 1 | +| Verification: `just test` + `just lint` clean | Task 2, Steps 6-8 | + +All spec items covered. No placeholders. Test code matches the existing simple-function style in the codebase. The Prometheus test uses both the default `/metrics` path (valid) and `"metrics"` (invalid — no leading slash) to exercise both branches of `is_valid_path`'s logic without depending on `tests/test_path.py`. + + +--- + +# PR11: Logging Cleanup + Lifecycle Test (REF-4 + REF-2 + LOW-6 + LOW-8 + LOW-9 + TEST-8) + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** The biggest PR of the deferred-refactors sequence. Bundles six logging-area items: +- **REF-4**: Split `logging_instrument.py` (212 lines) into a new `logging_factory.py` (factory + serializer + protocols) plus a slimmer `logging_instrument.py` (config + instrument + tracer injection). +- **REF-2**: Delete the dead `if import_checker.is_structlog_installed and import_checker.is_litestar_installed:` defensive check in `LitestarLoggingInstrument.bootstrap()`. +- **LOW-6**: Wrap `MemoryLoggerFactory`'s 4 logging-config kwargs into an internal `_MemoryLoggerFactoryConfig` dataclass. +- **LOW-8**: Add a docstring to `LoggingInstrument._unset_handlers` documenting that the mutation is permanent. +- **LOW-9**: Add a docstring to `LoggingInstrument.teardown()` documenting that root logger level is unconditionally reset to WARNING. +- **TEST-8**: Add a bootstrap→teardown→bootstrap→teardown lifecycle replay test. + +**Architecture:** One new module file, one production refactor (the split), one cross-file dead-code removal, two docstring additions, one test update, one new test. Backward compatibility is preserved by re-exporting moved symbols from `logging_instrument.py`. + +**Tech Stack:** Python 3.10+ dataclasses, structlog, pytest. + +**Parent spec:** `docs/superpowers/specs/2026-06-01-deferred-refactors-sequencing.md` (PR11 section). +**Parent audit:** `docs/superpowers/specs/2026-05-31-bug-refactor-audit.md` (REF-2, REF-4, LOW-6, LOW-8, LOW-9, TEST-8). + +--- + +## File Structure + +5 files touched. + +- Create: `lite_bootstrap/instruments/logging_factory.py` — `MemoryLoggerFactory`, `_MemoryLoggerFactoryConfig`, `_serialize_log_with_orjson_to_string`, `AddressProtocol`, `RequestProtocol`, `ScopeType`. +- Modify: `lite_bootstrap/instruments/logging_instrument.py` — slim to `LoggingConfig`, `LoggingInstrument`, `tracer_injection`; re-import the moved symbols for backward compat; add LOW-8 / LOW-9 docstrings. +- Modify: `lite_bootstrap/bootstrappers/litestar_bootstrapper.py` — REF-2: delete the dead defensive check, unindent the body. +- Modify: `tests/instruments/test_logging_instrument.py` — update `MemoryLoggerFactory` instantiations to use `_MemoryLoggerFactoryConfig`; add `test_logging_instrument_lifecycle_replay`. + +--- + +## Locked decisions (from sequencing spec) + +- **Internal config dataclass for `MemoryLoggerFactory`:** Prefix `_MemoryLoggerFactoryConfig` (underscore = internal). Not exported from `__init__.py`. Tests import it via the underscore-prefixed name. +- **Backward compat for moved symbols:** `MemoryLoggerFactory`, `AddressProtocol`, `RequestProtocol`, `ScopeType` are re-imported into `logging_instrument.py` so existing `from lite_bootstrap.instruments.logging_instrument import MemoryLoggerFactory` imports continue to work. +- **No PLR2004 noqa for magic-value test assertions.** If ruff complains about a numeric literal in a test assertion (e.g., `assert config.x == 10`), extract the value to a named local variable. See PR10's `expected_max_age = 600` pattern. + +--- + +## Task 1: Create branch + +**Files:** (no files; git only) + +- [ ] **Step 1: Branch off `main`** + +```bash +git checkout main +git pull --ff-only origin main +git checkout -b refactor/ref-4-logging-cleanup +``` + +Expected: `Switched to a new branch 'refactor/ref-4-logging-cleanup'`. + +--- + +## Task 2: Apply all six changes, verify, commit + +The order of steps below is important — start with the file split (steps 1-3), then dead code removal (step 4), then docstrings (step 5), then test updates (steps 6-7), then verify. + +### Step 1: Create `lite_bootstrap/instruments/logging_factory.py` + +New file with full contents: + +```python +import dataclasses +import logging +import logging.handlers +import sys +import typing + +import orjson + +from lite_bootstrap import import_checker + + +ScopeType = typing.MutableMapping[str, typing.Any] + + +class AddressProtocol(typing.Protocol): + host: str + port: int + + +class RequestProtocol(typing.Protocol): + client: AddressProtocol + scope: ScopeType + method: str + + +@dataclasses.dataclass(kw_only=True, slots=True, frozen=True) +class _MemoryLoggerFactoryConfig: + logging_buffer_capacity: int + logging_flush_level: int + logging_log_level: int + log_stream: typing.Any = sys.stdout # noqa: ANN401 + + +def _serialize_log_with_orjson_to_string(value: typing.Any, **kwargs: typing.Any) -> str: # noqa: ANN401 + return orjson.dumps(value, **kwargs).decode() + + +if import_checker.is_structlog_installed: + import structlog + + class MemoryLoggerFactory(structlog.stdlib.LoggerFactory): + def __init__( + self, + *args: typing.Any, # noqa: ANN401 + config: "_MemoryLoggerFactoryConfig", + **kwargs: typing.Any, # noqa: ANN401 + ) -> None: + super().__init__(*args, **kwargs) + self.config = config + self._created_handlers: list[tuple[logging.Logger, logging.handlers.MemoryHandler]] = [] + + def __call__(self, *args: typing.Any) -> logging.Logger: # noqa: ANN401 + logger: typing.Final = super().__call__(*args) + stream_handler: typing.Final = logging.StreamHandler(stream=self.config.log_stream) + handler: typing.Final = logging.handlers.MemoryHandler( + capacity=self.config.logging_buffer_capacity, + flushLevel=self.config.logging_flush_level, + target=stream_handler, + ) + logger.addHandler(handler) + logger.setLevel(self.config.logging_log_level) + logger.propagate = False + self._created_handlers.append((logger, handler)) + return logger + + def close_handlers(self) -> None: + for created_logger, handler in self._created_handlers: + created_logger.removeHandler(handler) + created_logger.propagate = True + target = handler.target + handler.close() + if target is not None: + target.close() + self._created_handlers.clear() +``` + +Notes on the structlog gate: +- `MemoryLoggerFactory` MUST be inside the gate because it inherits from `structlog.stdlib.LoggerFactory`. +- `_MemoryLoggerFactoryConfig` and `_serialize_log_with_orjson_to_string` are OUTSIDE the gate — they have no structlog dependency (just stdlib + orjson, both unconditional). This matters because `logging_instrument.py` imports them at module top; if they were gated, the import would fail when structlog isn't installed. +- `ScopeType`, `AddressProtocol`, `RequestProtocol` are unconditional (no structlog dependency). + +### Step 2: Rewrite `lite_bootstrap/instruments/logging_instrument.py` + +Replace the full file contents with: + +```python +import dataclasses +import logging +import logging.handlers +import sys +import typing + +from lite_bootstrap import import_checker +from lite_bootstrap.instruments.base import BaseConfig, BaseInstrument +from lite_bootstrap.instruments.logging_factory import ( + AddressProtocol, + RequestProtocol, + ScopeType, + _MemoryLoggerFactoryConfig, + _serialize_log_with_orjson_to_string, +) + + +if typing.TYPE_CHECKING: + from lite_bootstrap.instruments.logging_factory import MemoryLoggerFactory + from structlog.typing import EventDict, WrappedLogger + + +if import_checker.is_structlog_installed: + import structlog + + from lite_bootstrap.instruments.logging_factory import MemoryLoggerFactory + + +if import_checker.is_opentelemetry_installed: + from opentelemetry import trace + + def tracer_injection(_: "WrappedLogger", __: str, event_dict: "EventDict") -> "EventDict": + current_span = trace.get_current_span() + if not current_span.is_recording(): + event_dict["tracing"] = {} + return event_dict + + current_span_context = current_span.get_span_context() + event_dict["tracing"] = { + "span_id": trace.format_span_id(current_span_context.span_id), + "trace_id": trace.format_trace_id(current_span_context.trace_id), + } + return event_dict + +else: # pragma: no cover + + def tracer_injection(_: "WrappedLogger", __: str, event_dict: "EventDict") -> "EventDict": + return event_dict + + +__all__ = [ + "AddressProtocol", + "LoggingConfig", + "LoggingInstrument", + "MemoryLoggerFactory", + "RequestProtocol", + "ScopeType", + "tracer_injection", +] + + +@dataclasses.dataclass(kw_only=True, frozen=True) +class LoggingConfig(BaseConfig): + logging_log_level: int = logging.INFO + logging_flush_level: int = logging.ERROR + logging_buffer_capacity: int = 10 + logging_extra_processors: list[typing.Any] = dataclasses.field(default_factory=list) + logging_unset_handlers: list[str] = dataclasses.field( + default_factory=list, + ) + logging_time_stamper: "structlog.processors.TimeStamper | None" = None + logging_enabled: bool = True + + +@dataclasses.dataclass(kw_only=True, slots=True, frozen=True) +class LoggingInstrument(BaseInstrument[LoggingConfig]): + not_ready_message = "logging_enabled is False" + missing_dependency_message = "structlog is not installed" + _logger_factory: "MemoryLoggerFactory | None" = dataclasses.field( + default_factory=lambda: None, init=False, repr=False, compare=False + ) + + @property + def structlog_pre_chain_processors(self) -> list[typing.Any]: + return [ + structlog.stdlib.add_log_level, + structlog.stdlib.add_logger_name, + tracer_injection, + structlog.stdlib.PositionalArgumentsFormatter(), + self.bootstrap_config.logging_time_stamper or structlog.processors.TimeStamper(fmt="iso"), + structlog.processors.StackInfoRenderer(), + structlog.processors.format_exc_info, + structlog.processors.UnicodeDecoder(), + ] + + def is_ready(self) -> bool: + return self.bootstrap_config.logging_enabled + + @staticmethod + def check_dependencies() -> bool: + return import_checker.is_structlog_installed + + def _unset_handlers(self) -> None: + """Clear handlers on the named loggers. Mutation is permanent; teardown() does not restore.""" + for unset_handlers_logger in self.bootstrap_config.logging_unset_handlers: + logging.getLogger(unset_handlers_logger).handlers = [] + + @property + def structlog_processors(self) -> list[typing.Any]: + return [ + structlog.stdlib.filter_by_level, + *self.structlog_pre_chain_processors, + *self.bootstrap_config.logging_extra_processors, + structlog.processors.JSONRenderer(serializer=_serialize_log_with_orjson_to_string), + ] + + @property + def memory_logger_factory(self) -> "MemoryLoggerFactory": + cached: MemoryLoggerFactory | None = self._logger_factory + if cached is None: + cached = MemoryLoggerFactory( + config=_MemoryLoggerFactoryConfig( + logging_buffer_capacity=self.bootstrap_config.logging_buffer_capacity, + logging_flush_level=self.bootstrap_config.logging_flush_level, + logging_log_level=self.bootstrap_config.logging_log_level, + ), + ) + object.__setattr__(self, "_logger_factory", cached) + return cached + + def _configure_structlog_loggers(self) -> None: + structlog.configure( + processors=self.structlog_processors, + context_class=dict, + logger_factory=self.memory_logger_factory, + wrapper_class=structlog.stdlib.BoundLogger, + cache_logger_on_first_use=True, + ) + + def _configure_foreign_loggers(self) -> None: + root_logger: typing.Final = logging.getLogger() + stream_handler: typing.Final = logging.StreamHandler(sys.stdout) + stream_handler.setFormatter( + structlog.stdlib.ProcessorFormatter( + foreign_pre_chain=self.structlog_pre_chain_processors, + processors=[ + structlog.stdlib.ProcessorFormatter.remove_processors_meta, + *self.bootstrap_config.logging_extra_processors, + structlog.processors.JSONRenderer(serializer=_serialize_log_with_orjson_to_string), + ], + logger=root_logger, + ) + ) + root_logger.addHandler(stream_handler) + root_logger.setLevel(self.bootstrap_config.logging_log_level) + + def bootstrap(self) -> None: + self._unset_handlers() + self._configure_structlog_loggers() + self._configure_foreign_loggers() + + def teardown(self) -> None: + """Reset structlog and root logger. Root logger level is unconditionally set to WARNING; pre-existing user configuration is overwritten.""" + structlog.reset_defaults() + root_logger = logging.getLogger() + for h in root_logger.handlers[:]: + root_logger.removeHandler(h) + h.close() + root_logger.setLevel(logging.WARNING) + if self._logger_factory is not None: + try: + self._logger_factory.close_handlers() + finally: + object.__setattr__(self, "_logger_factory", None) +``` + +Key differences from the original: +- Module-top imports from `logging_factory`: `AddressProtocol`, `RequestProtocol`, `ScopeType`, `_MemoryLoggerFactoryConfig`, `_serialize_log_with_orjson_to_string` (all unconditional, no structlog dependency). +- `MemoryLoggerFactory` import is gated — at module top in `TYPE_CHECKING` block (for annotations) and inside `if import_checker.is_structlog_installed:` (for runtime use). This mirrors the original module's lazy-resolution pattern: `MemoryLoggerFactory` is only referenced at runtime when structlog is installed. +- `__all__` explicitly re-exports the moved public symbols (`AddressProtocol`, `MemoryLoggerFactory`, `RequestProtocol`, `ScopeType`) for backward compatibility. Consumers who try to access `MemoryLoggerFactory` without structlog will get `AttributeError` — same behavior as the original module. +- `_unset_handlers` and `teardown` now have one-line docstrings (LOW-8, LOW-9). +- `memory_logger_factory` property constructs `MemoryLoggerFactory` with a `_MemoryLoggerFactoryConfig` (LOW-6). +- All `MemoryLoggerFactory`/factory-internal code is gone — sourced from the new module. + +### Step 3: Verify the split with a quick smoke test + +```bash +just test -- tests/instruments/test_logging_instrument.py -v +``` + +Expected: existing tests may fail because they construct `MemoryLoggerFactory(logging_buffer_capacity=..., ...)` with the old signature. We'll fix those in Step 6 below. For now, just verify that imports resolve cleanly (no `ImportError`). + +If you see `ImportError`, the file split is broken — investigate before proceeding. + +### Step 4: REF-2 — delete dead defensive check in `LitestarLoggingInstrument.bootstrap` + +**File:** `lite_bootstrap/bootstrappers/litestar_bootstrapper.py` + +Locate `LitestarLoggingInstrument.bootstrap`. Current: + +```python + def bootstrap(self) -> None: + self._unset_handlers() + if import_checker.is_structlog_installed and import_checker.is_litestar_installed: + self.bootstrap_config.application_config.plugins.append( + StructlogPlugin( + config=StructlogConfig( + structlog_logging_config=StructLoggingConfig( + processors=self.structlog_processors, + logger_factory=self.memory_logger_factory, + wrapper_class=structlog.stdlib.BoundLogger, + cache_logger_on_first_use=True, + pretty_print_tty=False, + standard_lib_logging_config=None, + ), + ), + ) + ) + self._configure_foreign_loggers() +``` + +Replace with (delete the `if` line and the matching dedent — the body unindents one level): + +```python + def bootstrap(self) -> None: + self._unset_handlers() + self.bootstrap_config.application_config.plugins.append( + StructlogPlugin( + config=StructlogConfig( + structlog_logging_config=StructLoggingConfig( + processors=self.structlog_processors, + logger_factory=self.memory_logger_factory, + wrapper_class=structlog.stdlib.BoundLogger, + cache_logger_on_first_use=True, + pretty_print_tty=False, + standard_lib_logging_config=None, + ), + ), + ) + ) + self._configure_foreign_loggers() +``` + +The `if import_checker.is_structlog_installed and import_checker.is_litestar_installed:` check is dead — the instrument couldn't have been registered without both packages installed. + +### Step 5: Update tests to use `_MemoryLoggerFactoryConfig` + +**File:** `tests/instruments/test_logging_instrument.py` + +There are two tests (`test_memory_logger_factory_info`, `test_memory_logger_factory_error`) that instantiate `MemoryLoggerFactory` directly. Both need to be updated to use the new config-based API. + +Locate the top-of-file imports. The current imports look like: + +```python +import logging +from io import StringIO + +import structlog +from opentelemetry.trace import get_tracer + +from lite_bootstrap.instruments.logging_instrument import LoggingConfig, LoggingInstrument, MemoryLoggerFactory +from lite_bootstrap.instruments.opentelemetry_instrument import OpentelemetryConfig, OpenTelemetryInstrument +from tests.conftest import LoggingMock +``` + +(After PR3 the file also has `from unittest.mock import patch` and `import pytest`; preserve those.) + +Add `_MemoryLoggerFactoryConfig` to the imports. Either import from `logging_factory` directly (more explicit) or from `logging_instrument` (which already re-imports it). Use the direct path for clarity: + +```python +from lite_bootstrap.instruments.logging_factory import _MemoryLoggerFactoryConfig +``` + +Place this after the `from lite_bootstrap.instruments.logging_instrument import ...` line. + +Locate `test_memory_logger_factory_info`. Current: + +```python +def test_memory_logger_factory_info() -> None: + test_capacity = 10 + test_flush_level = logging.ERROR + test_stream = StringIO() + + logger_factory = MemoryLoggerFactory( + logging_buffer_capacity=test_capacity, + logging_flush_level=test_flush_level, + logging_log_level=logging.INFO, + log_stream=test_stream, + ) + ... +``` + +Replace the `MemoryLoggerFactory(...)` call with: + +```python + logger_factory = MemoryLoggerFactory( + config=_MemoryLoggerFactoryConfig( + logging_buffer_capacity=test_capacity, + logging_flush_level=test_flush_level, + logging_log_level=logging.INFO, + log_stream=test_stream, + ), + ) +``` + +Do the same in `test_memory_logger_factory_error`. + +### Step 6: Add lifecycle replay test (TEST-8) + +In the same file, append a new test: + +```python +def test_logging_instrument_lifecycle_replay(logging_mock: LoggingMock) -> None: + instrument = LoggingInstrument( + bootstrap_config=LoggingConfig( + logging_buffer_capacity=0, + logging_extra_processors=[logging_mock], + ), + ) + try: + instrument.bootstrap() + instrument.teardown() + instrument.bootstrap() + logger = structlog.getLogger(__name__) + logger.info("after replay") + assert any(e.get("event") == "after replay" for e in logging_mock.entries) + finally: + instrument.teardown() +``` + +Contract: bootstrap → teardown → bootstrap must succeed without raising. After the second bootstrap, the instrument is functional (new logger entries flow through `logging_mock`). The final `teardown()` in `finally` ensures global state is cleaned up regardless of test outcome. + +### Step 7: Run the full logging test file + +```bash +just test -- tests/instruments/test_logging_instrument.py -v +``` + +Expected: all tests PASS, including the two updated factory tests and the new lifecycle replay test. + +If any test fails, investigate. The most likely cause is a typo in the `_MemoryLoggerFactoryConfig` construction. + +### Step 8: Run the full test suite + +```bash +just test +``` + +Expected: 127/127 PASS (after PR10 brought the total to 127, this PR adds 1 test → 128). Watch for failures in the framework integration tests — particularly `test_fastapi_bootstrap`, `test_litestar_bootstrap`, `test_faststream_bootstrap` — which exercise the full logging instrument lifecycle. + +### Step 9: Run lint + +```bash +just lint +``` + +Expected: clean. The file split and dataclass-config refactor shouldn't introduce lint issues. + +**Important — PLR2004 policy:** If ruff flags any numeric literal in test assertions with PLR2004 (magic value), DO NOT add `# noqa: PLR2004`. Extract the value to a named local variable. Example: `expected_capacity = 10; assert factory.config.logging_buffer_capacity == expected_capacity`. + +### Step 10: Commit + +Stage all five touched files: + +```bash +git add \ + lite_bootstrap/instruments/logging_factory.py \ + lite_bootstrap/instruments/logging_instrument.py \ + lite_bootstrap/bootstrappers/litestar_bootstrapper.py \ + tests/instruments/test_logging_instrument.py +git commit -m "$(cat <<'EOF' +refactor: split logging module + cleanup + lifecycle test + +REF-4: Split lite_bootstrap/instruments/logging_instrument.py (212 +lines, four concerns) into: +- logging_factory.py: MemoryLoggerFactory, _MemoryLoggerFactoryConfig, + _serialize_log_with_orjson_to_string, AddressProtocol, + RequestProtocol, ScopeType. Single structlog-conditional gate at + module top. +- logging_instrument.py: LoggingConfig, LoggingInstrument, + tracer_injection. Re-imports the moved public symbols + (MemoryLoggerFactory, AddressProtocol, RequestProtocol, ScopeType) + for backward compatibility — existing imports from + lite_bootstrap.instruments.logging_instrument continue to work. + +LOW-6: MemoryLoggerFactory.__init__ now takes a single +_MemoryLoggerFactoryConfig dataclass instead of four logging-config +kwargs. The dataclass is underscore-prefixed (internal); tests import +it explicitly when constructing the factory directly. + +REF-2: Delete the dead `if import_checker.is_structlog_installed and +import_checker.is_litestar_installed:` defensive check in +LitestarLoggingInstrument.bootstrap. The check is unreachable — +the instrument couldn't have been registered without both packages +installed. + +LOW-8: Add a docstring to LoggingInstrument._unset_handlers documenting +that the mutation is permanent (teardown does not restore handlers). + +LOW-9: Add a docstring to LoggingInstrument.teardown documenting that +root logger level is unconditionally reset to WARNING. + +TEST-8: Add test_logging_instrument_lifecycle_replay exercising +bootstrap → teardown → bootstrap → teardown and verifying the +instrument is functional after the second bootstrap. + +No external behavior change. Updated factory tests to use the new +config-based constructor. + +Closes REF-2, REF-4, LOW-6, LOW-8, LOW-9, TEST-8 from the audit. +EOF +)" +``` + +--- + +## Task 3: Push and open PR + +- [ ] **Step 1: Push the branch** + +```bash +git push -u origin refactor/ref-4-logging-cleanup +``` + +- [ ] **Step 2: Open the PR** + +```bash +gh pr create --title "refactor: split logging module + cleanup + lifecycle test" --body "$(cat <<'EOF' +## Summary +The biggest PR of the deferred-refactors sequence — six logging-area items bundled: + +- **REF-4:** Split `logging_instrument.py` (212 lines, four concerns) into a new `logging_factory.py` (MemoryLoggerFactory + serializer + protocols + the new internal `_MemoryLoggerFactoryConfig`) and a slimmer `logging_instrument.py` (config + instrument + tracer injection). Single structlog-conditional gate at the new module's top, vs three separate gates in the old layout. Backward compatibility: the moved public symbols are re-imported into `logging_instrument.py`, so existing `from lite_bootstrap.instruments.logging_instrument import MemoryLoggerFactory` continues to work. +- **REF-2:** Deleted the dead `if import_checker.is_structlog_installed and import_checker.is_litestar_installed:` check in `LitestarLoggingInstrument.bootstrap()`. Unreachable code (the instrument couldn't have been registered without both packages installed). +- **LOW-6:** `MemoryLoggerFactory.__init__` now takes a single `_MemoryLoggerFactoryConfig` dataclass instead of four logging-config kwargs. Underscore-prefixed because it's internal; tests import it directly. +- **LOW-8:** Added a docstring to `LoggingInstrument._unset_handlers` documenting that the mutation is permanent — teardown does NOT restore handlers. +- **LOW-9:** Added a docstring to `LoggingInstrument.teardown()` documenting that root logger level is unconditionally reset to `WARNING`. +- **TEST-8:** New `test_logging_instrument_lifecycle_replay` exercises bootstrap → teardown → bootstrap → teardown and verifies the instrument is functional after the second bootstrap. + +No external behavior change. Two existing factory tests updated to use the new config-based constructor. + +Closes REF-2, REF-4, LOW-6, LOW-8, LOW-9, TEST-8 from an internal audit. + +## Test plan +- [x] `just test -- tests/instruments/test_logging_instrument.py -v` — pass (including updated factory tests + new lifecycle replay). +- [x] `just test` — 128/128 (127 prior + 1 new). +- [x] `just lint` — clean. +- [ ] Reviewer: confirm the backward-compat re-imports in `logging_instrument.py` cover all the public symbols (MemoryLoggerFactory, AddressProtocol, RequestProtocol, ScopeType). +- [ ] Reviewer: confirm the test update for the new config-based `MemoryLoggerFactory` constructor is correct. + +🤖 Generated with [Claude Code](https://claude.com/claude-code) +EOF +)" +``` + +--- + +## Self-Review + +**Spec coverage check** against the sequencing spec (PR11 section) and audit: + +| Spec item | Task | +|-----------|------| +| REF-4: Split `logging_instrument.py` into `logging_factory.py` + slimmer `logging_instrument.py` | Task 2, Steps 1-2 | +| REF-2: Delete dead defensive check in `LitestarLoggingInstrument.bootstrap` | Task 2, Step 4 | +| LOW-6: `MemoryLoggerFactory` takes `_MemoryLoggerFactoryConfig` | Task 2, Steps 1, 2, 5 | +| LOW-8: docstring on `_unset_handlers` | Task 2, Step 2 | +| LOW-9: docstring on `teardown` | Task 2, Step 2 | +| TEST-8: lifecycle replay test | Task 2, Step 6 | +| Branch name `refactor/ref-4-logging-cleanup` | Task 1, Step 1 | +| Verification: `just test` + `just lint` clean | Task 2, Steps 8-9 | +| PLR2004 noqa NOT used; extract constants instead | Task 2, Step 9 (callout) | + +All spec items covered. No placeholders. Backward compatibility for moved public symbols is explicitly handled via re-imports in `logging_instrument.py` with `__all__` listing them. + +**Risk notes:** +- The file split is the highest-risk change. If imports break anywhere in the codebase or tests, this PR's full suite run catches it. +- The MemoryLoggerFactory constructor change is API-breaking for direct users of `MemoryLoggerFactory(logging_buffer_capacity=..., ...)`. Since `MemoryLoggerFactory` is publicly exported but its internals are framework-level (users rarely construct it directly outside tests), the impact is small. Documented in the commit message. + + +--- + +# PR12: Base Layer Cleanup (REF-3 + REF-5) + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** +- **REF-3**: Drop `abc.ABC` from `BaseInstrument`. After PR7 made the class generic and removed the `# noqa: B027` suppressions, the `abc.ABC` parent serves no purpose — all four methods (`bootstrap`, `teardown`, `is_ready`, `check_dependencies`) are concrete no-ops with sensible defaults. Removing it clarifies the class's role as a regular generic base. +- **REF-5**: Add a one-line module docstring to `swagger_instrument.py` and `prometheus_instrument.py` explaining that these files are config holders; framework-specific behavior lives in the bootstrapper subclasses. Per the locked decision in the sequencing spec, KEEP these files as separate modules (don't collapse). + +**Architecture:** Three files, three small edits. No behavior change. + +**Tech Stack:** Python 3.10+ dataclasses, generics. + +**Parent spec:** `docs/superpowers/specs/2026-06-01-deferred-refactors-sequencing.md` (PR12 section). +**Parent audit:** `docs/superpowers/specs/2026-05-31-bug-refactor-audit.md` (REF-3, REF-5). + +--- + +## File Structure + +Three files modified. + +- Modify: `lite_bootstrap/instruments/base.py` — drop `abc.ABC` from `BaseInstrument`; remove the `import abc`. +- Modify: `lite_bootstrap/instruments/swagger_instrument.py` — add module docstring. +- Modify: `lite_bootstrap/instruments/prometheus_instrument.py` — add module docstring. + +--- + +## Locked decisions (from sequencing spec) + +- **REF-3 scope:** Only `BaseInstrument` loses `abc.ABC`. `BaseBootstrapper` (in `lite_bootstrap/bootstrappers/base.py`) keeps `abc.ABC` because it HAS abstract methods (`not_ready_message`, `_prepare_application`, `is_ready`). +- **REF-5 scope:** Keep `swagger_instrument.py` and `prometheus_instrument.py` as separate files (don't collapse). Add module docstrings explaining the split. + +--- + +## Task 1: Create branch + +**Files:** (no files; git only) + +- [ ] **Step 1: Branch off `main`** + +```bash +git checkout main +git pull --ff-only origin main +git checkout -b refactor/ref-3-5-base-layer +``` + +Expected: `Switched to a new branch 'refactor/ref-3-5-base-layer'`. + +--- + +## Task 2: Apply both changes, verify, commit + +### Step 1: REF-3 — drop `abc.ABC` from `BaseInstrument` + +**File:** `lite_bootstrap/instruments/base.py` + +Current file: + +```python +import abc +import dataclasses +import typing + +import typing_extensions + + +@dataclasses.dataclass(kw_only=True, slots=True, frozen=True) +class BaseConfig: + ... + + +ConfigT = typing.TypeVar("ConfigT", bound=BaseConfig) + + +@dataclasses.dataclass(kw_only=True, slots=True, frozen=True) +class BaseInstrument(abc.ABC, typing.Generic[ConfigT]): + bootstrap_config: ConfigT + not_ready_message = "" + missing_dependency_message = "" + + def bootstrap(self) -> None: ... + + def teardown(self) -> None: ... + + def is_ready(self) -> bool: + return True + + @staticmethod + def check_dependencies() -> bool: + return True +``` + +Two changes: + +1. Remove `import abc` from the top (it's no longer used in this file). +2. Change `class BaseInstrument(abc.ABC, typing.Generic[ConfigT]):` to `class BaseInstrument(typing.Generic[ConfigT]):`. + +After: + +```python +import dataclasses +import typing + +import typing_extensions + + +@dataclasses.dataclass(kw_only=True, slots=True, frozen=True) +class BaseConfig: + ... + + +ConfigT = typing.TypeVar("ConfigT", bound=BaseConfig) + + +@dataclasses.dataclass(kw_only=True, slots=True, frozen=True) +class BaseInstrument(typing.Generic[ConfigT]): + bootstrap_config: ConfigT + not_ready_message = "" + missing_dependency_message = "" + + def bootstrap(self) -> None: ... + + def teardown(self) -> None: ... + + def is_ready(self) -> bool: + return True + + @staticmethod + def check_dependencies() -> bool: + return True +``` + +`BaseConfig` is preserved unchanged (it never used `abc.ABC`). + +### Step 2: REF-5 — add docstring to `swagger_instrument.py` + +**File:** `lite_bootstrap/instruments/swagger_instrument.py` + +Current file: + +```python +import dataclasses + +from lite_bootstrap.instruments.base import BaseConfig, BaseInstrument + + +@dataclasses.dataclass(kw_only=True, frozen=True) +class SwaggerConfig(BaseConfig): + swagger_static_path: str = "/static" + swagger_path: str = "/docs" + swagger_offline_docs: bool = False + + +@dataclasses.dataclass(kw_only=True, slots=True, frozen=True) +class SwaggerInstrument(BaseInstrument[SwaggerConfig]): + pass +``` + +Add a module docstring at the top: + +```python +"""Swagger config and minimal base instrument; framework-specific behavior lives in the bootstrapper subclasses.""" + +import dataclasses + +from lite_bootstrap.instruments.base import BaseConfig, BaseInstrument + + +@dataclasses.dataclass(kw_only=True, frozen=True) +class SwaggerConfig(BaseConfig): + swagger_static_path: str = "/static" + swagger_path: str = "/docs" + swagger_offline_docs: bool = False + + +@dataclasses.dataclass(kw_only=True, slots=True, frozen=True) +class SwaggerInstrument(BaseInstrument[SwaggerConfig]): + pass +``` + +Single addition: the module docstring on line 1. + +### Step 3: REF-5 — add docstring to `prometheus_instrument.py` + +**File:** `lite_bootstrap/instruments/prometheus_instrument.py` + +Current file: + +```python +import dataclasses + +from lite_bootstrap.helpers.path import is_valid_path +from lite_bootstrap.instruments.base import BaseConfig, BaseInstrument + + +@dataclasses.dataclass(kw_only=True, frozen=True) +class PrometheusConfig(BaseConfig): + prometheus_metrics_path: str = "/metrics" + prometheus_metrics_include_in_schema: bool = False + + +@dataclasses.dataclass(kw_only=True, slots=True, frozen=True) +class PrometheusInstrument(BaseInstrument[PrometheusConfig]): + not_ready_message = "prometheus_metrics_path is empty or not valid" + + def is_ready(self) -> bool: + return bool(self.bootstrap_config.prometheus_metrics_path) and is_valid_path( + self.bootstrap_config.prometheus_metrics_path + ) +``` + +Add a module docstring: + +```python +"""Prometheus config and readiness check; framework-specific bootstrap lives in the bootstrapper subclasses.""" + +import dataclasses + +from lite_bootstrap.helpers.path import is_valid_path +from lite_bootstrap.instruments.base import BaseConfig, BaseInstrument + + +@dataclasses.dataclass(kw_only=True, frozen=True) +class PrometheusConfig(BaseConfig): + ... +``` + +(Rest of the file unchanged.) + +### Step 4: Run the full test suite + +```bash +just test +``` + +Expected: 128/128 PASS. REF-3 is a metaclass/MRO change but `abc.ABC` was unused (no abstract methods); dropping it should be invisible at runtime. REF-5 is pure docstring additions. + +Watch for surprises in: +- `tests/test_free_bootstrap.py` — exercises the base instrument lifecycle directly. +- Framework integration tests — instantiate instruments via the bootstrapper chain. + +If anything fails, the most likely cause is some `isinstance(..., abc.ABC)` check somewhere, or a place that relies on the `abc.ABC` metaclass. Search the codebase: `grep -rn "abc\.ABC\|isinstance.*ABC" lite_bootstrap/ tests/`. If only `bootstrappers/base.py` matches (BaseBootstrapper still uses ABC), all good. + +### Step 5: Run lint + +```bash +just lint +``` + +Expected: clean. Watch for `F401` warning on the removed `import abc` — should not fire if the import was actually removed. + +### Step 6: Commit + +Stage exactly three files: + +```bash +git add \ + lite_bootstrap/instruments/base.py \ + lite_bootstrap/instruments/swagger_instrument.py \ + lite_bootstrap/instruments/prometheus_instrument.py +git commit -m "$(cat <<'EOF' +refactor: drop unused abc.ABC from BaseInstrument; document config holders + +REF-3: BaseInstrument inherited from abc.ABC but defined no abstract +methods. After PR7 made the class generic and removed the # noqa: B027 +suppressions, abc.ABC serves no purpose — all four methods +(bootstrap, teardown, is_ready, check_dependencies) are concrete +no-ops with sensible defaults. Drop the abc.ABC parent and the now- +unused `import abc`. + +BaseBootstrapper still uses abc.ABC (it has real abstract methods: +not_ready_message, _prepare_application, is_ready) and is unchanged. + +REF-5: Add one-line module docstrings to swagger_instrument.py and +prometheus_instrument.py explaining that these files hold config and +minimal base logic; framework-specific bootstrap behavior lives in +the bootstrapper subclasses (FastAPISwaggerInstrument, etc.). These +files were left as separate modules per the locked decision in the +deferred-refactors sequencing spec. + +No behavior change. + +Closes REF-3 and REF-5 from the audit. +EOF +)" +``` + +--- + +## Task 3: Push and open PR + +- [ ] **Step 1: Push the branch** + +```bash +git push -u origin refactor/ref-3-5-base-layer +``` + +- [ ] **Step 2: Open the PR** + +```bash +gh pr create --title "refactor: drop unused abc.ABC from BaseInstrument; document config holders" --body "$(cat <<'EOF' +## Summary +Two small base-layer cleanups: + +- **REF-3:** `BaseInstrument` inherited from `abc.ABC` but defined no abstract methods. After PR7 made the class generic and removed the `# noqa: B027` suppressions, `abc.ABC` serves no purpose — all four methods (`bootstrap`, `teardown`, `is_ready`, `check_dependencies`) are concrete no-ops with sensible defaults. Drop the `abc.ABC` parent and the now-unused `import abc`. `BaseBootstrapper` still uses `abc.ABC` (it has real abstract methods) and is unchanged. +- **REF-5:** Added one-line module docstrings to `swagger_instrument.py` and `prometheus_instrument.py` explaining that these files hold config and minimal base logic; framework-specific bootstrap behavior lives in the bootstrapper subclasses. Kept as separate modules per the locked decision in the deferred-refactors sequencing spec. + +No behavior change. + +Closes REF-3 and REF-5 from an internal audit. + +## Test plan +- [x] `just test` — 128/128. +- [x] `just lint` — clean. +- [ ] Reviewer: confirm no `isinstance(..., abc.ABC)` check anywhere in the codebase relies on `BaseInstrument`'s ABC parent. + +🤖 Generated with [Claude Code](https://claude.com/claude-code) +EOF +)" +``` + +--- + +## Self-Review + +**Spec coverage check** against the sequencing spec (PR12 section) and audit (REF-3, REF-5): + +| Spec item | Task | +|-----------|------| +| REF-3: drop `abc.ABC` from `BaseInstrument`; drop unused `import abc` | Task 2, Step 1 | +| REF-3: keep `abc.ABC` on `BaseBootstrapper` (not touched) | Task 2, Step 1 (out of scope confirmation) | +| REF-5: docstring on `swagger_instrument.py` | Task 2, Step 2 | +| REF-5: docstring on `prometheus_instrument.py` | Task 2, Step 3 | +| REF-5: keep both files separate (don't collapse) | Locked decisions section | +| Branch name `refactor/ref-3-5-base-layer` | Task 1, Step 1 | +| Verification: `just test` + `just lint` clean | Task 2, Steps 4-5 | + +All spec items covered. No placeholders. + +**Risk:** Low. Both REF-3 and REF-5 are essentially metadata/documentation changes. The only theoretical risk is a downstream consumer relying on `isinstance(inst, abc.ABC)` checks on `BaseInstrument` instances — vanishingly unlikely in practice. The test suite catches it if anything's broken. + + +--- + +# PR13: Drop `frozen=True` From Instruments + FastAPIConfig Default Cleanup (REF-6 + LOW-4) + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Two related-but-distinct cleanups. + +- **REF-6**: Drop `frozen=True` from the instrument hierarchy. Python's dataclass rules force a cascade: dropping `frozen=True` from `LoggingInstrument` requires dropping it from `BaseInstrument`, which requires dropping it from every other instrument subclass. 23 dataclass declarations across 12 files lose `frozen=True`. In `LoggingInstrument` and `OpenTelemetryInstrument`, the four `object.__setattr__(self, "_x", value)` workarounds for cached runtime state become plain `self._x = value` assignments. **Configs stay frozen** — only instruments lose `frozen=True`. + +- **LOW-4**: `FastAPIConfig.application` currently uses `default=None` + `# ty: ignore[invalid-assignment]` because the field is typed `fastapi.FastAPI` (non-Optional). Replace with a proper sentinel-type pattern: introduce `UnsetType` + `UNSET` in `lite_bootstrap/types.py` (a sentinel class with a singleton instance), type the field as `fastapi.FastAPI | UnsetType`, default to `UNSET`, and replace the truthiness check in `__post_init__` with `isinstance(self.application, UnsetType)`. Add a `_narrow_app(config)` helper at module scope that asserts the type and returns the narrowed value; FastAPI framework instruments call `_narrow_app(self.bootstrap_config)` instead of `self.bootstrap_config.application` directly. Drops the `# ty: ignore`. FastAPIConfig stays frozen — `object.__setattr__(self, "application", ...)` in `__post_init__` remains because the freeze bypass is the only way to mutate a frozen field after construction; a code comment documents the rationale. + +**Architecture:** Largest mechanical refactor in the deferred-refactors sequence. The cascade is purely mechanical — every change is `frozen=True` → (delete). The setattr replacements and LOW-4 sentinel are the only meaningful diffs. + +**Tech Stack:** Python 3.10+ dataclasses, frozen-inheritance rules. + +**Parent spec:** `docs/superpowers/specs/2026-06-01-deferred-refactors-sequencing.md` (PR13 section). +**Parent audit:** `docs/superpowers/specs/2026-05-31-bug-refactor-audit.md` (REF-6, LOW-4). + +--- + +## Important context: Why the cascade + +Python's `@dataclasses.dataclass` enforces that **a non-frozen dataclass cannot inherit from a frozen one** (and vice versa). The check fires at class definition time as `TypeError`. + +Today's instrument hierarchy: +- `BaseInstrument(frozen=True)` +- 8 base instrument subclasses, all `frozen=True` +- 15 framework instrument subclasses inheriting from the base instruments, all `frozen=True` + +To drop `frozen=True` from `LoggingInstrument` (REF-6's stated target), `BaseInstrument` must also drop it, which forces every other subclass to drop it too. There's no surgical option. + +The sequencing spec's PR13 section said "drop `frozen=True` from `LoggingInstrument` and `OpenTelemetryInstrument`" — that was incorrect; the cascade is required. This plan implements the cascade. + +--- + +## File Structure + +12 files modified. + +**Instrument modules (9 files):** +- `lite_bootstrap/instruments/base.py` — `BaseInstrument` loses `frozen=True`. +- `lite_bootstrap/instruments/cors_instrument.py` — `CorsInstrument` loses `frozen=True`. +- `lite_bootstrap/instruments/healthchecks_instrument.py` — `HealthChecksInstrument` loses `frozen=True`. +- `lite_bootstrap/instruments/logging_instrument.py` — `LoggingInstrument` loses `frozen=True`; 2 `object.__setattr__` calls become direct assignment. +- `lite_bootstrap/instruments/opentelemetry_instrument.py` — `OpenTelemetryInstrument` loses `frozen=True`; 2 `object.__setattr__` calls become direct assignment. +- `lite_bootstrap/instruments/prometheus_instrument.py` — `PrometheusInstrument` loses `frozen=True`. +- `lite_bootstrap/instruments/pyroscope_instrument.py` — `PyroscopeInstrument` loses `frozen=True`. +- `lite_bootstrap/instruments/sentry_instrument.py` — `SentryInstrument` loses `frozen=True`. +- `lite_bootstrap/instruments/swagger_instrument.py` — `SwaggerInstrument` loses `frozen=True`. + +**Bootstrapper modules (3 files):** +- `lite_bootstrap/bootstrappers/fastapi_bootstrapper.py` — 5 framework instruments lose `frozen=True` + LOW-4 sentinel-type pattern on `FastAPIConfig.application` + `_narrow_app` helper + every FastAPI instrument bootstrap calls `_narrow_app(self.bootstrap_config)` for the app reference. +- `lite_bootstrap/bootstrappers/litestar_bootstrapper.py` — 6 framework instruments lose `frozen=True`. +- `lite_bootstrap/bootstrappers/faststream_bootstrapper.py` — 4 framework instruments lose `frozen=True`. + +**Shared types (1 file):** +- `lite_bootstrap/types.py` — add `UnsetType` class + `UNSET: typing.Final[UnsetType]` singleton. Reusable sentinel for fields that distinguish "not passed" from "explicitly None". + +--- + +## Locked decisions + +- **Cascade scope:** Drop `frozen=True` from `BaseInstrument` and ALL 22 instrument subclasses. Configs stay frozen. Confirmed by the user after the constraint surfaced. +- **LOW-4 pattern:** Proper `UnsetType` sentinel class in `lite_bootstrap/types.py`, used via `isinstance(value, UnsetType)`. Honest to the type checker (no `typing.cast` lie). Adds a `_narrow_app` helper that wraps the assert/return narrowing for callers. Revised from the original plan's `typing.cast("fastapi.FastAPI", object())` pattern — the spec was updated retroactively to match what was built. +- **`FastAPIConfig` stays frozen:** Confirmed by user. The `object.__setattr__(self, "application", ...)` in `__post_init__` remains; a one-line code comment documents the rationale (frozen for user-facing immutability; bypass needed because `application` is constructed using other config fields). +- **No new tests.** The full existing test suite verifies behavior preservation; pure-refactor changes should not affect runtime semantics other than enabling future direct mutation (which we don't exercise). + +--- + +## Cascade list (23 dataclass declarations) + +For traceability, every dataclass decorator changing from `@dataclasses.dataclass(kw_only=True, slots=True, frozen=True)` to `@dataclasses.dataclass(kw_only=True, slots=True)` (or `@dataclasses.dataclass(kw_only=True, frozen=True)` to `@dataclasses.dataclass(kw_only=True)`): + +| # | Class | File | +|---|-------|------| +| 1 | `BaseInstrument` | `instruments/base.py` | +| 2 | `CorsInstrument` | `instruments/cors_instrument.py` | +| 3 | `HealthChecksInstrument` | `instruments/healthchecks_instrument.py` | +| 4 | `LoggingInstrument` | `instruments/logging_instrument.py` | +| 5 | `OpenTelemetryInstrument` | `instruments/opentelemetry_instrument.py` | +| 6 | `PrometheusInstrument` | `instruments/prometheus_instrument.py` | +| 7 | `PyroscopeInstrument` | `instruments/pyroscope_instrument.py` | +| 8 | `SentryInstrument` | `instruments/sentry_instrument.py` | +| 9 | `SwaggerInstrument` | `instruments/swagger_instrument.py` | +| 10 | `FastAPICorsInstrument` | `bootstrappers/fastapi_bootstrapper.py` | +| 11 | `FastAPIHealthChecksInstrument` | `bootstrappers/fastapi_bootstrapper.py` | +| 12 | `FastAPIOpenTelemetryInstrument` | `bootstrappers/fastapi_bootstrapper.py` | +| 13 | `FastAPIPrometheusInstrument` | `bootstrappers/fastapi_bootstrapper.py` | +| 14 | `FastAPISwaggerInstrument` | `bootstrappers/fastapi_bootstrapper.py` | +| 15 | `LitestarCorsInstrument` | `bootstrappers/litestar_bootstrapper.py` | +| 16 | `LitestarHealthChecksInstrument` | `bootstrappers/litestar_bootstrapper.py` | +| 17 | `LitestarLoggingInstrument` | `bootstrappers/litestar_bootstrapper.py` | +| 18 | `LitestarOpenTelemetryInstrument` | `bootstrappers/litestar_bootstrapper.py` | +| 19 | `LitestarPrometheusInstrument` | `bootstrappers/litestar_bootstrapper.py` | +| 20 | `LitestarSwaggerInstrument` | `bootstrappers/litestar_bootstrapper.py` | +| 21 | `FastStreamHealthChecksInstrument` | `bootstrappers/faststream_bootstrapper.py` | +| 22 | `FastStreamLoggingInstrument` | `bootstrappers/faststream_bootstrapper.py` | +| 23 | `FastStreamOpenTelemetryInstrument` | `bootstrappers/faststream_bootstrapper.py` | +| 24 | `FastStreamPrometheusInstrument` | `bootstrappers/faststream_bootstrapper.py` | + +(Yes, that's 24 — `LitestarSwaggerInstrument` is on the list because it inherits from `SwaggerInstrument`. All 24 actually need updating to keep the cascade consistent.) + +**Configs are NOT in this list.** `BaseConfig`, `LoggingConfig`, `SentryConfig`, `OpentelemetryConfig`, `PyroscopeConfig`, `CorsConfig`, `HealthChecksConfig`, `PrometheusConfig`, `SwaggerConfig`, `OpenTelemetryServiceFieldsConfig`, `FastAPIConfig`, `LitestarConfig`, `FastStreamConfig`, `FreeBootstrapperConfig` — all stay frozen. + +--- + +## Task 1: Create branch + +**Files:** (no files; git only) + +- [ ] **Step 1: Branch off `main`** + +```bash +git checkout main +git pull --ff-only origin main +git checkout -b refactor/ref-6-frozen-setattr +``` + +Expected: `Switched to a new branch 'refactor/ref-6-frozen-setattr'`. + +--- + +## Task 2: Drop `frozen=True` from the cascade + +For each file below, the change pattern is identical: locate each instrument's `@dataclasses.dataclass(...)` decorator and remove `, frozen=True` (or `frozen=True,` if it's not last). Configs are untouched. + +### Step 1: `lite_bootstrap/instruments/base.py` + +Locate `BaseInstrument`. Change: + +```python +@dataclasses.dataclass(kw_only=True, slots=True, frozen=True) +class BaseInstrument(typing.Generic[ConfigT]): +``` + +to: + +```python +@dataclasses.dataclass(kw_only=True, slots=True) +class BaseInstrument(typing.Generic[ConfigT]): +``` + +`BaseConfig` (above it) stays untouched — keep `frozen=True`. + +### Step 2: `lite_bootstrap/instruments/cors_instrument.py` + +`CorsInstrument` decorator: drop `frozen=True`. + +### Step 3: `lite_bootstrap/instruments/healthchecks_instrument.py` + +`HealthChecksInstrument` decorator: drop `frozen=True`. + +### Step 4: `lite_bootstrap/instruments/logging_instrument.py` + +`LoggingInstrument` decorator: drop `frozen=True`. + +Then replace 2 `object.__setattr__` calls with direct assignment: + +In `memory_logger_factory` property (around line 109): +```python +# Before: +object.__setattr__(self, "_logger_factory", cached) +# After: +self._logger_factory = cached +``` + +In `teardown` method: +```python +# Before: +try: + self._logger_factory.close_handlers() +finally: + object.__setattr__(self, "_logger_factory", None) + +# After: +try: + self._logger_factory.close_handlers() +finally: + self._logger_factory = None +``` + +### Step 5: `lite_bootstrap/instruments/opentelemetry_instrument.py` + +`OpenTelemetryInstrument` decorator: drop `frozen=True`. + +Then replace 2 `object.__setattr__` calls: + +In `bootstrap()`: +```python +# Before: +object.__setattr__(self, "_tracer_provider", tracer_provider) +# After: +self._tracer_provider = tracer_provider +``` + +In `teardown()`: +```python +# Before: +try: + self._tracer_provider.shutdown() +finally: + object.__setattr__(self, "_tracer_provider", None) + +# After: +try: + self._tracer_provider.shutdown() +finally: + self._tracer_provider = None +``` + +### Step 6: `lite_bootstrap/instruments/prometheus_instrument.py` + +`PrometheusInstrument` decorator: drop `frozen=True`. + +### Step 7: `lite_bootstrap/instruments/pyroscope_instrument.py` + +`PyroscopeInstrument` decorator: drop `frozen=True`. + +### Step 8: `lite_bootstrap/instruments/sentry_instrument.py` + +`SentryInstrument` decorator: drop `frozen=True`. + +### Step 9: `lite_bootstrap/instruments/swagger_instrument.py` + +`SwaggerInstrument` decorator: drop `frozen=True`. + +### Step 10: `lite_bootstrap/bootstrappers/fastapi_bootstrapper.py` — 5 framework instruments + +Drop `frozen=True` from the decorators of: +- `FastAPICorsInstrument` +- `FastAPIHealthChecksInstrument` +- `FastAPIOpenTelemetryInstrument` +- `FastAPIPrometheusInstrument` +- `FastAPISwaggerInstrument` + +Each uses `@dataclasses.dataclass(kw_only=True, frozen=True)` (no `slots=True`). After: `@dataclasses.dataclass(kw_only=True)`. + +`FastAPIConfig` stays untouched here (frozen). The LOW-4 sentinel change comes in Task 3. + +### Step 11: `lite_bootstrap/bootstrappers/litestar_bootstrapper.py` — 6 framework instruments + +Drop `frozen=True` from: +- `LitestarCorsInstrument` +- `LitestarHealthChecksInstrument` +- `LitestarLoggingInstrument` +- `LitestarOpenTelemetryInstrument` +- `LitestarPrometheusInstrument` +- `LitestarSwaggerInstrument` + +`LitestarConfig` stays frozen. + +### Step 12: `lite_bootstrap/bootstrappers/faststream_bootstrapper.py` — 4 framework instruments + +Drop `frozen=True` from: +- `FastStreamHealthChecksInstrument` +- `FastStreamLoggingInstrument` +- `FastStreamOpenTelemetryInstrument` +- `FastStreamPrometheusInstrument` + +`FastStreamConfig` stays frozen. + +### Step 13: Quick verification + +```bash +just test +``` + +Expected: 128/128 PASS. If anything fails, the most likely cause is a `frozen=True` left in one of the 24 classes (Python's TypeError surfaces immediately on import). + +Run a sanity grep to confirm no instrument-class declaration still has `frozen=True`: + +```bash +grep -rn "frozen=True" lite_bootstrap/instruments/ lite_bootstrap/bootstrappers/ | grep -v "Config" +``` + +Expected: zero matches. The `grep -v "Config"` filters out config classes (which should still have `frozen=True`). + +--- + +## Task 3: LOW-4 — FastAPIConfig sentinel pattern + +**File:** `lite_bootstrap/bootstrappers/fastapi_bootstrapper.py` + +### Step 1: Add module-level sentinel and update `FastAPIConfig` + +Locate the top of the file (after the conditional imports for fastapi). Add this constant right after the `if import_checker.is_fastapi_installed:` block: + +```python +if import_checker.is_fastapi_installed: + import fastapi + from fastapi.middleware.cors import CORSMiddleware + from fastapi.routing import _merge_lifespan_context + +# ... other conditional imports stay as they are ... + +_UNSET_FASTAPI_APP: typing.Final = typing.cast("fastapi.FastAPI", object()) +``` + +`typing.cast(...)` is a runtime no-op (returns the second argument). The type checker sees `_UNSET_FASTAPI_APP` as `fastapi.FastAPI`; at runtime it's a unique `object()` sentinel. `typing.Final` prevents accidental reassignment. + +The string-quoted `"fastapi.FastAPI"` in the cast lets the line evaluate even when `fastapi` isn't installed (cast doesn't look up the type at runtime). + +### Step 2: Update `FastAPIConfig.application` field declaration and `__post_init__` + +Locate `FastAPIConfig`. Current: + +```python +@dataclasses.dataclass(kw_only=True, slots=True, frozen=True) +class FastAPIConfig( + CorsConfig, + ... +): + application: "fastapi.FastAPI" = dataclasses.field(default=None) # ty: ignore[invalid-assignment] + application_kwargs: dict[str, typing.Any] = dataclasses.field(default_factory=dict) + ... + + def __post_init__(self) -> None: + if not import_checker.is_fastapi_installed: + msg = "fastapi is not installed" + raise ConfigurationError(msg) + + if not self.application: + object.__setattr__( + self, "application", fastapi.FastAPI(docs_url=self.swagger_path, **self.application_kwargs) + ) + elif self.application_kwargs: + warnings.warn("application_kwargs must be used without application", stacklevel=2) + + self.application.title = self.service_name + self.application.debug = self.service_debug + self.application.version = self.service_version +``` + +Change two things: + +1. Replace `application: "fastapi.FastAPI" = dataclasses.field(default=None) # ty: ignore[invalid-assignment]` with `application: "fastapi.FastAPI" = _UNSET_FASTAPI_APP`. Drop the `# ty: ignore`. + +2. In `__post_init__`, replace `if not self.application:` with `if self.application is _UNSET_FASTAPI_APP:`. Identity check instead of truthiness — clearer intent. + +After: + +```python +@dataclasses.dataclass(kw_only=True, slots=True, frozen=True) +class FastAPIConfig( + CorsConfig, + ... +): + application: "fastapi.FastAPI" = _UNSET_FASTAPI_APP + application_kwargs: dict[str, typing.Any] = dataclasses.field(default_factory=dict) + ... + + def __post_init__(self) -> None: + if not import_checker.is_fastapi_installed: + msg = "fastapi is not installed" + raise ConfigurationError(msg) + + if self.application is _UNSET_FASTAPI_APP: + object.__setattr__( + self, "application", fastapi.FastAPI(docs_url=self.swagger_path, **self.application_kwargs) + ) + elif self.application_kwargs: + warnings.warn("application_kwargs must be used without application", stacklevel=2) + + self.application.title = self.service_name + self.application.debug = self.service_debug + self.application.version = self.service_version +``` + +The `object.__setattr__` for `self.application` stays — `FastAPIConfig` is still frozen. + +The `self.application.title = ...` lines below also stay — they mutate the `fastapi.FastAPI` instance (which isn't frozen), not the `FastAPIConfig` dataclass. + +--- + +## Task 4: Verify and commit + +### Step 1: Run the full test suite + +```bash +just test +``` + +Expected: 128/128 PASS. The cascade is mechanical and should not affect runtime behavior. Watch for surprises in: + +- All framework integration tests (FastAPI, Litestar, FastStream, Free) — they exercise the full instrument lifecycle. +- `test_logging_instrument_lifecycle_replay` (from PR11) — confirms the `_logger_factory` direct-assignment doesn't break the replay cycle. +- `test_opentelemetry_instrument_teardown_shuts_down_tracer_provider` (from PR2) — confirms the `_tracer_provider` direct-assignment doesn't break shutdown. + +If any test fails because something now mutates an instrument unexpectedly, that's a real bug surfaced by the refactor (frozen was masking it). Stop and investigate. + +### Step 2: Run lint + +```bash +just lint +``` + +Expected: clean. The `# ty: ignore[invalid-assignment]` is gone from `FastAPIConfig.application`. No new lint warnings should appear. + +### Step 3: Verify the cascade + +```bash +grep -n "frozen=True" lite_bootstrap/instruments/*.py lite_bootstrap/bootstrappers/*.py +``` + +Expected matches: ONLY on config classes (`BaseConfig`, `LoggingConfig`, `SentryConfig`, `OpentelemetryConfig`, `PyroscopeConfig`, `CorsConfig`, `HealthChecksConfig`, `PrometheusConfig`, `SwaggerConfig`, `OpenTelemetryServiceFieldsConfig`, `FastAPIConfig`, `LitestarConfig`, `FastStreamConfig`, `FreeBootstrapperConfig`). + +No instrument class should match. If one does, that's a missed cascade entry — fix it before committing. + +### Step 4: Commit + +Stage exactly the 12 modified files: + +```bash +git add \ + lite_bootstrap/instruments/base.py \ + lite_bootstrap/instruments/cors_instrument.py \ + lite_bootstrap/instruments/healthchecks_instrument.py \ + lite_bootstrap/instruments/logging_instrument.py \ + lite_bootstrap/instruments/opentelemetry_instrument.py \ + lite_bootstrap/instruments/prometheus_instrument.py \ + lite_bootstrap/instruments/pyroscope_instrument.py \ + lite_bootstrap/instruments/sentry_instrument.py \ + lite_bootstrap/instruments/swagger_instrument.py \ + lite_bootstrap/bootstrappers/fastapi_bootstrapper.py \ + lite_bootstrap/bootstrappers/litestar_bootstrapper.py \ + lite_bootstrap/bootstrappers/faststream_bootstrapper.py +git commit -m "$(cat <<'EOF' +refactor: drop frozen=True from instruments; sentinel for FastAPIConfig.application + +REF-6: LoggingInstrument and OpenTelemetryInstrument cached mutable +runtime state (_logger_factory, _tracer_provider) via +object.__setattr__ workarounds because the instruments were declared +frozen=True. The frozen claim was partly false — those two fields +mutated freely under the hood. + +Python's dataclass rules forbid surgically dropping frozen=True from +a subclass while the parent remains frozen (TypeError at class +definition). The fix cascades through BaseInstrument and all 22 +instrument subclasses: drop frozen=True from each. Configs stay +frozen — only instruments lose immutability. The 4 object.__setattr__ +call sites in LoggingInstrument and OpenTelemetryInstrument +(bootstrap-cache + teardown-reset for each) become plain self._x = ... +assignments. + +LOW-4: FastAPIConfig.application declared default=None with a +# ty: ignore[invalid-assignment] because the field is typed +fastapi.FastAPI (non-Optional). Replace with a typed sentinel: +_UNSET_FASTAPI_APP: typing.Final = typing.cast("fastapi.FastAPI", object()) +The cast suppresses the type lie; the sentinel makes __post_init__'s +identity check (is _UNSET_FASTAPI_APP) clearer than the prior +truthiness check (not self.application). FastAPIConfig stays frozen, +so the object.__setattr__(self, "application", ...) in __post_init__ +remains — only the default and the check change. + +No behavior change. 128/128 tests pass. + +Closes REF-6 and LOW-4 from the audit. +EOF +)" +``` + +--- + +## Task 5: Push and open PR + +- [ ] **Step 1: Push the branch** + +```bash +git push -u origin refactor/ref-6-frozen-setattr +``` + +- [ ] **Step 2: Open the PR** + +```bash +gh pr create --title "refactor: drop frozen=True from instruments; sentinel for FastAPIConfig.application" --body "$(cat <<'EOF' +## Summary +Two related cleanups in one PR: + +- **REF-6 cascade:** `LoggingInstrument` and `OpenTelemetryInstrument` cached mutable runtime state via `object.__setattr__` workarounds because they were declared `frozen=True`. Python's dataclass rules forbid surgically dropping `frozen=True` from one subclass while the parent stays frozen — the cascade is required. `BaseInstrument` and all 22 instrument subclasses lose `frozen=True`. The 4 `object.__setattr__` call sites in `LoggingInstrument` and `OpenTelemetryInstrument` become plain `self._x = ...` assignments. +- **LOW-4:** `FastAPIConfig.application` used `default=None` with a `# ty: ignore`. Replaced with a typed sentinel `_UNSET_FASTAPI_APP: typing.Final = typing.cast("fastapi.FastAPI", object())`. The `__post_init__` check changes from `if not self.application:` to `if self.application is _UNSET_FASTAPI_APP:`. `FastAPIConfig` stays frozen — the existing `object.__setattr__(self, "application", ...)` in `__post_init__` remains. + +**Configs are unchanged.** All `*Config` classes keep `frozen=True`. Only instrument classes lose immutability. + +12 files modified; 24 dataclass declarations lose `frozen=True`; 4 `object.__setattr__` call sites simplified; 1 `# ty: ignore` removed. + +No behavior change. 128/128 tests pass. + +Closes REF-6 and LOW-4 from an internal audit. + +## Test plan +- [x] `just test` — 128/128. +- [x] `just lint` — clean (no `# ty: ignore` left in FastAPIConfig). +- [x] `grep -n "frozen=True" lite_bootstrap/instruments/ lite_bootstrap/bootstrappers/` — only config classes match. +- [ ] Reviewer: confirm `LoggingInstrument`'s and `OpenTelemetryInstrument`'s direct assignments preserve the `try/finally` exception safety from PR3. + +## Why the cascade +Python's `@dataclasses.dataclass` enforces that a non-frozen dataclass cannot inherit from a frozen one (and vice versa) — `TypeError` at class definition. To drop `frozen=True` from `LoggingInstrument` (REF-6's stated target), `BaseInstrument` must also drop it, which propagates to every other instrument subclass. The sequencing spec's PR13 section called for a surgical 2-class change; the actual cascade is 24 classes. + +🤖 Generated with [Claude Code](https://claude.com/claude-code) +EOF +)" +``` + +--- + +## Self-Review + +**Spec coverage check** against the sequencing spec (PR13 section) and audit (REF-6, LOW-4): + +| Spec item | Task | +|-----------|------| +| Drop `frozen=True` from instrument hierarchy (cascade) | Task 2, Steps 1-12 | +| Replace `object.__setattr__` in `LoggingInstrument` with direct assignment | Task 2, Step 4 | +| Replace `object.__setattr__` in `OpenTelemetryInstrument` with direct assignment | Task 2, Step 5 | +| Configs stay frozen | Locked decisions + Task 4 Step 3 verification | +| LOW-4: replace `default=None` + `# ty: ignore` with sentinel | Task 3 | +| Branch name `refactor/ref-6-frozen-setattr` | Task 1, Step 1 | +| Verification: `just test` + `just lint` clean | Task 4, Steps 1-2 | + +All spec items covered. No placeholders. + +**Risk:** Medium. The cascade is mechanical but touches many classes. The chief risk is a missed entry — the verification grep at Task 4 Step 3 catches that. + +The behavioral risk is essentially zero: nothing in the codebase currently mutates an instrument after construction except the 4 `object.__setattr__` calls being replaced. Tests verify the cycles still work. + +**Deviation from sequencing spec:** Locked decision said "drop frozen=True from LoggingInstrument and OpenTelemetryInstrument" — implementation required the full 24-class cascade. Documented in the commit message and PR body. The sequencing spec should be updated retroactively (out of scope for this PR; can be a follow-up doc commit). + + +--- + +# PR14: Configurable FastStream Broker Health-Check Timeout (REF-7) + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** `FastStreamHealthChecksInstrument._define_health_status` calls `broker.ping(timeout=5)` with a hardcoded 5-second timeout. For users with slow brokers (large Redis clusters, message queues with cold connections), this is a footgun. Add a `faststream_health_check_broker_timeout: float = 5.0` field on `FastStreamConfig` and wire it through. + +**Architecture:** Pure additive config change. New field with backward-compatible default; existing callers see no behavior difference. One new regression test. + +**Tech Stack:** Python 3.10+ dataclasses, faststream, `unittest.mock.AsyncMock`. + +**Parent spec:** `docs/superpowers/specs/2026-06-01-deferred-refactors-sequencing.md` (PR14 section). +**Parent audit:** `docs/superpowers/specs/2026-05-31-bug-refactor-audit.md` (REF-7). + +--- + +## File Structure + +Two files modified. + +- Modify: `lite_bootstrap/bootstrappers/faststream_bootstrapper.py` — add `faststream_health_check_broker_timeout: float = 5.0` to `FastStreamConfig`; update `FastStreamHealthChecksInstrument._define_health_status` to use it. +- Modify: `tests/test_faststream_bootstrap.py` — add `test_faststream_health_check_uses_configured_broker_timeout` exercising a non-default timeout. + +--- + +## Locked decisions (from sequencing spec) + +- **Field placement:** `FastStreamConfig`, NOT shared `HealthChecksConfig`. The timeout is FastStream-shaped (it's specifically about a message broker ping), so it belongs on the FastStream-specific config alongside other FastStream-only fields (`faststream_log_level`, `opentelemetry_middleware_cls`, etc.). Putting it on `HealthChecksConfig` would pollute FastAPI/Litestar configs with an unused field. +- **Default `5.0`:** Preserves existing behavior. Users who don't set the field see no change. +- **Field name:** `faststream_health_check_broker_timeout`. Prefixed `faststream_` for consistency with the other FastStream-specific fields on this config. + +--- + +## Task 1: Create branch + +**Files:** (no files; git only) + +- [ ] **Step 1: Branch off `main`** + +```bash +git checkout main +git pull --ff-only origin main +git checkout -b fix/ref-7-faststream-timeout +``` + +Expected: `Switched to a new branch 'fix/ref-7-faststream-timeout'`. + +--- + +## Task 2: Add the failing regression test + +**File:** `tests/test_faststream_bootstrap.py` + +The existing file uses `RedisBroker` fixtures and `TestClient` from starlette to exercise the health check. We'll spy on `broker.ping` to capture the timeout argument. + +### Step 1: Update imports + +Current top of file: + +```python +import logging +import typing + +import faststream.asgi +import pytest +import structlog +from faststream._internal.broker import BrokerUsecase +from faststream._internal.logger.params_storage import ManualLoggerStorage +from faststream.redis import RedisBroker, TestRedisBroker +... +``` + +Add `dataclasses` (stdlib) and `from unittest.mock import AsyncMock, patch` (stdlib). After: + +```python +import dataclasses +import logging +import typing +from unittest.mock import AsyncMock, patch + +import faststream.asgi +import pytest +import structlog +from faststream._internal.broker import BrokerUsecase +from faststream._internal.logger.params_storage import ManualLoggerStorage +from faststream.redis import RedisBroker, TestRedisBroker +... +``` + +(Preserve any existing imports between these — only adding the three new lines in the right import groups.) + +### Step 2: Append the new test + +At the end of the file, add: + +```python +async def test_faststream_health_check_uses_configured_broker_timeout(broker: RedisBroker) -> None: + expected_timeout = 12.5 + config = dataclasses.replace( + build_faststream_config(broker=broker), + faststream_health_check_broker_timeout=expected_timeout, + ) + bootstrapper = FastStreamBootstrapper(bootstrap_config=config) + application = bootstrapper.bootstrap() + try: + with ( + patch.object(broker, "ping", new=AsyncMock(return_value=True)) as mock_ping, + TestClient(app=application) as test_client, + ): + response = test_client.get(config.health_checks_path) + assert response.status_code == status.HTTP_200_OK + mock_ping.assert_called_once_with(timeout=expected_timeout) + finally: + bootstrapper.teardown() +``` + +Contract: +- `dataclasses.replace` on a `FastStreamConfig` (still `frozen=True` post-PR13) creates a new instance overriding only the timeout. +- `patch.object(broker, "ping", new=AsyncMock(return_value=True))` replaces `broker.ping` with an async mock that returns `True` (healthy). +- `TestClient(app=application).get(config.health_checks_path)` triggers the health check, which calls `await broker.ping(timeout=...)`. +- `mock_ping.assert_called_once_with(timeout=expected_timeout)` asserts the configured value reached the broker. + +Note: `expected_timeout = 12.5` extracts the magic value into a named local — per the no-`PLR2004`-noqa policy established in PR10. + +### Step 3: Run the test and verify it FAILS + +```bash +just test -- 'tests/test_faststream_bootstrap.py::test_faststream_health_check_uses_configured_broker_timeout' -v +``` + +Expected: **FAIL** in one of two ways: +- `AttributeError: 'FastStreamConfig' object has no attribute 'faststream_health_check_broker_timeout'` (the field doesn't exist yet on the config). +- Or: `AssertionError: expected call: ping(timeout=12.5)\nactual call: ping(timeout=5)` (if the field is somehow on the config but the instrument still hardcodes `5`). + +Either way, the test should NOT pass before the fix. If it does, stop and investigate. + +--- + +## Task 3: Implement the fix + +**File:** `lite_bootstrap/bootstrappers/faststream_bootstrapper.py` + +### Step 1: Add field to `FastStreamConfig` + +Locate `FastStreamConfig`. Current: + +```python +@dataclasses.dataclass(kw_only=True, slots=True, frozen=True) +class FastStreamConfig( + HealthChecksConfig, LoggingConfig, OpentelemetryConfig, PrometheusConfig, PyroscopeConfig, SentryConfig +): + application: "AsgiFastStream" = dataclasses.field(default_factory=_make_asgi_faststream) + opentelemetry_middleware_cls: type[FastStreamTelemetryMiddlewareProtocol] | None = None + prometheus_middleware_cls: type[FastStreamPrometheusMiddlewareProtocol] | None = None + faststream_log_level: int = logging.WARNING +``` + +Add the new field at the end of the body: + +```python +@dataclasses.dataclass(kw_only=True, slots=True, frozen=True) +class FastStreamConfig( + HealthChecksConfig, LoggingConfig, OpentelemetryConfig, PrometheusConfig, PyroscopeConfig, SentryConfig +): + application: "AsgiFastStream" = dataclasses.field(default_factory=_make_asgi_faststream) + opentelemetry_middleware_cls: type[FastStreamTelemetryMiddlewareProtocol] | None = None + prometheus_middleware_cls: type[FastStreamPrometheusMiddlewareProtocol] | None = None + faststream_log_level: int = logging.WARNING + faststream_health_check_broker_timeout: float = 5.0 +``` + +Single additive change. + +### Step 2: Use the field in `FastStreamHealthChecksInstrument._define_health_status` + +Locate `_define_health_status`. Current: + +```python + async def _define_health_status(self) -> bool: + if not self.bootstrap_config.application or not self.bootstrap_config.application.broker: + return False + + return await self.bootstrap_config.application.broker.ping(timeout=5) +``` + +Replace the hardcoded `timeout=5` with `timeout=self.bootstrap_config.faststream_health_check_broker_timeout`: + +```python + async def _define_health_status(self) -> bool: + if not self.bootstrap_config.application or not self.bootstrap_config.application.broker: + return False + + return await self.bootstrap_config.application.broker.ping( + timeout=self.bootstrap_config.faststream_health_check_broker_timeout, + ) +``` + +The expression is long enough that ruff will likely format it as multi-line (as shown). If ruff formats differently, accept its choice. + +### Step 3: Run the new test, verify PASS + +```bash +just test -- 'tests/test_faststream_bootstrap.py::test_faststream_health_check_uses_configured_broker_timeout' -v +``` + +Expected: PASS. + +### Step 4: Run the full FastStream test file + +```bash +just test -- tests/test_faststream_bootstrap.py -v +``` + +Expected: all tests PASS. Watch the existing `test_faststream_bootstrap` (which exercises the health check via a real broker connection) — the default `5.0` is identical to the prior hardcoded `5`, so behavior should be unchanged. + +### Step 5: Run the full test suite + +```bash +just test +``` + +Expected: 129/129 (128 prior + 1 new). + +### Step 6: Run lint + +```bash +just lint +``` + +Expected: clean. The new field's `: float = 5.0` annotation should not trigger any ruff complaints; the test's `expected_timeout = 12.5` named-local pattern avoids PLR2004. + +### Step 7: Commit + +Stage the two modified files explicitly: + +```bash +git add \ + lite_bootstrap/bootstrappers/faststream_bootstrapper.py \ + tests/test_faststream_bootstrap.py +git commit -m "$(cat <<'EOF' +feat: configurable broker ping timeout for FastStream health check + +FastStreamHealthChecksInstrument._define_health_status called +broker.ping(timeout=5) with a hardcoded 5-second timeout. For users +with slow brokers (large Redis clusters under load, message queues +with cold connections), this is a footgun. + +Add faststream_health_check_broker_timeout: float = 5.0 to +FastStreamConfig. Default preserves the existing behavior; users can +now override. + +The field lives on FastStreamConfig (not the shared HealthChecksConfig) +because the timeout is FastStream-shaped — it's specifically about a +message broker ping, not a generic concern that FastAPI/Litestar +health checks would share. + +Regression test patches broker.ping to assert the configured timeout +value reaches it. + +Closes REF-7 from the audit. +EOF +)" +``` + +--- + +## Task 4: Push and open PR + +- [ ] **Step 1: Push the branch** + +```bash +git push -u origin fix/ref-7-faststream-timeout +``` + +- [ ] **Step 2: Open the PR** + +```bash +gh pr create --title "feat: configurable broker ping timeout for FastStream health check" --body "$(cat <<'EOF' +## Summary +- Added `faststream_health_check_broker_timeout: float = 5.0` to `FastStreamConfig`. +- `FastStreamHealthChecksInstrument._define_health_status` now reads from the config instead of the previously hardcoded `timeout=5`. +- New regression test patches `broker.ping` and asserts the configured value reaches it. + +Default preserves existing behavior — pure-additive config option. Users with slow brokers can now bump the timeout without forking the library. + +Closes REF-7 from an internal audit. + +## Test plan +- [x] `just test -- 'tests/test_faststream_bootstrap.py::test_faststream_health_check_uses_configured_broker_timeout' -v` — pass. +- [x] `just test` — 129/129. +- [x] `just lint` — clean. +- [ ] Reviewer: confirm the field lives on `FastStreamConfig` (not `HealthChecksConfig`) — this was the locked decision in the sequencing spec because the timeout is FastStream-shaped. + +🤖 Generated with [Claude Code](https://claude.com/claude-code) +EOF +)" +``` + +--- + +## Self-Review + +**Spec coverage check** against the sequencing spec (PR14 section) and audit (REF-7): + +| Spec item | Task | +|-----------|------| +| Add `faststream_health_check_broker_timeout: float = 5.0` to `FastStreamConfig` | Task 3, Step 1 | +| Update `_define_health_status` to use the field instead of hardcoded `5` | Task 3, Step 2 | +| Add a regression test asserting the configured timeout reaches `broker.ping` | Task 2, Step 2 | +| Field on `FastStreamConfig`, NOT `HealthChecksConfig` (locked decision Q5) | Task 3, Step 1 | +| Branch name `fix/ref-7-faststream-timeout` | Task 1, Step 1 | +| Verification: `just test` + `just lint` clean | Task 3, Steps 5-6 | +| `expected_timeout = 12.5` named local (no PLR2004 noqa) | Task 2, Step 2 | + +All spec items covered. No placeholders. Risk: low — additive change with a backward-compatible default. + + +--- + +# PR15: Naming Pass — `Opentelemetry`→`OpenTelemetry` + `FreeBootstrapperConfig`→`FreeConfig` + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** The final PR of the deferred-refactors sequence. Two API-surface renames with silent backward-compatibility aliases. + +- **Bonus (Otel capitalization)**: `OpentelemetryConfig` → `OpenTelemetryConfig` to match `OpenTelemetryServiceFieldsConfig` (introduced in PR6) and conventional OpenTelemetry capitalization. +- **LOW-7**: `FreeBootstrapperConfig` → `FreeConfig` for consistency with sibling configs (`FastAPIConfig`, `LitestarConfig`, `FastStreamConfig` — none carry the `Bootstrapper` infix). + +Backward compat: silent aliases (`OpentelemetryConfig = OpenTelemetryConfig`, `FreeBootstrapperConfig = FreeConfig`) at module level plus a `FreeBootstrapperConfig` re-export in `lite_bootstrap/__init__.py`. Existing user code that imports the old names continues to work unchanged. + +**Architecture:** Mechanical renames across 11 files (7 production + 4 test). The aliases are simple assignments — same class object, so `isinstance(x, OldName)` and `isinstance(x, NewName)` are interchangeable. + +**Tech Stack:** Python 3.10+ dataclasses, module-level aliases. + +**Parent spec:** `docs/superpowers/specs/2026-06-01-deferred-refactors-sequencing.md` (PR15 section). +**Parent audit:** `docs/superpowers/specs/2026-05-31-bug-refactor-audit.md` (LOW-7). + +--- + +## File Structure + +11 files modified, no new files. + +**Production (7 files):** +- `lite_bootstrap/instruments/opentelemetry_instrument.py` — rename `OpentelemetryConfig` → `OpenTelemetryConfig`; add silent alias. +- `lite_bootstrap/bootstrappers/free_bootstrapper.py` — rename `FreeBootstrapperConfig` → `FreeConfig`; add silent alias; update internal references. +- `lite_bootstrap/bootstrappers/fastapi_bootstrapper.py` — update import and inheritance to `OpenTelemetryConfig`. +- `lite_bootstrap/bootstrappers/litestar_bootstrapper.py` — same. +- `lite_bootstrap/bootstrappers/faststream_bootstrapper.py` — same. +- `lite_bootstrap/__init__.py` — export both `FreeConfig` (canonical) and `FreeBootstrapperConfig` (alias) in `__all__`. + +**Tests (4 files):** +- `tests/test_free_bootstrap.py` — update internal usages to `FreeConfig`. +- `tests/instruments/test_opentelemetry_instrument.py` — update usages to `OpenTelemetryConfig`. +- `tests/instruments/test_logging_instrument.py` — update usages to `OpenTelemetryConfig`. +- `tests/instruments/test_pyroscope_instrument.py` — update usages to `FreeConfig`. + +--- + +## Locked decisions (from sequencing spec, Q2 + Q7) + +- **Silent aliases** (no warn-on-access). Library is small; warn-on-access is overkill for two renames. +- **Both names in `__init__.py`** for `FreeBootstrapperConfig`/`FreeConfig`. `OpentelemetryConfig` is not in `__init__.py` today, so the module-level alias suffices. +- **Update internal references and tests to the new names.** Aliases serve external users, not internal code. Aliases also exercise the new public API via tests. +- **Alias is a class assignment, not a subclass:** `OpentelemetryConfig = OpenTelemetryConfig` — same class object. `isinstance(x, OpentelemetryConfig) is isinstance(x, OpenTelemetryConfig)`. No `__init_subclass__` surprises, no MRO churn. + +--- + +## Cross-cutting concerns + +1. **Pickling.** Class identity is preserved by the alias (same object). New pickles use `OpenTelemetryConfig.__qualname__`. Old pickles (made before the rename, containing `OpentelemetryConfig` in their serialized form) still unpickle because the alias keeps the name resolvable in the module namespace. No data migration needed. + +2. **Import ordering.** The alias line MUST come AFTER the class definition. Standard Python — but easy to get wrong if reordering imports. + +3. **No PLR2004 noqa.** No new magic-value assertions in this PR. + +--- + +## Task 1: Create branch + +**Files:** (no files; git only) + +- [ ] **Step 1: Branch off `main`** + +```bash +git checkout main +git pull --ff-only origin main +git checkout -b refactor/low-7-naming +``` + +Expected: `Switched to a new branch 'refactor/low-7-naming'`. + +--- + +## Task 2: Rename `OpentelemetryConfig` → `OpenTelemetryConfig` + +### Step 1: Rename in `lite_bootstrap/instruments/opentelemetry_instrument.py` + +Locate the class definition (after `OpenTelemetryServiceFieldsConfig` from PR6): + +```python +@dataclasses.dataclass(kw_only=True, frozen=True) +class OpentelemetryConfig(OpenTelemetryServiceFieldsConfig): + ... +``` + +Change `class OpentelemetryConfig` → `class OpenTelemetryConfig`. + +Locate `OpenTelemetryInstrument`'s generic parameter: + +```python +class OpenTelemetryInstrument(BaseInstrument[OpentelemetryConfig]): +``` + +Change to `BaseInstrument[OpenTelemetryConfig]`. + +Locate any other reference to `OpentelemetryConfig` in the file (e.g., field type annotations, function signatures) — there shouldn't be many; the symbol mostly appears in the class definition and the instrument generic. + +**Add the alias at the very end of the file**, after all class declarations: + +```python +# Backward-compatible alias preserved for users importing the old (lowercase t) spelling. +OpentelemetryConfig = OpenTelemetryConfig +``` + +### Step 2: Update inheritance + imports in three framework bootstrappers + +**File:** `lite_bootstrap/bootstrappers/fastapi_bootstrapper.py` + +Locate the import line: + +```python +from lite_bootstrap.instruments.opentelemetry_instrument import OpentelemetryConfig, OpenTelemetryInstrument +``` + +Change `OpentelemetryConfig` → `OpenTelemetryConfig`. + +Locate `FastAPIConfig`'s inheritance: + +```python +class FastAPIConfig( + CorsConfig, + HealthChecksConfig, + LoggingConfig, + OpentelemetryConfig, # ← change to OpenTelemetryConfig + ... +``` + +Change `OpentelemetryConfig` → `OpenTelemetryConfig`. + +**File:** `lite_bootstrap/bootstrappers/litestar_bootstrapper.py` — same two changes. + +**File:** `lite_bootstrap/bootstrappers/faststream_bootstrapper.py` — same two changes. + +### Step 3: Update tests + +**Files:** `tests/instruments/test_opentelemetry_instrument.py`, `tests/instruments/test_logging_instrument.py` + +In each test file, change every `OpentelemetryConfig` reference (in imports and constructor calls) to `OpenTelemetryConfig`. Use Edit's `replace_all=true` for safety: + +```python +# Before: +from lite_bootstrap.instruments.opentelemetry_instrument import OpentelemetryConfig, ... + +# After: +from lite_bootstrap.instruments.opentelemetry_instrument import OpenTelemetryConfig, ... +``` + +And similarly for constructor calls like `OpentelemetryConfig(...)` → `OpenTelemetryConfig(...)`. + +### Step 4: Smoke test after the Otel rename + +```bash +just test -- tests/instruments/test_opentelemetry_instrument.py tests/instruments/test_logging_instrument.py -v +``` + +Expected: all PASS. If anything fails with `NameError`, check that all references were updated. + +--- + +## Task 3: Rename `FreeBootstrapperConfig` → `FreeConfig` + +### Step 1: Rename in `lite_bootstrap/bootstrappers/free_bootstrapper.py` + +Current class: + +```python +@dataclasses.dataclass(kw_only=True, slots=True, frozen=True) +class FreeBootstrapperConfig(LoggingConfig, OpentelemetryConfig, PyroscopeConfig, SentryConfig): ... +``` + +Two changes here: +1. Rename to `class FreeConfig(LoggingConfig, OpenTelemetryConfig, PyroscopeConfig, SentryConfig): ...` (also picks up the Otel rename from Task 2). +2. Update the bootstrapper: + +```python +class FreeBootstrapper(BaseBootstrapper[None]): + ... + instruments_types: typing.ClassVar = [...] + bootstrap_config: FreeBootstrapperConfig # ← change to FreeConfig + not_ready_message = "" + ... + def __init__(self, bootstrap_config: FreeBootstrapperConfig) -> None: # ← change to FreeConfig + super().__init__(bootstrap_config) +``` + +Replace both `FreeBootstrapperConfig` occurrences with `FreeConfig`. + +**Add the alias at the end of the file:** + +```python +# Backward-compatible alias preserved for users importing the old name. +FreeBootstrapperConfig = FreeConfig +``` + +### Step 2: Update `lite_bootstrap/__init__.py` + +Current: + +```python +from lite_bootstrap.bootstrappers.free_bootstrapper import FreeBootstrapper, FreeBootstrapperConfig +... +__all__ = [ + ... + "FreeBootstrapper", + "FreeBootstrapperConfig", + ... +] +``` + +Change to: + +```python +from lite_bootstrap.bootstrappers.free_bootstrapper import FreeBootstrapper, FreeBootstrapperConfig, FreeConfig +... +__all__ = [ + ... + "FreeBootstrapper", + "FreeBootstrapperConfig", + "FreeConfig", + ... +] +``` + +Both names exported. Alphabetical order in `__all__` keeps `FreeBootstrapperConfig` before `FreeConfig`. Add `FreeConfig` after `FreeBootstrapperConfig`. + +### Step 3: Update tests + +**File:** `tests/test_free_bootstrap.py` + +Change every `FreeBootstrapperConfig` → `FreeConfig` (in imports, fixture annotations, constructor calls). Use Edit's `replace_all=true`. + +**File:** `tests/instruments/test_pyroscope_instrument.py` + +The pyroscope tests use `FreeBootstrapperConfig` to exercise the inheritance-through-Free path. Change references to `FreeConfig`. + +### Step 4: Smoke test after the Free rename + +```bash +just test -- tests/test_free_bootstrap.py tests/instruments/test_pyroscope_instrument.py -v +``` + +Expected: all PASS. + +--- + +## Task 4: Verify everything, commit + +### Step 1: Run the full test suite + +```bash +just test +``` + +Expected: 129/129 PASS. No behavior change; just symbol renames. + +### Step 2: Verify the aliases work for external imports + +```bash +uv run python -c "from lite_bootstrap import FreeBootstrapperConfig, FreeConfig; assert FreeBootstrapperConfig is FreeConfig; print('FreeConfig alias OK')" +uv run python -c "from lite_bootstrap.instruments.opentelemetry_instrument import OpentelemetryConfig, OpenTelemetryConfig; assert OpentelemetryConfig is OpenTelemetryConfig; print('OpenTelemetryConfig alias OK')" +``` + +Both should print `... OK`. If either fails, the alias is broken. + +### Step 3: Run lint + +```bash +just lint +``` + +Expected: clean. Watch for: +- `ruff format` may reorder imports — accept its formatting. +- `ty` should be happy with both names since they're the same class. + +### Step 4: Sanity grep — verify no leftover old-name references in internal code + +```bash +grep -rn "OpentelemetryConfig\|FreeBootstrapperConfig" lite_bootstrap/ tests/ --include="*.py" | grep -v "alias\|backward" +``` + +Expected: ONLY the two alias-definition lines (`OpentelemetryConfig = OpenTelemetryConfig` and `FreeBootstrapperConfig = FreeConfig`) PLUS the `__init__.py` import/export entries (3 matches total for `FreeBootstrapperConfig`: import line, `__all__` entry, alias line; 1 match total for `OpentelemetryConfig`: the alias line). + +If any other `*.py` file has a reference to the old names outside these alias contexts, that's a missed update — fix it. + +### Step 5: Commit + +Stage all 11 files explicitly: + +```bash +git add \ + lite_bootstrap/instruments/opentelemetry_instrument.py \ + lite_bootstrap/bootstrappers/free_bootstrapper.py \ + lite_bootstrap/bootstrappers/fastapi_bootstrapper.py \ + lite_bootstrap/bootstrappers/litestar_bootstrapper.py \ + lite_bootstrap/bootstrappers/faststream_bootstrapper.py \ + lite_bootstrap/__init__.py \ + tests/test_free_bootstrap.py \ + tests/instruments/test_opentelemetry_instrument.py \ + tests/instruments/test_logging_instrument.py \ + tests/instruments/test_pyroscope_instrument.py +git commit -m "$(cat <<'EOF' +refactor: rename OpentelemetryConfig → OpenTelemetryConfig; FreeBootstrapperConfig → FreeConfig + +Two API-surface renames with silent backward-compatibility aliases. + +OpentelemetryConfig → OpenTelemetryConfig: matches the conventional +OpenTelemetry capitalization and the OpenTelemetryServiceFieldsConfig +mixin introduced in PR6. Module-level alias `OpentelemetryConfig = +OpenTelemetryConfig` preserves existing imports. Not exported from +__init__.py (wasn't before either). + +FreeBootstrapperConfig → FreeConfig: matches the sibling configs +(FastAPIConfig, LitestarConfig, FastStreamConfig — none carry the +"Bootstrapper" infix). Module-level alias plus `FreeBootstrapperConfig` +re-export in __init__.py preserves existing public imports. + +Internal references and tests updated to the new canonical names. +Aliases are simple class assignments — same class object, so +isinstance(x, OldName) and isinstance(x, NewName) are interchangeable. +Old pickles continue to unpickle via the alias. + +No behavior change. 129/129 tests pass. + +Closes LOW-7 from the audit. Also closes the bonus Otel capitalization +item surfaced during PR6's code review. +EOF +)" +``` + +--- + +## Task 5: Push and open PR + +- [ ] **Step 1: Push the branch** + +```bash +git push -u origin refactor/low-7-naming +``` + +- [ ] **Step 2: Open the PR** + +```bash +gh pr create --title "refactor: rename OpentelemetryConfig → OpenTelemetryConfig; FreeBootstrapperConfig → FreeConfig" --body "$(cat <<'EOF' +## Summary +The final PR of the deferred-refactors sequence. Two API-surface renames with silent backward-compatibility aliases: + +- **`OpentelemetryConfig` → `OpenTelemetryConfig`** — matches the conventional OpenTelemetry capitalization and the `OpenTelemetryServiceFieldsConfig` mixin from PR6. Module-level alias preserves existing imports. Not exported from `__init__.py` (wasn't before). +- **`FreeBootstrapperConfig` → `FreeConfig`** — matches the sibling configs (`FastAPIConfig`, `LitestarConfig`, `FastStreamConfig` — none carry the `Bootstrapper` infix). Module-level alias plus `FreeBootstrapperConfig` re-export in `__init__.py` preserves existing public imports. + +Internal references and tests updated to the new canonical names. Aliases are simple class assignments — same class object, so `isinstance(x, OldName)` and `isinstance(x, NewName)` are interchangeable. Old pickles continue to unpickle via the alias. + +No behavior change. 129/129 tests pass. + +Closes LOW-7 from an internal audit. Also closes the bonus Otel capitalization item surfaced during PR6's code review. + +## Test plan +- [x] `just test` — 129/129. +- [x] `just lint` — clean. +- [x] Aliases verified working (`isinstance(x, OldName) is isinstance(x, NewName)` for both renames). +- [ ] Reviewer: confirm the aliases are simple class assignments (not subclasses), so isinstance behavior is fully preserved. + +🤖 Generated with [Claude Code](https://claude.com/claude-code) +EOF +)" +``` + +--- + +## Self-Review + +**Spec coverage check** against the sequencing spec (PR15 section) and audit (LOW-7): + +| Spec item | Task | +|-----------|------| +| Rename `OpentelemetryConfig` → `OpenTelemetryConfig` | Task 2, Step 1 | +| Add silent alias `OpentelemetryConfig = OpenTelemetryConfig` | Task 2, Step 1 | +| Rename `FreeBootstrapperConfig` → `FreeConfig` | Task 3, Step 1 | +| Add silent alias `FreeBootstrapperConfig = FreeConfig` | Task 3, Step 1 | +| Export both names from `__init__.py` | Task 3, Step 2 | +| Update internal references in framework bootstrappers | Task 2, Step 2 | +| Update tests to use new canonical names | Tasks 2 Step 3 + 3 Step 3 | +| Verify aliases work (`is` identity) | Task 4, Step 2 | +| Sanity grep for missed references | Task 4, Step 4 | +| Branch name `refactor/low-7-naming` | Task 1, Step 1 | +| Verification: `just test` + `just lint` clean | Task 4, Steps 1, 3 | + +All spec items covered. No placeholders. + +**Risk:** Low. Aliases preserve every existing import. The mechanical rename is well-scoped (11 files, predictable changes). The sanity grep at Task 4 Step 4 catches any miss. + +**Why this is the last PR:** With this merged, all 8 deferred-refactor PRs (PR8-15) close every audit finding except those explicitly marked out-of-scope in the sequencing spec. The audit becomes fully resolved. + + +--- + +# PR16: Post-Retro Hygiene (uv_build upper bound + Pyroscope endpoint assert) + +**Goal:** Two small hygiene items surfaced during retrospective action-item work. + +**Files:** +- `pyproject.toml` — add upper bound to `uv_build` to silence the every-`just lint` warning +- `lite_bootstrap/instruments/pyroscope_instrument.py` — add a runtime assert on `pyroscope_endpoint` to document the `is_ready()`-enforced invariant + +**Parent docs:** Surfaced in the [audit retrospective](../../retros/2026-06-01-audit-implementation-retro.md). Neither is an audit finding; both noticed during the retro action-item work (`just lint` warning persistence + Pyright's `reportArgumentType` on pyroscope's `server_address`). + +This is the first PR using the [lightweight plan template](../templates/lightweight-plan-template.md). Eat your own dog food. + +--- + +## Diff + +### `pyproject.toml` + +```python +# Before: +[build-system] +requires = ["uv_build"] +build-backend = "uv_build" + +# After: +[build-system] +requires = ["uv_build<0.12"] +build-backend = "uv_build" +``` + +The upper bound aligns with the warning's own suggestion (`Without bounding the uv_build version, the source distribution will break when a future, breaking version of uv_build is released. ...such as <0.12`). Pinning to <0.12 matches the major version we're on; the next breaking change is the next major. + +### `lite_bootstrap/instruments/pyroscope_instrument.py` + +In `PyroscopeInstrument.bootstrap()`, add an assert at the top documenting the precondition that `is_ready()` enforces: + +```python +# Before: +def bootstrap(self) -> None: + namespace = self.bootstrap_config.opentelemetry_namespace + tags = ({"service_namespace": namespace} if namespace else {}) | self.bootstrap_config.pyroscope_tags + pyroscope.configure( + application_name=self.bootstrap_config.opentelemetry_service_name or self.bootstrap_config.service_name, + server_address=self.bootstrap_config.pyroscope_endpoint, + sample_rate=self.bootstrap_config.pyroscope_sample_rate, + tags=tags, + **self.bootstrap_config.pyroscope_additional_params, + ) + +# After: +def bootstrap(self) -> None: + # is_ready() guarantees pyroscope_endpoint is set; assert documents the precondition + # for type narrowing and for direct callers that bypass the bootstrapper. + assert self.bootstrap_config.pyroscope_endpoint is not None + namespace = self.bootstrap_config.opentelemetry_namespace + tags = ({"service_namespace": namespace} if namespace else {}) | self.bootstrap_config.pyroscope_tags + pyroscope.configure( + application_name=self.bootstrap_config.opentelemetry_service_name or self.bootstrap_config.service_name, + server_address=self.bootstrap_config.pyroscope_endpoint, + sample_rate=self.bootstrap_config.pyroscope_sample_rate, + tags=tags, + **self.bootstrap_config.pyroscope_additional_params, + ) +``` + +Why an assert (not a cast): +- The invariant is real: `is_ready()` returns `bool(self.bootstrap_config.pyroscope_endpoint)`, and `BaseBootstrapper._register_or_skip` doesn't call `bootstrap()` if `is_ready()` returned False. +- `assert` runs at runtime and catches direct-bypass callers (e.g., `PyroscopeInstrument(config).bootstrap()` without going through a bootstrapper) with a clear `AssertionError` instead of a confusing pyroscope-side TypeError. +- The project allows `assert` (S101 is in ruff ignores). +- `ty` and Pyright both narrow `str | None` → `str` after the assert. + +No new test. The existing `test_pyroscope_instrument_bootstrap_and_teardown` covers the bootstrap path. + +--- + +## Verification + +1. `grep -n "uv_build" pyproject.toml` — confirm exactly one match, with `<0.12`. +2. `just lint` — the "missing upper bound on uv_build" warning should be gone. Lint stays clean otherwise. +3. `just test -- tests/instruments/test_pyroscope_instrument.py -v` — all pyroscope tests pass (the existing bootstrap test exercises the new assert path). +4. `just test` — full suite 129/129. + +### Pre-flight grep (template requirement) + +```bash +grep -rn "uv_build" pyproject.toml +grep -rn "self\.bootstrap_config\.pyroscope_endpoint" lite_bootstrap/instruments/pyroscope_instrument.py +``` + +Expected: +- `pyproject.toml` shows 2 matches (the `requires` line and `build-backend` line) — only the first changes. +- `pyroscope_instrument.py` shows 2 matches (the `is_ready` check at line ~28 and the `server_address` reference at line ~39); the assert addition is between them. + +## Commit + +```bash +git add pyproject.toml lite_bootstrap/instruments/pyroscope_instrument.py +git commit -m "$(cat <<'EOF' +chore: pin uv_build upper bound; assert pyroscope_endpoint precondition + +uv_build: silence the `just lint` warning about missing upper bound by +pinning to <0.12 (matches the warning's own suggestion). + +Pyroscope: add `assert self.bootstrap_config.pyroscope_endpoint is not None` +at the top of bootstrap(). This documents the precondition that is_ready() +already enforces and narrows the type for both ty and Pyright (was the +only remaining real Pyright complaint after the post-retro suppressions +landed). Direct callers that bypass the bootstrapper now get a clear +AssertionError instead of a TypeError from pyroscope. + +Both items surfaced during retro action-item work. First PR using the +lightweight plan template. +EOF +)" +``` + +## PR + +Branch: `chore/post-retro-hygiene`. Push, open via `gh pr create`. No reviewer asks beyond "diff looks right." diff --git a/planning/changes/2026-06-02.01-stdlib-logging-and-build-summary/design.md b/planning/changes/2026-06-02.01-stdlib-logging-and-build-summary/design.md index da145de..d6bc0ba 100644 --- a/planning/changes/2026-06-02.01-stdlib-logging-and-build-summary/design.md +++ b/planning/changes/2026-06-02.01-stdlib-logging-and-build-summary/design.md @@ -1,12 +1,5 @@ --- -status: shipped -date: 2026-06-02 -slug: stdlib-logging-and-build-summary summary: Stdlib `logging` in `bootstrappers/base.py` + public `build_summary()`. -supersedes: instrument-skip-rework -superseded_by: null -pr: "107" -outcome: "merged as #107" --- # Design: Stdlib Logging in `bootstrappers/base.py` + Public `build_summary()` Method diff --git a/planning/changes/2026-06-02.01-stdlib-logging-and-build-summary/plan.md b/planning/changes/2026-06-02.01-stdlib-logging-and-build-summary/plan.md index d7030ac..95c79f0 100644 --- a/planning/changes/2026-06-02.01-stdlib-logging-and-build-summary/plan.md +++ b/planning/changes/2026-06-02.01-stdlib-logging-and-build-summary/plan.md @@ -1,10 +1,3 @@ ---- -status: shipped -date: 2026-06-02 -slug: stdlib-logging-and-build-summary -spec: stdlib-logging-and-build-summary -pr: "107" ---- # Stdlib Logging + `build_summary()` Implementation Plan > **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. diff --git a/planning/changes/2026-06-05.01-bug-audit-v2/design.md b/planning/changes/2026-06-05.01-bug-audit-v2/design.md index 9066760..7d4f263 100644 --- a/planning/changes/2026-06-05.01-bug-audit-v2/design.md +++ b/planning/changes/2026-06-05.01-bug-audit-v2/design.md @@ -1,12 +1,5 @@ --- -status: shipped -date: 2026-06-05 -slug: bug-audit-v2 summary: 26 findings (UX · logic · security · tests) shipped across three themed PRs. -supersedes: null -superseded_by: null -pr: null -outcome: "shipped as #108–#110" --- # Bug Audit v2 — Implementation Sequencing diff --git a/planning/changes/2026-06-05.01-bug-audit-v2/plan-pr1-lifecycle.md b/planning/changes/2026-06-05.01-bug-audit-v2/plan-pr1-lifecycle.md deleted file mode 100644 index be5591a..0000000 --- a/planning/changes/2026-06-05.01-bug-audit-v2/plan-pr1-lifecycle.md +++ /dev/null @@ -1,1083 +0,0 @@ -# PR1 — Lifecycle & Teardown Correctness Implementation Plan - -> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. - -**Goal:** Land all 10 lifecycle/teardown fixes from the 2026-06-05 audit (LOG-1..9 + SEC-4) into one cohesive PR. - -**Architecture:** Each fix is a small TDD cycle (failing test → implement → verify → commit). The sub-fixes are independent code-wise but share the "bootstrap promised, teardown didn't deliver" theme. Tasks are ordered to land the simplest invariants first (assert → raise), then the OTel teardown completeness, then the broader teardown-robustness fixes, then the app-reuse safety guards. - -**Tech Stack:** Python 3.10+, `uv` workspace, `pytest`, `pytest-asyncio`, `ty` type checker, `ruff` formatter, `structlog`, `opentelemetry-sdk`, `sentry-sdk`, `fastmcp`, `litestar`, `faststream`, `fastapi`. - -**Branch:** `fix/bug-audit-v2-pr1-lifecycle` - -**Important deviation from sequencing doc:** During plan drafting, the OTel SDK's `set_tracer_provider` was found to be enforced as set-once via `_TRACER_PROVIDER_SET_ONCE.do_once(...)` (see `opentelemetry/trace/__init__.py:548-556` in the pinned version). A second `set_tracer_provider(NoOpTracerProvider())` would be logged-and-ignored, not applied. **LOG-1 therefore becomes a docstring-only change** that documents the OTel set-once constraint — the existing `shutdown()` call (added in CRIT-2) is the only practical teardown action. The audit's TEST-NEW-2 splits accordingly: the "shutdown called" half is already covered by `test_opentelemetry_instrument_teardown_shuts_down_tracer_provider`; the "global is reset" half is dropped because OTel doesn't support it. - ---- - -## File Structure - -Modifications to existing files only — no new files. - -| File | What changes | -|------|-------------| -| `lite_bootstrap/bootstrappers/fastapi_bootstrapper.py` | `_narrow_app` assert → raise (Task 1); LOG-8 re-wrap guard (Task 10) | -| `lite_bootstrap/bootstrappers/fastmcp_bootstrapper.py` | LOG-7 re-attach guard (Task 9) | -| `lite_bootstrap/bootstrappers/faststream_bootstrapper.py` | LOG-4 broker logger restore (Task 6) | -| `lite_bootstrap/bootstrappers/litestar_bootstrapper.py` | LOG-6 WeakKeyDictionary cache (Task 8) | -| `lite_bootstrap/instruments/logging_instrument.py` | LOG-3 teardown try/finally (Task 5) | -| `lite_bootstrap/instruments/opentelemetry_instrument.py` | LOG-1 docstring (Task 3); LOG-2 logger restore (Task 4) | -| `lite_bootstrap/instruments/pyroscope_instrument.py` | Pyroscope precondition assert → raise (Task 2) | -| `lite_bootstrap/instruments/sentry_instrument.py` | LOG-9 teardown override (Task 7) | -| `tests/test_fastapi_bootstrap.py` | Tests for Tasks 1, 10 | -| `tests/test_fastmcp_bootstrap.py` | Test for Task 9 | -| `tests/test_faststream_bootstrap.py` | Test for Task 6 | -| `tests/test_litestar_bootstrap.py` | Test for Task 8 | -| `tests/instruments/test_logging_instrument.py` | Test for Task 5 | -| `tests/instruments/test_opentelemetry_instrument.py` | Tests for Tasks 3, 4 | -| `tests/instruments/test_pyroscope_instrument.py` | Test for Task 2 | -| `tests/instruments/test_sentry_instrument.py` | Test for Task 7; remove `finally: sentry_sdk.init()` workarounds | - ---- - -## Task 1: LOG-5 part A — `_narrow_app` assert → explicit raise - -**Files:** -- Modify: `lite_bootstrap/bootstrappers/fastapi_bootstrapper.py:78-80` -- Test: `tests/test_fastapi_bootstrap.py` - -- [ ] **Step 1: Write the failing test** - -Add to `tests/test_fastapi_bootstrap.py`: - -```python -import dataclasses - -import pytest - -from lite_bootstrap.bootstrappers.fastapi_bootstrapper import _narrow_app -from lite_bootstrap.types import UNSET - - -def test_narrow_app_raises_when_application_unset() -> None: - # Build a config and forcibly reset application to UNSET to simulate the - # invariant violation `_narrow_app` was guarding with an assert. - config = FastAPIConfig() - object.__setattr__(config, "application", UNSET) - with pytest.raises(RuntimeError, match="application is UNSET"): - _narrow_app(config) -``` - -- [ ] **Step 2: Run test to verify it fails** - -```bash -just test -- tests/test_fastapi_bootstrap.py::test_narrow_app_raises_when_application_unset -``` - -Expected: FAIL — current code uses `assert` which raises `AssertionError`, not `RuntimeError`. The `pytest.raises(RuntimeError, match=...)` will not match. - -- [ ] **Step 3: Replace the assert** - -In `lite_bootstrap/bootstrappers/fastapi_bootstrapper.py`, replace lines 78-80: - -```python -def _narrow_app(config: "FastAPIConfig") -> "fastapi.FastAPI": - if isinstance(config.application, UnsetType): - msg = "FastAPIConfig.application is UNSET; __post_init__ did not run" - raise RuntimeError(msg) - return config.application -``` - -- [ ] **Step 4: Run the test to verify it passes** - -```bash -just test -- tests/test_fastapi_bootstrap.py::test_narrow_app_raises_when_application_unset -``` - -Expected: PASS. - -- [ ] **Step 5: Verify nothing else broke** - -```bash -just test -- tests/test_fastapi_bootstrap.py tests/test_fastapi_offline_docs.py -``` - -Expected: all green. - -- [ ] **Step 6: Commit** - -```bash -git add lite_bootstrap/bootstrappers/fastapi_bootstrapper.py tests/test_fastapi_bootstrap.py -git commit -m "fix: replace _narrow_app assert with RuntimeError (LOG-5/SEC-4 part A) - -Bandit B101 flagged the assert as stripped under \`python -O\`. Replace with -explicit raise so the invariant holds under all Python optimization levels." -``` - ---- - -## Task 2: LOG-5 part B — Pyroscope precondition assert → explicit raise - -**Files:** -- Modify: `lite_bootstrap/instruments/pyroscope_instrument.py:34-37` -- Test: `tests/instruments/test_pyroscope_instrument.py` - -- [ ] **Step 1: Write the failing test** - -Add to `tests/instruments/test_pyroscope_instrument.py`: - -```python -def test_pyroscope_bootstrap_raises_when_endpoint_unset() -> None: - # Build a config that passes is_configured (endpoint set), then forcibly - # clear the endpoint to simulate a caller bypassing the bootstrapper's - # is_configured gate. - config = _make_config() - object.__setattr__(config, "pyroscope_endpoint", None) - instrument = PyroscopeInstrument(bootstrap_config=config) - with pytest.raises(RuntimeError, match="pyroscope_endpoint is unset"): - instrument.bootstrap() -``` - -Verify `pytest` is already imported in the file (it is — see line 4). - -- [ ] **Step 2: Run test to verify it fails** - -```bash -just test -- tests/instruments/test_pyroscope_instrument.py::test_pyroscope_bootstrap_raises_when_endpoint_unset -``` - -Expected: FAIL — the current `assert` raises `AssertionError`, not `RuntimeError`. - -- [ ] **Step 3: Replace the assert** - -In `lite_bootstrap/instruments/pyroscope_instrument.py`, replace lines 34-37: - -```python -def bootstrap(self) -> None: - # is_configured() guarantees pyroscope_endpoint is set when called via the - # bootstrapper. Direct callers bypassing is_configured see an explicit raise. - if self.bootstrap_config.pyroscope_endpoint is None: - msg = "pyroscope_endpoint is unset; PyroscopeInstrument.is_configured() should have returned False" - raise RuntimeError(msg) - namespace = self.bootstrap_config.opentelemetry_namespace - tags = ({"service_namespace": namespace} if namespace else {}) | self.bootstrap_config.pyroscope_tags - pyroscope.configure( - application_name=self.bootstrap_config.opentelemetry_service_name or self.bootstrap_config.service_name, - server_address=self.bootstrap_config.pyroscope_endpoint, - sample_rate=self.bootstrap_config.pyroscope_sample_rate, - tags=tags, - **self.bootstrap_config.pyroscope_additional_params, - ) -``` - -- [ ] **Step 4: Run the test to verify it passes** - -```bash -just test -- tests/instruments/test_pyroscope_instrument.py::test_pyroscope_bootstrap_raises_when_endpoint_unset -``` - -Expected: PASS. - -- [ ] **Step 5: Verify nothing else broke** - -```bash -just test -- tests/instruments/test_pyroscope_instrument.py -``` - -Expected: all green. - -- [ ] **Step 6: Commit** - -```bash -git add lite_bootstrap/instruments/pyroscope_instrument.py tests/instruments/test_pyroscope_instrument.py -git commit -m "fix: replace pyroscope precondition assert with RuntimeError (LOG-5/SEC-4 part B) - -Bandit B101 flagged the assert as stripped under \`python -O\`. Replace with -explicit raise. Resolves the second of the two bandit B101 findings in PR1." -``` - ---- - -## Task 3: LOG-1 — Document OTel `set_tracer_provider` constraint - -**Files:** -- Modify: `lite_bootstrap/instruments/opentelemetry_instrument.py` (class docstring on `OpenTelemetryInstrument`) - -**Context:** `opentelemetry.trace.set_tracer_provider` is enforced as set-once via `_TRACER_PROVIDER_SET_ONCE.do_once(...)` (verified against the pinned OTel SDK). A teardown that resets the global to a no-op would require touching private SDK internals (`_TRACER_PROVIDER` and `_TRACER_PROVIDER_SET_ONCE`). That hack is out of scope for this PR — we instead document the constraint so users understand the lifecycle. The audit's LOG-1 finding is downgraded to "documentation only" once the OTel API behavior is verified. - -- [ ] **Step 1: Add docstring to `OpenTelemetryInstrument`** - -In `lite_bootstrap/instruments/opentelemetry_instrument.py:80-86`, add a class docstring: - -```python -@dataclasses.dataclass(kw_only=True, slots=True) -class OpenTelemetryInstrument(BaseInstrument[OpenTelemetryConfig]): - """OpenTelemetry tracing instrument. - - Lifecycle note: ``bootstrap()`` calls ``opentelemetry.trace.set_tracer_provider``, - which the OTel SDK enforces as **set-once per process** (subsequent calls log - "Overriding of current TracerProvider is not allowed" and have no effect). - ``teardown()`` calls ``shutdown()`` on the provider, which flushes batched - spans and closes exporters, but it cannot reset the process-global pointer — - callers of ``opentelemetry.trace.get_tracer_provider()`` after teardown will - still receive the shut-down provider. The supported lifecycle is one - ``OpenTelemetryInstrument`` per process; do not bootstrap a second instance. - """ - - not_ready_message = "opentelemetry_endpoint is empty and opentelemetry_log_traces is False" - missing_dependency_message = "opentelemetry is not installed" - _tracer_provider: "TracerProvider | None" = dataclasses.field( - default_factory=lambda: None, init=False, repr=False, compare=False - ) -``` - -- [ ] **Step 2: Verify lint passes** - -```bash -just lint-ci -``` - -Expected: green. Docstring change only. - -- [ ] **Step 3: Commit** - -```bash -git add lite_bootstrap/instruments/opentelemetry_instrument.py -git commit -m "docs: document OTel set_tracer_provider set-once constraint (LOG-1) - -The OTel SDK enforces set_tracer_provider as set-once per process. teardown() -calls shutdown() (added in CRIT-2) but cannot reset the process-global pointer. -Document the supported lifecycle: one OpenTelemetryInstrument per process." -``` - ---- - -## Task 4: LOG-2 — Restore disabled OTel loggers on teardown - -**Files:** -- Modify: `lite_bootstrap/instruments/opentelemetry_instrument.py` -- Test: `tests/instruments/test_opentelemetry_instrument.py` - -- [ ] **Step 1: Write the failing test** - -Add to `tests/instruments/test_opentelemetry_instrument.py`: - -```python -import logging - - -def test_opentelemetry_teardown_restores_disabled_loggers() -> None: - instrumentor_logger = logging.getLogger("opentelemetry.instrumentation.instrumentor") - trace_logger = logging.getLogger("opentelemetry.trace") - # Capture pre-bootstrap state so the assertion is independent of test order. - prior_instrumentor_disabled = instrumentor_logger.disabled - prior_trace_disabled = trace_logger.disabled - - instrument = OpenTelemetryInstrument( - bootstrap_config=OpenTelemetryConfig(opentelemetry_log_traces=True), - ) - instrument.bootstrap() - assert instrumentor_logger.disabled is True - assert trace_logger.disabled is True - - instrument.teardown() - - assert instrumentor_logger.disabled is prior_instrumentor_disabled - assert trace_logger.disabled is prior_trace_disabled -``` - -- [ ] **Step 2: Run test to verify it fails** - -```bash -just test -- tests/instruments/test_opentelemetry_instrument.py::test_opentelemetry_teardown_restores_disabled_loggers -``` - -Expected: FAIL — current `teardown()` does not restore the `disabled` flags. - -- [ ] **Step 3: Add capture-and-restore logic** - -In `lite_bootstrap/instruments/opentelemetry_instrument.py`, modify the `OpenTelemetryInstrument` dataclass (around line 80-86 — keep the docstring from Task 3) to add a new init=False field: - -```python -@dataclasses.dataclass(kw_only=True, slots=True) -class OpenTelemetryInstrument(BaseInstrument[OpenTelemetryConfig]): - """OpenTelemetry tracing instrument. - - Lifecycle note: ``bootstrap()`` calls ``opentelemetry.trace.set_tracer_provider``, - which the OTel SDK enforces as **set-once per process** (subsequent calls log - "Overriding of current TracerProvider is not allowed" and have no effect). - ``teardown()`` calls ``shutdown()`` on the provider, which flushes batched - spans and closes exporters, but it cannot reset the process-global pointer — - callers of ``opentelemetry.trace.get_tracer_provider()`` after teardown will - still receive the shut-down provider. The supported lifecycle is one - ``OpenTelemetryInstrument`` per process; do not bootstrap a second instance. - """ - - not_ready_message = "opentelemetry_endpoint is empty and opentelemetry_log_traces is False" - missing_dependency_message = "opentelemetry is not installed" - _tracer_provider: "TracerProvider | None" = dataclasses.field( - default_factory=lambda: None, init=False, repr=False, compare=False - ) - _prior_logger_disabled: dict[str, bool] = dataclasses.field( - default_factory=dict, init=False, repr=False, compare=False - ) -``` - -In the same file, modify `bootstrap()` (lines 107-109 currently set `.disabled = True` on two loggers). Replace those two lines with capture-then-set: - -```python -def bootstrap(self) -> None: - for logger_name in ("opentelemetry.instrumentation.instrumentor", "opentelemetry.trace"): - otel_logger = logging.getLogger(logger_name) - self._prior_logger_disabled[logger_name] = otel_logger.disabled - otel_logger.disabled = True - attributes = { - resources.SERVICE_NAME: self.bootstrap_config.opentelemetry_service_name - or self.bootstrap_config.service_name, - resources.TELEMETRY_SDK_LANGUAGE: "python", - resources.SERVICE_NAMESPACE: self.bootstrap_config.opentelemetry_namespace, - resources.SERVICE_VERSION: self.bootstrap_config.service_version, - resources.CONTAINER_NAME: self.bootstrap_config.opentelemetry_container_name, - } - # ... (rest of bootstrap body unchanged from current code) -``` - -In `teardown()` (lines 146-156), add restore logic. Place it after the uninstrument loop, before the `_tracer_provider.shutdown()` block so restoration happens even if shutdown raises: - -```python -def teardown(self) -> None: - for one_instrumentor in self.bootstrap_config.opentelemetry_instrumentors: - if isinstance(one_instrumentor, InstrumentorWithParams): - one_instrumentor.instrumentor.uninstrument(**one_instrumentor.additional_params) - else: - one_instrumentor.uninstrument() - for logger_name, prior in self._prior_logger_disabled.items(): - logging.getLogger(logger_name).disabled = prior - self._prior_logger_disabled.clear() - if self._tracer_provider is not None: - try: - self._tracer_provider.shutdown() - finally: - self._tracer_provider = None -``` - -- [ ] **Step 4: Run the new test to verify it passes** - -```bash -just test -- tests/instruments/test_opentelemetry_instrument.py::test_opentelemetry_teardown_restores_disabled_loggers -``` - -Expected: PASS. - -- [ ] **Step 5: Verify the rest of the OTel test file** - -```bash -just test -- tests/instruments/test_opentelemetry_instrument.py -``` - -Expected: all green. In particular `test_opentelemetry_instrument_teardown_resets_tracer_provider_when_shutdown_raises` should still pass because the new `_prior_logger_disabled` restore runs **before** the shutdown raise. - -- [ ] **Step 6: Commit** - -```bash -git add lite_bootstrap/instruments/opentelemetry_instrument.py tests/instruments/test_opentelemetry_instrument.py -git commit -m "fix: restore OTel loggers' disabled state on teardown (LOG-2) - -bootstrap() previously set opentelemetry.instrumentation.instrumentor and -opentelemetry.trace loggers to disabled=True unconditionally, with no symmetric -restoration. Capture the pre-bootstrap state and restore on teardown so unrelated -code in the same process retains its expected logger configuration." -``` - ---- - -## Task 5: LOG-3 — Protect `LoggingInstrument.teardown()` root-handler loop - -**Files:** -- Modify: `lite_bootstrap/instruments/logging_instrument.py:163-178` -- Test: `tests/instruments/test_logging_instrument.py` - -- [ ] **Step 1: Write the failing test** - -Add to `tests/instruments/test_logging_instrument.py`: - -```python -def test_logging_instrument_teardown_aggregates_handler_close_errors() -> None: - instrument = LoggingInstrument( - bootstrap_config=LoggingConfig(logging_buffer_capacity=0), - ) - instrument.bootstrap() - root_logger = logging.getLogger() - - # Find the StreamHandler added by _configure_foreign_loggers and patch its close. - bootstrap_added = [h for h in root_logger.handlers if isinstance(h, logging.StreamHandler)] - assert bootstrap_added, "bootstrap should have added at least one StreamHandler to root" - target_handler = bootstrap_added[0] - - with patch.object(target_handler, "close", side_effect=RuntimeError("boom")): - with pytest.raises(RuntimeError, match="boom"): - instrument.teardown() - - # After teardown despite the raise, post-loop cleanup must have completed: - assert instrument._logger_factory is None # noqa: SLF001 - assert root_logger.level == logging.WARNING - # And the broken handler must have been removed from root despite raising. - assert target_handler not in root_logger.handlers -``` - -`patch` is already imported in the file (see line 3); `pytest` and `logging` are already in scope. - -- [ ] **Step 2: Run test to verify it fails** - -```bash -just test -- tests/instruments/test_logging_instrument.py::test_logging_instrument_teardown_aggregates_handler_close_errors -``` - -Expected: FAIL — current `teardown` would let `h.close()` raise mid-loop; `_logger_factory.close_handlers()` never runs and `_logger_factory` stays non-None. The level reset also doesn't fire. - -- [ ] **Step 3: Rewrite teardown with try/finally** - -In `lite_bootstrap/instruments/logging_instrument.py`, replace `teardown()` (lines 163-178): - -```python -def teardown(self) -> None: - """Reset structlog and root logger. - - Root logger level is unconditionally set to WARNING; pre-existing user configuration is overwritten. - - Best-effort cleanup: errors from individual ``handler.close()`` calls are collected and - re-raised after the rest of teardown (level reset, factory close) completes, so a single - misbehaving handler can't prevent the instrument from releasing the rest of its resources. - """ - structlog.reset_defaults() - root_logger = logging.getLogger() - close_errors: list[BaseException] = [] - try: - for h in root_logger.handlers[:]: - root_logger.removeHandler(h) - try: - h.close() - except Exception as e: # noqa: BLE001, PERF203 - close_errors.append(e) - root_logger.setLevel(logging.WARNING) - finally: - if self._logger_factory is not None: - try: - self._logger_factory.close_handlers() - finally: - self._logger_factory = None - if close_errors: - raise close_errors[0] -``` - -- [ ] **Step 4: Run the new test to verify it passes** - -```bash -just test -- tests/instruments/test_logging_instrument.py::test_logging_instrument_teardown_aggregates_handler_close_errors -``` - -Expected: PASS. - -- [ ] **Step 5: Verify the rest of the logging tests** - -```bash -just test -- tests/instruments/test_logging_instrument.py -``` - -Expected: all green. In particular `test_logging_instrument_teardown_resets_factory_when_close_handlers_raises` (existing) still asserts that `_logger_factory` is nulled on close_handlers raise — the new try/finally preserves that behavior. - -- [ ] **Step 6: Commit** - -```bash -git add lite_bootstrap/instruments/logging_instrument.py tests/instruments/test_logging_instrument.py -git commit -m "fix: protect LoggingInstrument.teardown root-handler loop (LOG-3) - -A raise from handler.close() mid-loop previously left remaining handlers -attached, skipped the root-level reset, and never called close_handlers on the -factory. Wrap in try/finally and aggregate close errors so the rest of teardown -completes before the first error is re-raised." -``` - ---- - -## Task 6: LOG-4 — Restore broker logger storage on FastStream teardown - -**Files:** -- Modify: `lite_bootstrap/bootstrappers/faststream_bootstrapper.py:110-120` -- Test: `tests/test_faststream_bootstrap.py` - -- [ ] **Step 1: Write the failing test** - -Add to `tests/test_faststream_bootstrap.py`: - -```python -def test_faststream_teardown_restores_broker_params_storage(broker: RedisBroker) -> None: - config = build_faststream_config(broker=broker) - original_storage = broker.config.logger.params_storage - bootstrapper = FastStreamBootstrapper(bootstrap_config=config) - bootstrapper.bootstrap() - assert isinstance(broker.config.logger.params_storage, ManualLoggerStorage) - assert broker.config.logger.params_storage is not original_storage - - bootstrapper.teardown() - - assert broker.config.logger.params_storage is original_storage -``` - -`ManualLoggerStorage` and `RedisBroker` are already imported in the file (lines 11-12); `build_faststream_config` is defined locally (line 34). - -- [ ] **Step 2: Run test to verify it fails** - -```bash -just test -- tests/test_faststream_bootstrap.py::test_faststream_teardown_restores_broker_params_storage -``` - -Expected: FAIL — `FastStreamLoggingInstrument` has no `teardown()` override, so `params_storage` stays as the `ManualLoggerStorage` set by bootstrap. - -- [ ] **Step 3: Add snapshot field and teardown override** - -In `lite_bootstrap/bootstrappers/faststream_bootstrapper.py`, modify `FastStreamLoggingInstrument` (lines 110-120) to add a snapshot field and a teardown override: - -```python -@dataclasses.dataclass(kw_only=True) -class FastStreamLoggingInstrument(LoggingInstrument): - bootstrap_config: FastStreamConfig - _prior_broker_params_storage: typing.Any = dataclasses.field( - default=None, init=False, repr=False, compare=False - ) - _broker_logger_replaced: bool = dataclasses.field( - default=False, init=False, repr=False, compare=False - ) - - def bootstrap(self) -> None: - super().bootstrap() - broker = self.bootstrap_config.application.broker - if broker is not None and import_checker.is_structlog_installed and import_checker.is_faststream_installed: - logger = structlog.get_logger("faststream") - logger.setLevel(self.bootstrap_config.faststream_log_level) - self._prior_broker_params_storage = broker.config.logger.params_storage - broker.config.logger.params_storage = ManualLoggerStorage(logger) - self._broker_logger_replaced = True - - def teardown(self) -> None: - if self._broker_logger_replaced: - broker = self.bootstrap_config.application.broker - if broker is not None: - broker.config.logger.params_storage = self._prior_broker_params_storage - self._broker_logger_replaced = False - self._prior_broker_params_storage = None - super().teardown() -``` - -- [ ] **Step 4: Run the new test to verify it passes** - -```bash -just test -- tests/test_faststream_bootstrap.py::test_faststream_teardown_restores_broker_params_storage -``` - -Expected: PASS. - -- [ ] **Step 5: Verify the rest of the FastStream tests** - -```bash -just test -- tests/test_faststream_bootstrap.py -``` - -Expected: all green. In particular `test_faststream_logging_instrument_injects_structlog_logger` should still pass — it inspects state mid-bootstrap, before teardown. - -- [ ] **Step 6: Commit** - -```bash -git add lite_bootstrap/bootstrappers/faststream_bootstrapper.py tests/test_faststream_bootstrap.py -git commit -m "fix: restore broker logger storage on FastStreamLoggingInstrument teardown (LOG-4) - -bootstrap() mutates broker.config.logger.params_storage to inject a structlog -logger; teardown() now captures and restores the original value. Symmetric with -LoggingInstrument's parent teardown, which still runs via super()." -``` - ---- - -## Task 7: LOG-9 — Add `SentryInstrument.teardown()` - -**Files:** -- Modify: `lite_bootstrap/instruments/sentry_instrument.py:94-125` -- Modify: `tests/instruments/test_sentry_instrument.py` (add test + drop workarounds) - -- [ ] **Step 1: Write the failing test** - -Add to `tests/instruments/test_sentry_instrument.py`: - -```python -def test_sentry_teardown_disables_sdk(minimal_sentry_config: SentryConfig) -> None: - instrument = SentryInstrument(bootstrap_config=minimal_sentry_config) - instrument.bootstrap() - assert sentry_sdk.Hub.current.client is not None - - instrument.teardown() - - # sentry_sdk.init() with no DSN disables the SDK; the Hub's client either becomes - # None or has dsn=None depending on the SDK version. Both indicate "disabled". - client = sentry_sdk.Hub.current.client - assert client is None or client.dsn is None -``` - -- [ ] **Step 2: Run test to verify it fails** - -```bash -just test -- tests/instruments/test_sentry_instrument.py::test_sentry_teardown_disables_sdk -``` - -Expected: FAIL — `SentryInstrument` currently has no `teardown()` override, so the SDK stays initialized with the test DSN. - -- [ ] **Step 3: Add the teardown override** - -In `lite_bootstrap/instruments/sentry_instrument.py`, extend `SentryInstrument` (after `bootstrap()`, around line 125): - -```python -def teardown(self) -> None: - """Flush pending events and reset the SDK to a no-op state. - - Calling ``sentry_sdk.init()`` with no DSN disables further event capture. This - cleans up after a bootstrap so the same process can be torn down and re-tested - without leaking the previous DSN/transport into subsequent code. - """ - sentry_sdk.flush(timeout=2) - sentry_sdk.init() -``` - -- [ ] **Step 4: Run the new test to verify it passes** - -```bash -just test -- tests/instruments/test_sentry_instrument.py::test_sentry_teardown_disables_sdk -``` - -Expected: PASS. - -- [ ] **Step 5: Drop the redundant `finally: sentry_sdk.init()` workarounds** - -The existing tests at `tests/instruments/test_sentry_instrument.py:43` and `:65` use -`finally: sentry_sdk.init()` to reset the SDK between test cases. With the new -`teardown()` in place, callers that go through the bootstrapper / instrument will get the -reset automatically. Update the two tests so the cleanup happens via `instrument.teardown()`: - -Replace `test_sentry_instrument_with_raise` (lines 36-43): - -```python -def test_sentry_instrument_with_raise(minimal_sentry_config: SentryConfig, sentry_mock: SentryTestTransport) -> None: - instrument = SentryInstrument(bootstrap_config=minimal_sentry_config) - instrument.bootstrap() - - try: - std_logger.error("some error") - assert len(sentry_mock.mock_envelopes) == 1 - finally: - instrument.teardown() -``` - -Replace `test_sentry_instrument_with_structlog_error` (lines 46-66): - -```python -def test_sentry_instrument_with_structlog_error( - minimal_sentry_config: SentryConfig, sentry_mock: SentryTestTransport, logging_mock: LoggingMock -) -> None: - sentry_instrument = SentryInstrument(bootstrap_config=minimal_sentry_config) - sentry_instrument.bootstrap() - logging_instrument = LoggingInstrument( - bootstrap_config=LoggingConfig( - logging_unset_handlers=["uvicorn"], - logging_buffer_capacity=0, - service_debug=False, - logging_extra_processors=[logging_mock], - ) - ) - logging_instrument.bootstrap() - - try: - logger.error("some error") - logger.error("some error, skipping sentry", skip_sentry=True) - assert len(sentry_mock.mock_envelopes) == 1 - finally: - logging_instrument.teardown() - sentry_instrument.teardown() -``` - -- [ ] **Step 6: Run the full sentry test file** - -```bash -just test -- tests/instruments/test_sentry_instrument.py -``` - -Expected: all green. - -- [ ] **Step 7: Commit** - -```bash -git add lite_bootstrap/instruments/sentry_instrument.py tests/instruments/test_sentry_instrument.py -git commit -m "fix: add SentryInstrument.teardown that disables the SDK (LOG-9) - -bootstrap() called sentry_sdk.init() but no teardown reset the global state. -Process-local tests had to call sentry_sdk.init() in finally blocks as a -workaround; those are now replaced by instrument.teardown(). flush() drains -in-flight events before the reset." -``` - ---- - -## Task 8: LOG-6 — `WeakKeyDictionary` for Litestar OTel cache - -**Files:** -- Modify: `lite_bootstrap/bootstrappers/litestar_bootstrapper.py:75-99` -- Test: `tests/test_litestar_bootstrap.py` - -- [ ] **Step 1: Write the failing test** - -Add to `tests/test_litestar_bootstrap.py`: - -```python -import gc -import weakref - - -def test_litestar_otel_apps_cache_evicts_dead_refs() -> None: - from lite_bootstrap.bootstrappers.litestar_bootstrapper import ( - LitestarOpenTelemetryInstrumentationMiddleware, - ) - from opentelemetry.sdk.trace import TracerProvider - - tracer_provider = TracerProvider() - middleware = LitestarOpenTelemetryInstrumentationMiddleware( - tracer_provider=tracer_provider, - excluded_urls=set(), - ) - - async def transient_app(scope: dict, receive: object, send: object) -> None: # noqa: ARG001 - return None - - weak_app = weakref.ref(transient_app) - middleware._otel_apps[transient_app] = "marker" # noqa: SLF001 — direct cache poke - assert weak_app() is not None - assert len(middleware._otel_apps) == 1 # noqa: SLF001 - - del transient_app - gc.collect() - - assert weak_app() is None - assert len(middleware._otel_apps) == 0 # noqa: SLF001 -``` - -- [ ] **Step 2: Run test to verify it fails** - -```bash -just test -- tests/test_litestar_bootstrap.py::test_litestar_otel_apps_cache_evicts_dead_refs -``` - -Expected: FAIL — `_otel_apps` is a `dict[int, ASGIApp]`; the integer key doesn't evict when the `transient_app` is GC'd. Also fails to even accept `transient_app` as a key (current code uses `id(next_app)`). - -The test sets `middleware._otel_apps[transient_app] = "marker"` to verify the dict-like API expects the app object as the key. This will need to be a real WeakKeyDictionary for the assertion to pass. - -- [ ] **Step 3: Replace cache with WeakKeyDictionary** - -In `lite_bootstrap/bootstrappers/litestar_bootstrapper.py`, add `import weakref` near the top of the file (alongside the existing imports) and replace lines 75-99: - -```python -if import_checker.is_litestar_opentelemetry_installed: - - class LitestarOpenTelemetryInstrumentationMiddleware(ASGIMiddleware): - def __init__(self, tracer_provider: "TracerProvider", excluded_urls: set[str]) -> None: - self._tracer_provider = tracer_provider - self._excluded_urls = ",".join(excluded_urls) - # WeakKeyDictionary so wrapper apps are evicted when Litestar drops the - # next_app reference (hot reload, plugin add/remove, AppConfig rebuild). - # Falls back gracefully — apps that don't support weak refs are simply - # not cached. - self._otel_apps: "weakref.WeakKeyDictionary[ASGIApp, ASGIApp]" = weakref.WeakKeyDictionary() - - async def handle( - self, - scope: "Scope", - receive: "Receive", - send: "Send", - next_app: "ASGIApp", - ) -> None: - otel_app = self._otel_apps.get(next_app) - if otel_app is None: - otel_app = OpenTelemetryMiddleware( - app=next_app, - default_span_details=build_litestar_route_details_from_scope, - excluded_urls=self._excluded_urls, - tracer_provider=self._tracer_provider, - ) - try: - self._otel_apps[next_app] = otel_app # ty: ignore[invalid-assignment] - except TypeError: - # next_app doesn't support weak references; skip caching for it. - pass - await otel_app(scope, receive, send) # ty: ignore[invalid-argument-type] -``` - -- [ ] **Step 4: Run the new test to verify it passes** - -```bash -just test -- tests/test_litestar_bootstrap.py::test_litestar_otel_apps_cache_evicts_dead_refs -``` - -Expected: PASS. - -- [ ] **Step 5: Verify the rest of the Litestar tests** - -```bash -just test -- tests/test_litestar_bootstrap.py -``` - -Expected: all green. In particular `test_litestar_otel_span_naming` exercises the cache hit path on real requests. - -- [ ] **Step 6: Commit** - -```bash -git add lite_bootstrap/bootstrappers/litestar_bootstrapper.py tests/test_litestar_bootstrap.py -git commit -m "fix: use WeakKeyDictionary for Litestar OTel app cache (LOG-6) - -The previous dict[int, ASGIApp] keyed by id() never evicted, holding wrapper -OpenTelemetryMiddleware instances alive after Litestar dropped the next_app -reference. Switch to weakref.WeakKeyDictionary so wrappers GC with their apps. -Skip caching gracefully when an app doesn't support weak references." -``` - ---- - -## Task 9: LOG-7 — Guard against double `_TeardownProvider` attachment on FastMCP - -**Files:** -- Modify: `lite_bootstrap/bootstrappers/fastmcp_bootstrapper.py:152-154` -- Test: `tests/test_fastmcp_bootstrap.py` - -**Context:** FastMCP's `add_provider` appends to `self.providers` (verified at -`fastmcp/server/providers/aggregate.py:116`). The list is inherited from `AggregateProvider` -(`fastmcp/server/providers/aggregate.py:88`). We can iterate `application.providers` to -check for existing `_TeardownProvider` instances. - -- [ ] **Step 1: Write the failing test** - -Add to `tests/test_fastmcp_bootstrap.py`: - -```python -import warnings - - -def test_second_fastmcp_bootstrapper_on_same_app_warns_not_stacks() -> None: - application = FastMCP() - config_a = FastMcpConfig(application=application, service_name="a") - bootstrapper_a = FastMcpBootstrapper(bootstrap_config=config_a) - providers_after_first = list(application.providers) - - config_b = FastMcpConfig(application=application, service_name="b") - with warnings.catch_warnings(record=True) as caught: - warnings.simplefilter("always") - FastMcpBootstrapper(bootstrap_config=config_b) - - matching = [w for w in caught if "_TeardownProvider" in str(w.message)] - assert matching, "expected warning about existing _TeardownProvider" - assert list(application.providers) == providers_after_first, ( - "second bootstrapper must not stack another _TeardownProvider" - ) - - bootstrapper_a.teardown() -``` - -`FastMCP` and `FastMcpConfig` are already imported in the file. - -- [ ] **Step 2: Run test to verify it fails** - -```bash -just test -- tests/test_fastmcp_bootstrap.py::test_second_fastmcp_bootstrapper_on_same_app_warns_not_stacks -``` - -Expected: FAIL — current code unconditionally calls `add_provider`, so `application.providers` would grow by one and no warning is emitted. - -- [ ] **Step 3: Add the re-attach guard** - -In `lite_bootstrap/bootstrappers/fastmcp_bootstrapper.py`, add `import warnings` at the top -of the file (alongside the existing imports) and modify `__init__` (lines 152-154): - -```python -def __init__(self, bootstrap_config: FastMcpConfig) -> None: - super().__init__(bootstrap_config) - if any(isinstance(p, _TeardownProvider) for p in self.bootstrap_config.application.providers): - warnings.warn( - "FastMCP application already has a _TeardownProvider attached; " - "skipping re-attachment. Construct one FastMcpBootstrapper per application.", - stacklevel=2, - ) - return - self.bootstrap_config.application.add_provider(_TeardownProvider(self.teardown)) -``` - -- [ ] **Step 4: Run the new test to verify it passes** - -```bash -just test -- tests/test_fastmcp_bootstrap.py::test_second_fastmcp_bootstrapper_on_same_app_warns_not_stacks -``` - -Expected: PASS. - -- [ ] **Step 5: Verify the rest of the FastMCP tests** - -```bash -just test -- tests/test_fastmcp_bootstrap.py -``` - -Expected: all green. The existing `test_fastmcp_teardown_runs_via_asgi_lifespan` confirms a single attachment still fires teardown on lifespan shutdown. - -- [ ] **Step 6: Commit** - -```bash -git add lite_bootstrap/bootstrappers/fastmcp_bootstrapper.py tests/test_fastmcp_bootstrap.py -git commit -m "fix: guard against double _TeardownProvider attachment on FastMCP (LOG-7) - -Constructing two FastMcpBootstrappers around the same application previously -stacked two _TeardownProvider instances, doubling the teardown call on shutdown. -Detect an existing _TeardownProvider in application.providers and skip re-attach -with a warning." -``` - ---- - -## Task 10: LOG-8 — Guard against double lifespan wrapping on FastAPI - -**Files:** -- Modify: `lite_bootstrap/bootstrappers/fastapi_bootstrapper.py:197-205` -- Test: `tests/test_fastapi_bootstrap.py` - -- [ ] **Step 1: Write the failing test** - -Add to `tests/test_fastapi_bootstrap.py`: - -```python -def test_second_fastapi_bootstrapper_on_same_app_warns_not_stacks(fastapi_config: FastAPIConfig) -> None: - application = fastapi.FastAPI() - config_a = dataclasses.replace(fastapi_config, application=application) - FastAPIBootstrapper(bootstrap_config=config_a) - lifespan_after_first = application.router.lifespan_context - - config_b = dataclasses.replace(fastapi_config, application=application) - with warnings.catch_warnings(record=True) as caught: - warnings.simplefilter("always") - FastAPIBootstrapper(bootstrap_config=config_b) - - matching = [w for w in caught if "lifespan" in str(w.message).lower()] - assert matching, "expected warning about existing lite-bootstrap lifespan" - assert application.router.lifespan_context is lifespan_after_first, ( - "second bootstrapper must not re-wrap the lifespan" - ) -``` - -`warnings` will need to be imported at the top of the test file (it is — line 4 in current state via no import; verify via Read before committing). If missing, add `import warnings`. - -- [ ] **Step 2: Run test to verify it fails** - -```bash -just test -- tests/test_fastapi_bootstrap.py::test_second_fastapi_bootstrapper_on_same_app_warns_not_stacks -``` - -Expected: FAIL — current `__init__` unconditionally wraps `lifespan_context`, so the second bootstrapper stacks another layer. - -- [ ] **Step 3: Add the re-wrap guard** - -In `lite_bootstrap/bootstrappers/fastapi_bootstrapper.py`, modify the `FastAPIBootstrapper.__init__` (lines 197-205): - -```python -def __init__(self, bootstrap_config: FastAPIConfig) -> None: - super().__init__(bootstrap_config) - - application = _narrow_app(self.bootstrap_config) - if getattr(application.state, "lite_bootstrap_lifespan_attached", False): - warnings.warn( - "FastAPI application already has a lite-bootstrap lifespan wrapper attached; " - "skipping re-wrap. Construct one FastAPIBootstrapper per application.", - stacklevel=2, - ) - return - application.state.lite_bootstrap_lifespan_attached = True - old_lifespan_manager = application.router.lifespan_context - application.router.lifespan_context = _merge_lifespan_context( - old_lifespan_manager, - self.lifespan_manager, - ) -``` - -`warnings` is already imported in this file (line 4). `application.state` is the standard Starlette state container — assignment to arbitrary attributes is supported. - -- [ ] **Step 4: Run the new test to verify it passes** - -```bash -just test -- tests/test_fastapi_bootstrap.py::test_second_fastapi_bootstrapper_on_same_app_warns_not_stacks -``` - -Expected: PASS. - -- [ ] **Step 5: Verify the rest of the FastAPI tests** - -```bash -just test -- tests/test_fastapi_bootstrap.py -``` - -Expected: all green. The existing `test_fastapi_bootstrap` exercises the single-attachment lifespan path on a real ASGI client. - -- [ ] **Step 6: Commit** - -```bash -git add lite_bootstrap/bootstrappers/fastapi_bootstrapper.py tests/test_fastapi_bootstrap.py -git commit -m "fix: guard against double lifespan wrapping on FastAPI (LOG-8) - -Constructing two FastAPIBootstrappers around the same application previously -stacked two lifespan wrappers, calling teardown twice on shutdown. Detect the -sentinel on application.state and skip re-wrap with a warning. Idempotent -teardown (CRIT-3) means the prior behavior was harmless, but this removes the -log noise and confusing stack depth." -``` - ---- - -## Final verification - -- [ ] **Step 1: Full test suite** - -```bash -just test -``` - -Expected: all green. Coverage stays at 100% (`--cov-fail-under=100` from `pyproject.toml`). - -- [ ] **Step 2: Lint (includes ty type check)** - -```bash -just lint-ci -``` - -Expected: all green. - -- [ ] **Step 3: Bandit clean (manual verification of LOG-5/SEC-4 closure)** - -```bash -bandit -r lite_bootstrap 2>&1 | grep -c "B101" -``` - -Expected: `0` (zero remaining B101 findings after Tasks 1 and 2). - -- [ ] **Step 4: Confirm the commit log shape** - -```bash -git log --oneline origin/main..HEAD -``` - -Expected: 10 commits, each titled `fix:` (or `docs:` for Task 3), each scoped to a single finding. Optional: rebase to squash trivially-related commits if reviewer prefers — recommend keeping them separate so the PR review can match commits to audit IDs. - ---- - -## Self-review checklist (run after the plan finishes) - -1. **Coverage of PR1 findings:** LOG-1 (Task 3) · LOG-2 (Task 4) · LOG-3 (Task 5) · LOG-4 (Task 6) · LOG-5 (Tasks 1+2) · LOG-6 (Task 8) · LOG-7 (Task 9) · LOG-8 (Task 10) · LOG-9 (Task 7) · SEC-4 (Tasks 1+2). All 10. -2. **TEST-NEW coverage:** TEST-NEW-2 (Task 4 — logger restore; LOG-1's "shutdown called" already exists in the suite per the audit) · TEST-NEW-3 (Task 5) · TEST-NEW-4 (Task 6) · TEST-NEW-5 (Tasks 8, 9, 10). All four mapped. -3. **No placeholders:** every step has the exact file path, line numbers where relevant, complete code blocks, exact `just test -- ` command, and the expected outcome. -4. **Type consistency:** `OpenTelemetryInstrument._prior_logger_disabled: dict[str, bool]` (Task 4), `FastStreamLoggingInstrument._prior_broker_params_storage / _broker_logger_replaced` (Task 6), `LitestarOpenTelemetryInstrumentationMiddleware._otel_apps: weakref.WeakKeyDictionary[ASGIApp, ASGIApp]` (Task 8). All consistent between definition and usage. -5. **Commit isolation:** each task ends with a single commit scoped to its finding ID, so PR review can land/revert at the task level if needed. diff --git a/planning/changes/2026-06-05.01-bug-audit-v2/plan-pr2-config-security.md b/planning/changes/2026-06-05.01-bug-audit-v2/plan-pr2-config-security.md deleted file mode 100644 index f319532..0000000 --- a/planning/changes/2026-06-05.01-bug-audit-v2/plan-pr2-config-security.md +++ /dev/null @@ -1,1003 +0,0 @@ -# PR2 — Config UX & Security Validation Implementation Plan - -> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. - -**Goal:** Land six config-layer fixes from the 2026-06-05 audit (UX-1, UX-2, UX-3, SEC-1, SEC-2, SEC-3 + folded TEST-NEW-1 and TEST-NEW-6) in one PR. - -**Architecture:** Each fix is a small TDD cycle. The work all happens at config-construction time or at the user-facing helper layer — no instrument lifecycle changes. Tasks are ordered to land additive UX improvements first (UX-1, UX-2, UX-3), then the security validators (SEC-1, SEC-2, SEC-3) which are slightly more cross-cutting (SEC-2 introduces an `OpenTelemetryConfig.__post_init__` that interacts with the FastAPIConfig override touched in Task 1). - -**Tech Stack:** Python 3.10+, `uv` workspace, `pytest` (with `pytest-asyncio`), `ty` type checker, `ruff` formatter, `structlog`, `prometheus_client`, `opentelemetry-sdk`, `fastapi`, `litestar`, `faststream`, `fastmcp`. - -**Branch:** `fix/bug-audit-v2-pr2-config-security` (branch off `main` after PR1 merges, or off PR1's branch if parallel work is desired). - -**Sequencing prerequisite:** PR1 should merge first. PR2 doesn't structurally depend on PR1, but Task 5 introduces `OpenTelemetryConfig.__post_init__` which interacts with the `_prior_logger_disabled` field added in PR1 Task 4 (both touch `OpenTelemetryConfig`/`OpenTelemetryInstrument`). Rebasing PR2 onto a merged PR1 keeps the conflict resolution trivial. - ---- - -## File Structure - -Modifications to existing files only — no new files. - -| File | What changes | -|------|-------------| -| `lite_bootstrap/bootstrappers/fastapi_bootstrapper.py` | Move user-app field overrides into UnsetType branch (Task 1); add `super().__post_init__()` for Task 5 cascade | -| `lite_bootstrap/bootstrappers/faststream_bootstrapper.py` | Add `prometheus_collector_registry` field + thread to instrument (Task 2); add `opentelemetry_excluded_urls` field (Task 3) | -| `lite_bootstrap/helpers/fastapi_helpers.py` | Validate `root_path` via `is_valid_path` + fall back on warning (Task 4) | -| `lite_bootstrap/instruments/cors_instrument.py` | Add `__post_init__` to `CorsConfig` rejecting wildcard + credentials combo (Task 6) | -| `lite_bootstrap/instruments/opentelemetry_instrument.py` | Add `__post_init__` to `OpenTelemetryConfig` warning on insecure non-local endpoint (Task 5) | -| `tests/test_fastapi_bootstrap.py` | Test for Task 1 | -| `tests/test_fastapi_offline_docs.py` | Test for Task 4 | -| `tests/test_faststream_bootstrap.py` | Tests for Tasks 2, 3 | -| `tests/instruments/test_cors_instrument.py` | Test for Task 6 | -| `tests/instruments/test_opentelemetry_instrument.py` | Test for Task 5 | - ---- - -## Task 1: UX-1 — `FastAPIConfig` respects user app's title/debug/version - -**Files:** -- Modify: `lite_bootstrap/bootstrappers/fastapi_bootstrapper.py:58-75` -- Test: `tests/test_fastapi_bootstrap.py` - -- [ ] **Step 1: Write the failing test** - -Add to `tests/test_fastapi_bootstrap.py` (at the bottom, after existing tests): - -```python -def test_user_supplied_app_keeps_title_version_debug() -> None: - user_app = fastapi.FastAPI(title="user-title", version="9.9.9", debug=False) - config = FastAPIConfig( - application=user_app, - service_name="lite-name", - service_version="1.0.0", - service_debug=True, - ) - assert config.application is user_app - assert user_app.title == "user-title" - assert user_app.version == "9.9.9" - assert user_app.debug is False -``` - -`fastapi`, `FastAPIConfig` are already imported in this file. - -- [ ] **Step 2: Run test to verify it fails** - -```bash -just test -- tests/test_fastapi_bootstrap.py::test_user_supplied_app_keeps_title_version_debug -``` - -Expected: FAIL — current `__post_init__` overwrites `application.title/.debug/.version` with the config defaults even when the user supplies a pre-configured app. - -- [ ] **Step 3: Move the field overrides into the UnsetType branch** - -In `lite_bootstrap/bootstrappers/fastapi_bootstrapper.py`, the current `__post_init__` (lines 58-75) reads: - -```python -def __post_init__(self) -> None: - if not import_checker.is_fastapi_installed: - msg = "fastapi is not installed" - raise ConfigurationError(msg) - - if isinstance(self.application, UnsetType): - application = fastapi.FastAPI(docs_url=self.swagger_path, **self.application_kwargs) - # FastAPIConfig stays frozen for user-facing immutability; __post_init__ needs - # to set application after construction, so we bypass the freeze here. - object.__setattr__(self, "application", application) - else: - application = self.application - if self.application_kwargs: - warnings.warn("application_kwargs must be used without application", stacklevel=2) - - application.title = self.service_name - application.debug = self.service_debug - application.version = self.service_version -``` - -Replace with: - -```python -def __post_init__(self) -> None: - if not import_checker.is_fastapi_installed: - msg = "fastapi is not installed" - raise ConfigurationError(msg) - - if isinstance(self.application, UnsetType): - application = fastapi.FastAPI(docs_url=self.swagger_path, **self.application_kwargs) - # FastAPIConfig stays frozen for user-facing immutability; __post_init__ needs - # to set application after construction, so we bypass the freeze here. - object.__setattr__(self, "application", application) - application.title = self.service_name - application.debug = self.service_debug - application.version = self.service_version - elif self.application_kwargs: - warnings.warn("application_kwargs must be used without application", stacklevel=2) -``` - -Key changes: -- Three `application.X = ...` lines moved inside the `if isinstance(...)` branch -- The `else: application = self.application` line is gone (no longer needed since we don't reference `application` after the branch) -- `else: if self.application_kwargs:` collapsed to `elif self.application_kwargs:` - -- [ ] **Step 4: Run the new test to verify it passes** - -```bash -just test -- tests/test_fastapi_bootstrap.py::test_user_supplied_app_keeps_title_version_debug -``` - -Expected: PASS. - -- [ ] **Step 5: Verify nothing else broke** - -```bash -just test -- tests/test_fastapi_bootstrap.py tests/test_fastapi_offline_docs.py -``` - -Expected: all green. In particular `test_fastapi_bootstrapper_apps_and_kwargs_warning` still triggers when both `application` and `application_kwargs` are passed. - -- [ ] **Step 6: Run full suite + lint** - -```bash -just test -just lint-ci -``` - -Expected: 165 passed (164 + 1 new), 100% coverage, lint clean. - -- [ ] **Step 7: Commit** - -```bash -git add lite_bootstrap/bootstrappers/fastapi_bootstrapper.py tests/test_fastapi_bootstrap.py -git commit -m "$(cat <<'EOF' -fix: FastAPIConfig respects user-supplied app title/version/debug (UX-1) - -Move the application.title/.debug/.version assignments inside the UnsetType -branch so they only apply when lite-bootstrap built the FastAPI() instance. -Pre-configured user apps now keep their construction-time values. - -Co-Authored-By: Claude Opus 4.7 (1M context) -EOF -)" -``` - ---- - -## Task 2: UX-2 — Injectable `prometheus_collector_registry` on FastStream - -**Files:** -- Modify: `lite_bootstrap/bootstrappers/faststream_bootstrapper.py:63-72` (add config field), `:143-167` (thread to instrument) -- Test: `tests/test_faststream_bootstrap.py` - -- [ ] **Step 1: Write the failing test** - -Add to `tests/test_faststream_bootstrap.py`: - -```python -import uuid - - -async def test_faststream_prometheus_uses_injected_registry(broker: RedisBroker) -> None: - custom_registry = prometheus_client.CollectorRegistry() - counter_name = f"injected_counter_{uuid.uuid4().hex}_total" - counter = prometheus_client.Counter(counter_name, "Injected registry counter", registry=custom_registry) - counter.inc() - - bootstrap_config = dataclasses.replace( - build_faststream_config(broker=broker), - prometheus_collector_registry=custom_registry, - ) - bootstrapper = FastStreamBootstrapper(bootstrap_config=bootstrap_config) - application = bootstrapper.bootstrap() - try: - with TestClient(app=application) as test_client, TestRedisBroker(broker): - response = test_client.get(bootstrap_config.prometheus_metrics_path) - assert response.status_code == status.HTTP_200_OK - assert counter_name.encode() in response.content - finally: - bootstrapper.teardown() -``` - -Imports — verify `prometheus_client` is imported at module level (it isn't yet in this test file). Add: - -```python -import prometheus_client -``` - -Near the existing `import` block at the top of `tests/test_faststream_bootstrap.py`. `dataclasses`, `uuid`, `status`, `TestClient`, `TestRedisBroker`, `RedisBroker` are already imported or will be from new imports. - -- [ ] **Step 2: Run test to verify it fails** - -```bash -just test -- tests/test_faststream_bootstrap.py::test_faststream_prometheus_uses_injected_registry -``` - -Expected: FAIL — `FastStreamConfig` doesn't have `prometheus_collector_registry` field; `dataclasses.replace(...)` raises `TypeError: __init__() got an unexpected keyword argument 'prometheus_collector_registry'`. - -- [ ] **Step 3: Add the config field** - -In `lite_bootstrap/bootstrappers/faststream_bootstrapper.py`, the current `FastStreamConfig` (lines 63-72) reads: - -```python -@dataclasses.dataclass(kw_only=True, slots=True, frozen=True) -class FastStreamConfig( - HealthChecksConfig, LoggingConfig, OpenTelemetryConfig, PrometheusConfig, PyroscopeConfig, SentryConfig -): - application: "AsgiFastStream" = dataclasses.field(default_factory=_make_asgi_faststream) - opentelemetry_middleware_cls: type[FastStreamTelemetryMiddlewareProtocol] | None = None - prometheus_middleware_cls: type[FastStreamPrometheusMiddlewareProtocol] | None = None - faststream_log_level: int = logging.WARNING - faststream_health_check_broker_timeout: float = 5.0 -``` - -Add a `prometheus_collector_registry` field after `prometheus_middleware_cls`: - -```python -@dataclasses.dataclass(kw_only=True, slots=True, frozen=True) -class FastStreamConfig( - HealthChecksConfig, LoggingConfig, OpenTelemetryConfig, PrometheusConfig, PyroscopeConfig, SentryConfig -): - application: "AsgiFastStream" = dataclasses.field(default_factory=_make_asgi_faststream) - opentelemetry_middleware_cls: type[FastStreamTelemetryMiddlewareProtocol] | None = None - prometheus_middleware_cls: type[FastStreamPrometheusMiddlewareProtocol] | None = None - prometheus_collector_registry: "prometheus_client.CollectorRegistry | None" = None - faststream_log_level: int = logging.WARNING - faststream_health_check_broker_timeout: float = 5.0 -``` - -The string-annotation form `"prometheus_client.CollectorRegistry | None"` works because `prometheus_client` is imported conditionally at module scope (`if import_checker.is_prometheus_client_installed: import prometheus_client`). Dataclass field type annotations are not resolved at runtime by default, so the conditional import is fine. - -- [ ] **Step 4: Thread the field into `FastStreamPrometheusInstrument`** - -Currently (lines 143-167): - -```python -@dataclasses.dataclass(kw_only=True) -class FastStreamPrometheusInstrument(PrometheusInstrument): - bootstrap_config: FastStreamConfig - collector_registry: "prometheus_client.CollectorRegistry" = dataclasses.field( - default_factory=_make_collector_registry, init=False - ) - not_ready_message = PrometheusInstrument.not_ready_message + " or prometheus_middleware_cls is missing" - missing_dependency_message = "prometheus_client is not installed" - ... -``` - -Replace the `collector_registry` field declaration with an `init=False` field set in `__post_init__`: - -```python -@dataclasses.dataclass(kw_only=True) -class FastStreamPrometheusInstrument(PrometheusInstrument): - bootstrap_config: FastStreamConfig - collector_registry: "prometheus_client.CollectorRegistry" = dataclasses.field(init=False) - not_ready_message = PrometheusInstrument.not_ready_message + " or prometheus_middleware_cls is missing" - missing_dependency_message = "prometheus_client is not installed" - - def __post_init__(self) -> None: - injected = self.bootstrap_config.prometheus_collector_registry - self.collector_registry = injected if injected is not None else _make_collector_registry() - - @classmethod - def is_configured(cls, bootstrap_config: "FastStreamConfig") -> bool: # ty: ignore[invalid-method-override] - return super().is_configured(bootstrap_config) and bool(bootstrap_config.prometheus_middleware_cls) - ... -``` - -`__post_init__` runs after dataclass `__init__`; assignment via `self.collector_registry = ...` works because the instrument is non-frozen. - -Keep the rest of the class unchanged (the `is_configured`, `check_dependencies`, `bootstrap` methods). - -- [ ] **Step 5: Run the new test to verify it passes** - -```bash -just test -- tests/test_faststream_bootstrap.py::test_faststream_prometheus_uses_injected_registry -``` - -Expected: PASS. - -- [ ] **Step 6: Verify default-path still works** - -```bash -just test -- tests/test_faststream_bootstrap.py -``` - -Expected: all green. Existing `test_faststream_bootstrap` (which doesn't supply `prometheus_collector_registry`) continues to use the per-instance default. - -- [ ] **Step 7: Full suite + lint** - -```bash -just test -just lint-ci -``` - -Expected: 166 passed (165 + 1 new), 100% coverage, lint clean. - -- [ ] **Step 8: Commit** - -```bash -git add lite_bootstrap/bootstrappers/faststream_bootstrapper.py tests/test_faststream_bootstrap.py -git commit -m "$(cat <<'EOF' -feat: support injectable Prometheus CollectorRegistry on FastStream (UX-2) - -Add prometheus_collector_registry: CollectorRegistry | None config field on -FastStreamConfig. When non-None, FastStreamPrometheusInstrument uses the -injected registry; otherwise the existing per-instance default is preserved. -Lets users expose metrics that were registered on a shared registry. - -Co-Authored-By: Claude Opus 4.7 (1M context) -EOF -)" -``` - ---- - -## Task 3: UX-3 — Add `opentelemetry_excluded_urls` to FastStreamConfig - -**Files:** -- Modify: `lite_bootstrap/bootstrappers/faststream_bootstrapper.py:63-72` -- Test: `tests/test_faststream_bootstrap.py` - -- [ ] **Step 1: Write the failing test** - -Add to `tests/test_faststream_bootstrap.py`: - -```python -def test_faststream_opentelemetry_excluded_urls_in_built_set(broker: RedisBroker) -> None: - from lite_bootstrap.bootstrappers.faststream_bootstrapper import ( - FastStreamOpenTelemetryInstrument, - ) - - bootstrap_config = dataclasses.replace( - build_faststream_config(broker=broker), - opentelemetry_excluded_urls=["/foo", "/bar"], - ) - instrument = FastStreamOpenTelemetryInstrument(bootstrap_config=bootstrap_config) - excluded = instrument._build_excluded_urls() # noqa: SLF001 - assert "/foo" in excluded - assert "/bar" in excluded -``` - -- [ ] **Step 2: Run test to verify it fails** - -```bash -just test -- tests/test_faststream_bootstrap.py::test_faststream_opentelemetry_excluded_urls_in_built_set -``` - -Expected: FAIL — `dataclasses.replace(...)` raises because `FastStreamConfig` has no `opentelemetry_excluded_urls` field. - -- [ ] **Step 3: Add the field** - -In `lite_bootstrap/bootstrappers/faststream_bootstrapper.py`, the current `FastStreamConfig` (after Task 2's edit) reads approximately: - -```python -@dataclasses.dataclass(kw_only=True, slots=True, frozen=True) -class FastStreamConfig( - HealthChecksConfig, LoggingConfig, OpenTelemetryConfig, PrometheusConfig, PyroscopeConfig, SentryConfig -): - application: "AsgiFastStream" = dataclasses.field(default_factory=_make_asgi_faststream) - opentelemetry_middleware_cls: type[FastStreamTelemetryMiddlewareProtocol] | None = None - prometheus_middleware_cls: type[FastStreamPrometheusMiddlewareProtocol] | None = None - prometheus_collector_registry: "prometheus_client.CollectorRegistry | None" = None - faststream_log_level: int = logging.WARNING - faststream_health_check_broker_timeout: float = 5.0 -``` - -Add `opentelemetry_excluded_urls` after `opentelemetry_middleware_cls`: - -```python -@dataclasses.dataclass(kw_only=True, slots=True, frozen=True) -class FastStreamConfig( - HealthChecksConfig, LoggingConfig, OpenTelemetryConfig, PrometheusConfig, PyroscopeConfig, SentryConfig -): - application: "AsgiFastStream" = dataclasses.field(default_factory=_make_asgi_faststream) - opentelemetry_middleware_cls: type[FastStreamTelemetryMiddlewareProtocol] | None = None - opentelemetry_excluded_urls: list[str] = dataclasses.field(default_factory=list) - prometheus_middleware_cls: type[FastStreamPrometheusMiddlewareProtocol] | None = None - prometheus_collector_registry: "prometheus_client.CollectorRegistry | None" = None - faststream_log_level: int = logging.WARNING - faststream_health_check_broker_timeout: float = 5.0 -``` - -The behavior of `_build_excluded_urls` (in `opentelemetry_instrument.py`) is unchanged — it already reads via `getattr(self.bootstrap_config, "opentelemetry_excluded_urls", [])`. The new field is just for discoverability (IDE help, tab completion). - -- [ ] **Step 4: Run the new test to verify it passes** - -```bash -just test -- tests/test_faststream_bootstrap.py::test_faststream_opentelemetry_excluded_urls_in_built_set -``` - -Expected: PASS. - -- [ ] **Step 5: Full suite + lint** - -```bash -just test -just lint-ci -``` - -Expected: 167 passed (166 + 1 new), 100% coverage, lint clean. - -- [ ] **Step 6: Commit** - -```bash -git add lite_bootstrap/bootstrappers/faststream_bootstrapper.py tests/test_faststream_bootstrap.py -git commit -m "$(cat <<'EOF' -feat: add opentelemetry_excluded_urls to FastStreamConfig (UX-3) - -FastAPIConfig and LitestarConfig already expose opentelemetry_excluded_urls; -FastStream relied on getattr fallback with no discoverable field. Add the field -to FastStreamConfig matching the FastAPI/Litestar pattern. _build_excluded_urls -behavior is unchanged — the getattr access still works the same way. - -Co-Authored-By: Claude Opus 4.7 (1M context) -EOF -)" -``` - ---- - -## Task 4: SEC-1 — Validate `root_path` in offline-docs HTML - -**Files:** -- Modify: `lite_bootstrap/helpers/fastapi_helpers.py` -- Test: `tests/test_fastapi_offline_docs.py` - -- [ ] **Step 1: Write the failing test** - -Add to `tests/test_fastapi_offline_docs.py`: - -```python -def test_offline_docs_rejects_unsafe_root_path() -> None: - malicious_root = "/foo" - app = FastAPI(title="Tests", root_path=malicious_root, docs_url="/custom_docs") - enable_offline_docs(app, static_path="/static") - - with TestClient(app, root_path=malicious_root) as client, pytest.warns(UserWarning, match="root_path"): - response = client.get("/custom_docs") - assert response.status_code == HTTPStatus.OK - assert "" not in response.text - assert "/static/swagger-ui.css" in response.text # falls back to empty root_path -``` - -`FastAPI`, `TestClient`, `HTTPStatus`, `pytest`, `enable_offline_docs` are already imported in this file. - -- [ ] **Step 2: Run test to verify it fails** - -```bash -just test -- tests/test_fastapi_offline_docs.py::test_offline_docs_rejects_unsafe_root_path -``` - -Expected: FAIL — current handler reflects `root_path` verbatim into the swagger HTML, so the `" + app = FastAPI(title="Tests", root_path=malicious_root, docs_url="/custom_docs") + enable_offline_docs(app, static_path="/static") + + with TestClient(app, root_path=malicious_root) as client, pytest.warns(UserWarning, match="root_path"): + response = client.get("/custom_docs") + assert response.status_code == HTTPStatus.OK + assert "" not in response.text + assert "/static/swagger-ui.css" in response.text # falls back to empty root_path +``` + +`FastAPI`, `TestClient`, `HTTPStatus`, `pytest`, `enable_offline_docs` are already imported in this file. + +- [ ] **Step 2: Run test to verify it fails** + +```bash +just test -- tests/test_fastapi_offline_docs.py::test_offline_docs_rejects_unsafe_root_path +``` + +Expected: FAIL — current handler reflects `root_path` verbatim into the swagger HTML, so the `