docs: consolidate documentation into organized structure

- Move 12 markdown files from root to docs/ subdirectories
- Organize documentation by category:
  • docs/troubleshooting/ (1 file)  - Technical troubleshooting guides
  • docs/deployment/      (4 files) - Deployment and security documentation
  • docs/guides/          (3 files) - Feature-specific guides
  • docs/planning/        (4 files) - Planning and improvement proposals

Root directory cleanup:
- Reduced from 16 to 4 markdown files in root
- Only essential project files remain:
  • CLAUDE.md (AI instructions)
  • README.md (Main project readme)
  • CLEANUP_PLAN.md (Current cleanup plan)
  • SRC_STRUCTURE_IMPROVEMENTS.md (Structure improvements)

This improves:
 Documentation discoverability
 Logical organization by purpose
 Clean root directory
 Better maintainability
This commit is contained in:
2025-10-05 11:05:04 +02:00
parent 887847dde6
commit 5050c7d73a
36686 changed files with 196456 additions and 12398919 deletions

View File

@@ -0,0 +1,48 @@
<?php
declare(strict_types=1);
namespace App\Framework\Console\Analytics;
use App\Framework\Console\Analytics\Middleware\AnalyticsCollectionMiddleware;
use App\Framework\Console\Analytics\Repository\CommandUsageRepository;
use App\Framework\Console\Analytics\Repository\DatabaseCommandUsageRepository;
use App\Framework\DI\Attributes\Initializer;
use App\Framework\DI\Container;
use App\Framework\Environment\Environment;
use App\Framework\Environment\EnvKey;
final readonly class AnalyticsInitializer
{
public function __construct(
private Environment $environment
) {
}
#[Initializer]
public function initialize(Container $container): void
{
// Register repository
$container->bind(
CommandUsageRepository::class,
DatabaseCommandUsageRepository::class
);
// Register analytics service
$container->singleton(AnalyticsService::class, function (Container $container) {
return new AnalyticsService(
$container->get(CommandUsageRepository::class)
);
});
// Register analytics collection middleware
$container->singleton(AnalyticsCollectionMiddleware::class, function (Container $container) {
$enabled = $this->environment->getBool(EnvKey::CONSOLE_ANALYTICS_ENABLED, true);
return new AnalyticsCollectionMiddleware(
repository: $container->get(CommandUsageRepository::class),
enabled: $enabled
);
});
}
}

View File

@@ -0,0 +1,153 @@
<?php
declare(strict_types=1);
namespace App\Framework\Console\Analytics;
use App\Framework\Console\Analytics\Repository\CommandUsageRepository;
use App\Framework\Console\Analytics\ValueObjects\Period;
use App\Framework\Console\Analytics\ValueObjects\UsageStatistics;
use DateTimeImmutable;
final readonly class AnalyticsService
{
public function __construct(
private CommandUsageRepository $repository
) {
}
public function getCommandStatistics(string $commandName, ?DateTimeImmutable $since = null): UsageStatistics
{
return $this->repository->getUsageStatistics($commandName, $since);
}
public function getPopularCommands(int $limit = 10, ?DateTimeImmutable $since = null): array
{
return $this->repository->getPopularCommands($limit, $since);
}
public function getCommandTrends(
string $commandName,
Period $period = Period::DAILY,
?DateTimeImmutable $start = null,
?DateTimeImmutable $end = null
): array {
$start ??= new DateTimeImmutable('-30 days');
$end ??= new DateTimeImmutable();
return $this->repository->getTrendData($commandName, $period, $start, $end);
}
public function getAllCommandNames(): array
{
return $this->repository->getAllCommandNames();
}
public function cleanupOldData(int $daysToKeep = 90): int
{
$cutoffDate = new DateTimeImmutable("-{$daysToKeep} days");
return $this->repository->cleanup($cutoffDate);
}
public function getOverallStatistics(?DateTimeImmutable $since = null): array
{
$commands = $this->getAllCommandNames();
$statistics = [];
foreach ($commands as $commandName) {
$statistics[$commandName] = $this->getCommandStatistics($commandName, $since);
}
return $statistics;
}
public function getMostFailingCommands(int $limit = 10, ?DateTimeImmutable $since = null): array
{
$commands = $this->getAllCommandNames();
$failingCommands = [];
foreach ($commands as $commandName) {
$stats = $this->getCommandStatistics($commandName, $since);
if ($stats->totalExecutions > 0) {
$failingCommands[] = [
'command_name' => $commandName,
'failure_rate' => $stats->getFailureRate(),
'total_executions' => $stats->totalExecutions,
'failed_executions' => $stats->failedExecutions,
];
}
}
// Sort by failure rate descending
usort($failingCommands, function ($a, $b) {
return $b['failure_rate']->getValue() <=> $a['failure_rate']->getValue();
});
return array_slice($failingCommands, 0, $limit);
}
public function getSlowestCommands(int $limit = 10, ?DateTimeImmutable $since = null): array
{
$commands = $this->getAllCommandNames();
$slowCommands = [];
foreach ($commands as $commandName) {
$stats = $this->getCommandStatistics($commandName, $since);
if ($stats->totalExecutions > 0) {
$slowCommands[] = [
'command_name' => $commandName,
'average_execution_time' => $stats->averageExecutionTime,
'max_execution_time' => $stats->maxExecutionTime,
'total_executions' => $stats->totalExecutions,
];
}
}
// Sort by average execution time descending
usort($slowCommands, function ($a, $b) {
return $b['average_execution_time']->toMilliseconds() <=>
$a['average_execution_time']->toMilliseconds();
});
return array_slice($slowCommands, 0, $limit);
}
public function getUsageByHour(?DateTimeImmutable $since = null): array
{
$commands = $this->getAllCommandNames();
$hourlyUsage = array_fill(0, 24, 0);
foreach ($commands as $commandName) {
$stats = $this->getCommandStatistics($commandName, $since);
if ($stats->hourlyDistribution) {
foreach ($stats->hourlyDistribution as $hour => $count) {
$hourlyUsage[$hour] += $count;
}
}
}
return $hourlyUsage;
}
public function getCommandHealthScore(string $commandName, ?DateTimeImmutable $since = null): float
{
$stats = $this->getCommandStatistics($commandName, $since);
if ($stats->totalExecutions === 0) {
return 0.0;
}
// Health score based on success rate (70%) and performance (30%)
$successScore = $stats->getSuccessRate()->getValue() / 100;
// Performance score: inverse of execution time (normalized)
$avgTimeMs = $stats->averageExecutionTime->toMilliseconds();
$performanceScore = $avgTimeMs > 0 ? min(1.0, 1000 / $avgTimeMs) : 1.0;
return ($successScore * 0.7) + ($performanceScore * 0.3);
}
}

