Files
SkinbaseNova/app/Console/Commands/AcademyAnalyticsHealthCommand.php

184 lines
8.4 KiB
PHP

<?php
declare(strict_types=1);
namespace App\Console\Commands;
use App\Models\AcademyContentMetricDaily;
use App\Models\AcademyEvent;
use App\Models\AcademyLike;
use App\Models\AcademySave;
use App\Models\AcademySearchLog;
use App\Models\AcademyUserProgress;
use App\Support\AcademyAnalytics\AcademyAnalyticsEventType;
use Illuminate\Console\Command;
use Illuminate\Support\Carbon;
use Illuminate\Support\Facades\Schema;
final class AcademyAnalyticsHealthCommand extends Command
{
protected $signature = 'academy:analytics-health {--json : Output machine-readable JSON}';
protected $description = 'Inspect Academy analytics collection health, rollup freshness, and privacy safeguards';
private const RETENTION_DAYS = 180;
public function handle(): int
{
$report = $this->buildReport();
if ((bool) $this->option('json')) {
$this->line(json_encode($report, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES) ?: '{}');
return self::SUCCESS;
}
$this->line('Academy Analytics Health Check');
$this->line('==============================');
$this->newLine();
$this->line(sprintf('Events last 24h: %d', $report['events_last_24h']));
$this->line(sprintf('Events last 7d: %d', $report['events_last_7d']));
$this->line(sprintf('Latest event: %s', $report['latest_event_at'] ?? 'none'));
$this->line(sprintf('Latest rollup date: %s', $report['latest_rollup_date'] ?? 'none'));
$this->line(sprintf('Search logs: %d', $report['search_logs']));
$this->line(sprintf('Search clicks: %d', $report['search_clicks']));
$this->line(sprintf('Likes: %d', $report['likes']));
$this->line(sprintf('Saves: %d', $report['saves']));
$this->line(sprintf('Progress records: %d', $report['progress_records']));
$this->line(sprintf('Prompt copies: %d', $report['prompt_copies']));
$this->line(sprintf('Upgrade clicks: %d', $report['upgrade_clicks']));
$this->line(sprintf('Human events: %d', $report['human_events']));
$this->line(sprintf('Bot/admin events: %d', $report['bot_admin_events']));
$this->line(sprintf('Recent daily metric rows: %d', $report['recent_daily_metric_rows']));
$this->line(sprintf('Raw IP storage detected: %s', $report['raw_ip_storage_detected'] ? 'yes' : 'no'));
$this->line(sprintf('Events older than retention: %d', $report['events_older_than_retention']));
$this->newLine();
foreach ($report['warnings'] as $warning) {
$this->warn(sprintf('WARNING: %s', $warning));
}
$this->info(sprintf('Status: %s', $report['status']));
return self::SUCCESS;
}
/**
* @return array<string, mixed>
*/
private function buildReport(): array
{
$now = now();
$last24Hours = $now->copy()->subDay();
$last7Days = $now->copy()->subDays(7);
$retentionCutoff = $now->copy()->subDays(self::RETENTION_DAYS);
$warnings = [];
$rawIpStorageDetected = $this->rawIpStorageDetected();
$eventsTableExists = Schema::hasTable('academy_events');
$metricsTableExists = Schema::hasTable('academy_content_metrics_daily');
$searchLogsTableExists = Schema::hasTable('academy_search_logs');
$likesTableExists = Schema::hasTable('academy_likes');
$savesTableExists = Schema::hasTable('academy_saves');
$progressTableExists = Schema::hasTable('academy_user_progress');
$latestEvent = $eventsTableExists ? AcademyEvent::query()->latest('occurred_at')->value('occurred_at') : null;
$latestRollup = $metricsTableExists ? AcademyContentMetricDaily::query()->latest('date')->value('date') : null;
$searchLogCount = $searchLogsTableExists ? AcademySearchLog::query()->count() : 0;
$searchClickCount = $searchLogsTableExists ? AcademySearchLog::query()->whereNotNull('clicked_content_id')->count() : 0;
$eventsOlderThanRetention = $eventsTableExists ? AcademyEvent::query()->where('occurred_at', '<', $retentionCutoff)->count() : 0;
$recentDailyMetricRows = $metricsTableExists
? AcademyContentMetricDaily::query()->whereBetween('date', [$now->copy()->subDays(6)->toDateString(), $now->toDateString()])->count()
: 0;
$report = [
'events_last_24h' => $eventsTableExists ? AcademyEvent::query()->where('occurred_at', '>=', $last24Hours)->count() : 0,
'events_last_7d' => $eventsTableExists ? AcademyEvent::query()->where('occurred_at', '>=', $last7Days)->count() : 0,
'latest_event_at' => $latestEvent ? Carbon::parse((string) $latestEvent)->toDateTimeString() : null,
'latest_rollup_date' => $latestRollup ? Carbon::parse((string) $latestRollup)->toDateString() : null,
'search_logs' => $searchLogCount,
'search_clicks' => $searchClickCount,
'likes' => $likesTableExists ? AcademyLike::query()->count() : 0,
'saves' => $savesTableExists ? AcademySave::query()->count() : 0,
'progress_records' => $progressTableExists ? AcademyUserProgress::query()->count() : 0,
'prompt_copies' => $eventsTableExists ? AcademyEvent::query()->where('event_type', AcademyAnalyticsEventType::PROMPT_COPY)->count() : 0,
'upgrade_clicks' => $eventsTableExists ? AcademyEvent::query()->where('event_type', AcademyAnalyticsEventType::UPGRADE_CLICK)->count() : 0,
'human_events' => $eventsTableExists ? AcademyEvent::query()->where('is_bot', false)->where('is_admin', false)->where('is_suspicious', false)->count() : 0,
'bot_admin_events' => $eventsTableExists ? AcademyEvent::query()->where(function ($query): void {
$query->where('is_bot', true)->orWhere('is_admin', true)->orWhere('is_suspicious', true);
})->count() : 0,
'raw_ip_storage_detected' => $rawIpStorageDetected,
'events_older_than_retention' => $eventsOlderThanRetention,
'recent_daily_metric_rows' => $recentDailyMetricRows,
'retention_days' => self::RETENTION_DAYS,
'tables_present' => [
'academy_events' => $eventsTableExists,
'academy_content_metrics_daily' => $metricsTableExists,
'academy_search_logs' => $searchLogsTableExists,
'academy_likes' => $likesTableExists,
'academy_saves' => $savesTableExists,
'academy_user_progress' => $progressTableExists,
],
'warnings' => [],
'status' => 'OK',
];
foreach ($report['tables_present'] as $table => $present) {
if (! $present) {
$warnings[] = sprintf('Analytics table %s is missing.', $table);
}
}
if ($report['events_last_24h'] === 0) {
$warnings[] = 'No events received in last 24 hours.';
}
if ($report['events_last_7d'] === 0) {
$warnings[] = 'No events received in last 7 days.';
}
if ($report['latest_rollup_date'] === null) {
$warnings[] = 'No rollup rows exist yet.';
} elseif ($report['latest_rollup_date'] !== $now->toDateString()) {
$warnings[] = 'Rollup has not run for today.';
}
if ($searchLogCount > 0 && $searchClickCount === 0) {
$warnings[] = 'Search clicks are zero although search logs exist.';
}
if ($eventsOlderThanRetention > 0) {
$warnings[] = 'Raw events older than configured retention period exist.';
}
if ($recentDailyMetricRows === 0) {
$warnings[] = 'No daily metrics exist for recent days.';
}
if ($rawIpStorageDetected) {
$warnings[] = 'Raw IP storage indicators were found in Academy analytics tables.';
}
$report['warnings'] = $warnings;
$report['status'] = $warnings === [] ? 'OK' : 'WARNING';
return $report;
}
private function rawIpStorageDetected(): bool
{
foreach (['academy_events', 'academy_search_logs', 'academy_content_metrics_daily', 'academy_likes', 'academy_saves', 'academy_user_progress'] as $table) {
if (! Schema::hasTable($table)) {
continue;
}
foreach (['ip', 'ip_address', 'visitor_ip', 'raw_ip', 'remote_addr'] as $column) {
if (Schema::hasColumn($table, $column)) {
return true;
}
}
}
return false;
}
}