diff --git a/PLAN.md b/PLAN.md index 14b9a02..67ac2b1 100644 --- a/PLAN.md +++ b/PLAN.md @@ -106,14 +106,14 @@ The `CollectController` currently does synchronous DB writes. This won't scale u Raw event tables grow fast. Pre-aggregate data for fast dashboard loads. -- [ ] Create `daily_aggregates` table (migration + model): +- [x] Create `daily_aggregates` table (migration + model): - `website_id`, `date`, `visitors`, `sessions`, `page_views`, `bounce_count`, `total_duration` -- [ ] Create `page_daily_aggregates` table: +- [x] Create `page_daily_aggregates` table: - `website_id`, `date`, `path`, `views`, `unique_views`, `total_time_on_page`, `bounces` -- [ ] Create a scheduled Artisan command (`analytics:aggregate`) that rolls up yesterday's raw data into aggregate tables — run daily via `schedule:run` -- [ ] Refactor dashboard and report queries to read from aggregates for past days, and query raw tables only for "today" -- [ ] Add database indexes review — ensure all date-range + website_id queries are covered -- [ ] Consider adding a `data_retention_days` setting per website and a cleanup command to purge old raw rows +- [x] Create a scheduled Artisan command (`analytics:aggregate`) that rolls up yesterday's raw data into aggregate tables — run daily via `schedule:run` +- [x] Refactor dashboard and report queries to read from aggregates for past days, and query raw tables only for "today" +- [x] Add database indexes review — ensure all date-range + website_id queries are covered +- [x] Add `data_retention_days` setting per website (stored in `settings` JSON) and a cleanup command (`analytics:purge`) to purge old raw rows --- diff --git a/app/Console/Commands/AggregateAnalytics.php b/app/Console/Commands/AggregateAnalytics.php new file mode 100644 index 0000000..011522c --- /dev/null +++ b/app/Console/Commands/AggregateAnalytics.php @@ -0,0 +1,143 @@ +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); + } + } +} diff --git a/app/Console/Commands/PurgeAnalytics.php b/app/Console/Commands/PurgeAnalytics.php new file mode 100644 index 0000000..3b3828c --- /dev/null +++ b/app/Console/Commands/PurgeAnalytics.php @@ -0,0 +1,84 @@ +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; + } +} diff --git a/app/Models/DailyAggregate.php b/app/Models/DailyAggregate.php new file mode 100644 index 0000000..c472200 --- /dev/null +++ b/app/Models/DailyAggregate.php @@ -0,0 +1,36 @@ + 'date', + 'visitors' => 'integer', + 'sessions' => 'integer', + 'page_views' => 'integer', + 'bounce_count' => 'integer', + 'total_duration' => 'integer', + ]; + } + + public function website(): BelongsTo + { + return $this->belongsTo(Website::class); + } +} diff --git a/app/Models/PageDailyAggregate.php b/app/Models/PageDailyAggregate.php new file mode 100644 index 0000000..e6cda4c --- /dev/null +++ b/app/Models/PageDailyAggregate.php @@ -0,0 +1,35 @@ + 'date', + 'views' => 'integer', + 'unique_views' => 'integer', + 'total_time_on_page' => 'integer', + 'bounces' => 'integer', + ]; + } + + public function website(): BelongsTo + { + return $this->belongsTo(Website::class); + } +} diff --git a/app/Models/Website.php b/app/Models/Website.php index 4bfe905..b6fba31 100644 --- a/app/Models/Website.php +++ b/app/Models/Website.php @@ -73,6 +73,21 @@ class Website extends Model return $this->hasMany(GoalConversion::class); } + public function dailyAggregates(): HasMany + { + return $this->hasMany(DailyAggregate::class); + } + + public function pageDailyAggregates(): HasMany + { + return $this->hasMany(PageDailyAggregate::class); + } + + public function getDataRetentionDaysAttribute(): int + { + return $this->settings['data_retention_days'] ?? 365; + } + public function embedSnippet(): string { $scriptUrl = route('tracking.script', $this->tracking_id); diff --git a/app/Services/AnalyticsQueryService.php b/app/Services/AnalyticsQueryService.php index cd5850d..7e60dae 100644 --- a/app/Services/AnalyticsQueryService.php +++ b/app/Services/AnalyticsQueryService.php @@ -3,6 +3,8 @@ namespace App\Services; use App\Models\AnalyticsSession; +use App\Models\DailyAggregate; +use App\Models\PageDailyAggregate; use App\Models\PageView; use App\Models\Visitor; use App\Models\Website; @@ -12,73 +14,188 @@ use Illuminate\Support\Facades\DB; class AnalyticsQueryService { + private readonly Carbon $todayStart; + public function __construct( private readonly Website $website, private readonly Carbon $from, private readonly Carbon $to, - ) {} + ) { + $this->todayStart = Carbon::today(); + } public static function for(Website $website, Carbon $from, Carbon $to): self { return new self($website, $from->startOfDay(), $to->endOfDay()); } + /** + * Split the requested date range into two parts: + * - "aggregate range": completed days that can be served from aggregate tables + * - "live range": today's partial data that must come from raw tables + * + * Returns [?Carbon $aggFrom, ?Carbon $aggTo, ?Carbon $liveFrom, ?Carbon $liveTo] + */ + private function splitRange(): array + { + $aggFrom = null; + $aggTo = null; + $liveFrom = null; + $liveTo = null; + + $yesterdayEnd = $this->todayStart->copy()->subSecond(); + + if ($this->from->lt($this->todayStart)) { + $aggFrom = $this->from->copy(); + $aggTo = $this->to->lt($this->todayStart) ? $this->to->copy() : $yesterdayEnd; + } + + if ($this->to->gte($this->todayStart)) { + $liveFrom = $this->from->gte($this->todayStart) ? $this->from->copy() : $this->todayStart->copy(); + $liveTo = $this->to->copy(); + } + + return [$aggFrom, $aggTo, $liveFrom, $liveTo]; + } + + private function hasAggregateRange(): bool + { + [$aggFrom] = $this->splitRange(); + + return $aggFrom !== null; + } + + private function hasLiveRange(): bool + { + [,, $liveFrom] = $this->splitRange(); + + return $liveFrom !== null; + } + // ---- Summary Metrics ---- public function totalPageViews(): int { - return PageView::where('website_id', $this->website->id) - ->whereBetween('viewed_at', [$this->from, $this->to]) - ->count(); + $total = 0; + [$aggFrom, $aggTo, $liveFrom, $liveTo] = $this->splitRange(); + + if ($aggFrom) { + $total += (int) DailyAggregate::where('website_id', $this->website->id) + ->whereBetween('date', [$aggFrom->toDateString(), $aggTo->toDateString()]) + ->sum('page_views'); + } + + if ($liveFrom) { + $total += PageView::where('website_id', $this->website->id) + ->whereBetween('viewed_at', [$liveFrom, $liveTo]) + ->count(); + } + + return $total; } public function uniqueVisitors(): int { - return PageView::where('website_id', $this->website->id) - ->whereBetween('viewed_at', [$this->from, $this->to]) - ->distinct('visitor_id') - ->count('visitor_id'); + $total = 0; + [$aggFrom, $aggTo, $liveFrom, $liveTo] = $this->splitRange(); + + if ($aggFrom) { + $total += (int) DailyAggregate::where('website_id', $this->website->id) + ->whereBetween('date', [$aggFrom->toDateString(), $aggTo->toDateString()]) + ->sum('visitors'); + } + + if ($liveFrom) { + $total += PageView::where('website_id', $this->website->id) + ->whereBetween('viewed_at', [$liveFrom, $liveTo]) + ->distinct('visitor_id') + ->count('visitor_id'); + } + + return $total; } public function totalSessions(): int { - return AnalyticsSession::where('website_id', $this->website->id) - ->whereBetween('started_at', [$this->from, $this->to]) - ->count(); + $total = 0; + [$aggFrom, $aggTo, $liveFrom, $liveTo] = $this->splitRange(); + + if ($aggFrom) { + $total += (int) DailyAggregate::where('website_id', $this->website->id) + ->whereBetween('date', [$aggFrom->toDateString(), $aggTo->toDateString()]) + ->sum('sessions'); + } + + if ($liveFrom) { + $total += AnalyticsSession::where('website_id', $this->website->id) + ->whereBetween('started_at', [$liveFrom, $liveTo]) + ->count(); + } + + return $total; } public function bounceRate(): float { - $total = $this->totalSessions(); - if ($total === 0) { + $totalSessions = $this->totalSessions(); + if ($totalSessions === 0) { return 0; } - $bounced = AnalyticsSession::where('website_id', $this->website->id) - ->whereBetween('started_at', [$this->from, $this->to]) - ->where('is_bounce', true) - ->count(); + $bounced = 0; + [$aggFrom, $aggTo, $liveFrom, $liveTo] = $this->splitRange(); - return round(($bounced / $total) * 100, 1); + if ($aggFrom) { + $bounced += (int) DailyAggregate::where('website_id', $this->website->id) + ->whereBetween('date', [$aggFrom->toDateString(), $aggTo->toDateString()]) + ->sum('bounce_count'); + } + + if ($liveFrom) { + $bounced += AnalyticsSession::where('website_id', $this->website->id) + ->whereBetween('started_at', [$liveFrom, $liveTo]) + ->where('is_bounce', true) + ->count(); + } + + return round(($bounced / $totalSessions) * 100, 1); } public function avgSessionDuration(): float { - return round( - (float) AnalyticsSession::where('website_id', $this->website->id) - ->whereBetween('started_at', [$this->from, $this->to]) - ->avg('duration') ?? 0, - ); + $totalSessions = $this->totalSessions(); + if ($totalSessions === 0) { + return 0; + } + + $totalDuration = 0; + [$aggFrom, $aggTo, $liveFrom, $liveTo] = $this->splitRange(); + + if ($aggFrom) { + $totalDuration += (int) DailyAggregate::where('website_id', $this->website->id) + ->whereBetween('date', [$aggFrom->toDateString(), $aggTo->toDateString()]) + ->sum('total_duration'); + } + + if ($liveFrom) { + $totalDuration += (int) AnalyticsSession::where('website_id', $this->website->id) + ->whereBetween('started_at', [$liveFrom, $liveTo]) + ->sum('duration'); + } + + return round($totalDuration / $totalSessions); } public function avgPagesPerSession(): float { - return round( - (float) AnalyticsSession::where('website_id', $this->website->id) - ->whereBetween('started_at', [$this->from, $this->to]) - ->avg('pages_count') ?? 0, - 1, - ); + $totalSessions = $this->totalSessions(); + if ($totalSessions === 0) { + return 0; + } + + $totalPageViews = $this->totalPageViews(); + + return round($totalPageViews / $totalSessions, 1); } public function summary(): array @@ -98,46 +215,91 @@ class AnalyticsQueryService public function visitorsOverTime(): Collection { $days = $this->from->diffInDays($this->to); - $groupFormat = $days <= 2 ? '%Y-%m-%d %H:00' : '%Y-%m-%d'; - $carbonFormat = $days <= 2 ? 'Y-m-d H:00' : 'Y-m-d'; + $isHourly = $days <= 2; + $groupFormat = $isHourly ? '%Y-%m-%d %H:00' : '%Y-%m-%d'; + $carbonFormat = $isHourly ? 'Y-m-d H:00' : 'Y-m-d'; - $pageViews = PageView::where('website_id', $this->website->id) - ->whereBetween('viewed_at', [$this->from, $this->to]) - ->select( - DB::raw("strftime('{$groupFormat}', viewed_at) as period"), - DB::raw('COUNT(*) as page_views'), - DB::raw('COUNT(DISTINCT visitor_id) as visitors'), - ) - ->groupBy('period') - ->orderBy('period') - ->get() - ->keyBy('period'); + [$aggFrom, $aggTo, $liveFrom, $liveTo] = $this->splitRange(); + $aggregated = collect(); + $liveData = collect(); - $sessions = AnalyticsSession::where('website_id', $this->website->id) - ->whereBetween('started_at', [$this->from, $this->to]) - ->select( - DB::raw("strftime('{$groupFormat}', started_at) as period"), - DB::raw('COUNT(*) as sessions'), - ) - ->groupBy('period') - ->orderBy('period') - ->get() - ->keyBy('period'); + if ($aggFrom && ! $isHourly) { + $aggregated = DailyAggregate::where('website_id', $this->website->id) + ->whereBetween('date', [$aggFrom->toDateString(), $aggTo->toDateString()]) + ->select('date as period', 'page_views', 'visitors', 'sessions') + ->get() + ->mapWithKeys(fn ($row) => [ + Carbon::parse($row->period)->format('Y-m-d') => [ + 'page_views' => $row->page_views, + 'visitors' => $row->visitors, + 'sessions' => $row->sessions, + ], + ]); + } + + $liveQueryFrom = ($aggFrom && ! $isHourly) ? ($liveFrom ?? $this->todayStart) : $this->from; + $liveQueryTo = $liveTo ?? $this->to; + $needsLive = $isHourly || $liveFrom; + + if ($needsLive || ! $aggFrom) { + $rawFrom = $isHourly ? $this->from : $liveQueryFrom; + $rawTo = $isHourly ? $this->to : $liveQueryTo; + + $pvRaw = PageView::where('website_id', $this->website->id) + ->whereBetween('viewed_at', [$rawFrom, $rawTo]) + ->select( + DB::raw("strftime('{$groupFormat}', viewed_at) as period"), + DB::raw('COUNT(*) as page_views'), + DB::raw('COUNT(DISTINCT visitor_id) as visitors'), + ) + ->groupBy('period') + ->get() + ->keyBy('period'); + + $sessRaw = AnalyticsSession::where('website_id', $this->website->id) + ->whereBetween('started_at', [$rawFrom, $rawTo]) + ->select( + DB::raw("strftime('{$groupFormat}', started_at) as period"), + DB::raw('COUNT(*) as sessions'), + ) + ->groupBy('period') + ->get() + ->keyBy('period'); + + foreach ($pvRaw as $key => $row) { + $liveData[$key] = [ + 'page_views' => $row->page_views, + 'visitors' => $row->visitors, + 'sessions' => $sessRaw->get($key)?->sessions ?? 0, + ]; + } + + foreach ($sessRaw as $key => $row) { + if (! isset($liveData[$key])) { + $liveData[$key] = [ + 'page_views' => 0, + 'visitors' => 0, + 'sessions' => $row->sessions, + ]; + } + } + } + + $merged = $aggregated->union($liveData); $periods = collect(); $cursor = $this->from->copy(); - $increment = $days <= 2 ? 'addHour' : 'addDay'; + $increment = $isHourly ? 'addHour' : 'addDay'; while ($cursor->lte($this->to)) { $key = $cursor->format($carbonFormat); - $pv = $pageViews->get($key); - $sess = $sessions->get($key); + $data = $merged->get($key); $periods->push([ 'period' => $key, - 'page_views' => $pv ? $pv->page_views : 0, - 'visitors' => $pv ? $pv->visitors : 0, - 'sessions' => $sess ? $sess->sessions : 0, + 'page_views' => $data['page_views'] ?? 0, + 'visitors' => $data['visitors'] ?? 0, + 'sessions' => $data['sessions'] ?? 0, ]); $cursor->$increment(); @@ -295,18 +457,65 @@ class AnalyticsQueryService public function topPages(int $limit = 50): Collection { - return PageView::where('website_id', $this->website->id) - ->whereBetween('viewed_at', [$this->from, $this->to]) - ->select( - 'path', - DB::raw('COUNT(*) as views'), - DB::raw('COUNT(DISTINCT visitor_id) as unique_views'), - DB::raw('AVG(time_on_page) as avg_time'), - ) - ->groupBy('path') - ->orderByDesc('views') - ->limit($limit) - ->get(); + [$aggFrom, $aggTo, $liveFrom, $liveTo] = $this->splitRange(); + $aggData = collect(); + $liveData = collect(); + + if ($aggFrom) { + $aggData = PageDailyAggregate::where('website_id', $this->website->id) + ->whereBetween('date', [$aggFrom->toDateString(), $aggTo->toDateString()]) + ->select( + 'path', + DB::raw('SUM(views) as views'), + DB::raw('SUM(unique_views) as unique_views'), + DB::raw('CASE WHEN SUM(views) > 0 THEN SUM(total_time_on_page) * 1.0 / SUM(views) ELSE 0 END as avg_time'), + ) + ->groupBy('path') + ->get() + ->keyBy('path'); + } + + if ($liveFrom) { + $liveData = PageView::where('website_id', $this->website->id) + ->whereBetween('viewed_at', [$liveFrom, $liveTo]) + ->select( + 'path', + DB::raw('COUNT(*) as views'), + DB::raw('COUNT(DISTINCT visitor_id) as unique_views'), + DB::raw('AVG(time_on_page) as avg_time'), + ) + ->groupBy('path') + ->get() + ->keyBy('path'); + } + + if ($aggData->isEmpty()) { + return $liveData->values()->sortByDesc('views')->take($limit)->values(); + } + + if ($liveData->isEmpty()) { + return $aggData->values()->sortByDesc('views')->take($limit)->values(); + } + + $allPaths = $aggData->keys()->merge($liveData->keys())->unique(); + + return $allPaths->map(function (string $path) use ($aggData, $liveData) { + $agg = $aggData->get($path); + $live = $liveData->get($path); + + $totalViews = ($agg->views ?? 0) + ($live->views ?? 0); + $totalUnique = ($agg->unique_views ?? 0) + ($live->unique_views ?? 0); + $avgTime = $totalViews > 0 + ? (($agg ? $agg->avg_time * $agg->views : 0) + ($live ? $live->avg_time * $live->views : 0)) / $totalViews + : 0; + + return (object) [ + 'path' => $path, + 'views' => $totalViews, + 'unique_views' => $totalUnique, + 'avg_time' => $avgTime, + ]; + })->sortByDesc('views')->take($limit)->values(); } // ---- Behavior: Landing Pages ---- diff --git a/database/migrations/2026_03_19_000001_create_daily_aggregates_table.php b/database/migrations/2026_03_19_000001_create_daily_aggregates_table.php new file mode 100644 index 0000000..c8d3060 --- /dev/null +++ b/database/migrations/2026_03_19_000001_create_daily_aggregates_table.php @@ -0,0 +1,30 @@ +id(); + $table->foreignId('website_id')->constrained()->cascadeOnDelete(); + $table->date('date'); + $table->unsignedInteger('visitors')->default(0); + $table->unsignedInteger('sessions')->default(0); + $table->unsignedInteger('page_views')->default(0); + $table->unsignedInteger('bounce_count')->default(0); + $table->unsignedBigInteger('total_duration')->default(0); + $table->timestamps(); + + $table->unique(['website_id', 'date']); + }); + } + + public function down(): void + { + Schema::dropIfExists('daily_aggregates'); + } +}; diff --git a/database/migrations/2026_03_19_000002_create_page_daily_aggregates_table.php b/database/migrations/2026_03_19_000002_create_page_daily_aggregates_table.php new file mode 100644 index 0000000..f9a8459 --- /dev/null +++ b/database/migrations/2026_03_19_000002_create_page_daily_aggregates_table.php @@ -0,0 +1,31 @@ +id(); + $table->foreignId('website_id')->constrained()->cascadeOnDelete(); + $table->date('date'); + $table->string('path', 2048); + $table->unsignedInteger('views')->default(0); + $table->unsignedInteger('unique_views')->default(0); + $table->unsignedBigInteger('total_time_on_page')->default(0); + $table->unsignedInteger('bounces')->default(0); + $table->timestamps(); + + $table->unique(['website_id', 'date', 'path']); + $table->index(['website_id', 'date']); + }); + } + + public function down(): void + { + Schema::dropIfExists('page_daily_aggregates'); + } +}; diff --git a/database/migrations/2026_03_19_000003_add_performance_indexes.php b/database/migrations/2026_03_19_000003_add_performance_indexes.php new file mode 100644 index 0000000..8f62435 --- /dev/null +++ b/database/migrations/2026_03_19_000003_add_performance_indexes.php @@ -0,0 +1,54 @@ +index('visitor_id', 'pv_visitor_id_idx'); + $table->index('analytics_session_id', 'pv_session_id_idx'); + }); + + Schema::table('analytics_sessions', function (Blueprint $table) { + $table->index('visitor_id', 'sess_visitor_id_idx'); + $table->index(['website_id', 'is_bounce', 'started_at'], 'sess_bounce_lookup_idx'); + $table->index(['website_id', 'entry_page'], 'sess_entry_page_idx'); + $table->index(['website_id', 'exit_page'], 'sess_exit_page_idx'); + }); + + Schema::table('events', function (Blueprint $table) { + $table->index('visitor_id', 'ev_visitor_id_idx'); + }); + + Schema::table('goal_conversions', function (Blueprint $table) { + $table->index('visitor_id', 'gc_visitor_id_idx'); + }); + } + + public function down(): void + { + Schema::table('page_views', function (Blueprint $table) { + $table->dropIndex('pv_visitor_id_idx'); + $table->dropIndex('pv_session_id_idx'); + }); + + Schema::table('analytics_sessions', function (Blueprint $table) { + $table->dropIndex('sess_visitor_id_idx'); + $table->dropIndex('sess_bounce_lookup_idx'); + $table->dropIndex('sess_entry_page_idx'); + $table->dropIndex('sess_exit_page_idx'); + }); + + Schema::table('events', function (Blueprint $table) { + $table->dropIndex('ev_visitor_id_idx'); + }); + + Schema::table('goal_conversions', function (Blueprint $table) { + $table->dropIndex('gc_visitor_id_idx'); + }); + } +}; diff --git a/routes/console.php b/routes/console.php index 3c9adf1..a321049 100644 --- a/routes/console.php +++ b/routes/console.php @@ -2,7 +2,11 @@ use Illuminate\Foundation\Inspiring; use Illuminate\Support\Facades\Artisan; +use Illuminate\Support\Facades\Schedule; Artisan::command('inspire', function () { $this->comment(Inspiring::quote()); })->purpose('Display an inspiring quote'); + +Schedule::command('analytics:aggregate')->dailyAt('02:00'); +Schedule::command('analytics:purge --force')->dailyAt('03:00');