context !== null) { return; } RequestContext::ensureStarted(); RequestContext::setChannel('api'); $requestContext = RequestContext::context(); $method = strtoupper(trim($this->requestRuntime->method())); if ($method === 'OPTIONS') { $this->context = ['active' => false, 'finished' => true]; return; } $path = trim($this->requestRuntime->path()); if ($path === '') { $this->context = ['active' => false, 'finished' => true]; return; } $this->context = [ 'active' => true, 'finished' => false, 'started_at' => microtime(true), 'request_id' => (string) ($requestContext['request_id'] ?? RequestContext::id()), 'method' => (string) ($requestContext['method'] ?? substr($method !== '' ? $method : 'GET', 0, 8)), 'path' => (string) ($requestContext['path'] ?? substr($path, 0, 255)), 'query_json' => $this->buildRedactedQueryJson($this->requestRuntime->queryParams()), ]; } public function currentRequestId(): ?string { if (!is_array($this->context)) { return null; } $requestId = trim((string) ($this->context['request_id'] ?? '')); return $requestId !== '' ? $requestId : null; } public function finish(int $statusCode, ?string $errorCode = null): void { if (!is_array($this->context)) { return; } if (!empty($this->context['finished'])) { return; } $this->context['finished'] = true; if (empty($this->context['active'])) { return; } $durationMs = null; if (isset($this->context['started_at'])) { $durationMs = (int) round((microtime(true) - (float) $this->context['started_at']) * 1000); if ($durationMs < 0) { $durationMs = 0; } } $statusCode = ($statusCode >= 100 && $statusCode <= 999) ? $statusCode : 500; $normalizedErrorCode = trim((string) ($errorCode ?? '')); if ($normalizedErrorCode === '') { $normalizedErrorCode = null; } elseif (strlen($normalizedErrorCode) > 100) { $normalizedErrorCode = substr($normalizedErrorCode, 0, 100); } $requestContext = RequestContext::context(); $ip = trim((string) ($requestContext['ip'] ?? $this->requestRuntime->ip())); if ($ip === '') { $ip = null; } elseif (strlen($ip) > 45) { $ip = substr($ip, 0, 45); } $userAgent = trim((string) ($requestContext['user_agent'] ?? $this->requestRuntime->userAgent())); if ($userAgent === '') { $userAgent = null; } elseif (strlen($userAgent) > self::MAX_USER_AGENT_LENGTH) { $userAgent = substr($userAgent, 0, self::MAX_USER_AGENT_LENGTH); } $userId = ApiAuth::isAuthenticated() ? ApiAuth::userId() : 0; $tenantId = ApiAuth::isAuthenticated() ? (int) (ApiAuth::tenantId() ?? 0) : 0; $apiTokenId = ApiAuth::isAuthenticated() ? (int) (ApiAuth::tokenId() ?? 0) : 0; $tokenTenantId = ApiAuth::isAuthenticated() ? (int) (ApiAuth::scopedTenantId() ?? 0) : 0; $payload = [ 'request_id' => (string) ($this->context['request_id'] ?? RequestContext::id()), 'method' => (string) ($this->context['method'] ?? ''), 'path' => (string) ($this->context['path'] ?? ''), 'query_json' => $this->context['query_json'] ?? null, 'status_code' => $statusCode, 'duration_ms' => $durationMs, 'error_code' => $normalizedErrorCode, 'user_id' => $userId > 0 ? $userId : null, 'tenant_id' => $tenantId > 0 ? $tenantId : null, 'api_token_id' => $apiTokenId > 0 ? $apiTokenId : null, 'token_tenant_id' => $tokenTenantId > 0 ? $tokenTenantId : null, 'ip' => $ip, 'user_agent' => $userAgent, ]; try { $this->apiAuditLogRepository->create($payload); } catch (\Throwable $exception) { // Never break API responses because of audit logging failures. } } public function listPaged(array $filters): array { return $this->apiAuditLogRepository->listPaged($filters); } public function find(int $id): ?array { return $this->apiAuditLogRepository->find($id); } public function filterOptions(int $limit = 200): array { return $this->apiAuditLogRepository->listFilterOptions($limit); } public function purgeExpired(): int { return $this->apiAuditLogRepository->purgeOlderThanDays(self::RETENTION_DAYS); } private function buildRedactedQueryJson(array $query): ?string { if (!$query) { return null; } $redacted = $this->redactArray($query); if ($redacted === []) { return null; } $encoded = json_encode($redacted, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES); return is_string($encoded) ? $encoded : null; } private function redactArray(array $data): array { $result = []; $i = 0; foreach ($data as $rawKey => $value) { if ($i >= self::MAX_ARRAY_ITEMS) { break; } $key = trim((string) $rawKey); if ($key === '') { continue; } if ($this->isSensitiveKey($key)) { $result[$key] = '[REDACTED]'; } else { $result[$key] = $this->normalizeValue($value); } $i++; } return $result; } private function normalizeValue(mixed $value): mixed { if (is_array($value)) { return $this->redactArray($value); } if (is_bool($value) || is_int($value) || is_float($value) || $value === null) { return $value; } $string = trim((string) $value); if ($string === '') { return ''; } if (strlen($string) > self::MAX_STRING_LENGTH) { return substr($string, 0, self::MAX_STRING_LENGTH); } return $string; } private function isSensitiveKey(string $key): bool { $key = strtolower(trim($key)); if ($key === '') { return false; } if (in_array($key, [ 'token', 'access_token', 'refresh_token', 'id_token', 'secret', 'password', 'authorization', 'api_key', 'client_secret', 'key', ], true)) { return true; } return str_contains($key, 'token') || str_contains($key, 'secret') || str_contains($key, 'password') || str_contains($key, 'authorization') || str_ends_with($key, '_key'); } }