baseUrl = rtrim(config('graph.base_url', 'https://graph.microsoft.com'), '/') .'/'.trim(config('graph.version', 'beta'), '/'); $this->tokenUrlTemplate = config('graph.token_url', 'https://login.microsoftonline.com/%s/oauth2/v2.0/token'); $this->tenantId = config('graph.tenant_id', ''); $this->clientId = config('graph.client_id', ''); $this->clientSecret = config('graph.client_secret', ''); $this->defaultScopes = $this->normalizeScopes(config('graph.scope', self::DEFAULT_SCOPE)); $this->timeout = (int) config('graph.timeout', 10); $this->retryTimes = (int) config('graph.retry.times', 2); $this->retrySleepMs = (int) config('graph.retry.sleep', 200); } public function listPolicies(string $policyType, array $options = []): GraphResponse { $endpoint = $this->endpointFor($policyType); $contract = $this->contracts->get($policyType); $allowedSelect = is_array($contract['allowed_select'] ?? null) ? $contract['allowed_select'] : []; $defaultSelect = $options['select'] ?? ($allowedSelect !== [] ? implode(',', $allowedSelect) : null); $queryInput = array_filter([ '$top' => $options['top'] ?? null, '$filter' => $options['filter'] ?? null, '$select' => $defaultSelect, 'platform' => $options['platform'] ?? null, ], fn ($value) => $value !== null && $value !== ''); $sanitized = $this->contracts->sanitizeQuery($policyType, $queryInput); $query = $sanitized['query']; $warnings = $sanitized['warnings']; $context = $this->resolveContext($options); $clientRequestId = $options['client_request_id'] ?? (string) Str::uuid(); $fullPath = $this->buildFullPath($endpoint, $query); $this->logger->logRequest('list_policies', [ 'endpoint' => $endpoint, 'full_path' => $fullPath, 'method' => 'GET', 'policy_type' => $policyType, 'tenant' => $context['tenant'], 'query' => $query ?: null, 'client_request_id' => $clientRequestId, ]); $sendOptions = ['query' => $query, 'client_request_id' => $clientRequestId]; if (isset($options['access_token'])) { $sendOptions['access_token'] = $options['access_token']; } $response = $this->send('GET', $endpoint, $sendOptions, $context); if ($response->failed()) { $graphResponse = $this->toGraphResponse( action: 'list_policies', response: $response, transform: fn (array $json) => $json['value'] ?? (is_array($json) ? $json : []), meta: [ 'tenant' => $context['tenant'] ?? null, 'path' => $endpoint, 'full_path' => $fullPath, 'method' => 'GET', 'query' => $query ?: null, 'client_request_id' => $clientRequestId, ], warnings: $warnings, ); if (! $this->shouldApplySelectFallback($graphResponse, $query)) { return $graphResponse; } $fallbackQuery = array_filter($query, fn ($value, $key) => $key !== '$select', ARRAY_FILTER_USE_BOTH); $fallbackPath = $this->buildFullPath($endpoint, $fallbackQuery); $fallbackSendOptions = ['query' => $fallbackQuery, 'client_request_id' => $clientRequestId]; if (isset($options['access_token'])) { $fallbackSendOptions['access_token'] = $options['access_token']; } $this->logger->logRequest('list_policies_fallback', [ 'endpoint' => $endpoint, 'full_path' => $fallbackPath, 'method' => 'GET', 'policy_type' => $policyType, 'tenant' => $context['tenant'], 'query' => $fallbackQuery ?: null, 'client_request_id' => $clientRequestId, ]); $fallbackResponse = $this->send('GET', $endpoint, $fallbackSendOptions, $context); if ($fallbackResponse->failed()) { return $this->toGraphResponse( action: 'list_policies', response: $fallbackResponse, transform: fn (array $json) => $json['value'] ?? (is_array($json) ? $json : []), meta: [ 'tenant' => $context['tenant'] ?? null, 'path' => $endpoint, 'full_path' => $fallbackPath, 'method' => 'GET', 'query' => $fallbackQuery ?: null, 'client_request_id' => $clientRequestId, ], warnings: array_values(array_unique(array_merge( $warnings, ['Capability fallback applied: removed $select for compatibility.'] ))), ); } $response = $fallbackResponse; $query = $fallbackQuery; $fullPath = $fallbackPath; $warnings = array_values(array_unique(array_merge( $warnings, ['Capability fallback applied: removed $select for compatibility.'] ))); } $json = $response->json() ?? []; $policies = $json['value'] ?? (is_array($json) ? $json : []); $nextLink = $json['@odata.nextLink'] ?? null; $pages = 1; while (is_string($nextLink) && $nextLink !== '') { if ($pages >= self::MAX_LIST_PAGES) { $graphResponse = new GraphResponse( success: false, data: [], status: 500, errors: [[ 'message' => 'Graph pagination exceeded maximum page limit.', 'max_pages' => self::MAX_LIST_PAGES, ]], warnings: $warnings, meta: [ 'tenant' => $context['tenant'] ?? null, 'path' => $endpoint, 'full_path' => $fullPath, 'method' => 'GET', 'query' => $query ?: null, 'client_request_id' => $clientRequestId, 'pages_fetched' => $pages, ], ); $this->logger->logResponse('list_policies', $graphResponse, $graphResponse->meta); return $graphResponse; } $pageOptions = ['client_request_id' => $clientRequestId]; if (isset($options['access_token'])) { $pageOptions['access_token'] = $options['access_token']; } $pageResponse = $this->send('GET', $nextLink, $pageOptions, $context); if ($pageResponse->failed()) { $graphResponse = $this->toGraphResponse( action: 'list_policies', response: $pageResponse, transform: fn (array $json) => $json['value'] ?? (is_array($json) ? $json : []), meta: [ 'tenant' => $context['tenant'] ?? null, 'path' => $endpoint, 'full_path' => $fullPath, 'method' => 'GET', 'query' => $query ?: null, 'client_request_id' => $clientRequestId, 'pages_fetched' => $pages, ], warnings: array_values(array_unique(array_merge( $warnings, ['Pagination failed while listing policies.'] ))), ); return $graphResponse; } $pageJson = $pageResponse->json() ?? []; $pageValue = $pageJson['value'] ?? []; if (is_array($pageValue) && $pageValue !== []) { $policies = array_merge($policies, $pageValue); } $nextLink = $pageJson['@odata.nextLink'] ?? null; $pages++; } $meta = $this->responseMeta($response, [ 'tenant' => $context['tenant'] ?? null, 'path' => $endpoint, 'full_path' => $fullPath, 'method' => 'GET', 'query' => $query ?: null, 'client_request_id' => $clientRequestId, ]); $meta['pages_fetched'] = $pages; $meta['item_count'] = count($policies); if ($pages > 1) { $warnings = array_values(array_unique(array_merge($warnings, [ sprintf('Pagination applied: fetched %d pages.', $pages), ]))); } $graphResponse = new GraphResponse( success: true, data: $policies, status: $response->status(), warnings: $warnings, meta: $meta, ); $this->logger->logResponse('list_policies', $graphResponse, $meta); return $graphResponse; } public function getPolicy(string $policyType, string $policyId, array $options = []): GraphResponse { $endpoint = $this->endpointFor($policyType).'/'.urlencode($policyId); $queryInput = array_filter([ '$select' => $options['select'] ?? null, '$expand' => $options['expand'] ?? null, ], fn ($value) => $value !== null && $value !== ''); $sanitized = $this->contracts->sanitizeQuery($policyType, $queryInput); $query = $sanitized['query']; $warnings = $sanitized['warnings']; $context = $this->resolveContext($options); $clientRequestId = $options['client_request_id'] ?? (string) Str::uuid(); $fullPath = $this->buildFullPath($endpoint, $query); $this->logger->logRequest('get_policy', [ 'endpoint' => $endpoint, 'policy_type' => $policyType, 'policy_id' => $policyId, 'tenant' => $context['tenant'], 'full_path' => $fullPath, 'method' => 'GET', 'query' => $query ?: null, 'client_request_id' => $clientRequestId, ]); $response = $this->send('GET', $endpoint, ['query' => $query, 'client_request_id' => $clientRequestId], $context); $graphResponse = $this->toGraphResponse( action: 'get_policy', response: $response, transform: fn (array $json) => ['payload' => $json], meta: [ 'tenant' => $context['tenant'] ?? null, 'path' => $endpoint, 'full_path' => $fullPath, 'method' => 'GET', 'query' => $query ?: null, 'client_request_id' => $clientRequestId, ], warnings: $warnings, ); if ($graphResponse->failed() && ! empty($query)) { $fallbackQuery = array_filter($query, fn ($value, $key) => $key !== '$select' && $key !== '$expand', ARRAY_FILTER_USE_BOTH); $fallbackPath = $this->buildFullPath($endpoint, $fallbackQuery); $fallbackSendOptions = ['query' => $fallbackQuery, 'client_request_id' => $clientRequestId]; if (isset($options['access_token'])) { $fallbackSendOptions['access_token'] = $options['access_token']; } $this->logger->logRequest('get_policy_fallback', [ 'endpoint' => $endpoint, 'policy_type' => $policyType, 'policy_id' => $policyId, 'tenant' => $context['tenant'], 'full_path' => $fallbackPath, 'method' => 'GET', 'query' => $fallbackQuery ?: null, 'client_request_id' => $clientRequestId, ]); $fallbackResponse = $this->send('GET', $endpoint, $fallbackSendOptions, $context); $graphResponse = $this->toGraphResponse( action: 'get_policy', response: $fallbackResponse, transform: fn (array $json) => ['payload' => $json], meta: [ 'tenant' => $context['tenant'] ?? null, 'path' => $endpoint, 'full_path' => $fallbackPath, 'method' => 'GET', 'query' => $fallbackQuery ?: null, 'client_request_id' => $clientRequestId, ], warnings: array_values(array_unique(array_merge( $warnings, ['Capability fallback applied: removed $select/$expand for compatibility.'] ))), ); } return $graphResponse; } private function shouldApplySelectFallback(GraphResponse $graphResponse, array $query): bool { if (! $graphResponse->failed()) { return false; } if (($graphResponse->status ?? null) !== 400) { return false; } if (! array_key_exists('$select', $query)) { return false; } $errorMessage = $graphResponse->meta['error_message'] ?? null; if (! is_string($errorMessage) || $errorMessage === '') { return false; } if (stripos($errorMessage, 'Parsing OData Select and Expand failed') !== false) { return true; } if (stripos($errorMessage, 'Could not find a property named') !== false) { return true; } return false; } public function getOrganization(array $options = []): GraphResponse { $context = $this->resolveContext($options); $endpoint = 'organization'; $clientRequestId = $options['client_request_id'] ?? (string) Str::uuid(); $fullPath = $this->buildFullPath($endpoint); $this->logger->logRequest('get_organization', [ 'endpoint' => $endpoint, 'tenant' => $context['tenant'], 'full_path' => $fullPath, 'method' => 'GET', 'client_request_id' => $clientRequestId, ]); $response = $this->send('GET', $endpoint, ['client_request_id' => $clientRequestId], $context); return $this->toGraphResponse( action: 'get_organization', response: $response, transform: fn (array $json) => $json['value'][0] ?? $json, meta: [ 'tenant' => $context['tenant'] ?? null, 'path' => $endpoint, 'full_path' => $fullPath, 'method' => 'GET', 'client_request_id' => $clientRequestId, ] ); } public function applyPolicy(string $policyType, string $policyId, array $payload, array $options = []): GraphResponse { $endpoint = $this->endpointFor($policyType).'/'.urlencode($policyId); $method = strtoupper($options['method'] ?? 'PATCH'); $context = $this->resolveContext($options); $clientRequestId = $options['client_request_id'] ?? (string) Str::uuid(); $fullPath = $this->buildFullPath($endpoint); $this->logger->logRequest('apply_policy', [ 'endpoint' => $endpoint, 'policy_type' => $policyType, 'policy_id' => $policyId, 'tenant' => $context['tenant'], 'method' => $method, 'full_path' => $fullPath, 'client_request_id' => $clientRequestId, ]); $response = $this->send($method, $endpoint, ['json' => $payload, 'client_request_id' => $clientRequestId], $context); return $this->toGraphResponse( action: 'apply_policy', response: $response, transform: fn (array $json) => $json, meta: [ 'tenant' => $context['tenant'] ?? null, 'path' => $endpoint, 'full_path' => $fullPath, 'method' => $method, 'client_request_id' => $clientRequestId, ] ); } public function getServicePrincipalPermissions(array $options = []): GraphResponse { $context = $this->resolveContext($options); $clientId = $context['client_id']; $clientRequestId = $options['client_request_id'] ?? (string) Str::uuid(); // First, get the service principal object by clientId (appId) $endpoint = "servicePrincipals?\$filter=appId eq '{$clientId}'"; $this->logger->logRequest('get_service_principal', [ 'endpoint' => $endpoint, 'client_id' => $clientId, 'tenant' => $context['tenant'], 'method' => 'GET', 'full_path' => $endpoint, 'client_request_id' => $clientRequestId, ]); $response = $this->send('GET', $endpoint, ['client_request_id' => $clientRequestId], $context); if ($response->failed()) { return $this->toGraphResponse( action: 'get_service_principal', response: $response, transform: fn (array $json) => [], meta: [ 'tenant' => $context['tenant'] ?? null, 'path' => $endpoint, 'full_path' => $endpoint, 'method' => 'GET', 'client_request_id' => $clientRequestId, ] ); } $servicePrincipals = $response->json('value', []); if (empty($servicePrincipals)) { return new GraphResponse( success: false, data: [], status: 404, errors: [['message' => 'Service principal not found']], ); } $servicePrincipalId = $servicePrincipals[0]['id'] ?? null; if (! $servicePrincipalId) { return new GraphResponse( success: false, data: [], status: 500, errors: [['message' => 'Service principal ID missing']], ); } // Now get the app role assignments (application permissions) $assignmentsEndpoint = "servicePrincipals/{$servicePrincipalId}/appRoleAssignments"; $this->logger->logRequest('get_app_role_assignments', [ 'endpoint' => $assignmentsEndpoint, 'service_principal_id' => $servicePrincipalId, 'tenant' => $context['tenant'], 'method' => 'GET', 'full_path' => $assignmentsEndpoint, 'client_request_id' => $clientRequestId, ]); $assignmentsResponse = $this->send('GET', $assignmentsEndpoint, ['client_request_id' => $clientRequestId], $context); return $this->toGraphResponse( action: 'get_service_principal_permissions', response: $assignmentsResponse, transform: function (array $json) use ($context) { $assignments = $json['value'] ?? []; $permissions = []; // Get Microsoft Graph service principal to map role IDs to permission names $graphSpEndpoint = "servicePrincipals?\$filter=appId eq '00000003-0000-0000-c000-000000000000'"; $graphSpResponse = $this->send('GET', $graphSpEndpoint, [], $context); $graphSps = $graphSpResponse->json('value', []); $appRoles = $graphSps[0]['appRoles'] ?? []; // Map role IDs to permission names $roleMap = []; foreach ($appRoles as $role) { $roleMap[$role['id']] = $role['value']; } foreach ($assignments as $assignment) { $roleId = $assignment['appRoleId'] ?? null; if ($roleId && isset($roleMap[$roleId])) { $permissions[] = $roleMap[$roleId]; } } return ['permissions' => $permissions]; }, meta: [ 'tenant' => $context['tenant'] ?? null, 'path' => $assignmentsEndpoint, 'full_path' => $assignmentsEndpoint, 'method' => 'GET', 'client_request_id' => $clientRequestId, ] ); } public function request(string $method, string $path, array $options = []): GraphResponse { $context = $this->resolveContext($options); $method = strtoupper($method); $query = $options['query'] ?? []; $fullPath = $this->buildFullPath($path, $query); $clientRequestId = $options['client_request_id'] ?? (string) Str::uuid(); $action = strtolower($method).' '.$fullPath; $this->logger->logRequest($action, [ 'endpoint' => $path, 'full_path' => $fullPath, 'method' => $method, 'tenant' => $context['tenant'], 'query' => $query ?: null, 'client_request_id' => $clientRequestId, ]); $options['client_request_id'] = $clientRequestId; try { $response = $this->send($method, $path, $options, $context); } catch (RequestException|GraphException $exception) { $graphResponse = $this->graphResponseFromException($exception, [ 'tenant' => $context['tenant'] ?? null, 'path' => $path, 'full_path' => $fullPath, 'method' => $method, 'client_request_id' => $clientRequestId, 'query' => $query ?: null, ]); $this->logger->logResponse($action, $graphResponse, [ 'tenant' => $context['tenant'] ?? null, 'endpoint' => $path, 'full_path' => $fullPath, 'method' => $method, ]); return $graphResponse; } return $this->toGraphResponse( action: $action, response: $response, transform: fn (array $json) => $json, meta: [ 'tenant' => $context['tenant'] ?? null, 'path' => $path, 'full_path' => $fullPath, 'method' => $method, 'query' => $query ?: null, 'client_request_id' => $clientRequestId, ] ); } private function send(string $method, string $path, array $options = [], array $context = []): Response { $context = $context ?: $this->resolveContext([]); $token = $options['access_token'] ?? $context['access_token'] ?? null; $clientRequestId = $options['client_request_id'] ?? (string) Str::uuid(); if (! $token) { $token = $this->getAccessToken($context); } $pending = Http::baseUrl($this->baseUrl) ->acceptJson() ->timeout($this->timeout) ->retry( $this->retryTimes, fn (int $attempt, Throwable $exception): int => $this->retryDelayMs($attempt), function (Throwable $exception): bool { if ($exception instanceof ConnectionException) { return true; } if ($exception instanceof RequestException) { $status = $exception->response?->status(); return in_array($status, [429, 503], true); } return false; }, throw: true, ) ->withToken($token) ->withHeaders([ 'client-request-id' => $clientRequestId, ]); if (! empty($options['query'])) { $pending = $pending->withQueryParameters($options['query']); } try { $response = $pending->send( $method, ltrim($path, '/'), isset($options['json']) ? ['json' => $options['json']] : [] ); } catch (ConnectionException $exception) { throw new GraphException( 'Graph connection failed: '.$exception->getMessage(), null, ['path' => $path, 'method' => $method, 'tenant' => $context['tenant'] ?? null] ); } catch (RequestException $exception) { if ($exception->response) { $response = $exception->response; } else { throw GraphErrorMapper::fromThrowable( $exception, ['path' => $path, 'method' => $method, 'tenant' => $context['tenant'] ?? null] ); } } catch (Throwable $throwable) { throw GraphErrorMapper::fromThrowable( $throwable, ['path' => $path, 'method' => $method, 'tenant' => $context['tenant'] ?? null] ); } return $response; } private function retryDelayMs(int $attempt): int { $baseMs = max(0, $this->retrySleepMs); if ($attempt <= 1 || $baseMs === 0) { return $baseMs; } $exponential = $baseMs * (2 ** ($attempt - 1)); $capped = min($exponential, 5000); $jitterMax = (int) floor($capped * 0.2); $jitter = $jitterMax > 0 ? random_int(0, $jitterMax) : 0; return $capped + $jitter; } private function toGraphResponse(string $action, Response $response, callable $transform, array $meta = [], array $warnings = []): GraphResponse { $json = $response->json() ?? []; $meta = $this->responseMeta($response, $meta); if ($response->failed()) { $error = $response->json('error') ?? $json ?? $response->body(); $graphResponse = new GraphResponse( success: false, data: is_array($json) ? $json : [], status: $response->status(), errors: is_array($error) ? [$error] : [$error], warnings: $warnings, meta: $meta, ); $this->logger->logResponse($action, $graphResponse, $meta); return $graphResponse; } $graphResponse = new GraphResponse( success: true, data: $transform(is_array($json) ? $json : []), status: $response->status(), warnings: $warnings, meta: $meta, ); $this->logger->logResponse($action, $graphResponse, $meta); return $graphResponse; } private function graphResponseFromException(RequestException|GraphException $exception, array $context = []): GraphResponse { if ($exception instanceof RequestException && $exception->response) { $response = $exception->response; $json = $response->json() ?? []; $error = $response->json('error') ?? $json ?? $response->body(); return new GraphResponse( success: false, data: is_array($json) ? $json : [], status: $response->status(), errors: is_array($error) ? [$error] : [$error], meta: $this->responseMeta($response, $context), ); } $contextualError = []; if ($exception instanceof GraphException && ! empty($exception->context)) { $contextualError = $exception->context + ['message' => $exception->getMessage()]; } return new GraphResponse( success: false, data: [], status: $exception instanceof GraphException ? $exception->status : null, errors: [$contextualError ?: $exception->getMessage()], meta: $context, ); } /** * @return array{tenant:string,client_id:string,client_secret:string|null,scope:array,token_url:string} */ private function resolveContext(array $options): array { $tenant = $options['tenant'] ?? $this->tenantId; $clientId = $options['client_id'] ?? $this->clientId; $clientSecret = $options['client_secret'] ?? $this->clientSecret; $tokenUrlTemplate = $options['token_url'] ?? $this->tokenUrlTemplate; $scopes = $this->normalizeScopes($options['scope'] ?? null); return [ 'tenant' => $tenant, 'client_id' => $clientId, 'client_secret' => $clientSecret, 'scope' => $scopes, 'token_url' => sprintf($tokenUrlTemplate, $tenant), ]; } /** * @param array|string|null $scope * @return array */ private function normalizeScopes(array|string|null $scope): array { if ($scope === null) { return $this->defaultScopes; } if (is_string($scope)) { $scope = array_values(array_filter(explode(' ', $scope))); } if (is_array($scope)) { $scope = array_values(array_filter($scope, static fn ($value) => ! empty($value))); } return $scope ?: $this->defaultScopes; } private function endpointFor(string $policyType): string { $contractResource = $this->contracts->resourcePath($policyType); if (is_string($contractResource) && $contractResource !== '') { return $contractResource; } $builtinEndpoint = $this->builtinEndpointFor($policyType); if ($builtinEndpoint !== null) { return $builtinEndpoint; } $types = array_merge( config('tenantpilot.supported_policy_types', []), config('tenantpilot.foundation_types', []), ); foreach ($types as $type) { if (($type['type'] ?? null) === $policyType && ! empty($type['endpoint'])) { return $type['endpoint']; } } return 'deviceManagement/'.$policyType; } private function builtinEndpointFor(string $policyType): ?string { return match ($policyType) { 'settingsCatalogPolicy', 'endpointSecurityPolicy', 'securityBaselinePolicy' => 'deviceManagement/configurationPolicies', default => null, }; } private function getAccessToken(array $context): string { $tenant = $context['tenant'] ?? $this->tenantId; $clientId = $context['client_id'] ?? $this->clientId; $scopes = $context['scope'] ?? $this->defaultScopes; $cacheKey = sprintf('graph_token_%s_%s_%s', $tenant, $clientId, md5(implode('|', $scopes))); if ($token = Cache::get($cacheKey)) { return $token; } [$token, $ttl] = $this->requestAccessToken($context); Cache::put($cacheKey, $token, now()->addSeconds($ttl)); return $token; } /** * @return array{0:string,1:int} [token, ttlSeconds] */ private function requestAccessToken(array $context): array { $tenant = $context['tenant'] ?? $this->tenantId; $clientId = $context['client_id'] ?? $this->clientId; $clientSecret = $context['client_secret'] ?? $this->clientSecret; $scopes = $context['scope'] ?? $this->defaultScopes; $tokenUrl = $context['token_url'] ?? sprintf($this->tokenUrlTemplate, $tenant); $response = Http::asForm() ->timeout($this->timeout) ->retry($this->retryTimes, $this->retrySleepMs) ->post($tokenUrl, [ 'client_id' => $clientId, 'scope' => implode(' ', $scopes), 'client_secret' => $clientSecret, 'grant_type' => 'client_credentials', ]); if ($response->failed()) { $error = $response->json('error') ?? $response->json() ?? $response->body(); throw new GraphException( 'Unable to fetch Graph token', $response->status(), ['error' => $error] ); } $data = $response->json(); $expiresIn = (int) ($data['expires_in'] ?? 300); $ttl = max(60, $expiresIn - 60); return [(string) $data['access_token'], $ttl]; } private function buildFullPath(string $path, array $query = []): string { $path = ltrim($path, '/'); if (empty($query)) { return $path; } return sprintf('%s?%s', $path, http_build_query($query)); } /** * @return array */ private function responseMeta(Response $response, array $context = []): array { $requestId = $response->header('request-id') ?? $response->header('x-ms-request-id'); $clientRequestId = $response->header('client-request-id') ?? $context['client_request_id'] ?? null; $bodyExcerpt = Str::limit((string) $response->body(), 2000); $jsonError = $response->json('error'); $errorCode = is_array($jsonError) ? ($jsonError['code'] ?? null) : null; $errorMessage = is_array($jsonError) ? ($jsonError['message'] ?? null) : (is_string($jsonError) ? $jsonError : null); return array_filter([ 'tenant' => $context['tenant'] ?? null, 'path' => $context['path'] ?? null, 'full_path' => $context['full_path'] ?? null, 'method' => $context['method'] ?? null, 'query' => $context['query'] ?? null, 'request_id' => $requestId, 'client_request_id' => $clientRequestId, 'body_excerpt' => $bodyExcerpt, 'error_code' => $errorCode, 'error_message' => $errorMessage, ], static fn ($value) => $value !== null && $value !== ''); } }