phase 6
This commit is contained in:
@@ -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.
|
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`
|
- `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`
|
- `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`
|
- [x] 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"
|
- [x] 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
|
- [x] 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] Add `data_retention_days` setting per website (stored in `settings` JSON) and a cleanup command (`analytics:purge`) to purge old raw rows
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@@ -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,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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Models;
|
||||||
|
|
||||||
|
use Illuminate\Database\Eloquent\Model;
|
||||||
|
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||||
|
|
||||||
|
class DailyAggregate extends Model
|
||||||
|
{
|
||||||
|
protected $fillable = [
|
||||||
|
'website_id',
|
||||||
|
'date',
|
||||||
|
'visitors',
|
||||||
|
'sessions',
|
||||||
|
'page_views',
|
||||||
|
'bounce_count',
|
||||||
|
'total_duration',
|
||||||
|
];
|
||||||
|
|
||||||
|
protected function casts(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'date' => 'date',
|
||||||
|
'visitors' => 'integer',
|
||||||
|
'sessions' => 'integer',
|
||||||
|
'page_views' => 'integer',
|
||||||
|
'bounce_count' => 'integer',
|
||||||
|
'total_duration' => 'integer',
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
public function website(): BelongsTo
|
||||||
|
{
|
||||||
|
return $this->belongsTo(Website::class);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Models;
|
||||||
|
|
||||||
|
use Illuminate\Database\Eloquent\Model;
|
||||||
|
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||||
|
|
||||||
|
class PageDailyAggregate extends Model
|
||||||
|
{
|
||||||
|
protected $fillable = [
|
||||||
|
'website_id',
|
||||||
|
'date',
|
||||||
|
'path',
|
||||||
|
'views',
|
||||||
|
'unique_views',
|
||||||
|
'total_time_on_page',
|
||||||
|
'bounces',
|
||||||
|
];
|
||||||
|
|
||||||
|
protected function casts(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'date' => 'date',
|
||||||
|
'views' => 'integer',
|
||||||
|
'unique_views' => 'integer',
|
||||||
|
'total_time_on_page' => 'integer',
|
||||||
|
'bounces' => 'integer',
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
public function website(): BelongsTo
|
||||||
|
{
|
||||||
|
return $this->belongsTo(Website::class);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -73,6 +73,21 @@ class Website extends Model
|
|||||||
return $this->hasMany(GoalConversion::class);
|
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
|
public function embedSnippet(): string
|
||||||
{
|
{
|
||||||
$scriptUrl = route('tracking.script', $this->tracking_id);
|
$scriptUrl = route('tracking.script', $this->tracking_id);
|
||||||
|
|||||||
@@ -3,6 +3,8 @@
|
|||||||
namespace App\Services;
|
namespace App\Services;
|
||||||
|
|
||||||
use App\Models\AnalyticsSession;
|
use App\Models\AnalyticsSession;
|
||||||
|
use App\Models\DailyAggregate;
|
||||||
|
use App\Models\PageDailyAggregate;
|
||||||
use App\Models\PageView;
|
use App\Models\PageView;
|
||||||
use App\Models\Visitor;
|
use App\Models\Visitor;
|
||||||
use App\Models\Website;
|
use App\Models\Website;
|
||||||
@@ -12,73 +14,188 @@ use Illuminate\Support\Facades\DB;
|
|||||||
|
|
||||||
class AnalyticsQueryService
|
class AnalyticsQueryService
|
||||||
{
|
{
|
||||||
|
private readonly Carbon $todayStart;
|
||||||
|
|
||||||
public function __construct(
|
public function __construct(
|
||||||
private readonly Website $website,
|
private readonly Website $website,
|
||||||
private readonly Carbon $from,
|
private readonly Carbon $from,
|
||||||
private readonly Carbon $to,
|
private readonly Carbon $to,
|
||||||
) {}
|
) {
|
||||||
|
$this->todayStart = Carbon::today();
|
||||||
|
}
|
||||||
|
|
||||||
public static function for(Website $website, Carbon $from, Carbon $to): self
|
public static function for(Website $website, Carbon $from, Carbon $to): self
|
||||||
{
|
{
|
||||||
return new self($website, $from->startOfDay(), $to->endOfDay());
|
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 ----
|
// ---- Summary Metrics ----
|
||||||
|
|
||||||
public function totalPageViews(): int
|
public function totalPageViews(): int
|
||||||
{
|
{
|
||||||
return PageView::where('website_id', $this->website->id)
|
$total = 0;
|
||||||
->whereBetween('viewed_at', [$this->from, $this->to])
|
[$aggFrom, $aggTo, $liveFrom, $liveTo] = $this->splitRange();
|
||||||
->count();
|
|
||||||
|
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
|
public function uniqueVisitors(): int
|
||||||
{
|
{
|
||||||
return PageView::where('website_id', $this->website->id)
|
$total = 0;
|
||||||
->whereBetween('viewed_at', [$this->from, $this->to])
|
[$aggFrom, $aggTo, $liveFrom, $liveTo] = $this->splitRange();
|
||||||
->distinct('visitor_id')
|
|
||||||
->count('visitor_id');
|
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
|
public function totalSessions(): int
|
||||||
{
|
{
|
||||||
return AnalyticsSession::where('website_id', $this->website->id)
|
$total = 0;
|
||||||
->whereBetween('started_at', [$this->from, $this->to])
|
[$aggFrom, $aggTo, $liveFrom, $liveTo] = $this->splitRange();
|
||||||
->count();
|
|
||||||
|
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
|
public function bounceRate(): float
|
||||||
{
|
{
|
||||||
$total = $this->totalSessions();
|
$totalSessions = $this->totalSessions();
|
||||||
if ($total === 0) {
|
if ($totalSessions === 0) {
|
||||||
return 0;
|
return 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
$bounced = AnalyticsSession::where('website_id', $this->website->id)
|
$bounced = 0;
|
||||||
->whereBetween('started_at', [$this->from, $this->to])
|
[$aggFrom, $aggTo, $liveFrom, $liveTo] = $this->splitRange();
|
||||||
->where('is_bounce', true)
|
|
||||||
->count();
|
|
||||||
|
|
||||||
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
|
public function avgSessionDuration(): float
|
||||||
{
|
{
|
||||||
return round(
|
$totalSessions = $this->totalSessions();
|
||||||
(float) AnalyticsSession::where('website_id', $this->website->id)
|
if ($totalSessions === 0) {
|
||||||
->whereBetween('started_at', [$this->from, $this->to])
|
return 0;
|
||||||
->avg('duration') ?? 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
|
public function avgPagesPerSession(): float
|
||||||
{
|
{
|
||||||
return round(
|
$totalSessions = $this->totalSessions();
|
||||||
(float) AnalyticsSession::where('website_id', $this->website->id)
|
if ($totalSessions === 0) {
|
||||||
->whereBetween('started_at', [$this->from, $this->to])
|
return 0;
|
||||||
->avg('pages_count') ?? 0,
|
}
|
||||||
1,
|
|
||||||
);
|
$totalPageViews = $this->totalPageViews();
|
||||||
|
|
||||||
|
return round($totalPageViews / $totalSessions, 1);
|
||||||
}
|
}
|
||||||
|
|
||||||
public function summary(): array
|
public function summary(): array
|
||||||
@@ -98,46 +215,91 @@ class AnalyticsQueryService
|
|||||||
public function visitorsOverTime(): Collection
|
public function visitorsOverTime(): Collection
|
||||||
{
|
{
|
||||||
$days = $this->from->diffInDays($this->to);
|
$days = $this->from->diffInDays($this->to);
|
||||||
$groupFormat = $days <= 2 ? '%Y-%m-%d %H:00' : '%Y-%m-%d';
|
$isHourly = $days <= 2;
|
||||||
$carbonFormat = $days <= 2 ? 'Y-m-d H:00' : 'Y-m-d';
|
$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)
|
[$aggFrom, $aggTo, $liveFrom, $liveTo] = $this->splitRange();
|
||||||
->whereBetween('viewed_at', [$this->from, $this->to])
|
$aggregated = collect();
|
||||||
->select(
|
$liveData = collect();
|
||||||
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');
|
|
||||||
|
|
||||||
$sessions = AnalyticsSession::where('website_id', $this->website->id)
|
if ($aggFrom && ! $isHourly) {
|
||||||
->whereBetween('started_at', [$this->from, $this->to])
|
$aggregated = DailyAggregate::where('website_id', $this->website->id)
|
||||||
->select(
|
->whereBetween('date', [$aggFrom->toDateString(), $aggTo->toDateString()])
|
||||||
DB::raw("strftime('{$groupFormat}', started_at) as period"),
|
->select('date as period', 'page_views', 'visitors', 'sessions')
|
||||||
DB::raw('COUNT(*) as sessions'),
|
->get()
|
||||||
)
|
->mapWithKeys(fn ($row) => [
|
||||||
->groupBy('period')
|
Carbon::parse($row->period)->format('Y-m-d') => [
|
||||||
->orderBy('period')
|
'page_views' => $row->page_views,
|
||||||
->get()
|
'visitors' => $row->visitors,
|
||||||
->keyBy('period');
|
'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();
|
$periods = collect();
|
||||||
$cursor = $this->from->copy();
|
$cursor = $this->from->copy();
|
||||||
$increment = $days <= 2 ? 'addHour' : 'addDay';
|
$increment = $isHourly ? 'addHour' : 'addDay';
|
||||||
|
|
||||||
while ($cursor->lte($this->to)) {
|
while ($cursor->lte($this->to)) {
|
||||||
$key = $cursor->format($carbonFormat);
|
$key = $cursor->format($carbonFormat);
|
||||||
$pv = $pageViews->get($key);
|
$data = $merged->get($key);
|
||||||
$sess = $sessions->get($key);
|
|
||||||
|
|
||||||
$periods->push([
|
$periods->push([
|
||||||
'period' => $key,
|
'period' => $key,
|
||||||
'page_views' => $pv ? $pv->page_views : 0,
|
'page_views' => $data['page_views'] ?? 0,
|
||||||
'visitors' => $pv ? $pv->visitors : 0,
|
'visitors' => $data['visitors'] ?? 0,
|
||||||
'sessions' => $sess ? $sess->sessions : 0,
|
'sessions' => $data['sessions'] ?? 0,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
$cursor->$increment();
|
$cursor->$increment();
|
||||||
@@ -295,18 +457,65 @@ class AnalyticsQueryService
|
|||||||
|
|
||||||
public function topPages(int $limit = 50): Collection
|
public function topPages(int $limit = 50): Collection
|
||||||
{
|
{
|
||||||
return PageView::where('website_id', $this->website->id)
|
[$aggFrom, $aggTo, $liveFrom, $liveTo] = $this->splitRange();
|
||||||
->whereBetween('viewed_at', [$this->from, $this->to])
|
$aggData = collect();
|
||||||
->select(
|
$liveData = collect();
|
||||||
'path',
|
|
||||||
DB::raw('COUNT(*) as views'),
|
if ($aggFrom) {
|
||||||
DB::raw('COUNT(DISTINCT visitor_id) as unique_views'),
|
$aggData = PageDailyAggregate::where('website_id', $this->website->id)
|
||||||
DB::raw('AVG(time_on_page) as avg_time'),
|
->whereBetween('date', [$aggFrom->toDateString(), $aggTo->toDateString()])
|
||||||
)
|
->select(
|
||||||
->groupBy('path')
|
'path',
|
||||||
->orderByDesc('views')
|
DB::raw('SUM(views) as views'),
|
||||||
->limit($limit)
|
DB::raw('SUM(unique_views) as unique_views'),
|
||||||
->get();
|
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 ----
|
// ---- Behavior: Landing Pages ----
|
||||||
|
|||||||
@@ -0,0 +1,30 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
use Illuminate\Database\Migrations\Migration;
|
||||||
|
use Illuminate\Database\Schema\Blueprint;
|
||||||
|
use Illuminate\Support\Facades\Schema;
|
||||||
|
|
||||||
|
return new class extends Migration
|
||||||
|
{
|
||||||
|
public function up(): void
|
||||||
|
{
|
||||||
|
Schema::create('daily_aggregates', function (Blueprint $table) {
|
||||||
|
$table->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');
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
use Illuminate\Database\Migrations\Migration;
|
||||||
|
use Illuminate\Database\Schema\Blueprint;
|
||||||
|
use Illuminate\Support\Facades\Schema;
|
||||||
|
|
||||||
|
return new class extends Migration
|
||||||
|
{
|
||||||
|
public function up(): void
|
||||||
|
{
|
||||||
|
Schema::create('page_daily_aggregates', function (Blueprint $table) {
|
||||||
|
$table->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');
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -0,0 +1,54 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
use Illuminate\Database\Migrations\Migration;
|
||||||
|
use Illuminate\Database\Schema\Blueprint;
|
||||||
|
use Illuminate\Support\Facades\Schema;
|
||||||
|
|
||||||
|
return new class extends Migration
|
||||||
|
{
|
||||||
|
public function up(): void
|
||||||
|
{
|
||||||
|
Schema::table('page_views', function (Blueprint $table) {
|
||||||
|
$table->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');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -2,7 +2,11 @@
|
|||||||
|
|
||||||
use Illuminate\Foundation\Inspiring;
|
use Illuminate\Foundation\Inspiring;
|
||||||
use Illuminate\Support\Facades\Artisan;
|
use Illuminate\Support\Facades\Artisan;
|
||||||
|
use Illuminate\Support\Facades\Schedule;
|
||||||
|
|
||||||
Artisan::command('inspire', function () {
|
Artisan::command('inspire', function () {
|
||||||
$this->comment(Inspiring::quote());
|
$this->comment(Inspiring::quote());
|
||||||
})->purpose('Display an inspiring quote');
|
})->purpose('Display an inspiring quote');
|
||||||
|
|
||||||
|
Schedule::command('analytics:aggregate')->dailyAt('02:00');
|
||||||
|
Schedule::command('analytics:purge --force')->dailyAt('03:00');
|
||||||
|
|||||||
Reference in New Issue
Block a user