View File

@@ -0,0 +1,324 @@
<?php
declare(strict_types=1);
namespace App\Framework\Console\Analytics\Commands;
use App\Framework\Console\Analytics\AnalyticsService;
use App\Framework\Console\Analytics\ValueObjects\Period;
use App\Framework\Console\Attributes\ConsoleCommand;
use App\Framework\Console\ExitCode;
use App\Framework\Console\Input\ConsoleInput;
use App\Framework\Console\Layout\ResponsiveOutput;
use App\Framework\Console\Output\ConsoleOutput;
use DateTimeImmutable;
final readonly class AnalyticsCommand
{
public function __construct(
private AnalyticsService $analyticsService
) {
}
#[ConsoleCommand(
name: 'analytics:usage',
description: 'Show command usage analytics and statistics'
)]
public function showUsage(ConsoleInput $input, ConsoleOutput $output): ExitCode
{
$commandName = $input->getArgument('command');
$days = (int) ($input->getOption('days') ?? 30);
$since = new DateTimeImmutable("-{$days} days");
if ($commandName) {
return $this->showCommandUsage($commandName, $since, $output);
}
return $this->showOverallUsage($since, $output);
}
#[ConsoleCommand(
name: 'analytics:popular',
description: 'Show most popular commands'
)]
public function showPopularCommands(ConsoleInput $input, ConsoleOutput $output): ExitCode
{
$limit = (int) ($input->getOption('limit') ?? 10);
$days = (int) ($input->getOption('days') ?? 30);
$since = new DateTimeImmutable("-{$days} days");
$popularCommands = $this->analyticsService->getPopularCommands($limit, $since);
if (empty($popularCommands)) {
$output->writeLine('<red>No command usage data found.</red>');
return ExitCode::SUCCESS;
}
$responsiveOutput = ResponsiveOutput::create($output);
$output->writeLine("<yellow>Most Popular Commands (Last {$days} days)</yellow>\n");
// Prepare table data
$headers = ['Rank', 'Command', 'Executions', 'Usage %', 'Avg Time', 'Success Rate'];
$rows = [];
foreach ($popularCommands as $command) {
$rows[] = [
(string) $command->rank,
$command->commandName,
(string) $command->totalExecutions,
$command->usagePercentage->format(1),
$command->averageExecutionTime->toHumanReadable(),
$command->successRate->format(1),
];
}
$responsiveOutput->writeTable($headers, $rows);
return ExitCode::SUCCESS;
}
#[ConsoleCommand(
name: 'analytics:trends',
description: 'Show command usage trends over time'
)]
public function showTrends(ConsoleInput $input, ConsoleOutput $output): ExitCode
{
$commandName = $input->getArgument('command');
if (! $commandName) {
$output->writeLine('<red>Command name is required for trend analysis.</red>');
return ExitCode::INVALID_ARGUMENTS;
}
$periodStr = $input->getOption('period') ?? 'daily';
$period = match (strtolower($periodStr)) {
'hourly' => Period::HOURLY,
'daily' => Period::DAILY,
'weekly' => Period::WEEKLY,
'monthly' => Period::MONTHLY,
'yearly' => Period::YEARLY,
default => Period::DAILY
};
$days = (int) ($input->getOption('days') ?? 30);
$start = new DateTimeImmutable("-{$days} days");
$end = new DateTimeImmutable();
$trends = $this->analyticsService->getCommandTrends($commandName, $period, $start, $end);
if (empty($trends)) {
$output->writeLine('<red>No trend data found for this command.</red>');
return ExitCode::SUCCESS;
}
$responsiveOutput = ResponsiveOutput::create($output);
$trend = $trends[0];
$output->writeLine("<yellow>Usage Trends for '{$commandName}' ({$period->getDescription()})</yellow>\n");
// Show trend summary
$summary = [
'Period' => "{$trend->startDate->format('Y-m-d')} to {$trend->endDate->format('Y-m-d')}",
'Trend' => $trend->getTrendDescription(),
'Direction' => ($trend->trendDirection > 0 ? '+' : '') . round($trend->trendDirection, 3),
'Strength' => round($trend->trendStrength, 3),
];
$responsiveOutput->writeKeyValue($summary);
$output->writeLine('');
// Show trend data points as responsive table
$headers = ['Date', 'Executions', 'Avg Time', 'Success Rate'];
$rows = [];
foreach ($trend->dataPoints as $point) {
$rows[] = [
$point->date->format($period->getDateFormat()),
(string) $point->executionCount,
$point->averageExecutionTime->toHumanReadable(),
$point->successRate->format(1),
];
}
$responsiveOutput->writeTable($headers, $rows);
return ExitCode::SUCCESS;
}
#[ConsoleCommand(
name: 'analytics:health',
description: 'Show command health scores and problematic commands'
)]
public function showHealth(ConsoleInput $input, ConsoleOutput $output): ExitCode
{
$days = (int) ($input->getOption('days') ?? 30);
$since = new DateTimeImmutable("-{$days} days");
$output->writeLine("<yellow>Command Health Analysis (Last {$days} days)</yellow>\n");
// Show failing commands
$failingCommands = $this->analyticsService->getMostFailingCommands(10, $since);
if (! empty($failingCommands)) {
$output->writeLine("<red>Commands with Highest Failure Rates:</red>");
$output->writeLine(sprintf(
"%-25s %-12s %-12s %s",
'Command',
'Failure Rate',
'Total Runs',
'Failures'
));
$output->writeLine(str_repeat('-', 70));
foreach ($failingCommands as $command) {
$output->writeLine(sprintf(
"%-25s %-12s %-12d %d",
$command['command_name'],
$command['failure_rate']->format(1),
$command['total_executions'],
$command['failed_executions']
));
}
$output->writeLine('');
}
// Show slow commands
$slowCommands = $this->analyticsService->getSlowestCommands(10, $since);
if (! empty($slowCommands)) {
$output->writeLine("<yellow>Slowest Commands:</yellow>");
$output->writeLine(sprintf(
"%-25s %-15s %-15s %s",
'Command',
'Avg Time',
'Max Time',
'Total Runs'
));
$output->writeLine(str_repeat('-', 80));
foreach ($slowCommands as $command) {
$output->writeLine(sprintf(
"%-25s %-15s %-15s %d",
$command['command_name'],
$command['average_execution_time']->toHumanReadable(),
$command['max_execution_time']->toHumanReadable(),
$command['total_executions']
));
}
$output->writeLine('');
}
return ExitCode::SUCCESS;
}
#[ConsoleCommand(
name: 'analytics:cleanup',
description: 'Clean up old analytics data'
)]
public function cleanup(ConsoleInput $input, ConsoleOutput $output): ExitCode
{
$days = (int) ($input->getOption('days') ?? 90);
$output->writeLine("<yellow>Cleaning up analytics data older than {$days} days...</yellow>");
$deletedRecords = $this->analyticsService->cleanupOldData($days);
$output->writeLine("<green>Successfully deleted {$deletedRecords} old analytics records.</green>");
return ExitCode::SUCCESS;
}
private function showCommandUsage(string $commandName, DateTimeImmutable $since, ConsoleOutput $output): ExitCode
{
$stats = $this->analyticsService->getCommandStatistics($commandName, $since);
$output->writeLine("<yellow>Usage Statistics for '{$commandName}'</yellow>\n");
if ($stats->totalExecutions === 0) {
$output->writeLine('<red>No usage data found for this command.</red>');
return ExitCode::SUCCESS;
}
$output->writeLine("Total Executions: {$stats->totalExecutions}");
$output->writeLine("Successful: {$stats->successfulExecutions} ({$stats->getSuccessRate()->format(1)})");
$output->writeLine("Failed: {$stats->failedExecutions} ({$stats->getFailureRate()->format(1)})");
$output->writeLine('');
$output->writeLine("Performance:");
$output->writeLine(" Average Time: {$stats->averageExecutionTime->toHumanReadable()}");
$output->writeLine(" Min Time: {$stats->minExecutionTime->toHumanReadable()}");
$output->writeLine(" Max Time: {$stats->maxExecutionTime->toHumanReadable()}");
if ($stats->medianExecutionTime) {
$output->writeLine(" Median Time: {$stats->medianExecutionTime->toHumanReadable()}");
}
// Show hourly distribution
if ($stats->hourlyDistribution) {
$output->writeLine('');
$output->writeLine("Usage by Hour:");
$maxUsage = max($stats->hourlyDistribution);
for ($hour = 0; $hour < 24; $hour++) {
$usage = $stats->hourlyDistribution[$hour];
$percentage = $maxUsage > 0 ? ($usage / $maxUsage) * 100 : 0;
$bar = str_repeat('▓', (int) ($percentage / 5));
$output->writeLine(sprintf(
"%02d:00 %4d %s",
$hour,
$usage,
$bar
));
}
}
return ExitCode::SUCCESS;
}
private function showOverallUsage(DateTimeImmutable $since, ConsoleOutput $output): ExitCode
{
$commands = $this->analyticsService->getAllCommandNames();
$output->writeLine("<yellow>Overall Command Usage Statistics</yellow>\n");
if (empty($commands)) {
$output->writeLine('<red>No command usage data found.</red>');
return ExitCode::SUCCESS;
}
$totalExecutions = 0;
$totalCommands = count($commands);
foreach ($commands as $commandName) {
$stats = $this->analyticsService->getCommandStatistics($commandName, $since);
$totalExecutions += $stats->totalExecutions;
}
$output->writeLine("Total Commands: {$totalCommands}");
$output->writeLine("Total Executions: {$totalExecutions}");
$output->writeLine("Average per Command: " . round($totalExecutions / $totalCommands, 2));
$output->writeLine('');
// Show usage by hour
$hourlyUsage = $this->analyticsService->getUsageByHour($since);
$output->writeLine("Usage Distribution by Hour:");
$maxHourly = max($hourlyUsage);
for ($hour = 0; $hour < 24; $hour++) {
$usage = $hourlyUsage[$hour];
$percentage = $maxHourly > 0 ? ($usage / $maxHourly) * 100 : 0;
$bar = str_repeat('▓', (int) ($percentage / 5));
$output->writeLine(sprintf(
"%02d:00 %4d %s",
$hour,
$usage,
$bar
));
}
return ExitCode::SUCCESS;
}
}

