context !== null) { return; } RequestContext::ensureStarted(); $requestContext = RequestContext::context(); $method = strtoupper(trim((string) ($requestContext['method'] ?? ($_SERVER['REQUEST_METHOD'] ?? 'GET')))); if ($method === '') { $method = 'GET'; } $path = trim((string) ($requestContext['path'] ?? '')); if ($path === '') { $fallbackPath = parse_url((string) ($_SERVER['REQUEST_URI'] ?? ''), PHP_URL_PATH); $path = is_string($fallbackPath) ? trim($fallbackPath) : ''; } $this->context = [ 'started_at' => microtime(true), 'method' => $method, 'path' => $path, 'finished' => false, ]; } public function finish(int $statusCode, ?string $errorCode = null): void { if (!is_array($this->context) || !empty($this->context['finished'])) { return; } $this->context['finished'] = true; $method = strtoupper(trim((string) $this->context['method'])); $path = trim((string) $this->context['path']); $statusCode = $this->normalizeStatusCode($statusCode); if (!$this->shouldRecord($method, $path, $statusCode)) { return; } $durationMs = max(0, (int) round((microtime(true) - (float) $this->context['started_at']) * 1000)); $securityEndpoint = $this->isSecurityEndpoint($path); $writeMethod = $this->isWriteMethod($method); $normalizedErrorCode = trim((string) ($errorCode ?? '')); $metadata = [ 'endpoint_key' => $this->endpointKey($path), 'status_code' => $statusCode, 'status_class' => $this->statusClass($statusCode), 'duration_ms' => $durationMs, 'auth_mode' => $this->authMode(), 'write_method' => $writeMethod, 'security_endpoint' => $securityEndpoint, ]; $this->systemAuditService->record( 'api.request', $this->outcomeFromStatusCode($statusCode), [ 'actor_user_id' => ApiAuth::isAuthenticated() ? ApiAuth::userId() : null, 'error_code' => $normalizedErrorCode !== '' ? substr($normalizedErrorCode, 0, 100) : null, 'metadata' => $metadata, ] ); } // Recording strategy: always log writes and server errors; for reads, only log // failures on security-sensitive endpoints (auth, tokens) to keep volume manageable. private function shouldRecord(string $method, string $path, int $statusCode): bool { if ($method === 'OPTIONS') { return false; } if ($this->isWriteMethod($method)) { return true; } if ($statusCode >= 500) { return true; } return $this->isSecurityEndpoint($path) && $statusCode >= 400; } private function isWriteMethod(string $method): bool { return in_array($method, ['POST', 'PUT', 'PATCH', 'DELETE'], true); } private function isSecurityEndpoint(string $path): bool { $path = '/' . ltrim(strtolower(trim($path)), '/'); if ($path === '/api/v1/auth/login' || $path === '/api/v1/me/password') { return true; } return str_starts_with($path, '/api/v1/me/tokens'); } // Normalizes dynamic path segments (IDs, UUIDs, token selectors) so audit queries // can group by endpoint pattern rather than individual resource IDs. private function endpointKey(string $path): string { $path = '/' . ltrim(trim($path), '/'); if ($path === '/') { return '/'; } $segments = array_values(array_filter(explode('/', trim($path, '/')), static fn (string $segment): bool => $segment !== '')); if ($segments === []) { return '/'; } $normalized = []; foreach ($segments as $segment) { $value = strtolower(trim($segment)); if ($value === '') { continue; } if (ctype_digit($value)) { $normalized[] = '{id}'; continue; } if (preg_match('/^[a-f0-9]{8}-[a-f0-9]{4}-[1-5][a-f0-9]{3}-[89ab][a-f0-9]{3}-[a-f0-9]{12}$/i', $value) === 1) { $normalized[] = '{uuid}'; continue; } if (preg_match('/^[a-f0-9]{24}$/i', $value) === 1) { $normalized[] = '{token_selector}'; continue; } $normalized[] = substr($value, 0, 64); } return '/' . implode('/', $normalized); } private function outcomeFromStatusCode(int $statusCode): string { if ($statusCode >= 500) { return SystemAuditOutcome::Failed->value; } if ($statusCode >= 400) { return SystemAuditOutcome::Denied->value; } return SystemAuditOutcome::Success->value; } private function statusClass(int $statusCode): string { if ($statusCode >= 100 && $statusCode <= 199) { return '1xx'; } if ($statusCode >= 200 && $statusCode <= 299) { return '2xx'; } if ($statusCode >= 300 && $statusCode <= 399) { return '3xx'; } if ($statusCode >= 400 && $statusCode <= 499) { return '4xx'; } if ($statusCode >= 500 && $statusCode <= 599) { return '5xx'; } return 'unknown'; } private function authMode(): string { if (ApiAuth::isAuthenticated() || ApiAuth::extractBearerToken() !== '') { return 'token'; } return 'public'; } private function normalizeStatusCode(int $statusCode): int { return ($statusCode >= 100 && $statusCode <= 999) ? $statusCode : 500; } }