Enable Discovery debug logging for production troubleshooting

- Add DISCOVERY_LOG_LEVEL=debug
- Add DISCOVERY_SHOW_PROGRESS=true
- Temporary changes for debugging InitializerProcessor fixes on production
This commit is contained in:
2025-08-11 20:13:26 +02:00
parent 59fd3dd3b1
commit 55a330b223
3683 changed files with 2956207 additions and 16948 deletions

View File

@@ -0,0 +1,353 @@
<?php
declare(strict_types=1);
namespace App\Application\Analytics;
use App\Application\Analytics\Service\AnalyticsDashboardService;
use App\Application\Analytics\Service\AnalyticsRealTimeService;
use App\Application\Analytics\Service\AnalyticsReportService;
use App\Framework\Analytics\AnalyticsCategory;
use App\Framework\Analytics\AnalyticsCollector;
use App\Framework\Analytics\Storage\AnalyticsStorage;
use App\Framework\Attributes\Route;
use App\Framework\Http\Method;
use App\Framework\Http\Status;
use App\Framework\Meta\StaticPageMetaResolver;
use App\Framework\Router\Result\JsonResult;
use App\Framework\Router\Result\ViewResult;
final class AnalyticsController
{
public function __construct(
private AnalyticsCollector $analyticsCollector,
private AnalyticsStorage $storage,
private AnalyticsDashboardService $dashboardService,
private AnalyticsReportService $reportService,
private AnalyticsRealTimeService $realTimeService,
) {
}
#[Route(path: '/admin/analytics', method: Method::GET)]
public function dashboard(): ViewResult
{
return new ViewResult(
'analytics-dashboard',
new StaticPageMetaResolver(
'Analytics Dashboard',
'View website analytics and user behavior data'
)(),
[
'title' => 'Analytics Dashboard',
'description' => 'Monitor your website performance and user engagement',
]
);
}
#[Route(path: '/admin/analytics/api/overview', method: Method::GET)]
public function getOverview(): JsonResult
{
try {
$endDate = $_GET['end_date'] ?? date('Y-m-d');
$startDate = $_GET['start_date'] ?? date('Y-m-d', strtotime('-30 days'));
// Calculate days between dates
$days = max(1, (strtotime($endDate) - strtotime($startDate)) / 86400 + 1);
$overview = $this->dashboardService->getOverview($startDate, $endDate);
return new JsonResult([
'success' => true,
'data' => $overview,
'period' => [
'start' => $startDate,
'end' => $endDate,
'days' => (int)$days,
],
]);
} catch (\Exception $e) {
return new JsonResult([
'success' => false,
'error' => $e->getMessage(),
], Status::from(500));
}
}
#[Route(path: '/admin/analytics/api/timeseries', method: Method::GET)]
public function getTimeSeries(): JsonResult
{
try {
$metric = $_GET['metric'] ?? 'page_views';
$period = $_GET['period'] ?? 'day';
$days = (int) ($_GET['days'] ?? 30);
$endDate = date('Y-m-d');
$startDate = date('Y-m-d', strtotime("-{$days} days"));
$timeSeries = $this->storage->getTimeSeries($metric, $startDate, $endDate, $period);
return new JsonResult([
'success' => true,
'data' => $timeSeries,
'metric' => $metric,
'period' => $period,
'range' => [
'start' => $startDate,
'end' => $endDate,
'days' => $days,
],
]);
} catch (\Exception $e) {
return new JsonResult([
'success' => false,
'error' => $e->getMessage(),
], Status::from(500));
}
}
#[Route(path: '/admin/analytics/api/top-pages', method: Method::GET)]
public function getTopPages(): JsonResult
{
try {
$limit = (int) ($_GET['limit'] ?? 10);
$days = (int) ($_GET['days'] ?? 30);
$endDate = date('Y-m-d');
$startDate = date('Y-m-d', strtotime("-{$days} days"));
$topPages = $this->dashboardService->getTopPages($startDate, $endDate, $limit);
return new JsonResult([
'success' => true,
'data' => $topPages,
'limit' => $limit,
'period' => [
'start' => $startDate,
'end' => $endDate,
'days' => $days,
],
]);
} catch (\Exception $e) {
return new JsonResult([
'success' => false,
'error' => $e->getMessage(),
], Status::from(500));
}
}
#[Route(path: '/admin/analytics/api/traffic-sources', method: Method::GET)]
public function getTrafficSources(): JsonResult
{
try {
$days = (int) ($_GET['days'] ?? 30);
$endDate = date('Y-m-d');
$startDate = date('Y-m-d', strtotime("-{$days} days"));
$sources = $this->dashboardService->getTrafficSources($startDate, $endDate);
return new JsonResult([
'success' => true,
'data' => $sources,
'period' => [
'start' => $startDate,
'end' => $endDate,
'days' => $days,
],
]);
} catch (\Exception $e) {
return new JsonResult([
'success' => false,
'error' => $e->getMessage(),
], Status::from(500));
}
}
#[Route(path: '/admin/analytics/api/user-behavior', method: Method::GET)]
public function getUserBehavior(): JsonResult
{
try {
$days = (int) ($_GET['days'] ?? 30);
$endDate = date('Y-m-d');
$startDate = date('Y-m-d', strtotime("-{$days} days"));
$behavior = $this->reportService->getUserBehavior($startDate, $endDate);
return new JsonResult([
'success' => true,
'data' => $behavior,
'period' => [
'start' => $startDate,
'end' => $endDate,
'days' => $days,
],
]);
} catch (\Exception $e) {
return new JsonResult([
'success' => false,
'error' => $e->getMessage(),
], Status::from(500));
}
}
#[Route(path: '/admin/analytics/api/business-metrics', method: Method::GET)]
public function getBusinessMetrics(): JsonResult
{
try {
$days = (int) ($_GET['days'] ?? 30);
$endDate = date('Y-m-d');
$startDate = date('Y-m-d', strtotime("-{$days} days"));
$metrics = $this->reportService->getBusinessMetrics($startDate, $endDate);
return new JsonResult([
'success' => true,
'data' => $metrics,
'period' => [
'start' => $startDate,
'end' => $endDate,
'days' => $days,
],
]);
} catch (\Exception $e) {
return new JsonResult([
'success' => false,
'error' => $e->getMessage(),
], Status::from(500));
}
}
#[Route(path: '/admin/analytics/api/real-time', method: Method::GET)]
public function getRealTimeData(): JsonResult
{
try {
$realTime = $this->realTimeService->getRealTimeData();
return new JsonResult([
'success' => true,
'data' => $realTime,
'timestamp' => time(),
'updated_at' => date('Y-m-d H:i:s'),
]);
} catch (\Exception $e) {
return new JsonResult([
'success' => false,
'error' => $e->getMessage(),
], Status::from(500));
}
}
#[Route(path: '/admin/analytics/api/events', method: Method::GET)]
public function getEvents(): JsonResult
{
try {
$category = $_GET['category'] ?? null;
$limit = (int) ($_GET['limit'] ?? 100);
$offset = (int) ($_GET['offset'] ?? 0);
$days = (int) ($_GET['days'] ?? 7);
$endDate = date('Y-m-d');
$startDate = date('Y-m-d', strtotime("-{$days} days"));
$categoryFilter = $category ? AnalyticsCategory::tryFrom($category) : null;
$events = $this->reportService->getEvents($startDate, $endDate, $categoryFilter, $limit, $offset);
$total = $this->reportService->getEventsCount($startDate, $endDate, $categoryFilter);
return new JsonResult([
'success' => true,
'data' => $events,
'pagination' => [
'limit' => $limit,
'offset' => $offset,
'total' => $total,
'has_more' => ($offset + $limit) < $total,
],
'filter' => [
'category' => $category,
'start' => $startDate,
'end' => $endDate,
],
]);
} catch (\Exception $e) {
return new JsonResult([
'success' => false,
'error' => $e->getMessage(),
], Status::from(500));
}
}
#[Route(path: '/admin/analytics/api/export', method: Method::GET)]
public function exportData(): JsonResult
{
try {
$format = $_GET['format'] ?? 'json';
$category = $_GET['category'] ?? null;
$days = (int) ($_GET['days'] ?? 30);
$endDate = date('Y-m-d');
$startDate = date('Y-m-d', strtotime("-{$days} days"));
$categoryFilter = $category ? AnalyticsCategory::tryFrom($category) : null;
$data = $this->reportService->exportData($startDate, $endDate, $categoryFilter, $format);
return new JsonResult([
'success' => true,
'data' => $data,
'format' => $format,
'exported_at' => date('Y-m-d H:i:s'),
'period' => [
'start' => $startDate,
'end' => $endDate,
'days' => $days,
],
]);
} catch (\Exception $e) {
return new JsonResult([
'success' => false,
'error' => $e->getMessage(),
], Status::from(500));
}
}
#[Route(path: '/admin/analytics/api/track', method: Method::POST)]
public function trackEvent(): JsonResult
{
try {
$action = $_POST['action'] ?? null;
$category = $_POST['category'] ?? 'user_behavior';
$properties = json_decode($_POST['properties'] ?? '{}', true);
if (! $action) {
return new JsonResult([
'success' => false,
'error' => 'Action is required',
], Status::from(400));
}
$categoryEnum = AnalyticsCategory::tryFrom($category);
if (! $categoryEnum) {
return new JsonResult([
'success' => false,
'error' => 'Invalid category',
], Status::from(400));
}
$this->analyticsCollector->trackAction($action, $categoryEnum, $properties);
return new JsonResult([
'success' => true,
'message' => 'Event tracked successfully',
'tracked_at' => date('Y-m-d H:i:s'),
]);
} catch (\Exception $e) {
return new JsonResult([
'success' => false,
'error' => $e->getMessage(),
], Status::from(500));
}
}
}

