Add logic to send visits to a matomo instance

This commit is contained in:
Alejandro Celaya 2023-11-15 19:57:58 +01:00
parent 0edb3e5c2c
commit 9dbd15bc0c
13 changed files with 341 additions and 147 deletions

View file

@ -10,7 +10,7 @@ return [
'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(),
],
];

View file

@ -92,6 +92,9 @@ return [
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 @@ return [
],
ConfigAbstractFactory::class => [
Matomo\MatomoTrackerBuilder::class => [Matomo\MatomoOptions::class],
ErrorHandler\NotFoundTypeResolverMiddleware::class => ['config.router.base_path'],
ErrorHandler\NotFoundTrackerMiddleware::class => [Visit\RequestTracker::class],
ErrorHandler\NotFoundRedirectHandler::class => [

View file

@ -11,6 +11,7 @@ use Shlinkio\Shlink\Common\Cache\RedisPublishingHelper;
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\DbUpdater;
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,
],
],
];
$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,
],
];
'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,
// 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];
}
EventDispatcher\Helper\EnabledListenerChecker::class => ConfigAbstractFactory::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,
],
EventDispatcher\Helper\EnabledListenerChecker::class => [
Options\RabbitMqOptions::class,
'config.redis.pub_sub_enabled',
MercureOptions::class,
Options\WebhookOptions::class,
GeoLite2Options::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,
],
],
];
];
})();

View file

@ -9,17 +9,19 @@ use Shlinkio\Shlink\EventDispatcher\Util\JsonUnserializable;
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);
}
}

View file

@ -6,18 +6,4 @@ namespace Shlinkio\Shlink\Core\EventDispatcher\Event;
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;
}
}

View file

@ -41,8 +41,8 @@ class LocateVisit
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

View file

@ -0,0 +1,88 @@
<?php
declare(strict_types=1);
namespace Shlinkio\Shlink\Core\EventDispatcher\Matomo;
use Doctrine\ORM\EntityManagerInterface;
use Psr\Log\LoggerInterface;
use Shlinkio\Shlink\Core\EventDispatcher\Event\VisitLocated;
use Shlinkio\Shlink\Core\Matomo\MatomoOptions;
use Shlinkio\Shlink\Core\Matomo\MatomoTrackerBuilderInterface;
use Shlinkio\Shlink\Core\ShortUrl\Helper\ShortUrlStringifier;
use Shlinkio\Shlink\Core\Visit\Entity\Visit;
use Throwable;
class SendVisitToMatomo
{
public function __construct(
private readonly EntityManagerInterface $em,
private readonly LoggerInterface $logger,
private readonly ShortUrlStringifier $shortUrlStringifier,
private readonly MatomoOptions $matomoOptions,
private readonly MatomoTrackerBuilderInterface $trackerBuilder,
) {
}
public function __invoke(VisitLocated $visitLocated): void
{
if (! $this->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);
}
}

View file

@ -0,0 +1,27 @@
<?php
declare(strict_types=1);
namespace Shlinkio\Shlink\Core\Matomo;
class MatomoOptions
{
public function __construct(
public readonly bool $enabled,
public readonly ?string $baseUrl,
/** @var numeric-string|int|null */
private readonly string|int|null $siteId,
public readonly ?string $apiToken,
) {
}
public function siteId(): ?int
{
if ($this->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;
}
}

View file

@ -0,0 +1,39 @@
<?php
declare(strict_types=1);
namespace Shlinkio\Shlink\Core\Matomo;
use MatomoTracker;
use Shlinkio\Shlink\Core\Exception\RuntimeException;
class MatomoTrackerBuilder implements MatomoTrackerBuilderInterface
{
public function __construct(private readonly MatomoOptions $options)
{
}
/**
* @throws RuntimeException If there's any missing matomo parameter
*/
public function buildMatomoTracker(): MatomoTracker
{
$siteId = $this->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;
}
}

View file

@ -0,0 +1,16 @@
<?php
declare(strict_types=1);
namespace Shlinkio\Shlink\Core\Matomo;
use MatomoTracker;
use Shlinkio\Shlink\Core\Exception\RuntimeException;
interface MatomoTrackerBuilderInterface
{
/**
* @throws RuntimeException If there's any missing matomo parameter
*/
public function buildMatomoTracker(): MatomoTracker;
}

View file

@ -188,6 +188,11 @@ class Visit extends AbstractEntity implements JsonSerializable
return $this->date;
}
public function userAgent(): string
{
return $this->userAgent;
}
public function jsonSerialize(): array
{
return [

View file

@ -75,6 +75,6 @@ class VisitsTracker implements VisitsTrackerInterface
$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));
}
}

View file

@ -159,7 +159,7 @@ class LocateVisitTest extends TestCase
{
$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');