mirror of
https://github.com/shlinkio/shlink.git
synced 2026-03-06 23:33:13 +08:00
Add logic to send visits to a matomo instance
This commit is contained in:
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
88
module/Core/src/EventDispatcher/Matomo/SendVisitToMatomo.php
Normal file
88
module/Core/src/EventDispatcher/Matomo/SendVisitToMatomo.php
Normal file
@@ -0,0 +1,88 @@
|
||||
<?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());
|
||||
|
||||
$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,
|
||||
public readonly ?string $baseUrl,
|
||||
/** @var numeric-string|int|null */
|
||||
private readonly string|int|null $siteId,
|
||||
public readonly ?string $apiToken,
|
||||
) {
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
39
module/Core/src/Matomo/MatomoTrackerBuilder.php
Normal file
39
module/Core/src/Matomo/MatomoTrackerBuilder.php
Normal file
@@ -0,0 +1,39 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Shlinkio\Shlink\Core\Matomo;
|
||||
|
||||
use MatomoTracker;
|
||||
use Shlinkio\Shlink\Core\Exception\RuntimeException;
|
||||
|
||||
class MatomoTrackerBuilder implements MatomoTrackerBuilderInterface
|
||||
{
|
||||
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);
|
||||
// Token required to set the IP and location
|
||||
$tracker->setTokenAuth($this->options->apiToken);
|
||||
// We don't want to bulk send, as every request to Shlink will create a new tracker
|
||||
$tracker->disableBulkTracking();
|
||||
// Ensure params are not sent in the URL, for security reasons
|
||||
$tracker->setRequestMethodNonBulk('POST');
|
||||
|
||||
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;
|
||||
}
|
||||
@@ -188,6 +188,11 @@ class Visit extends AbstractEntity implements JsonSerializable
|
||||
return $this->date;
|
||||
}
|
||||
|
||||
public function userAgent(): string
|
||||
{
|
||||
return $this->userAgent;
|
||||
}
|
||||
|
||||
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));
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user