2026-03-18 12:18:07 +02:00
|
|
|
<?php
|
|
|
|
|
|
|
|
|
|
namespace App\Services;
|
|
|
|
|
|
|
|
|
|
use App\Models\AnalyticsSession;
|
2026-03-19 14:53:54 +02:00
|
|
|
use App\Models\DailyAggregate;
|
|
|
|
|
use App\Models\PageDailyAggregate;
|
2026-03-18 12:18:07 +02:00
|
|
|
use App\Models\PageView;
|
|
|
|
|
use App\Models\Visitor;
|
|
|
|
|
use App\Models\Website;
|
|
|
|
|
use Illuminate\Support\Carbon;
|
|
|
|
|
use Illuminate\Support\Collection;
|
|
|
|
|
use Illuminate\Support\Facades\DB;
|
|
|
|
|
|
|
|
|
|
class AnalyticsQueryService
|
|
|
|
|
{
|
2026-03-19 14:53:54 +02:00
|
|
|
private readonly Carbon $todayStart;
|
|
|
|
|
|
2026-03-19 18:42:19 +02:00
|
|
|
private ?Collection $segmentVisitorIds = null;
|
|
|
|
|
|
2026-03-18 12:18:07 +02:00
|
|
|
public function __construct(
|
|
|
|
|
private readonly Website $website,
|
|
|
|
|
private readonly Carbon $from,
|
|
|
|
|
private readonly Carbon $to,
|
2026-03-19 14:53:54 +02:00
|
|
|
) {
|
|
|
|
|
$this->todayStart = Carbon::today();
|
|
|
|
|
}
|
2026-03-18 12:18:07 +02:00
|
|
|
|
|
|
|
|
public static function for(Website $website, Carbon $from, Carbon $to): self
|
|
|
|
|
{
|
|
|
|
|
return new self($website, $from->startOfDay(), $to->endOfDay());
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-19 18:42:19 +02:00
|
|
|
public function withSegment(?Collection $visitorIds): self
|
|
|
|
|
{
|
|
|
|
|
$this->segmentVisitorIds = $visitorIds;
|
|
|
|
|
|
|
|
|
|
return $this;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
public function isSegmented(): bool
|
|
|
|
|
{
|
|
|
|
|
return $this->segmentVisitorIds !== null;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private function applySegmentFilter($query, string $column = 'visitor_id')
|
|
|
|
|
{
|
|
|
|
|
if ($this->segmentVisitorIds !== null) {
|
|
|
|
|
$query->whereIn($column, $this->segmentVisitorIds);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return $query;
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-19 14:53:54 +02:00
|
|
|
/**
|
|
|
|
|
* 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;
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-18 12:18:07 +02:00
|
|
|
// ---- Summary Metrics ----
|
|
|
|
|
|
|
|
|
|
public function totalPageViews(): int
|
|
|
|
|
{
|
2026-03-19 18:42:19 +02:00
|
|
|
if ($this->isSegmented()) {
|
|
|
|
|
return $this->applySegmentFilter(
|
|
|
|
|
PageView::where('website_id', $this->website->id)
|
|
|
|
|
->whereBetween('viewed_at', [$this->from, $this->to])
|
|
|
|
|
)->count();
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-19 14:53:54 +02:00
|
|
|
$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;
|
2026-03-18 12:18:07 +02:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
public function uniqueVisitors(): int
|
|
|
|
|
{
|
2026-03-19 18:42:19 +02:00
|
|
|
if ($this->isSegmented()) {
|
|
|
|
|
return $this->applySegmentFilter(
|
|
|
|
|
PageView::where('website_id', $this->website->id)
|
|
|
|
|
->whereBetween('viewed_at', [$this->from, $this->to])
|
|
|
|
|
)->distinct('visitor_id')->count('visitor_id');
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-19 14:53:54 +02:00
|
|
|
$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;
|
2026-03-18 12:18:07 +02:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
public function totalSessions(): int
|
|
|
|
|
{
|
2026-03-19 18:42:19 +02:00
|
|
|
if ($this->isSegmented()) {
|
|
|
|
|
return $this->applySegmentFilter(
|
|
|
|
|
AnalyticsSession::where('website_id', $this->website->id)
|
|
|
|
|
->whereBetween('started_at', [$this->from, $this->to])
|
|
|
|
|
)->count();
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-19 14:53:54 +02:00
|
|
|
$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;
|
2026-03-18 12:18:07 +02:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
public function bounceRate(): float
|
|
|
|
|
{
|
2026-03-19 14:53:54 +02:00
|
|
|
$totalSessions = $this->totalSessions();
|
|
|
|
|
if ($totalSessions === 0) {
|
2026-03-18 12:18:07 +02:00
|
|
|
return 0;
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-19 14:53:54 +02:00
|
|
|
$bounced = 0;
|
|
|
|
|
[$aggFrom, $aggTo, $liveFrom, $liveTo] = $this->splitRange();
|
2026-03-18 12:18:07 +02:00
|
|
|
|
2026-03-19 14:53:54 +02:00
|
|
|
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);
|
2026-03-18 12:18:07 +02:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
public function avgSessionDuration(): float
|
|
|
|
|
{
|
2026-03-19 14:53:54 +02:00
|
|
|
$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);
|
2026-03-18 12:18:07 +02:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
public function avgPagesPerSession(): float
|
|
|
|
|
{
|
2026-03-19 14:53:54 +02:00
|
|
|
$totalSessions = $this->totalSessions();
|
|
|
|
|
if ($totalSessions === 0) {
|
|
|
|
|
return 0;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
$totalPageViews = $this->totalPageViews();
|
|
|
|
|
|
|
|
|
|
return round($totalPageViews / $totalSessions, 1);
|
2026-03-18 12:18:07 +02:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
public function summary(): array
|
|
|
|
|
{
|
|
|
|
|
return [
|
|
|
|
|
'page_views' => $this->totalPageViews(),
|
|
|
|
|
'visitors' => $this->uniqueVisitors(),
|
|
|
|
|
'sessions' => $this->totalSessions(),
|
|
|
|
|
'bounce_rate' => $this->bounceRate(),
|
|
|
|
|
'avg_duration' => $this->avgSessionDuration(),
|
|
|
|
|
'avg_pages' => $this->avgPagesPerSession(),
|
|
|
|
|
];
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// ---- Time Series ----
|
|
|
|
|
|
|
|
|
|
public function visitorsOverTime(): Collection
|
|
|
|
|
{
|
|
|
|
|
$days = $this->from->diffInDays($this->to);
|
2026-03-19 14:53:54 +02:00
|
|
|
$isHourly = $days <= 2;
|
|
|
|
|
$groupFormat = $isHourly ? '%Y-%m-%d %H:00' : '%Y-%m-%d';
|
|
|
|
|
$carbonFormat = $isHourly ? 'Y-m-d H:00' : 'Y-m-d';
|
2026-03-18 12:18:07 +02:00
|
|
|
|
2026-03-19 14:53:54 +02:00
|
|
|
[$aggFrom, $aggTo, $liveFrom, $liveTo] = $this->splitRange();
|
|
|
|
|
$aggregated = collect();
|
|
|
|
|
$liveData = collect();
|
2026-03-18 12:18:07 +02:00
|
|
|
|
2026-03-19 14:53:54 +02:00
|
|
|
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(
|
2026-03-21 12:08:01 +02:00
|
|
|
DB::raw($this->dateFormat($groupFormat, 'viewed_at').' as period'),
|
2026-03-19 14:53:54 +02:00
|
|
|
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(
|
2026-03-21 12:08:01 +02:00
|
|
|
DB::raw($this->dateFormat($groupFormat, 'started_at').' as period'),
|
2026-03-19 14:53:54 +02:00
|
|
|
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);
|
2026-03-18 12:18:07 +02:00
|
|
|
|
|
|
|
|
$periods = collect();
|
|
|
|
|
$cursor = $this->from->copy();
|
2026-03-19 14:53:54 +02:00
|
|
|
$increment = $isHourly ? 'addHour' : 'addDay';
|
2026-03-18 12:18:07 +02:00
|
|
|
|
|
|
|
|
while ($cursor->lte($this->to)) {
|
|
|
|
|
$key = $cursor->format($carbonFormat);
|
2026-03-19 14:53:54 +02:00
|
|
|
$data = $merged->get($key);
|
2026-03-18 12:18:07 +02:00
|
|
|
|
|
|
|
|
$periods->push([
|
|
|
|
|
'period' => $key,
|
2026-03-19 14:53:54 +02:00
|
|
|
'page_views' => $data['page_views'] ?? 0,
|
|
|
|
|
'visitors' => $data['visitors'] ?? 0,
|
|
|
|
|
'sessions' => $data['sessions'] ?? 0,
|
2026-03-18 12:18:07 +02:00
|
|
|
]);
|
|
|
|
|
|
|
|
|
|
$cursor->$increment();
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return $periods;
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-18 13:32:36 +02:00
|
|
|
// ---- Audience: Geographic ----
|
|
|
|
|
|
|
|
|
|
public function visitorsByCountry(int $limit = 50): Collection
|
|
|
|
|
{
|
|
|
|
|
return Visitor::where('website_id', $this->website->id)
|
|
|
|
|
->whereNotNull('country')
|
|
|
|
|
->whereHas('pageViews', fn ($q) => $q->whereBetween('viewed_at', [$this->from, $this->to]))
|
|
|
|
|
->select('country', DB::raw('COUNT(*) as visitors'))
|
|
|
|
|
->groupBy('country')
|
|
|
|
|
->orderByDesc('visitors')
|
|
|
|
|
->limit($limit)
|
|
|
|
|
->get();
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
public function visitorsByCity(int $limit = 50): Collection
|
|
|
|
|
{
|
|
|
|
|
return Visitor::where('website_id', $this->website->id)
|
|
|
|
|
->whereNotNull('city')
|
|
|
|
|
->whereHas('pageViews', fn ($q) => $q->whereBetween('viewed_at', [$this->from, $this->to]))
|
|
|
|
|
->select('country', 'region', 'city', DB::raw('COUNT(*) as visitors'))
|
|
|
|
|
->groupBy('country', 'region', 'city')
|
|
|
|
|
->orderByDesc('visitors')
|
|
|
|
|
->limit($limit)
|
|
|
|
|
->get();
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// ---- Audience: Technology ----
|
|
|
|
|
|
|
|
|
|
public function visitorsByBrowser(int $limit = 20): Collection
|
|
|
|
|
{
|
|
|
|
|
return $this->visitorGroupedBy('browser', $limit);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
public function visitorsByOS(int $limit = 20): Collection
|
|
|
|
|
{
|
|
|
|
|
return $this->visitorGroupedBy('os', $limit);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
public function visitorsByDeviceType(int $limit = 10): Collection
|
|
|
|
|
{
|
|
|
|
|
return $this->visitorGroupedBy('device_type', $limit);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
public function visitorsByScreenResolution(int $limit = 20): Collection
|
|
|
|
|
{
|
|
|
|
|
return $this->visitorGroupedBy('screen_resolution', $limit);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// ---- Audience: Language ----
|
|
|
|
|
|
|
|
|
|
public function visitorsByLanguage(int $limit = 20): Collection
|
|
|
|
|
{
|
|
|
|
|
return $this->visitorGroupedBy('language', $limit);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// ---- Audience: New vs Returning ----
|
|
|
|
|
|
|
|
|
|
public function newVsReturning(): array
|
|
|
|
|
{
|
|
|
|
|
$activeVisitorIds = PageView::where('website_id', $this->website->id)
|
|
|
|
|
->whereBetween('viewed_at', [$this->from, $this->to])
|
|
|
|
|
->distinct()
|
|
|
|
|
->pluck('visitor_id');
|
|
|
|
|
|
|
|
|
|
$total = $activeVisitorIds->count();
|
|
|
|
|
if ($total === 0) {
|
|
|
|
|
return ['new' => 0, 'returning' => 0, 'new_pct' => 0, 'returning_pct' => 0];
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
$returning = AnalyticsSession::where('website_id', $this->website->id)
|
|
|
|
|
->whereIn('visitor_id', $activeVisitorIds)
|
|
|
|
|
->select('visitor_id')
|
|
|
|
|
->groupBy('visitor_id')
|
|
|
|
|
->havingRaw('COUNT(*) > 1')
|
|
|
|
|
->get()
|
|
|
|
|
->count();
|
|
|
|
|
|
|
|
|
|
$new = $total - $returning;
|
|
|
|
|
|
|
|
|
|
return [
|
|
|
|
|
'new' => $new,
|
|
|
|
|
'returning' => $returning,
|
|
|
|
|
'new_pct' => round(($new / $total) * 100, 1),
|
|
|
|
|
'returning_pct' => round(($returning / $total) * 100, 1),
|
|
|
|
|
];
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// ---- Acquisition: Traffic Sources ----
|
|
|
|
|
|
|
|
|
|
public function trafficSources(int $limit = 50): Collection
|
|
|
|
|
{
|
|
|
|
|
return AnalyticsSession::where('website_id', $this->website->id)
|
|
|
|
|
->whereBetween('started_at', [$this->from, $this->to])
|
|
|
|
|
->select(
|
|
|
|
|
DB::raw("COALESCE(NULLIF(referrer_domain, ''), 'Direct') as source"),
|
|
|
|
|
DB::raw('COUNT(*) as sessions'),
|
|
|
|
|
DB::raw('SUM(CASE WHEN is_bounce = 1 THEN 1 ELSE 0 END) as bounces'),
|
|
|
|
|
DB::raw('AVG(duration) as avg_duration'),
|
|
|
|
|
DB::raw('AVG(pages_count) as avg_pages'),
|
|
|
|
|
)
|
|
|
|
|
->groupBy('source')
|
|
|
|
|
->orderByDesc('sessions')
|
|
|
|
|
->limit($limit)
|
|
|
|
|
->get();
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// ---- Acquisition: Campaigns (UTM) ----
|
|
|
|
|
|
|
|
|
|
public function campaigns(int $limit = 50): Collection
|
|
|
|
|
{
|
|
|
|
|
return AnalyticsSession::where('website_id', $this->website->id)
|
|
|
|
|
->whereBetween('started_at', [$this->from, $this->to])
|
|
|
|
|
->whereNotNull('utm_source')
|
|
|
|
|
->select(
|
|
|
|
|
'utm_source', 'utm_medium', 'utm_campaign',
|
|
|
|
|
DB::raw('COUNT(*) as sessions'),
|
|
|
|
|
DB::raw('SUM(CASE WHEN is_bounce = 1 THEN 1 ELSE 0 END) as bounces'),
|
|
|
|
|
DB::raw('AVG(duration) as avg_duration'),
|
|
|
|
|
)
|
|
|
|
|
->groupBy('utm_source', 'utm_medium', 'utm_campaign')
|
|
|
|
|
->orderByDesc('sessions')
|
|
|
|
|
->limit($limit)
|
|
|
|
|
->get();
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// ---- Acquisition: Referrers ----
|
|
|
|
|
|
|
|
|
|
public function referrers(int $limit = 50): Collection
|
|
|
|
|
{
|
|
|
|
|
return AnalyticsSession::where('website_id', $this->website->id)
|
|
|
|
|
->whereBetween('started_at', [$this->from, $this->to])
|
|
|
|
|
->whereNotNull('referrer_domain')
|
|
|
|
|
->where('referrer_domain', '!=', '')
|
|
|
|
|
->select(
|
|
|
|
|
'referrer_domain',
|
|
|
|
|
DB::raw('COUNT(*) as sessions'),
|
|
|
|
|
DB::raw('SUM(CASE WHEN is_bounce = 1 THEN 1 ELSE 0 END) as bounces'),
|
|
|
|
|
DB::raw('AVG(duration) as avg_duration'),
|
|
|
|
|
)
|
|
|
|
|
->groupBy('referrer_domain')
|
|
|
|
|
->orderByDesc('sessions')
|
|
|
|
|
->limit($limit)
|
|
|
|
|
->get();
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// ---- Behavior: Pages ----
|
|
|
|
|
|
|
|
|
|
public function topPages(int $limit = 50): Collection
|
|
|
|
|
{
|
2026-03-19 14:53:54 +02:00
|
|
|
[$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();
|
2026-03-18 13:32:36 +02:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// ---- Behavior: Landing Pages ----
|
|
|
|
|
|
|
|
|
|
public function landingPages(int $limit = 50): Collection
|
|
|
|
|
{
|
|
|
|
|
return AnalyticsSession::where('website_id', $this->website->id)
|
|
|
|
|
->whereBetween('started_at', [$this->from, $this->to])
|
|
|
|
|
->whereNotNull('entry_page')
|
|
|
|
|
->select(
|
|
|
|
|
'entry_page as path',
|
|
|
|
|
DB::raw('COUNT(*) as sessions'),
|
|
|
|
|
DB::raw('SUM(CASE WHEN is_bounce = 1 THEN 1 ELSE 0 END) as bounces'),
|
|
|
|
|
DB::raw('AVG(duration) as avg_duration'),
|
|
|
|
|
)
|
|
|
|
|
->groupBy('entry_page')
|
|
|
|
|
->orderByDesc('sessions')
|
|
|
|
|
->limit($limit)
|
|
|
|
|
->get();
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// ---- Behavior: Exit Pages ----
|
|
|
|
|
|
|
|
|
|
public function exitPages(int $limit = 50): Collection
|
|
|
|
|
{
|
|
|
|
|
return AnalyticsSession::where('website_id', $this->website->id)
|
|
|
|
|
->whereBetween('started_at', [$this->from, $this->to])
|
|
|
|
|
->whereNotNull('exit_page')
|
|
|
|
|
->select(
|
|
|
|
|
'exit_page as path',
|
|
|
|
|
DB::raw('COUNT(*) as exits'),
|
|
|
|
|
)
|
|
|
|
|
->groupBy('exit_page')
|
|
|
|
|
->orderByDesc('exits')
|
|
|
|
|
->limit($limit)
|
|
|
|
|
->get();
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// ---- Events: Overview ----
|
|
|
|
|
|
|
|
|
|
public function eventsOverview(int $limit = 50): Collection
|
|
|
|
|
{
|
2026-03-21 12:08:01 +02:00
|
|
|
$driver = DB::connection()->getDriverName();
|
|
|
|
|
$castType = $driver === 'sqlite' ? 'REAL' : 'DECIMAL(10,2)';
|
|
|
|
|
|
2026-03-18 13:32:36 +02:00
|
|
|
return \App\Models\Event::where('website_id', $this->website->id)
|
|
|
|
|
->whereBetween('occurred_at', [$this->from, $this->to])
|
|
|
|
|
->select(
|
|
|
|
|
'name',
|
|
|
|
|
'category',
|
|
|
|
|
DB::raw('COUNT(*) as total'),
|
|
|
|
|
DB::raw('COUNT(DISTINCT visitor_id) as unique_visitors'),
|
2026-03-21 12:08:01 +02:00
|
|
|
DB::raw("SUM(CAST(value AS {$castType})) as total_value"),
|
2026-03-18 13:32:36 +02:00
|
|
|
)
|
|
|
|
|
->groupBy('name', 'category')
|
|
|
|
|
->orderByDesc('total')
|
|
|
|
|
->limit($limit)
|
|
|
|
|
->get();
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// ---- Events: Detail ----
|
|
|
|
|
|
|
|
|
|
public function eventDetail(string $eventName): Collection
|
|
|
|
|
{
|
2026-03-21 12:08:01 +02:00
|
|
|
$driver = DB::connection()->getDriverName();
|
|
|
|
|
$castType = $driver === 'sqlite' ? 'REAL' : 'DECIMAL(10,2)';
|
|
|
|
|
|
2026-03-18 13:32:36 +02:00
|
|
|
return \App\Models\Event::where('website_id', $this->website->id)
|
|
|
|
|
->whereBetween('occurred_at', [$this->from, $this->to])
|
|
|
|
|
->where('name', $eventName)
|
|
|
|
|
->select(
|
|
|
|
|
'action', 'label',
|
|
|
|
|
DB::raw('COUNT(*) as total'),
|
2026-03-21 12:08:01 +02:00
|
|
|
DB::raw("SUM(CAST(value AS {$castType})) as total_value"),
|
2026-03-18 13:32:36 +02:00
|
|
|
)
|
|
|
|
|
->groupBy('action', 'label')
|
|
|
|
|
->orderByDesc('total')
|
|
|
|
|
->get();
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// ---- Events: Trending ----
|
|
|
|
|
|
|
|
|
|
public function eventTrending(string $eventName): 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';
|
|
|
|
|
|
|
|
|
|
$data = \App\Models\Event::where('website_id', $this->website->id)
|
|
|
|
|
->whereBetween('occurred_at', [$this->from, $this->to])
|
|
|
|
|
->where('name', $eventName)
|
|
|
|
|
->select(
|
2026-03-21 12:08:01 +02:00
|
|
|
DB::raw($this->dateFormat($groupFormat, 'occurred_at').' as period'),
|
2026-03-18 13:32:36 +02:00
|
|
|
DB::raw('COUNT(*) as total'),
|
|
|
|
|
)
|
|
|
|
|
->groupBy('period')
|
|
|
|
|
->orderBy('period')
|
|
|
|
|
->get()
|
|
|
|
|
->keyBy('period');
|
|
|
|
|
|
|
|
|
|
$periods = collect();
|
|
|
|
|
$cursor = $this->from->copy();
|
|
|
|
|
$increment = $days <= 2 ? 'addHour' : 'addDay';
|
|
|
|
|
|
|
|
|
|
while ($cursor->lte($this->to)) {
|
|
|
|
|
$key = $cursor->format($carbonFormat);
|
|
|
|
|
$periods->push([
|
|
|
|
|
'period' => $key,
|
|
|
|
|
'total' => $data->get($key)?->total ?? 0,
|
|
|
|
|
]);
|
|
|
|
|
$cursor->$increment();
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return $periods;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// ---- Helpers ----
|
|
|
|
|
|
|
|
|
|
private function visitorGroupedBy(string $column, int $limit): Collection
|
|
|
|
|
{
|
|
|
|
|
return Visitor::where('website_id', $this->website->id)
|
|
|
|
|
->whereNotNull($column)
|
|
|
|
|
->where($column, '!=', '')
|
|
|
|
|
->whereHas('pageViews', fn ($q) => $q->whereBetween('viewed_at', [$this->from, $this->to]))
|
|
|
|
|
->select($column, DB::raw('COUNT(*) as visitors'))
|
|
|
|
|
->groupBy($column)
|
|
|
|
|
->orderByDesc('visitors')
|
|
|
|
|
->limit($limit)
|
|
|
|
|
->get();
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-21 12:08:01 +02:00
|
|
|
/**
|
|
|
|
|
* Get the appropriate date formatting function for the current database driver.
|
|
|
|
|
*/
|
|
|
|
|
private function dateFormat(string $format, string $column): string
|
|
|
|
|
{
|
|
|
|
|
$driver = DB::connection()->getDriverName();
|
|
|
|
|
|
|
|
|
|
if ($driver === 'sqlite') {
|
|
|
|
|
return "strftime('{$format}', {$column})";
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// MySQL/MariaDB
|
|
|
|
|
// Convert SQLite strftime format to MySQL DATE_FORMAT
|
|
|
|
|
$mysqlFormat = str_replace(
|
|
|
|
|
['%Y', '%m', '%d', '%H', '%M', '%S'],
|
|
|
|
|
['%Y', '%m', '%d', '%H', '%i', '%s'],
|
|
|
|
|
$format
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
return "DATE_FORMAT({$column}, '{$mysqlFormat}')";
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-18 16:59:39 +02:00
|
|
|
// ---- Conversions ----
|
|
|
|
|
|
|
|
|
|
public function conversionsReport(): Collection
|
|
|
|
|
{
|
|
|
|
|
$goals = \App\Models\Goal::where('website_id', $this->website->id)->get();
|
|
|
|
|
$totalSessions = $this->totalSessions();
|
|
|
|
|
|
|
|
|
|
return $goals->map(function ($goal) use ($totalSessions) {
|
|
|
|
|
$completions = \App\Models\GoalConversion::where('goal_id', $goal->id)
|
|
|
|
|
->whereBetween('converted_at', [$this->from, $this->to])
|
|
|
|
|
->count();
|
|
|
|
|
|
|
|
|
|
$totalValue = \App\Models\GoalConversion::where('goal_id', $goal->id)
|
|
|
|
|
->whereBetween('converted_at', [$this->from, $this->to])
|
|
|
|
|
->sum('value');
|
|
|
|
|
|
|
|
|
|
return (object) [
|
|
|
|
|
'id' => $goal->id,
|
|
|
|
|
'name' => $goal->name,
|
|
|
|
|
'type' => $goal->type,
|
|
|
|
|
'is_active' => $goal->is_active,
|
|
|
|
|
'completions' => $completions,
|
|
|
|
|
'conversion_rate' => $totalSessions > 0 ? round(($completions / $totalSessions) * 100, 2) : 0,
|
|
|
|
|
'total_value' => $totalValue,
|
|
|
|
|
];
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
public function goalCompletionsOverTime(): 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';
|
|
|
|
|
|
|
|
|
|
$data = \App\Models\GoalConversion::where('website_id', $this->website->id)
|
|
|
|
|
->whereBetween('converted_at', [$this->from, $this->to])
|
|
|
|
|
->select(
|
2026-03-21 12:08:01 +02:00
|
|
|
DB::raw($this->dateFormat($groupFormat, 'converted_at').' as period'),
|
2026-03-18 16:59:39 +02:00
|
|
|
DB::raw('COUNT(*) as completions'),
|
|
|
|
|
)
|
|
|
|
|
->groupBy('period')
|
|
|
|
|
->orderBy('period')
|
|
|
|
|
->get()
|
|
|
|
|
->keyBy('period');
|
|
|
|
|
|
|
|
|
|
$periods = collect();
|
|
|
|
|
$cursor = $this->from->copy();
|
|
|
|
|
$increment = $days <= 2 ? 'addHour' : 'addDay';
|
|
|
|
|
|
|
|
|
|
while ($cursor->lte($this->to)) {
|
|
|
|
|
$key = $cursor->format($carbonFormat);
|
|
|
|
|
$periods->push([
|
|
|
|
|
'period' => $key,
|
|
|
|
|
'completions' => $data->get($key)?->completions ?? 0,
|
|
|
|
|
]);
|
|
|
|
|
$cursor->$increment();
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return $periods;
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-18 12:18:07 +02:00
|
|
|
// ---- Real-Time ----
|
|
|
|
|
|
|
|
|
|
public static function activeVisitors(Website $website, int $minutes = 5): int
|
|
|
|
|
{
|
|
|
|
|
return AnalyticsSession::where('website_id', $website->id)
|
|
|
|
|
->where('ended_at', '>=', Carbon::now()->subMinutes($minutes))
|
|
|
|
|
->distinct('visitor_id')
|
|
|
|
|
->count('visitor_id');
|
|
|
|
|
}
|
|
|
|
|
}
|