agent/php/Mcp/Services/McpMetricsService.php
Snider 91551dec9b feat(mcp): implement extended RFC services + transport (#842)
Additive-only — no existing files modified.

Services (php/Mcp/Services/):
- CircuitBreaker (3-state, Cache::add trial lock)
- DataRedactor (28 sensitive + 16 PII keys, partial-redact algorithm)
- McpHealthService (YAML registry + JSON-RPC stdio ping protocolVersion 2024-11-05)
- McpMetricsService (p50/p95/p99 linear interpolation)
- McpWebhookDispatcher (mcp.tool.executed → WebhookEndpoints)
- OpenApiGenerator (OpenAPI 3.0.3)
- ToolRateLimiter (Cache::put first, Cache::increment after — no reset)
- AgentSessionService (php/Mod/Mcp/Services/ namespace per spec)

Transport (php/Mcp/Transport/):
- McpContext (transport-agnostic callbacks)
- Contracts/McpToolHandler interface

Resources (php/Mcp/Resources/):
- AppConfig, ContentResource, DatabaseSchema

Config: php/resources/mcp/registry.yaml.
Pest Feature tests _Good/_Bad/_Ugly per AX-10 for each new class.

Co-authored-by: Codex <noreply@openai.com>
Closes tasks.lthn.sh/view.php?id=842
2026-04-25 05:50:16 +01:00

351 lines
14 KiB
PHP

<?php
// SPDX-License-Identifier: EUPL-1.2
declare(strict_types=1);
namespace Core\Mcp\Services;
use Carbon\CarbonImmutable;
use Illuminate\Support\Carbon;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
final class McpMetricsService
{
protected string $statsTable = 'mcp_tool_call_stats';
protected string $callsTable = 'mcp_tool_calls';
public function getOverview(int $days = 7): array
{
$currentStart = CarbonImmutable::now()->subDays($days - 1)->startOfDay();
$currentEnd = CarbonImmutable::now()->endOfDay();
$previousStart = $currentStart->subDays($days);
$previousEnd = $currentStart->subDay()->endOfDay();
$current = $this->statsInRange($currentStart, $currentEnd);
$previous = $this->statsInRange($previousStart, $previousEnd);
$totalCalls = (int) $current->sum('call_count');
$successCalls = (int) $current->sum('success_count');
$errorCalls = (int) $current->sum('error_count');
$previousCalls = (int) $previous->sum('call_count');
$totalDuration = (float) $current->sum('total_duration_ms');
return [
'total_calls' => $totalCalls,
'success_calls' => $successCalls,
'error_calls' => $errorCalls,
'success_rate' => $totalCalls > 0 ? round(($successCalls / $totalCalls) * 100, 1) : 0.0,
'avg_duration_ms' => $totalCalls > 0 ? round($totalDuration / $totalCalls, 1) : 0.0,
'calls_trend_percent' => $previousCalls > 0 ? round((($totalCalls - $previousCalls) / $previousCalls) * 100, 1) : 0.0,
'unique_tools' => $current->pluck('tool_name')->filter()->unique()->count(),
'unique_servers' => $current->pluck('server_id')->filter()->unique()->count(),
'period_days' => $days,
];
}
public function getDailyTrend(int $days = 7): Collection
{
$result = collect();
$start = CarbonImmutable::now()->subDays($days - 1)->startOfDay();
$end = CarbonImmutable::now()->endOfDay();
$rows = $this->dailyTrendRows($start, $end)->keyBy('date');
for ($offset = 0; $offset < $days; $offset++) {
$date = $start->addDays($offset)->toDateString();
$row = $rows->get($date);
$totalCalls = (int) ($row->total_calls ?? 0);
$totalSuccess = (int) ($row->total_success ?? 0);
$totalErrors = (int) ($row->total_errors ?? 0);
$result->push(collect([
'date' => $date,
'date_formatted' => Carbon::parse($date)->format('M j'),
'total_calls' => $totalCalls,
'total_success' => $totalSuccess,
'total_errors' => $totalErrors,
'success_rate' => $totalCalls > 0 ? round(($totalSuccess / $totalCalls) * 100, 1) : 0.0,
]));
}
return $result;
}
public function getTopTools(int $days = 7, int $limit = 10): Collection
{
if (! Schema::hasTable($this->statsTable)) {
return collect();
}
return DB::table($this->statsTable)
->select('tool_name')
->selectRaw('SUM(call_count) as call_count')
->selectRaw('SUM(success_count) as success_count')
->selectRaw('SUM(error_count) as error_count')
->selectRaw('SUM(total_duration_ms) as total_duration_ms')
->whereBetween('date', [
CarbonImmutable::now()->subDays($days - 1)->toDateString(),
CarbonImmutable::now()->toDateString(),
])
->groupBy('tool_name')
->orderByDesc('call_count')
->limit($limit)
->get()
->map(function (object $row): Collection {
$callCount = (int) $row->call_count;
$successCount = (int) $row->success_count;
return collect([
'tool_name' => (string) $row->tool_name,
'call_count' => $callCount,
'success_count' => $successCount,
'error_count' => (int) $row->error_count,
'success_rate' => $callCount > 0 ? round(($successCount / $callCount) * 100, 1) : 0.0,
'avg_duration_ms' => $callCount > 0 ? round(((int) $row->total_duration_ms) / $callCount, 1) : 0.0,
]);
});
}
public function getServerStats(int $days = 7): Collection
{
if (! Schema::hasTable($this->statsTable)) {
return collect();
}
return DB::table($this->statsTable)
->select('server_id')
->selectRaw('SUM(call_count) as call_count')
->selectRaw('SUM(success_count) as success_count')
->selectRaw('SUM(error_count) as error_count')
->selectRaw('COUNT(DISTINCT tool_name) as unique_tools')
->whereBetween('date', [
CarbonImmutable::now()->subDays($days - 1)->toDateString(),
CarbonImmutable::now()->toDateString(),
])
->groupBy('server_id')
->orderByDesc('call_count')
->get()
->map(function (object $row): Collection {
$callCount = (int) $row->call_count;
$successCount = (int) $row->success_count;
return collect([
'server_id' => (string) $row->server_id,
'call_count' => $callCount,
'success_count' => $successCount,
'error_count' => (int) $row->error_count,
'unique_tools' => (int) $row->unique_tools,
'success_rate' => $callCount > 0 ? round(($successCount / $callCount) * 100, 1) : 0.0,
]);
});
}
public function getRecentCalls(int $limit = 20): Collection
{
if (! Schema::hasTable($this->callsTable)) {
return collect();
}
return DB::table($this->callsTable)
->orderByDesc('created_at')
->limit($limit)
->get()
->map(function (object $row): Collection {
$createdAt = isset($row->created_at) ? Carbon::parse((string) $row->created_at) : null;
$durationMs = isset($row->duration_ms) ? (int) $row->duration_ms : null;
return collect([
'id' => $row->id,
'server_id' => (string) ($row->server_id ?? ''),
'tool_name' => (string) ($row->tool_name ?? ''),
'success' => (bool) ($row->success ?? false),
'duration' => $this->humanDuration($durationMs),
'duration_ms' => $durationMs,
'error_message' => $row->error_message,
'session_id' => $row->session_id,
'plan_slug' => $row->plan_slug,
'created_at' => $createdAt?->diffForHumans(),
'created_at_full' => $createdAt?->toIso8601String(),
]);
});
}
public function getErrorBreakdown(int $days = 7): Collection
{
if (! Schema::hasTable($this->callsTable)) {
return collect();
}
return DB::table($this->callsTable)
->select('tool_name', 'error_code')
->selectRaw('COUNT(*) as error_count')
->where('success', false)
->where('created_at', '>=', CarbonImmutable::now()->subDays($days)->startOfDay()->toDateTimeString())
->groupBy('tool_name', 'error_code')
->orderByDesc('error_count')
->get()
->map(fn (object $row): Collection => collect([
'tool_name' => (string) ($row->tool_name ?? ''),
'error_code' => $row->error_code,
'error_count' => (int) $row->error_count,
]));
}
public function getToolPerformance(int $days = 7, int $limit = 10): Collection
{
if (! Schema::hasTable($this->callsTable)) {
return collect();
}
$rows = DB::table($this->callsTable)
->select('tool_name', 'duration_ms')
->whereNotNull('duration_ms')
->where('success', true)
->where('created_at', '>=', CarbonImmutable::now()->subDays($days)->startOfDay()->toDateTimeString())
->get()
->groupBy('tool_name');
return $rows->map(function (Collection $items, string $toolName): Collection {
$durations = $items->pluck('duration_ms')->map(static fn (mixed $value): int => (int) $value)->sort()->values();
$count = $durations->count();
return collect([
'tool_name' => $toolName,
'call_count' => $count,
'min_ms' => $count > 0 ? (int) $durations->first() : 0,
'max_ms' => $count > 0 ? (int) $durations->last() : 0,
'avg_ms' => $count > 0 ? round($durations->avg(), 1) : 0.0,
'p50_ms' => $this->percentile($durations, 50),
'p95_ms' => $this->percentile($durations, 95),
'p99_ms' => $this->percentile($durations, 99),
]);
})->sortByDesc('call_count')->take($limit)->values();
}
public function getHourlyDistribution(): Collection
{
$distribution = collect();
$hours = collect();
if (Schema::hasTable($this->callsTable)) {
$hours = DB::table($this->callsTable)
->select('success', 'created_at')
->where('created_at', '>=', CarbonImmutable::now()->subHours(24)->toDateTimeString())
->get()
->groupBy(static function (object $row): string {
return Carbon::parse((string) $row->created_at)->format('H');
});
}
for ($hour = 0; $hour < 24; $hour++) {
$key = str_pad((string) $hour, 2, '0', STR_PAD_LEFT);
$rows = $hours->get($key, collect());
$distribution->push(collect([
'hour' => $key,
'hour_formatted' => Carbon::createFromTime($hour)->format('ga'),
'call_count' => $rows->count(),
'success_count' => $rows->filter(static fn (object $row): bool => (bool) ($row->success ?? false))->count(),
]));
}
return $distribution;
}
public function getPlanActivity(int $days = 7, int $limit = 10): Collection
{
if (! Schema::hasTable($this->callsTable)) {
return collect();
}
return DB::table($this->callsTable)
->select('plan_slug')
->selectRaw('COUNT(*) as call_count')
->selectRaw('COUNT(DISTINCT tool_name) as unique_tools')
->selectRaw('SUM(CASE WHEN success = 1 THEN 1 ELSE 0 END) as success_count')
->whereNotNull('plan_slug')
->where('created_at', '>=', CarbonImmutable::now()->subDays($days)->startOfDay()->toDateTimeString())
->groupBy('plan_slug')
->orderByDesc('call_count')
->limit($limit)
->get()
->map(function (object $row): Collection {
$callCount = (int) $row->call_count;
$successCount = (int) $row->success_count;
return collect([
'plan_slug' => (string) $row->plan_slug,
'call_count' => $callCount,
'unique_tools' => (int) $row->unique_tools,
'success_count' => $successCount,
'success_rate' => $callCount > 0 ? round(($successCount / $callCount) * 100, 1) : 0.0,
]);
});
}
protected function statsInRange(CarbonImmutable $start, CarbonImmutable $end): Collection
{
if (! Schema::hasTable($this->statsTable)) {
return collect();
}
return DB::table($this->statsTable)
->whereBetween('date', [$start->toDateString(), $end->toDateString()])
->get()
->map(static fn (object $row): Collection => collect((array) $row));
}
protected function dailyTrendRows(CarbonImmutable $start, CarbonImmutable $end): Collection
{
if (! Schema::hasTable($this->statsTable)) {
return collect();
}
return DB::table($this->statsTable)
->select('date')
->selectRaw('SUM(call_count) as total_calls')
->selectRaw('SUM(success_count) as total_success')
->selectRaw('SUM(error_count) as total_errors')
->whereBetween('date', [$start->toDateString(), $end->toDateString()])
->groupBy('date')
->orderBy('date')
->get();
}
protected function percentile(Collection $sortedValues, int $percentile): float
{
$count = $sortedValues->count();
if ($count === 0) {
return 0.0;
}
$index = ($percentile / 100) * ($count - 1);
$lower = (int) floor($index);
$upper = (int) ceil($index);
if ($lower === $upper) {
return (float) $sortedValues[$lower];
}
$fraction = $index - $lower;
return round(((float) $sortedValues[$lower]) + (((float) $sortedValues[$upper] - (float) $sortedValues[$lower]) * $fraction), 1);
}
protected function humanDuration(?int $durationMs): string
{
if ($durationMs === null || $durationMs <= 0) {
return '-';
}
if ($durationMs < 1000) {
return $durationMs.'ms';
}
return round($durationMs / 1000, 2).'s';
}
}