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"]) 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( @router.post(
"/interfaces", "/interfaces",
response_model=LegacyInterfaceRead, response_model=LegacyInterfaceRead,
@ -107,23 +122,43 @@ async def record_usage(
@router.get("/summary", response_model=LegacyUsageSummary) @router.get("/summary", response_model=LegacyUsageSummary)
async def usage_summary( 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_start: datetime | None = None,
window_end: datetime | None = None, window_end: datetime | None = None,
session: AsyncSession = Depends(get_session), session: AsyncSession = Depends(get_session),
) -> LegacyUsageSummary: ) -> LegacyUsageSummary:
end = window_end or datetime.now(tz=timezone.utc) if days is not None and hours is not None:
start = window_start or (end - timedelta(days=days)) 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) return await legacy_usage_summary(session, window_start=start, window_end=end)
@router.get("/weekly-review", response_model=LegacyWeeklyReview) @router.get("/weekly-review", response_model=LegacyWeeklyReview)
async def weekly_review( 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_start: datetime | None = None,
window_end: datetime | None = None, window_end: datetime | None = None,
session: AsyncSession = Depends(get_session), session: AsyncSession = Depends(get_session),
) -> LegacyWeeklyReview: ) -> LegacyWeeklyReview:
end = window_end or datetime.now(tz=timezone.utc) if days is not None and hours is not None:
start = window_start or (end - timedelta(days=days)) 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) 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", "api_base": "http://127.0.0.1:8000",
"days": 7, "workplan": "STATE-WP-0070",
"workplan": "STATE-WP-0069", "retired_interfaces": [],
"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"
}
],
"weekly_review": { "weekly_review": {
"generated_at": "2026-07-08T23:08:23.276946Z", "generated_at": "2026-07-08T23:35:18.459774Z",
"window_start": "2026-07-01T23:08:22.803913Z", "window_start": "2026-07-08T15:35:18.219148Z",
"window_end": "2026-07-08T23:08:22.803913Z", "window_end": "2026-07-08T23:35:18.219148Z",
"cadence": "weekly", "cadence": "weekly",
"activity_core_handoff": { "activity_core_handoff": {
"activity_id": "statehub-legacy-interface-review", "activity_id": "statehub-legacy-interface-review",
@ -67,18 +49,18 @@
} }
}, },
"window": { "window": {
"calls": 54, "calls": 17,
"tenant_count": 1, "tenant_count": 1,
"user_count": 1, "user_count": 1,
"component_count": 1, "component_count": 1,
"tenants": { "tenants": {
"unknown": 54 "unknown": 17
}, },
"users": { "users": {
"unknown": 54 "unknown": 17
}, },
"components": { "components": {
"state-hub.events": 54 "state-hub.events": 17
} }
}, },
"last_seen_at": "2026-07-08T20:49:12.839383Z", "last_seen_at": "2026-07-08T20:49:12.839383Z",
@ -214,23 +196,23 @@
} }
}, },
"window": { "window": {
"calls": 24897, "calls": 1752,
"tenant_count": 1, "tenant_count": 1,
"user_count": 1, "user_count": 1,
"component_count": 1, "component_count": 1,
"tenants": { "tenants": {
"unknown": 24897 "unknown": 1752
}, },
"users": { "users": {
"unknown": 24897 "unknown": 1752
}, },
"components": { "components": {
"unknown": 24897 "unknown": 1752
} }
}, },
"last_seen_at": "2026-07-08T21:14:39.652776Z", "last_seen_at": "2026-07-08T21:14:39.652776Z",
"retirement_candidate": false, "retirement_candidate": false,
"retirement_reason": "24897 call(s) in review window" "retirement_reason": "1752 call(s) in review window"
}, },
{ {
"interface": { "interface": {
@ -265,23 +247,17 @@
} }
}, },
"window": { "window": {
"calls": 4, "calls": 0,
"tenant_count": 1, "tenant_count": 0,
"user_count": 1, "user_count": 0,
"component_count": 1, "component_count": 0,
"tenants": { "tenants": {},
"unknown": 4 "users": {},
}, "components": {}
"users": {
"unknown": 4
},
"components": {
"unknown": 4
}
}, },
"last_seen_at": "2026-07-03T05:20:34.534439Z", "last_seen_at": "2026-07-03T05:20:34.534439Z",
"retirement_candidate": false, "retirement_candidate": true,
"retirement_reason": "4 call(s) in review window" "retirement_reason": "no measured usage in review window"
}, },
{ {
"interface": { "interface": {
@ -316,23 +292,23 @@
} }
}, },
"window": { "window": {
"calls": 82774, "calls": 7653,
"tenant_count": 1, "tenant_count": 1,
"user_count": 1, "user_count": 1,
"component_count": 1, "component_count": 1,
"tenants": { "tenants": {
"unknown": 82774 "unknown": 7653
}, },
"users": { "users": {
"unknown": 82774 "unknown": 7653
}, },
"components": { "components": {
"unknown": 82774 "unknown": 7653
} }
}, },
"last_seen_at": "2026-07-08T21:14:20.781103Z", "last_seen_at": "2026-07-08T21:14:20.781103Z",
"retirement_candidate": false, "retirement_candidate": false,
"retirement_reason": "82774 call(s) in review window" "retirement_reason": "7653 call(s) in review window"
}, },
{ {
"interface": { "interface": {
@ -367,23 +343,23 @@
} }
}, },
"window": { "window": {
"calls": 41448, "calls": 3892,
"tenant_count": 1, "tenant_count": 1,
"user_count": 1, "user_count": 1,
"component_count": 1, "component_count": 1,
"tenants": { "tenants": {
"unknown": 41448 "unknown": 3892
}, },
"users": { "users": {
"unknown": 41448 "unknown": 3892
}, },
"components": { "components": {
"unknown": 41448 "unknown": 3892
} }
}, },
"last_seen_at": "2026-07-08T21:14:12.747005Z", "last_seen_at": "2026-07-08T21:14:12.747005Z",
"retirement_candidate": false, "retirement_candidate": false,
"retirement_reason": "41448 call(s) in review window" "retirement_reason": "3892 call(s) in review window"
}, },
{ {
"interface": { "interface": {
@ -418,23 +394,74 @@
} }
}, },
"window": { "window": {
"calls": 128, "calls": 46,
"tenant_count": 1, "tenant_count": 1,
"user_count": 1, "user_count": 1,
"component_count": 1, "component_count": 1,
"tenants": { "tenants": {
"unknown": 128 "unknown": 46
}, },
"users": { "users": {
"unknown": 128 "unknown": 46
}, },
"components": { "components": {
"unknown": 128 "unknown": 46
} }
}, },
"last_seen_at": "2026-07-08T20:49:12.335710Z", "last_seen_at": "2026-07-08T20:49:12.335710Z",
"retirement_candidate": false, "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": { "interface": {
@ -520,23 +547,23 @@
} }
}, },
"window": { "window": {
"calls": 40, "calls": 18,
"tenant_count": 1, "tenant_count": 1,
"user_count": 1, "user_count": 1,
"component_count": 1, "component_count": 1,
"tenants": { "tenants": {
"unknown": 40 "unknown": 18
}, },
"users": { "users": {
"unknown": 40 "unknown": 18
}, },
"components": { "components": {
"unknown": 40 "unknown": 18
} }
}, },
"last_seen_at": "2026-07-08T20:38:05.903144Z", "last_seen_at": "2026-07-08T20:38:05.903144Z",
"retirement_candidate": false, "retirement_candidate": false,
"retirement_reason": "40 call(s) in review window" "retirement_reason": "18 call(s) in review window"
}, },
{ {
"interface": { "interface": {
@ -630,6 +657,51 @@
"retirement_candidate": true, "retirement_candidate": true,
"retirement_reason": "no measured usage in review window" "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": { "interface": {
"id": "281fd706-b192-4194-8055-2d8733c115f9", "id": "281fd706-b192-4194-8055-2d8733c115f9",
@ -676,5 +748,6 @@
"retirement_reason": "no measured usage in review window" "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 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) # 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/* # 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) (`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`) - [x] Weekly legacy-meter review shows `state-hub.fix-consistency` (not only `unknown`)
(`docs/evidence/legacy-meter-weekly-review-20260709.json`, 5 attributed calls) (`docs/evidence/legacy-meter-weekly-review-20260709.json`, 5 attributed calls)
- [ ] `GET /workstreams/*` 7-day window counts trend down week-over-week - [ ] `GET /workstreams/*` counts trend down in 8-hour capture windows post-deploy
(deploy complete 2026-07-09; re-capture after one full week for ≥50% drop gate) (default `capture_legacy_meter_evidence.py`; weekly `--days 7` for retirement gate)
## Rollback ## Rollback

View file

@ -43,7 +43,7 @@ on legacy REST keys; most lacked `X-StateHub-Component` identity (`unknown` buck
## Next verification ## Next verification
```bash ```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 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: Evidence capture:
```bash ```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 # writes docs/evidence/legacy-meter-weekly-review-YYYYMMDD.json
``` ```

View file

@ -1,8 +1,11 @@
#!/usr/bin/env python3 #!/usr/bin/env python3
"""Capture legacy-meter weekly-review payload as dated evidence JSON. """Capture legacy-meter review payload as dated evidence JSON.
Usage: 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: Writes:
docs/evidence/legacy-meter-weekly-review-YYYYMMDD.json docs/evidence/legacy-meter-weekly-review-YYYYMMDD.json
@ -68,23 +71,40 @@ def _retire_interfaces(base: str, keys: list[str]) -> list[dict]:
return retired return retired
def capture(*, api_base: str, days: int, retire_keys: list[str], dry_run: bool) -> Path: def _review_query(*, days: int | None, hours: int | None) -> str:
review = _api_get(api_base, f"/legacy-meter/weekly-review?days={days}") 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] = [] retired: list[dict] = []
if retire_keys and not dry_run: if retire_keys and not dry_run:
retired = _retire_interfaces(api_base, retire_keys) 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") today = datetime.date.today().strftime("%Y%m%d")
out_path = EVIDENCE_DIR / f"legacy-meter-weekly-review-{today}.json" out_path = EVIDENCE_DIR / f"legacy-meter-weekly-review-{today}.json"
payload = { payload: dict = {
"captured_at": datetime.datetime.now(datetime.timezone.utc).isoformat(), "captured_at": datetime.datetime.now(datetime.timezone.utc).isoformat(),
"api_base": api_base, "api_base": api_base,
"days": days, "workplan": "STATE-WP-0070",
"workplan": "STATE-WP-0069",
"retired_interfaces": retired, "retired_interfaces": retired,
"weekly_review": review, "weekly_review": review,
} }
if days is not None:
payload["days"] = days
else:
payload["hours"] = hours or 8
if dry_run: if dry_run:
print(json.dumps(payload, indent=2)) 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") out_path.write_text(json.dumps(payload, indent=2) + "\n", encoding="utf-8")
candidates = len(review.get("retirement_candidates", [])) candidates = len(review.get("retirement_candidates", []))
interfaces = len(review.get("interfaces", [])) 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 return out_path
def main() -> None: def main() -> None:
parser = argparse.ArgumentParser(description=__doc__) 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( parser.add_argument(
"--api-base", "--api-base",
default=os.environ.get("API_BASE", "http://127.0.0.1:8000"), default=os.environ.get("API_BASE", "http://127.0.0.1:8000"),
@ -118,6 +150,7 @@ def main() -> None:
capture( capture(
api_base=args.api_base, api_base=args.api_base,
days=args.days, days=args.days,
hours=args.hours,
retire_keys=retire_keys, retire_keys=retire_keys,
dry_run=args.dry_run, dry_run=args.dry_run,
) )

View file

@ -93,6 +93,25 @@ class TestWorkplanAliasesAndLegacyMeter:
assert item["window"]["components"] == {"old-client": 1} assert item["window"]["components"] == {"old-client": 1}
assert item["retirement_candidate"] is False 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): async def test_weekly_review_reports_unused_verified_legacy_interface(self, client):
r = await client.post("/legacy-meter/interfaces", json={ r = await client.post("/legacy-meter/interfaces", json={
"interface_key": "rest_api:GET /obsolete", "interface_key": "rest_api:GET /obsolete",