CI & Build / Python lint (push) Successful in 3s
CI & Build / Plugin hooks (push) Successful in 13s
CI & Build / integration (push) Successful in 50s
CI & Build / TypeScript typecheck (push) Successful in 52s
CI & Build / Python tests (push) Successful in 1m44s
CI & Build / Build & push image (push) Successful in 36s
#4769 "Rulings are counted where someone will read them": milestone 444 step 4 wrote system_usage_events and nothing read it. - retrieval_telemetry gains a `system_usage` block: surfacings and opens by source, distinct counts, and `by_system` naming the areas most shown. There is deliberately no pull-through ratio, because rulings travel in full in the line and opens are the exception. - usage_for_systems (one GROUP BY) adds `usage` to the REST Systems list and detail, and to MCP get_system. MCP list_systems is unchanged. - The Systems UI shows a "rulings shown N×" chip. - rulings_pre_tool, rulings_write_path and mcp_get_system are now declared registry points; the registry guard covers their recorders. - The Systems store merges a PATCH reply instead of replacing the row. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
135 lines
5.0 KiB
Python
135 lines
5.0 KiB
Python
"""System usage telemetry — were an area's rulings read once shown?
|
|
|
|
The twin of `note_usage` and `rule_usage` for Systems (milestone 444). The
|
|
rulings arm shows a System's rulings when a command or edit touches the
|
|
System's files; a pull is somebody then opening the System (`get_system`). The
|
|
ratio says whether delivering rulings by path earns its line.
|
|
|
|
Fire-and-forget like its siblings: telemetry never adds latency to, or
|
|
breaks, the surface it observes, and failures report through the shared
|
|
canary rather than vanishing.
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
import logging
|
|
|
|
from sqlalchemy import func, select
|
|
|
|
from scribe.models import async_session
|
|
from scribe.models.base import iso
|
|
from scribe.models.system_usage import PULLED, SURFACED, SystemUsageEvent
|
|
from scribe.services.background import report_telemetry_failure, spawn
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
|
|
async def _insert_events(rows: list[dict]) -> None:
|
|
"""Persist usage rows. Best-effort: failures degrade, visibly."""
|
|
try:
|
|
async with async_session() as session:
|
|
session.add_all([SystemUsageEvent(**row) for row in rows])
|
|
await session.commit()
|
|
except Exception:
|
|
await report_telemetry_failure("system_usage", "write")
|
|
|
|
|
|
def _rows(user_id, system_ids, event: str, source: str, project_id) -> list[dict]:
|
|
pid = int(project_id or 0) or None
|
|
return [
|
|
{"user_id": user_id, "system_id": int(sid), "event": event,
|
|
"source": source, "project_id": pid}
|
|
for sid in system_ids
|
|
]
|
|
|
|
|
|
def record_system_surfaced(
|
|
*, user_id: int | None, system_ids: list[int], source: str,
|
|
project_id: int | None = None,
|
|
) -> None:
|
|
"""Fire-and-forget: these Systems' rulings were shown in full. A repeat
|
|
rendered as a short reference is not a surfacing and is not recorded."""
|
|
try:
|
|
rows = _rows(user_id, system_ids, SURFACED, source, project_id)
|
|
except Exception:
|
|
logger.debug("system usage payload build failed", exc_info=True)
|
|
return
|
|
if rows:
|
|
spawn(_insert_events(rows), site="system_usage_write")
|
|
|
|
|
|
def record_system_pulled(
|
|
*, user_id: int | None, system_id: int, source: str,
|
|
project_id: int | None = None,
|
|
) -> None:
|
|
"""Fire-and-forget: a System was opened in full."""
|
|
try:
|
|
rows = _rows(user_id, [system_id], PULLED, source, project_id)
|
|
except Exception:
|
|
logger.debug("system usage payload build failed", exc_info=True)
|
|
return
|
|
spawn(_insert_events(rows), site="system_usage_write")
|
|
|
|
|
|
def empty_system_usage() -> dict:
|
|
"""The zero readout — a System no event has touched.
|
|
|
|
Rendered unconditionally, like `empty_rule_usage`, so a System predating
|
|
the table reads as "never shown, never opened" rather than as a missing
|
|
key. Every System in an install predates it, so for a while this is the
|
|
normal state and must not look like a broken readout.
|
|
|
|
The same four keys as the client's `RecordUsage`, but read differently: a
|
|
System's rulings are delivered IN FULL in the injected line, so a
|
|
surfacing with no pull is the arm working, not dead weight. See
|
|
`retrieval_telemetry`'s `system_usage` block for why no ratio is offered.
|
|
"""
|
|
return {
|
|
"surfaced_count": 0,
|
|
"pull_count": 0,
|
|
"last_surfaced_at": None,
|
|
"last_pulled_at": None,
|
|
}
|
|
|
|
|
|
async def usage_for_systems(system_ids: list[int]) -> dict[int, dict]:
|
|
"""Aggregate usage for a set of Systems: {system_id: {counts + timestamps}}.
|
|
|
|
One GROUP BY for the whole list, never a query per row — this decorates a
|
|
list view. Systems with no events come back as `empty_system_usage()`.
|
|
Fails open: a readout that could break the Systems list is worse than a
|
|
zero, but the failure is reported so the zero is not mistaken for silence
|
|
(#2663).
|
|
"""
|
|
ids = [int(s) for s in system_ids]
|
|
out: dict[int, dict] = {sid: empty_system_usage() for sid in ids}
|
|
if not ids:
|
|
return out
|
|
try:
|
|
async with async_session() as session:
|
|
rows = (
|
|
await session.execute(
|
|
select(
|
|
SystemUsageEvent.system_id,
|
|
SystemUsageEvent.event,
|
|
func.count().label("n"),
|
|
func.max(SystemUsageEvent.created_at).label("last_at"),
|
|
)
|
|
.where(SystemUsageEvent.system_id.in_(ids))
|
|
.group_by(SystemUsageEvent.system_id, SystemUsageEvent.event)
|
|
)
|
|
).all()
|
|
except Exception:
|
|
await report_telemetry_failure("system_usage", "readout")
|
|
return out
|
|
for system_id, event, n, last_at in rows:
|
|
slot = out.get(int(system_id))
|
|
if slot is None:
|
|
continue
|
|
if event == SURFACED:
|
|
slot["surfaced_count"] = int(n)
|
|
slot["last_surfaced_at"] = iso(last_at)
|
|
elif event == PULLED:
|
|
slot["pull_count"] = int(n)
|
|
slot["last_pulled_at"] = iso(last_at)
|
|
return out
|