- 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
318 lines
10 KiB
PHP
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();
|
|
}
|
|
}
|