Create command to send visits to matomo

This commit is contained in:
Alejandro Celaya 2024-04-13 09:55:40 +02:00
parent 4fdbcc25a0
commit 6121efec59
11 changed files with 260 additions and 10 deletions

View file

@ -42,6 +42,8 @@ return [
Command\RedirectRule\ManageRedirectRulesCommand::NAME =>
Command\RedirectRule\ManageRedirectRulesCommand::class,
Command\Integration\MatomoSendVisitsCommand::NAME => Command\Integration\MatomoSendVisitsCommand::class,
],
],

View file

@ -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,

View file

@ -0,0 +1,144 @@
<?php
declare(strict_types=1);
namespace Shlinkio\Shlink\CLI\Command\Integration;
use Cake\Chronos\Chronos;
use Shlinkio\Shlink\CLI\Util\ExitCode;
use Shlinkio\Shlink\Core\Matomo\MatomoOptions;
use Shlinkio\Shlink\Core\Matomo\MatomoVisitSenderInterface;
use Shlinkio\Shlink\Core\Matomo\VisitSendingProgressTrackerInterface;
use Symfony\Component\Console\Application;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Input\InputOption;
use Symfony\Component\Console\Output\OutputInterface;
use Symfony\Component\Console\Style\SymfonyStyle;
use Throwable;
use function Shlinkio\Shlink\Common\buildDateRange;
use function sprintf;
class MatomoSendVisitsCommand extends Command
{
public const NAME = 'integration:matomo:send-visits';
private readonly bool $matomoEnabled;
public function __construct(MatomoOptions $matomoOptions, private readonly MatomoVisitSenderInterface $visitSender)
{
$this->matomoEnabled = $matomoOptions->enabled;
parent::__construct();
}
protected function configure(): void
{
$help = <<<HELP
This command allows you to send existing visits from this Shlink instance to the configured Matomo server.
Its intention is to allow you to configure Matomo at some point in time, and still have your whole visits
history tracked there.
This command will unconditionally send to Matomo all visits for a specific date range, so make sure you
provide the proper limits to avoid duplicated visits.
Send all visits created so far:
<info>%command.name%</info>
Send all visits created before 2024:
<info>%command.name% --until 2023-12-31</info>
Send all visits created after a specific day:
<info>%command.name% --since 2022-03-27</info>
Send all visits created during 2022:
<info>%command.name% --since 2022-01-01 --until 2022-12-31</info>
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('<error>E</error>');
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;
}
}

View file

@ -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],

View file

@ -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]);

View file

@ -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();

View file

@ -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;
}

View file

@ -0,0 +1,33 @@
<?php
declare(strict_types=1);
namespace Shlinkio\Shlink\Core\Matomo\Model;
use Countable;
final readonly class SendVisitsResult implements Countable
{
/**
* @param int<0, max> $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;
}
}

View file

@ -0,0 +1,14 @@
<?php
declare(strict_types=1);
namespace Shlinkio\Shlink\Core\Matomo;
use Throwable;
interface VisitSendingProgressTrackerInterface
{
public function success(int $index): void;
public function error(int $index, Throwable $e): void;
}

View file

@ -35,7 +35,7 @@ class SendVisitToMatomoTest extends TestCase
public function visitIsNotSentWhenMatomoIsDisabled(): void
{
$this->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}',

View file

@ -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