Problem
Visit tracking currently has two issues:
- Two separate tracking code paths: the
ShortUrlVisitMiddleware(HTTP layer) andVisitTracker(service) both build visit field arrays and insert intoshorturl_visitsindependently, with different method signatures. Changes to the visit schema must be applied in both places. - No extension point for external analytics: there is no way for submodules to send visit events to external analytics services (Matomo, Piano/AT Internet, Google Analytics Measurement Protocol, etc.) without monkey-patching or replacing the entire tracker.
Proposed solution
Introduce a visit event dispatched via Symfony EventDispatcher when a visit occurs. This provides a single, clean extension point for all visit-related behavior.
Architecture
- ShortUrlVisitEvent: a Symfony event carrying all visit data (nid, langcode, referrer, IP hash, user agent, country code, timestamp, plus any extra fields added by submodules like domain_id).
- Middleware dispatches the event: instead of doing its own DB insert, the middleware builds the visit data from response headers and dispatches a
ShortUrlVisitEvent. This becomes the single entry point for all visit tracking. - Event subscribers react:
- DatabaseVisitSubscriber (priority 0): writes to
shorturl_visitstable (current behavior, now in one place) - AnalyticsVisitSubscriber (contrib/custom): sends the event to Matomo, Piano, GA, etc.
- Custom subscribers (contrib/custom): can read any field from the event and send to external services, add logging, etc.
- DatabaseVisitSubscriber (priority 0): writes to
- VisitTracker service: simplified to dispatch the same event, so direct
trackVisit()calls also go through the event system. ThegetStats()andpurgeExpiredVisits()methods remain on the service (they are DB-specific).
Benefits
- Single code path for visit tracking (no more middleware vs service duplication)
- Submodules add fields via event subscribers (e.g. domain_id) with standard Drupal patterns
- External analytics integration via event subscribers, no core changes needed
- Consistent method signatures throughout
- Easy to test (dispatch event, assert subscriber behavior)
Example: analytics subscriber
class MatomoVisitSubscriber implements EventSubscriberInterface {
public static function getSubscribedEvents(): array {
return [ShortUrlVisitEvent::class => 'onVisit'];
}
public function onVisit(ShortUrlVisitEvent $event): void {
// Send to Matomo tracking API.
$this->matomoClient->trackPageView(
$event->getSlug(),
$event->getReferrer(),
$event->getCountryCode(),
);
}
}
Automatic field collection from response headers
Instead of hardcoding which headers to read, the middleware should collect all X-Shorturl-* response headers automatically and add them to the event:
$fields = [];
foreach ($response->headers->all() as $name => $values) {
if (str_starts_with($name, 'x-shorturl-')) {
$key = substr($name, 11); // strip "x-shorturl-"
$fields[$key] = $values[0];
}
}
Any module that stamps an X-Shorturl-* header in hook_redirect_response_alter automatically gets that field into the event. Zero code changes in shorturl for new fields. For example:
X-Shorturl-Nid(base module) →nidX-Shorturl-Langcode(base module) →langcodeX-Shorturl-DomainId(domain_shorturl) →domainid- Any future
X-Shorturl-CampaignId,X-Shorturl-ABVariant, etc.
The headers are cached by PageCache along with the redirect response, so subsequent anonymous requests still carry all metadata without any DB lookup.
Domain awareness is generic, not a submodule concern
The domain_id field on redirects is added by domain_redirect (a base field on the redirect entity). The event should carry domain_id natively by reading it from the redirect entity, just like it reads shorturl_nid and langcode.
This means:
- The
ShortUrlVisitEventincludesdomain_idwhen the field exists on the redirect - The
DatabaseVisitSubscriberwrites it to theshorturl_visitstable (when the column exists) - Analytics subscribers receive it automatically
DomainAwareVisitTrackerandDomainAwareVisitMiddlewarein domain_shorturl become unnecessary and can be removed
The event architecture eliminates the need for separate domain-aware overrides of the tracker and middleware. The redirect entity is the single source of truth, and the event carries all its relevant fields generically.
Backward compatibility
The VisitTrackerInterface remains unchanged. The buildVisitFields() method (added in #3584473) would be replaced by the event data object, but since it is a protected method, this is not a BC break.
Performance considerations
The current middleware is optimized for zero DB lookups: it reads metadata from cached response headers and does a single INSERT. The event-based architecture must preserve this performance characteristic.
Event dispatch overhead
Dispatching a Symfony event is negligible: an array lookup + method call per subscriber. No reflection or DI resolution at dispatch time. With 1–3 subscribers this adds microseconds.
Risks and mitigations
| Risk | Impact | Mitigation |
|---|---|---|
| Subscriber dependency loading | A subscriber that injects heavy services (HTTP client, analytics SDK) causes those to be instantiated at dispatch time, even on non-shorturl requests. | Use lazy service proxies for subscribers with heavy dependencies. The container only instantiates the real service when a method is called. |
| Blocking external API calls | An analytics subscriber that makes a synchronous HTTP call to Matomo/Piano during the request blocks the redirect response. | Subscribers should write to a lightweight queue (database or memory). A cron worker or kernel.terminate subscriber sends batched events to the external API after the response. |
| Entity loading | If the event carries a Redirect entity, that requires a DB load that the middleware currently avoids. | The event carries raw field data (from response headers), not entities. The ShortUrlVisitEvent object holds scalar values: nid, langcode, referrer, domain_id, etc. Subscribers that need the full entity can load it themselves. |
| Multiple DB writes | Multiple subscribers each doing their own INSERT. | Only the DatabaseVisitSubscriber writes to shorturl_visits. Other subscribers read from the event data (which is already in memory) and write to their own destinations. |
Performance target
The event-based architecture should add no more than 0.5ms overhead per visit compared to the current direct INSERT. This should be validated with benchmarks before and after the refactor.
Integration with kernel.terminate for external analytics
External analytics subscribers (Matomo, Piano, GA) should NOT make HTTP calls during the ShortUrlVisitEvent — that would block the redirect response. Instead, the recommended pattern uses kernel.terminate:
- Visit event subscriber (runs during request): collects visit data into an in-memory collector service. No external calls.
- kernel.terminate subscriber (runs after response is sent): reads collected visits and sends them to the external API. The user does not wait.
Example pattern
// Collects visits during the request.
class VisitAnalyticsCollector {
protected array $visits = [];
public function add(ShortUrlVisitEvent $event): void {
$this->visits[] = $event->getFields();
}
public function flush(): array {
$visits = $this->visits;
$this->visits = [];
return $visits;
}
}
// Subscribes to visit event — just collects, no API call.
class AnalyticsVisitSubscriber implements EventSubscriberInterface {
public static function getSubscribedEvents(): array {
return [ShortUrlVisitEvent::class => 'onVisit'];
}
public function onVisit(ShortUrlVisitEvent $event): void {
$this->collector->add($event);
}
}
// Subscribes to kernel.terminate — sends to API after response.
class AnalyticsTerminateSubscriber implements EventSubscriberInterface {
public static function getSubscribedEvents(): array {
return [KernelEvents::TERMINATE => 'onTerminate'];
}
public function onTerminate(): void {
foreach ($this->collector->flush() as $visit) {
$this->analyticsClient->send($visit);
}
}
}
Caveat: middleware and container bootstrap
The visit middleware runs at priority 210 (before PageCache at 200). On cached anonymous requests, kernel.terminate still fires, but the Drupal container may not be fully bootstrapped. Analytics subscribers that rely on Drupal services (config, entity storage) need to verify the container is available. The visit data itself (from the event) is always available since it was collected during the middleware phase.
Issue fork shorturl-3584485
Show commands
Start within a Git clone of the project using the version control instructions.
Or, if you do not have SSH keys set up on git.drupalcode.org:
Comments
Comment #2
mably commentedComment #3
mably commentedComment #4
mably commentedComment #5
mably commentedComment #7
mably commentedComment #9
mably commented