View File

@@ -0,0 +1,23 @@
<?php
declare(strict_types=1);
namespace App\Application\Analytics\Contracts;
/**
* Interface for Value Objects that can be converted to legacy array format
*/
interface LegacyArrayConvertible
{
/**
* Convert to legacy array format for backward compatibility
* @return array<string, mixed>
*/
public function toArray(): array;
/**
* Convert to enhanced analytics array format
* @return array<string, mixed>
*/
public function toAnalyticsArray(): array;
}

View File

@@ -0,0 +1,110 @@
<?php
declare(strict_types=1);
namespace App\Application\Analytics\Service;
use App\Framework\Analytics\Storage\AnalyticsStorage;
/**
* Service für Analytics Dashboard-Daten
* Verantwortlich für: Zusammenfassung und Aufbereitung von Dashboard-Metriken
*/
final readonly class AnalyticsDashboardService
{
public function __construct(
private AnalyticsStorage $storage
) {
}
/**
* @return array<string, int|float>
*/
public function getOverview(string $startDate, string $endDate): array
{
$timeSeries = $this->storage->getTimeSeries('page_views_total', $startDate, $endDate, 'hour');
$aggregatedData = $this->storage->getAggregated($startDate, $endDate, 'hour');
$totalPageViews = array_sum(array_column($timeSeries, 'value'));
// Falls keine Daten gefunden, schaue direkt in aggregierten Daten
if ($totalPageViews === 0 && ! empty($aggregatedData)) {
foreach ($aggregatedData as $period => $data) {
if (isset($data['page_views_total'])) {
$totalPageViews += $data['page_views_total'];
}
}
}
// Berechne geschätzte Metriken basierend auf verfügbaren Daten
$estimatedVisitors = (int) ($totalPageViews * 0.7); // 70% unique visitors
$bounceRate = 0.35; // Geschätzte Absprungrate
$conversionRate = 0.02; // Geschätzte Conversion Rate
return [
'total_page_views' => $totalPageViews,
'unique_visitors' => $estimatedVisitors,
'bounce_rate' => $bounceRate,
'avg_session_duration' => 180.5,
'conversion_rate' => $conversionRate,
'error_rate' => 0.01,
];
}
/**
* @return array<string, int>
*/
public function getTopPages(string $startDate, string $endDate, int $limit = 10): array
{
// Hole alle aggregierten Daten und suche nach page_views_* Mustern
$aggregatedData = $this->storage->getAggregated($startDate, $endDate, 'hour');
$pageViews = [];
foreach ($aggregatedData as $period => $data) {
foreach ($data as $key => $value) {
// Suche nach page_views_/path Pattern
if (str_starts_with($key, 'page_views_/')) {
$path = substr($key, 11); // Remove 'page_views_' prefix
if (! isset($pageViews[$path])) {
$pageViews[$path] = 0;
}
$pageViews[$path] += (int)$value;
}
}
}
// Sortiere nach Views
arsort($pageViews);
// Formatiere für Ausgabe
$topPages = [];
foreach (array_slice($pageViews, 0, $limit, true) as $path => $views) {
// Verwende deterministischen Wert basierend auf dem Pfad für konsistente Bounce-Rate
$pathHash = crc32($path);
$consistentBounceRate = 0.3 + (($pathHash % 21) / 100); // 0.30 bis 0.50
$topPages[] = [
'path' => $path,
'views' => $views,
'unique_visitors' => (int)($views * 0.7), // Schätzung
'bounce_rate' => round($consistentBounceRate, 2),
];
}
return $topPages;
}
/**
* @return array<string, int>
*/
public function getTrafficSources(string $startDate, string $endDate): array
{
// Simuliere Traffic-Quellen
return [
'Direct' => 45,
'Google' => 35,
'Social Media' => 12,
'Referral' => 8,
];
}
}

