Files
michaelschiemer/src/Framework/Analytics/Storage/PerformanceBasedAnalyticsStorage.php
Michael Schiemer 3b623e7afb feat(Deployment): Integrate Ansible deployment via PHP deployment pipeline
- Create AnsibleDeployStage using framework's Process module for secure command execution
- Integrate AnsibleDeployStage into DeploymentPipelineCommands for production deployments
- Add force_deploy flag support in Ansible playbook to override stale locks
- Use PHP deployment module as orchestrator (php console.php deploy:production)
- Fix ErrorAggregationInitializer to use Environment class instead of $_ENV superglobal

Architecture:
- BuildStage → AnsibleDeployStage → HealthCheckStage for production
- Process module provides timeout, error handling, and output capture
- Ansible playbook supports rollback via rollback-git-based.yml
- Zero-downtime deployments with health checks
2025-10-26 14:08:07 +01:00

318 lines
10 KiB
PHP

<?php
declare(strict_types=1);
namespace App\Framework\Analytics\Storage;
use App\Framework\Filesystem\AtomicStorage;
use App\Framework\Filesystem\Exceptions\FilePermissionException;
use App\Framework\Filesystem\Serializer;
use App\Framework\Filesystem\Storage;
use App\Framework\Performance\Contracts\PerformanceCollectorInterface;
/**
* Analytics-Storage basierend auf dem Performance-System
*
* Nutzt das Performance-Framework für effiziente Datenspeicherung
* und -aggregation von Analytics-Daten.
*/
final class PerformanceBasedAnalyticsStorage implements AnalyticsStorage
{
/** @var array<string, array> */
private array $aggregatedData = [];
/** @var array<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();
}
}