/dev/null'); foreach (explode("\n", trim((string) $out)) as $line) { // println space-separates its arguments, so the mapping arrives as "src : dst". $line = str_replace(' : ', ':', trim($line)); [$src, $dst] = array_pad(explode(':', $line, 2), 2, ''); if ($dst === '/config' && $src !== '' && is_dir("$src/log")) return "$src/log"; if ($dst === '/data' && $src !== '' && is_dir("$src/logs")) return "$src/logs"; } return ''; } function vv_npm_stats_read(): array { $p = vv_npm_stats_path(); if (!is_file($p)) return ['hosts' => []]; $j = json_decode((string) @file_get_contents($p), true); if (!is_array($j) || !isset($j['hosts']) || !is_array($j['hosts'])) return []; return $j; } function vv_npm_stats_write(array $data): bool { $p = vv_npm_stats_path(); if (!is_dir(dirname($p)) && !@mkdir(dirname($p), 0755, true)) return false; $data['updated'] = time(); $json = json_encode($data, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES); if ($json === false) return false; $tmp = $p . '.vv.tmp'; if (@file_put_contents($tmp, $json) === false) return false; if (json_decode((string) @file_get_contents($tmp), true) === null) { @unlink($tmp); return false; } if (!@rename($tmp, $p)) { @unlink($tmp); return false; } return true; } // ── Status ──────────────────────────────────────────────────────────────────── if ($status) { $s = vv_npm_stats_read(); if (!$s) { echo "npm_access.json is unreadable\n"; exit(0); } printf("%-6s %12s %12s %8s %8s %8s %s\n", 'host', 'requests', 'sent', '2xx', '4xx', '5xx', 'last hit'); foreach ($s['hosts'] ?? [] as $id => $h) { printf("%-6s %12s %12s %8s %8s %8s %s\n", $id, number_format($h['requests'] ?? 0), vv_npm_bytes($h['bytes'] ?? 0), number_format($h['s2xx'] ?? 0), number_format($h['s4xx'] ?? 0), number_format($h['s5xx'] ?? 0), !empty($h['last_seen']) ? date('Y-m-d H:i', $h['last_seen']) : '-'); } exit(0); } // Decimal, because this is a quantity of traffic and every network tool that will be compared // against it is decimal too. Memory is the binary one. function vv_npm_bytes(float $b): string { $u = ['B', 'kB', 'MB', 'GB', 'TB']; $i = 0; while ($b >= 1000 && $i < count($u) - 1) { $b /= 1000; $i++; } return round($b, $b < 10 && $i ? 1 : 0) . ' ' . $u[$i]; } $lock = @fopen(sys_get_temp_dir() . '/vv_npm_access.lock', 'c'); if ($lock === false || !flock($lock, LOCK_EX | LOCK_NB)) { echo "another pass is running\n"; exit(0); } try { $dir = vv_npm_log_dir(); if ($dir === '') { echo "NPM log directory not found — set NPM_LOG_DIR\n"; exit(0); } $store = $reset ? ['hosts' => []] : vv_npm_stats_read(); if (!$store) { echo "npm_access.json is malformed — refusing to overwrite it\n"; exit(1); } $hosts = $store['hosts'] ?? []; $files = glob("$dir/proxy-host-*_access.log") ?: []; if (!$files) { echo "no per-host access logs in $dir\n"; exit(0); } $t0 = microtime(true); $readTotal = 0; $newLines = 0; foreach ($files as $f) { if (!preg_match('/proxy-host-(\d+)_access\.log$/', $f, $m)) continue; $id = (string) (int) $m[1]; $size = @filesize($f); if ($size === false) continue; $h = $hosts[$id] ?? ['requests' => 0, 'bytes' => 0, 's2xx' => 0, 's3xx' => 0, 's4xx' => 0, 's5xx' => 0, 'offset' => 0, 'last_seen' => null, 'rotations' => 0, 'since' => time()]; $off = (int) ($h['offset'] ?? 0); // Smaller than where we stopped means the file was rotated out from under us. Counters are // cumulative and stay; only the offset resets, and the rotation is counted so the store can // say the totals have a gap in them. if ($size < $off) { $off = 0; $h['rotations'] = ($h['rotations'] ?? 0) + 1; } if ($size === $off) { $hosts[$id] = $h; continue; } $fp = @fopen($f, 'rb'); if (!$fp) { $hosts[$id] = $h; continue; } @fseek($fp, $off); $budget = VV_NPM_MAX_BYTES; $read = 0; $lastCompleteOffset = $off; while (!feof($fp) && $budget > 0) { $line = fgets($fp, 8192); if ($line === false) break; $len = strlen($line); $budget -= $len; $read += $len; // No trailing newline means nginx is mid-write. Stop and leave the offset before it. if (substr($line, -1) !== "\n") break; $lastCompleteOffset += $len; // [09/Aug/2026:05:52:08 +0000] - 200 200 - GET https host "/" [Client 1.2.3.4] [Length 567] ... if (!preg_match('/^\[([^\]]+)\]\s+\S+\s+(\d{3})/', $line, $lm)) continue; $code = (int) $lm[2]; $h['requests']++; $newLines++; if ($code >= 500) $h['s5xx']++; elseif ($code >= 400) $h['s4xx']++; elseif ($code >= 300) $h['s3xx']++; elseif ($code >= 200) $h['s2xx']++; if (preg_match('/\[Length (\d+)\]/', $line, $bm)) $h['bytes'] += (int) $bm[1]; // The log stamp is nginx's own format; a line that will not parse is not worth a // guessed timestamp, so last_seen simply does not move for it. $ts = strtotime(str_replace('/', ' ', preg_replace('/^(\d+)\/(\w+)\/(\d+):/', '$1 $2 $3 ', $lm[1]))); if ($ts !== false && ($h['last_seen'] === null || $ts > $h['last_seen'])) $h['last_seen'] = $ts; } fclose($fp); $h['offset'] = $lastCompleteOffset; $hosts[$id] = $h; $readTotal += $read; } ksort($hosts, SORT_NUMERIC); $store['hosts'] = $hosts; $store['last_pass'] = time(); printf("%d logs, read %s this pass, %s new requests, %.1fs\n", count($files), vv_npm_bytes($readTotal), number_format($newLines), microtime(true) - $t0); if ($dryRun) { echo "dry run — nothing written\n"; exit(0); } if (!vv_npm_stats_write($store)) { echo 'could not write ' . vv_npm_stats_path() . "\n"; exit(1); } echo 'wrote ' . vv_npm_stats_path() . "\n"; exit(0); } finally { flock($lock, LOCK_UN); fclose($lock); }