View File

@@ -0,0 +1,108 @@
<?php
declare(strict_types=1);
namespace App\Application\Analytics\Service;
use App\Framework\Analytics\Storage\AnalyticsStorage;
/**
* Service für Real-Time Analytics
* Verantwortlich für: Live-Daten, aktuelle Benutzer, Real-Time Events
*/
final readonly class AnalyticsRealTimeService
{
public function __construct(
private AnalyticsStorage $storage
) {
}
/**
* @return array{active_users: int, current_page_views: array<string, mixed>, recent_actions: array<int, mixed>, live_traffic: array<string, mixed>}
*/
public function getRealTimeData(): array
{
return [
'active_users' => $this->getActiveUsers(),
'current_page_views' => $this->getCurrentPageViews(),
'recent_actions' => $this->getRecentActions(50),
'live_traffic' => $this->getLiveTraffic(),
];
}
public function getActiveUsers(): int
{
// Simuliere aktive Benutzer
return rand(15, 45);
}
/**
* @return array<string, int>
*/
public function getCurrentPageViews(): array
{
return [
'/' => 12,
'/products' => 8,
'/about' => 3,
'/contact' => 2,
'/blog' => 5,
];
}
/**
* @return array<int, array{timestamp: string, action: string, page: string, user: string}>
*/
public function getRecentActions(int $limit = 50): array
{
$actions = [];
$now = time();
for ($i = 0; $i < $limit; $i++) {
$actions[] = [
'timestamp' => date('H:i:s', $now - ($i * 30)), // Alle 30 Sekunden
'action' => $this->getRandomAction(),
'page' => $this->getRandomPage(),
'user_id' => 'user_' . rand(1000, 9999),
'country' => $this->getRandomCountry(),
];
}
return $actions;
}
/**
* @return array{visitors_last_minute: int, visitors_last_5_minutes: int, visitors_last_30_minutes: int, peak_concurrent_users: int, current_bounce_rate: float}
*/
public function getLiveTraffic(): array
{
return [
'visitors_last_minute' => rand(5, 15),
'visitors_last_5_minutes' => rand(25, 75),
'visitors_last_30_minutes' => rand(150, 450),
'peak_concurrent_users' => rand(50, 120),
'current_bounce_rate' => round(rand(25, 45) / 100, 2),
];
}
private function getRandomAction(): string
{
$actions = ['page_view', 'button_click', 'form_submit', 'download', 'search'];
return $actions[array_rand($actions)];
}
private function getRandomPage(): string
{
$pages = ['/', '/products', '/about', '/contact', '/blog', '/impressum', '/datenschutz'];
return $pages[array_rand($pages)];
}
private function getRandomCountry(): string
{
$countries = ['Germany', 'Austria', 'Switzerland', 'Netherlands', 'France'];
return $countries[array_rand($countries)];
}
}

View File

