Merge pull request #1920 from acelaya-forks/feature/matomo-integration

Feature/matomo integration
This commit is contained in:
Alejandro Celaya 2023-11-22 18:59:45 +01:00 committed by GitHub
commit cb0bac55d2
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
26 changed files with 743 additions and 185 deletions

1
.gitignore vendored
View file

@ -9,6 +9,7 @@ vendor/
data/database.sqlite
data/shlink-tests.db
data/GeoLite2-City.*
data/infra/matomo
docs/swagger-ui*
docs/mercure.html
docker-compose.override.yml

View file

@ -6,6 +6,8 @@ The format is based on [Keep a Changelog](https://keepachangelog.com), and this
## [Unreleased]
### Added
* [#1798](https://github.com/shlinkio/shlink/issues/1798) Experimental support to send visits to an external Matomo instance.
* [#1780](https://github.com/shlinkio/shlink/issues/1780) Add new `NO_ORPHAN_VISITS` API key role.
Keys with this role will always get `0` when fetching orphan visits.

View file

@ -35,6 +35,7 @@
"laminas/laminas-stdlib": "^3.17",
"league/uri": "^6.8",
"lstrojny/functional-php": "^1.17",
"matomo/matomo-php-tracker": "^3.2",
"mezzio/mezzio": "^3.17",
"mezzio/mezzio-fastroute": "^3.10",
"mezzio/mezzio-problem-details": "^1.13",
@ -47,9 +48,9 @@
"ramsey/uuid": "^4.7",
"shlinkio/shlink-common": "dev-main#7d46772 as 5.7",
"shlinkio/shlink-config": "dev-main#cde5d3b as 2.5",
"shlinkio/shlink-event-dispatcher": "dev-main#faf2582 as 3.1",
"shlinkio/shlink-event-dispatcher": "dev-main#35ccc0b as 3.1",
"shlinkio/shlink-importer": "dev-main#d621b20 as 5.2",
"shlinkio/shlink-installer": "dev-develop#c1ef08c as 8.6",
"shlinkio/shlink-installer": "dev-develop#c505a19 as 8.6",
"shlinkio/shlink-ip-geolocation": "dev-main#4a1cef8 as 3.3",
"shlinkio/shlink-json": "dev-main#e5a111c as 1.1",
"spiral/roadrunner": "^2023.2",

View file

@ -66,6 +66,10 @@ return [
Option\RabbitMq\RabbitMqUserConfigOption::class,
Option\RabbitMq\RabbitMqPasswordConfigOption::class,
Option\RabbitMq\RabbitMqVhostConfigOption::class,
Option\Matomo\MatomoEnabledConfigOption::class,
Option\Matomo\MatomoBaseUrlConfigOption::class,
Option\Matomo\MatomoSiteIdConfigOption::class,
Option\Matomo\MatomoApiTokenConfigOption::class,
],
'installation_commands' => [

View file

@ -0,0 +1,16 @@
<?php
declare(strict_types=1);
use Shlinkio\Shlink\Core\Config\EnvVars;
return [
'matomo' => [
'enabled' => (bool) EnvVars::MATOMO_ENABLED->loadFromEnv(false),
'base_url' => EnvVars::MATOMO_BASE_URL->loadFromEnv(),
'site_id' => EnvVars::MATOMO_SITE_ID->loadFromEnv(),
'api_token' => EnvVars::MATOMO_API_TOKEN->loadFromEnv(),
],
];

View file

@ -0,0 +1,26 @@
<?php
declare(strict_types=1);
/*
* Dev matomo instance needs to be manually configured once before enabling the configuration below.
*
* 1. Go to http://localhost:8003 and follow the installation instructions.
* 2. Open data/infra/matomo/config/config.ini.php and replace `trusted_hosts[] = "localhost"` with
* `trusted_hosts[] = "localhost:8003"` (see https://github.com/matomo-org/matomo/issues/9549)
* 3. Go to http://localhost:8003/index.php?module=SitesManager&action=index and paste the ID for the site you just
* created into the `site_id` field below.
* 4. Go to http://localhost:8003/index.php?module=UsersManager&action=userSecurity, scroll down, click
* "Create new token" and once generated, paste the token into the `api_token` field below.
*/
return [
'matomo' => [
// 'enabled' => true,
// 'base_url' => 'http://shlink_matomo',
// 'site_id' => '...',
// 'api_token' => '...',
],
];

View file

@ -22,33 +22,39 @@ use const PHP_SAPI;
$isTestEnv = env('APP_ENV') === 'test';
$enableSwoole = PHP_SAPI === 'cli' && openswooleIsInstalled() && ! runningInRoadRunner();
return (new ConfigAggregator\ConfigAggregator([
! $isTestEnv
? new EnvVarLoaderProvider('config/params/generated_config.php', enumValues(Core\Config\EnvVars::class))
: new ConfigAggregator\ArrayProvider([]),
Mezzio\ConfigProvider::class,
Mezzio\Router\ConfigProvider::class,
Mezzio\Router\FastRouteRouter\ConfigProvider::class,
$enableSwoole && class_exists(Swoole\ConfigProvider::class)
? Swoole\ConfigProvider::class
: new ConfigAggregator\ArrayProvider([]),
ProblemDetails\ConfigProvider::class,
Diactoros\ConfigProvider::class,
Common\ConfigProvider::class,
Config\ConfigProvider::class,
Importer\ConfigProvider::class,
IpGeolocation\ConfigProvider::class,
EventDispatcher\ConfigProvider::class,
Core\ConfigProvider::class,
CLI\ConfigProvider::class,
Rest\ConfigProvider::class,
new ConfigAggregator\PhpFileProvider('config/autoload/{,*.}global.php'),
// Local config should not be loaded during tests, whereas test config should be loaded ONLY during tests
new ConfigAggregator\PhpFileProvider($isTestEnv ? 'config/test/*.global.php' : 'config/autoload/{,*.}local.php'),
// Routes have to be loaded last
new ConfigAggregator\PhpFileProvider('config/autoload/routes.config.php'),
], 'data/cache/app_config.php', [
Core\Config\PostProcessor\BasePathPrefixer::class,
Core\Config\PostProcessor\MultiSegmentSlugProcessor::class,
Core\Config\PostProcessor\ShortUrlMethodsProcessor::class,
]))->getMergedConfig();
return (new ConfigAggregator\ConfigAggregator(
providers: [
! $isTestEnv
? new EnvVarLoaderProvider('config/params/generated_config.php', enumValues(Core\Config\EnvVars::class))
: new ConfigAggregator\ArrayProvider([]),
Mezzio\ConfigProvider::class,
Mezzio\Router\ConfigProvider::class,
Mezzio\Router\FastRouteRouter\ConfigProvider::class,
$enableSwoole && class_exists(Swoole\ConfigProvider::class)
? Swoole\ConfigProvider::class
: new ConfigAggregator\ArrayProvider([]),
ProblemDetails\ConfigProvider::class,
Diactoros\ConfigProvider::class,
Common\ConfigProvider::class,
Config\ConfigProvider::class,
Importer\ConfigProvider::class,
IpGeolocation\ConfigProvider::class,
EventDispatcher\ConfigProvider::class,
Core\ConfigProvider::class,
CLI\ConfigProvider::class,
Rest\ConfigProvider::class,
new ConfigAggregator\PhpFileProvider('config/autoload/{,*.}global.php'),
// Local config should not be loaded during tests, whereas test config should be loaded ONLY during tests
new ConfigAggregator\PhpFileProvider(
$isTestEnv ? 'config/test/*.global.php' : 'config/autoload/{,*.}local.php',
),
// Routes have to be loaded last
new ConfigAggregator\PhpFileProvider('config/autoload/routes.config.php'),
],
cachedConfigFile: 'data/cache/app_config.php',
postProcessors: [
Core\Config\PostProcessor\BasePathPrefixer::class,
Core\Config\PostProcessor\MultiSegmentSlugProcessor::class,
Core\Config\PostProcessor\ShortUrlMethodsProcessor::class,
],
))->getMergedConfig();

View file

@ -33,6 +33,7 @@ services:
- shlink_mercure
- shlink_mercure_proxy
- shlink_rabbitmq
- shlink_matomo
environment:
LC_ALL: C
extra_hosts:
@ -70,6 +71,7 @@ services:
- shlink_mercure
- shlink_mercure_proxy
- shlink_rabbitmq
- shlink_matomo
environment:
LC_ALL: C
extra_hosts:
@ -95,6 +97,7 @@ services:
- shlink_mercure
- shlink_mercure_proxy
- shlink_rabbitmq
- shlink_matomo
environment:
LC_ALL: C
extra_hosts:
@ -201,3 +204,21 @@ services:
- "8005:8080"
volumes:
- ./docs/swagger:/app
shlink_matomo:
container_name: shlink_matomo
image: matomo:4.15-apache
ports:
- "8003:80"
volumes:
# Matomo does not persist port in trusted hosts. This volume is needed to edit config afterward
# https://github.com/matomo-org/matomo/issues/9549
- ./data/infra/matomo:/var/www/html
links:
- shlink_db_mysql
environment:
MATOMO_DATABASE_HOST: "shlink_db_mysql"
MATOMO_DATABASE_ADAPTER: "mysql"
MATOMO_DATABASE_DBNAME: "matomo"
MATOMO_DATABASE_USERNAME: "root"
MATOMO_DATABASE_PASSWORD: "root"

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,8 @@ 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\Matomo\MatomoOptions;
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 +20,178 @@ 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,
MatomoOptions::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

@ -24,11 +24,6 @@ enum EnvVars: string
case MERCURE_PUBLIC_HUB_URL = 'MERCURE_PUBLIC_HUB_URL';
case MERCURE_INTERNAL_HUB_URL = 'MERCURE_INTERNAL_HUB_URL';
case MERCURE_JWT_SECRET = 'MERCURE_JWT_SECRET';
case DEFAULT_QR_CODE_SIZE = 'DEFAULT_QR_CODE_SIZE';
case DEFAULT_QR_CODE_MARGIN = 'DEFAULT_QR_CODE_MARGIN';
case DEFAULT_QR_CODE_FORMAT = 'DEFAULT_QR_CODE_FORMAT';
case DEFAULT_QR_CODE_ERROR_CORRECTION = 'DEFAULT_QR_CODE_ERROR_CORRECTION';
case DEFAULT_QR_CODE_ROUND_BLOCK_SIZE = 'DEFAULT_QR_CODE_ROUND_BLOCK_SIZE';
case RABBITMQ_ENABLED = 'RABBITMQ_ENABLED';
case RABBITMQ_HOST = 'RABBITMQ_HOST';
case RABBITMQ_PORT = 'RABBITMQ_PORT';
@ -37,6 +32,15 @@ enum EnvVars: string
case RABBITMQ_VHOST = 'RABBITMQ_VHOST';
/** @deprecated */
case RABBITMQ_LEGACY_VISITS_PUBLISHING = 'RABBITMQ_LEGACY_VISITS_PUBLISHING';
case MATOMO_ENABLED = 'MATOMO_ENABLED';
case MATOMO_BASE_URL = 'MATOMO_BASE_URL';
case MATOMO_SITE_ID = 'MATOMO_SITE_ID';
case MATOMO_API_TOKEN = 'MATOMO_API_TOKEN';
case DEFAULT_QR_CODE_SIZE = 'DEFAULT_QR_CODE_SIZE';
case DEFAULT_QR_CODE_MARGIN = 'DEFAULT_QR_CODE_MARGIN';
case DEFAULT_QR_CODE_FORMAT = 'DEFAULT_QR_CODE_FORMAT';
case DEFAULT_QR_CODE_ERROR_CORRECTION = 'DEFAULT_QR_CODE_ERROR_CORRECTION';
case DEFAULT_QR_CODE_ROUND_BLOCK_SIZE = 'DEFAULT_QR_CODE_ROUND_BLOCK_SIZE';
case DEFAULT_INVALID_SHORT_URL_REDIRECT = 'DEFAULT_INVALID_SHORT_URL_REDIRECT';
case DEFAULT_REGULAR_404_REDIRECT = 'DEFAULT_REGULAR_404_REDIRECT';
case DEFAULT_BASE_URL_REDIRECT = 'DEFAULT_BASE_URL_REDIRECT';

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

@ -6,6 +6,7 @@ namespace Shlinkio\Shlink\Core\EventDispatcher\Helper;
use Shlinkio\Shlink\Common\Mercure\MercureOptions;
use Shlinkio\Shlink\Core\EventDispatcher;
use Shlinkio\Shlink\Core\Matomo\MatomoOptions;
use Shlinkio\Shlink\Core\Options\RabbitMqOptions;
use Shlinkio\Shlink\Core\Options\WebhookOptions;
use Shlinkio\Shlink\EventDispatcher\Listener\EnabledListenerCheckerInterface;
@ -19,6 +20,7 @@ class EnabledListenerChecker implements EnabledListenerCheckerInterface
private readonly MercureOptions $mercureOptions,
private readonly WebhookOptions $webhookOptions,
private readonly GeoLite2Options $geoLiteOptions,
private readonly MatomoOptions $matomoOptions,
) {
}
@ -35,6 +37,7 @@ class EnabledListenerChecker implements EnabledListenerCheckerInterface
EventDispatcher\RedisPubSub\NotifyNewShortUrlToRedis::class => $this->redisPubSubEnabled,
EventDispatcher\Mercure\NotifyVisitToMercure::class,
EventDispatcher\Mercure\NotifyNewShortUrlToMercure::class => $this->mercureOptions->isEnabled(),
EventDispatcher\Matomo\SendVisitToMatomo::class => $this->matomoOptions->enabled,
EventDispatcher\NotifyVisitToWebHooks::class => $this->webhookOptions->hasWebhooks(),
EventDispatcher\UpdateGeoLiteDb::class => $this->geoLiteOptions->hasLicenseKey(),
default => false, // Any unknown async listener should not be enabled by default

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,89 @@
<?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())
->setUrlReferrer($visit->referer());
$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 = false,
public readonly ?string $baseUrl = null,
/** @var numeric-string|int|null */
private readonly string|int|null $siteId = null,
public readonly ?string $apiToken = null,
) {
}
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,48 @@
<?php
declare(strict_types=1);
namespace Shlinkio\Shlink\Core\Matomo;
use MatomoTracker;
use Shlinkio\Shlink\Core\Exception\RuntimeException;
class MatomoTrackerBuilder implements MatomoTrackerBuilderInterface
{
public const MATOMO_DEFAULT_TIMEOUT = 10; // Time in seconds
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);
$tracker
// Token required to set the IP and location
->setTokenAuth($this->options->apiToken)
// Ensure params are not sent in the URL, for security reasons
->setRequestMethodNonBulk('POST')
// Set a reasonable timeout
->setRequestTimeout(self::MATOMO_DEFAULT_TIMEOUT)
->setRequestConnectTimeout(self::MATOMO_DEFAULT_TIMEOUT);
// We don't want to bulk send, as every request to Shlink will create a new tracker
$tracker->disableBulkTracking();
// Disable cookies, as they are ignored anyway
$tracker->disableCookieSupport();
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

@ -11,6 +11,9 @@ use function sprintf;
class ShortUrlStringifier implements ShortUrlStringifierInterface
{
/**
* @param array{schema?: string, hostname?: string} $domainConfig
*/
public function __construct(private readonly array $domainConfig, private readonly string $basePath = '')
{
}

View file

@ -188,6 +188,16 @@ class Visit extends AbstractEntity implements JsonSerializable
return $this->date;
}
public function userAgent(): string
{
return $this->userAgent;
}
public function referer(): string
{
return $this->referer;
}
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

@ -9,6 +9,7 @@ use PHPUnit\Framework\Attributes\Test;
use PHPUnit\Framework\TestCase;
use Shlinkio\Shlink\Common\Mercure\MercureOptions;
use Shlinkio\Shlink\Core\EventDispatcher\Helper\EnabledListenerChecker;
use Shlinkio\Shlink\Core\EventDispatcher\Matomo\SendVisitToMatomo;
use Shlinkio\Shlink\Core\EventDispatcher\Mercure\NotifyNewShortUrlToMercure;
use Shlinkio\Shlink\Core\EventDispatcher\Mercure\NotifyVisitToMercure;
use Shlinkio\Shlink\Core\EventDispatcher\NotifyVisitToWebHooks;
@ -17,6 +18,7 @@ use Shlinkio\Shlink\Core\EventDispatcher\RabbitMq\NotifyVisitToRabbitMq;
use Shlinkio\Shlink\Core\EventDispatcher\RedisPubSub\NotifyNewShortUrlToRedis;
use Shlinkio\Shlink\Core\EventDispatcher\RedisPubSub\NotifyVisitToRedis;
use Shlinkio\Shlink\Core\EventDispatcher\UpdateGeoLiteDb;
use Shlinkio\Shlink\Core\Matomo\MatomoOptions;
use Shlinkio\Shlink\Core\Options\RabbitMqOptions;
use Shlinkio\Shlink\Core\Options\WebhookOptions;
use Shlinkio\Shlink\IpGeolocation\GeoLite2\GeoLite2Options;
@ -26,7 +28,7 @@ class EnabledListenerCheckerTest extends TestCase
#[Test, DataProvider('provideListeners')]
public function syncListenersAreRegisteredByDefault(string $listener): void
{
self::assertTrue($this->checker()->shouldRegisterListener('', $listener, false));
self::assertTrue($this->checker()->shouldRegisterListener(event: '', listener: $listener, isAsync: false));
}
public static function provideListeners(): iterable
@ -38,6 +40,7 @@ class EnabledListenerCheckerTest extends TestCase
[NotifyNewShortUrlToRedis::class],
[NotifyVisitToMercure::class],
[NotifyNewShortUrlToMercure::class],
[SendVisitToMatomo::class],
[NotifyVisitToWebHooks::class],
[UpdateGeoLiteDb::class],
];
@ -113,6 +116,18 @@ class EnabledListenerCheckerTest extends TestCase
UpdateGeoLiteDb::class => true,
'unknown' => false,
]];
yield 'Matomo' => [self::checker(matomoEnabled: true), [
NotifyVisitToRabbitMq::class => false,
NotifyNewShortUrlToRabbitMq::class => false,
NotifyVisitToRedis::class => false,
NotifyNewShortUrlToRedis::class => false,
NotifyVisitToMercure::class => false,
NotifyNewShortUrlToMercure::class => false,
SendVisitToMatomo::class => true,
NotifyVisitToWebHooks::class => false,
UpdateGeoLiteDb::class => false,
'unknown' => false,
]];
yield 'All disabled' => [self::checker(), [
NotifyVisitToRabbitMq::class => false,
NotifyNewShortUrlToRabbitMq::class => false,
@ -130,6 +145,7 @@ class EnabledListenerCheckerTest extends TestCase
mercureEnabled: true,
webhooksEnabled: true,
geoLiteEnabled: true,
matomoEnabled: true,
), [
NotifyVisitToRabbitMq::class => true,
NotifyNewShortUrlToRabbitMq::class => true,
@ -137,6 +153,7 @@ class EnabledListenerCheckerTest extends TestCase
NotifyNewShortUrlToRedis::class => true,
NotifyVisitToMercure::class => true,
NotifyNewShortUrlToMercure::class => true,
SendVisitToMatomo::class => true,
NotifyVisitToWebHooks::class => true,
UpdateGeoLiteDb::class => true,
'unknown' => false,
@ -149,6 +166,7 @@ class EnabledListenerCheckerTest extends TestCase
bool $mercureEnabled = false,
bool $webhooksEnabled = false,
bool $geoLiteEnabled = false,
bool $matomoEnabled = false,
): EnabledListenerChecker {
return new EnabledListenerChecker(
new RabbitMqOptions(enabled: $rabbitMqEnabled),
@ -156,6 +174,7 @@ class EnabledListenerCheckerTest extends TestCase
new MercureOptions(publicHubUrl: $mercureEnabled ? 'the-url' : null),
new WebhookOptions(['webhooks' => $webhooksEnabled ? ['foo', 'bar'] : []]),
new GeoLite2Options(licenseKey: $geoLiteEnabled ? 'the-key' : null),
new MatomoOptions(enabled: $matomoEnabled),
);
}
}

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');
@ -168,7 +168,9 @@ class LocateVisitTest extends TestCase
$location,
);
$this->eventDispatcher->expects($this->once())->method('dispatch')->with(new VisitLocated('123'));
$this->eventDispatcher->expects($this->once())->method('dispatch')->with(
new VisitLocated('123', $originalIpAddress),
);
$this->logger->expects($this->never())->method('warning');
($this->locateVisit)($event);

