diff --git a/codebase/app/Models/AnalyticsEvent.php b/codebase/app/Models/AnalyticsEvent.php new file mode 100644 index 0000000..d8400cb --- /dev/null +++ b/codebase/app/Models/AnalyticsEvent.php @@ -0,0 +1,54 @@ + + */ + protected $fillable = [ + 'analytics_page_view_id', + 'visitor_id', + 'browser_session_id', + 'user_id', + 'event_name', + 'route_name', + 'page_kind', + 'path', + 'label', + 'target_host', + 'target_url', + 'scroll_depth', + 'properties', + 'occurred_at', + ]; + + /** + * @return array + */ + protected function casts(): array + { + return [ + 'scroll_depth' => 'integer', + 'properties' => 'array', + 'occurred_at' => 'datetime', + ]; + } + + public function pageView(): BelongsTo + { + return $this->belongsTo(AnalyticsPageView::class, 'analytics_page_view_id'); + } + + public function user(): BelongsTo + { + return $this->belongsTo(User::class); + } +} diff --git a/codebase/app/Models/AnalyticsPageView.php b/codebase/app/Models/AnalyticsPageView.php new file mode 100644 index 0000000..6de84a9 --- /dev/null +++ b/codebase/app/Models/AnalyticsPageView.php @@ -0,0 +1,75 @@ + + */ + protected $fillable = [ + 'visitor_id', + 'browser_session_id', + 'user_id', + 'route_name', + 'page_component', + 'page_kind', + 'path', + 'url', + 'title', + 'referrer_host', + 'referrer_url', + 'channel', + 'utm_source', + 'utm_medium', + 'utm_campaign', + 'device_type', + 'browser_family', + 'os_family', + 'locale', + 'is_authenticated', + 'viewport_width', + 'viewport_height', + 'max_scroll_depth', + 'duration_seconds', + 'viewed_at', + 'engaged_at', + 'last_seen_at', + 'ended_at', + ]; + + /** + * @return array + */ + protected function casts(): array + { + return [ + 'is_authenticated' => 'boolean', + 'viewport_width' => 'integer', + 'viewport_height' => 'integer', + 'max_scroll_depth' => 'integer', + 'duration_seconds' => 'integer', + 'viewed_at' => 'datetime', + 'engaged_at' => 'datetime', + 'last_seen_at' => 'datetime', + 'ended_at' => 'datetime', + ]; + } + + public function user(): BelongsTo + { + return $this->belongsTo(User::class); + } + + public function events(): HasMany + { + return $this->hasMany(AnalyticsEvent::class); + } +} diff --git a/codebase/app/Packages/Admin/Http/Controllers/DashboardController.php b/codebase/app/Packages/Admin/Http/Controllers/DashboardController.php index c2fdba0..6cc927e 100644 --- a/codebase/app/Packages/Admin/Http/Controllers/DashboardController.php +++ b/codebase/app/Packages/Admin/Http/Controllers/DashboardController.php @@ -4,15 +4,20 @@ use App\Http\Controllers\Controller; use App\Models\Alert; +use App\Models\AnalyticsEvent; +use App\Models\AnalyticsPageView; use App\Models\Monitor; use App\Models\MonitorRun; use App\Models\Monster; +use App\Models\MonsterFollow; use App\Models\MonsterSuggestion; use App\Models\PriceSnapshot; +use App\Models\PushSubscription; use App\Models\Site; use App\Models\User; use Illuminate\Support\Carbon; use Illuminate\Support\Collection; +use Illuminate\Support\Str; use Inertia\Inertia; use Inertia\Response; @@ -21,10 +26,11 @@ class DashboardController extends Controller public function index(): Response { $today = now()->startOfDay(); - $periodStart = $today->copy()->subDays(13); + $operationsPeriodStart = $today->copy()->subDays(13); + $analyticsPeriodStart = $today->copy()->subDays(29); $snapshotsByDay = PriceSnapshot::query() - ->where('checked_at', '>=', $periodStart) + ->where('checked_at', '>=', $operationsPeriodStart) ->selectRaw('DATE(checked_at) as day') ->selectRaw('COUNT(*) as total') ->selectRaw("SUM(CASE WHEN status = 'failed' THEN 1 ELSE 0 END) as failed") @@ -33,27 +39,76 @@ public function index(): Response ->get(); $alertsByDay = Alert::query() - ->where('created_at', '>=', $periodStart) + ->where('created_at', '>=', $operationsPeriodStart) ->selectRaw('DATE(created_at) as day') ->selectRaw('COUNT(*) as total') ->groupBy('day') ->orderBy('day') ->get(); + $pageViewsByDay = AnalyticsPageView::query() + ->where('viewed_at', '>=', $analyticsPeriodStart) + ->selectRaw('DATE(viewed_at) as day') + ->selectRaw('COUNT(*) as total') + ->selectRaw('COUNT(DISTINCT visitor_id) as visitors') + ->groupBy('day') + ->orderBy('day') + ->get(); + + $followsByDay = MonsterFollow::query() + ->where('created_at', '>=', $analyticsPeriodStart) + ->selectRaw('DATE(created_at) as day') + ->selectRaw('COUNT(*) as total') + ->groupBy('day') + ->orderBy('day') + ->get(); + + $contributorActionsByDay = $this->mergeCountRows( + Monitor::query() + ->where('created_at', '>=', $analyticsPeriodStart) + ->whereNotNull('created_by_user_id') + ->selectRaw('DATE(created_at) as day') + ->selectRaw('COUNT(*) as total') + ->groupBy('day') + ->orderBy('day') + ->get(), + MonsterSuggestion::query() + ->where('created_at', '>=', $analyticsPeriodStart) + ->selectRaw('DATE(created_at) as day') + ->selectRaw('COUNT(*) as total') + ->groupBy('day') + ->orderBy('day') + ->get(), + ); + $snapshotSeries = $this->buildDailySeries( - $periodStart, + $operationsPeriodStart, $today, $snapshotsByDay, totalField: 'total', secondaryField: 'failed', ); $alertSeries = $this->buildDailySeries( - $periodStart, + $operationsPeriodStart, $today, $alertsByDay, totalField: 'total', secondaryField: null, ); + $trafficSeries = $this->buildDailySeries( + $analyticsPeriodStart, + $today, + $pageViewsByDay, + totalField: 'total', + secondaryField: 'visitors', + ); + $communitySeries = $this->buildDailySeries( + $analyticsPeriodStart, + $today, + $this->pairDailyRows($followsByDay, $contributorActionsByDay), + totalField: 'total', + secondaryField: 'secondary', + ); $totalMonitors = Monitor::query()->count(); $monitorsWithSelector = Monitor::query() @@ -108,6 +163,53 @@ public function index(): Response ]) ->values(); + $pageViewsLast30d = AnalyticsPageView::query() + ->where('viewed_at', '>=', $analyticsPeriodStart) + ->count(); + $uniqueVisitorsLast30d = AnalyticsPageView::query() + ->where('viewed_at', '>=', $analyticsPeriodStart) + ->distinct() + ->count('visitor_id'); + $sessionsLast30d = AnalyticsPageView::query() + ->where('viewed_at', '>=', $analyticsPeriodStart) + ->distinct() + ->count('browser_session_id'); + $engagedViewsLast30d = AnalyticsPageView::query() + ->where('viewed_at', '>=', $analyticsPeriodStart) + ->whereNotNull('engaged_at') + ->count(); + $repeatVisitorsLast30d = AnalyticsPageView::query() + ->where('viewed_at', '>=', $analyticsPeriodStart) + ->selectRaw('visitor_id') + ->groupBy('visitor_id') + ->havingRaw('COUNT(*) >= 2') + ->get() + ->count(); + $avgDurationSecondsLast30d = (int) round((float) ( + AnalyticsPageView::query() + ->where('viewed_at', '>=', $analyticsPeriodStart) + ->avg('duration_seconds') ?? 0 + )); + $signedInViewsLast30d = AnalyticsPageView::query() + ->where('viewed_at', '>=', $analyticsPeriodStart) + ->where('is_authenticated', true) + ->count(); + $guestViewsLast30d = max(0, $pageViewsLast30d - $signedInViewsLast30d); + $activeSignedInUsersLast30d = AnalyticsPageView::query() + ->where('viewed_at', '>=', $analyticsPeriodStart) + ->whereNotNull('user_id') + ->distinct() + ->count('user_id'); + $guestVisitorsLast30d = AnalyticsPageView::query() + ->where('viewed_at', '>=', $analyticsPeriodStart) + ->whereNull('user_id') + ->distinct() + ->count('visitor_id'); + $pushEnabledUsers = PushSubscription::query() + ->whereNotNull('user_id') + ->distinct() + ->count('user_id'); + return Inertia::render('Admin/Dashboard', [ 'stats' => [ 'monsters_total' => Monster::query()->count(), @@ -140,6 +242,187 @@ public function index(): Response 'alerts_daily' => $alertSeries, 'top_domains' => $topDomains, ], + 'analytics' => [ + 'summary' => [ + 'page_views_30d' => $pageViewsLast30d, + 'unique_visitors_30d' => $uniqueVisitorsLast30d, + 'sessions_30d' => $sessionsLast30d, + 'avg_duration_seconds_30d' => $avgDurationSecondsLast30d, + 'engaged_visits_percent_30d' => $pageViewsLast30d > 0 + ? (int) round(($engagedViewsLast30d / $pageViewsLast30d) * 100) + : 0, + 'repeat_visitors_30d' => $repeatVisitorsLast30d, + 'registered_users_total' => User::query()->count(), + 'new_users_30d' => User::query() + ->where('created_at', '>=', $analyticsPeriodStart) + ->count(), + 'active_signed_in_users_30d' => $activeSignedInUsersLast30d, + 'push_enabled_users' => $pushEnabledUsers, + 'signed_in_views_30d' => $signedInViewsLast30d, + 'guest_views_30d' => $guestViewsLast30d, + 'guest_visitors_30d' => $guestVisitorsLast30d, + ], + 'traffic' => [ + 'daily_views' => $trafficSeries, + 'channels' => $this->buildBreakdownRows( + AnalyticsPageView::query() + ->where('viewed_at', '>=', $analyticsPeriodStart) + ->selectRaw("COALESCE(channel, 'direct') as bucket") + ->selectRaw('COUNT(*) as total') + ->groupBy('bucket') + ->orderByDesc('total') + ->limit(6) + ->get(), + $pageViewsLast30d, + ), + 'top_referrers' => $this->buildBreakdownRows( + AnalyticsPageView::query() + ->where('viewed_at', '>=', $analyticsPeriodStart) + ->whereNotNull('referrer_host') + ->where('referrer_host', '!=', '') + ->where('channel', '!=', 'internal') + ->selectRaw('referrer_host as bucket') + ->selectRaw('COUNT(*) as total') + ->groupBy('bucket') + ->orderByDesc('total') + ->limit(6) + ->get(), + $pageViewsLast30d, + ), + 'recent_visits' => AnalyticsPageView::query() + ->with('user:id,name,email') + ->latest('viewed_at') + ->take(10) + ->get() + ->map(fn (AnalyticsPageView $view): array => [ + 'id' => $view->id, + 'path' => $view->path, + 'title' => $view->title ?: $this->fallbackPageTitle($view->path), + 'page_kind' => $view->page_kind, + 'channel' => $view->channel ?: 'direct', + 'referrer_host' => $view->referrer_host, + 'viewer_label' => $view->user?->name ?: 'Guest', + 'viewer_email' => $view->user?->email, + 'is_authenticated' => $view->is_authenticated, + 'duration_seconds' => (int) $view->duration_seconds, + 'viewed_at' => $view->viewed_at?->toIso8601String(), + 'device_type' => $view->device_type ?: 'unknown', + ]) + ->values(), + ], + 'audience' => [ + 'devices' => $this->buildBreakdownRows( + AnalyticsPageView::query() + ->where('viewed_at', '>=', $analyticsPeriodStart) + ->selectRaw("COALESCE(device_type, 'unknown') as bucket") + ->selectRaw('COUNT(*) as total') + ->groupBy('bucket') + ->orderByDesc('total') + ->limit(5) + ->get(), + $pageViewsLast30d, + ), + 'browsers' => $this->buildBreakdownRows( + AnalyticsPageView::query() + ->where('viewed_at', '>=', $analyticsPeriodStart) + ->selectRaw("COALESCE(browser_family, 'Unknown') as bucket") + ->selectRaw('COUNT(*) as total') + ->groupBy('bucket') + ->orderByDesc('total') + ->limit(6) + ->get(), + $pageViewsLast30d, + ), + 'locales' => $this->buildBreakdownRows( + AnalyticsPageView::query() + ->where('viewed_at', '>=', $analyticsPeriodStart) + ->whereNotNull('locale') + ->selectRaw('locale as bucket') + ->selectRaw('COUNT(*) as total') + ->groupBy('bucket') + ->orderByDesc('total') + ->limit(6) + ->get(), + $pageViewsLast30d, + ), + ], + 'behavior' => [ + 'community_daily' => $communitySeries, + 'top_pages' => AnalyticsPageView::query() + ->where('viewed_at', '>=', $analyticsPeriodStart) + ->where('page_kind', '!=', 'admin') + ->selectRaw('path') + ->selectRaw("MAX(COALESCE(title, path)) as title") + ->selectRaw('COUNT(*) as views') + ->selectRaw('COUNT(DISTINCT visitor_id) as unique_visitors') + ->selectRaw('AVG(duration_seconds) as avg_duration_seconds') + ->selectRaw("SUM(CASE WHEN engaged_at IS NOT NULL THEN 1 ELSE 0 END) as engaged_views") + ->groupBy('path') + ->orderByDesc('views') + ->limit(8) + ->get() + ->map(fn ($row): array => [ + 'path' => (string) $row->path, + 'title' => (string) ($row->title ?: $this->fallbackPageTitle((string) $row->path)), + 'views' => (int) $row->views, + 'unique_visitors' => (int) $row->unique_visitors, + 'avg_duration_seconds' => (int) round((float) $row->avg_duration_seconds), + 'engaged_percent' => (int) round(((int) $row->engaged_views / max(1, (int) $row->views)) * 100), + ]) + ->values(), + 'search_terms' => AnalyticsEvent::query() + ->where('occurred_at', '>=', $analyticsPeriodStart) + ->where('event_name', 'search') + ->whereNotNull('label') + ->where('label', '!=', '') + ->selectRaw('LOWER(label) as bucket') + ->selectRaw('COUNT(*) as total') + ->groupBy('bucket') + ->orderByDesc('total') + ->limit(8) + ->get() + ->map(fn ($row): array => [ + 'id' => (string) $row->bucket, + 'label' => (string) $row->bucket, + 'value' => (int) $row->total, + 'hint' => 'searches', + ]) + ->values(), + 'outbound_domains' => $this->buildBreakdownRows( + AnalyticsEvent::query() + ->where('occurred_at', '>=', $analyticsPeriodStart) + ->where('event_name', 'outbound_click') + ->whereNotNull('target_host') + ->where('target_host', '!=', '') + ->selectRaw('target_host as bucket') + ->selectRaw('COUNT(*) as total') + ->groupBy('bucket') + ->orderByDesc('total') + ->limit(8) + ->get(), + AnalyticsEvent::query() + ->where('occurred_at', '>=', $analyticsPeriodStart) + ->where('event_name', 'outbound_click') + ->count(), + ), + 'conversion' => [ + 'follows_30d' => MonsterFollow::query() + ->where('created_at', '>=', $analyticsPeriodStart) + ->count(), + 'contributor_actions_30d' => Monitor::query() + ->where('created_at', '>=', $analyticsPeriodStart) + ->whereNotNull('created_by_user_id') + ->count() + MonsterSuggestion::query() + ->where('created_at', '>=', $analyticsPeriodStart) + ->count(), + 'push_subscriptions_total' => PushSubscription::query()->count(), + 'outbound_clicks_30d' => AnalyticsEvent::query() + ->where('occurred_at', '>=', $analyticsPeriodStart) + ->where('event_name', 'outbound_click') + ->count(), + ], + ], + ], 'recentRuns' => $recentRuns, 'pushTestUsers' => User::query() ->orderBy('name') @@ -185,6 +468,122 @@ private function buildDailySeries( return $series; } + /** + * @param Collection $primaryRows + * @param Collection $secondaryRows + * @return Collection + */ + private function pairDailyRows(Collection $primaryRows, Collection $secondaryRows): Collection + { + $days = collect() + ->merge($primaryRows->pluck('day')) + ->merge($secondaryRows->pluck('day')) + ->filter() + ->unique() + ->sort() + ->values(); + + $primary = $primaryRows->keyBy(fn ($row) => (string) ($row->day ?? '')); + $secondary = $secondaryRows->keyBy(fn ($row) => (string) ($row->day ?? '')); + + return $days->map(function ($day) use ($primary, $secondary): object { + $primaryRow = $primary->get((string) $day); + $secondaryRow = $secondary->get((string) $day); + + return (object) [ + 'day' => $day, + 'total' => (int) ($primaryRow->total ?? 0), + 'secondary' => (int) ($secondaryRow->total ?? 0), + ]; + }); + } + + /** + * @param Collection ...$collections + * @return Collection + */ + private function mergeCountRows(Collection ...$collections): Collection + { + $totals = []; + + foreach ($collections as $collection) { + foreach ($collection as $row) { + $day = (string) ($row->day ?? ''); + if ($day === '') { + continue; + } + + $totals[$day] = ($totals[$day] ?? 0) + (int) ($row->total ?? 0); + } + } + + ksort($totals); + + return collect($totals) + ->map(fn (int $total, string $day): object => (object) [ + 'day' => $day, + 'total' => $total, + ]) + ->values(); + } + + /** + * @param Collection $rows + * @return array + */ + private function buildBreakdownRows(Collection $rows, int $overallTotal): array + { + return $rows + ->map(function ($row) use ($overallTotal): array { + $bucket = (string) ($row->bucket ?? ''); + $total = (int) ($row->total ?? 0); + $percent = $overallTotal > 0 + ? (int) round(($total / $overallTotal) * 100) + : 0; + + return [ + 'id' => $bucket !== '' ? $bucket : 'unknown', + 'label' => $this->labelForBucket($bucket), + 'value' => $total, + 'hint' => $percent > 0 ? $percent.'% of total' : '0% of total', + ]; + }) + ->values() + ->all(); + } + + private function labelForBucket(string $bucket): string + { + if ($bucket === '') { + return 'Unknown'; + } + + return match ($bucket) { + 'direct' => 'Direct', + 'internal' => 'Internal', + 'referral' => 'Referral', + 'search' => 'Search', + 'social' => 'Social', + 'email' => 'Email', + 'paid' => 'Paid', + default => Str::headline(str_replace(['-', '_'], ' ', $bucket)), + }; + } + + private function fallbackPageTitle(string $path): string + { + if ($path === '/') { + return 'Homepage'; + } + + $trimmed = trim($path, '/'); + if ($trimmed === '') { + return 'Homepage'; + } + + return Str::headline(str_replace('/', ' ', $trimmed)); + } + private function hasPriceSelector(Monitor $monitor): bool { $selectorConfig = $monitor->selector_config; diff --git a/codebase/app/Packages/Analytics/Http/Controllers/AnalyticsEventController.php b/codebase/app/Packages/Analytics/Http/Controllers/AnalyticsEventController.php new file mode 100644 index 0000000..b7a774e --- /dev/null +++ b/codebase/app/Packages/Analytics/Http/Controllers/AnalyticsEventController.php @@ -0,0 +1,98 @@ +validate([ + 'analytics_page_view_id' => ['nullable', 'integer', 'exists:analytics_page_views,id'], + 'visitor_id' => ['required', 'string', 'max:64'], + 'browser_session_id' => ['required', 'string', 'max:64'], + 'event_name' => ['required', 'string', 'max:64'], + 'route_name' => ['nullable', 'string', 'max:120'], + 'path' => ['nullable', 'string', 'max:255'], + 'label' => ['nullable', 'string', 'max:255'], + 'target_url' => ['nullable', 'string', 'max:2048'], + 'scroll_depth' => ['nullable', 'integer', 'min:0', 'max:100'], + 'properties' => ['nullable', 'array'], + ]); + + $pageView = null; + if (isset($validated['analytics_page_view_id'])) { + $pageView = AnalyticsPageView::query()->find($validated['analytics_page_view_id']); + } + + if ($pageView !== null) { + $this->authorizeMutation($request, $pageView, $validated['visitor_id'], $validated['browser_session_id']); + } + + $path = $validated['path'] ?? $pageView?->path; + $pageKind = is_string($path) + ? $this->trafficClassifier->pageKindForPath($path) + : ($pageView?->page_kind ?? 'public'); + $targetUrl = $this->normalizeOptionalUrl($validated['target_url'] ?? null); + + AnalyticsEvent::query()->create([ + 'analytics_page_view_id' => $pageView?->id, + 'visitor_id' => $validated['visitor_id'], + 'browser_session_id' => $validated['browser_session_id'], + 'user_id' => $request->user()?->id, + 'event_name' => $validated['event_name'], + 'route_name' => $validated['route_name'] ?? $pageView?->route_name, + 'page_kind' => $pageKind, + 'path' => $path, + 'label' => $validated['label'] ?? null, + 'target_host' => $this->trafficClassifier->extractHost($targetUrl), + 'target_url' => $targetUrl, + 'scroll_depth' => $validated['scroll_depth'] ?? null, + 'properties' => $validated['properties'] ?? null, + 'occurred_at' => now(), + ]); + + if ($pageView !== null && in_array($validated['event_name'], ['outbound_click', 'search', 'follow_create', 'follow_remove'], true)) { + $pageView->forceFill([ + 'engaged_at' => $pageView->engaged_at ?? now(), + 'last_seen_at' => now(), + ])->save(); + } + + return response()->json([], 204); + } + + private function authorizeMutation( + Request $request, + AnalyticsPageView $pageView, + string $visitorId, + string $browserSessionId, + ): void { + $belongsToVisitor = $pageView->visitor_id === $visitorId + && $pageView->browser_session_id === $browserSessionId; + $belongsToUser = $request->user() !== null + && $pageView->user_id !== null + && (int) $pageView->user_id === (int) $request->user()->id; + + abort_unless($belongsToVisitor || $belongsToUser, 403); + } + + private function normalizeOptionalUrl(?string $url): ?string + { + if (! is_string($url) || trim($url) === '') { + return null; + } + + return trim($url); + } +} diff --git a/codebase/app/Packages/Analytics/Http/Controllers/AnalyticsPageViewController.php b/codebase/app/Packages/Analytics/Http/Controllers/AnalyticsPageViewController.php new file mode 100644 index 0000000..320d197 --- /dev/null +++ b/codebase/app/Packages/Analytics/Http/Controllers/AnalyticsPageViewController.php @@ -0,0 +1,179 @@ +validate([ + 'visitor_id' => ['required', 'string', 'max:64'], + 'browser_session_id' => ['required', 'string', 'max:64'], + 'route_name' => ['nullable', 'string', 'max:120'], + 'page_component' => ['nullable', 'string', 'max:160'], + 'url' => ['required', 'string', 'max:2048'], + 'path' => ['nullable', 'string', 'max:255'], + 'title' => ['nullable', 'string', 'max:255'], + 'referrer_url' => ['nullable', 'string', 'max:2048'], + 'viewport_width' => ['nullable', 'integer', 'min:0', 'max:20000'], + 'viewport_height' => ['nullable', 'integer', 'min:0', 'max:20000'], + 'locale' => ['nullable', 'string', 'max:16'], + ]); + + $path = $this->normalizePath( + $validated['path'] ?? null, + $validated['url'], + ); + + abort_unless($path !== null, 422, 'A valid path is required.'); + + $utmValues = $this->extractUtmValues($validated['url']); + $referrerUrl = $this->normalizeOptionalUrl($validated['referrer_url'] ?? null); + $referrerHost = $this->trafficClassifier->extractHost($referrerUrl); + $userAgentDetails = $this->trafficClassifier->parseUserAgent($request->userAgent()); + + $pageView = AnalyticsPageView::query()->create([ + 'visitor_id' => $validated['visitor_id'], + 'browser_session_id' => $validated['browser_session_id'], + 'user_id' => $request->user()?->id, + 'route_name' => $validated['route_name'] ?? null, + 'page_component' => $validated['page_component'] ?? null, + 'page_kind' => $this->trafficClassifier->pageKindForPath($path), + 'path' => $path, + 'url' => $validated['url'], + 'title' => $validated['title'] ?? null, + 'referrer_host' => $referrerHost, + 'referrer_url' => $referrerUrl, + 'channel' => $this->trafficClassifier->channelForReferrer( + $referrerUrl, + $utmValues['utm_source'], + $utmValues['utm_medium'], + $request->getHost(), + ), + 'utm_source' => $utmValues['utm_source'], + 'utm_medium' => $utmValues['utm_medium'], + 'utm_campaign' => $utmValues['utm_campaign'], + 'device_type' => $userAgentDetails['device_type'], + 'browser_family' => $userAgentDetails['browser_family'], + 'os_family' => $userAgentDetails['os_family'], + 'locale' => $validated['locale'] ?? app()->getLocale(), + 'is_authenticated' => $request->user() !== null, + 'viewport_width' => $validated['viewport_width'] ?? null, + 'viewport_height' => $validated['viewport_height'] ?? null, + 'viewed_at' => now(), + 'last_seen_at' => now(), + ]); + + return response()->json([ + 'id' => $pageView->id, + ]); + } + + public function close(Request $request, AnalyticsPageView $pageView): JsonResponse + { + $validated = $request->validate([ + 'visitor_id' => ['required', 'string', 'max:64'], + 'browser_session_id' => ['required', 'string', 'max:64'], + 'duration_seconds' => ['required', 'integer', 'min:0', 'max:86400'], + 'max_scroll_depth' => ['nullable', 'integer', 'min:0', 'max:100'], + 'engaged' => ['nullable', 'boolean'], + ]); + + $this->authorizeMutation($request, $pageView, $validated['visitor_id'], $validated['browser_session_id']); + + $pageView->forceFill([ + 'duration_seconds' => max( + (int) $pageView->duration_seconds, + (int) $validated['duration_seconds'], + ), + 'max_scroll_depth' => max( + (int) $pageView->max_scroll_depth, + (int) ($validated['max_scroll_depth'] ?? 0), + ), + 'last_seen_at' => now(), + 'ended_at' => now(), + 'engaged_at' => ($validated['engaged'] ?? false) + ? ($pageView->engaged_at ?? now()) + : $pageView->engaged_at, + ])->save(); + + return response()->json([], 204); + } + + private function authorizeMutation( + Request $request, + AnalyticsPageView $pageView, + string $visitorId, + string $browserSessionId, + ): void { + $belongsToVisitor = $pageView->visitor_id === $visitorId + && $pageView->browser_session_id === $browserSessionId; + $belongsToUser = $request->user() !== null + && $pageView->user_id !== null + && (int) $pageView->user_id === (int) $request->user()->id; + + abort_unless($belongsToVisitor || $belongsToUser, 403); + } + + private function normalizeOptionalUrl(?string $url): ?string + { + if (! is_string($url) || trim($url) === '') { + return null; + } + + return trim($url); + } + + private function normalizePath(?string $path, string $url): ?string + { + $candidate = is_string($path) && trim($path) !== '' + ? trim($path) + : parse_url($url, PHP_URL_PATH); + + return is_string($candidate) && $candidate !== '' ? $candidate : null; + } + + /** + * @return array{utm_source: ?string, utm_medium: ?string, utm_campaign: ?string} + */ + private function extractUtmValues(string $url): array + { + $query = parse_url($url, PHP_URL_QUERY); + if (! is_string($query) || $query === '') { + return [ + 'utm_source' => null, + 'utm_medium' => null, + 'utm_campaign' => null, + ]; + } + + parse_str($query, $params); + + return [ + 'utm_source' => $this->normalizeNullableString($params['utm_source'] ?? null), + 'utm_medium' => $this->normalizeNullableString($params['utm_medium'] ?? null), + 'utm_campaign' => $this->normalizeNullableString($params['utm_campaign'] ?? null), + ]; + } + + private function normalizeNullableString(mixed $value): ?string + { + if (! is_string($value)) { + return null; + } + + $trimmed = trim($value); + + return $trimmed !== '' ? $trimmed : null; + } +} diff --git a/codebase/app/Packages/Analytics/Support/TrafficClassifier.php b/codebase/app/Packages/Analytics/Support/TrafficClassifier.php new file mode 100644 index 0000000..33dd26a --- /dev/null +++ b/codebase/app/Packages/Analytics/Support/TrafficClassifier.php @@ -0,0 +1,153 @@ + 'admin', + str_starts_with($path, '/contribute') => 'contribute', + str_starts_with($path, '/dashboard') => 'dashboard', + str_starts_with($path, '/monsters/') => 'monster', + str_starts_with($path, '/login'), + str_starts_with($path, '/register') => 'auth', + default => 'public', + }; + } + + public function channelForReferrer( + ?string $referrerUrl, + ?string $utmSource, + ?string $utmMedium, + string $currentHost, + ): string { + $medium = strtolower(trim((string) $utmMedium)); + $source = strtolower(trim((string) $utmSource)); + $referrerHost = strtolower((string) $this->extractHost($referrerUrl)); + $normalizedCurrentHost = strtolower($currentHost); + + if ($medium !== '') { + if (str_contains($medium, 'email') || str_contains($medium, 'newsletter')) { + return 'email'; + } + + if (str_contains($medium, 'social')) { + return 'social'; + } + + if (str_contains($medium, 'cpc') || str_contains($medium, 'paid')) { + return 'paid'; + } + } + + if ($source !== '') { + if ($this->containsAny($source, ['google', 'bing', 'duckduckgo', 'yahoo'])) { + return 'search'; + } + + if ($this->containsAny($source, ['twitter', 'x', 'facebook', 'instagram', 'reddit', 'tiktok', 'linkedin', 'youtube'])) { + return 'social'; + } + + if ($this->containsAny($source, ['email', 'newsletter', 'mailchimp'])) { + return 'email'; + } + } + + if ($referrerHost === '') { + return 'direct'; + } + + if ($normalizedCurrentHost !== '' && $referrerHost === $normalizedCurrentHost) { + return 'internal'; + } + + if ($this->containsAny($referrerHost, ['google.', 'bing.', 'duckduckgo.', 'yahoo.'])) { + return 'search'; + } + + if ($this->containsAny($referrerHost, ['twitter.com', 'x.com', 'facebook.com', 'instagram.com', 'reddit.com', 'tiktok.com', 'linkedin.com', 'youtube.com'])) { + return 'social'; + } + + if ($this->containsAny($referrerHost, ['mail.', 'outlook.', 'gmail.', 'proton.'])) { + return 'email'; + } + + return 'referral'; + } + + /** + * @return array{device_type: string, browser_family: string, os_family: string} + */ + public function parseUserAgent(?string $userAgent): array + { + $agent = strtolower(trim((string) $userAgent)); + + $deviceType = match (true) { + $agent === '' => 'unknown', + str_contains($agent, 'ipad'), + str_contains($agent, 'tablet') => 'tablet', + str_contains($agent, 'mobile'), + str_contains($agent, 'iphone'), + str_contains($agent, 'android') => 'mobile', + default => 'desktop', + }; + + $browserFamily = match (true) { + $agent === '' => 'Unknown', + str_contains($agent, 'edg/') => 'Edge', + str_contains($agent, 'opr/') || str_contains($agent, 'opera') => 'Opera', + str_contains($agent, 'chrome/') && ! str_contains($agent, 'edg/') => 'Chrome', + str_contains($agent, 'firefox/') => 'Firefox', + str_contains($agent, 'safari/') && ! str_contains($agent, 'chrome/') => 'Safari', + default => 'Other', + }; + + $osFamily = match (true) { + $agent === '' => 'Unknown', + str_contains($agent, 'iphone'), + str_contains($agent, 'ipad'), + str_contains($agent, 'ios') => 'iOS', + str_contains($agent, 'android') => 'Android', + str_contains($agent, 'mac os') || str_contains($agent, 'macintosh') => 'macOS', + str_contains($agent, 'windows') => 'Windows', + str_contains($agent, 'linux') => 'Linux', + default => 'Other', + }; + + return [ + 'device_type' => $deviceType, + 'browser_family' => $browserFamily, + 'os_family' => $osFamily, + ]; + } + + public function extractHost(?string $url): ?string + { + if (! is_string($url) || trim($url) === '') { + return null; + } + + $host = parse_url($url, PHP_URL_HOST); + + return is_string($host) && $host !== '' ? strtolower($host) : null; + } + + private function containsAny(string $haystack, array $needles): bool + { + foreach ($needles as $needle) { + if ($needle !== '' && str_contains($haystack, $needle)) { + return true; + } + } + + return false; + } +} diff --git a/codebase/app/Providers/AppServiceProvider.php b/codebase/app/Providers/AppServiceProvider.php index b7b26b4..f5b9e9b 100644 --- a/codebase/app/Providers/AppServiceProvider.php +++ b/codebase/app/Providers/AppServiceProvider.php @@ -85,6 +85,10 @@ public function boot(): void RateLimiter::for('push-test', function (Request $request): Limit { return Limit::perMinute(10)->by($this->throttleKey($request)); }); + + RateLimiter::for('analytics-ingest', function (Request $request): Limit { + return Limit::perMinute(240)->by($this->throttleKey($request)); + }); } private function throttleKey(Request $request): string diff --git a/codebase/config/services.php b/codebase/config/services.php index 82bdbe8..22992e2 100644 --- a/codebase/config/services.php +++ b/codebase/config/services.php @@ -41,4 +41,8 @@ 'redirect' => env('GOOGLE_REDIRECT_URI'), ], + 'google_analytics' => [ + 'measurement_id' => env('GOOGLE_ANALYTICS_ID', 'G-TP8SL7T968'), + ], + ]; diff --git a/codebase/database/migrations/2026_04_05_000000_create_analytics_tables.php b/codebase/database/migrations/2026_04_05_000000_create_analytics_tables.php new file mode 100644 index 0000000..64184d5 --- /dev/null +++ b/codebase/database/migrations/2026_04_05_000000_create_analytics_tables.php @@ -0,0 +1,78 @@ +id(); + $table->string('visitor_id', 64)->index(); + $table->string('browser_session_id', 64)->index(); + $table->foreignId('user_id')->nullable()->constrained()->nullOnDelete(); + $table->string('route_name')->nullable()->index(); + $table->string('page_component')->nullable()->index(); + $table->string('page_kind', 32)->nullable()->index(); + $table->string('path', 255)->index(); + $table->text('url'); + $table->string('title')->nullable(); + $table->string('referrer_host')->nullable()->index(); + $table->text('referrer_url')->nullable(); + $table->string('channel', 32)->nullable()->index(); + $table->string('utm_source')->nullable()->index(); + $table->string('utm_medium')->nullable()->index(); + $table->string('utm_campaign')->nullable()->index(); + $table->string('device_type', 32)->nullable()->index(); + $table->string('browser_family', 64)->nullable()->index(); + $table->string('os_family', 64)->nullable()->index(); + $table->string('locale', 16)->nullable(); + $table->boolean('is_authenticated')->default(false)->index(); + $table->unsignedSmallInteger('viewport_width')->nullable(); + $table->unsignedSmallInteger('viewport_height')->nullable(); + $table->unsignedSmallInteger('max_scroll_depth')->default(0); + $table->unsignedInteger('duration_seconds')->default(0); + $table->timestamp('viewed_at')->index(); + $table->timestamp('engaged_at')->nullable()->index(); + $table->timestamp('last_seen_at')->nullable(); + $table->timestamp('ended_at')->nullable(); + $table->timestamps(); + + $table->index(['viewed_at', 'page_kind']); + $table->index(['viewed_at', 'channel']); + $table->index(['viewed_at', 'device_type']); + }); + + Schema::create('analytics_events', function (Blueprint $table) { + $table->id(); + $table->foreignId('analytics_page_view_id') + ->nullable() + ->constrained('analytics_page_views') + ->nullOnDelete(); + $table->string('visitor_id', 64)->index(); + $table->string('browser_session_id', 64)->index(); + $table->foreignId('user_id')->nullable()->constrained()->nullOnDelete(); + $table->string('event_name', 64)->index(); + $table->string('route_name')->nullable()->index(); + $table->string('page_kind', 32)->nullable()->index(); + $table->string('path', 255)->nullable()->index(); + $table->string('label')->nullable(); + $table->string('target_host')->nullable()->index(); + $table->text('target_url')->nullable(); + $table->unsignedSmallInteger('scroll_depth')->nullable(); + $table->json('properties')->nullable(); + $table->timestamp('occurred_at')->index(); + $table->timestamps(); + + $table->index(['event_name', 'occurred_at']); + }); + } + + public function down(): void + { + Schema::dropIfExists('analytics_events'); + Schema::dropIfExists('analytics_page_views'); + } +}; diff --git a/codebase/resources/js/Components/AnalyticsTracker.tsx b/codebase/resources/js/Components/AnalyticsTracker.tsx new file mode 100644 index 0000000..e586914 --- /dev/null +++ b/codebase/resources/js/Components/AnalyticsTracker.tsx @@ -0,0 +1,145 @@ +import { + beginAnalyticsPageView, + flushActiveAnalyticsPageView, + markActivePageEngaged, + syncActivePageScrollDepth, + trackAnalyticsEvent, +} from '@/lib/analytics'; +import { PageProps } from '@/types'; +import { usePage } from '@inertiajs/react'; +import { useEffect, useRef } from 'react'; + +export default function AnalyticsTracker() { + const page = usePage(); + const lastTrackedUrlRef = useRef(null); + + useEffect(() => { + let cancelled = false; + + const syncPageView = async () => { + await flushActiveAnalyticsPageView('navigate'); + + if (cancelled) { + return; + } + + const currentUrl = window.location.href; + const currentPath = window.location.pathname; + const referrerUrl = + lastTrackedUrlRef.current ?? normalizeReferrer(document.referrer); + + await beginAnalyticsPageView({ + routeName: route().current() ?? null, + pageComponent: page.component, + path: currentPath, + url: currentUrl, + title: document.title, + referrerUrl, + locale: page.props.locale.current, + viewportWidth: window.innerWidth, + viewportHeight: window.innerHeight, + }); + + if (!cancelled) { + lastTrackedUrlRef.current = currentUrl; + syncActivePageScrollDepth(); + } + }; + + void syncPageView(); + + return () => { + cancelled = true; + }; + }, [page.component, page.props.locale.current, page.url]); + + useEffect(() => { + const handleScroll = () => { + syncActivePageScrollDepth(); + }; + + const handleInteraction = () => { + markActivePageEngaged(); + }; + + const handleVisibilityChange = () => { + syncActivePageScrollDepth(); + + if (document.visibilityState === 'hidden') { + void flushActiveAnalyticsPageView('pagehide'); + } + }; + + const handlePageHide = () => { + syncActivePageScrollDepth(); + void flushActiveAnalyticsPageView('pagehide'); + }; + + const handleClick = (event: MouseEvent) => { + const target = event.target; + if (!(target instanceof Element)) { + return; + } + + const anchor = target.closest('a[href]'); + if (!(anchor instanceof HTMLAnchorElement)) { + return; + } + + const href = anchor.href; + if (!href) { + return; + } + + const targetUrl = new URL(href, window.location.href); + if (targetUrl.origin === window.location.origin) { + return; + } + + markActivePageEngaged(); + + void trackAnalyticsEvent({ + eventName: 'outbound_click', + label: anchor.textContent?.trim().slice(0, 120) ?? null, + targetUrl: targetUrl.toString(), + properties: { + target: anchor.target || null, + }, + }); + }; + + syncActivePageScrollDepth(); + + window.addEventListener('scroll', handleScroll, { passive: true }); + window.addEventListener('resize', handleScroll); + window.addEventListener('pagehide', handlePageHide); + document.addEventListener('visibilitychange', handleVisibilityChange); + document.addEventListener('pointerdown', handleInteraction, true); + document.addEventListener('keydown', handleInteraction, true); + document.addEventListener('click', handleClick, true); + + return () => { + window.removeEventListener('scroll', handleScroll); + window.removeEventListener('resize', handleScroll); + window.removeEventListener('pagehide', handlePageHide); + document.removeEventListener('visibilitychange', handleVisibilityChange); + document.removeEventListener('pointerdown', handleInteraction, true); + document.removeEventListener('keydown', handleInteraction, true); + document.removeEventListener('click', handleClick, true); + }; + }, []); + + useEffect(() => { + return () => { + void flushActiveAnalyticsPageView('unmount'); + }; + }, []); + + return null; +} + +function normalizeReferrer(value: string): string | null { + const trimmed = value.trim(); + + return trimmed !== '' ? trimmed : null; +} diff --git a/codebase/resources/js/Pages/Admin/Dashboard.tsx b/codebase/resources/js/Pages/Admin/Dashboard.tsx index b19a71e..8faae24 100644 --- a/codebase/resources/js/Pages/Admin/Dashboard.tsx +++ b/codebase/resources/js/Pages/Admin/Dashboard.tsx @@ -1,8 +1,18 @@ import BarMeter from '@/Components/admin/BarMeter'; import KpiCard from '@/Components/admin/KpiCard'; import TrendLineChart from '@/Components/admin/TrendLineChart'; +import { Badge } from '@/Components/ui/badge'; import { buttonVariants } from '@/Components/ui/button'; import { Card, CardContent, CardHeader, CardTitle } from '@/Components/ui/card'; +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from '@/Components/ui/table'; +import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/Components/ui/tabs'; import { useLocale } from '@/lib/locale'; import AuthenticatedLayout from '@/Layouts/AuthenticatedLayout'; import { cn } from '@/lib/utils'; @@ -57,9 +67,84 @@ type PushTestUser = { email: string; }; +type BreakdownRow = { + id: string; + label: string; + value: number; + hint: string; +}; + +type RecentVisit = { + id: number; + path: string; + title: string; + page_kind: string; + channel: string; + referrer_host: string | null; + viewer_label: string; + viewer_email: string | null; + is_authenticated: boolean; + duration_seconds: number; + viewed_at: string | null; + device_type: string; +}; + +type TopPage = { + path: string; + title: string; + views: number; + unique_visitors: number; + avg_duration_seconds: number; + engaged_percent: number; +}; + +type AnalyticsSummary = { + page_views_30d: number; + unique_visitors_30d: number; + sessions_30d: number; + avg_duration_seconds_30d: number; + engaged_visits_percent_30d: number; + repeat_visitors_30d: number; + registered_users_total: number; + new_users_30d: number; + active_signed_in_users_30d: number; + push_enabled_users: number; + signed_in_views_30d: number; + guest_views_30d: number; + guest_visitors_30d: number; +}; + +type AnalyticsData = { + summary: AnalyticsSummary; + traffic: { + daily_views: ChartPoint[]; + channels: BreakdownRow[]; + top_referrers: BreakdownRow[]; + recent_visits: RecentVisit[]; + }; + audience: { + devices: BreakdownRow[]; + browsers: BreakdownRow[]; + locales: BreakdownRow[]; + }; + behavior: { + community_daily: ChartPoint[]; + top_pages: TopPage[]; + search_terms: BreakdownRow[]; + outbound_domains: BreakdownRow[]; + conversion: { + follows_30d: number; + contributor_actions_30d: number; + push_subscriptions_total: number; + outbound_clicks_30d: number; + }; + }; +}; + export default function AdminDashboard({ stats, charts, + analytics, recentRuns, pushTestUsers, }: { @@ -69,6 +154,7 @@ export default function AdminDashboard({ alerts_daily: ChartPoint[]; top_domains: TopDomain[]; }; + analytics: AnalyticsData; recentRuns: RecentRun[]; pushTestUsers: PushTestUser[]; }) { @@ -84,15 +170,15 @@ export default function AdminDashboard({ return ( +

