Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Create console command to send visits to matomo #2098

Merged
merged 11 commits into from
Apr 13, 2024
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,8 @@ The format is based on [Keep a Changelog](https://keepachangelog.com), and this

This command can be run periodically by those who create many disposable URLs which are valid only for a period of time, and then can be deleted to save space.

* [#1925](https://github.com/shlinkio/shlink/issues/1925) Add new `integration:matomo:send-visits` console command that can be used to send existing visits to integrated Matomo instance.

### Changed
* [#2034](https://github.com/shlinkio/shlink/issues/2034) Modernize entities, using constructor property promotion and readonly wherever possible.
* [#2036](https://github.com/shlinkio/shlink/issues/2036) Deep performance improvement in some endpoints which involve counting visits:
Expand Down
2 changes: 1 addition & 1 deletion composer.json
Original file line number Diff line number Diff line change
Expand Up @@ -118,7 +118,7 @@
"@parallel test:unit test:db",
"@parallel test:api test:cli"
],
"test:unit": "@php vendor/bin/phpunit --order-by=random --colors=always --testdox",
"test:unit": "COLUMNS=120 vendor/bin/phpunit --order-by=random --colors=always --testdox",
"test:unit:ci": "@test:unit --coverage-php=build/coverage-unit.cov",
"test:unit:pretty": "@test:unit --coverage-html build/coverage-unit/coverage-html",
"test:db": "@parallel test:db:sqlite:ci test:db:mysql test:db:maria test:db:postgres test:db:ms",
Expand Down
2 changes: 2 additions & 0 deletions module/CLI/config/cli.config.php
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,8 @@

Command\RedirectRule\ManageRedirectRulesCommand::NAME =>
Command\RedirectRule\ManageRedirectRulesCommand::class,

Command\Integration\MatomoSendVisitsCommand::NAME => Command\Integration\MatomoSendVisitsCommand::class,
],
],

Expand Down
8 changes: 8 additions & 0 deletions module/CLI/config/dependencies.config.php
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
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;
Expand Down Expand Up @@ -71,6 +72,8 @@
Command\Domain\GetDomainVisitsCommand::class => ConfigAbstractFactory::class,

Command\RedirectRule\ManageRedirectRulesCommand::class => ConfigAbstractFactory::class,

Command\Integration\MatomoSendVisitsCommand::class => ConfigAbstractFactory::class,
],
],

Expand Down Expand Up @@ -129,6 +132,11 @@
RedirectRule\RedirectRuleHandler::class,
],

Command\Integration\MatomoSendVisitsCommand::class => [
Matomo\MatomoOptions::class,
Matomo\MatomoVisitSender::class,
],

Command\Db\CreateDatabaseCommand::class => [
LockFactory::class,
Util\ProcessRunner::class,
Expand Down
140 changes: 140 additions & 0 deletions module/CLI/src/Command/Integration/MatomoSendVisitsCommand.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,140 @@
<?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\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 Shlinkio\Shlink\Core\dateRangeToHumanFriendly;
use function sprintf;

class MatomoSendVisitsCommand extends Command implements VisitSendingProgressTrackerInterface
{
public const NAME = 'integration:matomo:send-visits';

private readonly bool $matomoEnabled;
private SymfonyStyle $io;

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
{
$this->io = new SymfonyStyle($input, $output);

if (! $this->matomoEnabled) {
$this->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()) {
$this->io->warning([
'You are about to send visits from this Shlink instance to Matomo',
'Resolved date range -> ' . dateRangeToHumanFriendly($dateRange),
'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 (! $this->io->confirm('Continue?', default: false)) {
return ExitCode::EXIT_WARNING;
}
}

$result = $this->visitSender->sendVisitsInDateRange($dateRange, $this);

match (true) {
$result->hasFailures() && $result->hasSuccesses() => $this->io->warning(
sprintf('%s visits sent to Matomo. %s failed.', $result->successfulVisits, $result->failedVisits),
),
$result->hasFailures() => $this->io->error(
sprintf('Failed to send %s visits to Matomo.', $result->failedVisits),
),
$result->hasSuccesses() => $this->io->success(
sprintf('%s visits sent to Matomo.', $result->successfulVisits),
),
default => $this->io->info('There was no visits matching provided date range.'),
};

return ExitCode::EXIT_SUCCESS;
}

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->getApplication()?->renderThrowable($e, $this->io);

Check warning on line 137 in module/CLI/src/Command/Integration/MatomoSendVisitsCommand.php

View check run for this annotation

Codecov / codecov/patch

module/CLI/src/Command/Integration/MatomoSendVisitsCommand.php#L137

Added line #L137 was not covered by tests
}
}
}
2 changes: 1 addition & 1 deletion module/CLI/src/Command/Visit/AbstractVisitsListCommand.php
Original file line number Diff line number Diff line change
Expand Up @@ -55,7 +55,7 @@ private function resolveRowsAndHeaders(Paginator $paginator): array