View file

@ -0,0 +1,188 @@
<?php
declare(strict_types=1);
namespace ShlinkioTest\Shlink\Core\EventDispatcher\Matomo;
use Doctrine\ORM\EntityManagerInterface;
use Exception;
use MatomoTracker;
use PHPUnit\Framework\Attributes\DataProvider;
use PHPUnit\Framework\Attributes\Test;
use PHPUnit\Framework\MockObject\MockObject;
use PHPUnit\Framework\TestCase;
use Psr\Log\LoggerInterface;
use Shlinkio\Shlink\Core\EventDispatcher\Event\VisitLocated;
use Shlinkio\Shlink\Core\EventDispatcher\Matomo\SendVisitToMatomo;
use Shlinkio\Shlink\Core\Matomo\MatomoOptions;
use Shlinkio\Shlink\Core\Matomo\MatomoTrackerBuilderInterface;
use Shlinkio\Shlink\Core\ShortUrl\Entity\ShortUrl;
use Shlinkio\Shlink\Core\ShortUrl\Helper\ShortUrlStringifier;
use Shlinkio\Shlink\Core\ShortUrl\Model\ShortUrlCreation;
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\IpGeolocation\Model\Location;
class SendVisitToMatomoTest extends TestCase
{
private MockObject & EntityManagerInterface $em;
private MockObject & LoggerInterface $logger;
private MockObject & MatomoTrackerBuilderInterface $trackerBuilder;
protected function setUp(): void
{
$this->em = $this->createMock(EntityManagerInterface::class);
$this->logger = $this->createMock(LoggerInterface::class);
$this->trackerBuilder = $this->createMock(MatomoTrackerBuilderInterface::class);
}
#[Test]
public function visitIsNotSentWhenMatomoIsDisabled(): void
{
$this->em->expects($this->never())->method('find');
$this->trackerBuilder->expects($this->never())->method('buildMatomoTracker');
$this->logger->expects($this->never())->method('error');
$this->logger->expects($this->never())->method('warning');
($this->listener(enabled: false))(new VisitLocated('123'));
}
#[Test]
public function visitIsNotSentWhenItDoesNotExist(): void
{
$this->em->expects($this->once())->method('find')->willReturn(null);
$this->trackerBuilder->expects($this->never())->method('buildMatomoTracker');
$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.',
['visitId' => '123'],
);
($this->listener())(new VisitLocated('123'));
}
#[Test, DataProvider('provideTrackerMethods')]
public function visitIsSentWhenItExists(Visit $visit, ?string $originalIpAddress, array $invokedMethods): void
{
$visitId = '123';
$tracker = $this->createMock(MatomoTracker::class);
$tracker->expects($this->once())->method('setUrl')->willReturn($tracker);
$tracker->expects($this->once())->method('setUserAgent')->willReturn($tracker);
$tracker->expects($this->once())->method('setUrlReferrer')->willReturn($tracker);
$tracker->expects($this->once())->method('doTrackPageView')->with('');
if ($visit->isOrphan()) {
$tracker->expects($this->exactly(2))->method('setCustomTrackingParameter')->willReturnMap([
['type', $visit->type()->value, $tracker],
['orphan', 'true', $tracker],
]);
} else {
$tracker->expects($this->once())->method('setCustomTrackingParameter')->with(
'type',
$visit->type()->value,
)->willReturn($tracker);
}
foreach ($invokedMethods as $invokedMethod) {
$tracker->expects($this->once())->method($invokedMethod)->willReturn($tracker);
}
$this->em->expects($this->once())->method('find')->with(Visit::class, $visitId)->willReturn($visit);
$this->trackerBuilder->expects($this->once())->method('buildMatomoTracker')->willReturn($tracker);
$this->logger->expects($this->never())->method('error');
$this->logger->expects($this->never())->method('warning');
($this->listener())(new VisitLocated($visitId, $originalIpAddress));
}
public static function provideTrackerMethods(): iterable
{
yield 'unlocated orphan visit' => [Visit::forBasePath(Visitor::emptyInstance()), null, []];
yield 'located regular visit' => [
Visit::forValidShortUrl(ShortUrl::withLongUrl('https://shlink.io'), Visitor::emptyInstance())
->locate(VisitLocation::fromGeolocation(new Location(
countryCode: 'countryCode',
countryName: 'countryName',
regionName: 'regionName',
city: 'city',
latitude: 123,
longitude: 123,
timeZone: 'timeZone',
))),
'1.2.3.4',
['setCity', 'setCountry', 'setLatitude', 'setLongitude', 'setIp'],
];
yield 'fallback IP' => [Visit::forBasePath(new Visitor('', '', '1.2.3.4', '')), null, ['setIp']];
}
#[Test, DataProvider('provideUrlsToTrack')]
public function properUrlIsTracked(Visit $visit, string $expectedTrackedUrl): void
{
$visitId = '123';
$tracker = $this->createMock(MatomoTracker::class);
$tracker->expects($this->once())->method('setUrl')->with($expectedTrackedUrl)->willReturn($tracker);
$tracker->expects($this->once())->method('setUserAgent')->willReturn($tracker);
$tracker->expects($this->once())->method('setUrlReferrer')->willReturn($tracker);
$tracker->expects($this->any())->method('setCustomTrackingParameter')->willReturn($tracker);
$tracker->expects($this->once())->method('doTrackPageView');
$this->em->expects($this->once())->method('find')->with(Visit::class, $visitId)->willReturn($visit);
$this->trackerBuilder->expects($this->once())->method('buildMatomoTracker')->willReturn($tracker);
$this->logger->expects($this->never())->method('error');
$this->logger->expects($this->never())->method('warning');
($this->listener())(new VisitLocated($visitId));
}
public static function provideUrlsToTrack(): iterable
{
yield 'orphan visit without visited URL' => [Visit::forBasePath(Visitor::emptyInstance()), ''];
yield 'orphan visit with visited URL' => [
Visit::forBasePath(new Visitor('', '', null, 'https://s.test/foo')),
'https://s.test/foo',
];
yield 'non-orphan visit' => [
Visit::forValidShortUrl(ShortUrl::create(
ShortUrlCreation::fromRawData([
ShortUrlInputFilter::LONG_URL => 'https://shlink.io',
ShortUrlInputFilter::CUSTOM_SLUG => 'bar',
]),
), Visitor::emptyInstance()),
'http://s2.test/bar',
];
}
#[Test]
public function logsErrorWhenTrackingFails(): void
{
$visitId = '123';
$e = new Exception('Error!');
$this->em->expects($this->once())->method('find')->with(Visit::class, $visitId)->willReturn(
$this->createMock(Visit::class),
);
$this->trackerBuilder->expects($this->once())->method('buildMatomoTracker')->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}',
['e' => $e],
);
($this->listener())(new VisitLocated($visitId));
}
private function listener(bool $enabled = true): SendVisitToMatomo
{
return new SendVisitToMatomo(
$this->em,
$this->logger,
new ShortUrlStringifier(['hostname' => 's2.test']),
new MatomoOptions(enabled: $enabled),
$this->trackerBuilder,
);
}
}

