Files
analytics/app/Services/AnalyticsQueryService.php
T

506 lines
16 KiB
PHP
Raw Normal View History

2026-03-18 12:18:07 +02:00
<?php
namespace App\Services;
use App\Models\AnalyticsSession;
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
{
public function __construct(
private readonly Website $website,
private readonly Carbon $from,
private readonly Carbon $to,
) {}
public static function for(Website $website, Carbon $from, Carbon $to): self
{
return new self($website, $from->startOfDay(), $to->endOfDay());
}
// ---- Summary Metrics ----
public function totalPageViews(): int
{
return PageView::where('website_id', $this->website->id)
->whereBetween('viewed_at', [$this->from, $this->to])
->count();
}
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');
}
public function totalSessions(): int
{
return AnalyticsSession::where('website_id', $this->website->id)
->whereBetween('started_at', [$this->from, $this->to])
->count();
}
public function bounceRate(): float
{
$total = $this->totalSessions();
if ($total === 0) {
return 0;
}
$bounced = AnalyticsSession::where('website_id', $this->website->id)
->whereBetween('started_at', [$this->from, $this->to])
->where('is_bounce', true)
->count();
return round(($bounced / $total) * 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,
);
}
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,
);
}
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);
$groupFormat = $days <= 2 ? '%Y-%m-%d %H:00' : '%Y-%m-%d';
$carbonFormat = $days <= 2 ? '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');
$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');
$periods = collect();
$cursor = $this->from->copy();
$increment = $days <= 2 ? 'addHour' : 'addDay';
while ($cursor->lte($this->to)) {
$key = $cursor->format($carbonFormat);
$pv = $pageViews->get($key);
$sess = $sessions->get($key);
$periods->push([
'period' => $key,
'page_views' => $pv ? $pv->page_views : 0,
'visitors' => $pv ? $pv->visitors : 0,
'sessions' => $sess ? $sess->sessions : 0,
]);
$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
{
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();
}
// ---- 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
{
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'),
DB::raw('SUM(CAST(value AS REAL)) as total_value'),
)
->groupBy('name', 'category')
->orderByDesc('total')
->limit($limit)
->get();
}
// ---- Events: Detail ----
public function eventDetail(string $eventName): Collection
{
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'),
DB::raw('SUM(CAST(value AS REAL)) as total_value'),
)
->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(
DB::raw("strftime('{$groupFormat}', occurred_at) as period"),
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-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(
DB::raw("strftime('{$groupFormat}', converted_at) as period"),
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');
}
}