LogApiRequests.php 3.9 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139
  1. <?php
  2. namespace Webkul\BagistoApi\Http\Middleware;
  3. use Closure;
  4. use Illuminate\Http\Request;
  5. use Illuminate\Support\Facades\Log;
  6. use Webkul\BagistoApi\Jobs\LogApiRequestJob;
  7. /**
  8. * Logs all API requests for security audit trail and monitoring
  9. */
  10. class LogApiRequests
  11. {
  12. /**
  13. * Paths to exclude from logging
  14. */
  15. private const EXCLUDED_PATHS = [
  16. 'health',
  17. 'ping',
  18. 'docs',
  19. 'graphiql',
  20. 'swagger-ui',
  21. 'docs.json',
  22. '.well-known',
  23. ];
  24. public function handle(Request $request, Closure $next)
  25. {
  26. $startTime = microtime(true);
  27. $response = $next($request);
  28. $duration = round((microtime(true) - $startTime) * 1000, 2);
  29. if ($this->shouldLog($request, $response)) {
  30. $this->logRequestAsync($request, $response, $duration);
  31. }
  32. return $response;
  33. }
  34. private function shouldLog(Request $request, $response): bool
  35. {
  36. foreach (self::EXCLUDED_PATHS as $path) {
  37. if (str_contains($request->path(), $path)) {
  38. return false;
  39. }
  40. }
  41. return str_starts_with($request->path(), 'api/');
  42. }
  43. private function logRequestAsync(Request $request, $response, float $duration): void
  44. {
  45. $logData = [
  46. 'method' => $request->getMethod(),
  47. 'path' => $request->path(),
  48. 'status' => $response->getStatusCode(),
  49. 'duration_ms' => $duration,
  50. 'ip' => $request->ip(),
  51. 'user_agent' => substr($request->userAgent() ?? '', 0, 255),
  52. 'user_id' => auth()->id(),
  53. 'api_key' => $this->maskApiKey($request->header('X-STOREFRONT-KEY')),
  54. 'graphql_operation' => $this->getGraphQLOperation($request),
  55. ];
  56. try {
  57. // Skip async logging in testing environment to avoid job queue issues
  58. if (app()->environment('testing')) {
  59. $this->logSync($logData);
  60. } elseif (class_exists(LogApiRequestJob::class)) {
  61. dispatch(new LogApiRequestJob($logData))->onQueue('api-logs');
  62. } else {
  63. $this->logSync($logData);
  64. }
  65. } catch (\Throwable $e) {
  66. $this->logSync($logData);
  67. }
  68. }
  69. private function logSync(array $logData): void
  70. {
  71. $level = $this->getLogLevel($logData['status']);
  72. try {
  73. // Use 'api' channel if configured, otherwise fallback to default
  74. if (config('logging.channels.api')) {
  75. Log::channel('api')->log($level, 'API Request', $logData);
  76. } else {
  77. Log::log($level, 'API Request', $logData);
  78. }
  79. } catch (\Throwable $e) {
  80. // If channel logging fails, use default logger
  81. Log::log($level, 'API Request', $logData);
  82. }
  83. }
  84. private function getLogLevel(int $statusCode): string
  85. {
  86. if ($statusCode >= 500) {
  87. return 'error';
  88. }
  89. if ($statusCode >= 400) {
  90. return 'warning';
  91. }
  92. return 'info';
  93. }
  94. private function maskApiKey(?string $apiKey): string
  95. {
  96. if (! $apiKey || strlen($apiKey) < 6) {
  97. return 'none';
  98. }
  99. return substr($apiKey, 0, 3).'***'.substr($apiKey, -3);
  100. }
  101. private function getGraphQLOperation(Request $request): ?string
  102. {
  103. if ($request->path() !== 'api/graphql') {
  104. return null;
  105. }
  106. $input = $request->json('operationName')
  107. ?? $this->extractOperationName($request->json('query') ?? '');
  108. return $input;
  109. }
  110. private function extractOperationName(string $query): ?string
  111. {
  112. if (preg_match('/^\s*(query|mutation|subscription)\s+(\w+)/i', $query, $matches)) {
  113. return strtolower($matches[1]).': '.$matches[2];
  114. }
  115. return null;
  116. }
  117. }