$rowData = [
'referer' => $visit->referer,
'date' => $visit->getDate()->toAtomString(),
'date' => $visit->date->toAtomString(),
'userAgent' => $visit->userAgent,
'potentialBot' => $visit->potentialBot,
'country' => $visit->getVisitLocation()?->countryName ?? 'Unknown',
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -60,7 +60,7 @@ public function outputIsProperlyGenerated(): void
+---------+---------------------------+------------+---------+--------+---------------+
| Referer | Date | User agent | Country | City | Short Url |
+---------+---------------------------+------------+---------+--------+---------------+
| foo | {$visit->getDate()->toAtomString()} | bar | Spain | Madrid | the_short_url |
| foo | {$visit->date->toAtomString()} | bar | Spain | Madrid | the_short_url |
+---------+---------------------------+------------+---------+--------+---------------+

OUTPUT,
Expand Down
135 changes: 135 additions & 0 deletions module/CLI/test/Command/Integration/MatomoSendVisitsCommandTest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,135 @@
<?php

namespace ShlinkioTest\Shlink\CLI\Command\Integration;

use Exception;
use PHPUnit\Framework\Attributes\Test;
use PHPUnit\Framework\Attributes\TestWith;
use PHPUnit\Framework\MockObject\MockObject;
use PHPUnit\Framework\TestCase;
use Shlinkio\Shlink\CLI\Command\Integration\MatomoSendVisitsCommand;
use Shlinkio\Shlink\CLI\Util\ExitCode;
use Shlinkio\Shlink\Common\Util\DateRange;
use Shlinkio\Shlink\Core\Matomo\MatomoOptions;
use Shlinkio\Shlink\Core\Matomo\MatomoVisitSenderInterface;
use Shlinkio\Shlink\Core\Matomo\Model\SendVisitsResult;
use ShlinkioTest\Shlink\CLI\Util\CliTestUtils;

class MatomoSendVisitsCommandTest extends TestCase
{
private MockObject & MatomoVisitSenderInterface $visitSender;

protected function setUp(): void
{
$this->visitSender = $this->createMock(MatomoVisitSenderInterface::class);
}

#[Test]
public function warningDisplayedIfIntegrationIsNotEnabled(): void
{
[$output, $exitCode] = $this->executeCommand(matomoEnabled: false);

self::assertStringContainsString('Matomo integration is not enabled in this Shlink instance', $output);
self::assertEquals(ExitCode::EXIT_WARNING, $exitCode);
}

#[Test]
#[TestWith([true])]
#[TestWith([false])]
public function warningIsOnlyDisplayedInInteractiveMode(bool $interactive): void
{
$this->visitSender->method('sendVisitsInDateRange')->willReturn(new SendVisitsResult());

[$output] = $this->executeCommand(['y'], ['interactive' => $interactive]);

if ($interactive) {
self::assertStringContainsString('You are about to send visits', $output);
} else {
self::assertStringNotContainsString('You are about to send visits', $output);
}
}

#[Test]
#[TestWith([true])]
#[TestWith([false])]
public function canCancelExecutionInInteractiveMode(bool $interactive): void
{
$this->visitSender->expects($this->exactly($interactive ? 0 : 1))->method('sendVisitsInDateRange')->willReturn(
new SendVisitsResult(),
);
$this->executeCommand(['n'], ['interactive' => $interactive]);
}

#[Test]
#[TestWith([new SendVisitsResult(), 'There was no visits matching provided date range'])]
#[TestWith([new SendVisitsResult(successfulVisits: 10), '10 visits sent to Matomo.'])]
#[TestWith([new SendVisitsResult(successfulVisits: 2), '2 visits sent to Matomo.'])]
#[TestWith([new SendVisitsResult(failedVisits: 238), 'Failed to send 238 visits to Matomo.'])]
#[TestWith([new SendVisitsResult(failedVisits: 18), 'Failed to send 18 visits to Matomo.'])]
#[TestWith([new SendVisitsResult(successfulVisits: 2, failedVisits: 35), '2 visits sent to Matomo. 35 failed.'])]
#[TestWith([new SendVisitsResult(successfulVisits: 81, failedVisits: 6), '81 visits sent to Matomo. 6 failed.'])]
public function expectedResultIsDisplayed(SendVisitsResult $result, string $expectedResultMessage): void
{
$this->visitSender->expects($this->once())->method('sendVisitsInDateRange')->willReturn($result);
[$output, $exitCode] = $this->executeCommand(['y']);

self::assertStringContainsString($expectedResultMessage, $output);
self::assertEquals(ExitCode::EXIT_SUCCESS, $exitCode);
}

#[Test]
public function printsResultOfSendingVisits(): void
{
$this->visitSender->method('sendVisitsInDateRange')->willReturnCallback(
function (DateRange $_, MatomoSendVisitsCommand $command): SendVisitsResult {
// Call it a few times for an easier match of its result in the command putput
$command->success(0);
$command->success(1);
$command->success(2);
$command->error(3, new Exception('Error'));
$command->success(4);
$command->error(5, new Exception('Error'));

return new SendVisitsResult();
},
);

[$output] = $this->executeCommand(['y']);

self::assertStringContainsString('...E.E', $output);
}

#[Test]
#[TestWith([[], 'All time'])]
#[TestWith([['--since' => '2023-05-01'], 'Since 2023-05-01 00:00:00'])]
#[TestWith([['--until' => '2023-05-01'], 'Until 2023-05-01 00:00:00'])]
#[TestWith([
['--since' => '2023-05-01', '--until' => '2024-02-02 23:59:59'],
'Between 2023-05-01 00:00:00 and 2024-02-02 23:59:59',
])]
public function providedDateAreParsed(array $args, string $expectedMessage): void
{
[$output] = $this->executeCommand(['n'], args: $args);
self::assertStringContainsString('Resolved date range -> ' . $expectedMessage, $output);
}

