Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
96.49% covered (success)
96.49%
55 / 57
33.33% covered (danger)
33.33%
1 / 3
CRAP
0.00% covered (danger)
0.00%
0 / 1
SqlApiLoggerRepository
96.43% covered (success)
96.43%
54 / 56
33.33% covered (danger)
33.33%
1 / 3
12
0.00% covered (danger)
0.00%
0 / 1
 __construct
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 logRequest
97.62% covered (success)
97.62%
41 / 42
0.00% covered (danger)
0.00%
0 / 1
9
 purgeExpiredLogs
92.31% covered (success)
92.31%
12 / 13
0.00% covered (danger)
0.00%
0 / 1
2.00
1<?php
2
3declare(strict_types=1);
4
5/** @license For full copyright and license information, please see the LICENSE.md file. */
6
7namespace App\Core\Api\Infrastructure\Repository;
8
9defined('AMMONLY_APP') || exit('Direct script access is forbidden.');
10
11use App\Core\Api\Domain\Model\ApiLogDetail;
12use App\Core\Api\Domain\Model\ApiLogEntry;
13use App\Core\Api\Domain\Repository\ApiLoggerInterface;
14use PDO;
15use Throwable;
16
17/**
18 * SQL Implementation of API Request Logger Repository.
19 *
20 * Persists high-throughput API access logs and detailed payload dumps using prepared PDO statements.
21 *
22 * @package App\Core\Api\Infrastructure\Repository
23 */
24final readonly class SqlApiLoggerRepository implements ApiLoggerInterface
25{
26    /**
27     * SqlApiLoggerRepository constructor.
28     *
29     * @param PDO $pdo Active PDO database connection.
30     */
31    public function __construct(
32        private PDO $pdo,
33    ) {
34    }
35
36    /** {@inheritdoc} */
37    public function logRequest(ApiLogEntry $entry, ?ApiLogDetail $detail = null): void
38    {
39        try {
40            $sql = 'INSERT INTO `a_logs_api_requests_records` (
41                `request_id`, `client_instance_id`, `user_id`, `api_key_id`,
42                `http_method`, `route_pattern`, `request_uri`, `status_code`,
43                `duration_ms`, `memory_peak_bytes`, `response_bytes`,
44                `ip_address`, `user_agent`, `log_level`,
45                `is_slow`, `is_error`, `is_aborted`, `has_payload_dump`, `created_at`
46            ) VALUES (
47                :request_id, :client_instance_id, :user_id, :api_key_id,
48                :http_method, :route_pattern, :request_uri, :status_code,
49                :duration_ms, :memory_peak_bytes, :response_bytes,
50                :ip_address, :user_agent, :log_level,
51                :is_slow, :is_error, :is_aborted, :has_payload_dump, :created_at
52            )';
53
54            $stmt = $this->pdo->prepare($sql);
55            $stmt->execute([
56                ':request_id'         => $entry->requestId,
57                ':client_instance_id' => $entry->clientInstanceId,
58                ':user_id'            => $entry->userId,
59                ':api_key_id'         => $entry->apiKeyId,
60                ':http_method'        => $entry->httpMethod,
61                ':route_pattern'      => $entry->routePattern,
62                ':request_uri'        => substr($entry->requestUri, 0, 500),
63                ':status_code'        => $entry->statusCode,
64                ':duration_ms'        => number_format($entry->durationMs, 3, '.', ''),
65                ':memory_peak_bytes'  => $entry->memoryPeakBytes,
66                ':response_bytes'     => $entry->responseBytes,
67                ':ip_address'         => $entry->ipAddress,
68                ':user_agent'         => substr($entry->userAgent, 0, 255),
69                ':log_level'          => $entry->logLevel,
70                ':is_slow'            => $entry->isSlow ? 1 : 0,
71                ':is_error'           => $entry->isError ? 1 : 0,
72                ':is_aborted'         => $entry->isAborted ? 1 : 0,
73                ':has_payload_dump'   => ($detail !== null || $entry->hasPayloadDump) ? 1 : 0,
74                ':created_at'         => $entry->createdAt->format('Y-m-d H:i:s.v'),
75            ]);
76
77            $logId = (int) $this->pdo->lastInsertId();
78
79            if ($detail !== null && $logId > 0) {
80                $detailSql = 'INSERT INTO `a_logs_api_details_records` (
81                    `id`, `request_id`, `request_headers`, `request_payload`,
82                    `response_headers`, `response_payload`,
83                    `exception_class`, `exception_message`, `exception_trace`, `created_at`
84                ) VALUES (
85                    :id, :request_id, :request_headers, :request_payload,
86                    :response_headers, :response_payload,
87                    :exception_class, :exception_message, :exception_trace, :created_at
88                )';
89
90                $detailStmt = $this->pdo->prepare($detailSql);
91                $detailStmt->execute([
92                    ':id'                => $logId,
93                    ':request_id'        => $detail->requestId,
94                    ':request_headers'   => $detail->requestHeaders,
95                    ':request_payload'   => $detail->requestPayload,
96                    ':response_headers'  => $detail->responseHeaders,
97                    ':response_payload'  => $detail->responsePayload,
98                    ':exception_class'   => $detail->exceptionClass,
99                    ':exception_message' => $detail->exceptionMessage,
100                    ':exception_trace'   => $detail->exceptionTrace,
101                    ':created_at'        => $entry->createdAt->format('Y-m-d H:i:s.v'),
102                ]);
103            }
104        } catch (Throwable) {
105            // Fail-safe: Logging failure must never break active user request
106        }
107    }
108
109    /** {@inheritdoc} */
110    public function purgeExpiredLogs(int $standardRetentionDays = 14, int $errorRetentionDays = 90): int
111    {
112        $deleted = 0;
113
114        try {
115            // 1. Purge fast standard success logs older than standard retention days
116            $sql1 = 'DELETE FROM `a_logs_api_requests_records`
117                     WHERE `is_slow` = 0
118                       AND `is_error` = 0
119                       AND `is_aborted` = 0
120                       AND `created_at` < DATE_SUB(NOW(), INTERVAL :std_days DAY)';
121            $stmt1 = $this->pdo->prepare($sql1);
122            $stmt1->execute([':std_days' => max(1, $standardRetentionDays)]);
123            $deleted += $stmt1->rowCount();
124
125            // 2. Purge remaining error/slow logs older than error retention days
126            $sql2 = 'DELETE FROM `a_logs_api_requests_records`
127                     WHERE `created_at` < DATE_SUB(NOW(), INTERVAL :err_days DAY)';
128            $stmt2 = $this->pdo->prepare($sql2);
129            $stmt2->execute([':err_days' => max(1, $errorRetentionDays)]);
130            $deleted += $stmt2->rowCount();
131        } catch (Throwable) {
132            // Log error during maintenance
133        }
134
135        return $deleted;
136    }
137}