- {t('Admin Operations')} + {t('Admin Intelligence')}

- {t('Control Center')} + {t('Traffic, Users, and Operations')}

-

- {t('Track monitor health, selector coverage, and scraping volume in one place.')} +

+ {t('Analyze visitor flow, user behavior, contribution activity, and monitoring health from one dashboard.')}

} @@ -158,281 +244,769 @@ export default function AdminDashboard({ -
- - - - - - -
+ + + {t('Traffic')} + {t('Audience')} + {t('Behavior')} + {t('Operations')} + -
- - - - {t('Push Test Sender')} - - - -
{ - event.preventDefault(); - pushTestForm.post(route('api.admin.push.test'), { - preserveScroll: true, - }); - }} - > -
- - -
-
- - - pushTestForm.setData('title', event.target.value) - } - required + +
+ + + + + + +
+ +
+ + + + {t('Traffic Trend (30 days)')} + + + + -
-
- - - pushTestForm.setData('body', event.target.value) - } - required +
+

+ {t('Signed-in views:')}{' '} + + {analytics.summary.signed_in_views_30d} + +

+

+ {t('Guest views:')}{' '} + + {analytics.summary.guest_views_30d} + +

+

+ {t('Guest visitors:')}{' '} + + {analytics.summary.guest_visitors_30d} + +

