first commit from hostslim
This commit is contained in:
@@ -0,0 +1,143 @@
|
||||
<?php
|
||||
|
||||
namespace App\Console\Commands;
|
||||
|
||||
use App\Models\AnalyticsSession;
|
||||
use App\Models\DailyAggregate;
|
||||
use App\Models\PageDailyAggregate;
|
||||
use App\Models\PageView;
|
||||
use App\Models\Website;
|
||||
use Illuminate\Console\Command;
|
||||
use Illuminate\Support\Carbon;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
class AggregateAnalytics extends Command
|
||||
{
|
||||
protected $signature = 'analytics:aggregate
|
||||
{--date= : The date to aggregate (YYYY-MM-DD). Defaults to yesterday.}
|
||||
{--website= : Aggregate only a specific website ID.}';
|
||||
|
||||
protected $description = 'Roll up raw analytics data into daily aggregate tables';
|
||||
|
||||
public function handle(): int
|
||||
{
|
||||
$date = $this->option('date')
|
||||
? Carbon::parse($this->option('date'))
|
||||
: Carbon::yesterday();
|
||||
|
||||
if ($date->isToday()) {
|
||||
$this->error('Cannot aggregate today\'s data — it is still being collected.');
|
||||
|
||||
return self::FAILURE;
|
||||
}
|
||||
|
||||
$websites = $this->option('website')
|
||||
? Website::where('id', $this->option('website'))->get()
|
||||
: Website::all();
|
||||
|
||||
if ($websites->isEmpty()) {
|
||||
$this->warn('No websites found.');
|
||||
|
||||
return self::SUCCESS;
|
||||
}
|
||||
|
||||
$this->info("Aggregating data for {$date->toDateString()} across {$websites->count()} website(s)...");
|
||||
|
||||
$bar = $this->output->createProgressBar($websites->count());
|
||||
$bar->start();
|
||||
|
||||
foreach ($websites as $website) {
|
||||
$this->aggregateForWebsite($website, $date);
|
||||
$bar->advance();
|
||||
}
|
||||
|
||||
$bar->finish();
|
||||
$this->newLine();
|
||||
$this->info('Aggregation complete.');
|
||||
|
||||
return self::SUCCESS;
|
||||
}
|
||||
|
||||
private function aggregateForWebsite(Website $website, Carbon $date): void
|
||||
{
|
||||
$dayStart = $date->copy()->startOfDay();
|
||||
$dayEnd = $date->copy()->endOfDay();
|
||||
|
||||
DB::transaction(function () use ($website, $date, $dayStart, $dayEnd) {
|
||||
$this->aggregateDaily($website, $date, $dayStart, $dayEnd);
|
||||
$this->aggregatePageDaily($website, $date, $dayStart, $dayEnd);
|
||||
});
|
||||
}
|
||||
|
||||
private function aggregateDaily(Website $website, Carbon $date, Carbon $dayStart, Carbon $dayEnd): void
|
||||
{
|
||||
$visitors = PageView::where('website_id', $website->id)
|
||||
->whereBetween('viewed_at', [$dayStart, $dayEnd])
|
||||
->distinct('visitor_id')
|
||||
->count('visitor_id');
|
||||
|
||||
$sessions = AnalyticsSession::where('website_id', $website->id)
|
||||
->whereBetween('started_at', [$dayStart, $dayEnd])
|
||||
->count();
|
||||
|
||||
$pageViews = PageView::where('website_id', $website->id)
|
||||
->whereBetween('viewed_at', [$dayStart, $dayEnd])
|
||||
->count();
|
||||
|
||||
$bounceCount = AnalyticsSession::where('website_id', $website->id)
|
||||
->whereBetween('started_at', [$dayStart, $dayEnd])
|
||||
->where('is_bounce', true)
|
||||
->count();
|
||||
|
||||
$totalDuration = (int) AnalyticsSession::where('website_id', $website->id)
|
||||
->whereBetween('started_at', [$dayStart, $dayEnd])
|
||||
->sum('duration');
|
||||
|
||||
DailyAggregate::updateOrCreate(
|
||||
['website_id' => $website->id, 'date' => $date->toDateString()],
|
||||
compact('visitors', 'sessions', 'page_views', 'bounce_count', 'total_duration'),
|
||||
);
|
||||
}
|
||||
|
||||
private function aggregatePageDaily(Website $website, Carbon $date, Carbon $dayStart, Carbon $dayEnd): void
|
||||
{
|
||||
$pages = PageView::where('website_id', $website->id)
|
||||
->whereBetween('viewed_at', [$dayStart, $dayEnd])
|
||||
->select(
|
||||
'path',
|
||||
DB::raw('COUNT(*) as views'),
|
||||
DB::raw('COUNT(DISTINCT visitor_id) as unique_views'),
|
||||
DB::raw('COALESCE(SUM(time_on_page), 0) as total_time_on_page'),
|
||||
)
|
||||
->groupBy('path')
|
||||
->get();
|
||||
|
||||
$bouncePaths = AnalyticsSession::where('website_id', $website->id)
|
||||
->whereBetween('started_at', [$dayStart, $dayEnd])
|
||||
->where('is_bounce', true)
|
||||
->whereNotNull('entry_page')
|
||||
->select('entry_page as path', DB::raw('COUNT(*) as bounces'))
|
||||
->groupBy('entry_page')
|
||||
->pluck('bounces', 'path');
|
||||
|
||||
PageDailyAggregate::where('website_id', $website->id)
|
||||
->where('date', $date->toDateString())
|
||||
->delete();
|
||||
|
||||
$rows = $pages->map(fn ($page) => [
|
||||
'website_id' => $website->id,
|
||||
'date' => $date->toDateString(),
|
||||
'path' => $page->path,
|
||||
'views' => $page->views,
|
||||
'unique_views' => $page->unique_views,
|
||||
'total_time_on_page' => $page->total_time_on_page,
|
||||
'bounces' => $bouncePaths->get($page->path, 0),
|
||||
'created_at' => now(),
|
||||
'updated_at' => now(),
|
||||
])->toArray();
|
||||
|
||||
foreach (array_chunk($rows, 500) as $chunk) {
|
||||
PageDailyAggregate::insert($chunk);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,116 @@
|
||||
<?php
|
||||
|
||||
namespace App\Console\Commands;
|
||||
|
||||
use App\Mail\AlertTriggered;
|
||||
use App\Models\Alert;
|
||||
use App\Services\AnalyticsQueryService;
|
||||
use Illuminate\Console\Command;
|
||||
use Illuminate\Support\Carbon;
|
||||
use Illuminate\Support\Facades\Mail;
|
||||
|
||||
class CheckAlerts extends Command
|
||||
{
|
||||
protected $signature = 'analytics:check-alerts';
|
||||
|
||||
protected $description = 'Evaluate active alerts and trigger notifications';
|
||||
|
||||
public function handle(): int
|
||||
{
|
||||
$alerts = Alert::with('website.user')
|
||||
->where('is_active', true)
|
||||
->get();
|
||||
|
||||
$triggered = 0;
|
||||
|
||||
foreach ($alerts as $alert) {
|
||||
$currentValue = $this->getCurrentValue($alert);
|
||||
$shouldTrigger = $this->evaluate($alert, $currentValue);
|
||||
|
||||
if ($shouldTrigger) {
|
||||
$this->trigger($alert, $currentValue);
|
||||
$triggered++;
|
||||
}
|
||||
}
|
||||
|
||||
$this->info("Checked {$alerts->count()} alerts, triggered {$triggered}.");
|
||||
|
||||
return self::SUCCESS;
|
||||
}
|
||||
|
||||
private function getCurrentValue(Alert $alert): float
|
||||
{
|
||||
$to = Carbon::today();
|
||||
$from = $alert->period === 'hourly'
|
||||
? Carbon::now()->subHour()
|
||||
: Carbon::yesterday();
|
||||
|
||||
$service = AnalyticsQueryService::for($alert->website, $from, $to);
|
||||
$summary = $service->summary();
|
||||
|
||||
return match ($alert->metric) {
|
||||
'page_views' => $summary['page_views'],
|
||||
'visitors' => $summary['visitors'],
|
||||
'sessions' => $summary['sessions'],
|
||||
'bounce_rate' => $summary['bounce_rate'],
|
||||
default => 0,
|
||||
};
|
||||
}
|
||||
|
||||
private function evaluate(Alert $alert, float $currentValue): bool
|
||||
{
|
||||
return match ($alert->condition) {
|
||||
'above' => $currentValue > $alert->threshold,
|
||||
'below' => $currentValue < $alert->threshold,
|
||||
'change_up' => $this->percentageChange($alert) > $alert->threshold,
|
||||
'change_down' => $this->percentageChange($alert) < -$alert->threshold,
|
||||
default => false,
|
||||
};
|
||||
}
|
||||
|
||||
private function percentageChange(Alert $alert): float
|
||||
{
|
||||
$to = Carbon::today();
|
||||
$from = Carbon::yesterday();
|
||||
$prevFrom = Carbon::yesterday()->subDay();
|
||||
$prevTo = Carbon::yesterday();
|
||||
|
||||
$current = $this->getValueForPeriod($alert, $from, $to);
|
||||
$previous = $this->getValueForPeriod($alert, $prevFrom, $prevTo);
|
||||
|
||||
if ($previous == 0) {
|
||||
return $current > 0 ? 100 : 0;
|
||||
}
|
||||
|
||||
return (($current - $previous) / $previous) * 100;
|
||||
}
|
||||
|
||||
private function getValueForPeriod(Alert $alert, Carbon $from, Carbon $to): float
|
||||
{
|
||||
$service = AnalyticsQueryService::for($alert->website, $from, $to);
|
||||
$summary = $service->summary();
|
||||
|
||||
return $summary[$alert->metric] ?? 0;
|
||||
}
|
||||
|
||||
private function trigger(Alert $alert, float $currentValue): void
|
||||
{
|
||||
$conditionLabel = Alert::$conditions[$alert->condition] ?? $alert->condition;
|
||||
$metricLabel = Alert::$metrics[$alert->metric] ?? $alert->metric;
|
||||
|
||||
$message = "{$metricLabel} {$conditionLabel} {$alert->threshold} (current: {$currentValue}) for {$alert->website->name}";
|
||||
|
||||
$notification = $alert->notifications()->create([
|
||||
'value' => $currentValue,
|
||||
'message' => $message,
|
||||
]);
|
||||
|
||||
$alert->update(['last_triggered_at' => now()]);
|
||||
|
||||
if (in_array($alert->channel, ['email', 'both'])) {
|
||||
Mail::to($alert->website->user)->send(new AlertTriggered($alert, $notification));
|
||||
}
|
||||
|
||||
$this->line(" → Triggered: {$alert->name} — {$message}");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
<?php
|
||||
|
||||
namespace App\Console\Commands;
|
||||
|
||||
use App\Models\AnalyticsSession;
|
||||
use App\Models\Event;
|
||||
use App\Models\PageView;
|
||||
use App\Models\Website;
|
||||
use Illuminate\Console\Command;
|
||||
use Illuminate\Support\Carbon;
|
||||
|
||||
class PurgeAnalytics extends Command
|
||||
{
|
||||
protected $signature = 'analytics:purge
|
||||
{--website= : Purge only a specific website ID.}
|
||||
{--force : Skip confirmation prompt.}';
|
||||
|
||||
protected $description = 'Delete raw analytics data older than each website\'s retention period';
|
||||
|
||||
private const DEFAULT_RETENTION_DAYS = 365;
|
||||
|
||||
public function handle(): int
|
||||
{
|
||||
$websites = $this->option('website')
|
||||
? Website::where('id', $this->option('website'))->get()
|
||||
: Website::all();
|
||||
|
||||
if ($websites->isEmpty()) {
|
||||
$this->warn('No websites found.');
|
||||
|
||||
return self::SUCCESS;
|
||||
}
|
||||
|
||||
$summary = $websites->map(fn (Website $w) => [
|
||||
'id' => $w->id,
|
||||
'name' => $w->name,
|
||||
'retention' => $this->getRetentionDays($w) . ' days',
|
||||
'cutoff' => Carbon::today()->subDays($this->getRetentionDays($w))->toDateString(),
|
||||
])->toArray();
|
||||
|
||||
$this->table(['ID', 'Website', 'Retention', 'Delete before'], $summary);
|
||||
|
||||
if (! $this->option('force') && ! $this->confirm('Proceed with purging old raw data?')) {
|
||||
$this->info('Cancelled.');
|
||||
|
||||
return self::SUCCESS;
|
||||
}
|
||||
|
||||
foreach ($websites as $website) {
|
||||
$this->purgeForWebsite($website);
|
||||
}
|
||||
|
||||
$this->info('Purge complete.');
|
||||
|
||||
return self::SUCCESS;
|
||||
}
|
||||
|
||||
private function purgeForWebsite(Website $website): void
|
||||
{
|
||||
$retentionDays = $this->getRetentionDays($website);
|
||||
$cutoff = Carbon::today()->subDays($retentionDays);
|
||||
|
||||
$this->info("Purging {$website->name} (data before {$cutoff->toDateString()})...");
|
||||
|
||||
$pvDeleted = PageView::where('website_id', $website->id)
|
||||
->where('viewed_at', '<', $cutoff)
|
||||
->delete();
|
||||
|
||||
$evDeleted = Event::where('website_id', $website->id)
|
||||
->where('occurred_at', '<', $cutoff)
|
||||
->delete();
|
||||
|
||||
$sessDeleted = AnalyticsSession::where('website_id', $website->id)
|
||||
->where('started_at', '<', $cutoff)
|
||||
->delete();
|
||||
|
||||
$this->line(" Deleted: {$pvDeleted} page views, {$evDeleted} events, {$sessDeleted} sessions");
|
||||
}
|
||||
|
||||
private function getRetentionDays(Website $website): int
|
||||
{
|
||||
return $website->settings['data_retention_days'] ?? self::DEFAULT_RETENTION_DAYS;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user