Raiju/includes/analytics-store.php
2026-10-25 06:05:42 +03:00

97 lines
5.3 KiB
PHP

<?php
/** Partitioned analytics. Legacy import is restartable and retains its source. */
function raijuAnalyticsDirectory(): string { return dirname(RAIJU_ANALYTICS_PATH) . '/analytics'; }
function raijuAnalyticsReadFile(string $path): array {
if (!is_file($path)) return [];
$data = json_decode((string)file_get_contents($path), true);
if (!is_array($data)) throw new RuntimeException('Analytics storage is damaged: ' . basename($path));
return $data;
}
function raijuAnalyticsMigrate(): void {
$dir = raijuAnalyticsDirectory();
if (is_file($dir . '/format.json')) return;
$lock = raijuJsonLock(RAIJU_ANALYTICS_PATH);
if (!$lock) throw new RuntimeException('Analytics storage unavailable');
try {
if (is_file($dir . '/format.json')) return;
$legacy = raijuAnalyticsReadFile(RAIJU_ANALYTICS_PATH);
foreach (($legacy['days'] ?? []) as $day => $bucket) {
if (!preg_match('/^\d{4}-\d{2}-\d{2}$/', (string)$day) || !is_array($bucket)) continue;
$target = $dir . '/days/' . $day . '.json';
// No tracking is allowed before the marker. A retry can safely replace partial imports.
if (!raijuJsonAtomicWrite($target, ['days' => [$day => $bucket], 'updatedAt' => $legacy['updatedAt'] ?? null])) {
throw new RuntimeException('Could not migrate analytics');
}
}
// Retain an aggregate migration source without keeping old visitor hashes indefinitely.
if (is_file(RAIJU_ANALYTICS_PATH)) {
foreach ($legacy['days'] as &$bucket) { if (is_array($bucket)) raijuAnalyticsForgetIdentifiers($bucket); }
unset($bucket);
if (!raijuJsonAtomicWrite(RAIJU_ANALYTICS_PATH, $legacy)) throw new RuntimeException('Could not compact migration source');
}
if (!raijuJsonAtomicWrite($dir . '/format.json', ['version' => 2, 'migratedAt' => date('c')])) throw new RuntimeException('Could not finish analytics migration');
} finally { flock($lock, LOCK_UN); fclose($lock); }
}
function raijuAnalyticsArchive(): void {
$dir = raijuAnalyticsDirectory();
$marker = $dir . '/maintenance.json';
if ((raijuAnalyticsReadFile($marker)['day'] ?? '') === date('Y-m-d')) return;
$lock = raijuJsonLock($marker);
if (!$lock) return;
try {
if ((raijuAnalyticsReadFile($marker)['day'] ?? '') === date('Y-m-d')) return;
$cutoff = date('Y-m-d', strtotime('-90 days'));
foreach (glob($dir . '/days/*.json') ?: [] as $path) {
$day = basename($path, '.json');
if ($day >= $cutoff) continue;
$data = raijuAnalyticsReadFile($path);
raijuAnalyticsCompact($data, 90);
// Archive contains per-day aggregates, preserving day/month/year queries.
$saved = raijuJsonUpdate($dir . '/archive/' . substr($day, 0, 7) . '.json', static function (&$archive) use ($data) {
$archive['days'] = array_replace($archive['days'] ?? [], $data['days'] ?? []);
$archive['updatedAt'] = $data['updatedAt'] ?? null;
});
if (!$saved) throw new RuntimeException('Could not archive analytics');
if (!unlink($path)) throw new RuntimeException('Could not finish analytics archive');
}
raijuJsonAtomicWrite($marker, ['day' => date('Y-m-d')]);
} finally { flock($lock, LOCK_UN); fclose($lock); }
}
function raijuAnalyticsLoad(?string $from = null): array {
raijuAnalyticsMigrate();
raijuAnalyticsArchive();
$data = ['days' => [], 'updatedAt' => null];
$dir = raijuAnalyticsDirectory();
$files = array_merge(glob($dir . '/archive/*.json') ?: [], glob($dir . '/days/*.json') ?: []);
foreach ($files as $file) {
$key = basename($file, '.json');
if ($from !== null && (strlen($key) === 7 ? $key < substr($from, 0, 7) : $key < $from)) continue;
$part = raijuAnalyticsReadFile($file);
foreach (($part['days'] ?? []) as $day => $bucket) {
if ($from === null || $day >= $from) $data['days'][$day] = $bucket;
}
$data['updatedAt'] = max($data['updatedAt'] ?? '', $part['updatedAt'] ?? '') ?: null;
}
return $data;
}
/** Historical summaries are cached briefly; a current-day write invalidates immediately. */
function raijuAnalyticsSummary(string $period = 'days', int $count = 30): array {
$period = in_array($period, ['days', 'months', 'years'], true) ? $period : 'days';
$count = max(0, min($count, $period === 'days' ? 3660 : 120));
try {
raijuAnalyticsMigrate();
raijuAnalyticsArchive();
$dir = raijuAnalyticsDirectory();
$revision = date('Y-m-d') . ':' . raijuJsonRevision($dir . '/days/' . date('Y-m-d') . '.json');
$path = $dir . '/cache/' . $period . '-' . $count . '.json';
$cache = raijuAnalyticsReadFile($path);
if (($cache['revision'] ?? '') === $revision && ($cache['expires'] ?? 0) > time()) return $cache['summary'];
$summary = raijuAnalyticsBuildSummary($period, $count);
raijuJsonAtomicWrite($path, ['revision' => $revision, 'expires' => time() + 60, 'summary' => $summary]);
return $summary;
} catch (Throwable $error) {
error_log('Raiju analytics: ' . $error->getMessage());
return ['state' => 'error', 'series' => [], 'hourlyToday' => [], 'periodViews' => 0, 'todayViews' => 0];
}
}