+
+ + + + + + + {t('Acquisition Channels')} + + + + -
-
- - - pushTestForm.setData('url', event.target.value) - } - required + -
-
- -
-
- {pushTestUsers.length === 0 && ( -

- {t('No users available to target yet.')} -

- )} -
-
-
+ + + -
- - - - {t('Snapshot Throughput (14 days)')} - - - - + + + + {t('Recent Visits')} + + + + {analytics.traffic.recent_visits.length === 0 ? ( +

+ {t('Traffic will appear here as soon as the tracker records visits.')} +

+ ) : ( + + + + {t('Page')} + {t('Viewer')} + {t('Source')} + {t('Device')} + {t('Time')} + {t('Seen')} + + + + {analytics.traffic.recent_visits.map((visit) => ( + + +
+

+ {visit.title} +

+
+ + {visit.page_kind} + + {visit.path} +
+
+
+ +
+

+ {visit.viewer_label} +

+

+ {visit.viewer_email ?? + t('Anonymous visitor')} +

+
+
+ +
+

+ {headline(visit.channel)} +

+

+ {visit.referrer_host ?? + t('Direct')} +

+
+
+ + {headline(visit.device_type)} + + + {formatDuration( + visit.duration_seconds, + )} + + + {visit.viewed_at + ? new Date( + visit.viewed_at, + ).toLocaleString( + dateLocale, + ) + : t('N/A')} + +
+ ))} +
+
+ )} +
+
+
+ + + +
+ -
-