@@ -0,0 +1,237 @@
<?php
declare(strict_types=1);
namespace App\Application\Analytics\Service;
use App\Application\Analytics\ValueObject\ActionBreakdown;
use App\Application\Analytics\ValueObject\BrowserBreakdown;
use App\Application\Analytics\ValueObject\BusinessMetricsReport;
use App\Application\Analytics\ValueObject\CountryBreakdown;
use App\Application\Analytics\ValueObject\DeviceBreakdown;
use App\Application\Analytics\ValueObject\UserBehaviorReport;
use App\Framework\Analytics\AnalyticsCategory;
use App\Framework\Analytics\Storage\AnalyticsStorage;
/**
* Service für Analytics-Reports und Datenexport
* Verantwortlich für: Report-Generierung, Datenexport, Filterung
*/
final readonly class AnalyticsReportService
{
public function __construct(
private AnalyticsStorage $storage
) {
}
public function getUserBehavior(string $startDate, string $endDate): UserBehaviorReport
{
$actions = ActionBreakdown::fromArray($this->getTopActions($startDate, $endDate, 20));
$devices = DeviceBreakdown::fromArray($this->getDeviceBreakdown($startDate, $endDate));
$browsers = BrowserBreakdown::fromArray($this->getBrowserBreakdown($startDate, $endDate));
$countries = CountryBreakdown::fromArray($this->getCountryBreakdown($startDate, $endDate));
return new UserBehaviorReport($actions, $devices, $browsers, $countries);
}
public function getBusinessMetrics(string $startDate, string $endDate): BusinessMetricsReport
{
return new BusinessMetricsReport(
conversions: $this->getConversions($startDate, $endDate),
revenue: $this->getRevenue($startDate, $endDate),
goalCompletions: $this->getGoalCompletions($startDate, $endDate),
funnelData: $this->getFunnelData($startDate, $endDate)
);
}
// Backward compatibility methods (deprecated)
/**
* @deprecated Use getUserBehavior() which returns UserBehaviorReport instead
* @return array<string, array<int|string, mixed>>
*/
public function getUserBehaviorArray(string $startDate, string $endDate): array
{
return $this->getUserBehavior($startDate, $endDate)->toArray();
}
/**
* @deprecated Use getBusinessMetrics() which returns BusinessMetricsReport instead
* @return array<string, array<int|string, mixed>>
*/
public function getBusinessMetricsArray(string $startDate, string $endDate): array
{
return $this->getBusinessMetrics($startDate, $endDate)->toArray();
}
/**
* @return array<int, array<string, mixed>>
*/
public function getEvents(string $startDate, string $endDate, ?AnalyticsCategory $category = null, int $limit = 100, int $offset = 0): array
{
// Simuliere Events basierend auf verfügbaren Daten
$events = [];
$totalEvents = 50; // Simulation
for ($i = $offset; $i < min($offset + $limit, $totalEvents); $i++) {
$events[] = [
'id' => $i + 1,
'timestamp' => date('Y-m-d H:i:s', strtotime($startDate) + ($i * 3600)),
'action' => 'page_view',
'category' => $category?->value ?? 'page_views',
'properties' => [
'path' => '/',
'user_agent' => 'Mozilla/5.0...',
],
];
}
return $events;
}
public function getEventsCount(string $startDate, string $endDate, ?AnalyticsCategory $category = null): int
{
// Simuliere Event-Count
return 150;
}
/**
* @return array<string, mixed>
*/
public function exportData(string $startDate, string $endDate, ?AnalyticsCategory $category = null, string $format = 'json'): array
{
$data = [
'overview' => $this->storage->getAggregated($startDate, $endDate, 'day'),
'timeseries' => $this->storage->getTimeSeries('page_views', $startDate, $endDate, 'day'),
'top_pages' => $this->storage->getTopList('page_views', $startDate, $endDate, 20),
];
if ($format === 'csv') {
// Convert to CSV format
return $this->convertToCsv($data);
}
return $data;
}
/**
* @return array<int, array{action: string, count: int}>
*/
private function getTopActions(string $startDate, string $endDate, int $limit): array
{
return [
['action' => 'page_view', 'count' => 1250],
['action' => 'button_click', 'count' => 340],
['action' => 'form_submit', 'count' => 89],
['action' => 'download', 'count' => 45],
];
}
/**
* @return array<string, int>
*/
private function getDeviceBreakdown(string $startDate, string $endDate): array
{
return [
'Desktop' => 60,
'Mobile' => 35,
'Tablet' => 5,
];
}
/**
* @return array<string, int>
*/
private function getBrowserBreakdown(string $startDate, string $endDate): array
{
return [
'Chrome' => 65,
'Firefox' => 20,
'Safari' => 10,
'Edge' => 5,
];
}
/**
* @return array<string, int>
*/
private function getCountryBreakdown(string $startDate, string $endDate): array
{
return [
'Germany' => 70,
'Austria' => 15,
'Switzerland' => 10,
'Others' => 5,
];
}
/**
* @return array<string, int>
*/
private function getConversions(string $startDate, string $endDate): array
{
return [
'signup' => 45,
'purchase' => 12,
'download' => 89,
'contact' => 23,
];
}
/**
* @return array<string, string|int|float>
*/
private function getRevenue(string $startDate, string $endDate): array
{
return [
'total' => 2345.67,
'currency' => 'EUR',
'transactions' => 12,
'average_order_value' => 195.47,
];
}
/**
* @return array<string, int>
*/
private function getGoalCompletions(string $startDate, string $endDate): array
{
return [
'newsletter_signup' => 234,
'contact_form' => 45,
'product_view' => 1234,
'checkout_start' => 67,
];
}
/**
* @return array<string, mixed>
*/
private function getFunnelData(string $startDate, string $endDate): array
{
return [
'steps' => [
['name' => 'Landing Page', 'visitors' => 1000, 'conversion' => 1.0],
['name' => 'Product View', 'visitors' => 650, 'conversion' => 0.65],
['name' => 'Add to Cart', 'visitors' => 130, 'conversion' => 0.20],
['name' => 'Checkout', 'visitors' => 65, 'conversion' => 0.50],
['name' => 'Purchase', 'visitors' => 32, 'conversion' => 0.49],
],
'overall_conversion' => 0.032,
];
}
/**
* @param array<string, mixed> $data
* @return array<string, string>
*/
private function convertToCsv(array $data): array
{
// Vereinfachte CSV-Konvertierung
return [
'format' => 'csv',
'data' => 'Date,Page Views,Unique Visitors\n' .
implode('\n', array_map(fn ($date, $views) => "$date,$views,0", array_keys($data['timeseries'] ?? []), array_values($data['timeseries'] ?? []))),
];
}
}

View File

@@ -0,0 +1,110 @@
<?php
declare(strict_types=1);
namespace App\Application\Analytics\ValueObject;
use App\Framework\Core\ValueObjects\Percentage;
/**
* User action breakdown for analytics reporting
*/
final readonly class ActionBreakdown
{
/**
* @param array<int, array{action: string, count: int}> $actions
*/
public function __construct(
public array $actions
) {
foreach ($actions as $action) {
if ($action['count'] < 0) {
throw new \InvalidArgumentException('Action counts cannot be negative');
}
}
}
/**
* @param array<int, array{action: string, count: int}> $actions
*/
public static function fromArray(array $actions): self
{
return new self($actions);
}
public function getTotal(): int
{
return array_sum(array_column($this->actions, 'count'));
}
/**
* @return array{action: string, count: int}|null
*/
public function getTopAction(): ?array
{
if (empty($this->actions)) {
return null;
}
$sorted = $this->actions;
usort($sorted, fn ($a, $b) => $b['count'] <=> $a['count']);
return $sorted[0];
}
/**
* @param int $limit
* @return array<int, array{action: string, count: int, percentage: string}>
*/
public function getTopActions(int $limit = 10): array
{
$sorted = $this->actions;
usort($sorted, fn ($a, $b) => $b['count'] <=> $a['count']);
$total = $this->getTotal();
$topActions = array_slice($sorted, 0, $limit);
return array_map(function ($action) use ($total) {
return [
'action' => $action['action'],
'count' => $action['count'],
'percentage' => Percentage::fromRatio($action['count'], $total)->format(),
];
}, $topActions);
}
public function getActionCount(string $actionName): int
{
foreach ($this->actions as $action) {
if ($action['action'] === $actionName) {
return $action['count'];
}
}
return 0;
}
public function getActionPercentage(string $actionName): Percentage
{
$count = $this->getActionCount($actionName);
return Percentage::fromRatio($count, $this->getTotal());
}
/**
* @return array<int, array{action: string, count: int}>
*/
public function toArray(): array
{
return $this->actions;
}
/**
* Legacy format for backward compatibility
* @return array<int, array{action: string, count: int}>
*/
public function toAnalyticsArray(): array
{
return $this->actions;
}
}

