Add 8-hour default window for legacy-meter deploy monitoring
Some checks failed
CI Smoke / host-smoke (push) Successful in 0s
CI Smoke / container-smoke (push) Successful in 5s
Build and Publish Multi-Context Image / build-and-push (push) Failing after 1m15s

Expose hours query param on /legacy-meter/summary and weekly-review;
capture_legacy_meter_evidence.py defaults to --hours 8 (--days 7 for
weekly retirement gate). Re-capture post-deploy evidence with tighter window.
This commit is contained in:
tegwick 2026-07-09 01:35:51 +02:00
parent 674479dcc7
commit e302ca9f59
7 changed files with 252 additions and 90 deletions

View file

@ -28,6 +28,21 @@ from api.services.legacy_meter import (
router = APIRouter(prefix="/legacy-meter", tags=["legacy-meter"])
def _review_window(
*,
days: int | None,
hours: int | None,
window_start: datetime | None,
window_end: datetime | None,
) -> tuple[datetime, datetime]:
end = window_end or datetime.now(tz=timezone.utc)
if window_start is not None:
return window_start, end
if hours is not None:
return end - timedelta(hours=hours), end
return end - timedelta(days=days or 7), end
@router.post(
"/interfaces",
response_model=LegacyInterfaceRead,
@ -107,23 +122,43 @@ async def record_usage(
@router.get("/summary", response_model=LegacyUsageSummary)
async def usage_summary(
days: int = Query(7, ge=1, le=366),
days: int | None = Query(None, ge=1, le=366),
hours: int | None = Query(None, ge=1, le=8784),
window_start: datetime | None = None,
window_end: datetime | None = None,
session: AsyncSession = Depends(get_session),
) -> LegacyUsageSummary:
end = window_end or datetime.now(tz=timezone.utc)
start = window_start or (end - timedelta(days=days))
if days is not None and hours is not None:
raise HTTPException(
status_code=422,
detail="Pass either days or hours, not both",
)
start, end = _review_window(
days=days,
hours=hours,
window_start=window_start,
window_end=window_end,
)
return await legacy_usage_summary(session, window_start=start, window_end=end)
@router.get("/weekly-review", response_model=LegacyWeeklyReview)
async def weekly_review(
days: int = Query(7, ge=1, le=366),
days: int | None = Query(None, ge=1, le=366),
hours: int | None = Query(None, ge=1, le=8784),
window_start: datetime | None = None,
window_end: datetime | None = None,
session: AsyncSession = Depends(get_session),
) -> LegacyWeeklyReview:
end = window_end or datetime.now(tz=timezone.utc)
start = window_start or (end - timedelta(days=days))
if days is not None and hours is not None:
raise HTTPException(
status_code=422,
detail="Pass either days or hours, not both",
)
start, end = _review_window(
days=days,
hours=hours,
window_start=window_start,
window_end=window_end,
)
return await legacy_weekly_review(session, window_start=start, window_end=end)

View file

@ -1,30 +1,12 @@
{
"captured_at": "2026-07-08T23:08:23.293570+00:00",
"captured_at": "2026-07-08T23:35:18.486286+00:00",
"api_base": "http://127.0.0.1:8000",
"days": 7,
"workplan": "STATE-WP-0069",
"retired_interfaces": [
{
"id": "8051fb40-66c9-4dc4-a413-13e8573e04a3",
"interface_key": "event_subject:org.statehub.workstream.completed",
"interface_kind": "event_subject",
"legacy_since": "2026-06-04T06:09:42.198193Z",
"replacement_ref": "org.statehub.workplan.completed",
"owner_component": "state-hub.events",
"status": "retired",
"replacement_verified": true,
"manual_hold": false,
"hold_reason": null,
"notes": "Retired by STATE-WP-0069 closeout",
"retired_at": "2026-07-08T21:38:52.773601Z",
"created_at": "2026-06-04T06:09:42.198193Z",
"updated_at": "2026-07-08T21:38:52.761243Z"
}
],
"workplan": "STATE-WP-0070",
"retired_interfaces": [],
"weekly_review": {
"generated_at": "2026-07-08T23:08:23.276946Z",
"window_start": "2026-07-01T23:08:22.803913Z",
"window_end": "2026-07-08T23:08:22.803913Z",
"generated_at": "2026-07-08T23:35:18.459774Z",
"window_start": "2026-07-08T15:35:18.219148Z",
"window_end": "2026-07-08T23:35:18.219148Z",
"cadence": "weekly",
"activity_core_handoff": {
"activity_id": "statehub-legacy-interface-review",
@ -67,18 +49,18 @@
}
},
"window": {
"calls": 54,
"calls": 17,
"tenant_count": 1,
"user_count": 1,
"component_count": 1,
"tenants": {
"unknown": 54
"unknown": 17
},
"users": {
"unknown": 54
"unknown": 17
},
"components": {
"state-hub.events": 54
"state-hub.events": 17
}
},
"last_seen_at": "2026-07-08T20:49:12.839383Z",
@ -214,23 +196,23 @@
}
},
"window": {
"calls": 24897,
"calls": 1752,
"tenant_count": 1,
"user_count": 1,
"component_count": 1,
"tenants": {
"unknown": 24897
"unknown": 1752
},
"users": {
"unknown": 24897
"unknown": 1752
},
"components": {
"unknown": 24897
"unknown": 1752
}
},
"last_seen_at": "2026-07-08T21:14:39.652776Z",
"retirement_candidate": false,
"retirement_reason": "24897 call(s) in review window"
"retirement_reason": "1752 call(s) in review window"
},
{
"interface": {
@ -265,23 +247,17 @@
}
},
"window": {
"calls": 4,
"tenant_count": 1,
"user_count": 1,
"component_count": 1,
"tenants": {
"unknown": 4
},
"users": {
"unknown": 4
},
"components": {
"unknown": 4
}
"calls": 0,
"tenant_count": 0,
"user_count": 0,
"component_count": 0,
"tenants": {},
"users": {},
"components": {}
},
"last_seen_at": "2026-07-03T05:20:34.534439Z",
"retirement_candidate": false,
"retirement_reason": "4 call(s) in review window"
"retirement_candidate": true,
"retirement_reason": "no measured usage in review window"
},
{
"interface": {
@ -316,23 +292,23 @@
}
},
"window": {
"calls": 82774,
"calls": 7653,
"tenant_count": 1,
"user_count": 1,
"component_count": 1,
"tenants": {
"unknown": 82774
"unknown": 7653
},
"users": {
"unknown": 82774
"unknown": 7653
},
"components": {
"unknown": 82774
"unknown": 7653
}
},
"last_seen_at": "2026-07-08T21:14:20.781103Z",
"retirement_candidate": false,
"retirement_reason": "82774 call(s) in review window"
"retirement_reason": "7653 call(s) in review window"
},
{
"interface": {
@ -367,23 +343,23 @@
}
},
"window": {
"calls": 41448,
"calls": 3892,
"tenant_count": 1,
"user_count": 1,
"component_count": 1,
"tenants": {
"unknown": 41448
"unknown": 3892
},
"users": {
"unknown": 41448
"unknown": 3892
},
"components": {
"unknown": 41448
"unknown": 3892
}
},
"last_seen_at": "2026-07-08T21:14:12.747005Z",
"retirement_candidate": false,
"retirement_reason": "41448 call(s) in review window"
"retirement_reason": "3892 call(s) in review window"
},
{
"interface": {
@ -418,23 +394,74 @@
}
},
"window": {
"calls": 128,
"calls": 46,
"tenant_count": 1,
"user_count": 1,
"component_count": 1,
"tenants": {
"unknown": 128
"unknown": 46
},
"users": {
"unknown": 128
"unknown": 46
},
"components": {
"unknown": 128
"unknown": 46
}
},
"last_seen_at": "2026-07-08T20:49:12.335710Z",
"retirement_candidate": false,
"retirement_reason": "128 call(s) in review window"
"retirement_reason": "46 call(s) in review window"
},
{
"interface": {
"id": "4a70583c-1e41-4ec1-99a9-728856a7d79c",
"interface_key": "rest_api:POST /progress/ workstream_id",
"interface_kind": "rest_api",
"legacy_since": "2026-07-08T23:35:09.148333Z",
"replacement_ref": "POST /progress/ with workplan_id",
"owner_component": "state-hub.api",
"status": "legacy",
"replacement_verified": true,
"manual_hold": false,
"hold_reason": null,
"notes": null,
"retired_at": null,
"created_at": "2026-07-08T23:35:09.148333Z",
"updated_at": "2026-07-08T23:35:09.148333Z"
},
"all_time": {
"calls": 1,
"tenant_count": 1,
"user_count": 1,
"component_count": 1,
"tenants": {
"unknown": 1
},
"users": {
"unknown": 1
},
"components": {
"unknown": 1
}
},
"window": {
"calls": 1,
"tenant_count": 1,
"user_count": 1,
"component_count": 1,
"tenants": {
"unknown": 1
},
"users": {
"unknown": 1
},
"components": {
"unknown": 1
}
},
"last_seen_at": "2026-07-08T23:35:09.125218Z",
"retirement_candidate": false,
"retirement_reason": "1 call(s) in review window"
},
{
"interface": {
@ -520,23 +547,23 @@
}
},
"window": {
"calls": 40,
"calls": 18,
"tenant_count": 1,
"user_count": 1,
"component_count": 1,
"tenants": {
"unknown": 40
"unknown": 18
},
"users": {
"unknown": 40
"unknown": 18
},
"components": {
"unknown": 40
"unknown": 18
}
},
"last_seen_at": "2026-07-08T20:38:05.903144Z",
"retirement_candidate": false,
"retirement_reason": "40 call(s) in review window"
"retirement_reason": "18 call(s) in review window"
},
{
"interface": {
@ -630,6 +657,51 @@
"retirement_candidate": true,
"retirement_reason": "no measured usage in review window"
},
{
"interface": {
"id": "e4c140ec-7479-46f9-b185-4b533f19d639",
"interface_key": "rest_api:GET /workstreams/workplan-index",
"interface_kind": "rest_api",
"legacy_since": "2026-06-04T05:20:58.321869Z",
"replacement_ref": "/workplans/index",
"owner_component": "state-hub.api",
"status": "legacy",
"replacement_verified": true,
"manual_hold": false,
"hold_reason": null,
"notes": null,
"retired_at": null,
"created_at": "2026-06-04T05:20:58.321869Z",
"updated_at": "2026-06-04T05:20:58.321869Z"
},
"all_time": {
"calls": 30,
"tenant_count": 1,
"user_count": 1,
"component_count": 1,
"tenants": {
"unknown": 30
},
"users": {
"unknown": 30
},
"components": {
"unknown": 30
}
},
"window": {
"calls": 0,
"tenant_count": 0,
"user_count": 0,
"component_count": 0,
"tenants": {},
"users": {},
"components": {}
},
"last_seen_at": "2026-07-03T05:20:34.534439Z",
"retirement_candidate": true,
"retirement_reason": "no measured usage in review window"
},
{
"interface": {
"id": "281fd706-b192-4194-8055-2d8733c115f9",
@ -676,5 +748,6 @@
"retirement_reason": "no measured usage in review window"
}
]
}
},
"hours": 8
}

