Problem

Visit tracking currently has two issues:

  1. Two separate tracking code paths: the ShortUrlVisitMiddleware (HTTP layer) and VisitTracker (service) both build visit field arrays and insert into shorturl_visits independently, with different method signatures. Changes to the visit schema must be applied in both places.
  2. 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

  1. 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).
  2. 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.
  3. Event subscribers react:
    • DatabaseVisitSubscriber (priority 0): writes to shorturl_visits table (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.
  4. VisitTracker service: simplified to dispatch the same event, so direct trackVisit() calls also go through the event system. The getStats() and purgeExpiredVisits() 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) → nid
  • X-Shorturl-Langcode (base module) → langcode
  • X-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 ShortUrlVisitEvent includes domain_id when the field exists on the redirect
  • The DatabaseVisitSubscriber writes it to the shorturl_visits table (when the column exists)
  • Analytics subscribers receive it automatically
  • DomainAwareVisitTracker and DomainAwareVisitMiddleware in 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:

  1. Visit event subscriber (runs during request): collects visit data into an in-memory collector service. No external calls.
  2. 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

Command icon 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

mably created an issue. See original summary.

mably’s picture

Issue summary: View changes
mably’s picture

Issue summary: View changes
mably’s picture

Issue summary: View changes
mably’s picture

Issue summary: View changes

mably’s picture

Status: Active » Needs review

  • mably committed 358b4155 on 2.x
    feat: #3584485 Introduce visit event for extensible analytics...
mably’s picture

Status: Needs review » Fixed

Now that this issue is closed, review the contribution record.

As a contributor, attribute any organization that helped you, or if you volunteered your own time.

Maintainers, credit people who helped resolve this issue.

Status: Fixed » Closed (fixed)

Automatically closed - issue fixed for 2 weeks with no activity.