- {t('Last 24h total:')}{' '} - - {stats.snapshots_24h} - -

-

- {t('Failed:')}{' '} - - {stats.snapshots_failed_24h} - -

-

- {t('Success rate:')}{' '} - - {stats.snapshot_success_percent_24h}% - -

-
- - - - - - - {t('Alert Activity (14 days)')} - - - - - ({ - id: domain.id, - label: domain.name, - value: domain.monitors_count, - hint: domain.domain, - }))} - emptyLabel={t('No domain usage yet.')} + - - -
+ + -
- - - - {t('Recent Monitor Runs')} - - - - {recentRuns.length === 0 && ( -

- {t('No run history yet. Trigger a monitor to start collecting operational telemetry.')} -

- )} - - {recentRuns.map((run) => ( -
-
-

- {run.monitor.monster ?? t('Unknown monster')} @{' '} - {run.monitor.site ?? t('Unknown site')} +

+ + + + {t('Device Mix')} + + + + + + + + + + + {t('Browsers')} + + + + + + + + + + + {t('Locales')} + + + + + + +
+ +
+ + + + {t('Signed-In vs Guest')} + + + +
+

+ {t('Signed-In Views')} +

+

+ {analytics.summary.signed_in_views_30d} +

+
+
+

+ {t('Guest Views')} +

+

+ {analytics.summary.guest_views_30d}

- - {run.status} -
-

- {run.monitor.domain ?? 'n/a'} + + + + + + + {t('What This Means')} + + + +

+ {t('Use signed-in activity to understand feature adoption, and guest traffic to judge how well the public board is attracting new visitors.')}

-

- {t('Started')}:{' '} - {run.started_at - ? new Date(run.started_at).toLocaleString(dateLocale) - : t('N/A')} - {' • '} - {t('Finished')}:{' '} - {run.finished_at - ? new Date(run.finished_at).toLocaleString(dateLocale) - : t('N/A')} +

+ {t('Push-enabled users show how many people are reachable for price alerts without relying on email or social re-engagement.')}

-
- ))} - - -
+ + + +
+ + +
+ + + + +
+ +
+ + + + {t('Community Activity')} + + + + + + + + + + + {t('Search Terms')} + + + + + + +
+ +
+ + + + {t('Outbound Domains')} + + + + + + + + + + + {t('Top Pages')} + + + + {analytics.behavior.top_pages.length === 0 ? ( +

+ {t('Top content will appear once traffic is recorded.')} +

+ ) : ( + + + + {t('Page')} + {t('Views')} + {t('Visitors')} + {t('Avg Time')} + {t('Engaged')} + + + + {analytics.behavior.top_pages.map((page) => ( + + +
+

+ {page.title} +

+

+ {page.path} +

+
+
+ {page.views} + + {page.unique_visitors} + + + {formatDuration( + page.avg_duration_seconds, + )} + + + {page.engaged_percent}% + +
+ ))} +
+
+ )} +
+
+
+
+ + +
+ + + + + + +
+ +
+ + + + {t('Push Test Sender')} + + + +
{ + event.preventDefault(); + pushTestForm.post(route('api.admin.push.test'), { + preserveScroll: true, + }); + }} + > +
+ + +
+
+ + + pushTestForm.setData( + 'title', + event.target.value, + ) + } + required + /> +
+
+ + + pushTestForm.setData( + 'body', + event.target.value, + ) + } + required + /> +
+
+ + + pushTestForm.setData( + 'url', + event.target.value, + ) + } + required + /> +
+
+ +
+
+ {pushTestUsers.length === 0 && ( +

+ {t('No users available to target yet.')} +

+ )} +
+
+
+ +
+ + + + {t('Snapshot Throughput (14 days)')} + + + + +
+

