From 6121efec59942a76ed7b196a67e047923ba87b22 Mon Sep 17 00:00:00 2001 From: Alejandro Celaya Date: Sat, 13 Apr 2024 09:55:40 +0200 Subject: [PATCH] Create command to send visits to matomo --- module/CLI/config/cli.config.php | 2 + module/CLI/config/dependencies.config.php | 8 + .../Integration/MatomoSendVisitsCommand.php | 144 ++++++++++++++++++ module/Core/config/dependencies.config.php | 6 +- .../Matomo/SendVisitToMatomo.php | 2 +- module/Core/src/Matomo/MatomoVisitSender.php | 32 +++- .../src/Matomo/MatomoVisitSenderInterface.php | 12 +- .../src/Matomo/Model/SendVisitsResult.php | 33 ++++ .../VisitSendingProgressTrackerInterface.php | 14 ++ .../Matomo/SendVisitToMatomoTest.php | 8 +- .../test/Matomo/MatomoVisitSenderTest.php | 9 +- 11 files changed, 260 insertions(+), 10 deletions(-) create mode 100644 module/CLI/src/Command/Integration/MatomoSendVisitsCommand.php create mode 100644 module/Core/src/Matomo/Model/SendVisitsResult.php create mode 100644 module/Core/src/Matomo/VisitSendingProgressTrackerInterface.php diff --git a/module/CLI/config/cli.config.php b/module/CLI/config/cli.config.php index 63b2de6f..2ee33a1d 100644 --- a/module/CLI/config/cli.config.php +++ b/module/CLI/config/cli.config.php @@ -42,6 +42,8 @@ return [ Command\RedirectRule\ManageRedirectRulesCommand::NAME => Command\RedirectRule\ManageRedirectRulesCommand::class, + + Command\Integration\MatomoSendVisitsCommand::NAME => Command\Integration\MatomoSendVisitsCommand::class, ], ], diff --git a/module/CLI/config/dependencies.config.php b/module/CLI/config/dependencies.config.php index 875c8226..f9b90dac 100644 --- a/module/CLI/config/dependencies.config.php +++ b/module/CLI/config/dependencies.config.php @@ -8,6 +8,7 @@ use Laminas\ServiceManager\AbstractFactory\ConfigAbstractFactory; use Laminas\ServiceManager\Factory\InvokableFactory; use Shlinkio\Shlink\Common\Doctrine\NoDbNameConnectionFactory; use Shlinkio\Shlink\Core\Domain\DomainService; +use Shlinkio\Shlink\Core\Matomo; use Shlinkio\Shlink\Core\Options\TrackingOptions; use Shlinkio\Shlink\Core\Options\UrlShortenerOptions; use Shlinkio\Shlink\Core\RedirectRule\ShortUrlRedirectRuleService; @@ -71,6 +72,8 @@ return [ Command\Domain\GetDomainVisitsCommand::class => ConfigAbstractFactory::class, Command\RedirectRule\ManageRedirectRulesCommand::class => ConfigAbstractFactory::class, + + Command\Integration\MatomoSendVisitsCommand::class => ConfigAbstractFactory::class, ], ], @@ -129,6 +132,11 @@ return [ RedirectRule\RedirectRuleHandler::class, ], + Command\Integration\MatomoSendVisitsCommand::class => [ + Matomo\MatomoOptions::class, + Matomo\MatomoVisitSender::class, + ], + Command\Db\CreateDatabaseCommand::class => [ LockFactory::class, Util\ProcessRunner::class, diff --git a/module/CLI/src/Command/Integration/MatomoSendVisitsCommand.php b/module/CLI/src/Command/Integration/MatomoSendVisitsCommand.php new file mode 100644 index 00000000..cacfb9d4 --- /dev/null +++ b/module/CLI/src/Command/Integration/MatomoSendVisitsCommand.php @@ -0,0 +1,144 @@ +matomoEnabled = $matomoOptions->enabled; + parent::__construct(); + } + + protected function configure(): void + { + $help = <<%command.name% + + Send all visits created before 2024: + %command.name% --until 2023-12-31 + + Send all visits created after a specific day: + %command.name% --since 2022-03-27 + + Send all visits created during 2022: + %command.name% --since 2022-01-01 --until 2022-12-31 + HELP; + + $this + ->setName(self::NAME) + ->setDescription(sprintf( + '%sSend existing visits to the configured matomo instance', + $this->matomoEnabled ? '' : '[MATOMO INTEGRATION DISABLED] ', + )) + ->setHelp($help) + ->addOption( + 'since', + 's', + InputOption::VALUE_REQUIRED, + 'Only visits created since this date, inclusively, will be sent to Matomo', + ) + ->addOption( + 'until', + 'u', + InputOption::VALUE_REQUIRED, + 'Only visits created until this date, inclusively, will be sent to Matomo', + ); + } + + protected function execute(InputInterface $input, OutputInterface $output): int + { + $io = new SymfonyStyle($input, $output); + + if (! $this->matomoEnabled) { + $io->warning('Matomo integration is not enabled in this Shlink instance'); + return ExitCode::EXIT_WARNING; + } + + // TODO Validate provided date formats + $since = $input->getOption('since'); + $until = $input->getOption('until'); + $dateRange = buildDateRange( + startDate: $since !== null ? Chronos::parse($since) : null, + endDate: $until !== null ? Chronos::parse($until) : null, + ); + + if ($input->isInteractive()) { + // TODO Display the resolved date range in case it didn't fail to parse but the value was incorrect + $io->warning([ + 'You are about to send visits in this Shlink instance to Matomo', + 'Shlink will not check for already sent visits, which could result in some duplications. Make sure ' + . 'you have verified only visits in the right date range are going to be sent.', + ]); + if (! $io->confirm('Continue?', default: false)) { + return ExitCode::EXIT_WARNING; + } + } + + $result = $this->visitSender->sendVisitsInDateRange( + $dateRange, + new class ($io, $this->getApplication()) implements VisitSendingProgressTrackerInterface { + public function __construct(private readonly SymfonyStyle $io, private readonly ?Application $app) + { + } + + public function success(int $index): void + { + $this->io->write('.'); + } + + public function error(int $index, Throwable $e): void + { + $this->io->write('E'); + if ($this->io->isVerbose()) { + $this->app?->renderThrowable($e, $this->io); + } + } + }, + ); + + match (true) { + $result->hasFailures() && $result->hasSuccesses() => $io->warning( + sprintf('%s visits sent to Matomo. %s failed', $result->successfulVisits, $result->failedVisits), + ), + $result->hasFailures() => $io->error( + sprintf('%s visits failed to be sent to Matomo.', $result->failedVisits), + ), + $result->hasSuccesses() => $io->success(sprintf('%s visits sent to Matomo.', $result->successfulVisits)), + default => $io->info('There was no visits matching provided date range'), + }; + + return ExitCode::EXIT_SUCCESS; + } +} diff --git a/module/Core/config/dependencies.config.php b/module/Core/config/dependencies.config.php index 8a333824..5fcc8e44 100644 --- a/module/Core/config/dependencies.config.php +++ b/module/Core/config/dependencies.config.php @@ -112,7 +112,11 @@ return [ ConfigAbstractFactory::class => [ Matomo\MatomoTrackerBuilder::class => [Matomo\MatomoOptions::class], - Matomo\MatomoVisitSender::class => [Matomo\MatomoTrackerBuilder::class, ShortUrlStringifier::class], + Matomo\MatomoVisitSender::class => [ + Matomo\MatomoTrackerBuilder::class, + ShortUrlStringifier::class, + Visit\Repository\VisitIterationRepository::class, + ], ErrorHandler\NotFoundTypeResolverMiddleware::class => ['config.router.base_path'], ErrorHandler\NotFoundTrackerMiddleware::class => [Visit\RequestTracker::class], diff --git a/module/Core/src/EventDispatcher/Matomo/SendVisitToMatomo.php b/module/Core/src/EventDispatcher/Matomo/SendVisitToMatomo.php index c47b9cba..5a85aed4 100644 --- a/module/Core/src/EventDispatcher/Matomo/SendVisitToMatomo.php +++ b/module/Core/src/EventDispatcher/Matomo/SendVisitToMatomo.php @@ -40,7 +40,7 @@ readonly class SendVisitToMatomo } try { - $this->visitSender->sendVisitToMatomo($visit, $visitLocated->originalIpAddress); + $this->visitSender->sendVisit($visit, $visitLocated->originalIpAddress); } 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]); diff --git a/module/Core/src/Matomo/MatomoVisitSender.php b/module/Core/src/Matomo/MatomoVisitSender.php index c051516c..d2a4484a 100644 --- a/module/Core/src/Matomo/MatomoVisitSender.php +++ b/module/Core/src/Matomo/MatomoVisitSender.php @@ -4,18 +4,48 @@ declare(strict_types=1); namespace Shlinkio\Shlink\Core\Matomo; +use Shlinkio\Shlink\Common\Util\DateRange; +use Shlinkio\Shlink\Core\Matomo\Model\SendVisitsResult; use Shlinkio\Shlink\Core\ShortUrl\Helper\ShortUrlStringifier; use Shlinkio\Shlink\Core\Visit\Entity\Visit; +use Shlinkio\Shlink\Core\Visit\Repository\VisitIterationRepositoryInterface; +use Throwable; readonly class MatomoVisitSender implements MatomoVisitSenderInterface { public function __construct( private MatomoTrackerBuilderInterface $trackerBuilder, private ShortUrlStringifier $shortUrlStringifier, + private VisitIterationRepositoryInterface $visitIterationRepository, ) { } - public function sendVisitToMatomo(Visit $visit, ?string $originalIpAddress = null): void + /** + * Sends all visits in provided date range to matomo, and returns the amount of affected visits + */ + public function sendVisitsInDateRange( + DateRange $dateRange, + VisitSendingProgressTrackerInterface|null $progressTracker = null, + ): SendVisitsResult { + $visitsIterator = $this->visitIterationRepository->findAllVisits($dateRange); + $successfulVisits = 0; + $failedVisits = 0; + + foreach ($visitsIterator as $index => $visit) { + try { + $this->sendVisit($visit); + $progressTracker?->success($index); + $successfulVisits++; + } catch (Throwable $e) { + $progressTracker?->error($index, $e); + $failedVisits++; + } + } + + return new SendVisitsResult($successfulVisits, $failedVisits); + } + + public function sendVisit(Visit $visit, ?string $originalIpAddress = null): void { $tracker = $this->trackerBuilder->buildMatomoTracker(); diff --git a/module/Core/src/Matomo/MatomoVisitSenderInterface.php b/module/Core/src/Matomo/MatomoVisitSenderInterface.php index fef16367..e1b1c3cb 100644 --- a/module/Core/src/Matomo/MatomoVisitSenderInterface.php +++ b/module/Core/src/Matomo/MatomoVisitSenderInterface.php @@ -4,9 +4,19 @@ declare(strict_types=1); namespace Shlinkio\Shlink\Core\Matomo; +use Shlinkio\Shlink\Common\Util\DateRange; +use Shlinkio\Shlink\Core\Matomo\Model\SendVisitsResult; use Shlinkio\Shlink\Core\Visit\Entity\Visit; interface MatomoVisitSenderInterface { - public function sendVisitToMatomo(Visit $visit, ?string $originalIpAddress = null): void; + /** + * Sends all visits in provided date range to matomo, and returns the amount of affected visits + */ + public function sendVisitsInDateRange( + DateRange $dateRange, + VisitSendingProgressTrackerInterface|null $progressTracker = null, + ): SendVisitsResult; + + public function sendVisit(Visit $visit, ?string $originalIpAddress = null): void; } diff --git a/module/Core/src/Matomo/Model/SendVisitsResult.php b/module/Core/src/Matomo/Model/SendVisitsResult.php new file mode 100644 index 00000000..2f6b455b --- /dev/null +++ b/module/Core/src/Matomo/Model/SendVisitsResult.php @@ -0,0 +1,33 @@ + $successfulVisits + * @param int<0, max> $failedVisits + */ + public function __construct(public int $successfulVisits = 0, public int $failedVisits = 0) + { + } + + public function hasSuccesses(): bool + { + return $this->successfulVisits > 0; + } + + public function hasFailures(): bool + { + return $this->failedVisits > 0; + } + + public function count(): int + { + return $this->successfulVisits + $this->failedVisits; + } +} diff --git a/module/Core/src/Matomo/VisitSendingProgressTrackerInterface.php b/module/Core/src/Matomo/VisitSendingProgressTrackerInterface.php new file mode 100644 index 00000000..94686992 --- /dev/null +++ b/module/Core/src/Matomo/VisitSendingProgressTrackerInterface.php @@ -0,0 +1,14 @@ +em->expects($this->never())->method('find'); - $this->visitSender->expects($this->never())->method('sendVisitToMatomo'); + $this->visitSender->expects($this->never())->method('sendVisit'); $this->logger->expects($this->never())->method('error'); $this->logger->expects($this->never())->method('warning'); @@ -46,7 +46,7 @@ class SendVisitToMatomoTest extends TestCase public function visitIsNotSentWhenItDoesNotExist(): void { $this->em->expects($this->once())->method('find')->willReturn(null); - $this->visitSender->expects($this->never())->method('sendVisitToMatomo'); + $this->visitSender->expects($this->never())->method('sendVisit'); $this->logger->expects($this->never())->method('error'); $this->logger->expects($this->once())->method('warning')->with( 'Tried to send visit with id "{visitId}" to matomo, but it does not exist.', @@ -63,7 +63,7 @@ class SendVisitToMatomoTest extends TestCase $visit = Visit::forBasePath(Visitor::emptyInstance()); $this->em->expects($this->once())->method('find')->with(Visit::class, $visitId)->willReturn($visit); - $this->visitSender->expects($this->once())->method('sendVisitToMatomo')->with($visit, $originalIpAddress); + $this->visitSender->expects($this->once())->method('sendVisit')->with($visit, $originalIpAddress); $this->logger->expects($this->never())->method('error'); $this->logger->expects($this->never())->method('warning'); @@ -85,7 +85,7 @@ class SendVisitToMatomoTest extends TestCase $this->em->expects($this->once())->method('find')->with(Visit::class, $visitId)->willReturn( $this->createMock(Visit::class), ); - $this->visitSender->expects($this->once())->method('sendVisitToMatomo')->willThrowException($e); + $this->visitSender->expects($this->once())->method('sendVisit')->willThrowException($e); $this->logger->expects($this->never())->method('warning'); $this->logger->expects($this->once())->method('error')->with( 'An error occurred while trying to send visit to Matomo. {e}', diff --git a/module/Core/test/Matomo/MatomoVisitSenderTest.php b/module/Core/test/Matomo/MatomoVisitSenderTest.php index 90c52446..0bad6577 100644 --- a/module/Core/test/Matomo/MatomoVisitSenderTest.php +++ b/module/Core/test/Matomo/MatomoVisitSenderTest.php @@ -18,19 +18,24 @@ use Shlinkio\Shlink\Core\ShortUrl\Model\Validation\ShortUrlInputFilter; use Shlinkio\Shlink\Core\Visit\Entity\Visit; use Shlinkio\Shlink\Core\Visit\Entity\VisitLocation; use Shlinkio\Shlink\Core\Visit\Model\Visitor; +use Shlinkio\Shlink\Core\Visit\Repository\VisitIterationRepositoryInterface; use Shlinkio\Shlink\IpGeolocation\Model\Location; class MatomoVisitSenderTest extends TestCase { private MockObject & MatomoTrackerBuilderInterface $trackerBuilder; + private MockObject & VisitIterationRepositoryInterface $visitIterationRepository; private MatomoVisitSender $visitSender; protected function setUp(): void { $this->trackerBuilder = $this->createMock(MatomoTrackerBuilderInterface::class); + $this->visitIterationRepository = $this->createMock(VisitIterationRepositoryInterface::class); + $this->visitSender = new MatomoVisitSender( $this->trackerBuilder, new ShortUrlStringifier(['hostname' => 's2.test']), + $this->visitIterationRepository, ); } @@ -64,7 +69,7 @@ class MatomoVisitSenderTest extends TestCase $this->trackerBuilder->expects($this->once())->method('buildMatomoTracker')->willReturn($tracker); - $this->visitSender->sendVisitToMatomo($visit, $originalIpAddress); + $this->visitSender->sendVisit($visit, $originalIpAddress); } public static function provideTrackerMethods(): iterable @@ -102,7 +107,7 @@ class MatomoVisitSenderTest extends TestCase $this->trackerBuilder->expects($this->once())->method('buildMatomoTracker')->willReturn($tracker); - $this->visitSender->sendVisitToMatomo($visit); + $this->visitSender->sendVisit($visit); } public static function provideUrlsToTrack(): iterable