View File

@@ -0,0 +1,95 @@
<?php
declare(strict_types=1);
namespace App\Application\Analytics\ValueObject;
use App\Framework\Core\ValueObjects\Percentage;
/**
* Browser usage breakdown for analytics reporting
*/
final readonly class BrowserBreakdown
{
public function __construct(
public int $chrome,
public int $firefox,
public int $safari,
public int $edge,
) {
if ($chrome < 0 || $firefox < 0 || $safari < 0 || $edge < 0) {
throw new \InvalidArgumentException('Browser counts cannot be negative');
}
}
/**
* Create from legacy array format: ['Chrome' => 65, 'Firefox' => 20, ...]
* @param array<string, int> $data
*/
public static function fromArray(array $data): self
{
return new self(
chrome: $data['Chrome'] ?? 0,
firefox: $data['Firefox'] ?? 0,
safari: $data['Safari'] ?? 0,
edge: $data['Edge'] ?? 0,
);
}
public function getTotal(): int
{
return $this->chrome + $this->firefox + $this->safari + $this->edge;
}
public function getChromePercentage(): Percentage
{
return Percentage::fromRatio($this->chrome, $this->getTotal());
}
public function getFirefoxPercentage(): Percentage
{
return Percentage::fromRatio($this->firefox, $this->getTotal());
}
public function getSafariPercentage(): Percentage
{
return Percentage::fromRatio($this->safari, $this->getTotal());
}
public function getEdgePercentage(): Percentage
{
return Percentage::fromRatio($this->edge, $this->getTotal());
}
/**
* @return array<string, int>
*/
public function toArray(): array
{
return [
'Chrome' => $this->chrome,
'Firefox' => $this->firefox,
'Safari' => $this->safari,
'Edge' => $this->edge,
];
}
/**
* Legacy format for backward compatibility
* @return array<string, mixed>
*/
public function toAnalyticsArray(): array
{
return [
'Chrome' => $this->chrome,
'Firefox' => $this->firefox,
'Safari' => $this->safari,
'Edge' => $this->edge,
'chrome_percentage' => $this->getChromePercentage()->format(),
'firefox_percentage' => $this->getFirefoxPercentage()->format(),
'safari_percentage' => $this->getSafariPercentage()->format(),
'edge_percentage' => $this->getEdgePercentage()->format(),
'total' => $this->getTotal(),
];
}
}

View File

@@ -0,0 +1,171 @@
<?php
declare(strict_types=1);
namespace App\Application\Analytics\ValueObject;
/**
* Business metrics analytics report containing conversion and revenue data
*/
final readonly class BusinessMetricsReport
{
/**
* @param array<string, int> $conversions
* @param array<string, string|int|float> $revenue
* @param array<string, int> $goalCompletions
* @param array<string, mixed> $funnelData
*/
public function __construct(
public array $conversions,
public array $revenue,
public array $goalCompletions,
public array $funnelData,
) {
// Validate conversions - only check business logic
foreach ($conversions as $count) {
if ($count < 0) {
throw new \InvalidArgumentException('Conversion counts cannot be negative');
}
}
// Validate goal completions - only check business logic
foreach ($goalCompletions as $completions) {
if ($completions < 0) {
throw new \InvalidArgumentException('Goal completion counts cannot be negative');
}
}
// Validate required revenue fields
$requiredRevenueFields = ['total', 'currency', 'transactions', 'average_order_value'];
foreach ($requiredRevenueFields as $field) {
if (! array_key_exists($field, $revenue)) {
throw new \InvalidArgumentException("Revenue must contain field: {$field}");
}
}
}
public function getTotalConversions(): int
{
return array_sum($this->conversions);
}
public function getTotalRevenue(): float
{
return (float) $this->revenue['total'];
}
public function getCurrency(): string
{
return (string) $this->revenue['currency'];
}
public function getTransactionCount(): int
{
return (int) $this->revenue['transactions'];
}
public function getAverageOrderValue(): float
{
return (float) $this->revenue['average_order_value'];
}
public function getTotalGoalCompletions(): int
{
return array_sum($this->goalCompletions);
}
public function getConversionRate(): float
{
if (! isset($this->funnelData['overall_conversion'])) {
return 0.0;
}
return (float) $this->funnelData['overall_conversion'];
}
public function getTopConversionType(): ?string
{
if (empty($this->conversions)) {
return null;
}
return array_key_first(
array_slice($this->conversions, 0, 1, true)
);
}
public function getTopGoal(): ?string
{
if (empty($this->goalCompletions)) {
return null;
}
$sortedGoals = $this->goalCompletions;
arsort($sortedGoals);
return array_key_first($sortedGoals);
}
/**
* Calculate revenue per conversion
*/
public function getRevenuePerConversion(): float
{
$totalConversions = $this->getTotalConversions();
if ($totalConversions === 0) {
return 0.0;
}
return $this->getTotalRevenue() / $totalConversions;
}
/**
* Get business insights and KPIs
* @return array<string, mixed>
*/
public function getInsights(): array
{
return [
'total_revenue' => $this->getTotalRevenue(),
'currency' => $this->getCurrency(),
'total_conversions' => $this->getTotalConversions(),
'total_transactions' => $this->getTransactionCount(),
'average_order_value' => $this->getAverageOrderValue(),
'revenue_per_conversion' => $this->getRevenuePerConversion(),
'overall_conversion_rate' => $this->getConversionRate() * 100, // as percentage
'top_conversion_type' => $this->getTopConversionType(),
'top_goal' => $this->getTopGoal(),
'is_profitable' => $this->getTotalRevenue() > 0,
'has_transactions' => $this->getTransactionCount() > 0,
];
}
/**
* Legacy array format for backward compatibility
* @return array<string, mixed>
*/
public function toArray(): array
{
return [
'conversions' => $this->conversions,
'revenue' => $this->revenue,
'goals' => $this->goalCompletions,
'funnel' => $this->funnelData,
];
}
/**
* Enhanced analytics array with insights
* @return array<string, mixed>
*/
public function toAnalyticsArray(): array
{
return [
'conversions' => $this->conversions,
'revenue' => $this->revenue,
'goals' => $this->goalCompletions,
'funnel' => $this->funnelData,
'insights' => $this->getInsights(),
];
}
}

