85 lines
2.9 KiB
Python
85 lines
2.9 KiB
Python
"""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
|