+ {t('Last 24h total:')}{' '} + + {stats.snapshots_24h} + +

+

+ {t('Failed:')}{' '} + + {stats.snapshots_failed_24h} + +

+

+ {t('Success rate:')}{' '} + + { + stats.snapshot_success_percent_24h + } + % + +

+
+
+
+ + + + + {t('Alert Activity (14 days)')} + + + + + ({ + id: String(domain.id), + label: domain.name, + value: domain.monitors_count, + hint: domain.domain, + }))} + emptyLabel={t( + 'No domain usage yet.', + )} + /> + + +
+ +
+ + + + {t('Recent Monitor Runs')} + + + + {recentRuns.length === 0 && ( +

+ {t('No run history yet. Trigger a monitor to start collecting operational telemetry.')} +

+ )} + + {recentRuns.map((run) => ( +
+
+

+ {run.monitor.monster ?? + t( + 'Unknown monster', + )}{' '} + @{' '} + {run.monitor.site ?? + t('Unknown site')} +

+ + {run.status} + +
+

+ {run.monitor.domain ?? + 'n/a'} +

+

+ {t('Started')}:{' '} + {run.started_at + ? new Date( + run.started_at, + ).toLocaleString( + dateLocale, + ) + : t('N/A')} + {' • '} + {t('Finished')}:{' '} + {run.finished_at + ? new Date( + run.finished_at, + ).toLocaleString( + dateLocale, + ) + : t('N/A')} +

+
+ ))} +
+
+
+
+
@@ -454,3 +1028,33 @@ function statusClass(status: string): string { return 'bg-white/10 text-white/70'; } + +function formatDuration(seconds: number): string { + if (seconds <= 0) { + return '0s'; + } + + if (seconds < 60) { + return `${seconds}s`; + } + + const minutes = Math.floor(seconds / 60); + const remainingSeconds = seconds % 60; + + if (minutes < 60) { + return remainingSeconds > 0 + ? `${minutes}m ${remainingSeconds}s` + : `${minutes}m`; + } + + const hours = Math.floor(minutes / 60); + const remainingMinutes = minutes % 60; + + return remainingMinutes > 0 ? `${hours}h ${remainingMinutes}m` : `${hours}h`; +} + +function headline(value: string): string { + return value + .replace(/[-_]+/g, ' ') + .replace(/\b\w/g, (match) => match.toUpperCase()); +} diff --git a/codebase/resources/js/Pages/Monsters/Show.tsx b/codebase/resources/js/Pages/Monsters/Show.tsx index d1ea4d3..07709f2 100644 --- a/codebase/resources/js/Pages/Monsters/Show.tsx +++ b/codebase/resources/js/Pages/Monsters/Show.tsx @@ -1,6 +1,7 @@ import LandingNav from '@/Components/public/LandingNav'; import PriceHistoryChart from '@/Components/public/PriceHistoryChart'; import { buttonVariants } from '@/Components/ui/button'; +import { trackAnalyticsEvent } from '@/lib/analytics'; import { useLocale } from '@/lib/locale'; import { cn } from '@/lib/utils'; import { PageProps } from '@/types'; @@ -350,6 +351,13 @@ export default function MonsterShow({ value !== currency, ), ); + void trackAnalyticsEvent({ + eventName: 'follow_remove', + label: monster.slug, + properties: { + currency, + }, + }); } else { await axios.post( route( @@ -374,6 +382,13 @@ export default function MonsterShow({ currency, ], ); + void trackAnalyticsEvent({ + eventName: 'follow_create', + label: monster.slug, + properties: { + currency, + }, + }); } } catch { toast.error( diff --git a/codebase/resources/js/Pages/Public/BestPricesIndex.tsx b/codebase/resources/js/Pages/Public/BestPricesIndex.tsx index c8a4609..c6d5ac6 100644 --- a/codebase/resources/js/Pages/Public/BestPricesIndex.tsx +++ b/codebase/resources/js/Pages/Public/BestPricesIndex.tsx @@ -1,6 +1,7 @@ import Hero from "@/Components/public/Hero"; import LandingNav from "@/Components/public/LandingNav"; import { Card, CardContent } from "@/Components/ui/card"; +import { trackAnalyticsEvent } from "@/lib/analytics"; import { useLocale } from "@/lib/locale"; import { PublicOfferRow, TrendingTrackRow } from "@/lib/publicPricing"; import { PageProps } from "@/types"; @@ -70,6 +71,7 @@ export default function BestPricesIndex({ }>) { const { locale, t } = useLocale(); const [query, setQuery] = useState(""); + const lastTrackedSearchRef = useRef(null); const normalizedQuery = query.trim().toLowerCase(); const copy = LANDING_COPY[locale as 'en' | 'nl'] ?? LANDING_COPY.en; const canonicalUrl = route("home"); @@ -113,6 +115,34 @@ export default function BestPricesIndex({ return () => window.clearInterval(intervalId); }, []); + useEffect(() => { + if (normalizedQuery.length < 2) { + if (normalizedQuery === "") { + lastTrackedSearchRef.current = null; + } + + return; + } + + const timeoutId = window.setTimeout(() => { + if (lastTrackedSearchRef.current === normalizedQuery) { + return; + } + + lastTrackedSearchRef.current = normalizedQuery; + + void trackAnalyticsEvent({ + eventName: "search", + label: normalizedQuery, + properties: { + results_count: filteredOffers.length, + }, + }); + }, 500); + + return () => window.clearTimeout(timeoutId); + }, [filteredOffers.length, normalizedQuery]); + return ( <> diff --git a/codebase/resources/js/app.tsx b/codebase/resources/js/app.tsx index da708c0..42474de 100644 --- a/codebase/resources/js/app.tsx +++ b/codebase/resources/js/app.tsx @@ -1,6 +1,7 @@ import '../css/app.css'; import './bootstrap'; +import AnalyticsTracker from '@/Components/AnalyticsTracker'; import { AppDialogProvider } from '@/Components/providers/AppDialogProvider'; import AppToaster from '@/Components/ui/toaster'; import { LocaleProvider } from '@/lib/locale'; @@ -114,6 +115,7 @@ createInertiaApp({ {({ Component, props: pageProps, key }) => ( <> + diff --git a/codebase/resources/js/lib/analytics.ts b/codebase/resources/js/lib/analytics.ts new file mode 100644 index 0000000..5a6452d --- /dev/null +++ b/codebase/resources/js/lib/analytics.ts @@ -0,0 +1,294 @@ +type PageViewPayload = { + routeName: string | null; + pageComponent: string; + path: string; + url: string; + title: string; + referrerUrl: string | null; + locale: string | null; + viewportWidth: number; + viewportHeight: number; +}; + +type ActivePageView = { + id: number; + visitorId: string; + browserSessionId: string; + routeName: string | null; + path: string; + startedAt: number; + maxScrollDepth: number; + engaged: boolean; +}; + +type TrackEventPayload = { + eventName: string; + label?: string | null; + targetUrl?: string | null; + scrollDepth?: number | null; + properties?: Record; +}; + +const VISITOR_STORAGE_KEY = 'monsterindex_analytics_visitor_id'; +const SESSION_STORAGE_KEY = 'monsterindex_analytics_session_id'; + +let activePageView: ActivePageView | null = null; +let pendingPageViewPromise: Promise | null = null; + +export async function beginAnalyticsPageView( + payload: PageViewPayload, +): Promise { + if (typeof window === 'undefined') { + return null; + } + + const visitorId = readOrCreateStorageId(VISITOR_STORAGE_KEY, window.localStorage); + const browserSessionId = readOrCreateStorageId( + SESSION_STORAGE_KEY, + window.sessionStorage, + ); + + const requestPayload = { + visitor_id: visitorId, + browser_session_id: browserSessionId, + route_name: payload.routeName, + page_component: payload.pageComponent, + path: payload.path, + url: payload.url, + title: payload.title, + referrer_url: payload.referrerUrl, + locale: payload.locale, + viewport_width: payload.viewportWidth, + viewport_height: payload.viewportHeight, + }; + + const startTime = Date.now(); + + pendingPageViewPromise = window.axios + .post<{ id: number }>(route('analytics.page-views.store'), requestPayload, { + headers: { + Accept: 'application/json', + }, + }) + .then(({ data }) => { + const nextView: ActivePageView = { + id: data.id, + visitorId, + browserSessionId, + routeName: payload.routeName, + path: payload.path, + startedAt: startTime, + maxScrollDepth: currentScrollDepth(), + engaged: false, + }; + + activePageView = nextView; + + return nextView; + }) + .catch(() => { + activePageView = null; + + return null; + }) + .finally(() => { + pendingPageViewPromise = null; + }); + + return pendingPageViewPromise; +} + +export async function flushActiveAnalyticsPageView( + reason: 'navigate' | 'pagehide' | 'unmount' = 'navigate', +): Promise { + const view = await resolveActivePageView(); + if (!view) { + return; + } + + activePageView = null; + + const durationSeconds = Math.max( + 0, + Math.round((Date.now() - view.startedAt) / 1000), + ); + const maxScrollDepth = Math.max(view.maxScrollDepth, currentScrollDepth()); + const engaged = + view.engaged || durationSeconds >= 12 || maxScrollDepth >= 50; + const payload = { + visitor_id: view.visitorId, + browser_session_id: view.browserSessionId, + duration_seconds: durationSeconds, + max_scroll_depth: maxScrollDepth, + engaged, + }; + + if (reason === 'pagehide') { + await sendKeepalive(route('analytics.page-views.close', view.id), payload); + + return; + } + + try { + await window.axios.post(route('analytics.page-views.close', view.id), payload, { + headers: { + Accept: 'application/json', + }, + }); + } catch { + // Analytics failures should never interrupt navigation. + } +} + +export async function trackAnalyticsEvent({ + eventName, + label = null, + targetUrl = null, + scrollDepth = null, + properties, +}: TrackEventPayload): Promise { + const view = await resolveActivePageView(); + if (!view) { + return; + } + + if ( + eventName === 'outbound_click' || + eventName === 'search' || + eventName === 'follow_create' || + eventName === 'follow_remove' + ) { + view.engaged = true; + } + + try { + await window.axios.post( + route('analytics.events.store'), + { + analytics_page_view_id: view.id, + visitor_id: view.visitorId, + browser_session_id: view.browserSessionId, + route_name: view.routeName, + path: view.path, + event_name: eventName, + label, + target_url: targetUrl, + scroll_depth: scrollDepth, + properties, + }, + { + headers: { + Accept: 'application/json', + }, + }, + ); + } catch { + // Ignore telemetry failures. + } +} + +export function syncActivePageScrollDepth(): void { + if (!activePageView) { + return; + } + + activePageView.maxScrollDepth = Math.max( + activePageView.maxScrollDepth, + currentScrollDepth(), + ); +} + +export function markActivePageEngaged(): void { + if (!activePageView) { + return; + } + + activePageView.engaged = true; +} + +function currentScrollDepth(): number { + if (typeof window === 'undefined') { + return 0; + } + + const root = document.documentElement; + const scrollableHeight = Math.max(root.scrollHeight - window.innerHeight, 0); + + if (scrollableHeight <= 0) { + return 100; + } + + return clampToPercent((window.scrollY / scrollableHeight) * 100); +} + +function clampToPercent(value: number): number { + if (!Number.isFinite(value)) { + return 0; + } + + return Math.min(100, Math.max(0, Math.round(value))); +} + +async function resolveActivePageView(): Promise { + if (activePageView) { + return activePageView; + } + + if (pendingPageViewPromise) { + return pendingPageViewPromise; + } + + return null; +} + +async function sendKeepalive(url: string, payload: Record): Promise { + if (typeof window === 'undefined' || typeof fetch !== 'function') { + return; + } + + try { + await fetch(url, { + method: 'POST', + credentials: 'same-origin', + keepalive: true, + headers: { + 'Content-Type': 'application/json', + Accept: 'application/json', + 'X-CSRF-TOKEN': csrfToken(), + 'X-Requested-With': 'XMLHttpRequest', + }, + body: JSON.stringify(payload), + }); + } catch { + // Ignore background flush failures. + } +} + +function csrfToken(): string { + const element = document.querySelector('meta[name="csrf-token"]'); + + return element?.content ?? ''; +} + +function readOrCreateStorageId( + key: string, + storage: Storage, +): string { + const existing = storage.getItem(key); + if (existing) { + return existing; + } + + const nextId = createId(); + storage.setItem(key, nextId); + + return nextId; +} + +function createId(): string { + if (typeof crypto !== 'undefined' && typeof crypto.randomUUID === 'function') { + return crypto.randomUUID(); + } + + return `mi-${Date.now()}-${Math.random().toString(16).slice(2, 10)}`; +} diff --git a/codebase/resources/views/app.blade.php b/codebase/resources/views/app.blade.php index e33cdc5..bbe1926 100644 --- a/codebase/resources/views/app.blade.php +++ b/codebase/resources/views/app.blade.php @@ -9,6 +9,8 @@ $canonical = request()->url(); $ogImage = $baseUrl !== '' ? $baseUrl.'/brand/monsterindex-og.png' : '/brand/monsterindex-og.png'; $vite = app(\Illuminate\Foundation\Vite::class); + $googleAnalyticsId = (string) config('services.google_analytics.measurement_id', ''); + $shouldLoadGoogleAnalytics = app()->environment('production') && $googleAnalyticsId !== ''; $shouldRenderViteAssets = ! app()->runningUnitTests() || is_file($vite->hotFile()) || file_exists(public_path('build/manifest.json')); @@ -35,6 +37,7 @@ + @@ -96,6 +99,16 @@ {!! json_encode($websiteSchema, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE) !!} + @if ($shouldLoadGoogleAnalytics) + + + @endif + @routes @if ($shouldRenderViteAssets) diff --git a/codebase/routes/web.php b/codebase/routes/web.php index 5a5a7d1..a9e552f 100644 --- a/codebase/routes/web.php +++ b/codebase/routes/web.php @@ -15,6 +15,8 @@ use Packages\Admin\Http\Controllers\MonsterSuggestionReviewController as AdminMonsterSuggestionReviewController; use Packages\Admin\Http\Controllers\PushTestController as AdminPushTestController; use Packages\Admin\Http\Controllers\SiteController as AdminSiteController; +use Packages\Analytics\Http\Controllers\AnalyticsEventController; +use Packages\Analytics\Http\Controllers\AnalyticsPageViewController; use Packages\Contributions\Http\Controllers\ContributorAlertController; use Packages\Contributions\Http\Controllers\FollowedMonsterController; use Packages\Contributions\Http\Controllers\MonitorContributionController; @@ -28,6 +30,15 @@ Route::get('/', [HomeController::class, 'index'])->name('home'); Route::get('/monsters/{monster:slug}', [PublicMonsterController::class, 'show'])->name('monsters.show'); Route::get('/sitemap.xml', [SitemapController::class, 'index'])->name('sitemap.xml'); +Route::post('/analytics/page-views', [AnalyticsPageViewController::class, 'store']) + ->middleware('throttle:analytics-ingest') + ->name('analytics.page-views.store'); +Route::post('/analytics/page-views/{pageView}/close', [AnalyticsPageViewController::class, 'close']) + ->middleware('throttle:analytics-ingest') + ->name('analytics.page-views.close'); +Route::post('/analytics/events', [AnalyticsEventController::class, 'store']) + ->middleware('throttle:analytics-ingest') + ->name('analytics.events.store'); Route::get('/robots.txt', function () { return response( implode("\n", [ diff --git a/codebase/tests/Feature/AdminDashboardTest.php b/codebase/tests/Feature/AdminDashboardTest.php index 28b626d..22b24c1 100644 --- a/codebase/tests/Feature/AdminDashboardTest.php +++ b/codebase/tests/Feature/AdminDashboardTest.php @@ -1,6 +1,8 @@ null, ]); + $pageView = AnalyticsPageView::query()->create([ + 'visitor_id' => 'visitor-1', + 'browser_session_id' => 'session-1', + 'user_id' => $admin->id, + 'route_name' => 'home', + 'page_component' => 'Public/BestPricesIndex', + 'page_kind' => 'landing', + 'path' => '/', + 'url' => 'https://monsterindex.test/', + 'title' => 'MonsterIndex', + 'channel' => 'direct', + 'device_type' => 'desktop', + 'browser_family' => 'Chrome', + 'os_family' => 'macOS', + 'locale' => 'en', + 'is_authenticated' => true, + 'duration_seconds' => 42, + 'max_scroll_depth' => 74, + 'viewed_at' => now()->subMinutes(3), + 'engaged_at' => now()->subMinutes(2), + 'last_seen_at' => now()->subMinutes(2), + 'ended_at' => now()->subMinutes(2), + ]); + + AnalyticsEvent::query()->create([ + 'analytics_page_view_id' => $pageView->id, + 'visitor_id' => 'visitor-1', + 'browser_session_id' => 'session-1', + 'user_id' => $admin->id, + 'event_name' => 'outbound_click', + 'page_kind' => 'landing', + 'path' => '/', + 'label' => 'Open deal', + 'target_host' => 'amazon.example', + 'target_url' => 'https://amazon.example/monster', + 'occurred_at' => now()->subMinutes(2), + ]); + $expectedMonsterCount = Monster::query()->count(); $this->actingAs($admin) @@ -66,5 +106,11 @@ ->has('charts.snapshots_daily') ->has('charts.alerts_daily') ->has('charts.top_domains') + ->where('analytics.summary.page_views_30d', 1) + ->where('analytics.summary.unique_visitors_30d', 1) + ->has('analytics.traffic.daily_views') + ->has('analytics.traffic.recent_visits') + ->has('analytics.audience.devices') + ->has('analytics.behavior.top_pages') ->has('recentRuns')); }); diff --git a/codebase/tests/Feature/AnalyticsTrackingTest.php b/codebase/tests/Feature/AnalyticsTrackingTest.php new file mode 100644 index 0000000..67daa5a --- /dev/null +++ b/codebase/tests/Feature/AnalyticsTrackingTest.php @@ -0,0 +1,114 @@ +create(); + + $response = $this->actingAs($user) + ->withHeader( + 'User-Agent', + 'Mozilla/5.0 (iPhone; CPU iPhone OS 18_0 like Mac OS X) Version/18.0 Mobile/15E148 Safari/604.1', + ) + ->postJson(route('analytics.page-views.store'), [ + 'visitor_id' => 'visitor-abc', + 'browser_session_id' => 'session-xyz', + 'route_name' => 'home', + 'page_component' => 'Public/BestPricesIndex', + 'path' => '/', + 'url' => 'https://monsterindex.test/?utm_source=reddit&utm_medium=social&utm_campaign=launch', + 'title' => 'MonsterIndex', + 'referrer_url' => 'https://www.google.com/search?q=monster+deals', + 'viewport_width' => 390, + 'viewport_height' => 844, + 'locale' => 'en', + ]); + + $response + ->assertOk() + ->assertJsonStructure(['id']); + + $pageViewId = $response->json('id'); + + $this->assertDatabaseHas('analytics_page_views', [ + 'id' => $pageViewId, + 'visitor_id' => 'visitor-abc', + 'browser_session_id' => 'session-xyz', + 'user_id' => $user->id, + 'page_kind' => 'landing', + 'channel' => 'social', + 'utm_source' => 'reddit', + 'utm_medium' => 'social', + 'utm_campaign' => 'launch', + 'device_type' => 'mobile', + 'browser_family' => 'Safari', + 'os_family' => 'iOS', + ]); + + $this->postJson(route('analytics.page-views.close', $pageViewId), [ + 'visitor_id' => 'visitor-abc', + 'browser_session_id' => 'session-xyz', + 'duration_seconds' => 55, + 'max_scroll_depth' => 68, + 'engaged' => true, + ])->assertNoContent(); + + $pageView = AnalyticsPageView::query()->findOrFail($pageViewId); + + expect($pageView->duration_seconds)->toBe(55) + ->and($pageView->max_scroll_depth)->toBe(68) + ->and($pageView->engaged_at)->not->toBeNull() + ->and($pageView->ended_at)->not->toBeNull(); +}); + +it('stores analytics behavior events against a page view', function () { + $pageView = AnalyticsPageView::query()->create([ + 'visitor_id' => 'visitor-1', + 'browser_session_id' => 'session-1', + 'page_kind' => 'monster', + 'path' => '/monsters/ultra-white', + 'url' => 'https://monsterindex.test/monsters/ultra-white', + 'title' => 'Ultra White', + 'channel' => 'direct', + 'device_type' => 'desktop', + 'browser_family' => 'Chrome', + 'os_family' => 'Windows', + 'locale' => 'en', + 'is_authenticated' => false, + 'viewed_at' => now()->subMinute(), + 'last_seen_at' => now()->subMinute(), + ]); + + $this->postJson(route('analytics.events.store'), [ + 'analytics_page_view_id' => $pageView->id, + 'visitor_id' => 'visitor-1', + 'browser_session_id' => 'session-1', + 'event_name' => 'outbound_click', + 'path' => '/monsters/ultra-white', + 'label' => 'Open Cheapest Deal', + 'target_url' => 'https://shop.example/monster', + 'properties' => [ + 'target' => '_blank', + ], + ])->assertNoContent(); + + $this->assertDatabaseHas('analytics_events', [ + 'analytics_page_view_id' => $pageView->id, + 'visitor_id' => 'visitor-1', + 'browser_session_id' => 'session-1', + 'event_name' => 'outbound_click', + 'target_host' => 'shop.example', + ]); + + $event = AnalyticsEvent::query()->where('analytics_page_view_id', $pageView->id)->firstOrFail(); + + expect($event->properties)->toBe([ + 'target' => '_blank', + ]); + + $pageView->refresh(); + + expect($pageView->engaged_at)->not->toBeNull(); +});