whisper-money/app/Console/Commands/McpUsageStatsCommand.php

125 lines
4.1 KiB
PHP

<?php
namespace App\Console\Commands;
use App\Models\McpToolCall;
use Illuminate\Console\Command;
use Illuminate\Database\Query\Builder;
use Illuminate\Support\Carbon;
use Illuminate\Support\Str;
class McpUsageStatsCommand extends Command
{
protected $signature = 'stats:mcp-usage {--days=30 : Days of history to report on} {--top=20 : Users listed in the per-user table}';
protected $description = 'Show MCP usage: calls per tool, calls per user and daily totals';
public function handle(): int
{
$days = max(1, (int) $this->option('days'));
$since = Carbon::now()->subDays($days - 1)->startOfDay();
$total = $this->callsSince($since)->count();
$window = $days.' '.Str::plural('day', $days);
if ($total === 0) {
$this->warn("No MCP tool calls in the last {$window}.");
return self::SUCCESS;
}
$users = $this->callsSince($since)->distinct()->count('user_id');
$this->newLine();
$this->line("<options=bold>MCP usage — last {$window}</> (since {$since->toDateString()})");
$this->line(" Calls: <fg=cyan>{$total}</> Users: <fg=cyan>{$users}</>");
$this->renderTools($since, $total);
$this->renderUsers($since);
$this->renderDays($since);
return self::SUCCESS;
}
/**
* Every call in the window, as a plain query builder: the report only ever
* reads aggregates, so it has no use for hydrated models.
*/
private function callsSince(Carbon $since): Builder
{
return McpToolCall::query()->toBase()->where('mcp_tool_calls.created_at', '>=', $since);
}
private function renderTools(Carbon $since, int $total): void
{
$rows = $this->callsSince($since)
->selectRaw('tool, count(*) as calls, count(distinct user_id) as users')
->groupBy('tool')
->orderByDesc('calls')
->get();
$this->newLine();
$this->line('<options=bold>By tool</>');
$this->table(
['Tool', 'Calls', '%', 'Users'],
$rows->map(fn (object $row): array => [
$row->tool,
$row->calls,
sprintf('%.1f%%', $row->calls / $total * 100),
$row->users,
])->all()
);
}
/**
* Joined rather than eager-loaded so users who deleted their account still
* show up: `user:delete` soft-deletes, so the cascade never fires and the
* relation (with its `deleted_at is null` scope) would come back empty.
*/
private function renderUsers(Carbon $since): void
{
$top = max(1, (int) $this->option('top'));
$rows = $this->callsSince($since)
->join('users', 'users.id', '=', 'mcp_tool_calls.user_id')
->selectRaw('users.email, users.deleted_at, count(*) as calls, count(distinct tool) as tools, max(mcp_tool_calls.created_at) as last_call')
->groupBy('users.id', 'users.email', 'users.deleted_at')
->orderByDesc('calls')
->limit($top)
->get();
$this->newLine();
$this->line("<options=bold>By user</> (top {$top})");
$this->table(
['User', 'Calls', 'Tools', 'Last call'],
$rows->map(fn (object $row): array => [
$row->email.($row->deleted_at === null ? '' : ' (deleted)'),
$row->calls,
$row->tools,
(string) $row->last_call,
])->all()
);
}
private function renderDays(Carbon $since): void
{
$rows = $this->callsSince($since)
->selectRaw('date(mcp_tool_calls.created_at) as day, count(*) as calls, count(distinct user_id) as users')
->groupBy('day')
->orderBy('day')
->get();
$this->newLine();
$this->line('<options=bold>By day</>');
$this->table(
['Day', 'Calls', 'Users'],
$rows->map(fn (object $row): array => [
(string) $row->day,
$row->calls,
$row->users,
])->all()
);
}
}