storing analytics data

This commit is contained in:
2026-02-27 09:46:51 +01:00
parent 15b7b77d20
commit f0cca76eb3
57 changed files with 3478 additions and 466 deletions

View File

@@ -0,0 +1,44 @@
<?php
declare(strict_types=1);
namespace App\Console\Commands;
use App\Services\ArtworkStatsService;
use Illuminate\Console\Command;
/**
* Drain the Redis artwork-stat delta queue into MySQL.
*
* The ArtworkStatsService::incrementViews/Downloads methods push compressed
* delta payloads to a Redis list (`artwork_stats:deltas`) when Redis is
* available. This command drains that queue by applying each delta to the
* artwork_stats table via applyDelta().
*
* Designed to run every 5 minutes so counters stay reasonably fresh while
* keeping MySQL write pressure low. If Redis is unavailable the command exits
* immediately without error the service already fell back to direct DB
* writes in that case.
*
* Usage:
* php artisan skinbase:flush-redis-stats
* php artisan skinbase:flush-redis-stats --max=500
*/
class FlushRedisStatsCommand extends Command
{
protected $signature = 'skinbase:flush-redis-stats {--max=1000 : Maximum deltas to process per run}';
protected $description = 'Drain Redis artwork stat delta queue into MySQL';
public function handle(ArtworkStatsService $service): int
{
$max = (int) $this->option('max');
$processed = $service->processPendingFromRedis($max);
if ($this->getOutput()->isVerbose()) {
$this->info("Processed {$processed} artwork-stat delta(s) from Redis.");
}
return self::SUCCESS;
}
}

View File

@@ -0,0 +1,42 @@
<?php
declare(strict_types=1);
namespace App\Console\Commands;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\DB;
/**
* Delete artwork_view_events rows older than N days.
*
* The view event log grows ~proportionally to site traffic. Rows beyond the
* retention window are no longer useful for trending (which looks back ≤7
* days) or for computing "recently viewed" lists in the UI.
*
* Default retention is 90 days long enough for analytics queries and user
* history pages, short enough to keep the table from growing unbounded.
*
* Usage:
* php artisan skinbase:prune-view-events
* php artisan skinbase:prune-view-events --days=30
*/
class PruneViewEventsCommand extends Command
{
protected $signature = 'skinbase:prune-view-events {--days=90 : Delete events older than this many days}';
protected $description = 'Delete artwork_view_events rows older than N days';
public function handle(): int
{
$days = (int) $this->option('days');
$cutoff = now()->subDays($days);
$deleted = DB::table('artwork_view_events')
->where('viewed_at', '<', $cutoff)
->delete();
$this->info("Pruned {$deleted} view event(s) older than {$days} days (cutoff: {$cutoff}).");
return self::SUCCESS;
}
}

View File

@@ -0,0 +1,57 @@
<?php
declare(strict_types=1);
namespace App\Console\Commands;
use App\Services\TrendingService;
use Illuminate\Console\Command;
/**
* php artisan skinbase:recalculate-trending [--period=24h|7d] [--chunk=1000] [--skip-index]
*/
class RecalculateTrendingCommand extends Command
{
protected $signature = 'skinbase:recalculate-trending
{--period=7d : Period to recalculate (24h or 7d). Use "all" to run both.}
{--chunk=1000 : DB chunk size}
{--skip-index : Skip dispatching Meilisearch re-index jobs}';
protected $description = 'Recalculate trending scores for artworks and sync to Meilisearch';
public function __construct(private readonly TrendingService $trending)
{
parent::__construct();
}
public function handle(): int
{
$period = (string) $this->option('period');
$chunkSize = (int) $this->option('chunk');
$skipIndex = (bool) $this->option('skip-index');
$periods = $period === 'all' ? ['24h', '7d'] : [$period];
foreach ($periods as $p) {
if (! in_array($p, ['24h', '7d'], true)) {
$this->error("Invalid period '{$p}'. Use 24h, 7d, or all.");
return self::FAILURE;
}
$this->info("Recalculating trending ({$p}) …");
$start = microtime(true);
$updated = $this->trending->recalculate($p, $chunkSize);
$elapsed = round(microtime(true) - $start, 2);
$this->info("{$updated} artworks updated in {$elapsed}s");
if (! $skipIndex) {
$this->info(" Dispatching Meilisearch index jobs …");
$this->trending->syncToSearchIndex($p);
$this->info(" ✓ Index jobs dispatched");
}
}
return self::SUCCESS;
}
}

View File

@@ -0,0 +1,97 @@
<?php
declare(strict_types=1);
namespace App\Console\Commands;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Log;
/**
* php artisan skinbase:reset-windowed-stats --period=24h|7d
*
* Resets / recomputes the sliding-window stats columns in artwork_stats:
*
* views_24h / views_7d
* Zeroed on each reset because we have no per-view event log.
* Artworks re-accumulate from the next view event onward.
* (Low-traffic reset window: 03:30 means minimal trending disruption.)
*
* downloads_24h / downloads_7d
* Recomputed accurately from the artwork_downloads event log.
* A single bulk UPDATE with a correlated COUNT() is safe here because
* it runs once nightly/weekly, not in the hot path.
*
* Scheduled in routes/console.php:
* --period=24h daily at 03:30
* --period=7d weekly (Monday) at 03:30
*/
class ResetWindowedStatsCommand extends Command
{
protected $signature = 'skinbase:reset-windowed-stats
{--period=24h : Window to reset: 24h or 7d}';
protected $description = 'Reset windowed view/download counters in artwork_stats';
public function handle(): int
{
$period = (string) $this->option('period');
if (! in_array($period, ['24h', '7d'], true)) {
$this->error("Invalid period '{$period}'. Use 24h or 7d.");
return self::FAILURE;
}
[$viewsCol, $downloadsCol, $cutoff] = match ($period) {
'24h' => ['views_24h', 'downloads_24h', now()->subDay()],
default => ['views_7d', 'downloads_7d', now()->subDays(7)],
};
$start = microtime(true);
// ── 1. Zero the views window column ──────────────────────────────────
// We have no per-view event log, so we reset the accumulator.
$viewsReset = DB::table('artwork_stats')->update([$viewsCol => 0]);
// ── 2. Recompute downloads window from the event log ─────────────────
// artwork_downloads has created_at, so each row's window is accurate.
// Chunked PHP loop avoids MySQL-only functions (GREATEST, INTERVAL)
// so this command works in both MySQL (production) and SQLite (tests).
$downloadsRecomputed = 0;
DB::table('artwork_stats')
->orderBy('artwork_id')
->chunk(1000, function ($rows) use ($downloadsCol, $cutoff, &$downloadsRecomputed): void {
foreach ($rows as $row) {
$count = DB::table('artwork_downloads')
->where('artwork_id', $row->artwork_id)
->where('created_at', '>=', $cutoff)
->count();
DB::table('artwork_stats')
->where('artwork_id', $row->artwork_id)
->update([$downloadsCol => max(0, $count)]);
$downloadsRecomputed++;
}
});
$elapsed = round(microtime(true) - $start, 2);
$this->info("Period: {$period}");
$this->info(" {$viewsCol}: zeroed {$viewsReset} rows");
$this->info(" {$downloadsCol}: recomputed {$downloadsRecomputed} rows ({$elapsed}s)");
Log::info('ResetWindowedStats complete', [
'period' => $period,
'views_col' => $viewsCol,
'views_rows_reset' => $viewsReset,
'downloads_col' => $downloadsCol,
'downloads_recomputed' => $downloadsRecomputed,
'elapsed_s' => $elapsed,
]);
return self::SUCCESS;
}
}