/**
* @return array{string, int, MatomoSendVisitsCommand}
*/
private function executeCommand(
array $input = [],
array $options = [],
array $args = [],
bool $matomoEnabled = true,
): array {
$command = new MatomoSendVisitsCommand(new MatomoOptions(enabled: $matomoEnabled), $this->visitSender);
$commandTester = CliTestUtils::testerForCommand($command);
$commandTester->setInputs($input);
$commandTester->execute($args, $options);

$output = $commandTester->getDisplay();
$exitCode = $commandTester->getStatusCode();

return [$output, $exitCode, $command];
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -110,7 +110,7 @@ public function outputIsProperlyGenerated(): void
+---------+---------------------------+------------+---------+--------+
| Referer | Date | User agent | Country | City |
+---------+---------------------------+------------+---------+--------+
| foo | {$visit->getDate()->toAtomString()} | bar | Spain | Madrid |
| foo | {$visit->date->toAtomString()} | bar | Spain | Madrid |
+---------+---------------------------+------------+---------+--------+

OUTPUT,
Expand Down
2 changes: 1 addition & 1 deletion module/CLI/test/Command/Tag/GetTagVisitsCommandTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -57,7 +57,7 @@ public function outputIsProperlyGenerated(): void
+---------+---------------------------+------------+---------+--------+---------------+
| Referer | Date | User agent | Country | City | Short Url |
+---------+---------------------------+------------+---------+--------+---------------+
| foo | {$visit->getDate()->toAtomString()} | bar | Spain | Madrid | the_short_url |
| foo | {$visit->date->toAtomString()} | bar | Spain | Madrid | the_short_url |
+---------+---------------------------+------------+---------+--------+---------------+

OUTPUT,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -56,7 +56,7 @@ public function outputIsProperlyGenerated(): void
+---------+---------------------------+------------+---------+--------+---------------+
| Referer | Date | User agent | Country | City | Short Url |
+---------+---------------------------+------------+---------+--------+---------------+
| foo | {$visit->getDate()->toAtomString()} | bar | Spain | Madrid | the_short_url |
| foo | {$visit->date->toAtomString()} | bar | Spain | Madrid | the_short_url |
+---------+---------------------------+------------+---------+--------+---------------+

OUTPUT,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -54,7 +54,7 @@ public function outputIsProperlyGenerated(array $args, bool $includesType): void
+---------+---------------------------+------------+---------+--------+----------+
| Referer | Date | User agent | Country | City | Type |
+---------+---------------------------+------------+---------+--------+----------+
| foo | {$visit->getDate()->toAtomString()} | bar | Spain | Madrid | base_url |
| foo | {$visit->date->toAtomString()} | bar | Spain | Madrid | base_url |
+---------+---------------------------+------------+---------+--------+----------+

OUTPUT,
Expand Down
Loading
Loading