diff --git a/config/autoload/matomo.global.php b/config/autoload/matomo.global.php index a72d48a4d..120ad2898 100644 --- a/config/autoload/matomo.global.php +++ b/config/autoload/matomo.global.php @@ -10,7 +10,7 @@ 'enabled' => (bool) EnvVars::MATOMO_ENABLED->loadFromEnv(false), 'base_url' => EnvVars::MATOMO_BASE_URL->loadFromEnv(), 'site_id' => EnvVars::MATOMO_SITE_ID->loadFromEnv(), - 'token' => EnvVars::MATOMO_API_TOKEN->loadFromEnv(), + 'api_token' => EnvVars::MATOMO_API_TOKEN->loadFromEnv(), ], ]; diff --git a/module/Core/config/dependencies.config.php b/module/Core/config/dependencies.config.php index a245b10ed..591fcc796 100644 --- a/module/Core/config/dependencies.config.php +++ b/module/Core/config/dependencies.config.php @@ -92,6 +92,9 @@ Importer\ImportedLinksProcessor::class => ConfigAbstractFactory::class, Crawling\CrawlingHelper::class => ConfigAbstractFactory::class, + + Matomo\MatomoOptions::class => [ValinorConfigFactory::class, 'config.matomo'], + Matomo\MatomoTrackerBuilder::class => ConfigAbstractFactory::class, ], 'aliases' => [ @@ -100,6 +103,8 @@ ], ConfigAbstractFactory::class => [ + Matomo\MatomoTrackerBuilder::class => [Matomo\MatomoOptions::class], + ErrorHandler\NotFoundTypeResolverMiddleware::class => ['config.router.base_path'], ErrorHandler\NotFoundTrackerMiddleware::class => [Visit\RequestTracker::class], ErrorHandler\NotFoundRedirectHandler::class => [ diff --git a/module/Core/config/event_dispatcher.config.php b/module/Core/config/event_dispatcher.config.php index ac8626e81..312e39172 100644 --- a/module/Core/config/event_dispatcher.config.php +++ b/module/Core/config/event_dispatcher.config.php @@ -11,6 +11,7 @@ use Shlinkio\Shlink\Common\Mercure\MercureHubPublishingHelper; use Shlinkio\Shlink\Common\Mercure\MercureOptions; use Shlinkio\Shlink\Common\RabbitMq\RabbitMqPublishingHelper; +use Shlinkio\Shlink\Core\ShortUrl\Helper\ShortUrlStringifier; use Shlinkio\Shlink\Core\Visit\Geolocation\VisitLocator; use Shlinkio\Shlink\Core\Visit\Geolocation\VisitToLocationHelper; use Shlinkio\Shlink\EventDispatcher\Listener\EnabledListenerCheckerInterface; @@ -18,152 +19,177 @@ use Shlinkio\Shlink\IpGeolocation\GeoLite2\GeoLite2Options; use Shlinkio\Shlink\IpGeolocation\Resolver\IpLocationResolverInterface; -return [ +use function Shlinkio\Shlink\Config\runningInOpenswoole; +use function Shlinkio\Shlink\Config\runningInRoadRunner; - 'events' => [ - 'regular' => [ - EventDispatcher\Event\UrlVisited::class => [ - EventDispatcher\LocateVisit::class, - ], - EventDispatcher\Event\GeoLiteDbCreated::class => [ - EventDispatcher\LocateUnlocatedVisits::class, - ], +return (static function (): array { + $regularEvents = [ + EventDispatcher\Event\UrlVisited::class => [ + EventDispatcher\LocateVisit::class, ], - 'async' => [ - EventDispatcher\Event\VisitLocated::class => [ - EventDispatcher\Mercure\NotifyVisitToMercure::class, - EventDispatcher\RabbitMq\NotifyVisitToRabbitMq::class, - EventDispatcher\RedisPubSub\NotifyVisitToRedis::class, - EventDispatcher\NotifyVisitToWebHooks::class, - EventDispatcher\UpdateGeoLiteDb::class, - ], - EventDispatcher\Event\ShortUrlCreated::class => [ - EventDispatcher\Mercure\NotifyNewShortUrlToMercure::class, - EventDispatcher\RabbitMq\NotifyNewShortUrlToRabbitMq::class, - EventDispatcher\RedisPubSub\NotifyNewShortUrlToRedis::class, - ], + EventDispatcher\Event\GeoLiteDbCreated::class => [ + EventDispatcher\LocateUnlocatedVisits::class, ], - ], - - 'dependencies' => [ - 'factories' => [ - EventDispatcher\LocateVisit::class => ConfigAbstractFactory::class, - EventDispatcher\LocateUnlocatedVisits::class => ConfigAbstractFactory::class, - EventDispatcher\NotifyVisitToWebHooks::class => ConfigAbstractFactory::class, - EventDispatcher\Mercure\NotifyVisitToMercure::class => ConfigAbstractFactory::class, - EventDispatcher\Mercure\NotifyNewShortUrlToMercure::class => ConfigAbstractFactory::class, - EventDispatcher\RabbitMq\NotifyVisitToRabbitMq::class => ConfigAbstractFactory::class, - EventDispatcher\RabbitMq\NotifyNewShortUrlToRabbitMq::class => ConfigAbstractFactory::class, - EventDispatcher\RedisPubSub\NotifyVisitToRedis::class => ConfigAbstractFactory::class, - EventDispatcher\RedisPubSub\NotifyNewShortUrlToRedis::class => ConfigAbstractFactory::class, - EventDispatcher\UpdateGeoLiteDb::class => ConfigAbstractFactory::class, - - EventDispatcher\Helper\EnabledListenerChecker::class => ConfigAbstractFactory::class, + ]; + $asyncEvents = [ + EventDispatcher\Event\VisitLocated::class => [ + EventDispatcher\Mercure\NotifyVisitToMercure::class, + EventDispatcher\RabbitMq\NotifyVisitToRabbitMq::class, + EventDispatcher\RedisPubSub\NotifyVisitToRedis::class, + EventDispatcher\NotifyVisitToWebHooks::class, + EventDispatcher\UpdateGeoLiteDb::class, + ], + EventDispatcher\Event\ShortUrlCreated::class => [ + EventDispatcher\Mercure\NotifyNewShortUrlToMercure::class, + EventDispatcher\RabbitMq\NotifyNewShortUrlToRabbitMq::class, + EventDispatcher\RedisPubSub\NotifyNewShortUrlToRedis::class, + ], + ]; + + // Send visits to matomo asynchronously if the runtime allows it + if (runningInRoadRunner() || runningInOpenswoole()) { + $asyncEvents[EventDispatcher\Event\VisitLocated::class][] = EventDispatcher\Matomo\SendVisitToMatomo::class; + } else { + $regularEvents[EventDispatcher\Event\VisitLocated::class] = [EventDispatcher\Matomo\SendVisitToMatomo::class]; + } + + return [ + + 'events' => [ + 'regular' => $regularEvents, + 'async' => $asyncEvents, ], - 'aliases' => [ - EnabledListenerCheckerInterface::class => EventDispatcher\Helper\EnabledListenerChecker::class, + 'dependencies' => [ + 'factories' => [ + EventDispatcher\LocateVisit::class => ConfigAbstractFactory::class, + EventDispatcher\Matomo\SendVisitToMatomo::class => ConfigAbstractFactory::class, + EventDispatcher\LocateUnlocatedVisits::class => ConfigAbstractFactory::class, + EventDispatcher\NotifyVisitToWebHooks::class => ConfigAbstractFactory::class, + EventDispatcher\Mercure\NotifyVisitToMercure::class => ConfigAbstractFactory::class, + EventDispatcher\Mercure\NotifyNewShortUrlToMercure::class => ConfigAbstractFactory::class, + EventDispatcher\RabbitMq\NotifyVisitToRabbitMq::class => ConfigAbstractFactory::class, + EventDispatcher\RabbitMq\NotifyNewShortUrlToRabbitMq::class => ConfigAbstractFactory::class, + EventDispatcher\RedisPubSub\NotifyVisitToRedis::class => ConfigAbstractFactory::class, + EventDispatcher\RedisPubSub\NotifyNewShortUrlToRedis::class => ConfigAbstractFactory::class, + EventDispatcher\UpdateGeoLiteDb::class => ConfigAbstractFactory::class, + + EventDispatcher\Helper\EnabledListenerChecker::class => ConfigAbstractFactory::class, + ], + + 'aliases' => [ + EnabledListenerCheckerInterface::class => EventDispatcher\Helper\EnabledListenerChecker::class, + ], + + 'delegators' => [ + EventDispatcher\Mercure\NotifyVisitToMercure::class => [ + EventDispatcher\CloseDbConnectionEventListenerDelegator::class, + ], + EventDispatcher\Mercure\NotifyNewShortUrlToMercure::class => [ + EventDispatcher\CloseDbConnectionEventListenerDelegator::class, + ], + EventDispatcher\RabbitMq\NotifyVisitToRabbitMq::class => [ + EventDispatcher\CloseDbConnectionEventListenerDelegator::class, + ], + EventDispatcher\RabbitMq\NotifyNewShortUrlToRabbitMq::class => [ + EventDispatcher\CloseDbConnectionEventListenerDelegator::class, + ], + EventDispatcher\RedisPubSub\NotifyVisitToRedis::class => [ + EventDispatcher\CloseDbConnectionEventListenerDelegator::class, + ], + EventDispatcher\RedisPubSub\NotifyNewShortUrlToRedis::class => [ + EventDispatcher\CloseDbConnectionEventListenerDelegator::class, + ], + EventDispatcher\LocateUnlocatedVisits::class => [ + EventDispatcher\CloseDbConnectionEventListenerDelegator::class, + ], + EventDispatcher\NotifyVisitToWebHooks::class => [ + EventDispatcher\CloseDbConnectionEventListenerDelegator::class, + ], + ], ], - 'delegators' => [ + ConfigAbstractFactory::class => [ + EventDispatcher\LocateVisit::class => [ + IpLocationResolverInterface::class, + 'em', + 'Logger_Shlink', + DbUpdater::class, + EventDispatcherInterface::class, + ], + EventDispatcher\LocateUnlocatedVisits::class => [VisitLocator::class, VisitToLocationHelper::class], + EventDispatcher\NotifyVisitToWebHooks::class => [ + 'httpClient', + 'em', + 'Logger_Shlink', + Options\WebhookOptions::class, + ShortUrl\Transformer\ShortUrlDataTransformer::class, + Options\AppOptions::class, + ], EventDispatcher\Mercure\NotifyVisitToMercure::class => [ - EventDispatcher\CloseDbConnectionEventListenerDelegator::class, + MercureHubPublishingHelper::class, + EventDispatcher\PublishingUpdatesGenerator::class, + 'em', + 'Logger_Shlink', ], EventDispatcher\Mercure\NotifyNewShortUrlToMercure::class => [ - EventDispatcher\CloseDbConnectionEventListenerDelegator::class, + MercureHubPublishingHelper::class, + EventDispatcher\PublishingUpdatesGenerator::class, + 'em', + 'Logger_Shlink', ], EventDispatcher\RabbitMq\NotifyVisitToRabbitMq::class => [ - EventDispatcher\CloseDbConnectionEventListenerDelegator::class, + RabbitMqPublishingHelper::class, + EventDispatcher\PublishingUpdatesGenerator::class, + 'em', + 'Logger_Shlink', + Visit\Transformer\OrphanVisitDataTransformer::class, + Options\RabbitMqOptions::class, ], EventDispatcher\RabbitMq\NotifyNewShortUrlToRabbitMq::class => [ - EventDispatcher\CloseDbConnectionEventListenerDelegator::class, + RabbitMqPublishingHelper::class, + EventDispatcher\PublishingUpdatesGenerator::class, + 'em', + 'Logger_Shlink', + Options\RabbitMqOptions::class, ], EventDispatcher\RedisPubSub\NotifyVisitToRedis::class => [ - EventDispatcher\CloseDbConnectionEventListenerDelegator::class, + RedisPublishingHelper::class, + EventDispatcher\PublishingUpdatesGenerator::class, + 'em', + 'Logger_Shlink', + 'config.redis.pub_sub_enabled', ], EventDispatcher\RedisPubSub\NotifyNewShortUrlToRedis::class => [ - EventDispatcher\CloseDbConnectionEventListenerDelegator::class, + RedisPublishingHelper::class, + EventDispatcher\PublishingUpdatesGenerator::class, + 'em', + 'Logger_Shlink', + 'config.redis.pub_sub_enabled', ], - EventDispatcher\LocateUnlocatedVisits::class => [ - EventDispatcher\CloseDbConnectionEventListenerDelegator::class, + + EventDispatcher\Matomo\SendVisitToMatomo::class => [ + 'em', + 'Logger_Shlink', + ShortUrlStringifier::class, + Matomo\MatomoOptions::class, + Matomo\MatomoTrackerBuilder::class, ], - EventDispatcher\NotifyVisitToWebHooks::class => [ - EventDispatcher\CloseDbConnectionEventListenerDelegator::class, + + EventDispatcher\UpdateGeoLiteDb::class => [ + GeolocationDbUpdater::class, + 'Logger_Shlink', + EventDispatcherInterface::class, ], - ], - ], - - ConfigAbstractFactory::class => [ - EventDispatcher\LocateVisit::class => [ - IpLocationResolverInterface::class, - 'em', - 'Logger_Shlink', - DbUpdater::class, - EventDispatcherInterface::class, - ], - EventDispatcher\LocateUnlocatedVisits::class => [VisitLocator::class, VisitToLocationHelper::class], - EventDispatcher\NotifyVisitToWebHooks::class => [ - 'httpClient', - 'em', - 'Logger_Shlink', - Options\WebhookOptions::class, - ShortUrl\Transformer\ShortUrlDataTransformer::class, - Options\AppOptions::class, - ], - EventDispatcher\Mercure\NotifyVisitToMercure::class => [ - MercureHubPublishingHelper::class, - EventDispatcher\PublishingUpdatesGenerator::class, - 'em', - 'Logger_Shlink', - ], - EventDispatcher\Mercure\NotifyNewShortUrlToMercure::class => [ - MercureHubPublishingHelper::class, - EventDispatcher\PublishingUpdatesGenerator::class, - 'em', - 'Logger_Shlink', - ], - EventDispatcher\RabbitMq\NotifyVisitToRabbitMq::class => [ - RabbitMqPublishingHelper::class, - EventDispatcher\PublishingUpdatesGenerator::class, - 'em', - 'Logger_Shlink', - Visit\Transformer\OrphanVisitDataTransformer::class, - Options\RabbitMqOptions::class, - ], - EventDispatcher\RabbitMq\NotifyNewShortUrlToRabbitMq::class => [ - RabbitMqPublishingHelper::class, - EventDispatcher\PublishingUpdatesGenerator::class, - 'em', - 'Logger_Shlink', - Options\RabbitMqOptions::class, - ], - EventDispatcher\RedisPubSub\NotifyVisitToRedis::class => [ - RedisPublishingHelper::class, - EventDispatcher\PublishingUpdatesGenerator::class, - 'em', - 'Logger_Shlink', - 'config.redis.pub_sub_enabled', - ], - EventDispatcher\RedisPubSub\NotifyNewShortUrlToRedis::class => [ - RedisPublishingHelper::class, - EventDispatcher\PublishingUpdatesGenerator::class, - 'em', - 'Logger_Shlink', - 'config.redis.pub_sub_enabled', - ], - EventDispatcher\UpdateGeoLiteDb::class => [ - GeolocationDbUpdater::class, - 'Logger_Shlink', - EventDispatcherInterface::class, - ], - EventDispatcher\Helper\EnabledListenerChecker::class => [ - Options\RabbitMqOptions::class, - 'config.redis.pub_sub_enabled', - MercureOptions::class, - Options\WebhookOptions::class, - GeoLite2Options::class, + EventDispatcher\Helper\EnabledListenerChecker::class => [ + Options\RabbitMqOptions::class, + 'config.redis.pub_sub_enabled', + MercureOptions::class, + Options\WebhookOptions::class, + GeoLite2Options::class, + ], ], - ], -]; + ]; +})(); diff --git a/module/Core/src/EventDispatcher/Event/AbstractVisitEvent.php b/module/Core/src/EventDispatcher/Event/AbstractVisitEvent.php index 907b3d9c2..87f7dba2a 100644 --- a/module/Core/src/EventDispatcher/Event/AbstractVisitEvent.php +++ b/module/Core/src/EventDispatcher/Event/AbstractVisitEvent.php @@ -9,17 +9,19 @@ abstract class AbstractVisitEvent implements JsonSerializable, JsonUnserializable { - final public function __construct(public readonly string $visitId) - { + final public function __construct( + public readonly string $visitId, + public readonly ?string $originalIpAddress = null, + ) { } public function jsonSerialize(): array { - return ['visitId' => $this->visitId]; + return ['visitId' => $this->visitId, 'originalIpAddress' => $this->originalIpAddress]; } public static function fromPayload(array $payload): self { - return new static($payload['visitId'] ?? ''); + return new static($payload['visitId'] ?? '', $payload['originalIpAddress'] ?? null); } } diff --git a/module/Core/src/EventDispatcher/Event/UrlVisited.php b/module/Core/src/EventDispatcher/Event/UrlVisited.php index c57d59d6a..d1158a4ed 100644 --- a/module/Core/src/EventDispatcher/Event/UrlVisited.php +++ b/module/Core/src/EventDispatcher/Event/UrlVisited.php @@ -6,18 +6,4 @@ final class UrlVisited extends AbstractVisitEvent { - private ?string $originalIpAddress = null; - - public static function withOriginalIpAddress(string $visitId, ?string $originalIpAddress): self - { - $instance = new self($visitId); - $instance->originalIpAddress = $originalIpAddress; - - return $instance; - } - - public function originalIpAddress(): ?string - { - return $this->originalIpAddress; - } } diff --git a/module/Core/src/EventDispatcher/LocateVisit.php b/module/Core/src/EventDispatcher/LocateVisit.php index ba3ac3f0f..f139c0f53 100644 --- a/module/Core/src/EventDispatcher/LocateVisit.php +++ b/module/Core/src/EventDispatcher/LocateVisit.php @@ -41,8 +41,8 @@ public function __invoke(UrlVisited $shortUrlVisited): void return; } - $this->locateVisit($visitId, $shortUrlVisited->originalIpAddress(), $visit); - $this->eventDispatcher->dispatch(new VisitLocated($visitId)); + $this->locateVisit($visitId, $shortUrlVisited->originalIpAddress, $visit); + $this->eventDispatcher->dispatch(new VisitLocated($visitId, $shortUrlVisited->originalIpAddress)); } private function locateVisit(string $visitId, ?string $originalIpAddress, Visit $visit): void diff --git a/module/Core/src/EventDispatcher/Matomo/SendVisitToMatomo.php b/module/Core/src/EventDispatcher/Matomo/SendVisitToMatomo.php new file mode 100644 index 000000000..4e0bcb863 --- /dev/null +++ b/module/Core/src/EventDispatcher/Matomo/SendVisitToMatomo.php @@ -0,0 +1,88 @@ +matomoOptions->enabled) { + return; + } + + $visitId = $visitLocated->visitId; + + /** @var Visit|null $visit */ + $visit = $this->em->find(Visit::class, $visitId); + if ($visit === null) { + $this->logger->warning('Tried to send visit with id "{visitId}" to matomo, but it does not exist.', [ + 'visitId' => $visitId, + ]); + return; + } + + try { + $tracker = $this->trackerBuilder->buildMatomoTracker(); + + $tracker + ->setUrl($this->resolveUrlToTrack($visit)) + ->setCustomTrackingParameter('type', $visit->type()->value) + ->setUserAgent($visit->userAgent()); + + $location = $visit->getVisitLocation(); + if ($location !== null) { + $tracker + ->setCity($location->getCityName()) + ->setCountry($location->getCountryName()) + ->setLatitude($location->getLatitude()) + ->setLongitude($location->getLongitude()); + } + + // Set not obfuscated IP if possible, as matomo handles obfuscation itself + $ip = $visitLocated->originalIpAddress ?? $visit->getRemoteAddr(); + if ($ip !== null) { + $tracker->setIp($ip); + } + + if ($visit->isOrphan()) { + $tracker->setCustomTrackingParameter('orphan', 'true'); + } + + // Send empty document title to avoid different actions to be created by matomo + $tracker->doTrackPageView(''); + } catch (Throwable $e) { + // Capture all exceptions to make sure this does not interfere with the regular execution + $this->logger->error('An error occurred while trying to send visit to Matomo. {e}', ['e' => $e]); + } + } + + public function resolveUrlToTrack(Visit $visit): string + { + $shortUrl = $visit->getShortUrl(); + if ($shortUrl === null) { + return $visit->visitedUrl() ?? ''; + } + + return $this->shortUrlStringifier->stringify($shortUrl); + } +} diff --git a/module/Core/src/Matomo/MatomoOptions.php b/module/Core/src/Matomo/MatomoOptions.php new file mode 100644 index 000000000..d2423684a --- /dev/null +++ b/module/Core/src/Matomo/MatomoOptions.php @@ -0,0 +1,27 @@ +siteId === null) { + return null; + } + + // We enforce site ID to be hydrated as a numeric string or int, so it's safe to cast to int here + return (int) $this->siteId; + } +} diff --git a/module/Core/src/Matomo/MatomoTrackerBuilder.php b/module/Core/src/Matomo/MatomoTrackerBuilder.php new file mode 100644 index 000000000..655bbd0ba --- /dev/null +++ b/module/Core/src/Matomo/MatomoTrackerBuilder.php @@ -0,0 +1,39 @@ +options->siteId(); + if ($siteId === null || $this->options->baseUrl === null || $this->options->apiToken === null) { + throw new RuntimeException( + 'Cannot create MatomoTracker. Either site ID, base URL or api token are not defined', + ); + } + + // Create a new MatomoTracker on every request, because it infers request info during construction + $tracker = new MatomoTracker($siteId, $this->options->baseUrl); + // Token required to set the IP and location + $tracker->setTokenAuth($this->options->apiToken); + // We don't want to bulk send, as every request to Shlink will create a new tracker + $tracker->disableBulkTracking(); + // Ensure params are not sent in the URL, for security reasons + $tracker->setRequestMethodNonBulk('POST'); + + return $tracker; + } +} diff --git a/module/Core/src/Matomo/MatomoTrackerBuilderInterface.php b/module/Core/src/Matomo/MatomoTrackerBuilderInterface.php new file mode 100644 index 000000000..7601f17a1 --- /dev/null +++ b/module/Core/src/Matomo/MatomoTrackerBuilderInterface.php @@ -0,0 +1,16 @@ +date; } + public function userAgent(): string + { + return $this->userAgent; + } + public function jsonSerialize(): array { return [ diff --git a/module/Core/src/Visit/VisitsTracker.php b/module/Core/src/Visit/VisitsTracker.php index dd5fff917..9e4b88dfd 100644 --- a/module/Core/src/Visit/VisitsTracker.php +++ b/module/Core/src/Visit/VisitsTracker.php @@ -75,6 +75,6 @@ private function trackVisit(callable $createVisit, Visitor $visitor): void $this->em->persist($visit); $this->em->flush(); - $this->eventDispatcher->dispatch(UrlVisited::withOriginalIpAddress($visit->getId(), $visitor->remoteAddress)); + $this->eventDispatcher->dispatch(new UrlVisited($visit->getId(), $visitor->remoteAddress)); } } diff --git a/module/Core/test/EventDispatcher/LocateVisitTest.php b/module/Core/test/EventDispatcher/LocateVisitTest.php index b6f214951..21c3bf1d3 100644 --- a/module/Core/test/EventDispatcher/LocateVisitTest.php +++ b/module/Core/test/EventDispatcher/LocateVisitTest.php @@ -159,7 +159,7 @@ public function locatableVisitsResolveToLocation(Visit $visit, ?string $original { $ipAddr = $originalIpAddress ?? $visit->getRemoteAddr(); $location = new Location('', '', '', '', 0.0, 0.0, ''); - $event = UrlVisited::withOriginalIpAddress('123', $originalIpAddress); + $event = new UrlVisited('123', $originalIpAddress); $this->em->expects($this->once())->method('find')->with(Visit::class, '123')->willReturn($visit); $this->em->expects($this->once())->method('flush');