View File

@@ -0,0 +1,120 @@
<?php
declare(strict_types=1);
namespace App\Framework\Console\Analytics\Middleware;
use App\Framework\Console\Analytics\Repository\CommandUsageRepository;
use App\Framework\Console\Analytics\ValueObjects\CommandUsageMetric;
use App\Framework\Console\ExitCode;
use App\Framework\Console\Input\ConsoleInput;
use App\Framework\Console\Middleware\ConsoleMiddleware;
use App\Framework\Console\Output\ConsoleOutput;
use App\Framework\Core\ValueObjects\Duration;
use DateTimeImmutable;
final readonly class AnalyticsCollectionMiddleware implements ConsoleMiddleware
{
public function __construct(
private CommandUsageRepository $repository,
private bool $enabled = true
) {
}
public function handle(ConsoleInput $input, ConsoleOutput $output, callable $next): ExitCode
{
if (! $this->enabled) {
return $next($input, $output);
}
$commandName = $this->extractCommandName($input);
$startTime = hrtime(true);
$executedAt = new DateTimeImmutable();
try {
$exitCode = $next($input, $output);
} catch (\Throwable $e) {
// Record the failure and re-throw
$this->recordUsage(
$commandName,
$executedAt,
$startTime,
ExitCode::GENERAL_ERROR,
$input
);
throw $e;
}
$this->recordUsage($commandName, $executedAt, $startTime, $exitCode, $input);
return $exitCode;
}
private function recordUsage(
string $commandName,
DateTimeImmutable $executedAt,
int $startTime,
ExitCode $exitCode,
ConsoleInput $input
): void {
try {
$endTime = hrtime(true);
$executionTimeNs = $endTime - $startTime;
$executionTime = Duration::fromNanoseconds($executionTimeNs);
$metric = CommandUsageMetric::create(
commandName: $commandName,
executionTime: $executionTime,
exitCode: $exitCode,
argumentCount: count($input->getArguments()),
userId: $this->getCurrentUserId(),
metadata: $this->collectMetadata($input, $exitCode)
);
$this->repository->store($metric);
} catch (\Throwable $e) {
// Analytics collection should never break the command execution
// In production, this could be logged to a separate error log
error_log("Analytics collection failed: " . $e->getMessage());
}
}
private function extractCommandName(ConsoleInput $input): string
{
$arguments = $input->getArguments();
// First argument after script name is usually the command
return $arguments[1] ?? 'unknown';
}
private function getCurrentUserId(): ?string
{
// In a real implementation, this would extract user ID from context
// Could be from environment variables, session data, etc.
return $_ENV['CONSOLE_USER_ID'] ?? null;
}
private function collectMetadata(ConsoleInput $input, ExitCode $exitCode): array
{
$metadata = [
'options' => $input->getOptions(),
'has_stdin' => ! empty(stream_get_contents(STDIN, 1)),
'php_version' => PHP_VERSION,
'memory_peak' => memory_get_peak_usage(true),
'exit_code_name' => $exitCode->name,
];
// Add environment context
if (isset($_ENV['APP_ENV'])) {
$metadata['environment'] = $_ENV['APP_ENV'];
}
// Add process info
if (function_exists('posix_getpid')) {
$metadata['process_id'] = posix_getpid();
}
return $metadata;
}
}

