Feature Implementation Plan: API Request Logging¶
Overview¶
Implement an asynchronous API request logging system to capture every request to /api/* endpoints. This data will later be used for:
1. Admin UI to visualize traffic patterns (Cloudflare-style analytics)
2. Cache warming by identifying "hot" endpoints
Related Plans:
- api-request-admin-ui.md (depends on this plan)
- api-cache-warming.md (depends on this plan)
Todo Checklist¶
- [ ] Create
ApiRequestPatternentity with migration - [ ] Create
ApiRequestOccurrenceentity with migration - [ ] Create
ApiRequestPatternRepositorywith native upsert method - [ ] Create
ApiRequestOccurrenceRepository - [ ] Create
LogApiRequestMessagemessage class - [ ] Create
LogApiRequestHandlermessage handler - [ ] Create
ApiRequestLogListenerkernel event listener - [ ] Configure Messenger transport for logging
- [ ] Add data retention scheduled task
- [ ] Write tests for the logging pipeline
Analysis & Investigation¶
Existing Infrastructure¶
Messenger Setup:
- Multiple transports configured in config/packages/messenger.php
- Uses TransportNamesStamp to route messages to specific transports
- Handlers use #[AsMessageHandler] attribute pattern
- Failed messages stored in Doctrine-backed failed transport
Cache Infrastructure:
- CacheKeyGenerator normalizes query params (sorts keys, handles arrays)
- Redis tag-aware cache pools with multiple TTL strategies
- CacheResponseService sets Cache-Control and custom headers
Event Listener Pattern:
- Uses #[AsEventListener] attribute (see ErrorLoggingListener)
- Priority -100 runs after main processing
Entity Conventions:
- UUID primary keys with doctrine.uuid_generator
- DateTimeImmutable for timestamps
- JSON columns for flexible data storage
Architecture Design¶
┌─────────────────────────────────────────────────────────────────┐
│ Request Lifecycle │
├─────────────────────────────────────────────────────────────────┤
│ │
│ HTTP Request │
│ │ │
│ ▼ │
│ ┌─────────────┐ │
│ │ Controller │ → Process request → Return response │
│ └─────────────┘ │
│ │ │
│ ▼ │
│ ┌──────────────────────┐ │
│ │ ApiRequestLogListener│ (KernelEvents::TERMINATE) │
│ │ - Capture metrics │ │
│ │ - Dispatch message │ → Response sent to client (immediate) │
│ └──────────────────────┘ │
│ │ │
│ ▼ (async) │
│ ┌──────────────────────┐ │
│ │ LogApiRequestHandler │ (Messenger worker) │
│ │ - Generate hash │ │
│ │ - Upsert pattern │ │
│ │ - Insert occurrence│ │
│ └──────────────────────┘ │
│ │ │
│ ▼ │
│ ┌──────────────────────┐ │
│ │ PostgreSQL │ │
│ │ - api_request_pattern │
│ │ - api_request_occurrence │
│ └──────────────────────┘ │
│ │
└─────────────────────────────────────────────────────────────────┘
Entity Design¶
ApiRequestPattern - Unique request signatures (deduplicated)
┌────────────────────────────────────────┐
│ api_request_pattern │
├────────────────────────────────────────┤
│ id UUID PK │
│ method VARCHAR(10) │
│ path VARCHAR(255) │
│ query_hash VARCHAR(64) UNIQUE │ ← SHA256 of method:path:normalized_params
│ query_params JSON │ ← Original params for display
│ hit_count BIGINT DEFAULT 0 │ ← Denormalized for fast sorting
│ avg_response_time_ms FLOAT │ ← Running average
│ last_seen_at TIMESTAMP │
│ created_at TIMESTAMP │
├────────────────────────────────────────┤
│ INDEX idx_hit_count (hit_count) │
│ INDEX idx_last_seen (last_seen_at) │
│ UNIQUE idx_query_hash (query_hash) │
└────────────────────────────────────────┘
ApiRequestOccurrence - Individual request instances
┌────────────────────────────────────────┐
│ api_request_occurrence │
├────────────────────────────────────────┤
│ id UUID PK │
│ pattern_id UUID FK │ → api_request_pattern.id
│ ip_address VARCHAR(45) NULL │ ← IPv6 max length
│ user_agent TEXT NULL │
│ response_time_ms FLOAT │
│ cache_status VARCHAR(20) NULL │ ← HIT/MISS/STALE
│ status_code SMALLINT │
│ created_at TIMESTAMP │
├────────────────────────────────────────┤
│ INDEX idx_pattern (pattern_id) │
│ INDEX idx_created (created_at) │
│ INDEX idx_pattern_created │
│ (pattern_id, created_at) │
└────────────────────────────────────────┘
Query Hash Generation¶
Include HTTP method in the hash to differentiate GET vs POST to same endpoint:
$signature = sprintf(
'%s:%s:%s',
$method, // GET, POST, etc.
$path, // /api/coffee-beans
$this->normalizeQueryParams($params) // Sorted, serialized params
);
$queryHash = hash('sha256', $signature);
Upsert Strategy¶
Use native PostgreSQL ON CONFLICT for atomic, race-condition-free updates:
INSERT INTO api_request_pattern
(id, method, path, query_hash, query_params, hit_count, avg_response_time_ms, last_seen_at, created_at)
VALUES
(gen_random_uuid(), :method, :path, :hash, :params, 1, :responseTime, NOW(), NOW())
ON CONFLICT (query_hash) DO UPDATE SET
hit_count = api_request_pattern.hit_count + 1,
avg_response_time_ms = (
(api_request_pattern.avg_response_time_ms * api_request_pattern.hit_count) + :responseTime
) / (api_request_pattern.hit_count + 1),
last_seen_at = NOW()
RETURNING id;
Implementation Plan¶
Prerequisites¶
- Symfony Messenger configured (already done)
- Redis for message transport (already configured)
- PostgreSQL database (already in use)
Step 1: Create ApiRequestPattern Entity¶
File: src/Entity/ApiRequestPattern.php
<?php
namespace App\Entity;
use App\Repository\ApiRequestPatternRepository;
use DateTimeImmutable;
use Doctrine\DBAL\Types\Types;
use Doctrine\ORM\Mapping as ORM;
#[ORM\Entity(repositoryClass: ApiRequestPatternRepository::class)]
#[ORM\Table(name: 'api_request_pattern')]
#[ORM\UniqueConstraint(name: 'uniq_query_hash', columns: ['query_hash'])]
#[ORM\Index(columns: ['hit_count'], name: 'idx_pattern_hit_count')]
#[ORM\Index(columns: ['last_seen_at'], name: 'idx_pattern_last_seen')]
final class ApiRequestPattern
{
#[ORM\Id]
#[ORM\Column(type: 'uuid', unique: true)]
#[ORM\GeneratedValue(strategy: 'CUSTOM')]
#[ORM\CustomIdGenerator(class: 'doctrine.uuid_generator')]
private ?string $id = null;
#[ORM\Column(length: 10)]
private string $method;
#[ORM\Column(length: 255)]
private string $path;
#[ORM\Column(length: 64, unique: true)]
private string $queryHash;
#[ORM\Column(type: Types::JSON)]
private array $queryParams = [];
#[ORM\Column(type: Types::BIGINT, options: ['default' => 0])]
private int $hitCount = 0;
#[ORM\Column(type: Types::FLOAT, nullable: true)]
private ?float $avgResponseTimeMs = null;
#[ORM\Column]
private DateTimeImmutable $lastSeenAt;
#[ORM\Column]
private DateTimeImmutable $createdAt;
public function __construct(
string $method,
string $path,
string $queryHash,
array $queryParams,
) {
$this->method = $method;
$this->path = $path;
$this->queryHash = $queryHash;
$this->queryParams = $queryParams;
$this->createdAt = new DateTimeImmutable();
$this->lastSeenAt = new DateTimeImmutable();
}
// Getters...
}
Step 2: Create ApiRequestOccurrence Entity¶
File: src/Entity/ApiRequestOccurrence.php
<?php
namespace App\Entity;
use App\Repository\ApiRequestOccurrenceRepository;
use DateTimeImmutable;
use Doctrine\DBAL\Types\Types;
use Doctrine\ORM\Mapping as ORM;
#[ORM\Entity(repositoryClass: ApiRequestOccurrenceRepository::class)]
#[ORM\Table(name: 'api_request_occurrence')]
#[ORM\Index(columns: ['pattern_id'], name: 'idx_occurrence_pattern')]
#[ORM\Index(columns: ['created_at'], name: 'idx_occurrence_created')]
#[ORM\Index(columns: ['pattern_id', 'created_at'], name: 'idx_occurrence_pattern_created')]
final class ApiRequestOccurrence
{
#[ORM\Id]
#[ORM\Column(type: 'uuid', unique: true)]
#[ORM\GeneratedValue(strategy: 'CUSTOM')]
#[ORM\CustomIdGenerator(class: 'doctrine.uuid_generator')]
private ?string $id = null;
#[ORM\ManyToOne(targetEntity: ApiRequestPattern::class)]
#[ORM\JoinColumn(nullable: false, onDelete: 'CASCADE')]
private ApiRequestPattern $pattern;
#[ORM\Column(length: 45, nullable: true)]
private ?string $ipAddress = null;
#[ORM\Column(type: Types::TEXT, nullable: true)]
private ?string $userAgent = null;
#[ORM\Column(type: Types::FLOAT)]
private float $responseTimeMs;
#[ORM\Column(length: 20, nullable: true)]
private ?string $cacheStatus = null;
#[ORM\Column(type: Types::SMALLINT)]
private int $statusCode;
#[ORM\Column]
private DateTimeImmutable $createdAt;
public function __construct(
ApiRequestPattern $pattern,
float $responseTimeMs,
int $statusCode,
?string $ipAddress = null,
?string $userAgent = null,
?string $cacheStatus = null,
) {
$this->pattern = $pattern;
$this->responseTimeMs = $responseTimeMs;
$this->statusCode = $statusCode;
$this->ipAddress = $ipAddress;
$this->userAgent = $userAgent;
$this->cacheStatus = $cacheStatus;
$this->createdAt = new DateTimeImmutable();
}
// Getters...
}
Step 3: Create ApiRequestPatternRepository with Native Upsert¶
File: src/Repository/ApiRequestPatternRepository.php
<?php
namespace App\Repository;
use App\Entity\ApiRequestPattern;
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
use Doctrine\Persistence\ManagerRegistry;
/**
* @extends ServiceEntityRepository<ApiRequestPattern>
*/
final class ApiRequestPatternRepository extends ServiceEntityRepository
{
public function __construct(ManagerRegistry $registry)
{
parent::__construct($registry, ApiRequestPattern::class);
}
/**
* Upserts a request pattern and returns the pattern ID.
* Uses native PostgreSQL ON CONFLICT for atomic operation.
*/
public function upsertPattern(
string $method,
string $path,
string $queryHash,
array $queryParams,
float $responseTimeMs,
): string {
$conn = $this->getEntityManager()->getConnection();
$sql = <<<'SQL'
INSERT INTO api_request_pattern
(id, method, path, query_hash, query_params, hit_count, avg_response_time_ms, last_seen_at, created_at)
VALUES
(gen_random_uuid(), :method, :path, :hash, :params, 1, :responseTime, NOW(), NOW())
ON CONFLICT (query_hash) DO UPDATE SET
hit_count = api_request_pattern.hit_count + 1,
avg_response_time_ms = (
(api_request_pattern.avg_response_time_ms * api_request_pattern.hit_count) + :responseTime
) / (api_request_pattern.hit_count + 1),
last_seen_at = NOW()
RETURNING id
SQL;
$result = $conn->executeQuery($sql, [
'method' => $method,
'path' => $path,
'hash' => $queryHash,
'params' => json_encode($queryParams),
'responseTime' => $responseTimeMs,
]);
return $result->fetchOne();
}
/**
* Get top patterns by hit count for cache warming.
*
* @return ApiRequestPattern[]
*/
public function findTopPatterns(int $limit = 20): array
{
return $this->createQueryBuilder('p')
->orderBy('p.hitCount', 'DESC')
->setMaxResults($limit)
->getQuery()
->getResult();
}
}
Step 4: Create LogApiRequestMessage¶
File: src/Message/LogApiRequestMessage.php
<?php
namespace App\Message;
final readonly class LogApiRequestMessage
{
public function __construct(
public string $method,
public string $path,
public array $queryParams,
public float $responseTimeMs,
public int $statusCode,
public ?string $ipAddress = null,
public ?string $userAgent = null,
public ?string $cacheStatus = null,
) {}
}
Step 5: Create LogApiRequestHandler¶
File: src/MessageHandler/LogApiRequestHandler.php
<?php
namespace App\MessageHandler;
use App\Entity\ApiRequestOccurrence;
use App\Message\LogApiRequestMessage;
use App\Repository\ApiRequestPatternRepository;
use Doctrine\ORM\EntityManagerInterface;
use Psr\Log\LoggerInterface;
use Symfony\Component\Messenger\Attribute\AsMessageHandler;
#[AsMessageHandler]
final readonly class LogApiRequestHandler
{
public function __construct(
private ApiRequestPatternRepository $patternRepository,
private EntityManagerInterface $entityManager,
private LoggerInterface $logger,
) {}
public function __invoke(LogApiRequestMessage $message): void
{
try {
$queryHash = $this->generateQueryHash(
$message->method,
$message->path,
$message->queryParams,
);
// Atomic upsert - returns pattern ID
$patternId = $this->patternRepository->upsertPattern(
$message->method,
$message->path,
$queryHash,
$message->queryParams,
$message->responseTimeMs,
);
// Fetch the pattern entity for the relationship
$pattern = $this->patternRepository->find($patternId);
if ($pattern === null) {
$this->logger->error('Pattern not found after upsert', [
'patternId' => $patternId,
]);
return;
}
// Create occurrence record
$occurrence = new ApiRequestOccurrence(
pattern: $pattern,
responseTimeMs: $message->responseTimeMs,
statusCode: $message->statusCode,
ipAddress: $message->ipAddress,
userAgent: $message->userAgent,
cacheStatus: $message->cacheStatus,
);
$this->entityManager->persist($occurrence);
$this->entityManager->flush();
} catch (\Throwable $e) {
$this->logger->error('Failed to log API request', [
'path' => $message->path,
'error' => $e->getMessage(),
]);
throw $e;
}
}
private function generateQueryHash(string $method, string $path, array $params): string
{
// Sort params for consistent hashing
ksort($params);
// Normalize array values
array_walk($params, function (&$value) {
if (is_array($value)) {
sort($value);
$value = implode(',', $value);
}
});
$signature = sprintf(
'%s:%s:%s',
$method,
$path,
http_build_query($params),
);
return hash('sha256', $signature);
}
}
Step 6: Create ApiRequestLogListener¶
File: src/EventListener/ApiRequestLogListener.php
<?php
namespace App\EventListener;
use App\Message\LogApiRequestMessage;
use Symfony\Component\EventDispatcher\Attribute\AsEventListener;
use Symfony\Component\HttpKernel\Event\TerminateEvent;
use Symfony\Component\HttpKernel\KernelEvents;
use Symfony\Component\Messenger\MessageBusInterface;
use Symfony\Component\Stopwatch\Stopwatch;
#[AsEventListener(event: KernelEvents::TERMINATE)]
final readonly class ApiRequestLogListener
{
public function __construct(
private MessageBusInterface $messageBus,
private Stopwatch $stopwatch,
) {}
public function onKernelTerminate(TerminateEvent $event): void
{
if (!$event->isMainRequest()) {
return;
}
$request = $event->getRequest();
$response = $event->getResponse();
// Only log API routes
if (!str_starts_with($request->getPathInfo(), '/api/')) {
return;
}
// Skip non-GET methods for now (adjust as needed)
if ($request->getMethod() !== 'GET') {
return;
}
// Calculate response time
$responseTimeMs = $this->calculateResponseTime();
$message = new LogApiRequestMessage(
method: $request->getMethod(),
path: $request->getPathInfo(),
queryParams: $request->query->all(),
responseTimeMs: $responseTimeMs,
statusCode: $response->getStatusCode(),
ipAddress: $request->getClientIp(),
userAgent: $request->headers->get('User-Agent'),
cacheStatus: $response->headers->get('X-Cache-Status'),
);
$this->messageBus->dispatch($message);
}
private function calculateResponseTime(): float
{
if ($this->stopwatch->isStarted('request')) {
$event = $this->stopwatch->stop('request');
return $event->getDuration();
}
// Fallback: calculate from REQUEST_TIME_FLOAT
$startTime = $_SERVER['REQUEST_TIME_FLOAT'] ?? microtime(true);
return (microtime(true) - $startTime) * 1000;
}
}
Step 7: Configure Messenger Transport¶
File: config/packages/messenger.php (add to existing config)
// Add new transport for API logging
'api_request_log' => [
'dsn' => '%env(MESSENGER_TRANSPORT_DSN)%',
'options' => [
'queue_name' => 'api_request_log',
],
],
// Add routing for the message
'routing' => [
// ... existing routes ...
LogApiRequestMessage::class => 'api_request_log',
],
Step 8: Create Data Retention Scheduler¶
File: src/Scheduler/Message/PurgeApiRequestOccurrencesMessage.php
<?php
namespace App\Scheduler\Message;
use Symfony\Component\Scheduler\Attribute\AsSchedule;
#[AsSchedule('purge_api_logs')]
final readonly class PurgeApiRequestOccurrencesMessage
{
public function __construct(
public int $retentionDays = 90,
) {}
}
File: src/Scheduler/Handler/PurgeApiRequestOccurrencesHandler.php
<?php
namespace App\Scheduler\Handler;
use App\Repository\ApiRequestOccurrenceRepository;
use App\Scheduler\Message\PurgeApiRequestOccurrencesMessage;
use Psr\Log\LoggerInterface;
use Symfony\Component\Messenger\Attribute\AsMessageHandler;
#[AsMessageHandler]
final readonly class PurgeApiRequestOccurrencesHandler
{
public function __construct(
private ApiRequestOccurrenceRepository $repository,
private LoggerInterface $logger,
) {}
public function __invoke(PurgeApiRequestOccurrencesMessage $message): void
{
$deletedCount = $this->repository->deleteOlderThan($message->retentionDays);
$this->logger->info('Purged old API request occurrences', [
'deleted_count' => $deletedCount,
'retention_days' => $message->retentionDays,
]);
}
}
Step 9: Generate Migration¶
Review and adjust the generated migration, ensuring indexes are created correctly.
Testing Strategy¶
Unit Tests¶
- LogApiRequestHandler - Test hash generation is deterministic
- ApiRequestPatternRepository - Test upsert increments hit_count correctly
Integration Tests¶
- Full pipeline test:
- Make API request
- Verify message is dispatched
- Process message with worker
-
Verify pattern and occurrence records created
-
Deduplication test:
- Make same request twice
- Verify only one pattern exists with hit_count = 2
- Verify two occurrences exist
Manual Testing¶
# Start worker for api_request_log transport
make sf c="messenger:consume api_request_log -vv"
# Make test requests
make api path="/api/coffee-beans"
make api path="/api/coffee-beans?roaster=some-uuid"
# Verify records
make sf c="dbal:run-sql 'SELECT * FROM api_request_pattern'"
make sf c="dbal:run-sql 'SELECT COUNT(*) FROM api_request_occurrence'"
Success Criteria¶
- [ ] API requests to
/api/*are logged without impacting response time - [ ] Identical requests share a single pattern with incrementing hit_count
- [ ] Running average response time is calculated correctly
- [ ] Data retention job removes old occurrences
- [ ] Worker processes messages reliably
- [ ] No duplicate patterns created under concurrent load (atomic upsert)
Security Considerations¶
PII in Logs¶
- IP addresses: Stored for debugging, consider anonymization (zero last octet) if needed
- Query params: May contain sensitive data - be aware when building admin UI
- User agents: Generally safe, useful for bot detection
Data Retention¶
The 90-day default retention can be adjusted via the scheduler message constructor. Consider: - Shorter retention for high-traffic deployments - Longer retention for debugging historical issues
Future Enhancements (Out of Scope)¶
These will be addressed in follow-up plans:
- Admin UI (
api-request-admin-ui.md) - Cloudflare-style log viewer
- UUID resolution for human-readable display
-
Filtering and time range selection
-
Cache Warming (
api-cache-warming.md) - Event-driven warming on cache invalidation
- Query top patterns from this logging data
- Re-fetch endpoints to populate cache