mirror of
https://github.com/shlinkio/shlink.git
synced 2025-03-14 04:00:57 +03:00
Create command to send visits to matomo
This commit is contained in:
parent
4fdbcc25a0
commit
6121efec59
11 changed files with 260 additions and 10 deletions
|
@ -42,6 +42,8 @@ return [
|
|||
|
||||
Command\RedirectRule\ManageRedirectRulesCommand::NAME =>
|
||||
Command\RedirectRule\ManageRedirectRulesCommand::class,
|
||||
|
||||
Command\Integration\MatomoSendVisitsCommand::NAME => Command\Integration\MatomoSendVisitsCommand::class,
|
||||
],
|
||||
],
|
||||
|
||||
|
|
|
@ -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,
|
||||
|
|
144
module/CLI/src/Command/Integration/MatomoSendVisitsCommand.php
Normal file
144
module/CLI/src/Command/Integration/MatomoSendVisitsCommand.php
Normal 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;
|
||||
}
|
||||
}
|
|
@ -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],
|
||||
|
|
|
@ -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]);
|
||||
|
|
|
@ -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();
|
||||
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
|
|
33
module/Core/src/Matomo/Model/SendVisitsResult.php
Normal file
33
module/Core/src/Matomo/Model/SendVisitsResult.php
Normal 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;
|
||||
}
|
||||
}
|
|
@ -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;
|
||||
}
|
|
@ -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}',
|
||||
|
|
|
@ -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
|
||||
|
|
Loading…
Add table
Reference in a new issue