View File

@@ -0,0 +1,51 @@
<?php
declare(strict_types=1);
namespace App\Framework\Console\Analytics\Migrations;
use App\Framework\Database\ConnectionInterface;
use App\Framework\Database\Migration\Migration;
use App\Framework\Database\Migration\MigrationVersion;
final readonly class CreateCommandUsageAnalyticsTable implements Migration
{
public function up(ConnectionInterface $connection): void
{
$sql = "CREATE TABLE IF NOT EXISTS command_usage_analytics (
id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
command_name VARCHAR(255) NOT NULL,
executed_at DATETIME NOT NULL,
execution_time_ms DECIMAL(10, 3) NOT NULL,
exit_code TINYINT NOT NULL DEFAULT 0,
argument_count INT NOT NULL DEFAULT 0,
user_id VARCHAR(255) NULL,
metadata JSON NULL,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
INDEX idx_command_name (command_name),
INDEX idx_executed_at (executed_at),
INDEX idx_user_id (user_id),
INDEX idx_command_executed (command_name, executed_at),
INDEX idx_command_exit (command_name, exit_code),
INDEX idx_executed_exit (executed_at, exit_code)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci";
$connection->execute($sql);
}
public function down(ConnectionInterface $connection): void
{
$connection->execute("DROP TABLE IF EXISTS command_usage_analytics");
}
public function getVersion(): MigrationVersion
{
return MigrationVersion::fromTimestamp('2024_01_01_000001');
}
public function getDescription(): string
{
return 'Create command_usage_analytics table for console command metrics';
}
}

View File

@@ -0,0 +1,32 @@
<?php
declare(strict_types=1);
namespace App\Framework\Console\Analytics\Repository;
use App\Framework\Console\Analytics\ValueObjects\CommandUsageMetric;
use App\Framework\Console\Analytics\ValueObjects\Period;
use App\Framework\Console\Analytics\ValueObjects\UsageStatistics;
use DateTimeImmutable;
interface CommandUsageRepository
{
public function store(CommandUsageMetric $metric): void;
public function storeMultiple(CommandUsageMetric ...$metrics): void;
public function getUsageStatistics(string $commandName, ?DateTimeImmutable $since = null): UsageStatistics;
public function getPopularCommands(int $limit = 10, ?DateTimeImmutable $since = null): array;
public function getTrendData(
string $commandName,
Period $period,
DateTimeImmutable $start,
DateTimeImmutable $end
): array;
public function getAllCommandNames(): array;
public function cleanup(DateTimeImmutable $before): int;
}

