"""Abstract repository contract for tool / skill call metrics.""" from __future__ import annotations from abc import ABC, abstractmethod from dataclasses import dataclass from datetime import datetime from typing import Any @dataclass class ToolMetricRecord: """In-memory shape of a tool-call metric row before persistence.""" id: str created_at: datetime user_id: str | None = None thread_id: str | None = None run_id: str | None = None agent_name: str | None = None tool_name: str | None = None # The specific Agent Skill this call targets, when the tool is a skill tool # (``skill_view`` / ``skill_manage`` / ``skill_archive``) — taken from the # call's ``name`` argument. Null for every non-skill tool call. This is # what lets the admin see "skill X was invoked, and succeeded/failed". skill_name: str | None = None duration_ms: int = 0 status: str = "success" # Coarse failure bucket: rate_limited / ip_blocked / auth_error / timeout / # network_error / not_found / empty_result / tool_error. Null on success. error_category: str | None = None error_message: str | None = None @dataclass class ToolMetricsQuery: """Filter set for the admin list / export endpoints.""" user_id: str | None = None tool_name: str | None = None # When True, keep only rows that target a specific Agent Skill # (``skill_name IS NOT NULL``) — the ``kind=skill`` filter. skill_only: bool = False # Filter to one specific skill by name. skill_name: str | None = None status: str | None = None error_category: str | None = None since: datetime | None = None until: datetime | None = None limit: int = 100 offset: int = 0 class ToolMetricsStore(ABC): """Persistence contract for the tool-metrics middleware + admin API.""" @abstractmethod async def record(self, metric: ToolMetricRecord) -> None: """Persist one metric row. Must never raise on best-effort writes.""" raise NotImplementedError @abstractmethod async def list(self, query: ToolMetricsQuery) -> list[dict[str, Any]]: """Return the matching rows (newest first), paginated by ``query``.""" raise NotImplementedError @abstractmethod async def count(self, query: ToolMetricsQuery) -> int: """Return the unpaginated row count for ``query``.""" raise NotImplementedError @abstractmethod async def iter_all(self, query: ToolMetricsQuery): """Yield rows matching ``query`` without pagination — used by export.""" raise NotImplementedError @abstractmethod async def skill_breakdown(self, query: ToolMetricsQuery) -> list[dict[str, Any]]: """Per-skill aggregate: ``[{skill_name, total, success, error}, ...]``. Only rows with a non-null ``skill_name`` are counted, so this answers "which skills were invoked, and how often did each succeed/fail". """ raise NotImplementedError