View file

@ -35,7 +35,8 @@ git log -1 --oneline # expect 5a92923 or newer
statehub fix-consistency --all --remote --api-base http://127.0.0.1:8000
# 4. Verify legacy-meter attribution (from any host with hub API access)
python ~/state-hub/scripts/capture_legacy_meter_evidence.py --days 7
python ~/state-hub/scripts/capture_legacy_meter_evidence.py
# Default 8-hour window; use --days 7 for weekly cadence
# Expect component bucket state-hub.fix-consistency; declining GET /workstreams/*
```
@ -47,8 +48,8 @@ python ~/state-hub/scripts/capture_legacy_meter_evidence.py --days 7
(`POST /consistency/sweep/remote-all` exit_code 0, 2026-07-08T23:08Z)
- [x] Weekly legacy-meter review shows `state-hub.fix-consistency` (not only `unknown`)
(`docs/evidence/legacy-meter-weekly-review-20260709.json`, 5 attributed calls)
- [ ] `GET /workstreams/*` 7-day window counts trend down week-over-week
(deploy complete 2026-07-09; re-capture after one full week for ≥50% drop gate)
- [ ] `GET /workstreams/*` counts trend down in 8-hour capture windows post-deploy
(default `capture_legacy_meter_evidence.py`; weekly `--days 7` for retirement gate)
## Rollback

View file

@ -43,7 +43,7 @@ on legacy REST keys; most lacked `X-StateHub-Component` identity (`unknown` buck
## Next verification
```bash
python scripts/capture_legacy_meter_evidence.py --days 7
python scripts/capture_legacy_meter_evidence.py # default 8h; --days 7 for weekly
```
Expect `GET /workstreams/*` window counts to fall after deploy + one full

View file

@ -104,7 +104,8 @@ Deferred until seven consecutive zero-usage review windows per key:
Evidence capture:
```bash
python scripts/capture_legacy_meter_evidence.py --days 7
python scripts/capture_legacy_meter_evidence.py # default 8h post-deploy monitoring
python scripts/capture_legacy_meter_evidence.py --days 7 # weekly retirement gate
# writes docs/evidence/legacy-meter-weekly-review-YYYYMMDD.json
```

View file

@ -1,8 +1,11 @@
#!/usr/bin/env python3
"""Capture legacy-meter weekly-review payload as dated evidence JSON.
"""Capture legacy-meter review payload as dated evidence JSON.
Usage:
python scripts/capture_legacy_meter_evidence.py [--days 7] [--api-base URL] [--dry-run]
python scripts/capture_legacy_meter_evidence.py [--hours 8] [--days N] [--api-base URL] [--dry-run]
Default review window is 8 hours (post-deploy monitoring). Pass --days for the
weekly activity-core cadence (typically 7).
Writes:
docs/evidence/legacy-meter-weekly-review-YYYYMMDD.json
@ -68,23 +71,40 @@ def _retire_interfaces(base: str, keys: list[str]) -> list[dict]:
return retired
def capture(*, api_base: str, days: int, retire_keys: list[str], dry_run: bool) -> Path:
review = _api_get(api_base, f"/legacy-meter/weekly-review?days={days}")
def _review_query(*, days: int | None, hours: int | None) -> str:
if days is not None:
return f"days={days}"
return f"hours={hours or 8}"
def capture(
*,
api_base: str,
days: int | None,
hours: int | None,
retire_keys: list[str],
dry_run: bool,
) -> Path:
query = _review_query(days=days, hours=hours)
review = _api_get(api_base, f"/legacy-meter/weekly-review?{query}")
retired: list[dict] = []
if retire_keys and not dry_run:
retired = _retire_interfaces(api_base, retire_keys)
review = _api_get(api_base, f"/legacy-meter/weekly-review?days={days}")
review = _api_get(api_base, f"/legacy-meter/weekly-review?{query}")
today = datetime.date.today().strftime("%Y%m%d")
out_path = EVIDENCE_DIR / f"legacy-meter-weekly-review-{today}.json"
payload = {
payload: dict = {
"captured_at": datetime.datetime.now(datetime.timezone.utc).isoformat(),
"api_base": api_base,
"days": days,
"workplan": "STATE-WP-0069",
"workplan": "STATE-WP-0070",
"retired_interfaces": retired,
"weekly_review": review,
}
if days is not None:
payload["days"] = days
else:
payload["hours"] = hours or 8
if dry_run:
print(json.dumps(payload, indent=2))
@ -94,13 +114,25 @@ def capture(*, api_base: str, days: int, retire_keys: list[str], dry_run: bool)
out_path.write_text(json.dumps(payload, indent=2) + "\n", encoding="utf-8")
candidates = len(review.get("retirement_candidates", []))
interfaces = len(review.get("interfaces", []))
print(f"wrote {out_path} ({interfaces} interfaces, {candidates} retirement candidates)")
window = query.replace("=", " ")
print(f"wrote {out_path} ({interfaces} interfaces, {candidates} retirement candidates, {window})")
return out_path
def main() -> None:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--days", type=int, default=7)
parser.add_argument(
"--hours",
type=int,
default=8,
help="Review window in hours (default: 8; used when --days is omitted)",
)
parser.add_argument(
"--days",
type=int,
default=None,
help="Review window in days (weekly cadence; overrides --hours)",
)
parser.add_argument(
"--api-base",
default=os.environ.get("API_BASE", "http://127.0.0.1:8000"),
@ -118,6 +150,7 @@ def main() -> None:
capture(
api_base=args.api_base,
days=args.days,
hours=args.hours,
retire_keys=retire_keys,
dry_run=args.dry_run,
)

View file

@ -93,6 +93,25 @@ class TestWorkplanAliasesAndLegacyMeter:
assert item["window"]["components"] == {"old-client": 1}
assert item["retirement_candidate"] is False
async def test_weekly_review_accepts_hours_window(self, client):
r = await client.post("/legacy-meter/interfaces", json={
"interface_key": "rest_api:GET /hour-window",
"interface_kind": "rest_api",
"replacement_ref": "/workplans/",
"replacement_verified": True,
})
assert r.status_code == 201, r.text
review = (await client.get("/legacy-meter/weekly-review?hours=8")).json()
window_seconds = (
datetime.fromisoformat(review["window_end"].replace("Z", "+00:00"))
- datetime.fromisoformat(review["window_start"].replace("Z", "+00:00"))
).total_seconds()
assert 8 * 3600 - 5 <= window_seconds <= 8 * 3600 + 5
bad = await client.get("/legacy-meter/weekly-review?days=7&hours=8")
assert bad.status_code == 422
async def test_weekly_review_reports_unused_verified_legacy_interface(self, client):
r = await client.post("/legacy-meter/interfaces", json={
"interface_key": "rest_api:GET /obsolete",