View File

@@ -0,0 +1,391 @@
<?php
declare(strict_types=1);
namespace App\Framework\Console\Analytics\Repository;
use App\Framework\Console\Analytics\ValueObjects\CommandPopularity;
use App\Framework\Console\Analytics\ValueObjects\CommandUsageMetric;
use App\Framework\Console\Analytics\ValueObjects\Period;
use App\Framework\Console\Analytics\ValueObjects\UsageStatistics;
use App\Framework\Console\Analytics\ValueObjects\UsageTrend;
use App\Framework\Console\Analytics\ValueObjects\UsageTrendPoint;
use App\Framework\Core\ValueObjects\Duration;
use App\Framework\Core\ValueObjects\Percentage;
use App\Framework\Database\Connection;
use DateTimeImmutable;
final readonly class DatabaseCommandUsageRepository implements CommandUsageRepository
{
public function __construct(
private Connection $connection
) {
}
public function store(CommandUsageMetric $metric): void
{
$sql = "INSERT INTO command_usage_analytics (
command_name, executed_at, execution_time_ms, exit_code,
argument_count, user_id, metadata
) VALUES (?, ?, ?, ?, ?, ?, ?)";
$this->connection->execute($sql, [
$metric->commandName,
$metric->executedAt->format('Y-m-d H:i:s'),
$metric->executionTime->toMilliseconds(),
$metric->exitCode->value,
$metric->argumentCount,
$metric->userId,
$metric->metadata ? json_encode($metric->metadata) : null,
]);
}
public function storeMultiple(CommandUsageMetric ...$metrics): void
{
if (empty($metrics)) {
return;
}
$values = [];
$placeholders = [];
foreach ($metrics as $metric) {
$placeholders[] = "(?, ?, ?, ?, ?, ?, ?)";
$values[] = $metric->commandName;
$values[] = $metric->executedAt->format('Y-m-d H:i:s');
$values[] = $metric->executionTime->toMilliseconds();
$values[] = $metric->exitCode->value;
$values[] = $metric->argumentCount;
$values[] = $metric->userId;
$values[] = $metric->metadata ? json_encode($metric->metadata) : null;
}
$sql = "INSERT INTO command_usage_analytics (
command_name, executed_at, execution_time_ms, exit_code,
argument_count, user_id, metadata
) VALUES " . implode(', ', $placeholders);
$this->connection->execute($sql, $values);
}
public function getUsageStatistics(string $commandName, ?DateTimeImmutable $since = null): UsageStatistics
{
$whereClause = 'WHERE command_name = ?';
$params = [$commandName];
if ($since) {
$whereClause .= ' AND executed_at >= ?';
$params[] = $since->format('Y-m-d H:i:s');
}
$sql = "SELECT
COUNT(*) as total_executions,
SUM(CASE WHEN exit_code = 0 THEN 1 ELSE 0 END) as successful_executions,
SUM(CASE WHEN exit_code != 0 THEN 1 ELSE 0 END) as failed_executions,
AVG(execution_time_ms) as avg_execution_time,
MIN(execution_time_ms) as min_execution_time,
MAX(execution_time_ms) as max_execution_time,
GROUP_CONCAT(DISTINCT exit_code) as exit_codes
FROM command_usage_analytics
{$whereClause}";
$result = $this->connection->query($sql, $params);
if (empty($result) || $result[0]['total_executions'] == 0) {
return UsageStatistics::empty($commandName);
}
$row = $result[0];
$hourlyDistribution = $this->getHourlyDistribution($commandName, $since);
$exitCodeDistribution = $this->getExitCodeDistribution($commandName, $since);
return new UsageStatistics(
commandName: $commandName,
totalExecutions: (int) $row['total_executions'],
successfulExecutions: (int) $row['successful_executions'],
failedExecutions: (int) $row['failed_executions'],
averageExecutionTime: Duration::fromMilliseconds((float) $row['avg_execution_time']),
minExecutionTime: Duration::fromMilliseconds((float) $row['min_execution_time']),
maxExecutionTime: Duration::fromMilliseconds((float) $row['max_execution_time']),
medianExecutionTime: $this->getMedianExecutionTime($commandName, $since),
hourlyDistribution: $hourlyDistribution,
exitCodeDistribution: $exitCodeDistribution
);
}
public function getPopularCommands(int $limit = 10, ?DateTimeImmutable $since = null): array
{
$whereClause = '';
$params = [];
if ($since) {
$whereClause = 'WHERE executed_at >= ?';
$params[] = $since->format('Y-m-d H:i:s');
}
$sql = "SELECT
command_name,
COUNT(*) as total_executions,
AVG(execution_time_ms) as avg_execution_time,
SUM(CASE WHEN exit_code = 0 THEN 1 ELSE 0 END) / COUNT(*) * 100 as success_rate
FROM command_usage_analytics
{$whereClause}
GROUP BY command_name
ORDER BY total_executions DESC
LIMIT ?";
$params[] = $limit;
$results = $this->connection->query($sql, $params);
$totalExecutions = $this->getTotalExecutions($since);
$popularCommands = [];
foreach ($results as $index => $row) {
$usagePercentage = $totalExecutions > 0
? Percentage::fromValue(($row['total_executions'] / $totalExecutions) * 100)
: Percentage::zero();
$popularCommands[] = new CommandPopularity(
commandName: $row['command_name'],
rank: $index + 1,
totalExecutions: (int) $row['total_executions'],
usagePercentage: $usagePercentage,
averageExecutionTime: Duration::fromMilliseconds((float) $row['avg_execution_time']),
successRate: Percentage::fromValue((float) $row['success_rate'])
);
}
return $popularCommands;
}
public function getTrendData(
string $commandName,
Period $period,
DateTimeImmutable $start,
DateTimeImmutable $end
): array {
$groupByClause = match ($period) {
Period::HOURLY => "DATE_FORMAT(executed_at, '%Y-%m-%d %H:00:00')",
Period::DAILY => "DATE(executed_at)",
Period::WEEKLY => "YEARWEEK(executed_at, 1)",
Period::MONTHLY => "DATE_FORMAT(executed_at, '%Y-%m')",
Period::YEARLY => "YEAR(executed_at)"
};
$sql = "SELECT
{$groupByClause} as period_date,
COUNT(*) as execution_count,
AVG(execution_time_ms) as avg_execution_time,
SUM(CASE WHEN exit_code = 0 THEN 1 ELSE 0 END) / COUNT(*) * 100 as success_rate
FROM command_usage_analytics
WHERE command_name = ?
AND executed_at BETWEEN ? AND ?
GROUP BY {$groupByClause}
ORDER BY period_date";
$results = $this->connection->query($sql, [
$commandName,
$start->format('Y-m-d H:i:s'),
$end->format('Y-m-d H:i:s'),
]);
$trendPoints = [];
foreach ($results as $row) {
$date = match ($period) {
Period::WEEKLY => new DateTimeImmutable($row['period_date'] . '-1'), // First day of week
default => new DateTimeImmutable($row['period_date'])
};
$trendPoints[] = new UsageTrendPoint(
date: $date,
executionCount: (int) $row['execution_count'],
averageExecutionTime: Duration::fromMilliseconds((float) $row['avg_execution_time']),
successRate: Percentage::fromValue((float) $row['success_rate'])
);
}
// Calculate trend direction and strength
$trendDirection = $this->calculateTrendDirection($trendPoints);
$trendStrength = $this->calculateTrendStrength($trendPoints);
return [new UsageTrend(
commandName: $commandName,
period: $period,
startDate: $start,
endDate: $end,
dataPoints: $trendPoints,
trendDirection: $trendDirection,
trendStrength: $trendStrength
)];
}
public function getAllCommandNames(): array
{
$sql = "SELECT DISTINCT command_name FROM command_usage_analytics ORDER BY command_name";
$results = $this->connection->query($sql);
return array_column($results, 'command_name');
}
public function cleanup(DateTimeImmutable $before): int
{
$sql = "DELETE FROM command_usage_analytics WHERE executed_at < ?";
return $this->connection->execute($sql, [$before->format('Y-m-d H:i:s')]);
}
private function getHourlyDistribution(string $commandName, ?DateTimeImmutable $since): array
{
$whereClause = 'WHERE command_name = ?';
$params = [$commandName];
if ($since) {
$whereClause .= ' AND executed_at >= ?';
$params[] = $since->format('Y-m-d H:i:s');
}
$sql = "SELECT
HOUR(executed_at) as hour,
COUNT(*) as count
FROM command_usage_analytics
{$whereClause}
GROUP BY HOUR(executed_at)
ORDER BY hour";
$results = $this->connection->query($sql, $params);
// Initialize all hours to 0
$distribution = array_fill(0, 24, 0);
foreach ($results as $row) {
$distribution[(int) $row['hour']] = (int) $row['count'];
}
return $distribution;
}
private function getExitCodeDistribution(string $commandName, ?DateTimeImmutable $since): array
{
$whereClause = 'WHERE command_name = ?';
$params = [$commandName];
if ($since) {
$whereClause .= ' AND executed_at >= ?';
$params[] = $since->format('Y-m-d H:i:s');
}
$sql = "SELECT
exit_code,
COUNT(*) as count
FROM command_usage_analytics
{$whereClause}
GROUP BY exit_code
ORDER BY exit_code";
$results = $this->connection->query($sql, $params);
$distribution = [];
foreach ($results as $row) {
$distribution[$row['exit_code']] = (int) $row['count'];
}
return $distribution;
}
private function getMedianExecutionTime(string $commandName, ?DateTimeImmutable $since): ?Duration
{
$whereClause = 'WHERE command_name = ?';
$params = [$commandName];
if ($since) {
$whereClause .= ' AND executed_at >= ?';
$params[] = $since->format('Y-m-d H:i:s');
}
// MySQL median calculation using percentiles
$sql = "SELECT
PERCENTILE_CONT(0.5) WITHIN GROUP (ORDER BY execution_time_ms) as median_time
FROM command_usage_analytics
{$whereClause}";
$result = $this->connection->query($sql, $params);
if (empty($result) || $result[0]['median_time'] === null) {
return null;
}
return Duration::fromMilliseconds((float) $result[0]['median_time']);
}
private function getTotalExecutions(?DateTimeImmutable $since): int
{
$whereClause = '';
$params = [];
if ($since) {
$whereClause = 'WHERE executed_at >= ?';
$params[] = $since->format('Y-m-d H:i:s');
}
$sql = "SELECT COUNT(*) as total FROM command_usage_analytics {$whereClause}";
$result = $this->connection->query($sql, $params);
return (int) $result[0]['total'];
}
private function calculateTrendDirection(array $trendPoints): float
{
if (count($trendPoints) < 2) {
return 0.0;
}
$xValues = array_keys($trendPoints);
$yValues = array_map(fn ($point) => $point->executionCount, $trendPoints);
// Simple linear regression slope calculation
$n = count($trendPoints);
$sumX = array_sum($xValues);
$sumY = array_sum($yValues);
$sumXY = 0;
$sumXX = 0;
for ($i = 0; $i < $n; $i++) {
$sumXY += $xValues[$i] * $yValues[$i];
$sumXX += $xValues[$i] * $xValues[$i];
}
$denominator = $n * $sumXX - $sumX * $sumX;
if ($denominator == 0) {
return 0.0;
}
return ($n * $sumXY - $sumX * $sumY) / $denominator;
}
private function calculateTrendStrength(array $trendPoints): float
{
if (count($trendPoints) < 2) {
return 0.0;
}
$values = array_map(fn ($point) => $point->executionCount, $trendPoints);
$mean = array_sum($values) / count($values);
// Calculate coefficient of variation
$variance = 0;
foreach ($values as $value) {
$variance += ($value - $mean) ** 2;
}
$variance /= count($values);
$stdDev = sqrt($variance);
if ($mean == 0) {
return 0.0;
}
$coefficientOfVariation = $stdDev / $mean;
// Convert to 0-1 scale where higher variation = stronger trend
return min(1.0, $coefficientOfVariation);
}
}