View File

@@ -0,0 +1,107 @@
<?php
declare(strict_types=1);
namespace App\Application\Analytics\ValueObject;
use App\Framework\Core\ValueObjects\Percentage;
/**
* Country usage breakdown for analytics reporting
*/
final readonly class CountryBreakdown
{
public function __construct(
public int $germany,
public int $austria,
public int $switzerland,
public int $others,
) {
if ($germany < 0 || $austria < 0 || $switzerland < 0 || $others < 0) {
throw new \InvalidArgumentException('Country counts cannot be negative');
}
}
/**
* Create from legacy array format: ['Germany' => 70, 'Austria' => 15, ...]
* @param array<string, int> $data
*/
public static function fromArray(array $data): self
{
return new self(
germany: $data['Germany'] ?? 0,
austria: $data['Austria'] ?? 0,
switzerland: $data['Switzerland'] ?? 0,
others: $data['Others'] ?? 0,
);
}
public function getTotal(): int
{
return $this->germany + $this->austria + $this->switzerland + $this->others;
}
public function getGermanyPercentage(): Percentage
{
return Percentage::fromRatio($this->germany, $this->getTotal());
}
public function getAustriaPercentage(): Percentage
{
return Percentage::fromRatio($this->austria, $this->getTotal());
}
public function getSwitzerlandPercentage(): Percentage
{
return Percentage::fromRatio($this->switzerland, $this->getTotal());
}
public function getOthersPercentage(): Percentage
{
return Percentage::fromRatio($this->others, $this->getTotal());
}
public function getDachRegionTotal(): int
{
return $this->germany + $this->austria + $this->switzerland;
}
public function getDachRegionPercentage(): Percentage
{
return Percentage::fromRatio($this->getDachRegionTotal(), $this->getTotal());
}
/**
* @return array<string, int>
*/
public function toArray(): array
{
return [
'Germany' => $this->germany,
'Austria' => $this->austria,
'Switzerland' => $this->switzerland,
'Others' => $this->others,
];
}
/**
* Legacy format for backward compatibility
* @return array<string, mixed>
*/
public function toAnalyticsArray(): array
{
return [
'Germany' => $this->germany,
'Austria' => $this->austria,
'Switzerland' => $this->switzerland,
'Others' => $this->others,
'germany_percentage' => $this->getGermanyPercentage()->format(),
'austria_percentage' => $this->getAustriaPercentage()->format(),
'switzerland_percentage' => $this->getSwitzerlandPercentage()->format(),
'others_percentage' => $this->getOthersPercentage()->format(),
'dach_total' => $this->getDachRegionTotal(),
'dach_percentage' => $this->getDachRegionPercentage()->format(),
'total' => $this->getTotal(),
];
}
}

View File

@@ -0,0 +1,84 @@
<?php
declare(strict_types=1);
namespace App\Application\Analytics\ValueObject;
use App\Framework\Core\ValueObjects\Percentage;
/**
* Device usage breakdown for analytics reporting
*/
final readonly class DeviceBreakdown
{
public function __construct(
public int $desktop,
public int $mobile,
public int $tablet,
) {
if ($desktop < 0 || $mobile < 0 || $tablet < 0) {
throw new \InvalidArgumentException('Device counts cannot be negative');
}
}
/**
* Create from legacy array format: ['Desktop' => 60, 'Mobile' => 35, 'Tablet' => 5]
* @param array<string, int> $data
*/
public static function fromArray(array $data): self
{
return new self(
desktop: $data['Desktop'] ?? 0,
mobile: $data['Mobile'] ?? 0,
tablet: $data['Tablet'] ?? 0,
);
}
public function getTotal(): int
{
return $this->desktop + $this->mobile + $this->tablet;
}
public function getDesktopPercentage(): Percentage
{
return Percentage::fromRatio($this->desktop, $this->getTotal());
}
public function getMobilePercentage(): Percentage
{
return Percentage::fromRatio($this->mobile, $this->getTotal());
}
public function getTabletPercentage(): Percentage
{
return Percentage::fromRatio($this->tablet, $this->getTotal());
}
/**
* @return array<string, int>
*/
public function toArray(): array
{
return [
'Desktop' => $this->desktop,
'Mobile' => $this->mobile,
'Tablet' => $this->tablet,
];
}
/**
* @return array<string, mixed>
*/
public function toAnalyticsArray(): array
{
return [
'Desktop' => $this->desktop,
'Mobile' => $this->mobile,
'Tablet' => $this->tablet,
'desktop_percentage' => $this->getDesktopPercentage()->format(),
'mobile_percentage' => $this->getMobilePercentage()->format(),
'tablet_percentage' => $this->getTabletPercentage()->format(),
'total' => $this->getTotal(),
];
}
}

View File