View file

@ -0,0 +1,51 @@
<?php
namespace ShlinkioTest\Shlink\Core\Matomo;
use PHPUnit\Framework\Attributes\DataProvider;
use PHPUnit\Framework\Attributes\Test;
use PHPUnit\Framework\TestCase;
use Shlinkio\Shlink\Core\Exception\RuntimeException;
use Shlinkio\Shlink\Core\Matomo\MatomoOptions;
use Shlinkio\Shlink\Core\Matomo\MatomoTrackerBuilder;
class MatomoTrackerBuilderTest extends TestCase
{
#[Test, DataProvider('provideInvalidOptions')]
public function exceptionIsThrowsIfSomeParamIsMissing(MatomoOptions $options): void
{
$this->expectException(RuntimeException::class);
$this->expectExceptionMessage(
'Cannot create MatomoTracker. Either site ID, base URL or api token are not defined',
);
$this->builder($options)->buildMatomoTracker();
}
public static function provideInvalidOptions(): iterable
{
yield [new MatomoOptions()];
yield [new MatomoOptions(baseUrl: 'base_url')];
yield [new MatomoOptions(apiToken: 'api_token')];
yield [new MatomoOptions(siteId: 5)];
yield [new MatomoOptions(baseUrl: 'base_url', apiToken: 'api_token')];
yield [new MatomoOptions(baseUrl: 'base_url', siteId: 5)];
yield [new MatomoOptions(siteId: 5, apiToken: 'api_token')];
}
#[Test]
public function trackerIsCreated(): void
{
$tracker = $this->builder()->buildMatomoTracker();
self::assertEquals('api_token', $tracker->token_auth); // @phpstan-ignore-line
self::assertEquals(5, $tracker->idSite); // @phpstan-ignore-line
self::assertEquals(MatomoTrackerBuilder::MATOMO_DEFAULT_TIMEOUT, $tracker->getRequestTimeout());
self::assertEquals(MatomoTrackerBuilder::MATOMO_DEFAULT_TIMEOUT, $tracker->getRequestConnectTimeout());
}
private function builder(?MatomoOptions $options = null): MatomoTrackerBuilder
{
$options ??= new MatomoOptions(enabled: true, baseUrl: 'base_url', siteId: 5, apiToken: 'api_token');
return new MatomoTrackerBuilder($options);
}
}