97 lines
5.3 KiB
PHP
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];
|
|
}
|
|
}
|