@@ -0,0 +1,118 @@
<?php
declare(strict_types=1);
namespace App\Application\Analytics\ValueObject;
/**
* User behavior analytics report containing all user interaction breakdowns
*/
final readonly class UserBehaviorReport
{
public function __construct(
public ActionBreakdown $actions,
public DeviceBreakdown $devices,
public BrowserBreakdown $browsers,
public CountryBreakdown $countries,
) {
}
public function getTotalInteractions(): int
{
return $this->actions->getTotal();
}
public function getTotalUniqueUsers(): int
{
// In a real implementation, this would be calculated differently
// For now, we'll estimate based on device usage
return $this->devices->getTotal();
}
/**
* @return array{action: string, count: int}|null
*/
public function getMostPopularAction(): ?array
{
return $this->actions->getTopAction();
}
public function getDominantDevice(): string
{
$devices = $this->devices;
$max = max($devices->desktop, $devices->mobile, $devices->tablet);
return match ($max) {
$devices->desktop => 'Desktop',
$devices->mobile => 'Mobile',
$devices->tablet => 'Tablet',
default => 'Unknown'
};
}
public function getDominantBrowser(): string
{
$browsers = $this->browsers;
$max = max($browsers->chrome, $browsers->firefox, $browsers->safari, $browsers->edge);
return match ($max) {
$browsers->chrome => 'Chrome',
$browsers->firefox => 'Firefox',
$browsers->safari => 'Safari',
$browsers->edge => 'Edge',
default => 'Unknown'
};
}
public function isDachRegionDominant(): bool
{
return $this->countries->getDachRegionPercentage()->getValue() > 80.0;
}
/**
* Get insights about user behavior patterns
* @return array<string, mixed>
*/
public function getInsights(): array
{
return [
'total_interactions' => $this->getTotalInteractions(),
'estimated_unique_users' => $this->getTotalUniqueUsers(),
'most_popular_action' => $this->getMostPopularAction(),
'dominant_device' => $this->getDominantDevice(),
'dominant_browser' => $this->getDominantBrowser(),
'dach_region_focus' => $this->isDachRegionDominant(),
'mobile_first_audience' => $this->devices->getMobilePercentage()->getValue() > 50.0,
'chrome_dominance' => $this->browsers->getChromePercentage()->getValue() > 60.0,
];
}
/**
* Legacy array format for backward compatibility
* @return array<string, mixed>
*/
public function toArray(): array
{
return [
'actions' => $this->actions->toArray(),
'devices' => $this->devices->toArray(),
'browsers' => $this->browsers->toArray(),
'countries' => $this->countries->toArray(),
];
}
/**
* Enhanced analytics array with percentages and insights
* @return array<string, mixed>
*/
public function toAnalyticsArray(): array
{
return [
'actions' => $this->actions->toAnalyticsArray(),
'devices' => $this->devices->toAnalyticsArray(),
'browsers' => $this->browsers->toAnalyticsArray(),
'countries' => $this->countries->toAnalyticsArray(),
'insights' => $this->getInsights(),
];
}
}

View File

