mirror of
https://github.com/shlinkio/shlink.git
synced 2025-03-14 04:00:57 +03:00
Merge pull request #1920 from acelaya-forks/feature/matomo-integration
Feature/matomo integration
This commit is contained in:
commit
cb0bac55d2
26 changed files with 743 additions and 185 deletions
1
.gitignore
vendored
1
.gitignore
vendored
|
@ -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
|
||||
|
|
|
@ -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.
|
||||
|
|
|
@ -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",
|
||||
|
|
|
@ -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' => [
|
||||
|
|
16
config/autoload/matomo.global.php
Normal file
16
config/autoload/matomo.global.php
Normal 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(),
|
||||
],
|
||||
|
||||
];
|
26
config/autoload/matomo.local.php.dist
Normal file
26
config/autoload/matomo.local.php.dist
Normal 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' => '...',
|
||||
],
|
||||
|
||||
];
|
|
@ -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();
|
||||
|
|
|
@ -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"
|
||||
|
|
|
@ -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 => [
|
||||
|
|
|
@ -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,
|
||||
],
|
||||
],
|
||||
|
||||
];
|
||||
];
|
||||
})();
|
||||
|
|
|
@ -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';
|
||||
|
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
|
89
module/Core/src/EventDispatcher/Matomo/SendVisitToMatomo.php
Normal file
89
module/Core/src/EventDispatcher/Matomo/SendVisitToMatomo.php
Normal 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);
|
||||
}
|
||||
}
|
27
module/Core/src/Matomo/MatomoOptions.php
Normal file
27
module/Core/src/Matomo/MatomoOptions.php
Normal 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;
|
||||
}
|
||||
}
|
48
module/Core/src/Matomo/MatomoTrackerBuilder.php
Normal file
48
module/Core/src/Matomo/MatomoTrackerBuilder.php
Normal 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;
|
||||
}
|
||||
}
|
16
module/Core/src/Matomo/MatomoTrackerBuilderInterface.php
Normal file
16
module/Core/src/Matomo/MatomoTrackerBuilderInterface.php
Normal 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;
|
||||
}
|
|
@ -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 = '')
|
||||
{
|
||||
}
|
||||
|
|
|
@ -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 [
|
||||
|
|
|
@ -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));
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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);
|
||||
|
|
|
@ -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,
|
||||
);
|
||||
}
|
||||
}
|
51
module/Core/test/Matomo/MatomoTrackerBuilderTest.php
Normal file
51
module/Core/test/Matomo/MatomoTrackerBuilderTest.php
Normal 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);
|
||||
}
|
||||
}
|
Loading…
Add table
Reference in a new issue