View File

@@ -0,0 +1,66 @@
<?php
declare(strict_types=1);
namespace App\Framework\Console\Analytics\ValueObjects;
use App\Framework\Core\ValueObjects\Duration;
use App\Framework\Core\ValueObjects\Percentage;
/**
* Value object representing command popularity ranking
*/
final readonly class CommandPopularity
{
public function __construct(
public string $commandName,
public int $rank,
public int $totalExecutions,
public Percentage $usagePercentage,
public Duration $averageExecutionTime,
public Percentage $successRate,
public ?string $category = null
) {
}
public function isPopular(): bool
{
return $this->rank <= 10 || $this->usagePercentage->getValue() >= 5.0;
}
public function isRare(): bool
{
return $this->usagePercentage->getValue() < 0.5;
}
public function getPopularityLevel(): string
{
$usage = $this->usagePercentage->getValue();
return match (true) {
$usage >= 20 => 'very_high',
$usage >= 10 => 'high',
$usage >= 5 => 'medium',
$usage >= 1 => 'low',
default => 'very_low'
};
}
public function toArray(): array
{
return [
'command_name' => $this->commandName,
'rank' => $this->rank,
'total_executions' => $this->totalExecutions,
'usage_percentage' => $this->usagePercentage->format(2),
'usage_percentage_value' => $this->usagePercentage->getValue(),
'average_execution_time' => $this->averageExecutionTime->toHumanReadable(),
'success_rate' => $this->successRate->format(1),
'success_rate_value' => $this->successRate->getValue(),
'category' => $this->category,
'popularity_level' => $this->getPopularityLevel(),
'is_popular' => $this->isPopular(),
'is_rare' => $this->isRare(),
];
}
}