@@ -0,0 +1,472 @@
<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Analytics Dashboard</title>
<style>
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
background: #f5f7fa;
color: #2d3748;
}
.header {
background: white;
padding: 1rem 2rem;
border-bottom: 1px solid #e2e8f0;
box-shadow: 0 1px 3px rgba(0,0,0,0.1);
}
.header h1 {
font-size: 1.5rem;
font-weight: 600;
color: #1a202c;
}
.container {
max-width: 1200px;
margin: 0 auto;
padding: 2rem;
}
.stats-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(250px, 1fr));
gap: 1.5rem;
margin-bottom: 2rem;
}
.stat-card {
background: white;
padding: 1.5rem;
border-radius: 8px;
box-shadow: 0 1px 3px rgba(0,0,0,0.1);
border: 1px solid #e2e8f0;
}
.stat-card h3 {
font-size: 0.875rem;
font-weight: 500;
color: #718096;
text-transform: uppercase;
letter-spacing: 0.025em;
margin-bottom: 0.5rem;
}
.stat-value {
font-size: 2rem;
font-weight: 700;
color: #1a202c;
margin-bottom: 0.25rem;
}
.stat-change {
font-size: 0.875rem;
font-weight: 500;
}
.stat-change.positive {
color: #38a169;
}
.stat-change.negative {
color: #e53e3e;
}
.metrics-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(300px, 1fr));
gap: 1.5rem;
margin-bottom: 2rem;
}
.metrics-card {
background: white;
padding: 1.5rem;
border-radius: 8px;
box-shadow: 0 1px 3px rgba(0,0,0,0.1);
border: 1px solid #e2e8f0;
}
.metrics-title {
font-size: 1.125rem;
font-weight: 600;
color: #1a202c;
margin-bottom: 1rem;
}
.metric-item {
display: flex;
justify-content: space-between;
align-items: center;
padding: 0.75rem 0;
border-bottom: 1px solid #f1f5f9;
}
.metric-item:last-child {
border-bottom: none;
}
.metric-label {
font-weight: 500;
color: #4a5568;
}
.metric-value {
font-weight: 600;
color: #1a202c;
}
.table-container {
background: white;
border-radius: 8px;
overflow: hidden;
box-shadow: 0 1px 3px rgba(0,0,0,0.1);
border: 1px solid #e2e8f0;
}
.table-header {
background: #f7fafc;
padding: 1rem 1.5rem;
border-bottom: 1px solid #e2e8f0;
}
.table-title {
font-size: 1.125rem;
font-weight: 600;
color: #1a202c;
}
table {
width: 100%;
border-collapse: collapse;
}
th, td {
padding: 0.75rem 1.5rem;
text-align: left;
border-bottom: 1px solid #e2e8f0;
}
th {
background: #f7fafc;
font-weight: 600;
color: #4a5568;
font-size: 0.875rem;
text-transform: uppercase;
letter-spacing: 0.025em;
}
.loading {
text-align: center;
padding: 2rem;
color: #718096;
}
.error {
background: #fed7d7;
color: #c53030;
padding: 1rem;
border-radius: 4px;
margin: 1rem 0;
}
.period-selector {
margin-bottom: 1.5rem;
}
.period-buttons {
display: flex;
gap: 0.5rem;
}
.period-btn {
padding: 0.5rem 1rem;
background: white;
border: 1px solid #e2e8f0;
border-radius: 4px;
cursor: pointer;
font-size: 0.875rem;
transition: all 0.2s;
}
.period-btn:hover {
background: #f7fafc;
}
.period-btn.active {
background: #3182ce;
color: white;
border-color: #3182ce;
}
@media (max-width: 768px) {
.metrics-grid {
grid-template-columns: 1fr;
}
.container {
padding: 1rem;
}
.header {
padding: 1rem;
}
}
</style>
</head>
<body>
<div class="header">
<h1>📊 Analytics Dashboard</h1>
</div>
<div class="container">
<div class="period-selector">
<div class="period-buttons">
<button class="period-btn active" data-days="7">7 Tage</button>
<button class="period-btn" data-days="30">30 Tage</button>
<button class="period-btn" data-days="90">90 Tage</button>
</div>
</div>
<div class="stats-grid">
<div class="stat-card">
<h3>Seitenaufrufe</h3>
<div class="stat-value" id="pageViews">-</div>
<div class="stat-change positive" id="pageViewsChange">+0%</div>
</div>
<div class="stat-card">
<h3>Eindeutige Besucher</h3>
<div class="stat-value" id="uniqueVisitors">-</div>
<div class="stat-change positive" id="visitorsChange">+0%</div>
</div>
<div class="stat-card">
<h3>Absprungrate</h3>
<div class="stat-value" id="bounceRate">-</div>
<div class="stat-change negative" id="bounceChange">0%</div>
</div>
<div class="stat-card">
<h3>Conversion Rate</h3>
<div class="stat-value" id="conversionRate">-</div>
<div class="stat-change positive" id="conversionChange">+0%</div>
</div>
</div>
<div class="metrics-grid">
<div class="metrics-card">
<div class="metrics-title">Traffic-Quellen</div>
<div id="trafficSourcesList">
<div class="loading">Lade Daten...</div>
</div>
</div>
<div class="metrics-card">
<div class="metrics-title">Performance-Metriken</div>
<div class="metric-item">
<span class="metric-label">Durchschnittliche Sitzungsdauer</span>
<span class="metric-value" id="avgSessionDuration">-</span>
</div>
<div class="metric-item">
<span class="metric-label">Fehlerrate</span>
<span class="metric-value" id="errorRate">-</span>
</div>
<div class="metric-item">
<span class="metric-label">Conversion Rate</span>
<span class="metric-value" id="performanceConversionRate">-</span>
</div>
</div>
</div>
<div class="table-container">
<div class="table-header">
<div class="table-title">Top-Seiten</div>
</div>
<table>
<thead>
<tr>
<th>Seite</th>
<th>Aufrufe</th>
<th>Eindeutige Besucher</th>
<th>Absprungrate</th>
</tr>
</thead>
<tbody id="topPagesTable">
<tr>
<td colspan="4" class="loading">Lade Daten...</td>
</tr>
</tbody>
</table>
</div>
</div>
<script>
class AnalyticsDashboard {
constructor() {
this.currentPeriod = 30;
this.init();
}
async init() {
this.setupEventListeners();
await this.loadData();
}
setupEventListeners() {
document.querySelectorAll('.period-btn').forEach(btn => {
btn.addEventListener('click', (e) => {
document.querySelectorAll('.period-btn').forEach(b => b.classList.remove('active'));
e.target.classList.add('active');
this.currentPeriod = parseInt(e.target.dataset.days);
this.loadData();
});
});
}
async loadData() {
try {
await Promise.all([
this.loadOverview(),
this.loadTopPages(),
this.loadTrafficSources()
]);
} catch (error) {
console.error('Error loading data:', error);
this.showError('Fehler beim Laden der Analytics-Daten');
}
}
async loadOverview() {
try {
const response = await fetch(`/admin/analytics/api/overview?days=${this.currentPeriod}`);
const result = await response.json();
if (result.success) {
this.updateOverviewStats(result.data);
}
} catch (error) {
console.error('Error loading overview:', error);
}
}
updateOverviewStats(data) {
document.getElementById('pageViews').textContent = this.formatNumber(data.total_page_views || 0);
document.getElementById('uniqueVisitors').textContent = this.formatNumber(data.unique_visitors || 0);
document.getElementById('bounceRate').textContent = this.formatPercent(data.bounce_rate || 0);
document.getElementById('conversionRate').textContent = this.formatPercent(data.conversion_rate || 0);
// Update performance metrics
document.getElementById('avgSessionDuration').textContent = this.formatDuration(data.avg_session_duration || 0);
document.getElementById('errorRate').textContent = this.formatPercent(data.error_rate || 0);
document.getElementById('performanceConversionRate').textContent = this.formatPercent(data.conversion_rate || 0);
}
async loadTrafficSources() {
try {
const response = await fetch(`/admin/analytics/api/traffic-sources?days=${this.currentPeriod}`);
const result = await response.json();
if (result.success) {
this.updateTrafficSources(result.data);
}
} catch (error) {
console.error('Error loading traffic sources:', error);
}
}
updateTrafficSources(data) {
const container = document.getElementById('trafficSourcesList');
if (!data || Object.keys(data).length === 0) {
container.innerHTML = '<div class="loading">Keine Traffic-Daten verfügbar</div>';
return;
}
const total = Object.values(data).reduce((sum, val) => sum + val, 0);
container.innerHTML = Object.entries(data).map(([source, count]) => {
const percentage = total > 0 ? ((count / total) * 100).toFixed(1) : 0;
return `
<div class="metric-item">
<span class="metric-label">${source}</span>
<span class="metric-value">${count} (${percentage}%)</span>
</div>
`;
}).join('');
}
async loadTopPages() {
try {
const response = await fetch(`/admin/analytics/api/top-pages?days=${this.currentPeriod}&limit=10`);
const result = await response.json();
if (result.success) {
this.updateTopPagesTable(result.data);
}
} catch (error) {
console.error('Error loading top pages:', error);
}
}
updateTopPagesTable(data) {
const tbody = document.getElementById('topPagesTable');
if (!data || data.length === 0) {
tbody.innerHTML = '<tr><td colspan="4" class="loading">Keine Daten verfügbar</td></tr>';
return;
}
tbody.innerHTML = data.map(page => `
<tr>
<td>${page.path || '-'}</td>
<td>${this.formatNumber(page.views || 0)}</td>
<td>${this.formatNumber(page.unique_visitors || 0)}</td>
<td>${this.formatPercent(page.bounce_rate || 0)}</td>
</tr>
`).join('');
}
formatNumber(num) {
if (num >= 1000000) {
return (num / 1000000).toFixed(1) + 'M';
} else if (num >= 1000) {
return (num / 1000).toFixed(1) + 'K';
}
return num.toLocaleString();
}
formatPercent(num) {
return (num * 100).toFixed(1) + '%';
}
formatDuration(seconds) {
const minutes = Math.floor(seconds / 60);
const remainingSeconds = Math.floor(seconds % 60);
return `${minutes}:${remainingSeconds.toString().padStart(2, '0')}`;
}
showError(message) {
const container = document.querySelector('.container');
const errorDiv = document.createElement('div');
errorDiv.className = 'error';
errorDiv.textContent = message;
container.insertBefore(errorDiv, container.firstChild);
}
}
// Initialize dashboard when DOM is loaded
document.addEventListener('DOMContentLoaded', () => {
new AnalyticsDashboard();
});
</script>
</body>
</html>