*/ private array $aggregatedData = []; /** @var array */ private array $rawDataBuffer = []; private int $bufferSize = 1000; public function __construct( private PerformanceCollectorInterface $performanceCollector, private Storage|AtomicStorage $storage, private Serializer $serializer, private string $dataPath = '/tmp/analytics' ) { if (! $this->storage->exists($this->dataPath)) { $this->storage->createDirectory($this->dataPath); } } public function storeAggregated(string $period, array $data): void { error_log("Analytics: storeAggregated called with period={$period}, data=" . json_encode($data)); $periodKey = $this->getPeriodKey($period); if (! isset($this->aggregatedData[$periodKey])) { $this->aggregatedData[$periodKey] = []; } // Merge new data with existing data foreach ($data as $key => $value) { if (isset($this->aggregatedData[$periodKey][$key])) { if (is_numeric($value)) { $this->aggregatedData[$periodKey][$key] += $value; } else { $this->aggregatedData[$periodKey][$key] = $value; } } else { $this->aggregatedData[$periodKey][$key] = $value; } } // Persist to file every 10 updates (for better responsiveness in development) // Also persist immediately for small amounts to ensure data is not lost if (count($this->aggregatedData) % 10 === 0 || count($this->aggregatedData) <= 5) { $this->persistAggregatedData(); } } public function storeRawData(array $data, float $samplingRate = 0.1): void { error_log("Analytics: storeRawData called with data=" . json_encode($data) . ", samplingRate={$samplingRate}"); // Apply sampling if ($samplingRate < 1.0 && (random_int(1, 100) / 100) > $samplingRate) { error_log("Analytics: Data filtered out by sampling"); return; } $this->rawDataBuffer[] = array_merge($data, [ 'timestamp' => time(), 'sampled' => $samplingRate < 1.0, ]); // Flush buffer when full if (count($this->rawDataBuffer) >= $this->bufferSize) { $this->flushRawDataBuffer(); } } public function getAggregated(string $startDate, string $endDate, string $period = 'hour'): array { $this->loadAggregatedData(); $result = []; $start = strtotime($startDate . ' 00:00:00'); $end = strtotime($endDate . ' 23:59:59'); foreach ($this->aggregatedData as $periodKey => $data) { $timestamp = $this->parseTimestampFromPeriodKey($periodKey, $period); if ($timestamp >= $start && $timestamp <= $end) { $result[$periodKey] = $data; } } return $result; } public function getTopList(string $metric, string $startDate, string $endDate, int $limit = 10): array { $aggregated = $this->getAggregated($startDate, $endDate); $topList = []; foreach ($aggregated as $period => $data) { if (isset($data[$metric])) { $value = $data[$metric]; if (is_array($value)) { foreach ($value as $key => $count) { if (! isset($topList[$key])) { $topList[$key] = 0; } $topList[$key] += $count; } } } } arsort($topList); return array_slice($topList, 0, $limit, true); } public function getTimeSeries(string $metric, string $startDate, string $endDate, string $interval = 'hour'): array { $aggregated = $this->getAggregated($startDate, $endDate, $interval); $timeSeries = []; foreach ($aggregated as $period => $data) { $timestamp = $this->parseTimestampFromPeriodKey($period, $interval); $value = $data[$metric] ?? 0; if (is_array($value)) { $value = array_sum($value); } $timeSeries[] = [ 'timestamp' => $timestamp, 'value' => $value, 'period' => $period, ]; } // Sort by timestamp usort($timeSeries, fn ($a, $b) => $a['timestamp'] <=> $b['timestamp']); return $timeSeries; } public function cleanup(int $retentionDays): int { $cutoffTime = time() - ($retentionDays * 24 * 3600); $removed = 0; // Clean aggregated data foreach ($this->aggregatedData as $periodKey => $data) { $timestamp = $this->parseTimestampFromPeriodKey($periodKey, 'hour'); if ($timestamp < $cutoffTime) { unset($this->aggregatedData[$periodKey]); $removed++; } } // Clean raw data files $rawDataFiles = $this->storage->listDirectory($this->dataPath); foreach ($rawDataFiles as $file) { if (str_starts_with(basename($file), 'raw_') && str_ends_with($file, '.json') && $this->storage->lastModified($file) < $cutoffTime) { $this->storage->delete($file); $removed++; } } $this->persistAggregatedData(); return $removed; } /** * Generiert einen Period-Key für Aggregation */ private function getPeriodKey(string $period): string { $timestamp = time(); return match ($period) { 'minute' => date('Y-m-d_H:i', $timestamp), 'hour' => date('Y-m-d_H', $timestamp), 'day' => date('Y-m-d', $timestamp), 'week' => date('Y-W', $timestamp), 'month' => date('Y-m', $timestamp), default => date('Y-m-d_H', $timestamp) }; } /** * Parst Timestamp aus Period-Key */ private function parseTimestampFromPeriodKey(string $periodKey, string $period): int { $timestamp = match ($period) { 'minute' => strtotime($periodKey . ':00'), 'hour' => strtotime($periodKey . ':00:00'), 'day' => strtotime($periodKey . ' 00:00:00'), 'week' => strtotime($periodKey . '-1'), // Monday of week 'month' => strtotime($periodKey . '-01 00:00:00'), default => strtotime($periodKey . ':00:00') }; // Handle invalid date formats if ($timestamp === false) { // Return current timestamp as fallback return time(); } return $timestamp; } /** * Persistiert aggregierte Daten */ private function persistAggregatedData(): void { error_log("Analytics: persistAggregatedData called - persisting " . count($this->aggregatedData) . " items"); $filename = $this->dataPath . '/aggregated_' . date('Y-m-d') . '.' . $this->serializer->getFileExtension(); $content = $this->serializer->serialize($this->aggregatedData); try { if ($this->storage instanceof AtomicStorage) { $this->storage->putAtomic($filename, $content); } else { $this->storage->put($filename, $content); } } catch (FilePermissionException $e) { error_log("Analytics: Failed to persist aggregated data due to permissions: " . $e->getMessage()); // Continue gracefully - data will remain in memory for this request } catch (\Exception $e) { error_log("Analytics: Failed to persist aggregated data: " . $e->getMessage()); } } /** * Lädt aggregierte Daten */ private function loadAggregatedData(): void { if (! empty($this->aggregatedData)) { return; } $files = $this->storage->listDirectory($this->dataPath); foreach ($files as $file) { if (str_starts_with(basename($file), 'aggregated_') && str_ends_with($file, '.' . $this->serializer->getFileExtension()) && $this->storage->exists($file)) { $content = $this->storage->get($file); $data = $this->serializer->deserialize($content); if (is_array($data)) { $this->aggregatedData = array_merge($this->aggregatedData, $data); } } } } /** * Leert den Raw-Data Buffer */ private function flushRawDataBuffer(): void { if (empty($this->rawDataBuffer)) { return; } $generator = new \App\Framework\Ulid\UlidGenerator(); $filename = $this->dataPath . '/raw_' . date('Y-m-d_H-i-s') . '_' . $generator->generate() . '.' . $this->serializer->getFileExtension(); $content = $this->serializer->serialize($this->rawDataBuffer); try { if ($this->storage instanceof AtomicStorage) { $this->storage->putAtomic($filename, $content); } else { $this->storage->put($filename, $content); } $this->rawDataBuffer = []; } catch (FilePermissionException $e) { error_log("Analytics: Failed to flush raw data buffer due to permissions: " . $e->getMessage()); // Keep data in buffer for potential retry later } catch (\Exception $e) { error_log("Analytics: Failed to flush raw data buffer: " . $e->getMessage()); // Clear buffer to prevent memory buildup $this->rawDataBuffer = []; } } /** * Destructor um Buffer zu leeren */ public function __destruct() { $this->flushRawDataBuffer(); $this->persistAggregatedData(); } }