View File

@@ -0,0 +1,79 @@
<?php
declare(strict_types=1);
namespace App\Framework\Console\Analytics\ValueObjects;
use App\Framework\Console\ExitCode;
use App\Framework\Core\ValueObjects\Duration;
use DateTimeImmutable;
/**
* Value object representing a single command usage metric
*/
final readonly class CommandUsageMetric
{
public function __construct(
public string $commandName,
public DateTimeImmutable $executedAt,
public Duration $executionTime,
public ExitCode $exitCode,
public int $argumentCount,
public ?string $userId = null,
public ?array $metadata = null
) {
}
public function isSuccessful(): bool
{
return $this->exitCode === ExitCode::SUCCESS;
}
public function toArray(): array
{
return [
'command_name' => $this->commandName,
'executed_at' => $this->executedAt->format('Y-m-d H:i:s'),
'execution_time_ms' => $this->executionTime->toMilliseconds(),
'execution_time_seconds' => $this->executionTime->toSeconds(),
'execution_time_human' => $this->executionTime->toHumanReadable(),
'exit_code' => $this->exitCode->value,
'argument_count' => $this->argumentCount,
'user_id' => $this->userId,
'metadata' => $this->metadata,
'is_successful' => $this->isSuccessful(),
];
}
public static function fromArray(array $data): self
{
return new self(
commandName: $data['command_name'],
executedAt: new DateTimeImmutable($data['executed_at']),
executionTime: Duration::fromMilliseconds((float) $data['execution_time_ms']),
exitCode: ExitCode::from((int) $data['exit_code']),
argumentCount: (int) $data['argument_count'],
userId: $data['user_id'] ?? null,
metadata: $data['metadata'] ?? null
);
}
public static function create(
string $commandName,
Duration $executionTime,
ExitCode $exitCode,
int $argumentCount,
?string $userId = null,
?array $metadata = null
): self {
return new self(
commandName: $commandName,
executedAt: new DateTimeImmutable(),
executionTime: $executionTime,
exitCode: $exitCode,
argumentCount: $argumentCount,
userId: $userId,
metadata: $metadata
);
}
}

View File

@@ -0,0 +1,50 @@
<?php
declare(strict_types=1);
namespace App\Framework\Console\Analytics\ValueObjects;
/**
* Enum for analytics time periods
*/
enum Period: string
{
case HOURLY = 'hourly';
case DAILY = 'daily';
case WEEKLY = 'weekly';
case MONTHLY = 'monthly';
case YEARLY = 'yearly';
public function getDescription(): string
{
return match ($this) {
self::HOURLY => 'Hourly analysis',
self::DAILY => 'Daily analysis',
self::WEEKLY => 'Weekly analysis',
self::MONTHLY => 'Monthly analysis',
self::YEARLY => 'Yearly analysis',
};
}
public function getDateFormat(): string
{
return match ($this) {
self::HOURLY => 'Y-m-d H:00',
self::DAILY => 'Y-m-d',
self::WEEKLY => 'Y-\WW',
self::MONTHLY => 'Y-m',
self::YEARLY => 'Y',
};
}
public function getIntervalSpec(): string
{
return match ($this) {
self::HOURLY => 'PT1H',
self::DAILY => 'P1D',
self::WEEKLY => 'P1W',
self::MONTHLY => 'P1M',
self::YEARLY => 'P1Y',
};
}
}

View File

@@ -0,0 +1,81 @@
<?php
declare(strict_types=1);
namespace App\Framework\Console\Analytics\ValueObjects;
use App\Framework\Core\ValueObjects\Duration;
use App\Framework\Core\ValueObjects\Percentage;
/**
* Value object representing aggregated usage statistics for a command
*/
final readonly class UsageStatistics
{
public function __construct(
public string $commandName,
public int $totalExecutions,
public int $successfulExecutions,
public int $failedExecutions,
public Duration $averageExecutionTime,
public Duration $minExecutionTime,
public Duration $maxExecutionTime,
public ?Duration $medianExecutionTime = null,
public ?array $hourlyDistribution = null,
public ?array $exitCodeDistribution = null
) {
}
public function getSuccessRate(): Percentage
{
if ($this->totalExecutions === 0) {
return Percentage::zero();
}
return Percentage::fromRatio($this->successfulExecutions, $this->totalExecutions);
}
public function getFailureRate(): Percentage
{
if ($this->totalExecutions === 0) {
return Percentage::zero();
}
return Percentage::fromRatio($this->failedExecutions, $this->totalExecutions);
}
public function toArray(): array
{
return [
'command_name' => $this->commandName,
'total_executions' => $this->totalExecutions,
'successful_executions' => $this->successfulExecutions,
'failed_executions' => $this->failedExecutions,
'success_rate' => $this->getSuccessRate()->format(2),
'failure_rate' => $this->getFailureRate()->format(2),
'average_execution_time' => $this->averageExecutionTime->toHumanReadable(),
'average_execution_time_ms' => $this->averageExecutionTime->toMilliseconds(),
'min_execution_time' => $this->minExecutionTime->toHumanReadable(),
'max_execution_time' => $this->maxExecutionTime->toHumanReadable(),
'median_execution_time' => $this->medianExecutionTime?->toHumanReadable(),
'hourly_distribution' => $this->hourlyDistribution,
'exit_code_distribution' => $this->exitCodeDistribution,
];
}
public static function empty(string $commandName): self
{
return new self(
commandName: $commandName,
totalExecutions: 0,
successfulExecutions: 0,
failedExecutions: 0,
averageExecutionTime: Duration::zero(),
minExecutionTime: Duration::zero(),
maxExecutionTime: Duration::zero(),
medianExecutionTime: null,
hourlyDistribution: array_fill(0, 24, 0),
exitCodeDistribution: []
);
}
}

View File

@@ -0,0 +1,94 @@
<?php
declare(strict_types=1);
namespace App\Framework\Console\Analytics\ValueObjects;
use App\Framework\Core\ValueObjects\Duration;
use App\Framework\Core\ValueObjects\Percentage;
use DateTimeImmutable;
/**
* Value object representing usage trends over time
*/
final readonly class UsageTrend
{
public function __construct(
public string $commandName,
public Period $period,
public DateTimeImmutable $startDate,
public DateTimeImmutable $endDate,
public array $dataPoints, // Array of UsageTrendPoint
public float $trendDirection, // Positive = increasing, negative = decreasing
public float $trendStrength // 0-1, how strong the trend is
) {
}
public function isIncreasing(): bool
{
return $this->trendDirection > 0;
}
public function isDecreasing(): bool
{
return $this->trendDirection < 0;
}
public function isStable(): bool
{
return abs($this->trendDirection) < 0.1;
}
public function getTrendDescription(): string
{
if ($this->isStable()) {
return 'stable';
}
$strength = $this->trendStrength > 0.7 ? 'strongly' :
($this->trendStrength > 0.4 ? 'moderately' : 'slightly');
return $this->isIncreasing() ? "$strength increasing" : "$strength decreasing";
}
public function toArray(): array
{
return [
'command_name' => $this->commandName,
'period' => $this->period->value,
'period_description' => $this->period->getDescription(),
'start_date' => $this->startDate->format('Y-m-d'),
'end_date' => $this->endDate->format('Y-m-d'),
'data_points' => array_map(fn ($point) => $point->toArray(), $this->dataPoints),
'trend_direction' => $this->trendDirection,
'trend_strength' => $this->trendStrength,
'trend_description' => $this->getTrendDescription(),
];
}
}
/**
* Single data point in a usage trend
*/
final readonly class UsageTrendPoint
{
public function __construct(
public DateTimeImmutable $date,
public int $executionCount,
public Duration $averageExecutionTime,
public Percentage $successRate
) {
}
public function toArray(): array
{
return [
'date' => $this->date->format('Y-m-d'),
'execution_count' => $this->executionCount,
'average_execution_time' => $this->averageExecutionTime->toHumanReadable(),
'average_execution_time_ms' => $this->averageExecutionTime->toMilliseconds(),
'success_rate' => $this->successRate->format(2),
'success_rate_value' => $this->successRate->getValue(),
];
}
}