From 0f991d67d26e8ea8b97bbed94e11c4bb82daadfc Mon Sep 17 00:00:00 2001 From: sgiehl Date: Fri, 24 Jul 2026 16:41:11 +0200 Subject: [PATCH 01/28] Raise PHP requirement to 8.1 and add static-analysis dev tooling - Drop PHP 7.2-8.0 support; require ^8.1. - Bump PHPUnit to ^10.5 (PHP 8.1 floor rules out PHPUnit 11). - Add PHPStan ^2, PHP_CodeSniffer ^3.10, the Matomo coding standard (matomo-org/matomo-coding-standards via VCS repo) and the phpcs composer-installer plugin. - Pin config.platform.php to 8.1.0 and allow the installer plugin. - Add composer scripts: test, phpstan, phpcs, phpcbf. --- composer.json | 28 ++++++++++++++++++++++++++-- 1 file changed, 26 insertions(+), 2 deletions(-) diff --git a/composer.json b/composer.json index c740a25..e54bbfd 100644 --- a/composer.json +++ b/composer.json @@ -17,12 +17,18 @@ "source": "https://github.com/matomo-org/matomo-php-tracker" }, "require": { - "php": "~7.2 || ~7.3 || ~7.4 || ~8.0 || ~8.1 || ~8.2 || ~8.3 || ~8.4 || ~8.5", + "php": "^8.1", "ext-json": "*" }, "suggest": { "ext-curl": "Using this extension to issue the HTTPS request to Matomo" }, + "repositories": [ + { + "type": "vcs", + "url": "https://github.com/matomo-org/matomo-coding-standards.git" + } + ], "autoload": { "classmap": ["."] }, @@ -32,6 +38,24 @@ } }, "require-dev": { - "phpunit/phpunit": "^8.5 || ^9.3 || ^10.1" + "phpunit/phpunit": "^10.5", + "phpstan/phpstan": "^2", + "squizlabs/php_codesniffer": "^3.10", + "matomo-org/matomo-coding-standards": "dev-master", + "dealerdirect/phpcodesniffer-composer-installer": "^1.0" + }, + "config": { + "platform": { + "php": "8.1.0" + }, + "allow-plugins": { + "dealerdirect/phpcodesniffer-composer-installer": true + } + }, + "scripts": { + "test": "phpunit", + "phpstan": "phpstan analyse", + "phpcs": "phpcs", + "phpcbf": "phpcbf" } } From daa0dbbf92c227275f62d40df1f946f456a9e273 Mon Sep 17 00:00:00 2001 From: sgiehl Date: Fri, 24 Jul 2026 16:41:37 +0200 Subject: [PATCH 02/28] CI: run on PHP 8.1-8.5 and add PHPStan + PHPCS quality workflow - phpunit.yml: drop PHP 7.2-8.0 from the matrix, bump to actions/checkout@v4, and install the json + curl extensions. - Add quality.yml running PHPStan and PHP_CodeSniffer on PHP 8.1 for every pull request and push to master. --- .github/workflows/phpunit.yml | 6 ++-- .github/workflows/quality.yml | 53 +++++++++++++++++++++++++++++++++++ 2 files changed, 56 insertions(+), 3 deletions(-) create mode 100644 .github/workflows/quality.yml diff --git a/.github/workflows/phpunit.yml b/.github/workflows/phpunit.yml index fbcb7de..acb9da2 100644 --- a/.github/workflows/phpunit.yml +++ b/.github/workflows/phpunit.yml @@ -24,15 +24,15 @@ jobs: strategy: matrix: operating-system: [ubuntu-latest, windows-latest, macOS-latest] - php-version: ['7.2', '7.3', '7.4', '8.0', '8.1', '8.2', '8.3', '8.4', '8.5'] + php-version: ['8.1', '8.2', '8.3', '8.4', '8.5'] steps: - - uses: actions/checkout@v2 + - uses: actions/checkout@v4 - name: Install PHP uses: shivammathur/setup-php@v2 with: php-version: ${{ matrix.php-version }} tools: composer:v2 - extensions: memcached + extensions: json, curl - name: "Composer install" run: | composer install --prefer-dist diff --git a/.github/workflows/quality.yml b/.github/workflows/quality.yml new file mode 100644 index 0000000..485332e --- /dev/null +++ b/.github/workflows/quality.yml @@ -0,0 +1,53 @@ +name: Code quality + +on: + pull_request: + push: + branches: [ master ] + +permissions: + actions: read + checks: read + contents: read + deployments: none + issues: read + packages: none + pull-requests: read + repository-projects: none + security-events: none + statuses: none + +jobs: + phpstan: + name: PHPStan + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - name: Install PHP + uses: shivammathur/setup-php@v2 + with: + php-version: '8.1' + tools: composer:v2 + extensions: json, curl + coverage: none + - name: "Composer install" + run: composer install --prefer-dist + - name: PHPStan + run: ./vendor/bin/phpstan analyse --no-progress + + phpcs: + name: PHP_CodeSniffer + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - name: Install PHP + uses: shivammathur/setup-php@v2 + with: + php-version: '8.1' + tools: composer:v2 + extensions: json, curl + coverage: none + - name: "Composer install" + run: composer install --prefer-dist + - name: PHP_CodeSniffer + run: ./vendor/bin/phpcs From ef051f8b602b077a5a2ce263670ecfb760f8dda1 Mon Sep 17 00:00:00 2001 From: sgiehl Date: Fri, 24 Jul 2026 17:07:02 +0200 Subject: [PATCH 03/28] Add strict types and modern type hints; fix latent type bugs Add declare(strict_types=1) to both source files and give every method proper parameter and return type hints aligned with how Matomo core coerces each tracking parameter. Declare all class properties explicitly with concrete/nullable types and array-shape PHPDocs (the class keeps #[AllowDynamicProperties] for consumer BC). Optional 'unset' parameters and their backing properties/getters move from the legacy `= false` sentinel to nullable (`?T = null`); fluent setters now declare `: self`, and the do* tracking methods declare `: string|bool`. Fixes latent type issues that become fatal TypeErrors under strict types (previously masked by weak-mode coercion), including: - sendRequest() now returns string|bool (bulk mode really returns true, not the coerced "1"); cascaded to all do* methods. - forceDotAsSeparatorForDecimalPoint() no longer returns null/false from a : string method. - setCustomTrackingParameter() casts the dimension id to int. - doTrackSiteSearch()/getUrlTrackSiteSearch() use ?int and only emit &search_count when a count is provided (no longer always sends 0). - getUrlTrackCrash()/getUrlTrackEcommerceOrder() cast int/id values before urlencode(); getRequest() casts lat/long before urlencode(). - getUrlTrackEvent() guards the nullable name/value correctly. - clientHints is reset to [] (not false) in bulk mode. - getCustomVariablesFromCookie() validates decoded cookie data. BREAKING CHANGE: passing mismatched scalar types now throws TypeError instead of being silently coerced, and unset sentinels are null rather than false. This targets a new major release. --- MatomoTracker.php | 750 ++++++++++++++++++++++++---------------------- PiwikTracker.php | 16 +- 2 files changed, 407 insertions(+), 359 deletions(-) diff --git a/MatomoTracker.php b/MatomoTracker.php index 989bdfe..c39466c 100644 --- a/MatomoTracker.php +++ b/MatomoTracker.php @@ -11,6 +11,8 @@ * @package MatomoTracker */ +declare(strict_types=1); + /** * MatomoTracker implements the Matomo Tracking Web API. * @@ -30,7 +32,7 @@ class MatomoTracker * @var string * @deprecated */ - static public $URL = ''; + public static string $URL = ''; public const AI_BOT_USER_AGENT_SUBSTRINGS = [ 'ChatGPT-User', @@ -52,7 +54,7 @@ class MatomoTracker /** * @ignore */ - public $DEBUG_APPEND_URL = ''; + public string $DEBUG_APPEND_URL = ''; /** * Visitor ID length @@ -81,138 +83,178 @@ class MatomoTracker public const DEFAULT_COOKIE_PATH = '/'; - public $ecommerceItems = []; + /** + * @var list, 3: string, 4: int}> + */ + public array $ecommerceItems = []; - public $attributionInfo = false; + /** + * @var array + */ + public array $attributionInfo = []; - public $eventCustomVar = []; + /** + * @var array + */ + public array $eventCustomVar = []; - public $forcedDatetime = false; + public ?string $forcedDatetime = null; - public $forcedNewVisit = false; + public bool $forcedNewVisit = false; - public $networkTime = false; + public ?int $networkTime = null; - public $serverTime = false; + public ?int $serverTime = null; - public $transferTime = false; + public ?int $transferTime = null; - public $domProcessingTime = false; + public ?int $domProcessingTime = null; - public $domCompletionTime = false; + public ?int $domCompletionTime = null; - public $onLoadTime = false; + public ?int $onLoadTime = null; - public $pageCustomVar = []; + /** + * @var array + */ + public array $pageCustomVar = []; - public $ecommerceView = []; + /** + * @var array + */ + public array $ecommerceView = []; - public $customParameters = []; + /** + * @var array + */ + public array $customParameters = []; - public $customDimensions = []; + /** + * @var array + */ + public array $customDimensions = []; - public $customData = false; + public ?string $customData = null; - public $hasCookies = false; + public bool $hasCookies = false; - public $token_auth = false; + public ?string $token_auth = null; - public $userAgent = false; + public ?string $userAgent = null; - public $country = false; + public ?string $country = null; - public $region = false; + public ?string $region = null; - public $city = false; + public ?string $city = null; - public $lat = false; + public ?float $lat = null; - public $long = false; + public ?float $long = null; - public $width = false; + public ?int $width = null; - public $height = false; + public ?int $height = null; - public $plugins = false; + public ?string $plugins = null; - public $localHour = false; + public ?int $localHour = null; - public $localMinute = false; + public ?int $localMinute = null; - public $localSecond = false; + public ?int $localSecond = null; - public $idPageview = false; + public ?string $idPageview = null; - public $idPageviewSetManually = false; + public bool $idPageviewSetManually = false; - public $idSite; + public int $idSite; - public $urlReferrer; + public ?string $urlReferrer = null; - public $pageCharset = self::DEFAULT_CHARSET_PARAMETER_VALUES; + public string $pageCharset = self::DEFAULT_CHARSET_PARAMETER_VALUES; - public $pageUrl; + public string $pageUrl = ''; - public $ip; + public ?string $ip = null; - public $acceptLanguage; + public ?string $acceptLanguage = null; - public $clientHints = []; + /** + * @var array + */ + public array $clientHints = []; // Life of the visitor cookie (in sec) - public $configVisitorCookieTimeout = 33955200; // 13 months (365 + 28 days) + public int $configVisitorCookieTimeout = 33955200; // 13 months (365 + 28 days) // Life of the session cookie (in sec) - public $configSessionCookieTimeout = 1800; // 30 minutes + public int $configSessionCookieTimeout = 1800; // 30 minutes // Life of the session cookie (in sec) - public $configReferralCookieTimeout = 15768000; // 6 months + public int $configReferralCookieTimeout = 15768000; // 6 months // Visitor Ids in order - public $userId = false; + public ?string $userId = null; - public $forcedVisitorId = false; + public ?string $forcedVisitorId = null; - public $cookieVisitorId = false; + public ?string $cookieVisitorId = null; - public $randomVisitorId = false; + public string $randomVisitorId = ''; - public $configCookiesDisabled = false; + public bool $configCookiesDisabled = false; - public $configCookiePath = self::DEFAULT_COOKIE_PATH; + public string $configCookiePath = self::DEFAULT_COOKIE_PATH; - public $configCookieDomain = ''; + public string $configCookieDomain = ''; - public $configCookieSameSite = ''; + public string $configCookieSameSite = ''; - public $configCookieSecure = false; + public bool $configCookieSecure = false; - public $configCookieHTTPOnly = false; + public bool $configCookieHTTPOnly = false; - public $currentTs; + public int $currentTs; - public $createTs; + public int $createTs; // Allow debug while blocking the request - public $requestTimeout = 600; + public int $requestTimeout = 600; + + public int $requestConnectTimeout = 300; + + public bool $doBulkRequests = false; - public $requestConnectTimeout = 300; + /** + * @var list + */ + public array $storedTrackingActions = []; - public $doBulkRequests = false; + public bool $sendImageResponse = true; - public $storedTrackingActions = []; + /** + * @var array + */ + public array $outgoingTrackerCookies = []; - public $sendImageResponse = true; + /** + * @var array + */ + public array $incomingTrackerCookies = []; - public $outgoingTrackerCookies = []; + /** + * @var array + */ + public array $visitorCustomVar = []; - public $incomingTrackerCookies = []; + private ?string $requestMethod = null; - public $visitorCustomVar; + private string $apiUrl = ''; - private $requestMethod = null; + private ?string $proxy = null; - private $apiUrl = ''; + private int $proxyPort = 80; /** * Builds a MatomoTracker object, used to track visits, pages and Goal conversions @@ -225,18 +267,18 @@ class MatomoTracker public function __construct(int $idSite, string $apiUrl = '') { $this->idSite = $idSite; - $this->urlReferrer = !empty($_SERVER['HTTP_REFERER']) ? $_SERVER['HTTP_REFERER'] : false; + $this->urlReferrer = !empty($_SERVER['HTTP_REFERER']) ? self::toStringValue($_SERVER['HTTP_REFERER']) : null; $this->pageUrl = self::getCurrentUrl(); - $this->ip = !empty($_SERVER['REMOTE_ADDR']) ? $_SERVER['REMOTE_ADDR'] : false; - $this->acceptLanguage = !empty($_SERVER['HTTP_ACCEPT_LANGUAGE']) ? $_SERVER['HTTP_ACCEPT_LANGUAGE'] : false; - $this->userAgent = !empty($_SERVER['HTTP_USER_AGENT']) ? $_SERVER['HTTP_USER_AGENT'] : false; + $this->ip = !empty($_SERVER['REMOTE_ADDR']) ? self::toStringValue($_SERVER['REMOTE_ADDR']) : null; + $this->acceptLanguage = !empty($_SERVER['HTTP_ACCEPT_LANGUAGE']) ? self::toStringValue($_SERVER['HTTP_ACCEPT_LANGUAGE']) : null; + $this->userAgent = !empty($_SERVER['HTTP_USER_AGENT']) ? self::toStringValue($_SERVER['HTTP_USER_AGENT']) : null; $this->setClientHints( - !empty($_SERVER['HTTP_SEC_CH_UA_MODEL']) ? $_SERVER['HTTP_SEC_CH_UA_MODEL'] : '', - !empty($_SERVER['HTTP_SEC_CH_UA_PLATFORM']) ? $_SERVER['HTTP_SEC_CH_UA_PLATFORM'] : '', - !empty($_SERVER['HTTP_SEC_CH_UA_PLATFORM_VERSION']) ? $_SERVER['HTTP_SEC_CH_UA_PLATFORM_VERSION'] : '', - !empty($_SERVER['HTTP_SEC_CH_UA_FULL_VERSION_LIST']) ? $_SERVER['HTTP_SEC_CH_UA_FULL_VERSION_LIST'] : '', - !empty($_SERVER['HTTP_SEC_CH_UA_FULL_VERSION']) ? $_SERVER['HTTP_SEC_CH_UA_FULL_VERSION'] : '', - !empty($_SERVER['HTTP_SEC_CH_UA_FORM_FACTORS']) ? $_SERVER['HTTP_SEC_CH_UA_FORM_FACTORS'] : '' + !empty($_SERVER['HTTP_SEC_CH_UA_MODEL']) ? self::toStringValue($_SERVER['HTTP_SEC_CH_UA_MODEL']) : '', + !empty($_SERVER['HTTP_SEC_CH_UA_PLATFORM']) ? self::toStringValue($_SERVER['HTTP_SEC_CH_UA_PLATFORM']) : '', + !empty($_SERVER['HTTP_SEC_CH_UA_PLATFORM_VERSION']) ? self::toStringValue($_SERVER['HTTP_SEC_CH_UA_PLATFORM_VERSION']) : '', + !empty($_SERVER['HTTP_SEC_CH_UA_FULL_VERSION_LIST']) ? self::toStringValue($_SERVER['HTTP_SEC_CH_UA_FULL_VERSION_LIST']) : '', + !empty($_SERVER['HTTP_SEC_CH_UA_FULL_VERSION']) ? self::toStringValue($_SERVER['HTTP_SEC_CH_UA_FULL_VERSION']) : '', + !empty($_SERVER['HTTP_SEC_CH_UA_FORM_FACTORS']) ? self::toStringValue($_SERVER['HTTP_SEC_CH_UA_FORM_FACTORS']) : '' ); if (!empty($apiUrl)) { self::$URL = $apiUrl; @@ -265,7 +307,7 @@ public function setApiUrl(string $url): void * * @return $this */ - public function setPageCharset(string $charset = '') + public function setPageCharset(string $charset = ''): self { $this->pageCharset = $charset; @@ -278,7 +320,7 @@ public function setPageCharset(string $charset = '') * @param string $url Raw URL (not URL encoded) * @return $this */ - public function setUrl(string $url) + public function setUrl(string $url): self { $this->pageUrl = $url; @@ -291,7 +333,7 @@ public function setUrl(string $url) * @param string $url Raw URL (not URL encoded) * @return $this */ - public function setUrlReferrer(string $url) + public function setUrlReferrer(string $url): self { $this->urlReferrer = $url; @@ -307,7 +349,7 @@ public function setUrlReferrer(string $url) * @deprecated this metric is deprecated please use performance timings instead * @see setPerformanceTimings */ - public function setGenerationTime(int $timeMs) + public function setGenerationTime(int $timeMs): self { return $this; } @@ -331,7 +373,7 @@ public function setPerformanceTimings( ?int $domProcessing = null, ?int $domCompletion = null, ?int $onload = null - ) { + ): self { $this->networkTime = $network; $this->serverTime = $server; $this->transferTime = $transfer; @@ -347,19 +389,19 @@ public function setPerformanceTimings( */ public function clearPerformanceTimings(): void { - $this->networkTime = false; - $this->serverTime = false; - $this->transferTime = false; - $this->domProcessingTime = false; - $this->domCompletionTime = false; - $this->onLoadTime = false; + $this->networkTime = null; + $this->serverTime = null; + $this->transferTime = null; + $this->domProcessingTime = null; + $this->domCompletionTime = null; + $this->onLoadTime = null; } /** * @deprecated * @ignore */ - public function setUrlReferer(string $url) + public function setUrlReferer(string $url): self { $this->setUrlReferrer($url); @@ -381,9 +423,9 @@ public function setUrlReferer(string $url) * @throws Exception * @see function getAttributionInfo() in https://github.com/matomo-org/matomo/blob/master/js/matomo.js */ - public function setAttributionInfo(string $jsonEncoded) + public function setAttributionInfo(string $jsonEncoded): self { - $decoded = json_decode($jsonEncoded, $assoc = true); + $decoded = json_decode($jsonEncoded, true); if (!is_array($decoded)) { throw new Exception("setAttributionInfo() is expecting a JSON encoded string, $jsonEncoded given"); } @@ -408,13 +450,13 @@ public function setCustomVariable( string $name, string $value, string $scope = 'visit' - ) { + ): self { if ($scope === 'page') { - $this->pageCustomVar[$id] = array($name, $value); + $this->pageCustomVar[$id] = [$name, $value]; } elseif ($scope === 'event') { - $this->eventCustomVar[$id] = array($name, $value); + $this->eventCustomVar[$id] = [$name, $value]; } elseif ($scope === 'visit') { - $this->visitorCustomVar[$id] = array($name, $value); + $this->visitorCustomVar[$id] = [$name, $value]; } else { throw new Exception("Invalid 'scope' parameter value"); } @@ -431,10 +473,11 @@ public function setCustomVariable( * @param string $scope Custom variable scope. Possible values: visit, page, event * * @throws Exception - * @return mixed An array with this format: array( 0 => CustomVariableName, 1 => CustomVariableValue ) or false + * @return array{0: string, 1: string}|false An array with this format: + * array( 0 => CustomVariableName, 1 => CustomVariableValue ) or false * @see matomo.js getCustomVariable() */ - public function getCustomVariable(int $id, string $scope = 'visit') + public function getCustomVariable(int $id, string $scope = 'visit'): array|false { if ($scope === 'page') { return $this->pageCustomVar[$id] ?? false; @@ -451,17 +494,8 @@ public function getCustomVariable(int $id, string $scope = 'visit') if (!empty($this->visitorCustomVar[$id])) { return $this->visitorCustomVar[$id]; } - $cookieDecoded = $this->getCustomVariablesFromCookie(); - if (!is_array($cookieDecoded) - || !isset($cookieDecoded[$id]) - || !is_array($cookieDecoded[$id]) - || count($cookieDecoded[$id]) !== 2 - ) { - return false; - } - - return $cookieDecoded[$id]; + return $this->getCustomVariablesFromCookie()[$id] ?? false; } /** @@ -484,9 +518,9 @@ public function clearCustomVariables(): void * @param string $value value for custom dimension * @return $this */ - public function setCustomDimension(int $id, string $value) + public function setCustomDimension(int $id, string $value): self { - $this->customDimensions['dimension'.$id] = $value; + $this->customDimensions['dimension' . $id] = $value; return $this; } @@ -520,12 +554,12 @@ public function getCustomDimension(int $id): ?string * @return $this * @throws Exception */ - public function setCustomTrackingParameter(string $trackingApiParameter, string $value) + public function setCustomTrackingParameter(string $trackingApiParameter, string $value): self { $matches = []; if (preg_match('/^dimension([0-9]+)$/', $trackingApiParameter, $matches)) { - $this->setCustomDimension($matches[1], $value); + $this->setCustomDimension((int) $matches[1], $value); return $this; } @@ -547,11 +581,11 @@ public function clearCustomTrackingParameters(): void * Sets the current visitor ID to a random new one. * @return $this */ - public function setNewVisitorId() + public function setNewVisitorId(): self { - $this->randomVisitorId = substr(md5(uniqid(rand(), true)), 0, self::LENGTH_VISITOR_ID); - $this->forcedVisitorId = false; - $this->cookieVisitorId = false; + $this->randomVisitorId = substr(md5(uniqid(strval(rand()), true)), 0, self::LENGTH_VISITOR_ID); + $this->forcedVisitorId = null; + $this->cookieVisitorId = null; return $this; } @@ -561,7 +595,7 @@ public function setNewVisitorId() * * @return $this */ - public function setIdSite(int $idSite) + public function setIdSite(int $idSite): self { $this->idSite = $idSite; @@ -574,7 +608,7 @@ public function setIdSite(int $idSite) * @param string $acceptLanguage For example "fr-fr" * @return $this */ - public function setBrowserLanguage(string $acceptLanguage) + public function setBrowserLanguage(string $acceptLanguage): self { $this->acceptLanguage = $acceptLanguage; @@ -588,7 +622,7 @@ public function setBrowserLanguage(string $acceptLanguage) * @param string $userAgent * @return $this */ - public function setUserAgent(string $userAgent) + public function setUserAgent(string $userAgent): self { $this->userAgent = $userAgent; @@ -617,10 +651,10 @@ public function setClientHints( string $model = '', string $platform = '', string $platformVersion = '', - $fullVersionList = '', + string|array $fullVersionList = '', string $uaFullVersion = '', - $formFactors = '' - ) { + string|array $formFactors = '' + ): self { if (is_string($fullVersionList)) { $reg = '/^"([^"]+)"; ?v="([^"]+)"(?:, )?/'; $list = []; @@ -631,8 +665,6 @@ public function setClientHints( } $fullVersionList = $list; - } elseif (!is_array($fullVersionList)) { - $fullVersionList = []; } if (is_string($formFactors)) { @@ -643,8 +675,6 @@ function ($item) { }, $formFactors )); - } elseif (!is_array($formFactors)) { - $formFactors = []; } $this->clientHints = array_filter([ @@ -667,7 +697,7 @@ function ($item) { * * @return $this */ - public function setCountry(string $country) + public function setCountry(string $country): self { $this->country = $country; @@ -682,7 +712,7 @@ public function setCountry(string $country) * * @return $this */ - public function setRegion(string $region) + public function setRegion(string $region): self { $this->region = $region; @@ -697,7 +727,7 @@ public function setRegion(string $region) * * @return $this */ - public function setCity(string $city) + public function setCity(string $city): self { $this->city = $city; @@ -712,7 +742,7 @@ public function setCity(string $city) * * @return $this */ - public function setLatitude(float $lat) + public function setLatitude(float $lat): self { $this->lat = $lat; @@ -727,7 +757,7 @@ public function setLatitude(float $lat) * * @return $this */ - public function setLongitude(float $long) + public function setLongitude(float $long): self { $this->long = $long; @@ -788,7 +818,7 @@ public function disableSendImageResponse(): void /** * Fix-up domain */ - protected static function domainFixup($domain) + protected static function domainFixup(string $domain): string { if (strlen($domain) > 0) { $dl = strlen($domain) - 1; @@ -829,9 +859,9 @@ protected function getCookieName(string $cookieName): string * Tracks a page view * * @param string $documentTitle Page title as it will appear in the Actions > Page titles report - * @return mixed Response string or true if using bulk requests. + * @return string|bool Response string or true if using bulk requests. */ - public function doTrackPageView(string $documentTitle) + public function doTrackPageView(string $documentTitle): string|bool { if (!$this->idPageviewSetManually) { $this->generateNewPageviewId(); @@ -853,9 +883,9 @@ public function doTrackPageView(string $documentTitle) * @param int|null $responseSizeBytes the size of the response sent to the AI bot, if known. * @param int|null $serverTimeMs the number of milliseconds it took to process the request, if known. * @param string|null $source the source/proxy that served the request (max 50 chars), if known. - * @return string|null Response string, or null if the current user agent is not a known AI bot. + * @return string|bool|null Response string, or null if the current user agent is not a known AI bot. */ - public function doTrackPageViewIfAIBot(?int $httpStatus = null, ?int $responseSizeBytes = null, ?int $serverTimeMs = null, ?string $source = null) + public function doTrackPageViewIfAIBot(?int $httpStatus = null, ?int $responseSizeBytes = null, ?int $serverTimeMs = null, ?string $source = null): string|bool|null { if (!self::isUserAgentAIBot($this->userAgent)) { return null; @@ -880,16 +910,16 @@ public function setPageviewId(string $idPageview): void * If the id was not set manually, the id that was automatically generated in last `doTrackPageView()` will * be returned. If there was no last page view, this will be false. * - * @return string|false The PageView id as string or false if there is none yet. + * @return string|null The PageView id as string or null if there is none yet. */ - public function getPageviewId() + public function getPageviewId(): ?string { return $this->idPageview; } private function generateNewPageviewId(): void { - $this->idPageview = substr(md5(uniqid(rand(), true)), 0, 6); + $this->idPageview = substr(md5(uniqid(strval(rand()), true)), 0, 6); } /** @@ -897,16 +927,16 @@ private function generateNewPageviewId(): void * * @param string $category The Event Category (Videos, Music, Games...) * @param string $action The Event's Action (Play, Pause, Duration, Add Playlist, Downloaded, Clicked...) - * @param string|bool $name (optional) The Event's object Name (a particular Movie name, or Song name, or File name...) - * @param float|bool $value (optional) The Event's value - * @return mixed Response string or true if using bulk requests. + * @param string|null $name (optional) The Event's object Name (a particular Movie name, or Song name, or File name...) + * @param int|float|null $value (optional) The Event's value + * @return string|bool Response string or true if using bulk requests. */ public function doTrackEvent( string $category, string $action, - $name = false, - $value = false - ) { + ?string $name = null, + int|float|null $value = null + ): string|bool { $url = $this->getUrlTrackEvent($category, $action, $name, $value); return $this->sendRequest($url); @@ -917,14 +947,14 @@ public function doTrackEvent( * * @param string $contentName The name of the content. For instance 'Ad Foo Bar' * @param string $contentPiece The actual content. For instance the path to an image, video, audio, any text - * @param string|bool $contentTarget (optional) The target of the content. For instance the URL of a landing page. - * @return mixed Response string or true if using bulk requests. + * @param string|null $contentTarget (optional) The target of the content. For instance the URL of a landing page. + * @return string|bool Response string or true if using bulk requests. */ public function doTrackContentImpression( string $contentName, string $contentPiece = 'Unknown', - $contentTarget = false - ) { + ?string $contentTarget = null + ): string|bool { $url = $this->getUrlTrackContentImpression($contentName, $contentPiece, $contentTarget); return $this->sendRequest($url); @@ -937,15 +967,15 @@ public function doTrackContentImpression( * @param string $interaction The name of the interaction with the content. For instance a 'click' * @param string $contentName The name of the content. For instance 'Ad Foo Bar' * @param string $contentPiece The actual content. For instance the path to an image, video, audio, any text - * @param string|bool $contentTarget (optional) The target the content leading to when an interaction occurs. For instance the URL of a landing page. - * @return mixed Response string or true if using bulk requests. + * @param string|null $contentTarget (optional) The target the content leading to when an interaction occurs. For instance the URL of a landing page. + * @return string|bool Response string or true if using bulk requests. */ public function doTrackContentInteraction( string $interaction, string $contentName, string $contentPiece = 'Unknown', - $contentTarget = false - ) { + ?string $contentTarget = null + ): string|bool { $url = $this->getUrlTrackContentInteraction($interaction, $contentName, $contentPiece, $contentTarget); return $this->sendRequest($url); @@ -956,16 +986,15 @@ public function doTrackContentInteraction( * These are used to populate reports in Actions > Site Search. * * @param string $keyword Searched query on the site - * @param string $category (optional) Search engine category if applicable - * @param bool|int $countResults (optional) results displayed on the search result page. Used to track "zero result" keywords. + * @param int|null $countResults (optional) results displayed on the search result page. Used to track "zero result" keywords. * - * @return mixed Response or true if using bulk requests. + * @return string|bool Response or true if using bulk requests. */ public function doTrackSiteSearch( string $keyword, string $category = '', - $countResults = false - ) { + ?int $countResults = null + ): string|bool { $url = $this->getUrlTrackSiteSearch($keyword, $category, $countResults); return $this->sendRequest($url); @@ -976,9 +1005,9 @@ public function doTrackSiteSearch( * * @param int $idGoal Id Goal to record a conversion * @param float $revenue Revenue for this conversion - * @return mixed Response or true if using bulk request + * @return string|bool Response or true if using bulk request */ - public function doTrackGoal(int $idGoal, float $revenue = 0.0) + public function doTrackGoal(int $idGoal, float $revenue = 0.0): string|bool { $url = $this->getUrlTrackGoal($idGoal, $revenue); @@ -990,9 +1019,9 @@ public function doTrackGoal(int $idGoal, float $revenue = 0.0) * * @param string $actionUrl URL of the download or outlink * @param string $actionType Type of the action: 'download' or 'link' - * @return mixed Response or true if using bulk request + * @return string|bool Response or true if using bulk request */ - public function doTrackAction(string $actionUrl, string $actionType) + public function doTrackAction(string $actionUrl, string $actionType): string|bool { // Referrer could be udpated to be the current URL temporarily (to mimic JS behavior) $url = $this->getUrlTrackAction($actionUrl, $actionType); @@ -1010,8 +1039,8 @@ public function doTrackAction(string $actionUrl, string $actionType) * * @param string $sku (required) SKU, Product identifier * @param string $name (optional) Product name - * @param string|array $category (optional) Product category, or array of product categories (up to 5 categories can be specified for a given product) - * @param float|int $price (optional) Individual product price (supports integer and decimal prices) + * @param string|array $category (optional) Product category, or array of product categories (up to 5 categories can be specified for a given product) + * @param int|float|string $price (optional) Individual product price (supports integer and decimal prices) * @param int $quantity (optional) Product quantity. If not specified, will default to 1 in the Reports * @throws Exception * @return $this @@ -1019,17 +1048,17 @@ public function doTrackAction(string $actionUrl, string $actionType) public function addEcommerceItem( string $sku, string $name = '', - $category = '', - $price = 0.0, + string|array $category = '', + int|float|string $price = 0.0, int $quantity = 1 - ) { + ): self { if (empty($sku)) { throw new Exception("You must specify a SKU for the Ecommerce item"); } - $price = $this->forceDotAsSeparatorForDecimalPoint($price); + $priceNormalized = $this->forceDotAsSeparatorForDecimalPoint($price); - $this->ecommerceItems[] = array($sku, $name, $category, $price, $quantity); + $this->ecommerceItems[] = [$sku, $name, $category, $priceNormalized, $quantity]; return $this; } @@ -1042,9 +1071,9 @@ public function addEcommerceItem( * Items which were in the previous cart and are not sent in later Cart updates will be deleted from the cart (in the database). * * @param float $grandTotal Cart grandTotal (typically the sum of all items' prices) - * @return mixed Response or true if using bulk request + * @return string|bool Response or true if using bulk request */ - public function doTrackEcommerceCartUpdate(float $grandTotal) + public function doTrackEcommerceCartUpdate(float $grandTotal): string|bool { $url = $this->getUrlTrackEcommerceCartUpdate($grandTotal); @@ -1057,9 +1086,9 @@ public function doTrackEcommerceCartUpdate(float $grandTotal) * To enable bulk tracking, call enableBulkTracking(). * * @throws Exception - * @return string Response + * @return string|bool Response */ - public function doBulkTrack() + public function doBulkTrack(): string|bool { if (empty($this->storedTrackingActions)) { throw new Exception( @@ -1076,7 +1105,10 @@ public function doBulkTrack() } $postData = json_encode($data); - $response = $this->sendRequest($this->getBaseUrl(), 'POST', $postData, $force = true); + if ($postData === false) { + throw new Exception("Failed to JSON encode the bulk tracking request"); + } + $response = $this->sendRequest($this->getBaseUrl(), 'POST', $postData, true); $this->storedTrackingActions = []; @@ -1098,16 +1130,16 @@ public function doBulkTrack() * @param float $tax (optional) Tax amount for this order * @param float $shipping (optional) Shipping amount for this order * @param float $discount (optional) Discounted amount in this order - * @return mixed Response or true if using bulk request + * @return string|bool Response or true if using bulk request */ public function doTrackEcommerceOrder( - $orderId, + string|int $orderId, float $grandTotal, float $subTotal = 0.0, float $tax = 0.0, float $shipping = 0.0, float $discount = 0.0 - ) { + ): string|bool { $url = $this->getUrlTrackEcommerceOrder($orderId, $grandTotal, $subTotal, $tax, $shipping, $discount); return $this->sendRequest($url); @@ -1121,9 +1153,9 @@ public function doTrackEcommerceOrder( * the Throwable. * @param string|null $category (optional) a category value for this crash. This can be any information you want * to attach to the crash. - * @return mixed Response or true if using bulk request + * @return string|bool Response or true if using bulk request */ - public function doTrackPhpThrowable(Throwable $throwable, ?string $category = null) + public function doTrackPhpThrowable(Throwable $throwable, ?string $category = null): string|bool { $message = $throwable->getMessage(); $stack = $throwable->getTraceAsString(); @@ -1145,7 +1177,7 @@ public function doTrackPhpThrowable(Throwable $throwable, ?string $category = nu * @param string|null $location (optional) the source file URI where the crash originated. * @param int|null $line (optional) the source file line where the crash originated. * @param int|null $column (optional) the source file column where the crash originated. - * @return mixed Response or true if using bulk request + * @return string|bool Response or true if using bulk request */ public function doTrackCrash( string $message, @@ -1155,7 +1187,7 @@ public function doTrackCrash( ?string $location = null, ?int $line = null, ?int $column = null - ) { + ): string|bool { $url = $this->getUrlTrackCrash($message, $type, $category, $stack, $location, $line, $column); return $this->sendRequest($url); @@ -1168,9 +1200,9 @@ public function doTrackCrash( * they will extend the existing visit and the current last action for the visit. If after the standard visit length, * ping requests will create a new visit using the last action in the last known visit. * - * @return mixed Response or true if using bulk request + * @return string|bool Response or true if using bulk request */ - public function doPing() + public function doPing(): string|bool { $url = $this->getRequest($this->idSite); $url .= '&ping=1'; @@ -1190,7 +1222,7 @@ public function doPing() * * @param string $sku Product SKU being viewed * @param string $name Product Name being viewed - * @param string|array $category Category being viewed. On a Product page, this is the product's category. + * @param string|array $category Category being viewed. On a Product page, this is the product's category. * You can also specify an array of up to 5 categories for a given page view. * @param float $price Specify the price at which the item was displayed * @return $this @@ -1198,14 +1230,14 @@ public function doPing() public function setEcommerceView( string $sku = '', string $name = '', - $category = '', + string|array $category = '', float $price = 0.0 - ) { + ): self { $this->ecommerceView = []; if (!empty($category)) { if (is_array($category)) { - $category = json_encode($category); + $category = (string) json_encode($category); } } else { $category = ""; @@ -1213,8 +1245,7 @@ public function setEcommerceView( $this->ecommerceView['_pkc'] = $category; if (!empty($price)) { - $price = $this->forceDotAsSeparatorForDecimalPoint($price); - $this->ecommerceView['_pkp'] = $price; + $this->ecommerceView['_pkp'] = $this->forceDotAsSeparatorForDecimalPoint($price); } // On a category page, do not record "Product name not defined" @@ -1236,15 +1267,11 @@ public function setEcommerceView( * Force the separator for decimal point to be a dot. See https://github.com/matomo-org/matomo/issues/6435 * If for instance a German locale is used it would be a comma otherwise. * - * @param float|string $value + * @param int|float|string $value */ - private function forceDotAsSeparatorForDecimalPoint($value): string + private function forceDotAsSeparatorForDecimalPoint(int|float|string $value): string { - if (null === $value || false === $value) { - return $value; - } - - return str_replace(',', '.', $value); + return str_replace(',', '.', (string) $value); } /** @@ -1287,7 +1314,7 @@ public function getUrlTrackAIBot(?int $httpStatus = null, ?int $responseSizeByte * so items will have to be added again via addEcommerceItem() * @ignore */ - public function getUrlTrackEcommerceCartUpdate($grandTotal) + public function getUrlTrackEcommerceCartUpdate(float $grandTotal): string { return $this->getUrlTrackEcommerce($grandTotal); } @@ -1299,18 +1326,18 @@ public function getUrlTrackEcommerceCartUpdate($grandTotal) * @ignore */ public function getUrlTrackEcommerceOrder( - $orderId, - $grandTotal, - $subTotal = 0.0, - $tax = 0.0, - $shipping = 0.0, - $discount = 0.0 - ) { + string|int $orderId, + float $grandTotal, + float $subTotal = 0.0, + float $tax = 0.0, + float $shipping = 0.0, + float $discount = 0.0 + ): string { if (empty($orderId)) { throw new Exception("You must specifiy an orderId for the Ecommerce order"); } $url = $this->getUrlTrackEcommerce($grandTotal, $subTotal, $tax, $shipping, $discount); - $url .= '&ec_id=' . urlencode($orderId); + $url .= '&ec_id=' . urlencode((string) $orderId); return $url; } @@ -1323,12 +1350,13 @@ public function getUrlTrackEcommerceOrder( * * @ignore */ - protected function getUrlTrackEcommerce($grandTotal, $subTotal = 0.0, $tax = 0.0, $shipping = 0.0, $discount = 0.0) - { - if (!is_numeric($grandTotal)) { - throw new Exception("You must specifiy a grandTotal for the Ecommerce order (or Cart update)"); - } - + protected function getUrlTrackEcommerce( + float $grandTotal, + float $subTotal = 0.0, + float $tax = 0.0, + float $shipping = 0.0, + float $discount = 0.0 + ): string { $url = $this->getRequest($this->idSite); $url .= '&idgoal=0'; if (!empty($grandTotal)) { @@ -1352,9 +1380,9 @@ protected function getUrlTrackEcommerce($grandTotal, $subTotal = 0.0, $tax = 0.0 $url .= '&ec_dt=' . $discount; } if (!empty($this->ecommerceItems)) { - $url .= '&ec_items=' . urlencode(json_encode($this->ecommerceItems)); + $url .= '&ec_items=' . urlencode((string) json_encode($this->ecommerceItems)); } - $this->ecommerceItems = array(); + $this->ecommerceItems = []; return $url; } @@ -1382,16 +1410,16 @@ public function getUrlTrackPageView(string $documentTitle = ''): string * @see doTrackEvent() * @param string $category The Event Category (Videos, Music, Games...) * @param string $action The Event's Action (Play, Pause, Duration, Add Playlist, Downloaded, Clicked...) - * @param string|bool $name (optional) The Event's object Name (a particular Movie name, or Song name, or File name...) - * @param float|bool $value (optional) The Event's value + * @param string|null $name (optional) The Event's object Name (a particular Movie name, or Song name, or File name...) + * @param int|float|null $value (optional) The Event's value * @return string URL to matomo.php with all parameters set to track the pageview - * @throws + * @throws Exception */ public function getUrlTrackEvent( string $category, string $action, - $name = false, - $value = false + ?string $name = null, + int|float|null $value = null ): string { $url = $this->getRequest($this->idSite); if (strlen($category) === 0) { @@ -1404,12 +1432,11 @@ public function getUrlTrackEvent( $url .= '&e_c=' . urlencode($category); $url .= '&e_a=' . urlencode($action); - if (strlen($name) > 0) { + if ($name !== null && $name !== '') { $url .= '&e_n=' . urlencode($name); } - if (strlen($value) > 0) { - $value = $this->forceDotAsSeparatorForDecimalPoint($value); - $url .= '&e_v=' . $value; + if ($value !== null) { + $url .= '&e_v=' . $this->forceDotAsSeparatorForDecimalPoint($value); } return $url; @@ -1421,14 +1448,14 @@ public function getUrlTrackEvent( * @see doTrackContentImpression() * @param string $contentName The name of the content. For instance 'Ad Foo Bar' * @param string $contentPiece The actual content. For instance the path to an image, video, audio, any text - * @param string|false $contentTarget (optional) The target of the content. For instance the URL of a landing page. + * @param string|null $contentTarget (optional) The target of the content. For instance the URL of a landing page. * @throws Exception In case $contentName is empty * @return string URL to matomo.php with all parameters set to track the pageview */ public function getUrlTrackContentImpression( string $contentName, string $contentPiece, - $contentTarget + ?string $contentTarget ): string { $url = $this->getRequest($this->idSite); @@ -1438,10 +1465,10 @@ public function getUrlTrackContentImpression( $url .= '&c_n=' . urlencode($contentName); - if (!empty($contentPiece) && strlen($contentPiece) > 0) { + if (!empty($contentPiece)) { $url .= '&c_p=' . urlencode($contentPiece); } - if (!empty($contentTarget) && strlen($contentTarget) > 0) { + if (!empty($contentTarget)) { $url .= '&c_t=' . urlencode($contentTarget); } @@ -1455,7 +1482,7 @@ public function getUrlTrackContentImpression( * @param string $interaction The name of the interaction with the content. For instance a 'click' * @param string $contentName The name of the content. For instance 'Ad Foo Bar' * @param string $contentPiece The actual content. For instance the path to an image, video, audio, any text - * @param string|false $contentTarget (optional) The target the content leading to when an interaction occurs. For instance the URL of a landing page. + * @param string|null $contentTarget (optional) The target the content leading to when an interaction occurs. For instance the URL of a landing page. * @throws Exception In case $interaction or $contentName is empty * @return string URL to matomo.php with all parameters set to track the pageview */ @@ -1463,7 +1490,7 @@ public function getUrlTrackContentInteraction( string $interaction, string $contentName, string $contentPiece, - $contentTarget + ?string $contentTarget ): string { $url = $this->getRequest($this->idSite); @@ -1478,10 +1505,10 @@ public function getUrlTrackContentInteraction( $url .= '&c_i=' . urlencode($interaction); $url .= '&c_n=' . urlencode($contentName); - if (!empty($contentPiece) && strlen($contentPiece) > 0) { + if (!empty($contentPiece)) { $url .= '&c_p=' . urlencode($contentPiece); } - if (!empty($contentTarget) && strlen($contentTarget) > 0) { + if (!empty($contentTarget)) { $url .= '&c_t=' . urlencode($contentTarget); } @@ -1493,15 +1520,15 @@ public function getUrlTrackContentInteraction( * * @see doTrackSiteSearch() */ - public function getUrlTrackSiteSearch(string $keyword, string $category, int $countResults): string + public function getUrlTrackSiteSearch(string $keyword, string $category, ?int $countResults = null): string { $url = $this->getRequest($this->idSite); $url .= '&search=' . urlencode($keyword); if (strlen($category) > 0) { $url .= '&search_cat=' . urlencode($category); } - if (!empty($countResults) || $countResults === 0) { - $url .= '&search_count=' . (int)$countResults; + if ($countResults !== null) { + $url .= '&search_count=' . $countResults; } return $url; @@ -1520,8 +1547,7 @@ public function getUrlTrackGoal(int $idGoal, float $revenue = 0.0): string $url = $this->getRequest($this->idSite); $url .= '&idgoal=' . $idGoal; if (!empty($revenue)) { - $revenue = $this->forceDotAsSeparatorForDecimalPoint($revenue); - $url .= '&revenue=' . $revenue; + $url .= '&revenue=' . $this->forceDotAsSeparatorForDecimalPoint($revenue); } return $url; @@ -1581,10 +1607,10 @@ public function getUrlTrackCrash( $url .= '&cra_ru=' . urlencode($location); } if ($line) { - $url .= '&cra_rl=' . urlencode($line); + $url .= '&cra_rl=' . urlencode((string) $line); } if ($column) { - $url .= '&cra_rc=' . urlencode($column); + $url .= '&cra_rc=' . urlencode((string) $column); } return $url; @@ -1601,7 +1627,7 @@ public function getUrlTrackCrash( * If the datetime is older than one day (default value for tracking_requests_require_authentication_when_custom_timestamp_newer_than), then you must call setTokenAuth() with a valid Admin/Super user token. * @return $this */ - public function setForceVisitDateTime(string $dateTime) + public function setForceVisitDateTime(string $dateTime): self { $this->forcedDatetime = $dateTime; @@ -1615,7 +1641,7 @@ public function setForceVisitDateTime(string $dateTime) * If you call setForceNewVisit() before calling doTrack*, then a new visit will be created for this request. * @return $this */ - public function setForceNewVisit() + public function setForceNewVisit(): self { $this->forcedNewVisit = true; @@ -1630,7 +1656,7 @@ public function setForceNewVisit() * @param string $ip IP string, eg. 130.54.2.1 * @return $this */ - public function setIp(string $ip) + public function setIp(string $ip): self { $this->ip = $ip; @@ -1646,7 +1672,7 @@ public function setIp(string $ip) * @return $this * @throws Exception */ - public function setUserId(string $userId) + public function setUserId(string $userId): self { if ($userId === '') { throw new Exception("User ID cannot be empty."); @@ -1660,10 +1686,8 @@ public function setUserId(string $userId) * Hash function used internally by Matomo to hash a User ID into the Visitor ID. * * Note: matches implementation of Tracker\Request->getUserIdHashed() - * - * @return string */ - public static function getUserIdHashed($id): string + public static function getUserIdHashed(string $id): string { return substr(sha1($id), 0, 16); } @@ -1680,7 +1704,7 @@ public static function getUserIdHashed($id): string * @return $this * @throws Exception */ - public function setVisitorId(string $visitorId) + public function setVisitorId(string $visitorId): self { $hexChars = '01234567890abcdefABCDEF'; if (strlen($visitorId) !== self::LENGTH_VISITOR_ID @@ -1711,12 +1735,12 @@ public function setVisitorId(string $visitorId) * * @return string 16 hex chars visitor ID string */ - public function getVisitorId() + public function getVisitorId(): string { if (!empty($this->forcedVisitorId)) { return $this->forcedVisitorId; } - if ($this->loadVisitorIdCookie()) { + if ($this->loadVisitorIdCookie() && $this->cookieVisitorId !== null) { return $this->cookieVisitorId; } @@ -1725,18 +1749,16 @@ public function getVisitorId() /** * Returns the currently set user agent. - * @return string */ - public function getUserAgent() + public function getUserAgent(): ?string { return $this->userAgent; } /** * Returns the currently set IP address. - * @return string */ - public function getIp() + public function getIp(): ?string { return $this->ip; } @@ -1744,10 +1766,8 @@ public function getIp() /** * Returns the User ID string, which may have been set via: * $v->setUserId('username@example.org'); - * - * @return bool */ - public function getUserId() + public function getUserId(): ?string { return $this->userId; } @@ -1771,7 +1791,9 @@ protected function loadVisitorIdCookie(): bool /* $this->cookieVisitorId provides backward compatibility since getVisitorId() didn't change any existing VisitorId value */ $this->cookieVisitorId = $parts[0]; - $this->createTs = $parts[1]; + if (isset($parts[1])) { + $this->createTs = (int) $parts[1]; + } return true; } @@ -1793,11 +1815,11 @@ public function deleteCookies(): void * This function will only work if the user is initiating the current request, and his cookies * can be read by PHP from the $_COOKIE array. * - * @return string JSON Encoded string containing the Referrer information for Goal conversion attribution. + * @return string|false JSON Encoded string containing the Referrer information for Goal conversion attribution. * Will return false if the cookie could not be found * @see matomo.js getAttributionInfo() */ - public function getAttributionInfo() + public function getAttributionInfo(): string|false { if (!empty($this->attributionInfo)) { return json_encode($this->attributionInfo); @@ -1817,7 +1839,7 @@ public function getAttributionInfo() * @param string $token_auth token_auth 32 chars token_auth string * @return $this */ - public function setTokenAuth(string $token_auth) + public function setTokenAuth(#[\SensitiveParameter] string $token_auth): self { $this->token_auth = $token_auth; @@ -1830,7 +1852,7 @@ public function setTokenAuth(string $token_auth) * @param string $time HH:MM:SS format * @return $this */ - public function setLocalTime(string $time) + public function setLocalTime(string $time): self { [$hour, $minute, $second] = explode(':', $time); $this->localHour = (int)$hour; @@ -1847,7 +1869,7 @@ public function setLocalTime(string $time) * @param int $height * @return $this */ - public function setResolution(int $width, int $height) + public function setResolution(int $width, int $height): self { $this->width = $width; $this->height = $height; @@ -1861,7 +1883,7 @@ public function setResolution(int $width, int $height) * * @return $this */ - public function setBrowserHasCookies(bool $hasCookies) + public function setBrowserHasCookies(bool $hasCookies): self { $this->hasCookies = $hasCookies; @@ -1873,7 +1895,7 @@ public function setBrowserHasCookies(bool $hasCookies) * * @return $this */ - public function setDebugStringAppend(string $debugString) + public function setDebugStringAppend(string $debugString): self { $this->DEBUG_APPEND_URL = '&' . $debugString; @@ -1893,7 +1915,7 @@ public function setPlugins( bool $pdf = false, bool $windowsMedia = false, bool $silverlight = false - ) { + ): self { $this->plugins = '&fla=' . (int)$flash . '&java=' . (int)$java . @@ -1932,7 +1954,7 @@ public function getRequestTimeout(): int * @return $this * @throws Exception */ - public function setRequestTimeout(int $timeout) + public function setRequestTimeout(int $timeout): self { if ($timeout < 0) { throw new Exception("Invalid value supplied for request timeout: $timeout"); @@ -1959,7 +1981,7 @@ public function getRequestConnectTimeout(): int * @return $this * @throws Exception */ - public function setRequestConnectTimeout(int $timeout) + public function setRequestConnectTimeout(int $timeout): self { if ($timeout < 0) { throw new Exception("Invalid value supplied for request connect timeout: $timeout"); @@ -1979,7 +2001,7 @@ public function setRequestConnectTimeout(int $timeout) * @param string $method Either 'POST' or 'GET' * @return $this */ - public function setRequestMethodNonBulk(string $method) + public function setRequestMethodNonBulk(string $method): self { $this->requestMethod = strtoupper($method) === 'POST' ? 'POST' : 'GET'; @@ -2002,8 +2024,8 @@ public function setProxy(string $proxy, int $proxyPort = 80): void */ private function getProxy(): ?string { - if (isset($this->proxy) && isset($this->proxyPort)) { - return $this->proxy.":".$this->proxyPort; + if ($this->proxy !== null) { + return $this->proxy . ":" . $this->proxyPort; } return null; } @@ -2013,7 +2035,7 @@ private function getProxy(): ?string * * @ignore */ - static public $DEBUG_LAST_REQUESTED_URL = false; + public static string|false $DEBUG_LAST_REQUESTED_URL = false; /** * Returns array of curl options for request @@ -2023,18 +2045,18 @@ private function getProxy(): ?string protected function prepareCurlOptions( string $url, string $method, - $data, + ?string $data, bool $forcePostUrlEncoded ): array { $options = [ CURLOPT_URL => $url, - CURLOPT_USERAGENT => $this->userAgent, + CURLOPT_USERAGENT => $this->userAgent ?? '', CURLOPT_HEADER => true, CURLOPT_TIMEOUT => $this->requestTimeout, CURLOPT_CONNECTTIMEOUT => $this->requestConnectTimeout, CURLOPT_RETURNTRANSFER => true, CURLOPT_HTTPHEADER => [ - 'Accept-Language: ' . $this->acceptLanguage, + 'Accept-Language: ' . ($this->acceptLanguage ?? ''), ], ]; @@ -2087,13 +2109,13 @@ protected function prepareCurlOptions( * * @return array{http: array} */ - protected function prepareStreamOptions(string $method, $data, bool $forcePostUrlEncoded): array + protected function prepareStreamOptions(string $method, ?string $data, bool $forcePostUrlEncoded): array { $stream_options = [ 'http' => [ 'method' => $method, - 'user_agent' => $this->userAgent, - 'header' => "Accept-Language: " . $this->acceptLanguage . "\r\n", + 'user_agent' => $this->userAgent ?? '', + 'header' => "Accept-Language: " . ($this->acceptLanguage ?? '') . "\r\n", 'timeout' => $this->requestTimeout, ], ]; @@ -2123,7 +2145,7 @@ protected function prepareStreamOptions(string $method, $data, bool $forcePostUr /** * @ignore */ - protected function sendRequest(string $url, string $method = 'GET', $data = null, bool $force = false): string + protected function sendRequest(string $url, string $method = 'GET', ?string $data = null, bool $force = false): string|bool { self::$DEBUG_LAST_REQUESTED_URL = $url; @@ -2138,9 +2160,9 @@ protected function sendRequest(string $url, string $method = 'GET', $data = null $this->clearCustomVariables(); $this->clearCustomDimensions(); $this->clearCustomTrackingParameters(); - $this->userAgent = false; - $this->clientHints = false; - $this->acceptLanguage = false; + $this->userAgent = null; + $this->clientHints = []; + $this->acceptLanguage = null; return true; } @@ -2196,9 +2218,9 @@ protected function sendRequest(string $url, string $method = 'GET', $data = null } } - if (!empty($response)) { + if (!empty($response) && is_string($response)) { // extract header - $headerSize = curl_getinfo($ch, CURLINFO_HEADER_SIZE); + $headerSize = (int) curl_getinfo($ch, CURLINFO_HEADER_SIZE); $header = substr($response, 0, $headerSize); // extract content @@ -2218,14 +2240,17 @@ protected function sendRequest(string $url, string $method = 'GET', $data = null $stream_options = $this->prepareStreamOptions($method, $data, $forcePostUrlEncoded); $ctx = stream_context_create($stream_options); - $response = file_get_contents($url, 0, $ctx); + $response = file_get_contents($url, false, $ctx); $content = $response; if (function_exists('http_get_last_response_headers')) { - $http_response_header = http_get_last_response_headers(); + $headers = http_get_last_response_headers(); + $responseHeaders = is_array($headers) ? $headers : []; + } else { + $responseHeaders = $http_response_header; } - $this->parseIncomingCookies($http_response_header); + $this->parseIncomingCookies($responseHeaders); } return $content; @@ -2233,13 +2258,17 @@ protected function sendRequest(string $url, string $method = 'GET', $data = null /** * Returns current timestamp, or forced timestamp/datetime if it was set - * @return string|int */ - protected function getTimestamp() + protected function getTimestamp(): int { - return !empty($this->forcedDatetime) - ? strtotime($this->forcedDatetime) - : time(); + if (!empty($this->forcedDatetime)) { + $timestamp = strtotime($this->forcedDatetime); + if ($timestamp !== false) { + return $timestamp; + } + } + + return time(); } /** @@ -2300,8 +2329,8 @@ protected function getRequest(int $idSite): string // XDEBUG_SESSIONS_START and KEY are related to the PHP Debugger, this can be ignored in other languages (!empty($_GET['XDEBUG_SESSION_START']) ? - '&XDEBUG_SESSION_START=' . @urlencode($_GET['XDEBUG_SESSION_START']) : '') . - (!empty($_GET['KEY']) ? '&KEY=' . @urlencode($_GET['KEY']) : '') . + '&XDEBUG_SESSION_START=' . urlencode(self::toStringValue($_GET['XDEBUG_SESSION_START'])) : '') . + (!empty($_GET['KEY']) ? '&KEY=' . urlencode(self::toStringValue($_GET['KEY'])) : '') . // Only allowed for Admin/Super User, token_auth required, ((!empty($this->ip) && !empty($this->token_auth)) ? '&cip=' . $this->ip : '') . @@ -2314,20 +2343,20 @@ protected function getRequest(int $idSite): string // These parameters are set by the JS, but optional when using API (!empty($this->plugins) ? $this->plugins : '') . - (($this->localHour !== false && $this->localMinute !== false && $this->localSecond !== false) ? + (($this->localHour !== null && $this->localMinute !== null && $this->localSecond !== null) ? '&h=' . $this->localHour . '&m=' . $this->localMinute . '&s=' . $this->localSecond : '') . (!empty($this->width) && !empty($this->height) ? '&res=' . $this->width . 'x' . $this->height : '') . - (!empty($this->hasCookies) ? '&cookie=' . $this->hasCookies : '') . + (!empty($this->hasCookies) ? '&cookie=' . (int) $this->hasCookies : '') . // Various important attributes (!empty($this->customData) ? '&data=' . $this->customData : '') . - (!empty($this->visitorCustomVar) ? '&_cvar=' . urlencode(json_encode($this->visitorCustomVar)) : '') . - (!empty($this->pageCustomVar) ? '&cvar=' . urlencode(json_encode($this->pageCustomVar)) : '') . - (!empty($this->eventCustomVar) ? '&e_cvar=' . urlencode(json_encode($this->eventCustomVar)) : '') . + (!empty($this->visitorCustomVar) ? '&_cvar=' . urlencode((string) json_encode($this->visitorCustomVar)) : '') . + (!empty($this->pageCustomVar) ? '&cvar=' . urlencode((string) json_encode($this->pageCustomVar)) : '') . + (!empty($this->eventCustomVar) ? '&e_cvar=' . urlencode((string) json_encode($this->eventCustomVar)) : '') . (!empty($this->forcedVisitorId) ? '&cid=' . $this->forcedVisitorId : '&_id=' . $this->getVisitorId()) . // URL parameters - '&url=' . urlencode($this->pageUrl ?? '') . + '&url=' . urlencode($this->pageUrl) . '&urlref=' . urlencode($this->urlReferrer ?? '') . ((!empty($this->pageCharset) && $this->pageCharset != self::DEFAULT_CHARSET_PARAMETER_VALUES) ? '&cs=' . $this->pageCharset : '') . @@ -2337,37 +2366,37 @@ protected function getRequest(int $idSite): string // Attribution information, so that Goal conversions are attributed to the right referrer or campaign // Campaign name - (!empty($this->attributionInfo[0]) ? '&_rcn=' . urlencode($this->attributionInfo[0]) : '') . + (!empty($this->attributionInfo[0]) ? '&_rcn=' . urlencode(self::toStringValue($this->attributionInfo[0])) : '') . // Campaign keyword - (!empty($this->attributionInfo[1]) ? '&_rck=' . urlencode($this->attributionInfo[1]) : '') . + (!empty($this->attributionInfo[1]) ? '&_rck=' . urlencode(self::toStringValue($this->attributionInfo[1])) : '') . // Timestamp at which the referrer was set - (!empty($this->attributionInfo[2]) ? '&_refts=' . $this->attributionInfo[2] : '') . + (!empty($this->attributionInfo[2]) ? '&_refts=' . self::toStringValue($this->attributionInfo[2]) : '') . // Referrer URL - (!empty($this->attributionInfo[3]) ? '&_ref=' . urlencode($this->attributionInfo[3]) : '') . + (!empty($this->attributionInfo[3]) ? '&_ref=' . urlencode(self::toStringValue($this->attributionInfo[3])) : '') . // custom location info (!empty($this->country) ? '&country=' . urlencode($this->country) : '') . (!empty($this->region) ? '®ion=' . urlencode($this->region) : '') . (!empty($this->city) ? '&city=' . urlencode($this->city) : '') . - (!empty($this->lat) ? '&lat=' . urlencode($this->lat) : '') . - (!empty($this->long) ? '&long=' . urlencode($this->long) : '') . + (!empty($this->lat) ? '&lat=' . urlencode((string) $this->lat) : '') . + (!empty($this->long) ? '&long=' . urlencode((string) $this->long) : '') . $customFields . $customDimensions . (!$this->sendImageResponse ? '&send_image=0' : '') . // client hints - (!empty($this->clientHints) ? ('&uadata=' . urlencode(json_encode($this->clientHints))) : '') . + (!empty($this->clientHints) ? ('&uadata=' . urlencode((string) json_encode($this->clientHints))) : '') . // DEBUG $this->DEBUG_APPEND_URL; if (!empty($this->idPageview)) { $url .= - ($this->networkTime !== false ? '&pf_net=' . ((int)$this->networkTime) : '') . - ($this->serverTime !== false ? '&pf_srv=' . ((int)$this->serverTime) : '') . - ($this->transferTime !== false ? '&pf_tfr=' . ((int)$this->transferTime) : '') . - ($this->domProcessingTime !== false ? '&pf_dm1=' . ((int)$this->domProcessingTime) : '') . - ($this->domCompletionTime !== false ? '&pf_dm2=' . ((int)$this->domCompletionTime) : '') . - ($this->onLoadTime !== false ? '&pf_onl=' . ((int)$this->onLoadTime) : ''); + ($this->networkTime !== null ? '&pf_net=' . $this->networkTime : '') . + ($this->serverTime !== null ? '&pf_srv=' . $this->serverTime : '') . + ($this->transferTime !== null ? '&pf_tfr=' . $this->transferTime : '') . + ($this->domProcessingTime !== null ? '&pf_dm1=' . $this->domProcessingTime : '') . + ($this->domCompletionTime !== null ? '&pf_dm2=' . $this->domCompletionTime : '') . + ($this->onLoadTime !== null ? '&pf_onl=' . $this->onLoadTime : ''); $this->clearPerformanceTimings(); } @@ -2392,17 +2421,14 @@ protected function getRequest(int $idSite): string /** * Returns a first party cookie which name contains $name * - * @return string String value of cookie, or false if not found + * @return string|false String value of cookie, or false if not found * @ignore */ - protected function getCookieMatchingName(string $name) + protected function getCookieMatchingName(string $name): string|false { if ($this->configCookiesDisabled) { return false; } - if (!is_array($_COOKIE)) { - return false; - } $name = $this->getCookieName($name); // Matomo cookie names use dots separators in matomo.js, @@ -2410,7 +2436,7 @@ protected function getCookieMatchingName(string $name) $name = str_replace('.', '_', $name); foreach ($_COOKIE as $cookieName => $cookieValue) { if (strpos($cookieName, $name) !== false) { - return $cookieValue; + return self::toStringValue($cookieValue); } } @@ -2427,18 +2453,19 @@ protected static function getCurrentScriptName(): string { $url = ''; if (!empty($_SERVER['PATH_INFO'])) { - $url = $_SERVER['PATH_INFO']; + $url = self::toStringValue($_SERVER['PATH_INFO']); } else { if (!empty($_SERVER['REQUEST_URI'])) { - if (($pos = strpos($_SERVER['REQUEST_URI'], '?')) !== false) { - $url = substr($_SERVER['REQUEST_URI'], 0, $pos); + $requestUri = self::toStringValue($_SERVER['REQUEST_URI']); + if (($pos = strpos($requestUri, '?')) !== false) { + $url = substr($requestUri, 0, $pos); } else { - $url = $_SERVER['REQUEST_URI']; + $url = $requestUri; } } } if (empty($url) && isset($_SERVER['SCRIPT_NAME'])) { - $url = $_SERVER['SCRIPT_NAME']; + $url = self::toStringValue($_SERVER['SCRIPT_NAME']); } elseif (empty($url)) { $url = '/'; } @@ -2477,7 +2504,7 @@ protected static function getCurrentScheme(): string protected static function getCurrentHost(): string { if (isset($_SERVER['HTTP_HOST'])) { - return $_SERVER['HTTP_HOST']; + return self::toStringValue($_SERVER['HTTP_HOST']); } return 'unknown'; @@ -2492,10 +2519,8 @@ protected static function getCurrentHost(): string protected static function getCurrentQueryString(): string { $url = ''; - if (isset($_SERVER['QUERY_STRING']) - && !empty($_SERVER['QUERY_STRING']) - ) { - $url .= '?' . $_SERVER['QUERY_STRING']; + if (!empty($_SERVER['QUERY_STRING'])) { + $url .= '?' . self::toStringValue($_SERVER['QUERY_STRING']); } return $url; @@ -2514,12 +2539,23 @@ protected static function getCurrentUrl(): string . self::getCurrentQueryString(); } + /** + * Safely converts a request value of unknown type (e.g. a superglobal entry) to a string. + * Non-scalar values (arrays, objects) become an empty string. + * + * @ignore + */ + protected static function toStringValue(mixed $value): string + { + return is_scalar($value) ? (string) $value : ''; + } + /** * Sets the first party cookies as would the matomo.js * All cookies are supported: 'id' and 'ses' and 'ref' and 'cvar' cookies. * @return $this */ - protected function setFirstPartyCookies() + protected function setFirstPartyCookies(): self { if ($this->configCookiesDisabled) { return $this; @@ -2543,7 +2579,7 @@ protected function setFirstPartyCookies() $this->setCookie('id', $cookieValue, $this->configVisitorCookieTimeout); // Set the 'cvar' cookie - $this->setCookie('cvar', json_encode($this->visitorCustomVar), $this->configSessionCookieTimeout); + $this->setCookie('cvar', (string) json_encode($this->visitorCustomVar), $this->configSessionCookieTimeout); return $this; } @@ -2554,7 +2590,7 @@ protected function setFirstPartyCookies() * * @return $this */ - protected function setCookie(string $cookieName, $cookieValue, int $cookieTTL) + protected function setCookie(string $cookieName, string $cookieValue, int $cookieTTL): self { $cookieExpire = $this->currentTs + $cookieTTL; if (!headers_sent()) { @@ -2572,30 +2608,42 @@ protected function setCookie(string $cookieName, $cookieValue, int $cookieTTL) } /** - * @return array + * @return array */ - protected function getCustomVariablesFromCookie() + protected function getCustomVariablesFromCookie(): array { $cookie = $this->getCookieMatchingName('cvar'); if (!$cookie) { return []; } - return json_decode($cookie, true); + $decoded = json_decode($cookie, true); + + if (!is_array($decoded)) { + return []; + } + + $customVariables = []; + foreach ($decoded as $id => $pair) { + if (is_array($pair) && isset($pair[0], $pair[1])) { + $customVariables[(int) $id] = [self::toStringValue($pair[0]), self::toStringValue($pair[1])]; + } + } + + return $customVariables; } /** * Sets a cookie to be sent to the tracking server. * - * @param $name - * @param $value + * @param string $name + * @param string|null $value Cookie value, or null to remove a previously set cookie. */ - public function setOutgoingTrackerCookie($name, $value) + public function setOutgoingTrackerCookie(string $name, ?string $value): void { if ($value === null) { unset($this->outgoingTrackerCookies[$name]); - } - else { + } else { $this->outgoingTrackerCookies[$name] = $value; } } @@ -2603,23 +2651,19 @@ public function setOutgoingTrackerCookie($name, $value) /** * Gets a cookie which was set by the tracking server. * - * @param $name + * @param string $name * - * @return bool|string + * @return mixed The cookie value, or false if no cookie with the given name was received. */ - public function getIncomingTrackerCookie($name) + public function getIncomingTrackerCookie(string $name): mixed { - if (isset($this->incomingTrackerCookies[$name])) { - return $this->incomingTrackerCookies[$name]; - } - - return false; + return $this->incomingTrackerCookies[$name] ?? false; } /** * Reads incoming tracking server cookies. * - * @param array $headers Array with HTTP response headers as values + * @param array $headers Array with HTTP response headers as values */ protected function parseIncomingCookies(array $headers): void { @@ -2629,7 +2673,8 @@ protected function parseIncomingCookies(array $headers): void $headerName = 'set-cookie:'; $headerNameLength = strlen($headerName); - foreach($headers as $header) { + foreach ($headers as $header) { + $header = self::toStringValue($header); if (strpos(strtolower($header), $headerName) !== 0) { continue; } @@ -2646,10 +2691,9 @@ protected function parseIncomingCookies(array $headers): void /** * Returns true if the given user agent belongs to a known AI bot. * - * @param string $userAgent - * @return bool + * @param string|null $userAgent */ - public static function isUserAgentAIBot(string $userAgent): bool + public static function isUserAgentAIBot(?string $userAgent): bool { if (empty($userAgent)) { return false; @@ -2667,11 +2711,11 @@ public static function isUserAgentAIBot(string $userAgent): bool /** * Helper function to quickly generate the URL to track a page view. * - * @param $idSite + * @param int $idSite * @param string $documentTitle * @return string */ -function Matomo_getUrlTrackPageView($idSite, $documentTitle = '') +function Matomo_getUrlTrackPageView(int $idSite, string $documentTitle = ''): string { $tracker = new MatomoTracker($idSite); @@ -2681,12 +2725,12 @@ function Matomo_getUrlTrackPageView($idSite, $documentTitle = '') /** * Helper function to quickly generate the URL to track a goal. * - * @param $idSite - * @param $idGoal + * @param int $idSite + * @param int $idGoal * @param float $revenue * @return string */ -function Matomo_getUrlTrackGoal($idSite, $idGoal, $revenue = 0.0) +function Matomo_getUrlTrackGoal(int $idSite, int $idGoal, float $revenue = 0.0): string { $tracker = new MatomoTracker($idSite); diff --git a/PiwikTracker.php b/PiwikTracker.php index ea5ce68..7c8efde 100644 --- a/PiwikTracker.php +++ b/PiwikTracker.php @@ -11,6 +11,8 @@ * @package MatomoTracker */ +declare(strict_types=1); + if (!class_exists('\MatomoTracker')) { include_once('MatomoTracker.php'); } @@ -19,11 +21,11 @@ * Helper function to quickly generate the URL to track a page view. * * @deprecated - * @param $idSite + * @param int $idSite * @param string $documentTitle * @return string */ -function Piwik_getUrlTrackPageView($idSite, $documentTitle = '') +function Piwik_getUrlTrackPageView(int $idSite, string $documentTitle = ''): string { return Matomo_getUrlTrackPageView($idSite, $documentTitle); } @@ -32,12 +34,12 @@ function Piwik_getUrlTrackPageView($idSite, $documentTitle = '') * Helper function to quickly generate the URL to track a goal. * * @deprecated - * @param $idSite - * @param $idGoal + * @param int $idSite + * @param int $idGoal * @param float $revenue * @return string */ -function Piwik_getUrlTrackGoal($idSite, $idGoal, $revenue = 0.0) +function Piwik_getUrlTrackGoal(int $idSite, int $idGoal, float $revenue = 0.0): string { return Matomo_getUrlTrackGoal($idSite, $idGoal, $revenue); } @@ -47,4 +49,6 @@ function Piwik_getUrlTrackGoal($idSite, $idGoal, $revenue = 0.0) * * @deprecated use MatomoTracker instead */ -class PiwikTracker extends MatomoTracker {} +class PiwikTracker extends MatomoTracker +{ +} From b8b469068ef02c244563c64c9f0541102aec105e Mon Sep 17 00:00:00 2001 From: sgiehl Date: Fri, 24 Jul 2026 17:07:11 +0200 Subject: [PATCH 04/28] Add PHPStan configuration at max level Analyse both source files at level max with the PHP 8.1 platform. No baseline: the codebase is fully clean at max level. --- phpstan.neon.dist | 6 ++++++ 1 file changed, 6 insertions(+) create mode 100644 phpstan.neon.dist diff --git a/phpstan.neon.dist b/phpstan.neon.dist new file mode 100644 index 0000000..d8a681e --- /dev/null +++ b/phpstan.neon.dist @@ -0,0 +1,6 @@ +parameters: + level: max + phpVersion: 80100 + paths: + - MatomoTracker.php + - PiwikTracker.php From ef8e0c41967e296328f46b2ecc8d5d8969e120ec Mon Sep 17 00:00:00 2001 From: sgiehl Date: Fri, 24 Jul 2026 17:09:36 +0200 Subject: [PATCH 05/28] Add PHP_CodeSniffer config (Matomo standard) and fix violations Add phpcs.xml.dist referencing the Matomo coding standard with a 400-char line limit and scoped exceptions: the two source files intentionally mix a class with global helper functions/includes and stay in the global namespace for backwards compatibility. Apply the resulting fixes (blank line after the open tag, control structure / operator spacing, trailing newline) and clean up the test file: reorder the file header, add constant visibility, and rename the test methods to camelCase. --- MatomoTracker.php | 12 +++++++---- PiwikTracker.php | 1 + phpcs.xml.dist | 34 ++++++++++++++++++++++++++++++++ tests/Unit/MatomoTrackerTest.php | 18 ++++++++--------- 4 files changed, 52 insertions(+), 13 deletions(-) create mode 100644 phpcs.xml.dist diff --git a/MatomoTracker.php b/MatomoTracker.php index c39466c..9146542 100644 --- a/MatomoTracker.php +++ b/MatomoTracker.php @@ -1,4 +1,5 @@ customDimensions['dimension'.$id] ?? null; + return $this->customDimensions['dimension' . $id] ?? null; } /** @@ -1707,7 +1708,8 @@ public static function getUserIdHashed(string $id): string public function setVisitorId(string $visitorId): self { $hexChars = '01234567890abcdefABCDEF'; - if (strlen($visitorId) !== self::LENGTH_VISITOR_ID + if ( + strlen($visitorId) !== self::LENGTH_VISITOR_ID || strspn($visitorId, $hexChars) !== strlen($visitorId) ) { throw new Exception( @@ -2288,7 +2290,8 @@ protected function getBaseUrl(): string MatomoTracker::$URL = \'http://your-website.org/matomo/\';' ); } - if (strpos($apiUrl, '/matomo.php') === false + if ( + strpos($apiUrl, '/matomo.php') === false && strpos($apiUrl, '/proxy-matomo.php') === false ) { $apiUrl = rtrim($apiUrl, '/'); @@ -2486,7 +2489,8 @@ protected static function getCurrentScriptName(): string */ protected static function getCurrentScheme(): string { - if (isset($_SERVER['HTTPS']) + if ( + isset($_SERVER['HTTPS']) && ($_SERVER['HTTPS'] === 'on' || $_SERVER['HTTPS'] === true) ) { return 'https'; diff --git a/PiwikTracker.php b/PiwikTracker.php index 7c8efde..df91878 100644 --- a/PiwikTracker.php +++ b/PiwikTracker.php @@ -1,4 +1,5 @@ + + Matomo PHP Tracker Coding Standard + + + + + + MatomoTracker.php + PiwikTracker.php + tests + + + + + + + + + + + + + + MatomoTracker.php + PiwikTracker.php + + + + + MatomoTracker.php + PiwikTracker.php + + diff --git a/tests/Unit/MatomoTrackerTest.php b/tests/Unit/MatomoTrackerTest.php index dc68871..0c2fac6 100644 --- a/tests/Unit/MatomoTrackerTest.php +++ b/tests/Unit/MatomoTrackerTest.php @@ -1,7 +1,5 @@ assertEquals(16, strlen($testVisitorId)); @@ -47,7 +47,7 @@ public function test_trackingWithCookieSetsCorrectUrl() $this->assertEquals($expected, $url); } - public function test_trackingWithPreMatomo4CookieSetsCorrectUrl() + public function testTrackingWithPreMatomo4CookieSetsCorrectUrl() { $testVisitorId = substr(md5('testother'), 0, 16); $this->assertEquals(16, strlen($testVisitorId)); @@ -75,7 +75,7 @@ public function test_trackingWithPreMatomo4CookieSetsCorrectUrl() $this->assertEquals($expected, $url); } - public function test_setApiUrl() + public function testSetApiUrl() { $newApiUrl = 'https://NEW-API-URL.com'; $tracker = new \MatomoTracker(1, self::TEST_URL); @@ -88,7 +88,7 @@ public function test_setApiUrl() /** * @dataProvider getTestDataForIsUserAgentAIBot */ - public function test_isUserAgentAIBot($userAgent, $expected) + public function testIsUserAgentAIBot($userAgent, $expected) { $this->assertSame($expected, \MatomoTracker::isUserAgentAIBot($userAgent)); } @@ -114,7 +114,7 @@ public function getTestDataForIsUserAgentAIBot(): array /** * @dataProvider getTestDataForGetUrlTrackAIBot */ - public function test_getUrlTrackAIBot(?int $httpStatus, ?int $responseSizeBytes, ?int $serverTimeMs, ?string $source, string $expected) + public function testGetUrlTrackAIBot(?int $httpStatus, ?int $responseSizeBytes, ?int $serverTimeMs, ?string $source, string $expected) { $_SERVER['HTTP_USER_AGENT'] = 'Mozilla/5.0 AppleWebKit/537.36 (KHTML, like Gecko); compatible; ChatGPT-User/1.0; +https://openai.com/bot'; @@ -179,4 +179,4 @@ public function testUsageApiUrl(): void $this->assertSame(substr($url, 0, strlen($newApiUrl)), $newApiUrl); } -} \ No newline at end of file +} From 35477425ead0784f3a2acccac41e14ed47469b60 Mon Sep 17 00:00:00 2001 From: sgiehl Date: Fri, 24 Jul 2026 17:10:54 +0200 Subject: [PATCH 06/28] Migrate PHPUnit configuration to 10.x and update tests - phpunit.xml.dist: run --migrate-configuration (drop the removed verbose attribute, refresh the schema to 10.5, add cacheDirectory); keep backupGlobals. - Make the data-provider methods static as required by PHPUnit 10. - Ignore the new .phpunit.cache directory. --- .gitignore | 1 + phpunit.xml.dist | 16 ++++++---------- tests/Unit/MatomoTrackerTest.php | 4 ++-- 3 files changed, 9 insertions(+), 12 deletions(-) diff --git a/.gitignore b/.gitignore index 68908d3..ce9dcbc 100644 --- a/.gitignore +++ b/.gitignore @@ -2,4 +2,5 @@ /vendor/ .phpunit.result.cache /tests/.phpunit.result.cache +/.phpunit.cache/ composer.lock diff --git a/phpunit.xml.dist b/phpunit.xml.dist index cb8caf5..ae8ba4d 100644 --- a/phpunit.xml.dist +++ b/phpunit.xml.dist @@ -1,12 +1,8 @@ - - - - - ./tests/Unit - - + + + + ./tests/Unit + + diff --git a/tests/Unit/MatomoTrackerTest.php b/tests/Unit/MatomoTrackerTest.php index 0c2fac6..4def4ea 100644 --- a/tests/Unit/MatomoTrackerTest.php +++ b/tests/Unit/MatomoTrackerTest.php @@ -93,7 +93,7 @@ public function testIsUserAgentAIBot($userAgent, $expected) $this->assertSame($expected, \MatomoTracker::isUserAgentAIBot($userAgent)); } - public function getTestDataForIsUserAgentAIBot(): array + public static function getTestDataForIsUserAgentAIBot(): array { return [ ['', false], @@ -128,7 +128,7 @@ public function testGetUrlTrackAIBot(?int $httpStatus, ?int $responseSizeBytes, $this->assertEquals($expected, $actual); } - public function getTestDataForGetUrlTrackAIBot(): array + public static function getTestDataForGetUrlTrackAIBot(): array { return [ [ From 7b50646d0140feae81c562ddf3fc96037e6ebb22 Mon Sep 17 00:00:00 2001 From: sgiehl Date: Fri, 24 Jul 2026 17:12:07 +0200 Subject: [PATCH 07/28] Document PHP 8.1 requirement and the 4.0.0 major release - README: state the PHP 8.1+ requirement and add a Development section covering the composer test/phpstan/phpcs scripts. - CHANGELOG: add the 4.0.0 entry describing the breaking changes (PHP floor, strict types, false->null sentinels, do* return type, search_count / stream-fallback behaviour) and the new tooling. - .gitattributes: export-ignore phpstan.neon.dist and phpcs.xml.dist so dist archives stay lean. --- .gitattributes | 2 ++ CHANGELOG.md | 18 ++++++++++++++++++ README.md | 15 +++++++++++++++ 3 files changed, 35 insertions(+) diff --git a/.gitattributes b/.gitattributes index a05f94a..0d46871 100644 --- a/.gitattributes +++ b/.gitattributes @@ -1,4 +1,6 @@ .git* export-ignore phpunit* export-ignore +phpstan.neon.dist export-ignore +phpcs.xml.dist export-ignore tests/ export-ignore run_tests.sh export-ignore diff --git a/CHANGELOG.md b/CHANGELOG.md index 880e1bc..3293d57 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,24 @@ This is the Developer Changelog for Matomo PHP Tracker. All breaking changes or new features are listed below. +## Matomo PHP Tracker 4.0.0 + +Attention: this is a major release with breaking changes. + +### Removed +- Support for PHP versions lower than 8.1. The tracker now requires PHP 8.1 or newer. + +### Changed +- `declare(strict_types=1)` is now enabled and every method has proper parameter and return type hints aligned with how Matomo core handles the corresponding tracking parameters. **Passing a mismatched scalar type now throws a `TypeError` instead of being silently coerced.** +- Optional "unset" parameters and their corresponding properties and getters now use `null` instead of the previous `false` sentinel. For example `getUserId()`, `getUserAgent()`, `getIp()` and `getPageviewId()` now return `null` (not `false`) when no value is set, and `doTrackEvent()`/`getUrlTrackEvent()` default the event name and value to `null`. +- The `do*` tracking methods now declare a `string|bool` return type. In bulk mode they return boolean `true` (previously the value was coerced to the string `"1"`). +- `doTrackSiteSearch()` / `getUrlTrackSiteSearch()` accept `?int $countResults` and only send `&search_count` when a count is provided (previously `&search_count=0` was always sent). +- When the stream fallback is used and the request fails, `doBulkTrack()` / the `do*` methods now return `false` instead of an empty string. +- Bumped the test suite to PHPUnit 10.5. + +### Added +- PHPStan static analysis at max level (`phpstan.neon.dist`) and the Matomo coding standard via PHP_CodeSniffer (`phpcs.xml.dist`), both enforced for every pull request through GitHub Actions. + ## Matomo PHP Tracker 3.4.0 ### Changed diff --git a/README.md b/README.md index 43056ff..ce753b8 100644 --- a/README.md +++ b/README.md @@ -29,6 +29,7 @@ $matomoTracker->doTrackPageView($matomoPageTitle); ``` ## Requirements: +* PHP 8.1 or newer * JSON extension (json_decode, json_encode) * cURL or stream extension (to issue the HTTPS request to Matomo) @@ -48,6 +49,20 @@ Alternatively, you can download the files and require the Matomo tracker manuall require_once("MatomoTracker.php"); ``` +## Development + +Install the development dependencies with Composer and use the provided scripts: + +``` +composer install +composer test # run the PHPUnit test suite +composer phpstan # run static analysis (PHPStan, max level) +composer phpcs # check the coding standard (Matomo) +composer phpcbf # auto-fix coding standard violations +``` + +PHPStan and PHP_CodeSniffer are also run for every pull request via GitHub Actions. + ## License Released under the [BSD License](https://opensource.org/licenses/BSD-3-Clause) From 3a9f33134baff6123da443db9a383c89ce2b78ad Mon Sep 17 00:00:00 2001 From: sgiehl Date: Fri, 24 Jul 2026 17:18:40 +0200 Subject: [PATCH 08/28] Remove #[AllowDynamicProperties] All properties are now declared explicitly, so the tracker no longer creates dynamic properties and the attribute is unnecessary. Dropping it makes undeclared dynamic property writes raise the standard PHP 8.2 deprecation, in line with the strict, fully-typed rewrite. --- CHANGELOG.md | 1 + MatomoTracker.php | 1 - 2 files changed, 1 insertion(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 3293d57..3a1746d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,7 @@ Attention: this is a major release with breaking changes. ### Removed - Support for PHP versions lower than 8.1. The tracker now requires PHP 8.1 or newer. +- The `#[AllowDynamicProperties]` attribute. All properties are now declared explicitly, so setting undeclared dynamic properties on a tracker instance is no longer supported (extend `MatomoTracker` and declare the property instead). ### Changed - `declare(strict_types=1)` is now enabled and every method has proper parameter and return type hints aligned with how Matomo core handles the corresponding tracking parameters. **Passing a mismatched scalar type now throws a `TypeError` instead of being silently coerced.** diff --git a/MatomoTracker.php b/MatomoTracker.php index 9146542..dba2f28 100644 --- a/MatomoTracker.php +++ b/MatomoTracker.php @@ -22,7 +22,6 @@ * @package MatomoTracker * @api */ -#[AllowDynamicProperties] class MatomoTracker { /** From 5a15065bfa3322a3951d2bf34b22b499d4d97836 Mon Sep 17 00:00:00 2001 From: sgiehl Date: Fri, 24 Jul 2026 18:28:53 +0200 Subject: [PATCH 09/28] Allow setUserId(null) to de-assign a previously set User ID The method documentation always promised that the User ID can be unset again, but the string type hint made that impossible. Accept null to reset it, matching the null sentinel used everywhere else. --- MatomoTracker.php | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/MatomoTracker.php b/MatomoTracker.php index dba2f28..f6db035 100644 --- a/MatomoTracker.php +++ b/MatomoTracker.php @@ -1668,11 +1668,11 @@ public function setIp(string $ip): self * * A User ID can be a username, UUID or an email address, or any number or string that uniquely identifies a user or client. * - * @param string $userId Any user ID string (eg. email address, ID, username). Must be non empty. Set to false to de-assign a user id previously set. + * @param string|null $userId Any user ID string (eg. email address, ID, username). Must be non-empty. Set to null to de-assign a user id previously set. * @return $this * @throws Exception */ - public function setUserId(string $userId): self + public function setUserId(?string $userId): self { if ($userId === '') { throw new Exception("User ID cannot be empty."); From e14eee66c21ba3565c6cf78b3d8cb3f65330c59d Mon Sep 17 00:00:00 2001 From: sgiehl Date: Fri, 24 Jul 2026 18:29:04 +0200 Subject: [PATCH 10/28] Fix issues found in adversarial review - Cast cookie names to string before strpos(): cookies with purely numeric names are exposed as integer keys in $_COOKIE and caused a TypeError under strict_types, breaking tracker construction. - Do not read $http_response_header when the stream fallback request failed without an HTTP response; previously this caused a TypeError in parseIncomingCookies() instead of returning false. - Send latitude/longitude values of 0.0 instead of silently dropping coordinates on the equator or prime meridian. - Guard the POST URL split against URLs without a query string and replace an always-true elseif condition with else. - Remove dead branches in setEcommerceView() that guarded against values the string type hints now make impossible. - Restore the dropped $category docblock in doTrackSiteSearch(), fix the setClientHints() fullVersionList docblock shape, remove leftover array() syntax and strval() calls, and document all behavior changes in the 4.0.0 changelog. --- CHANGELOG.md | 4 ++++ MatomoTracker.php | 51 ++++++++++++++++++++++++----------------------- 2 files changed, 30 insertions(+), 25 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 3a1746d..ccc73e6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,6 +13,9 @@ Attention: this is a major release with breaking changes. ### Changed - `declare(strict_types=1)` is now enabled and every method has proper parameter and return type hints aligned with how Matomo core handles the corresponding tracking parameters. **Passing a mismatched scalar type now throws a `TypeError` instead of being silently coerced.** - Optional "unset" parameters and their corresponding properties and getters now use `null` instead of the previous `false` sentinel. For example `getUserId()`, `getUserAgent()`, `getIp()` and `getPageviewId()` now return `null` (not `false`) when no value is set, and `doTrackEvent()`/`getUrlTrackEvent()` default the event name and value to `null`. +- All public properties are now natively typed. Assigning a legacy sentinel value such as `false` to e.g. `$tracker->userAgent` now throws a `TypeError`; the `attributionInfo` property defaults to an empty array instead of `false`. Subclasses overriding methods with the old untyped signatures may need to be updated to the new signatures. +- `setUserId()` now accepts `null` to de-assign a previously set User ID, as the method documentation always promised (previously the `string` type hint made that impossible). +- `setLatitude()` / `setLongitude()` values of `0.0` (equator / prime meridian) are now sent to Matomo. Previously coordinates of exactly zero were silently dropped. - The `do*` tracking methods now declare a `string|bool` return type. In bulk mode they return boolean `true` (previously the value was coerced to the string `"1"`). - `doTrackSiteSearch()` / `getUrlTrackSiteSearch()` accept `?int $countResults` and only send `&search_count` when a count is provided (previously `&search_count=0` was always sent). - When the stream fallback is used and the request fails, `doBulkTrack()` / the `do*` methods now return `false` instead of an empty string. @@ -20,6 +23,7 @@ Attention: this is a major release with breaking changes. ### Added - PHPStan static analysis at max level (`phpstan.neon.dist`) and the Matomo coding standard via PHP_CodeSniffer (`phpcs.xml.dist`), both enforced for every pull request through GitHub Actions. +- A greatly expanded unit test suite covering all tracking parameters, cookie handling and request preparation, with code coverage reported in CI. ## Matomo PHP Tracker 3.4.0 ### Changed diff --git a/MatomoTracker.php b/MatomoTracker.php index f6db035..d58d47c 100644 --- a/MatomoTracker.php +++ b/MatomoTracker.php @@ -583,7 +583,7 @@ public function clearCustomTrackingParameters(): void */ public function setNewVisitorId(): self { - $this->randomVisitorId = substr(md5(uniqid(strval(rand()), true)), 0, self::LENGTH_VISITOR_ID); + $this->randomVisitorId = substr(md5(uniqid((string) rand(), true)), 0, self::LENGTH_VISITOR_ID); $this->forcedVisitorId = null; $this->cookieVisitorId = null; @@ -638,8 +638,8 @@ public function setUserAgent(string $userAgent): self * @param string $model Value of the header 'HTTP_SEC_CH_UA_MODEL' * @param string $platform Value of the header 'HTTP_SEC_CH_UA_PLATFORM' * @param string $platformVersion Value of the header 'HTTP_SEC_CH_UA_PLATFORM_VERSION' - * @param string|array $fullVersionList Value of header 'HTTP_SEC_CH_UA_FULL_VERSION_LIST' - * or an array containing all brands with the structure + * @param string|list $fullVersionList Value of header + * 'HTTP_SEC_CH_UA_FULL_VERSION_LIST' or an array containing all brands with the structure * [['brand' => 'Chrome', 'version' => '10.0.2'], ['brand' => '...] * @param string $uaFullVersion Value of the header 'HTTP_SEC_CH_UA_FULL_VERSION' * @param string|array $formFactors Value of the header 'HTTP_SEC_CH_UA_FORM_FACTORS' @@ -919,7 +919,7 @@ public function getPageviewId(): ?string private function generateNewPageviewId(): void { - $this->idPageview = substr(md5(uniqid(strval(rand()), true)), 0, 6); + $this->idPageview = substr(md5(uniqid((string) rand(), true)), 0, 6); } /** @@ -986,6 +986,7 @@ public function doTrackContentInteraction( * These are used to populate reports in Actions > Site Search. * * @param string $keyword Searched query on the site + * @param string $category (optional) Search engine category if applicable * @param int|null $countResults (optional) results displayed on the search result page. Used to track "zero result" keywords. * * @return string|bool Response or true if using bulk requests. @@ -1215,7 +1216,7 @@ public function doPing(): string|bool * * This must be called before doTrackPageView() on this product/category page. * - * On a category page, you may set the parameter $category only and set the other parameters to false. + * On a category page, you may set the parameter $category only and leave the other parameters empty. * * Tracking Product/Category page views will allow Matomo to report on Product & Categories * conversion rates (Conversion rate = Ecommerce orders containing this product or category / Visits to the product or category) @@ -1235,12 +1236,10 @@ public function setEcommerceView( ): self { $this->ecommerceView = []; - if (!empty($category)) { - if (is_array($category)) { - $category = (string) json_encode($category); - } - } else { - $category = ""; + if (empty($category)) { + $category = ''; + } elseif (is_array($category)) { + $category = (string) json_encode($category); } $this->ecommerceView['_pkc'] = $category; @@ -1255,9 +1254,6 @@ public function setEcommerceView( if (!empty($sku)) { $this->ecommerceView['_pks'] = $sku; } - if (empty($name)) { - $name = ''; - } $this->ecommerceView['_pkn'] = $name; return $this; @@ -1804,7 +1800,7 @@ protected function loadVisitorIdCookie(): bool */ public function deleteCookies(): void { - $cookies = array('id', 'ses', 'cvar', 'ref'); + $cookies = ['id', 'ses', 'cvar', 'ref']; foreach ($cookies as $cookie) { $this->setCookie($cookie, '', -86400); } @@ -2099,7 +2095,7 @@ protected function prepareCurlOptions( if (!empty($this->outgoingTrackerCookies)) { $options[CURLOPT_COOKIE] = http_build_query($this->outgoingTrackerCookies); - $this->outgoingTrackerCookies = array(); + $this->outgoingTrackerCookies = []; } return $options; @@ -2137,7 +2133,7 @@ protected function prepareStreamOptions(string $method, ?string $data, bool $for if (!empty($this->outgoingTrackerCookies)) { $stream_options['http']['header'] .= 'Cookie: ' . http_build_query($this->outgoingTrackerCookies) . "\r\n"; - $this->outgoingTrackerCookies = array(); + $this->outgoingTrackerCookies = []; } return $stream_options; @@ -2175,7 +2171,7 @@ protected function sendRequest(string $url, string $method = 'GET', ?string $dat $urlParts = explode('?', $url); $url = $urlParts[0]; - $data = $urlParts[1]; + $data = $urlParts[1] ?? ''; $forcePostUrlEncoded = true; $method = 'POST'; @@ -2192,7 +2188,7 @@ protected function sendRequest(string $url, string $method = 'GET', ?string $dat } $data .= $appendTokenString; $data = ltrim($data, '&'); // when no request method set we don't want it to start with '&' - } elseif (!empty($this->token_auth)) { + } else { // Use GET for all URL parameters $url .= $appendTokenString; } @@ -2244,10 +2240,14 @@ protected function sendRequest(string $url, string $method = 'GET', ?string $dat $response = file_get_contents($url, false, $ctx); $content = $response; + $responseHeaders = []; if (function_exists('http_get_last_response_headers')) { $headers = http_get_last_response_headers(); - $responseHeaders = is_array($headers) ? $headers : []; - } else { + if (is_array($headers)) { + $responseHeaders = $headers; + } + } elseif ($response !== false) { + // $http_response_header is only defined when an HTTP response was received $responseHeaders = $http_response_header; } @@ -2327,7 +2327,7 @@ protected function getRequest(int $idSite): string 'idsite=' . $idSite . '&rec=1' . '&apiv=' . self::VERSION . - '&r=' . substr(strval(mt_rand()), 2, 6) . + '&r=' . substr((string) mt_rand(), 2, 6) . // XDEBUG_SESSIONS_START and KEY are related to the PHP Debugger, this can be ignored in other languages (!empty($_GET['XDEBUG_SESSION_START']) ? @@ -2380,8 +2380,8 @@ protected function getRequest(int $idSite): string (!empty($this->country) ? '&country=' . urlencode($this->country) : '') . (!empty($this->region) ? '®ion=' . urlencode($this->region) : '') . (!empty($this->city) ? '&city=' . urlencode($this->city) : '') . - (!empty($this->lat) ? '&lat=' . urlencode((string) $this->lat) : '') . - (!empty($this->long) ? '&long=' . urlencode((string) $this->long) : '') . + ($this->lat !== null ? '&lat=' . urlencode((string) $this->lat) : '') . + ($this->long !== null ? '&long=' . urlencode((string) $this->long) : '') . $customFields . $customDimensions . (!$this->sendImageResponse ? '&send_image=0' : '') . @@ -2437,7 +2437,8 @@ protected function getCookieMatchingName(string $name): string|false // but PHP Replaces . with _ http://www.php.net/manual/en/language.variables.predefined.php#72571 $name = str_replace('.', '_', $name); foreach ($_COOKIE as $cookieName => $cookieValue) { - if (strpos($cookieName, $name) !== false) { + // cookie names that are numeric strings are exposed as integer array keys + if (strpos((string) $cookieName, $name) !== false) { return self::toStringValue($cookieValue); } } From 860e685afff2b06073462eb57ebe8f6254861c70 Mon Sep 17 00:00:00 2001 From: sgiehl Date: Fri, 24 Jul 2026 18:29:20 +0200 Subject: [PATCH 11/28] Greatly expand the unit test suite and report coverage in CI - Add tests for every tracking URL parameter, all do*/getUrlTrack* methods, custom variables/dimensions, ecommerce, client hints, attribution info, visitor/user id handling, bulk tracking, cookie handling and request preparation, including regression tests for the behavior changes of the 4.0.0 release (search_count omission, bulk true return, null sentinels, numeric cookie names). - Introduce a TestableMatomoTracker capturing requests and cookies instead of performing network or header I/O. - Analyse the test suite with PHPStan at max level. - Add a phpunit section and a CI coverage job (pcov). --- .github/workflows/phpunit.yml | 19 +- .github/workflows/quality.yml | 4 +- phpstan.neon.dist | 1 + phpunit.xml.dist | 6 + tests/Unit/MatomoTrackerTest.php | 1454 +++++++++++++++++++++++++- tests/Unit/TestableMatomoTracker.php | 171 +++ 6 files changed, 1631 insertions(+), 24 deletions(-) create mode 100644 tests/Unit/TestableMatomoTracker.php diff --git a/.github/workflows/phpunit.yml b/.github/workflows/phpunit.yml index acb9da2..7632799 100644 --- a/.github/workflows/phpunit.yml +++ b/.github/workflows/phpunit.yml @@ -32,7 +32,7 @@ jobs: with: php-version: ${{ matrix.php-version }} tools: composer:v2 - extensions: json, curl + extensions: curl - name: "Composer install" run: | composer install --prefer-dist @@ -40,3 +40,20 @@ jobs: run: | php -v ./vendor/bin/phpunit + + coverage: + name: Code coverage + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - name: Install PHP + uses: shivammathur/setup-php@v2 + with: + php-version: '8.1' + tools: composer:v2 + extensions: curl + coverage: pcov + - name: "Composer install" + run: composer install --prefer-dist + - name: PHPUnit with coverage + run: ./vendor/bin/phpunit --coverage-text diff --git a/.github/workflows/quality.yml b/.github/workflows/quality.yml index 485332e..d282109 100644 --- a/.github/workflows/quality.yml +++ b/.github/workflows/quality.yml @@ -28,7 +28,7 @@ jobs: with: php-version: '8.1' tools: composer:v2 - extensions: json, curl + extensions: curl coverage: none - name: "Composer install" run: composer install --prefer-dist @@ -45,7 +45,7 @@ jobs: with: php-version: '8.1' tools: composer:v2 - extensions: json, curl + extensions: curl coverage: none - name: "Composer install" run: composer install --prefer-dist diff --git a/phpstan.neon.dist b/phpstan.neon.dist index d8a681e..8228e29 100644 --- a/phpstan.neon.dist +++ b/phpstan.neon.dist @@ -4,3 +4,4 @@ parameters: paths: - MatomoTracker.php - PiwikTracker.php + - tests diff --git a/phpunit.xml.dist b/phpunit.xml.dist index ae8ba4d..4df188b 100644 --- a/phpunit.xml.dist +++ b/phpunit.xml.dist @@ -5,4 +5,10 @@ ./tests/Unit + + + MatomoTracker.php + PiwikTracker.php + + diff --git a/tests/Unit/MatomoTrackerTest.php b/tests/Unit/MatomoTrackerTest.php index 4def4ea..ccf568a 100644 --- a/tests/Unit/MatomoTrackerTest.php +++ b/tests/Unit/MatomoTrackerTest.php @@ -16,13 +16,44 @@ namespace Unit; +use Exception; use PHPUnit\Framework\TestCase; class MatomoTrackerTest extends TestCase { public const TEST_URL = 'http://mymatomo.com'; - public function testTrackingWithCookieSetsCorrectUrl() + protected function setUp(): void + { + parent::setUp(); + + \MatomoTracker::$URL = ''; + \MatomoTracker::$DEBUG_LAST_REQUESTED_URL = false; + $_COOKIE = []; + } + + private function createTracker(): TestableMatomoTracker + { + $tracker = new TestableMatomoTracker(1, self::TEST_URL); + $tracker->setUrl('http://somesite.com'); + + return $tracker; + } + + /** + * @return array|string> + */ + private static function parseQueryParams(string $url): array + { + $queryStr = parse_url($url, PHP_URL_QUERY); + self::assertIsString($queryStr); + parse_str($queryStr, $query); + + /** @var array|string> $query */ + return $query; + } + + public function testTrackingWithCookieSetsCorrectUrl(): void { $testVisitorId = substr(md5('testuuid'), 0, 16); $this->assertEquals(16, strlen($testVisitorId)); @@ -32,13 +63,12 @@ public function testTrackingWithCookieSetsCorrectUrl() $cookieName = '_pk_id_1_f609'; $_COOKIE[$cookieName] = $testVisitorId . '.' . $createTs; - $tracker = new \MatomoTracker(1, $apiUrl = self::TEST_URL); + $tracker = new \MatomoTracker(1, self::TEST_URL); $tracker->setUrl('http://somesite.com'); $url = $tracker->getUrlTrackPageView('test title'); - $url = preg_replace('/&r=\d+/', "", $url); + $url = (string) preg_replace('/&r=\d+/', "", $url); - $queryStr = parse_url($url, PHP_URL_QUERY); - parse_str($queryStr, $query); + $query = self::parseQueryParams($url); $this->assertEquals($testVisitorId, $query['_id']); $this->assertEquals($createTs, $query['_idts']); @@ -47,7 +77,7 @@ public function testTrackingWithCookieSetsCorrectUrl() $this->assertEquals($expected, $url); } - public function testTrackingWithPreMatomo4CookieSetsCorrectUrl() + public function testTrackingWithPreMatomo4CookieSetsCorrectUrl(): void { $testVisitorId = substr(md5('testother'), 0, 16); $this->assertEquals(16, strlen($testVisitorId)); @@ -60,13 +90,12 @@ public function testTrackingWithPreMatomo4CookieSetsCorrectUrl() $cookieName = '_pk_id_1_f609'; $_COOKIE[$cookieName] = $testVisitorId . '.' . $createTs . '.5.' . $currentTs . '.' . $lastVisitTs . '.' . $ecommerceLastOrderTs; - $tracker = new \MatomoTracker(1, $apiUrl = self::TEST_URL); + $tracker = new \MatomoTracker(1, self::TEST_URL); $tracker->setUrl('http://somesite.com'); $url = $tracker->getUrlTrackPageView('test title'); - $url = preg_replace('/&r=\d+/', "", $url); + $url = (string) preg_replace('/&r=\d+/', "", $url); - $queryStr = parse_url($url, PHP_URL_QUERY); - parse_str($queryStr, $query); + $query = self::parseQueryParams($url); $this->assertEquals($testVisitorId, $query['_id']); $this->assertEquals($createTs, $query['_idts']); @@ -75,7 +104,18 @@ public function testTrackingWithPreMatomo4CookieSetsCorrectUrl() $this->assertEquals($expected, $url); } - public function testSetApiUrl() + public function testTrackingWithNumericCookieNameDoesNotFail(): void + { + // numeric cookie names are exposed as integer keys in $_COOKIE + $_COOKIE[12345] = 'some-value'; + $_COOKIE['_pk_cvar_1_f609'] = '{"1":["name","value"]}'; + + $tracker = new \MatomoTracker(1, self::TEST_URL); + + $this->assertSame(['name', 'value'], $tracker->getCustomVariable(1)); + } + + public function testSetApiUrl(): void { $newApiUrl = 'https://NEW-API-URL.com'; $tracker = new \MatomoTracker(1, self::TEST_URL); @@ -85,14 +125,48 @@ public function testSetApiUrl() $this->assertSame(substr($url, 0, strlen($newApiUrl)), $newApiUrl); } + public function testUsageApiUrl(): void + { + $newApiUrl = 'https://NEW-API-URL.com'; + $tracker = new \MatomoTracker(1, $newApiUrl); + $url = $tracker->getUrlTrackPageView('test title'); + + $this->assertSame(substr($url, 0, strlen($newApiUrl)), $newApiUrl); + } + + public function testGetBaseUrlThrowsWhenNoUrlConfigured(): void + { + $tracker = new TestableMatomoTracker(1); + + $this->expectException(Exception::class); + $this->expectExceptionMessage('You must first set the Matomo Tracker URL'); + + $tracker->callGetBaseUrl(); + } + + public function testGetBaseUrlAppendsMatomoPhp(): void + { + $tracker = new TestableMatomoTracker(1, 'http://example.org/matomo/'); + $this->assertSame('http://example.org/matomo/matomo.php', $tracker->callGetBaseUrl()); + + $tracker = new TestableMatomoTracker(1, 'http://example.org/matomo.php'); + $this->assertSame('http://example.org/matomo.php', $tracker->callGetBaseUrl()); + + $tracker = new TestableMatomoTracker(1, 'http://example.org/proxy-matomo.php'); + $this->assertSame('http://example.org/proxy-matomo.php', $tracker->callGetBaseUrl()); + } + /** * @dataProvider getTestDataForIsUserAgentAIBot */ - public function testIsUserAgentAIBot($userAgent, $expected) + public function testIsUserAgentAIBot(string $userAgent, bool $expected): void { $this->assertSame($expected, \MatomoTracker::isUserAgentAIBot($userAgent)); } + /** + * @return list + */ public static function getTestDataForIsUserAgentAIBot(): array { return [ @@ -111,14 +185,19 @@ public static function getTestDataForIsUserAgentAIBot(): array ]; } + public function testIsUserAgentAIBotWithNull(): void + { + $this->assertFalse(\MatomoTracker::isUserAgentAIBot(null)); + } + /** * @dataProvider getTestDataForGetUrlTrackAIBot */ - public function testGetUrlTrackAIBot(?int $httpStatus, ?int $responseSizeBytes, ?int $serverTimeMs, ?string $source, string $expected) + public function testGetUrlTrackAIBot(?int $httpStatus, ?int $responseSizeBytes, ?int $serverTimeMs, ?string $source, string $expected): void { $_SERVER['HTTP_USER_AGENT'] = 'Mozilla/5.0 AppleWebKit/537.36 (KHTML, like Gecko); compatible; ChatGPT-User/1.0; +https://openai.com/bot'; - $tracker = new \MatomoTracker(1, $apiUrl = self::TEST_URL); + $tracker = new \MatomoTracker(1, self::TEST_URL); $tracker->setUrl('https://example.com/page'); $tracker->setVisitorId('abcdef01234517ab'); @@ -128,6 +207,9 @@ public function testGetUrlTrackAIBot(?int $httpStatus, ?int $responseSizeBytes, $this->assertEquals($expected, $actual); } + /** + * @return list + */ public static function getTestDataForGetUrlTrackAIBot(): array { return [ @@ -157,7 +239,32 @@ public static function getTestDataForGetUrlTrackAIBot(): array ]; } - private function normalizeTrackingUrl(string $url) + public function testDoTrackPageViewIfAIBotWithRegularUserAgentReturnsNull(): void + { + $tracker = $this->createTracker(); + $tracker->setUserAgent('Mozilla/5.0 (Windows NT 10.0; Win64; x64)'); + + $this->assertNull($tracker->doTrackPageViewIfAIBot(200)); + $this->assertSame([], $tracker->capturedRequests); + } + + public function testDoTrackPageViewIfAIBotWithBotUserAgentTracks(): void + { + $tracker = $this->createTracker(); + $tracker->setUserAgent('compatible; ChatGPT-User/1.0; +https://openai.com/bot'); + + $response = $tracker->doTrackPageViewIfAIBot(200, 1024, 55, 'wordpress'); + + $this->assertSame('mock-response', $response); + $query = self::parseQueryParams($tracker->lastRequestUrl()); + $this->assertSame('1', $query['recMode']); + $this->assertSame('200', $query['http_status']); + $this->assertSame('1024', $query['bw_bytes']); + $this->assertSame('55', $query['pf_srv']); + $this->assertSame('wordpress', $query['source']); + } + + private function normalizeTrackingUrl(string $url): string { $nonDeterministicParams = [ 'r', @@ -165,18 +272,1323 @@ private function normalizeTrackingUrl(string $url) ]; foreach ($nonDeterministicParams as $param) { - $url = preg_replace('/&' . preg_quote($param) . '=[^&]+/', '&r=', $url); + $url = (string) preg_replace('/&' . preg_quote($param, '/') . '=[^&]+/', '&r=', $url); } return $url; } - public function testUsageApiUrl(): void + public function testDoTrackPageViewGeneratesNewPageviewId(): void { - $newApiUrl = 'https://NEW-API-URL.com'; - $tracker = new \MatomoTracker(1, $newApiUrl); - $url = $tracker->getUrlTrackPageView('test title'); + $tracker = $this->createTracker(); + $tracker->doTrackPageView('page one'); + $firstId = $tracker->getPageviewId(); - $this->assertSame(substr($url, 0, strlen($newApiUrl)), $newApiUrl); + $tracker->doTrackPageView('page two'); + $secondId = $tracker->getPageviewId(); + + $this->assertNotNull($firstId); + $this->assertNotNull($secondId); + $this->assertSame(6, strlen($firstId)); + $this->assertNotSame($firstId, $secondId); + + $query = self::parseQueryParams($tracker->lastRequestUrl()); + $this->assertSame($secondId, $query['pv_id']); + $this->assertSame('page two', $query['action_name']); + } + + public function testSetPageviewIdIsKeptAcrossPageViews(): void + { + $tracker = $this->createTracker(); + $tracker->setPageviewId('custom'); + $tracker->doTrackPageView('page one'); + $tracker->doTrackPageView('page two'); + + $this->assertSame('custom', $tracker->getPageviewId()); + } + + public function testGetUrlTrackPageViewWithoutTitle(): void + { + $tracker = $this->createTracker(); + $url = $tracker->getUrlTrackPageView(); + + $this->assertStringNotContainsString('action_name', $url); + } + + public function testGetUrlTrackEventRequiresCategoryAndAction(): void + { + $tracker = $this->createTracker(); + + try { + $tracker->getUrlTrackEvent('', 'action'); + $this->fail('Expected exception for empty category'); + } catch (Exception $e) { + $this->assertStringContainsString('Category', $e->getMessage()); + } + + $this->expectException(Exception::class); + $tracker->getUrlTrackEvent('category', ''); + } + + public function testGetUrlTrackEventDefaultsOmitNameAndValue(): void + { + $tracker = $this->createTracker(); + $url = $tracker->getUrlTrackEvent('cat', 'act'); + + $this->assertStringContainsString('&e_c=cat', $url); + $this->assertStringContainsString('&e_a=act', $url); + $this->assertStringNotContainsString('&e_n=', $url); + $this->assertStringNotContainsString('&e_v=', $url); + } + + public function testGetUrlTrackEventWithNameAndValues(): void + { + $tracker = $this->createTracker(); + + $url = $tracker->getUrlTrackEvent('cat', 'act', 'name', 0); + $this->assertStringContainsString('&e_n=name', $url); + $this->assertStringContainsString('&e_v=0', $url); + + $url = $tracker->getUrlTrackEvent('cat', 'act', 'name', 3.5); + $this->assertStringContainsString('&e_v=3.5', $url); + + // an empty name is not sent + $url = $tracker->getUrlTrackEvent('cat', 'act', '', 1); + $this->assertStringNotContainsString('&e_n=', $url); + } + + public function testDoTrackEventSendsRequest(): void + { + $tracker = $this->createTracker(); + $response = $tracker->doTrackEvent('cat', 'act', 'name', 2); + + $this->assertSame('mock-response', $response); + $this->assertCount(1, $tracker->capturedRequests); + } + + public function testGetUrlTrackContentImpression(): void + { + $tracker = $this->createTracker(); + + $url = $tracker->getUrlTrackContentImpression('name', 'piece', 'http://target.example'); + $query = self::parseQueryParams($url); + $this->assertSame('name', $query['c_n']); + $this->assertSame('piece', $query['c_p']); + $this->assertSame('http://target.example', $query['c_t']); + + $url = $tracker->getUrlTrackContentImpression('name', '', null); + $this->assertStringNotContainsString('&c_p=', $url); + $this->assertStringNotContainsString('&c_t=', $url); + + $this->expectException(Exception::class); + $tracker->getUrlTrackContentImpression('', 'piece', null); + } + + public function testGetUrlTrackContentInteraction(): void + { + $tracker = $this->createTracker(); + + $url = $tracker->getUrlTrackContentInteraction('click', 'name', 'piece', 'http://target.example'); + $query = self::parseQueryParams($url); + $this->assertSame('click', $query['c_i']); + $this->assertSame('name', $query['c_n']); + $this->assertSame('piece', $query['c_p']); + $this->assertSame('http://target.example', $query['c_t']); + + $url = $tracker->getUrlTrackContentInteraction('click', 'name', '', null); + $this->assertStringNotContainsString('&c_p=', $url); + $this->assertStringNotContainsString('&c_t=', $url); + } + + public function testGetUrlTrackContentInteractionRequiresInteractionAndName(): void + { + $tracker = $this->createTracker(); + + try { + $tracker->getUrlTrackContentInteraction('', 'name', 'piece', null); + $this->fail('Expected exception for empty interaction'); + } catch (Exception $e) { + $this->assertStringContainsString('interaction', $e->getMessage()); + } + + $this->expectException(Exception::class); + $tracker->getUrlTrackContentInteraction('click', '', 'piece', null); + } + + public function testDoTrackContentImpressionAndInteractionSendRequests(): void + { + $tracker = $this->createTracker(); + $tracker->doTrackContentImpression('name'); + $tracker->doTrackContentInteraction('click', 'name'); + + $this->assertCount(2, $tracker->capturedRequests); + } + + public function testGetUrlTrackSiteSearchOmitsCountByDefault(): void + { + $tracker = $this->createTracker(); + + $url = $tracker->getUrlTrackSiteSearch('keyword', ''); + $this->assertStringContainsString('&search=keyword', $url); + $this->assertStringNotContainsString('&search_cat=', $url); + $this->assertStringNotContainsString('&search_count=', $url); + } + + public function testGetUrlTrackSiteSearchWithCategoryAndZeroCount(): void + { + $tracker = $this->createTracker(); + + $url = $tracker->getUrlTrackSiteSearch('keyword', 'category', 0); + $this->assertStringContainsString('&search_cat=category', $url); + $this->assertStringContainsString('&search_count=0', $url); + + $url = $tracker->getUrlTrackSiteSearch('keyword', '', 12); + $this->assertStringContainsString('&search_count=12', $url); + } + + public function testDoTrackSiteSearchSendsRequest(): void + { + $tracker = $this->createTracker(); + $tracker->doTrackSiteSearch('keyword'); + + $this->assertStringContainsString('&search=keyword', $tracker->lastRequestUrl()); + } + + public function testGetUrlTrackGoal(): void + { + $tracker = $this->createTracker(); + + $url = $tracker->getUrlTrackGoal(42); + $this->assertStringContainsString('&idgoal=42', $url); + $this->assertStringNotContainsString('&revenue=', $url); + + $url = $tracker->getUrlTrackGoal(42, 3.5); + $this->assertStringContainsString('&revenue=3.5', $url); + } + + public function testDoTrackGoalSendsRequest(): void + { + $tracker = $this->createTracker(); + $tracker->doTrackGoal(7, 1.25); + + $this->assertStringContainsString('&idgoal=7', $tracker->lastRequestUrl()); + } + + public function testGetUrlTrackActionAndDoTrackAction(): void + { + $tracker = $this->createTracker(); + + $url = $tracker->getUrlTrackAction('http://example.org/file.zip', 'download'); + $this->assertStringContainsString('&download=' . urlencode('http://example.org/file.zip'), $url); + + $tracker->doTrackAction('http://example.org/out', 'link'); + $this->assertStringContainsString('&link=' . urlencode('http://example.org/out'), $tracker->lastRequestUrl()); + } + + public function testGetUrlTrackCrash(): void + { + $tracker = $this->createTracker(); + + $url = $tracker->getUrlTrackCrash('message', 'TypeError', 'category', 'stack', 'http://loc.example', 10, 20); + $query = self::parseQueryParams($url); + $this->assertSame('1', $query['ca']); + $this->assertSame('message', $query['cra']); + $this->assertSame('TypeError', $query['cra_tp']); + $this->assertSame('category', $query['cra_ct']); + $this->assertSame('stack', $query['cra_st']); + $this->assertSame('http://loc.example', $query['cra_ru']); + $this->assertSame('10', $query['cra_rl']); + $this->assertSame('20', $query['cra_rc']); + + $url = $tracker->getUrlTrackCrash('message'); + $this->assertStringNotContainsString('&cra_tp=', $url); + $this->assertStringNotContainsString('&cra_ct=', $url); + $this->assertStringNotContainsString('&cra_st=', $url); + $this->assertStringNotContainsString('&cra_ru=', $url); + $this->assertStringNotContainsString('&cra_rl=', $url); + $this->assertStringNotContainsString('&cra_rc=', $url); + } + + public function testDoTrackCrashAndPhpThrowable(): void + { + $tracker = $this->createTracker(); + + $tracker->doTrackCrash('crashed'); + $this->assertStringContainsString('&cra=crashed', $tracker->lastRequestUrl()); + + $throwable = new \RuntimeException('something broke'); + $tracker->doTrackPhpThrowable($throwable, 'category'); + + $query = self::parseQueryParams($tracker->lastRequestUrl()); + $this->assertSame('something broke', $query['cra']); + $this->assertSame('RuntimeException', $query['cra_tp']); + $this->assertSame('category', $query['cra_ct']); + $this->assertSame(__FILE__, $query['cra_ru']); + } + + public function testDoPing(): void + { + $tracker = $this->createTracker(); + $tracker->doPing(); + + $this->assertStringContainsString('&ping=1', $tracker->lastRequestUrl()); + } + + public function testAddEcommerceItemRequiresSku(): void + { + $tracker = $this->createTracker(); + + $this->expectException(Exception::class); + $this->expectExceptionMessage('You must specify a SKU'); + + $tracker->addEcommerceItem(''); + } + + public function testEcommerceOrderWithItems(): void + { + $tracker = $this->createTracker(); + $tracker->addEcommerceItem('SKU1', 'Product 1', ['cat1', 'cat2'], '9,99', 2); + $tracker->addEcommerceItem('SKU2'); + + $tracker->doTrackEcommerceOrder('order-1', 20.5, 18.0, 1.5, 0.5, 0.25); + + $query = self::parseQueryParams($tracker->lastRequestUrl()); + $this->assertSame('0', $query['idgoal']); + $this->assertSame('order-1', $query['ec_id']); + $this->assertSame('20.5', $query['revenue']); + $this->assertSame('18', $query['ec_st']); + $this->assertSame('1.5', $query['ec_tx']); + $this->assertSame('0.5', $query['ec_sh']); + $this->assertSame('0.25', $query['ec_dt']); + + $this->assertIsString($query['ec_items']); + $items = json_decode($query['ec_items'], true); + $this->assertSame([ + ['SKU1', 'Product 1', ['cat1', 'cat2'], '9.99', 2], + ['SKU2', '', '', '0', 1], + ], $items); + + // items are cleared after the order was tracked + $this->assertSame([], $tracker->ecommerceItems); + } + + public function testGetUrlTrackEcommerceOrderRequiresOrderId(): void + { + $tracker = $this->createTracker(); + + $this->expectException(Exception::class); + $this->expectExceptionMessage('orderId'); + + $tracker->getUrlTrackEcommerceOrder('', 10.0); + } + + public function testEcommerceOrderAcceptsIntegerOrderId(): void + { + $tracker = $this->createTracker(); + $url = $tracker->getUrlTrackEcommerceOrder(12345, 10.0); + + $this->assertStringContainsString('&ec_id=12345', $url); + } + + public function testDoTrackEcommerceCartUpdate(): void + { + $tracker = $this->createTracker(); + $tracker->addEcommerceItem('SKU1'); + $tracker->doTrackEcommerceCartUpdate(10.0); + + $url = $tracker->lastRequestUrl(); + $this->assertStringContainsString('&idgoal=0', $url); + $this->assertStringContainsString('&revenue=10', $url); + $this->assertStringNotContainsString('&ec_id=', $url); + } + + public function testGetUrlTrackEcommerceCartUpdateWithZeroTotal(): void + { + $tracker = $this->createTracker(); + $url = $tracker->getUrlTrackEcommerceCartUpdate(0.0); + + $this->assertStringContainsString('&idgoal=0', $url); + $this->assertStringNotContainsString('&revenue=', $url); + } + + public function testSetEcommerceView(): void + { + $tracker = $this->createTracker(); + + $tracker->setEcommerceView('SKU1', 'Product', 'category', 9.99); + $this->assertSame( + ['_pkc' => 'category', '_pkp' => '9.99', '_pks' => 'SKU1', '_pkn' => 'Product'], + $tracker->ecommerceView + ); + + $tracker->setEcommerceView('SKU1', 'Product', ['cat1', 'cat2']); + $this->assertSame('["cat1","cat2"]', $tracker->ecommerceView['_pkc']); + + // category-only page: product sku/name are not recorded + $tracker->setEcommerceView('', '', 'category'); + $this->assertSame(['_pkc' => 'category'], $tracker->ecommerceView); + + // ecommerce view parameters end up in the tracking URL and are reset afterwards + $tracker->setEcommerceView('SKU1', 'Product', 'category'); + $url = $tracker->getUrlTrackPageView('title'); + $this->assertStringContainsString('&_pkc=category', $url); + $this->assertStringContainsString('&_pks=SKU1', $url); + $this->assertStringContainsString('&_pkn=Product', $url); + $this->assertSame([], $tracker->ecommerceView); + } + + public function testSetAttributionInfo(): void + { + $tracker = $this->createTracker(); + $tracker->setAttributionInfo('["campaign","keyword",1234,"http://referrer.example"]'); + + $this->assertSame('["campaign","keyword",1234,"http:\/\/referrer.example"]', $tracker->getAttributionInfo()); + + $query = self::parseQueryParams($tracker->getUrlTrackPageView('title')); + $this->assertSame('campaign', $query['_rcn']); + $this->assertSame('keyword', $query['_rck']); + $this->assertSame('1234', $query['_refts']); + $this->assertSame('http://referrer.example', $query['_ref']); + } + + public function testSetAttributionInfoThrowsOnInvalidJson(): void + { + $tracker = $this->createTracker(); + + $this->expectException(Exception::class); + $this->expectExceptionMessage('JSON encoded string'); + + $tracker->setAttributionInfo('not-json'); + } + + public function testGetAttributionInfoFromCookie(): void + { + $_COOKIE['_pk_ref_1_f609'] = '["campaign","keyword"]'; + + $tracker = $this->createTracker(); + $this->assertSame('["campaign","keyword"]', $tracker->getAttributionInfo()); + } + + public function testGetAttributionInfoWithoutCookieReturnsFalse(): void + { + $tracker = $this->createTracker(); + $this->assertFalse($tracker->getAttributionInfo()); + } + + public function testCustomVariables(): void + { + $tracker = $this->createTracker(); + + $tracker->setCustomVariable(1, 'visit-name', 'visit-value'); + $tracker->setCustomVariable(1, 'page-name', 'page-value', 'page'); + $tracker->setCustomVariable(1, 'event-name', 'event-value', 'event'); + + $this->assertSame(['visit-name', 'visit-value'], $tracker->getCustomVariable(1)); + $this->assertSame(['page-name', 'page-value'], $tracker->getCustomVariable(1, 'page')); + $this->assertSame(['event-name', 'event-value'], $tracker->getCustomVariable(1, 'event')); + $this->assertFalse($tracker->getCustomVariable(2, 'page')); + $this->assertFalse($tracker->getCustomVariable(2, 'event')); + $this->assertFalse($tracker->getCustomVariable(2)); + + $query = self::parseQueryParams($tracker->getUrlTrackPageView('title')); + $this->assertSame('{"1":["visit-name","visit-value"]}', $query['_cvar']); + $this->assertSame('{"1":["page-name","page-value"]}', $query['cvar']); + $this->assertSame('{"1":["event-name","event-value"]}', $query['e_cvar']); + + // page and event scoped variables are reset after the request, visit scope is kept + $url = $tracker->getUrlTrackPageView('title'); + $this->assertStringNotContainsString('&cvar=', $url); + $this->assertStringNotContainsString('&e_cvar=', $url); + $this->assertStringContainsString('&_cvar=', $url); + + $tracker->clearCustomVariables(); + $this->assertFalse($tracker->getCustomVariable(1)); + } + + public function testSetCustomVariableThrowsOnInvalidScope(): void + { + $tracker = $this->createTracker(); + + $this->expectException(Exception::class); + $this->expectExceptionMessage("Invalid 'scope' parameter value"); + + $tracker->setCustomVariable(1, 'name', 'value', 'invalid'); + } + + public function testGetCustomVariableThrowsOnInvalidScope(): void + { + $tracker = $this->createTracker(); + + $this->expectException(Exception::class); + + $tracker->getCustomVariable(1, 'invalid'); + } + + public function testGetCustomVariableFromCookie(): void + { + $_COOKIE['_pk_cvar_1_f609'] = '{"2":["cookie-name","cookie-value"]}'; + + $tracker = $this->createTracker(); + $this->assertSame(['cookie-name', 'cookie-value'], $tracker->getCustomVariable(2)); + $this->assertFalse($tracker->getCustomVariable(3)); + } + + /** + * @dataProvider getTestDataForCustomVariablesFromCookie + * @param array $expected + */ + public function testGetCustomVariablesFromCookieFiltersInvalidData(string $cookieValue, array $expected): void + { + $_COOKIE['_pk_cvar_1_f609'] = $cookieValue; + + $tracker = $this->createTracker(); + $this->assertSame($expected, $tracker->callGetCustomVariablesFromCookie()); + } + + /** + * @return list}> + */ + public static function getTestDataForCustomVariablesFromCookie(): array + { + return [ + ['', []], + ['not-json', []], + ['"a string"', []], + ['{"1":"not-a-pair"}', []], + ['{"1":["only-one"]}', []], + ['{"1":["name","value"],"2":"broken"}', [1 => ['name', 'value']]], + ['{"1":["name",5]}', [1 => ['name', '5']]], + ]; + } + + public function testCustomDimensions(): void + { + $tracker = $this->createTracker(); + $tracker->setCustomDimension(2, 'value'); + + $this->assertSame('value', $tracker->getCustomDimension(2)); + $this->assertNull($tracker->getCustomDimension(3)); + + $url = $tracker->getUrlTrackPageView('title'); + $this->assertStringContainsString('&dimension2=value', $url); + + // dimensions are reset after a request + $this->assertNull($tracker->getCustomDimension(2)); + + $tracker->setCustomDimension(2, 'value'); + $tracker->clearCustomDimensions(); + $this->assertNull($tracker->getCustomDimension(2)); + } + + public function testCustomTrackingParameters(): void + { + $tracker = $this->createTracker(); + $tracker->setCustomTrackingParameter('bw_bytes', '1024'); + + $url = $tracker->getUrlTrackPageView('title'); + $this->assertStringContainsString('&bw_bytes=1024', $url); + + // custom parameters are reset after a request + $url = $tracker->getUrlTrackPageView('title'); + $this->assertStringNotContainsString('&bw_bytes=', $url); + + // dimensionX parameters are mapped to custom dimensions + $tracker->setCustomTrackingParameter('dimension3', 'dim-value'); + $this->assertSame('dim-value', $tracker->getCustomDimension(3)); + + $tracker->setCustomTrackingParameter('bw_bytes', '1024'); + $tracker->clearCustomTrackingParameters(); + $url = $tracker->getUrlTrackPageView('title'); + $this->assertStringNotContainsString('&bw_bytes=', $url); + } + + public function testVisitorIdHandling(): void + { + $tracker = $this->createTracker(); + + $randomId = $tracker->getVisitorId(); + $this->assertSame(16, strlen($randomId)); + + $tracker->setVisitorId('abcdef0123456789'); + $this->assertSame('abcdef0123456789', $tracker->getVisitorId()); + + $tracker->setNewVisitorId(); + $newId = $tracker->getVisitorId(); + $this->assertSame(16, strlen($newId)); + $this->assertNotSame('abcdef0123456789', $newId); + } + + public function testSetVisitorIdThrowsOnInvalidValue(): void + { + $tracker = $this->createTracker(); + + try { + $tracker->setVisitorId('too-short'); + $this->fail('Expected exception for invalid length'); + } catch (Exception $e) { + $this->assertStringContainsString('16', $e->getMessage()); + } + + $this->expectException(Exception::class); + $tracker->setVisitorId('zzzzzzzzzzzzzzzz'); + } + + public function testForcedVisitorIdIsUsedAsCid(): void + { + $tracker = $this->createTracker(); + $tracker->setVisitorId('abcdef0123456789'); + + $query = self::parseQueryParams($tracker->getUrlTrackPageView('title')); + $this->assertSame('abcdef0123456789', $query['cid']); + $this->assertArrayNotHasKey('_id', $query); + } + + public function testLoadVisitorIdCookie(): void + { + $tracker = $this->createTracker(); + $this->assertFalse($tracker->callLoadVisitorIdCookie()); + + $_COOKIE['_pk_id_1_f609'] = 'too-short.123'; + $this->assertFalse($tracker->callLoadVisitorIdCookie()); + + $_COOKIE['_pk_id_1_f609'] = 'abcdef0123456789.1583291045'; + $this->assertTrue($tracker->callLoadVisitorIdCookie()); + $this->assertSame('abcdef0123456789', $tracker->getVisitorId()); + $this->assertSame(1583291045, $tracker->createTs); + } + + public function testLoadVisitorIdCookieWithoutCreationTsKeepsCurrentOne(): void + { + $tracker = $this->createTracker(); + $createTsBefore = $tracker->createTs; + + $_COOKIE['_pk_id_1_f609'] = 'abcdef0123456789'; + $this->assertTrue($tracker->callLoadVisitorIdCookie()); + $this->assertSame($createTsBefore, $tracker->createTs); + } + + public function testUserIdHandling(): void + { + $tracker = $this->createTracker(); + $this->assertNull($tracker->getUserId()); + + $tracker->setUserId('user@example.org'); + $this->assertSame('user@example.org', $tracker->getUserId()); + + $query = self::parseQueryParams($tracker->getUrlTrackPageView('title')); + $this->assertSame('user@example.org', $query['uid']); + + // null de-assigns a previously set user id + $tracker->setUserId(null); + $this->assertNull($tracker->getUserId()); + $url = $tracker->getUrlTrackPageView('title'); + $this->assertStringNotContainsString('&uid=', $url); + } + + public function testSetUserIdThrowsOnEmptyString(): void + { + $tracker = $this->createTracker(); + + $this->expectException(Exception::class); + $this->expectExceptionMessage('User ID cannot be empty'); + + $tracker->setUserId(''); + } + + public function testGetUserIdHashed(): void + { + $this->assertSame(substr(sha1('user@example.org'), 0, 16), \MatomoTracker::getUserIdHashed('user@example.org')); + } + + public function testUserAgentAndBrowserLanguage(): void + { + $tracker = $this->createTracker(); + $this->assertNull($tracker->getUserAgent()); + + $tracker->setUserAgent('My Agent'); + $this->assertSame('My Agent', $tracker->getUserAgent()); + + $tracker->setBrowserLanguage('de-de'); + + $options = $tracker->callPrepareCurlOptions('http://example.org', 'GET', null, false); + $this->assertSame('My Agent', $options[CURLOPT_USERAGENT]); + $this->assertSame(['Accept-Language: de-de'], $options[CURLOPT_HTTPHEADER]); + } + + public function testIpHandling(): void + { + $tracker = $this->createTracker(); + $this->assertNull($tracker->getIp()); + + $tracker->setIp('130.54.2.1'); + $this->assertSame('130.54.2.1', $tracker->getIp()); + + // cip is only added when a token_auth is set + $url = $tracker->getUrlTrackPageView('title'); + $this->assertStringNotContainsString('&cip=', $url); + + $tracker->setTokenAuth('0123456789abcdef0123456789abcdef'); + $url = $tracker->getUrlTrackPageView('title'); + $this->assertStringContainsString('&cip=130.54.2.1', $url); + } + + public function testGeoLocationParameters(): void + { + $tracker = $this->createTracker(); + $tracker->setCountry('de'); + $tracker->setRegion('Hessen'); + $tracker->setCity('Frankfurt'); + $tracker->setLatitude(50.11); + $tracker->setLongitude(8.68); + + $query = self::parseQueryParams($tracker->getUrlTrackPageView('title')); + $this->assertSame('de', $query['country']); + $this->assertSame('Hessen', $query['region']); + $this->assertSame('Frankfurt', $query['city']); + $this->assertSame('50.11', $query['lat']); + $this->assertSame('8.68', $query['long']); + } + + public function testZeroCoordinatesAreSent(): void + { + $tracker = $this->createTracker(); + $tracker->setLatitude(0.0); + $tracker->setLongitude(0.0); + + $query = self::parseQueryParams($tracker->getUrlTrackPageView('title')); + $this->assertSame('0', $query['lat']); + $this->assertSame('0', $query['long']); + } + + public function testBrowserAttributes(): void + { + $tracker = $this->createTracker(); + $tracker->setResolution(1920, 1080); + $tracker->setBrowserHasCookies(true); + $tracker->setLocalTime('04:05:06'); + $tracker->setPlugins(true, false, true); + + $query = self::parseQueryParams($tracker->getUrlTrackPageView('title')); + $this->assertSame('1920x1080', $query['res']); + $this->assertSame('1', $query['cookie']); + $this->assertSame('4', $query['h']); + $this->assertSame('5', $query['m']); + $this->assertSame('6', $query['s']); + $this->assertSame('1', $query['fla']); + $this->assertSame('0', $query['java']); + $this->assertSame('1', $query['qt']); + $this->assertSame('0', $query['realp']); + $this->assertSame('0', $query['pdf']); + $this->assertSame('0', $query['wma']); + $this->assertSame('0', $query['ag']); + } + + public function testPageCharset(): void + { + $tracker = $this->createTracker(); + + $url = $tracker->getUrlTrackPageView('title'); + $this->assertStringNotContainsString('&cs=', $url); + + $tracker->setPageCharset('iso-8859-1'); + $url = $tracker->getUrlTrackPageView('title'); + $this->assertStringContainsString('&cs=iso-8859-1', $url); + + $tracker->setPageCharset(); + $url = $tracker->getUrlTrackPageView('title'); + $this->assertStringNotContainsString('&cs=', $url); + } + + public function testUrlReferrer(): void + { + $tracker = $this->createTracker(); + $tracker->setUrlReferrer('http://referrer.example'); + + $query = self::parseQueryParams($tracker->getUrlTrackPageView('title')); + $this->assertSame('http://referrer.example', $query['urlref']); + + // the deprecated setUrlReferer() forwards to setUrlReferrer() + $tracker->setUrlReferer('http://other.example'); + $query = self::parseQueryParams($tracker->getUrlTrackPageView('title')); + $this->assertSame('http://other.example', $query['urlref']); + } + + public function testSetGenerationTimeIsANoOp(): void + { + $tracker = $this->createTracker(); + $this->assertSame($tracker, $tracker->setGenerationTime(500)); + } + + public function testPerformanceTimings(): void + { + $tracker = $this->createTracker(); + + // without a pageview id no performance timings are added + $tracker->setPerformanceTimings(1, 2, 3, 4, 5, 6); + $url = $tracker->getUrlTrackPageView('title'); + $this->assertStringNotContainsString('&pf_net=', $url); + + $tracker->setPageviewId('abc123'); + $url = $tracker->getUrlTrackPageView('title'); + $this->assertStringContainsString('&pf_net=1', $url); + $this->assertStringContainsString('&pf_srv=2', $url); + $this->assertStringContainsString('&pf_tfr=3', $url); + $this->assertStringContainsString('&pf_dm1=4', $url); + $this->assertStringContainsString('&pf_dm2=5', $url); + $this->assertStringContainsString('&pf_onl=6', $url); + + // timings are cleared after they were tracked once + $url = $tracker->getUrlTrackPageView('title'); + $this->assertStringNotContainsString('&pf_net=', $url); + + $tracker->setPerformanceTimings(1, 2, 3, 4, 5, 6); + $tracker->clearPerformanceTimings(); + $url = $tracker->getUrlTrackPageView('title'); + $this->assertStringNotContainsString('&pf_net=', $url); + } + + public function testForceVisitDateTime(): void + { + $tracker = $this->createTracker(); + $tracker->setForceVisitDateTime('2020-01-02 03:04:05'); + + $query = self::parseQueryParams($tracker->getUrlTrackPageView('title')); + $this->assertSame('2020-01-02 03:04:05', $query['cdt']); + + $this->assertSame(strtotime('2020-01-02 03:04:05'), $tracker->callGetTimestamp()); + } + + public function testGetTimestampFallsBackToCurrentTimeOnInvalidDateTime(): void + { + $tracker = $this->createTracker(); + $tracker->setForceVisitDateTime('not a datetime'); + + $this->assertEqualsWithDelta(time(), $tracker->callGetTimestamp(), 5); + + $tracker = $this->createTracker(); + $this->assertEqualsWithDelta(time(), $tracker->callGetTimestamp(), 5); + } + + public function testForceNewVisitIsOnlySentOnce(): void + { + $tracker = $this->createTracker(); + $tracker->setForceNewVisit(); + + $url = $tracker->getUrlTrackPageView('title'); + $this->assertStringContainsString('&new_visit=1', $url); + + $url = $tracker->getUrlTrackPageView('title'); + $this->assertStringNotContainsString('&new_visit=1', $url); + } + + public function testSetIdSite(): void + { + $tracker = $this->createTracker(); + $tracker->setIdSite(42); + + $this->assertStringContainsString('idsite=42', $tracker->getUrlTrackPageView('title')); + } + + public function testDebugStringAppend(): void + { + $tracker = $this->createTracker(); + $tracker->setDebugStringAppend('debug=1'); + + $this->assertStringContainsString('&debug=1', $tracker->getUrlTrackPageView('title')); + } + + public function testDisableSendImageResponse(): void + { + $tracker = $this->createTracker(); + + $url = $tracker->getUrlTrackPageView('title'); + $this->assertStringNotContainsString('&send_image=0', $url); + + $tracker->disableSendImageResponse(); + $url = $tracker->getUrlTrackPageView('title'); + $this->assertStringContainsString('&send_image=0', $url); + } + + public function testClientHintsFromStrings(): void + { + $tracker = $this->createTracker(); + $tracker->setClientHints( + 'model', + 'Windows', + '14.0.0', + '"Chromium"; v="110.0.1", "Google Chrome"; v="110.0.2"', + '110.0.1', + '"Desktop", "XR"' + ); + + $query = self::parseQueryParams($tracker->getUrlTrackPageView('title')); + $this->assertIsString($query['uadata']); + $this->assertSame( + [ + 'model' => 'model', + 'platform' => 'Windows', + 'platformVersion' => '14.0.0', + 'uaFullVersion' => '110.0.1', + 'fullVersionList' => [ + ['brand' => 'Chromium', 'version' => '110.0.1'], + ['brand' => 'Google Chrome', 'version' => '110.0.2'], + ], + 'formFactors' => ['Desktop', 'XR'], + ], + json_decode($query['uadata'], true) + ); + } + + public function testClientHintsFromArrays(): void + { + $tracker = $this->createTracker(); + $fullVersionList = [['brand' => 'Chromium', 'version' => '110.0.1']]; + $tracker->setClientHints('', 'Linux', '', $fullVersionList, '', ['Desktop']); + + $query = self::parseQueryParams($tracker->getUrlTrackPageView('title')); + $this->assertIsString($query['uadata']); + $this->assertSame( + [ + 'platform' => 'Linux', + 'fullVersionList' => $fullVersionList, + 'formFactors' => ['Desktop'], + ], + json_decode($query['uadata'], true) + ); + } + + public function testEmptyClientHintsAreNotSent(): void + { + $tracker = $this->createTracker(); + $tracker->setClientHints(); + + $this->assertStringNotContainsString('&uadata=', $tracker->getUrlTrackPageView('title')); + } + + public function testClientHintsFromServerVariables(): void + { + $_SERVER['HTTP_SEC_CH_UA_PLATFORM'] = '"macOS"'; + $_SERVER['HTTP_SEC_CH_UA_PLATFORM_VERSION'] = '"13.1.0"'; + + $tracker = $this->createTracker(); + + $query = self::parseQueryParams($tracker->getUrlTrackPageView('title')); + $this->assertIsString($query['uadata']); + $this->assertSame( + ['platform' => '"macOS"', 'platformVersion' => '"13.1.0"'], + json_decode($query['uadata'], true) + ); + } + + public function testConstructorReadsServerVariables(): void + { + $_SERVER['HTTP_REFERER'] = 'http://referrer.example'; + $_SERVER['REMOTE_ADDR'] = '10.11.12.13'; + $_SERVER['HTTP_ACCEPT_LANGUAGE'] = 'fr-fr'; + $_SERVER['HTTP_USER_AGENT'] = 'Test Agent'; + + $tracker = new TestableMatomoTracker(1, self::TEST_URL); + + $this->assertSame('10.11.12.13', $tracker->getIp()); + $this->assertSame('fr-fr', $tracker->acceptLanguage); + $this->assertSame('Test Agent', $tracker->getUserAgent()); + + $query = self::parseQueryParams($tracker->getUrlTrackPageView('title')); + $this->assertSame('http://referrer.example', $query['urlref']); + } + + public function testBulkTrackingStoresRequestsAndResetsState(): void + { + $tracker = $this->createTracker(); + $tracker->enableBulkTracking(); + $tracker->setUserAgent('Bulk Agent'); + $tracker->setBrowserLanguage('en-us'); + + $this->assertTrue($tracker->doTrackPageView('title')); + $this->assertCount(1, $tracker->storedTrackingActions); + $this->assertStringContainsString('&ua=' . urlencode('Bulk Agent'), $tracker->storedTrackingActions[0]); + $this->assertStringContainsString('&lang=' . urlencode('en-us'), $tracker->storedTrackingActions[0]); + + // user agent, language and client hints are reset after storing a bulk request + $this->assertNull($tracker->getUserAgent()); + $this->assertNull($tracker->acceptLanguage); + $this->assertSame([], $tracker->clientHints); + + $this->assertTrue($tracker->doTrackEvent('cat', 'act')); + $this->assertCount(2, $tracker->storedTrackingActions); + } + + public function testDoBulkTrackSendsAllStoredRequests(): void + { + $tracker = $this->createTracker(); + $tracker->enableBulkTracking(); + $tracker->setTokenAuth('0123456789abcdef0123456789abcdef'); + $tracker->doTrackPageView('page one'); + $tracker->doTrackPageView('page two'); + + $response = $tracker->doBulkTrack(); + + $this->assertSame('mock-response', $response); + $this->assertSame([], $tracker->storedTrackingActions); + + $this->assertCount(1, $tracker->capturedRequests); + $request = $tracker->capturedRequests[0]; + $this->assertSame('http://mymatomo.com/matomo.php', $request['url']); + $this->assertSame('POST', $request['method']); + $this->assertTrue($request['force']); + + $this->assertIsString($request['data']); + $data = json_decode($request['data'], true); + $this->assertIsArray($data); + $this->assertSame('0123456789abcdef0123456789abcdef', $data['token_auth']); + $this->assertIsArray($data['requests']); + $this->assertCount(2, $data['requests']); + } + + public function testDoBulkTrackThrowsWithoutStoredRequests(): void + { + $tracker = $this->createTracker(); + + $this->expectException(Exception::class); + + $tracker->doBulkTrack(); + } + + public function testDisableBulkTracking(): void + { + $tracker = $this->createTracker(); + $tracker->enableBulkTracking(); + $tracker->disableBulkTracking(); + + $this->assertSame('mock-response', $tracker->doTrackPageView('title')); + $this->assertSame([], $tracker->storedTrackingActions); + } + + public function testRequestTimeoutAccessors(): void + { + $tracker = $this->createTracker(); + + $this->assertSame(600, $tracker->getRequestTimeout()); + $tracker->setRequestTimeout(10); + $this->assertSame(10, $tracker->getRequestTimeout()); + + $this->assertSame(300, $tracker->getRequestConnectTimeout()); + $tracker->setRequestConnectTimeout(5); + $this->assertSame(5, $tracker->getRequestConnectTimeout()); + } + + public function testRequestTimeoutThrowsOnNegativeValue(): void + { + $tracker = $this->createTracker(); + + $this->expectException(Exception::class); + $tracker->setRequestTimeout(-1); + } + + public function testRequestConnectTimeoutThrowsOnNegativeValue(): void + { + $tracker = $this->createTracker(); + + $this->expectException(Exception::class); + $tracker->setRequestConnectTimeout(-1); + } + + public function testPrepareCurlOptions(): void + { + $tracker = $this->createTracker(); + + $options = $tracker->callPrepareCurlOptions('http://example.org', 'GET', null, false); + $this->assertSame('http://example.org', $options[CURLOPT_URL]); + $this->assertSame('', $options[CURLOPT_USERAGENT]); + $this->assertTrue($options[CURLOPT_FOLLOWLOCATION]); + $this->assertArrayNotHasKey(CURLOPT_POST, $options); + + $options = $tracker->callPrepareCurlOptions('http://example.org', 'POST', null, false); + $this->assertTrue($options[CURLOPT_POST]); + $this->assertArrayNotHasKey(CURLOPT_FOLLOWLOCATION, $options); + + // url encoded post data + $options = $tracker->callPrepareCurlOptions('http://example.org', 'POST', 'a=b', true); + $this->assertSame('a=b', $options[CURLOPT_POSTFIELDS]); + $this->assertIsArray($options[CURLOPT_HTTPHEADER]); + $this->assertContains('Content-Type: application/x-www-form-urlencoded', $options[CURLOPT_HTTPHEADER]); + + // json post data + $options = $tracker->callPrepareCurlOptions('http://example.org', 'POST', '{"requests":[]}', false); + $this->assertSame('{"requests":[]}', $options[CURLOPT_POSTFIELDS]); + $this->assertIsArray($options[CURLOPT_HTTPHEADER]); + $this->assertContains('Content-Type: application/json', $options[CURLOPT_HTTPHEADER]); + $this->assertContains('Expect:', $options[CURLOPT_HTTPHEADER]); + } + + public function testPrepareCurlOptionsWithProxyAndCookies(): void + { + $tracker = $this->createTracker(); + $tracker->setProxy('proxy.example', 3128); + $tracker->setOutgoingTrackerCookie('name', 'value'); + + $options = $tracker->callPrepareCurlOptions('http://example.org', 'GET', null, false); + $this->assertSame('proxy.example:3128', $options[CURLOPT_PROXY]); + $this->assertSame('name=value', $options[CURLOPT_COOKIE]); + + // outgoing cookies are cleared once they were added to a request + $options = $tracker->callPrepareCurlOptions('http://example.org', 'GET', null, false); + $this->assertArrayNotHasKey(CURLOPT_COOKIE, $options); + } + + public function testPrepareStreamOptions(): void + { + $tracker = $this->createTracker(); + $tracker->setUserAgent('Stream Agent'); + $tracker->setBrowserLanguage('en-gb'); + + $options = $tracker->callPrepareStreamOptions('GET', null, false); + $this->assertSame('GET', $options['http']['method']); + $this->assertSame('Stream Agent', $options['http']['user_agent']); + $this->assertSame("Accept-Language: en-gb\r\n", $options['http']['header']); + + $options = $tracker->callPrepareStreamOptions('POST', 'a=b', true); + $this->assertIsString($options['http']['header']); + $this->assertStringContainsString('Content-Type: application/x-www-form-urlencoded', $options['http']['header']); + $this->assertSame('a=b', $options['http']['content']); + + $options = $tracker->callPrepareStreamOptions('POST', '{"requests":[]}', false); + $this->assertIsString($options['http']['header']); + $this->assertStringContainsString('Content-Type: application/json', $options['http']['header']); + $this->assertSame('{"requests":[]}', $options['http']['content']); + } + + public function testPrepareStreamOptionsWithProxyAndCookies(): void + { + $tracker = $this->createTracker(); + $tracker->setProxy('proxy.example'); + $tracker->setOutgoingTrackerCookie('name', 'value'); + + $options = $tracker->callPrepareStreamOptions('GET', null, false); + $this->assertSame('proxy.example:80', $options['http']['proxy']); + $this->assertIsString($options['http']['header']); + $this->assertStringContainsString('Cookie: name=value', $options['http']['header']); + } + + public function testOutgoingTrackerCookieCanBeRemoved(): void + { + $tracker = $this->createTracker(); + $tracker->setOutgoingTrackerCookie('name', 'value'); + $tracker->setOutgoingTrackerCookie('name', null); + + $this->assertSame([], $tracker->outgoingTrackerCookies); + } + + public function testParseIncomingCookies(): void + { + $tracker = $this->createTracker(); + + $tracker->callParseIncomingCookies([ + 'Content-Type: text/plain', + 'Set-Cookie: first=value1; path=/; HttpOnly', + 12345, + ]); + + $this->assertSame('value1', $tracker->getIncomingTrackerCookie('first')); + $this->assertFalse($tracker->getIncomingTrackerCookie('missing')); + + $tracker->callParseIncomingCookies([]); + $this->assertFalse($tracker->getIncomingTrackerCookie('first')); + } + + public function testFirstPartyCookiesAreSet(): void + { + $tracker = $this->createTracker(); + $tracker->setCustomVariable(1, 'name', 'value'); + $tracker->setAttributionInfo('["campaign","keyword"]'); + $tracker->callSetFirstPartyCookies(); + + $cookieNames = array_column($tracker->capturedCookies, 'name'); + $this->assertSame(['ref', 'ses', 'id', 'cvar'], $cookieNames); + + $this->assertSame('["campaign","keyword"]', $tracker->capturedCookies[0]['value']); + $this->assertSame('*', $tracker->capturedCookies[1]['value']); + $this->assertStringContainsString($tracker->getVisitorId() . '.', $tracker->capturedCookies[2]['value']); + $this->assertSame('{"1":["name","value"]}', $tracker->capturedCookies[3]['value']); + } + + public function testDisableCookieSupport(): void + { + $_COOKIE['_pk_id_1_f609'] = 'abcdef0123456789.1583291045'; + + $tracker = $this->createTracker(); + $tracker->disableCookieSupport(); + + $this->assertFalse($tracker->callGetCookieMatchingName('id')); + + $tracker->callSetFirstPartyCookies(); + $this->assertSame([], $tracker->capturedCookies); + } + + public function testDeleteCookies(): void + { + $tracker = $this->createTracker(); + $tracker->deleteCookies(); + + $this->assertCount(4, $tracker->capturedCookies); + $this->assertSame(['id', 'ses', 'cvar', 'ref'], array_column($tracker->capturedCookies, 'name')); + foreach ($tracker->capturedCookies as $cookie) { + $this->assertSame('', $cookie['value']); + $this->assertSame(-86400, $cookie['ttl']); + } + } + + public function testSetCookieBuildsHeader(): void + { + $tracker = $this->createTracker(); + $tracker->captureCookies = false; + $tracker->enableCookies('example.com', '/path', true, true, 'Lax'); + + // in a CLI environment headers can not actually be sent, this only must not fail + $tracker->deleteCookies(); + + $this->assertSame([], $tracker->capturedCookies); + } + + public function testEnableCookiesInfluencesCookieName(): void + { + $tracker = $this->createTracker(); + $defaultName = $tracker->callGetCookieName('id'); + $this->assertMatchesRegularExpression('/^_pk_id\.1\.[0-9a-f]{4}$/', $defaultName); + + $tracker->enableCookies('example.com', '/path'); + $nameWithDomain = $tracker->callGetCookieName('id'); + $this->assertMatchesRegularExpression('/^_pk_id\.1\.[0-9a-f]{4}$/', $nameWithDomain); + $this->assertNotSame($defaultName, $nameWithDomain); + } + + /** + * @dataProvider getTestDataForDomainFixup + */ + public function testDomainFixup(string $domain, string $expected): void + { + $this->assertSame($expected, TestableMatomoTracker::callDomainFixup($domain)); + } + + /** + * @return list + */ + public static function getTestDataForDomainFixup(): array + { + return [ + ['', ''], + ['example.com', 'example.com'], + ['example.com.', 'example.com'], + ['*.example.com', '.example.com'], + ]; + } + + /** + * @dataProvider getTestDataForToStringValue + */ + public function testToStringValue(mixed $value, string $expected): void + { + $this->assertSame($expected, TestableMatomoTracker::callToStringValue($value)); + } + + /** + * @return list + */ + public static function getTestDataForToStringValue(): array + { + return [ + ['string', 'string'], + [5, '5'], + [1.5, '1.5'], + [true, '1'], + [false, ''], + [null, ''], + [['array'], ''], + [new \stdClass(), ''], + ]; + } + + public function testGetCookieMatchingNameReturnsFalseWhenNotFound(): void + { + $tracker = $this->createTracker(); + $this->assertFalse($tracker->callGetCookieMatchingName('id')); + } + + public function testGetCurrentScheme(): void + { + unset($_SERVER['HTTPS']); + $this->assertSame('http', TestableMatomoTracker::callGetCurrentScheme()); + + $_SERVER['HTTPS'] = 'on'; + $this->assertSame('https', TestableMatomoTracker::callGetCurrentScheme()); + } + + public function testGetCurrentHost(): void + { + unset($_SERVER['HTTP_HOST']); + $this->assertSame('unknown', TestableMatomoTracker::callGetCurrentHost()); + + $_SERVER['HTTP_HOST'] = 'matomo.example'; + $this->assertSame('matomo.example', TestableMatomoTracker::callGetCurrentHost()); + } + + public function testGetCurrentScriptName(): void + { + unset($_SERVER['PATH_INFO'], $_SERVER['REQUEST_URI'], $_SERVER['SCRIPT_NAME']); + $this->assertSame('/', TestableMatomoTracker::callGetCurrentScriptName()); + + $_SERVER['SCRIPT_NAME'] = 'script.php'; + $this->assertSame('/script.php', TestableMatomoTracker::callGetCurrentScriptName()); + + $_SERVER['REQUEST_URI'] = '/dir/page.php?query=1'; + $this->assertSame('/dir/page.php', TestableMatomoTracker::callGetCurrentScriptName()); + + $_SERVER['REQUEST_URI'] = '/dir/other.php'; + $this->assertSame('/dir/other.php', TestableMatomoTracker::callGetCurrentScriptName()); + + $_SERVER['PATH_INFO'] = '/path/info'; + $this->assertSame('/path/info', TestableMatomoTracker::callGetCurrentScriptName()); + } + + public function testGetCurrentQueryStringAndUrl(): void + { + unset($_SERVER['QUERY_STRING']); + $this->assertSame('', TestableMatomoTracker::callGetCurrentQueryString()); + + $_SERVER['QUERY_STRING'] = 'a=b&c=d'; + $this->assertSame('?a=b&c=d', TestableMatomoTracker::callGetCurrentQueryString()); + + $_SERVER['HTTPS'] = 'on'; + $_SERVER['HTTP_HOST'] = 'matomo.example'; + $_SERVER['PATH_INFO'] = '/page'; + $this->assertSame('https://matomo.example/page?a=b&c=d', TestableMatomoTracker::callGetCurrentUrl()); + } + + public function testHelperFunctions(): void + { + \MatomoTracker::$URL = self::TEST_URL; + + $url = \Matomo_getUrlTrackPageView(5, 'my title'); + $this->assertStringContainsString('idsite=5', $url); + $this->assertStringContainsString('&action_name=my+title', $url); + + $url = \Matomo_getUrlTrackGoal(5, 3, 1.5); + $this->assertStringContainsString('idsite=5', $url); + $this->assertStringContainsString('&idgoal=3', $url); + $this->assertStringContainsString('&revenue=1.5', $url); + } + + public function testPiwikCompatibilityShim(): void + { + \MatomoTracker::$URL = self::TEST_URL; + + $tracker = new \PiwikTracker(1, self::TEST_URL); + $this->assertInstanceOf(\MatomoTracker::class, $tracker); + + $url = \Piwik_getUrlTrackPageView(5, 'my title'); + $this->assertStringContainsString('idsite=5', $url); + + $url = \Piwik_getUrlTrackGoal(5, 3, 1.5); + $this->assertStringContainsString('&idgoal=3', $url); } } diff --git a/tests/Unit/TestableMatomoTracker.php b/tests/Unit/TestableMatomoTracker.php new file mode 100644 index 0000000..1af1945 --- /dev/null +++ b/tests/Unit/TestableMatomoTracker.php @@ -0,0 +1,171 @@ + + */ + public array $capturedRequests = []; + + public string|bool $mockResponse = 'mock-response'; + + /** + * @var list + */ + public array $capturedCookies = []; + + public bool $captureCookies = true; + + protected function sendRequest(string $url, string $method = 'GET', ?string $data = null, bool $force = false): string|bool + { + if ($this->doBulkRequests && !$force) { + return parent::sendRequest($url, $method, $data, $force); + } + + $this->capturedRequests[] = ['url' => $url, 'method' => $method, 'data' => $data, 'force' => $force]; + + return $this->mockResponse; + } + + protected function setCookie(string $cookieName, string $cookieValue, int $cookieTTL): self + { + if (!$this->captureCookies) { + return parent::setCookie($cookieName, $cookieValue, $cookieTTL); + } + + $this->capturedCookies[] = ['name' => $cookieName, 'value' => $cookieValue, 'ttl' => $cookieTTL]; + + return $this; + } + + public function lastRequestUrl(): string + { + $last = end($this->capturedRequests); + + return $last === false ? '' : $last['url']; + } + + /** + * @return array + */ + public function callPrepareCurlOptions(string $url, string $method, ?string $data, bool $forcePostUrlEncoded): array + { + return $this->prepareCurlOptions($url, $method, $data, $forcePostUrlEncoded); + } + + /** + * @return array{http: array} + */ + public function callPrepareStreamOptions(string $method, ?string $data, bool $forcePostUrlEncoded): array + { + return $this->prepareStreamOptions($method, $data, $forcePostUrlEncoded); + } + + /** + * @param array $headers + */ + public function callParseIncomingCookies(array $headers): void + { + $this->parseIncomingCookies($headers); + } + + public function callGetTimestamp(): int + { + return $this->getTimestamp(); + } + + public function callGetBaseUrl(): string + { + return $this->getBaseUrl(); + } + + public function callGetRequest(int $idSite): string + { + return $this->getRequest($idSite); + } + + public function callGetCookieMatchingName(string $name): string|false + { + return $this->getCookieMatchingName($name); + } + + public function callGetCookieName(string $name): string + { + return $this->getCookieName($name); + } + + public function callLoadVisitorIdCookie(): bool + { + return $this->loadVisitorIdCookie(); + } + + public function callSetFirstPartyCookies(): void + { + $this->setFirstPartyCookies(); + } + + /** + * @return array + */ + public function callGetCustomVariablesFromCookie(): array + { + return $this->getCustomVariablesFromCookie(); + } + + public static function callDomainFixup(string $domain): string + { + return self::domainFixup($domain); + } + + public static function callToStringValue(mixed $value): string + { + return self::toStringValue($value); + } + + public static function callGetCurrentScheme(): string + { + return self::getCurrentScheme(); + } + + public static function callGetCurrentHost(): string + { + return self::getCurrentHost(); + } + + public static function callGetCurrentScriptName(): string + { + return self::getCurrentScriptName(); + } + + public static function callGetCurrentQueryString(): string + { + return self::getCurrentQueryString(); + } + + public static function callGetCurrentUrl(): string + { + return self::getCurrentUrl(); + } +} From 54c32773b649b1af6231d09efeba82bb2bb1722c Mon Sep 17 00:00:00 2001 From: sgiehl Date: Sat, 25 Jul 2026 17:04:35 +0200 Subject: [PATCH 12/28] Improve compatibility with Matomo's tracker usage; add test-only raw param helper Findings from auditing tracker usage across Matomo core and plugins: - setUrlReferrer()/setUrlReferer() accept ?string again so callers can unset the referrer with null (used by several Matomo fixtures). - setCustomTrackingParameter() accepts string|array again; array values are serialized via http_build_query exactly as before 4.0.0, restoring multi-value parameters used by FormAnalytics and HeatmapSessionRecording. - Add setDebugTrackingParameter(string, string) (@internal): appends a raw, unvalidated parameter that overrides any built-in of the same name, so Matomo integration tests can verify server-side handling of malformed values without bypassing the tracker. Cleared per request. Adds unit tests and CHANGELOG entries for all three. --- CHANGELOG.md | 3 ++ MatomoTracker.php | 67 ++++++++++++++++++++++++++------ tests/Unit/MatomoTrackerTest.php | 34 ++++++++++++++++ 3 files changed, 93 insertions(+), 11 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index ccc73e6..6698f17 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -15,6 +15,8 @@ Attention: this is a major release with breaking changes. - Optional "unset" parameters and their corresponding properties and getters now use `null` instead of the previous `false` sentinel. For example `getUserId()`, `getUserAgent()`, `getIp()` and `getPageviewId()` now return `null` (not `false`) when no value is set, and `doTrackEvent()`/`getUrlTrackEvent()` default the event name and value to `null`. - All public properties are now natively typed. Assigning a legacy sentinel value such as `false` to e.g. `$tracker->userAgent` now throws a `TypeError`; the `attributionInfo` property defaults to an empty array instead of `false`. Subclasses overriding methods with the old untyped signatures may need to be updated to the new signatures. - `setUserId()` now accepts `null` to de-assign a previously set User ID, as the method documentation always promised (previously the `string` type hint made that impossible). +- `setUrlReferrer()` (and the deprecated `setUrlReferer()`) accept `null` to unset the referrer. +- `setCustomTrackingParameter()` accepts an array value again (serialized via `http_build_query`, as the JS tracker does); this restores the pre-4.0 behavior for multi-value parameters. - `setLatitude()` / `setLongitude()` values of `0.0` (equator / prime meridian) are now sent to Matomo. Previously coordinates of exactly zero were silently dropped. - The `do*` tracking methods now declare a `string|bool` return type. In bulk mode they return boolean `true` (previously the value was coerced to the string `"1"`). - `doTrackSiteSearch()` / `getUrlTrackSiteSearch()` accept `?int $countResults` and only send `&search_count` when a count is provided (previously `&search_count=0` was always sent). @@ -24,6 +26,7 @@ Attention: this is a major release with breaking changes. ### Added - PHPStan static analysis at max level (`phpstan.neon.dist`) and the Matomo coding standard via PHP_CodeSniffer (`phpcs.xml.dist`), both enforced for every pull request through GitHub Actions. - A greatly expanded unit test suite covering all tracking parameters, cookie handling and request preparation, with code coverage reported in CI. +- `setDebugTrackingParameter()` (`@internal` test helper) to append a raw, unvalidated tracking parameter that overrides any built-in parameter of the same name, so integration tests can verify server-side handling of malformed values. ## Matomo PHP Tracker 3.4.0 ### Changed diff --git a/MatomoTracker.php b/MatomoTracker.php index d58d47c..6a9286c 100644 --- a/MatomoTracker.php +++ b/MatomoTracker.php @@ -125,10 +125,18 @@ class MatomoTracker public array $ecommerceView = []; /** - * @var array + * @var array> */ public array $customParameters = []; + /** + * Raw tracking parameters set via setDebugTrackingParameter(), appended verbatim. + * + * @var array + * @internal + */ + public array $debugParameters = []; + /** * @var array */ @@ -330,10 +338,10 @@ public function setUrl(string $url): self /** * Sets the URL referrer used to track Referrers details for new visits. * - * @param string $url Raw URL (not URL encoded) + * @param string|null $url Raw URL (not URL encoded), or null to unset the referrer * @return $this */ - public function setUrlReferrer(string $url): self + public function setUrlReferrer(?string $url): self { $this->urlReferrer = $url; @@ -401,7 +409,7 @@ public function clearPerformanceTimings(): void * @deprecated * @ignore */ - public function setUrlReferer(string $url): self + public function setUrlReferer(?string $url): self { $this->setUrlReferrer($url); @@ -550,15 +558,16 @@ public function getCustomDimension(int $id): ?string * tracking request. * * @param string $trackingApiParameter The name of the tracking API parameter, eg 'bw_bytes' - * @param string $value Tracking parameter value that shall be sent for this tracking parameter. + * @param string|array $value Tracking parameter value that shall be sent for this tracking parameter. + * An array value is serialized the same way as the Matomo JS tracker does it (via http_build_query). * @return $this * @throws Exception */ - public function setCustomTrackingParameter(string $trackingApiParameter, string $value): self + public function setCustomTrackingParameter(string $trackingApiParameter, string|array $value): self { $matches = []; - if (preg_match('/^dimension([0-9]+)$/', $trackingApiParameter, $matches)) { + if (is_string($value) && preg_match('/^dimension([0-9]+)$/', $trackingApiParameter, $matches)) { $this->setCustomDimension((int) $matches[1], $value); return $this; @@ -577,6 +586,27 @@ public function clearCustomTrackingParameters(): void $this->customParameters = []; } + /** + * Test helper: sets a raw tracking parameter that is appended verbatim to the tracking + * request, bypassing the typed setters and any client-side validation. + * + * This is intended for integration tests (e.g. in Matomo itself) that need to verify how + * the server handles malformed or invalid parameter values. Because the parameter is + * appended last, it also overrides any built-in parameter of the same name. Like the other + * custom parameters, it is cleared after each tracking request. Not intended for production use. + * + * @internal + * @param string $name The tracking API parameter name, eg 'idsite' or '_cvar' + * @param string $value The raw value to send (may be intentionally invalid) + * @return $this + */ + public function setDebugTrackingParameter(string $name, string $value): self + { + $this->debugParameters[$name] = $value; + + return $this; + } + /** * Sets the current visitor ID to a random new one. * @return $this @@ -2027,6 +2057,15 @@ private function getProxy(): ?string return null; } + /** + * Returns the given value with any line breaks removed so it stays a single-line + * value when used in an outbound HTTP request header. + */ + private function normalizeHeaderValue(?string $value): string + { + return str_replace(["\r", "\n"], '', (string) $value); + } + /** * Used in tests to output useful error messages. * @@ -2047,13 +2086,13 @@ protected function prepareCurlOptions( ): array { $options = [ CURLOPT_URL => $url, - CURLOPT_USERAGENT => $this->userAgent ?? '', + CURLOPT_USERAGENT => $this->normalizeHeaderValue($this->userAgent), CURLOPT_HEADER => true, CURLOPT_TIMEOUT => $this->requestTimeout, CURLOPT_CONNECTTIMEOUT => $this->requestConnectTimeout, CURLOPT_RETURNTRANSFER => true, CURLOPT_HTTPHEADER => [ - 'Accept-Language: ' . ($this->acceptLanguage ?? ''), + 'Accept-Language: ' . $this->normalizeHeaderValue($this->acceptLanguage), ], ]; @@ -2111,8 +2150,8 @@ protected function prepareStreamOptions(string $method, ?string $data, bool $for $stream_options = [ 'http' => [ 'method' => $method, - 'user_agent' => $this->userAgent ?? '', - 'header' => "Accept-Language: " . ($this->acceptLanguage ?? '') . "\r\n", + 'user_agent' => $this->normalizeHeaderValue($this->userAgent), + 'header' => "Accept-Language: " . $this->normalizeHeaderValue($this->acceptLanguage) . "\r\n", 'timeout' => $this->requestTimeout, ], ]; @@ -2406,10 +2445,16 @@ protected function getRequest(int $idSite): string $url .= '&' . $param . '=' . urlencode($value); } + // Raw debug parameters are appended last so they override any built-in parameter of the same name. + foreach ($this->debugParameters as $param => $value) { + $url .= '&' . urlencode($param) . '=' . urlencode($value); + } + // Reset page level custom variables after this page view $this->ecommerceView = []; $this->pageCustomVar = []; $this->eventCustomVar = []; + $this->debugParameters = []; $this->clearCustomDimensions(); $this->clearCustomTrackingParameters(); diff --git a/tests/Unit/MatomoTrackerTest.php b/tests/Unit/MatomoTrackerTest.php index ccf568a..bcd84b3 100644 --- a/tests/Unit/MatomoTrackerTest.php +++ b/tests/Unit/MatomoTrackerTest.php @@ -802,6 +802,35 @@ public function testCustomTrackingParameters(): void $this->assertStringNotContainsString('&bw_bytes=', $url); } + public function testCustomTrackingParameterAcceptsArrayValue(): void + { + $tracker = $this->createTracker(); + // array values are serialized like the JS tracker does, via http_build_query + $tracker->setCustomTrackingParameter('forms', [['name' => 'a'], ['name' => 'b']]); + + $url = $tracker->getUrlTrackPageView('title'); + $this->assertStringContainsString('forms%5B0%5D%5Bname%5D=a', $url); + $this->assertStringContainsString('forms%5B1%5D%5Bname%5D=b', $url); + } + + public function testSetDebugTrackingParameterOverridesBuiltInParameter(): void + { + $tracker = $this->createTracker(); + // inject an intentionally invalid idsite to exercise server-side validation + $tracker->setDebugTrackingParameter('idsite', 'not-a-number'); + $tracker->setDebugTrackingParameter('_cvar', '{"1":[["bad"],"v"]}'); + + $url = $tracker->getUrlTrackPageView('title'); + // appended last so it wins over the built-in idsite=1 + $this->assertStringContainsString('&idsite=not-a-number', $url); + $this->assertStringContainsString('&_cvar=' . urlencode('{"1":[["bad"],"v"]}'), $url); + $query = self::parseQueryParams($url); + $this->assertSame('not-a-number', $query['idsite']); + + // debug parameters are cleared after a request + $this->assertStringNotContainsString('not-a-number', $tracker->getUrlTrackPageView('title')); + } + public function testVisitorIdHandling(): void { $tracker = $this->createTracker(); @@ -1011,6 +1040,11 @@ public function testUrlReferrer(): void $tracker->setUrlReferer('http://other.example'); $query = self::parseQueryParams($tracker->getUrlTrackPageView('title')); $this->assertSame('http://other.example', $query['urlref']); + + // null unsets the referrer (renders as an empty urlref) + $tracker->setUrlReferrer(null); + $query = self::parseQueryParams($tracker->getUrlTrackPageView('title')); + $this->assertSame('', $query['urlref']); } public function testSetGenerationTimeIsANoOp(): void From 1bc37b6be9e0cedcb008eaa80c337802dd07132e Mon Sep 17 00:00:00 2001 From: sgiehl Date: Sat, 25 Jul 2026 17:04:35 +0200 Subject: [PATCH 13/28] CI: harden workflows (pin setup-php, disable credential persistence) - Pin shivammathur/setup-php to a commit hash (2.37.2) instead of the floating @v2 tag, mitigating supply-chain risk (Aikido high finding). - Set persist-credentials: false on actions/checkout steps so the GITHUB_TOKEN is not left in the git config for later steps (Aikido). --- .github/workflows/phpunit.yml | 8 ++++++-- .github/workflows/quality.yml | 8 ++++++-- 2 files changed, 12 insertions(+), 4 deletions(-) diff --git a/.github/workflows/phpunit.yml b/.github/workflows/phpunit.yml index 7632799..fcf2a34 100644 --- a/.github/workflows/phpunit.yml +++ b/.github/workflows/phpunit.yml @@ -27,8 +27,10 @@ jobs: php-version: ['8.1', '8.2', '8.3', '8.4', '8.5'] steps: - uses: actions/checkout@v4 + with: + persist-credentials: false - name: Install PHP - uses: shivammathur/setup-php@v2 + uses: shivammathur/setup-php@f3e473d116dcccaddc5834248c87452386958240 # 2.37.2 with: php-version: ${{ matrix.php-version }} tools: composer:v2 @@ -46,8 +48,10 @@ jobs: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 + with: + persist-credentials: false - name: Install PHP - uses: shivammathur/setup-php@v2 + uses: shivammathur/setup-php@f3e473d116dcccaddc5834248c87452386958240 # 2.37.2 with: php-version: '8.1' tools: composer:v2 diff --git a/.github/workflows/quality.yml b/.github/workflows/quality.yml index d282109..f280782 100644 --- a/.github/workflows/quality.yml +++ b/.github/workflows/quality.yml @@ -23,8 +23,10 @@ jobs: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 + with: + persist-credentials: false - name: Install PHP - uses: shivammathur/setup-php@v2 + uses: shivammathur/setup-php@f3e473d116dcccaddc5834248c87452386958240 # 2.37.2 with: php-version: '8.1' tools: composer:v2 @@ -40,8 +42,10 @@ jobs: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 + with: + persist-credentials: false - name: Install PHP - uses: shivammathur/setup-php@v2 + uses: shivammathur/setup-php@f3e473d116dcccaddc5834248c87452386958240 # 2.37.2 with: php-version: '8.1' tools: composer:v2 From 05384bf118a48394e94c5058f55052bfe033b455 Mon Sep 17 00:00:00 2001 From: sgiehl Date: Sat, 25 Jul 2026 18:00:05 +0200 Subject: [PATCH 14/28] CI: remove the code coverage job Drop the pcov-based coverage job for now; the standard PHPUnit matrix, PHPStan and PHPCS jobs remain. --- .github/workflows/phpunit.yml | 19 ------------------- 1 file changed, 19 deletions(-) diff --git a/.github/workflows/phpunit.yml b/.github/workflows/phpunit.yml index fcf2a34..b929af7 100644 --- a/.github/workflows/phpunit.yml +++ b/.github/workflows/phpunit.yml @@ -42,22 +42,3 @@ jobs: run: | php -v ./vendor/bin/phpunit - - coverage: - name: Code coverage - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v4 - with: - persist-credentials: false - - name: Install PHP - uses: shivammathur/setup-php@f3e473d116dcccaddc5834248c87452386958240 # 2.37.2 - with: - php-version: '8.1' - tools: composer:v2 - extensions: curl - coverage: pcov - - name: "Composer install" - run: composer install --prefer-dist - - name: PHPUnit with coverage - run: ./vendor/bin/phpunit --coverage-text From faec8a4419ccb5d38ab1e2206d54c36633c5fd83 Mon Sep 17 00:00:00 2001 From: sgiehl Date: Sat, 25 Jul 2026 18:27:50 +0200 Subject: [PATCH 15/28] Distinguish unset from zero for goal/ecommerce revenue amounts Core treats an absent 'revenue' as 'use the goal's configured revenue', and defaults the optional ecommerce amounts to false (not 0), so a real zero is meaningful and must be distinguishable from 'not provided'. - doTrackGoal()/getUrlTrackGoal() (+ Matomo_/Piwik_ helpers) take ?float $revenue = null: null omits revenue, 0.0 now sends revenue=0. - doTrackEcommerceOrder()/getUrlTrackEcommerceOrder()/getUrlTrackEcommerce() take ?float $subTotal/$tax/$shipping/$discount = null, sent only when provided; the required grand total is now always sent (0.0 -> revenue=0). Previously an explicit 0/0.0 was silently dropped by the !empty() guards. Adds tests and a CHANGELOG entry. --- CHANGELOG.md | 1 + MatomoTracker.php | 74 +++++++++++++++----------------- PiwikTracker.php | 4 +- tests/Unit/MatomoTrackerTest.php | 34 ++++++++++++++- 4 files changed, 71 insertions(+), 42 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 6698f17..1191475 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -18,6 +18,7 @@ Attention: this is a major release with breaking changes. - `setUrlReferrer()` (and the deprecated `setUrlReferer()`) accept `null` to unset the referrer. - `setCustomTrackingParameter()` accepts an array value again (serialized via `http_build_query`, as the JS tracker does); this restores the pre-4.0 behavior for multi-value parameters. - `setLatitude()` / `setLongitude()` values of `0.0` (equator / prime meridian) are now sent to Matomo. Previously coordinates of exactly zero were silently dropped. +- Goal and Ecommerce revenue amounts now distinguish "not set" from an explicit `0`. `doTrackGoal()` / `getUrlTrackGoal()` (and the `Matomo_`/`Piwik_` goal helpers) take `?float $revenue = null`: `null` omits `revenue` (so Matomo uses the goal's configured revenue) while `0.0` now sends `revenue=0`. Likewise the optional Ecommerce amounts (`$subTotal`, `$tax`, `$shipping`, `$discount` of `doTrackEcommerceOrder()` etc.) are `?float = null` and only sent when provided, and the required Ecommerce grand total is now always sent (a `0.0` order/cart sends `revenue=0`). Previously an explicit `0`/`0.0` was silently omitted for all of these. - The `do*` tracking methods now declare a `string|bool` return type. In bulk mode they return boolean `true` (previously the value was coerced to the string `"1"`). - `doTrackSiteSearch()` / `getUrlTrackSiteSearch()` accept `?int $countResults` and only send `&search_count` when a count is provided (previously `&search_count=0` was always sent). - When the stream fallback is used and the request fails, `doBulkTrack()` / the `do*` methods now return `false` instead of an empty string. diff --git a/MatomoTracker.php b/MatomoTracker.php index 6a9286c..b77502a 100644 --- a/MatomoTracker.php +++ b/MatomoTracker.php @@ -1035,10 +1035,11 @@ public function doTrackSiteSearch( * Records a Goal conversion * * @param int $idGoal Id Goal to record a conversion - * @param float $revenue Revenue for this conversion + * @param float|null $revenue Revenue for this conversion. Pass null (default) to omit the + * revenue so Matomo uses the goal's configured revenue; pass 0.0 to force a zero revenue. * @return string|bool Response or true if using bulk request */ - public function doTrackGoal(int $idGoal, float $revenue = 0.0): string|bool + public function doTrackGoal(int $idGoal, ?float $revenue = null): string|bool { $url = $this->getUrlTrackGoal($idGoal, $revenue); @@ -1157,19 +1158,19 @@ public function doBulkTrack(): string|bool * This will be used to count this order only once in the event the order page is reloaded several times. * orderId must be unique for each transaction, even on different days, or the transaction will not be recorded by Matomo. * @param float $grandTotal (required) Grand Total revenue of the transaction (including tax, shipping, etc.) - * @param float $subTotal (optional) Sub total amount, typically the sum of items prices for all items in this order (before Tax and Shipping costs are applied) - * @param float $tax (optional) Tax amount for this order - * @param float $shipping (optional) Shipping amount for this order - * @param float $discount (optional) Discounted amount in this order + * @param float|null $subTotal (optional) Sub total amount, typically the sum of items prices for all items in this order (before Tax and Shipping costs are applied). Pass null to omit, 0.0 to send an explicit zero. + * @param float|null $tax (optional) Tax amount for this order + * @param float|null $shipping (optional) Shipping amount for this order + * @param float|null $discount (optional) Discounted amount in this order * @return string|bool Response or true if using bulk request */ public function doTrackEcommerceOrder( string|int $orderId, float $grandTotal, - float $subTotal = 0.0, - float $tax = 0.0, - float $shipping = 0.0, - float $discount = 0.0 + ?float $subTotal = null, + ?float $tax = null, + ?float $shipping = null, + ?float $discount = null ): string|bool { $url = $this->getUrlTrackEcommerceOrder($orderId, $grandTotal, $subTotal, $tax, $shipping, $discount); @@ -1354,10 +1355,10 @@ public function getUrlTrackEcommerceCartUpdate(float $grandTotal): string public function getUrlTrackEcommerceOrder( string|int $orderId, float $grandTotal, - float $subTotal = 0.0, - float $tax = 0.0, - float $shipping = 0.0, - float $discount = 0.0 + ?float $subTotal = null, + ?float $tax = null, + ?float $shipping = null, + ?float $discount = null ): string { if (empty($orderId)) { throw new Exception("You must specifiy an orderId for the Ecommerce order"); @@ -1378,32 +1379,26 @@ public function getUrlTrackEcommerceOrder( */ protected function getUrlTrackEcommerce( float $grandTotal, - float $subTotal = 0.0, - float $tax = 0.0, - float $shipping = 0.0, - float $discount = 0.0 + ?float $subTotal = null, + ?float $tax = null, + ?float $shipping = null, + ?float $discount = null ): string { $url = $this->getRequest($this->idSite); $url .= '&idgoal=0'; - if (!empty($grandTotal)) { - $grandTotal = $this->forceDotAsSeparatorForDecimalPoint($grandTotal); - $url .= '&revenue=' . $grandTotal; + // grandTotal is required, so it is always sent (including an explicit 0). + $url .= '&revenue=' . $this->forceDotAsSeparatorForDecimalPoint($grandTotal); + if ($subTotal !== null) { + $url .= '&ec_st=' . $this->forceDotAsSeparatorForDecimalPoint($subTotal); } - if (!empty($subTotal)) { - $subTotal = $this->forceDotAsSeparatorForDecimalPoint($subTotal); - $url .= '&ec_st=' . $subTotal; + if ($tax !== null) { + $url .= '&ec_tx=' . $this->forceDotAsSeparatorForDecimalPoint($tax); } - if (!empty($tax)) { - $tax = $this->forceDotAsSeparatorForDecimalPoint($tax); - $url .= '&ec_tx=' . $tax; + if ($shipping !== null) { + $url .= '&ec_sh=' . $this->forceDotAsSeparatorForDecimalPoint($shipping); } - if (!empty($shipping)) { - $shipping = $this->forceDotAsSeparatorForDecimalPoint($shipping); - $url .= '&ec_sh=' . $shipping; - } - if (!empty($discount)) { - $discount = $this->forceDotAsSeparatorForDecimalPoint($discount); - $url .= '&ec_dt=' . $discount; + if ($discount !== null) { + $url .= '&ec_dt=' . $this->forceDotAsSeparatorForDecimalPoint($discount); } if (!empty($this->ecommerceItems)) { $url .= '&ec_items=' . urlencode((string) json_encode($this->ecommerceItems)); @@ -1565,14 +1560,15 @@ public function getUrlTrackSiteSearch(string $keyword, string $category, ?int $c * * @see doTrackGoal() * @param int $idGoal Id Goal to record a conversion - * @param float $revenue Revenue for this conversion + * @param float|null $revenue Revenue for this conversion. Pass null (default) to omit the + * revenue so Matomo uses the goal's configured revenue; pass 0.0 to force a zero revenue. * @return string URL to matomo.php with all parameters set to track the goal conversion */ - public function getUrlTrackGoal(int $idGoal, float $revenue = 0.0): string + public function getUrlTrackGoal(int $idGoal, ?float $revenue = null): string { $url = $this->getRequest($this->idSite); $url .= '&idgoal=' . $idGoal; - if (!empty($revenue)) { + if ($revenue !== null) { $url .= '&revenue=' . $this->forceDotAsSeparatorForDecimalPoint($revenue); } @@ -2776,10 +2772,10 @@ function Matomo_getUrlTrackPageView(int $idSite, string $documentTitle = ''): st * * @param int $idSite * @param int $idGoal - * @param float $revenue + * @param float|null $revenue * @return string */ -function Matomo_getUrlTrackGoal(int $idSite, int $idGoal, float $revenue = 0.0): string +function Matomo_getUrlTrackGoal(int $idSite, int $idGoal, ?float $revenue = null): string { $tracker = new MatomoTracker($idSite); diff --git a/PiwikTracker.php b/PiwikTracker.php index df91878..a221dae 100644 --- a/PiwikTracker.php +++ b/PiwikTracker.php @@ -37,10 +37,10 @@ function Piwik_getUrlTrackPageView(int $idSite, string $documentTitle = ''): str * @deprecated * @param int $idSite * @param int $idGoal - * @param float $revenue + * @param float|null $revenue * @return string */ -function Piwik_getUrlTrackGoal(int $idSite, int $idGoal, float $revenue = 0.0): string +function Piwik_getUrlTrackGoal(int $idSite, int $idGoal, ?float $revenue = null): string { return Matomo_getUrlTrackGoal($idSite, $idGoal, $revenue); } diff --git a/tests/Unit/MatomoTrackerTest.php b/tests/Unit/MatomoTrackerTest.php index bcd84b3..a33f6ff 100644 --- a/tests/Unit/MatomoTrackerTest.php +++ b/tests/Unit/MatomoTrackerTest.php @@ -607,8 +607,40 @@ public function testGetUrlTrackEcommerceCartUpdateWithZeroTotal(): void $tracker = $this->createTracker(); $url = $tracker->getUrlTrackEcommerceCartUpdate(0.0); + // grandTotal is required, so an explicit zero total is sent as revenue=0 $this->assertStringContainsString('&idgoal=0', $url); - $this->assertStringNotContainsString('&revenue=', $url); + $this->assertStringContainsString('&revenue=0', $url); + } + + public function testGoalRevenueOmittedByDefaultButZeroIsSent(): void + { + $tracker = $this->createTracker(); + + // no revenue argument -> revenue omitted (Matomo uses the goal's configured revenue) + $this->assertStringNotContainsString('&revenue=', $tracker->getUrlTrackGoal(1)); + + // explicit 0.0 -> revenue=0 is sent (distinct from "unset") + $this->assertStringContainsString('&revenue=0', $tracker->getUrlTrackGoal(1, 0.0)); + + // a real value is sent as-is + $this->assertStringContainsString('&revenue=12.5', $tracker->getUrlTrackGoal(1, 12.5)); + } + + public function testEcommerceOptionalAmountsOmittedByDefaultButZeroIsSent(): void + { + $tracker = $this->createTracker(); + + // subtotal/tax/shipping/discount omitted when not provided + $url = $tracker->getUrlTrackEcommerceOrder('order-1', 10.0); + $this->assertStringNotContainsString('&ec_st=', $url); + $this->assertStringNotContainsString('&ec_tx=', $url); + + // explicit zeros are sent + $url = $tracker->getUrlTrackEcommerceOrder('order-2', 10.0, 0.0, 0.0, 0.0, 0.0); + $this->assertStringContainsString('&ec_st=0', $url); + $this->assertStringContainsString('&ec_tx=0', $url); + $this->assertStringContainsString('&ec_sh=0', $url); + $this->assertStringContainsString('&ec_dt=0', $url); } public function testSetEcommerceView(): void From a5543109c921e5c54aa3147794afdde1b479eaf8 Mon Sep 17 00:00:00 2001 From: sgiehl Date: Sat, 25 Jul 2026 20:17:29 +0200 Subject: [PATCH 16/28] Address open issues: URL-encode cip (#151), drop dead curl_close (#149) - URL-encode the cip (override IP) tracking parameter before adding it to the request, matching every other value and preventing tracking-parameter injection when an application sets cip from untrusted input. Fixes #151. - Remove the curl_close() call guarded by PHP_VERSION_ID < 80000: it is dead code now that PHP 8.1 is the minimum, and curl_close() is a no-op (and deprecated in PHP 8.5) on all supported versions. Fixes #149. --- CHANGELOG.md | 4 ++++ MatomoTracker.php | 7 +------ 2 files changed, 5 insertions(+), 6 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 1191475..110507f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -24,6 +24,10 @@ Attention: this is a major release with breaking changes. - When the stream fallback is used and the request fails, `doBulkTrack()` / the `do*` methods now return `false` instead of an empty string. - Bumped the test suite to PHPUnit 10.5. +### Fixed +- The `cip` (override IP) tracking parameter is now URL-encoded like every other value (#151). +- No longer calls the deprecated `curl_close()` (it was already a no-op on the supported PHP versions) (#149). + ### Added - PHPStan static analysis at max level (`phpstan.neon.dist`) and the Matomo coding standard via PHP_CodeSniffer (`phpcs.xml.dist`), both enforced for every pull request through GitHub Actions. - A greatly expanded unit test suite covering all tracking parameters, cookie handling and request preparation, with code coverage reported in CI. diff --git a/MatomoTracker.php b/MatomoTracker.php index b77502a..37f7315 100644 --- a/MatomoTracker.php +++ b/MatomoTracker.php @@ -2261,11 +2261,6 @@ protected function sendRequest(string $url, string $method = 'GET', ?string $dat $this->parseIncomingCookies(explode("\r\n", $header)); } finally { - // curl_close has no effect since PHP 8.0 - if (PHP_VERSION_ID < 80000) { - curl_close($ch); - } - ob_end_clean(); } } elseif (function_exists('stream_context_create')) { @@ -2370,7 +2365,7 @@ protected function getRequest(int $idSite): string (!empty($_GET['KEY']) ? '&KEY=' . urlencode(self::toStringValue($_GET['KEY'])) : '') . // Only allowed for Admin/Super User, token_auth required, - ((!empty($this->ip) && !empty($this->token_auth)) ? '&cip=' . $this->ip : '') . + ((!empty($this->ip) && !empty($this->token_auth)) ? '&cip=' . urlencode($this->ip) : '') . (!empty($this->userId) ? '&uid=' . urlencode($this->userId) : '') . (!empty($this->forcedDatetime) ? '&cdt=' . urlencode($this->forcedDatetime) : '') . (!empty($this->forcedNewVisit) ? '&new_visit=1' : '') . From d6d9c70ccbf2aa9fd3a13d3a723888dc538f3d71 Mon Sep 17 00:00:00 2001 From: sgiehl Date: Sat, 25 Jul 2026 20:59:32 +0200 Subject: [PATCH 17/28] Send ca=1 on event and content tracking requests (#80) Append &ca=1 (custom action) to the event and content-tracking URLs, matching the JS tracker. This prevents Matomo from falling back to recording these requests as page views when the handling plugin is disabled. Fixes #80. --- CHANGELOG.md | 1 + MatomoTracker.php | 6 ++++++ tests/Unit/MatomoTrackerTest.php | 6 ++++++ 3 files changed, 13 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 110507f..798d1ed 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -25,6 +25,7 @@ Attention: this is a major release with breaking changes. - Bumped the test suite to PHPUnit 10.5. ### Fixed +- Event and content tracking requests now send `&ca=1` (custom action), so Matomo no longer falls back to recording them as page views if the handling plugin is disabled (#80). - The `cip` (override IP) tracking parameter is now URL-encoded like every other value (#151). - No longer calls the deprecated `curl_close()` (it was already a no-op on the supported PHP versions) (#149). diff --git a/MatomoTracker.php b/MatomoTracker.php index 37f7315..1cc3166 100644 --- a/MatomoTracker.php +++ b/MatomoTracker.php @@ -1452,6 +1452,8 @@ public function getUrlTrackEvent( $url .= '&e_c=' . urlencode($category); $url .= '&e_a=' . urlencode($action); + // mark as a custom action so Matomo does not fall back to tracking it as a page view + $url .= '&ca=1'; if ($name !== null && $name !== '') { $url .= '&e_n=' . urlencode($name); @@ -1485,6 +1487,8 @@ public function getUrlTrackContentImpression( } $url .= '&c_n=' . urlencode($contentName); + // mark as a custom action so Matomo does not fall back to tracking it as a page view + $url .= '&ca=1'; if (!empty($contentPiece)) { $url .= '&c_p=' . urlencode($contentPiece); @@ -1525,6 +1529,8 @@ public function getUrlTrackContentInteraction( $url .= '&c_i=' . urlencode($interaction); $url .= '&c_n=' . urlencode($contentName); + // mark as a custom action so Matomo does not fall back to tracking it as a page view + $url .= '&ca=1'; if (!empty($contentPiece)) { $url .= '&c_p=' . urlencode($contentPiece); diff --git a/tests/Unit/MatomoTrackerTest.php b/tests/Unit/MatomoTrackerTest.php index a33f6ff..9a36cef 100644 --- a/tests/Unit/MatomoTrackerTest.php +++ b/tests/Unit/MatomoTrackerTest.php @@ -337,8 +337,12 @@ public function testGetUrlTrackEventDefaultsOmitNameAndValue(): void $this->assertStringContainsString('&e_c=cat', $url); $this->assertStringContainsString('&e_a=act', $url); + $this->assertStringContainsString('&ca=1', $url); $this->assertStringNotContainsString('&e_n=', $url); $this->assertStringNotContainsString('&e_v=', $url); + + // a plain page view is not a custom action + $this->assertStringNotContainsString('&ca=1', $tracker->getUrlTrackPageView('title')); } public function testGetUrlTrackEventWithNameAndValues(): void @@ -375,6 +379,7 @@ public function testGetUrlTrackContentImpression(): void $this->assertSame('name', $query['c_n']); $this->assertSame('piece', $query['c_p']); $this->assertSame('http://target.example', $query['c_t']); + $this->assertSame('1', $query['ca']); $url = $tracker->getUrlTrackContentImpression('name', '', null); $this->assertStringNotContainsString('&c_p=', $url); @@ -394,6 +399,7 @@ public function testGetUrlTrackContentInteraction(): void $this->assertSame('name', $query['c_n']); $this->assertSame('piece', $query['c_p']); $this->assertSame('http://target.example', $query['c_t']); + $this->assertSame('1', $query['ca']); $url = $tracker->getUrlTrackContentInteraction('click', 'name', '', null); $this->assertStringNotContainsString('&c_p=', $url); From 6893b50a76df7aea503720cfde25eeb7ddc5e810 Mon Sep 17 00:00:00 2001 From: sgiehl Date: Sat, 25 Jul 2026 21:00:52 +0200 Subject: [PATCH 18/28] Lower default request timeouts to sane in-page values (#88) Change the default requestTimeout from 600s to 5s and connect timeout from 300s to 2s, so a slow or unreachable Matomo can no longer block the calling page for up to 10 minutes. Callers can still raise them via setRequestTimeout()/setRequestConnectTimeout(). Fixes #88. --- CHANGELOG.md | 1 + MatomoTracker.php | 8 ++++---- tests/Unit/MatomoTrackerTest.php | 4 ++-- 3 files changed, 7 insertions(+), 6 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 798d1ed..0b79844 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -22,6 +22,7 @@ Attention: this is a major release with breaking changes. - The `do*` tracking methods now declare a `string|bool` return type. In bulk mode they return boolean `true` (previously the value was coerced to the string `"1"`). - `doTrackSiteSearch()` / `getUrlTrackSiteSearch()` accept `?int $countResults` and only send `&search_count` when a count is provided (previously `&search_count=0` was always sent). - When the stream fallback is used and the request fails, `doBulkTrack()` / the `do*` methods now return `false` instead of an empty string. +- Lowered the default request timeouts from 600s/300s to 5s/2s so a slow or unreachable Matomo can no longer block the calling page for minutes (#88). Raise them again via `setRequestTimeout()` / `setRequestConnectTimeout()` if needed. - Bumped the test suite to PHPUnit 10.5. ### Fixed diff --git a/MatomoTracker.php b/MatomoTracker.php index 1cc3166..90a80e7 100644 --- a/MatomoTracker.php +++ b/MatomoTracker.php @@ -228,9 +228,9 @@ class MatomoTracker public int $createTs; // Allow debug while blocking the request - public int $requestTimeout = 600; + public int $requestTimeout = 5; - public int $requestConnectTimeout = 300; + public int $requestConnectTimeout = 2; public bool $doBulkRequests = false; @@ -1969,7 +1969,7 @@ public function disableCookieSupport(): void /** * Returns the maximum number of seconds the tracker will spend waiting for a response - * from Matomo. Defaults to 600 seconds. + * from Matomo. Defaults to 5 seconds. */ public function getRequestTimeout(): int { @@ -1996,7 +1996,7 @@ public function setRequestTimeout(int $timeout): self /** * Returns the maximum number of seconds the tracker will spend trying to connect to Matomo. - * Defaults to 300 seconds. + * Defaults to 2 seconds. */ public function getRequestConnectTimeout(): int { diff --git a/tests/Unit/MatomoTrackerTest.php b/tests/Unit/MatomoTrackerTest.php index 9a36cef..7866a4e 100644 --- a/tests/Unit/MatomoTrackerTest.php +++ b/tests/Unit/MatomoTrackerTest.php @@ -1340,11 +1340,11 @@ public function testRequestTimeoutAccessors(): void { $tracker = $this->createTracker(); - $this->assertSame(600, $tracker->getRequestTimeout()); + $this->assertSame(5, $tracker->getRequestTimeout()); $tracker->setRequestTimeout(10); $this->assertSame(10, $tracker->getRequestTimeout()); - $this->assertSame(300, $tracker->getRequestConnectTimeout()); + $this->assertSame(2, $tracker->getRequestConnectTimeout()); $tracker->setRequestConnectTimeout(5); $this->assertSame(5, $tracker->getRequestConnectTimeout()); } From ffde37012e328e0d4b66ccf6b1d79a276086b81b Mon Sep 17 00:00:00 2001 From: sgiehl Date: Sat, 25 Jul 2026 21:02:52 +0200 Subject: [PATCH 19/28] Add setCurlOptions() for custom cURL options (#92) Allow callers to pass additional cURL options (e.g. CURLOPT_IPRESOLVE, CURLOPT_HTTP_VERSION) that are applied after the built-in options, so they can tune the request without the library committing to opinionated defaults. Fixes #92. --- CHANGELOG.md | 1 + MatomoTracker.php | 29 +++++++++++++++++++++++++++++ tests/Unit/MatomoTrackerTest.php | 13 +++++++++++++ 3 files changed, 43 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 0b79844..5198e98 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -34,6 +34,7 @@ Attention: this is a major release with breaking changes. - PHPStan static analysis at max level (`phpstan.neon.dist`) and the Matomo coding standard via PHP_CodeSniffer (`phpcs.xml.dist`), both enforced for every pull request through GitHub Actions. - A greatly expanded unit test suite covering all tracking parameters, cookie handling and request preparation, with code coverage reported in CI. - `setDebugTrackingParameter()` (`@internal` test helper) to append a raw, unvalidated tracking parameter that overrides any built-in parameter of the same name, so integration tests can verify server-side handling of malformed values. +- `setCurlOptions(array)` to pass additional cURL options (e.g. `CURLOPT_IPRESOLVE`, `CURLOPT_HTTP_VERSION`) for the tracking requests; they are applied after the built-in options (#92). ## Matomo PHP Tracker 3.4.0 ### Changed diff --git a/MatomoTracker.php b/MatomoTracker.php index 90a80e7..c7a236b 100644 --- a/MatomoTracker.php +++ b/MatomoTracker.php @@ -264,6 +264,13 @@ class MatomoTracker private int $proxyPort = 80; + /** + * Additional cURL options set via setCurlOptions(), applied last so they override the defaults. + * + * @var array + */ + private array $curlOptions = []; + /** * Builds a MatomoTracker object, used to track visits, pages and Goal conversions * for a specific website, by using the Matomo Tracking API. @@ -2047,6 +2054,23 @@ public function setProxy(string $proxy, int $proxyPort = 80): void $this->proxyPort = $proxyPort; } + /** + * Sets additional cURL options (a map of CURLOPT_* constant => value) for the tracking + * requests. They are applied after the built-in options, so they can extend them (e.g. + * `CURLOPT_IPRESOLVE`, `CURLOPT_HTTP_VERSION`) or override them. Only used on the cURL + * transport. Overriding core options such as CURLOPT_RETURNTRANSFER or CURLOPT_HEADER may + * break response handling, so use with care. + * + * @param array $curlOptions + * @return $this + */ + public function setCurlOptions(array $curlOptions): self + { + $this->curlOptions = $curlOptions; + + return $this; + } + /** * If the proxy IP and the proxy port have been set, with the setProxy() function * returns a string, like "173.234.92.107:80" @@ -2139,6 +2163,11 @@ protected function prepareCurlOptions( $this->outgoingTrackerCookies = []; } + // Caller-supplied options are applied last so they can extend or override the defaults. + if (!empty($this->curlOptions)) { + $options = array_replace($options, $this->curlOptions); + } + return $options; } diff --git a/tests/Unit/MatomoTrackerTest.php b/tests/Unit/MatomoTrackerTest.php index 7866a4e..75a23cb 100644 --- a/tests/Unit/MatomoTrackerTest.php +++ b/tests/Unit/MatomoTrackerTest.php @@ -1408,6 +1408,19 @@ public function testPrepareCurlOptionsWithProxyAndCookies(): void $this->assertArrayNotHasKey(CURLOPT_COOKIE, $options); } + public function testSetCurlOptionsExtendAndOverrideDefaults(): void + { + $tracker = $this->createTracker(); + $tracker->setCurlOptions([ + CURLOPT_IPRESOLVE => CURL_IPRESOLVE_V4, // extends the defaults + CURLOPT_TIMEOUT => 1, // overrides the built-in timeout + ]); + + $options = $tracker->callPrepareCurlOptions('http://example.org', 'GET', null, false); + $this->assertSame(CURL_IPRESOLVE_V4, $options[CURLOPT_IPRESOLVE]); + $this->assertSame(1, $options[CURLOPT_TIMEOUT]); + } + public function testPrepareStreamOptions(): void { $tracker = $this->createTracker(); From 5e8585828328d53dc22e9c00ac5d61cbc97f8afd Mon Sep 17 00:00:00 2001 From: sgiehl Date: Sat, 25 Jul 2026 21:05:27 +0200 Subject: [PATCH 20/28] Unify request-failure handling; add opt-in fail-safe mode (#105) Previously the cURL transport threw a RuntimeException on DNS/connection failures while the stream transport silently returned false. Both now throw by default (preserving cURL behavior and making the two transports consistent). Call setExceptionsEnabled(false) to make failed requests return false instead, so tracking never breaks the calling application. Refs #105. --- CHANGELOG.md | 2 +- MatomoTracker.php | 31 +++++++++++++++++++++++++++++-- tests/Unit/MatomoTrackerTest.php | 27 +++++++++++++++++++++++++++ 3 files changed, 57 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 5198e98..615e15a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -21,7 +21,7 @@ Attention: this is a major release with breaking changes. - Goal and Ecommerce revenue amounts now distinguish "not set" from an explicit `0`. `doTrackGoal()` / `getUrlTrackGoal()` (and the `Matomo_`/`Piwik_` goal helpers) take `?float $revenue = null`: `null` omits `revenue` (so Matomo uses the goal's configured revenue) while `0.0` now sends `revenue=0`. Likewise the optional Ecommerce amounts (`$subTotal`, `$tax`, `$shipping`, `$discount` of `doTrackEcommerceOrder()` etc.) are `?float = null` and only sent when provided, and the required Ecommerce grand total is now always sent (a `0.0` order/cart sends `revenue=0`). Previously an explicit `0`/`0.0` was silently omitted for all of these. - The `do*` tracking methods now declare a `string|bool` return type. In bulk mode they return boolean `true` (previously the value was coerced to the string `"1"`). - `doTrackSiteSearch()` / `getUrlTrackSiteSearch()` accept `?int $countResults` and only send `&search_count` when a count is provided (previously `&search_count=0` was always sent). -- When the stream fallback is used and the request fails, `doBulkTrack()` / the `do*` methods now return `false` instead of an empty string. +- Both transports now consistently throw a `RuntimeException` on request failure (DNS, connection or timeout errors) by default; previously only the cURL transport threw while the stream fallback silently returned `false`. Call `setExceptionsEnabled(false)` to make failed requests return `false` instead, so tracking never breaks the calling application (#105). - Lowered the default request timeouts from 600s/300s to 5s/2s so a slow or unreachable Matomo can no longer block the calling page for minutes (#88). Raise them again via `setRequestTimeout()` / `setRequestConnectTimeout()` if needed. - Bumped the test suite to PHPUnit 10.5. diff --git a/MatomoTracker.php b/MatomoTracker.php index c7a236b..51dbcf6 100644 --- a/MatomoTracker.php +++ b/MatomoTracker.php @@ -241,6 +241,9 @@ class MatomoTracker public bool $sendImageResponse = true; + // When true (default), failed tracking requests throw a RuntimeException; set false to return false instead. + public bool $exceptionsEnabled = true; + /** * @var array */ @@ -2071,6 +2074,23 @@ public function setCurlOptions(array $curlOptions): self return $this; } + /** + * Controls how failed tracking requests are handled. + * + * By default a request that fails to reach Matomo (DNS, connection or timeout errors) + * throws a RuntimeException. Call setExceptionsEnabled(false) to have such failures return + * false instead, so tracking never breaks the calling application. + * + * @param bool $enabled + * @return $this + */ + public function setExceptionsEnabled(bool $enabled = true): self + { + $this->exceptionsEnabled = $enabled; + + return $this; + } + /** * If the proxy IP and the proxy port have been set, with the setProxy() function * returns a string, like "173.234.92.107:80" @@ -2281,7 +2301,11 @@ protected function sendRequest(string $url, string $method = 'GET', ?string $dat if ($response === false) { $curlError = curl_error($ch); if (!empty($curlError)) { - throw new \RuntimeException($curlError); + if ($this->exceptionsEnabled) { + throw new \RuntimeException($curlError); + } + // fail-safe: a failed tracking request must not break the calling application + $content = false; } } @@ -2302,7 +2326,10 @@ protected function sendRequest(string $url, string $method = 'GET', ?string $dat $stream_options = $this->prepareStreamOptions($method, $data, $forcePostUrlEncoded); $ctx = stream_context_create($stream_options); - $response = file_get_contents($url, false, $ctx); + $response = @file_get_contents($url, false, $ctx); + if ($response === false && $this->exceptionsEnabled) { + throw new \RuntimeException('Failed to send the tracking request to ' . $url); + } $content = $response; $responseHeaders = []; diff --git a/tests/Unit/MatomoTrackerTest.php b/tests/Unit/MatomoTrackerTest.php index 75a23cb..10f7571 100644 --- a/tests/Unit/MatomoTrackerTest.php +++ b/tests/Unit/MatomoTrackerTest.php @@ -1421,6 +1421,33 @@ public function testSetCurlOptionsExtendAndOverrideDefaults(): void $this->assertSame(1, $options[CURLOPT_TIMEOUT]); } + private function makeFailingTracker(): \MatomoTracker + { + // A closed local port gives a fast, deterministic connection failure without external I/O. + $tracker = new \MatomoTracker(1, 'http://127.0.0.1:1/matomo.php'); + $tracker->disableCookieSupport(); + $tracker->setRequestConnectTimeout(1); + $tracker->setRequestTimeout(1); + + return $tracker; + } + + public function testFailedRequestThrowsByDefault(): void + { + $tracker = $this->makeFailingTracker(); + + $this->expectException(\RuntimeException::class); + $tracker->doTrackPageView('title'); + } + + public function testFailedRequestReturnsFalseWhenExceptionsDisabled(): void + { + $tracker = $this->makeFailingTracker(); + $tracker->setExceptionsEnabled(false); + + $this->assertFalse($tracker->doTrackPageView('title')); + } + public function testPrepareStreamOptions(): void { $tracker = $this->createTracker(); From cc393b93ca3bd6a72cf98b78bd5c71b06d69c502 Mon Sep 17 00:00:00 2001 From: sgiehl Date: Sat, 25 Jul 2026 22:27:33 +0200 Subject: [PATCH 21/28] Consistently encode tracking parameters; keep the request URL out of exceptions - URL-encode the remaining raw query values: _refts (from attribution data), customData (data) and pageCharset (cs), so a value can no longer inject extra tracking parameters. - Validate the visitor ID read from the first-party cookie as a 16-character hexadecimal string in loadVisitorIdCookie(), matching setVisitorId(). - Request-failure exceptions now include only the target host, never the full URL (which can carry token_auth/PII in its query string). --- CHANGELOG.md | 2 ++ MatomoTracker.php | 15 ++++++++++----- tests/Unit/MatomoTrackerTest.php | 25 +++++++++++++++++++++++++ 3 files changed, 37 insertions(+), 5 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 615e15a..5de5033 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -26,6 +26,8 @@ Attention: this is a major release with breaking changes. - Bumped the test suite to PHPUnit 10.5. ### Fixed +- All tracking parameter values are now consistently URL-encoded (including `_refts`, `data`/`customData` and `cs`/charset), and the visitor ID read from the first-party cookie is validated as a 16-character hexadecimal string. +- Request-failure exceptions no longer include the full request URL (only the target host), so its query string is never surfaced in error messages/logs. - Event and content tracking requests now send `&ca=1` (custom action), so Matomo no longer falls back to recording them as page views if the handling plugin is disabled (#80). - The `cip` (override IP) tracking parameter is now URL-encoded like every other value (#151). - No longer calls the deprecated `curl_close()` (it was already a no-op on the supported PHP versions) (#149). diff --git a/MatomoTracker.php b/MatomoTracker.php index 51dbcf6..bc97496 100644 --- a/MatomoTracker.php +++ b/MatomoTracker.php @@ -1823,7 +1823,11 @@ protected function loadVisitorIdCookie(): bool return false; } $parts = explode('.', $idCookie); - if (strlen($parts[0]) !== self::LENGTH_VISITOR_ID) { + $hexChars = '0123456789abcdefABCDEF'; + if ( + strlen($parts[0]) !== self::LENGTH_VISITOR_ID + || strspn($parts[0], $hexChars) !== self::LENGTH_VISITOR_ID + ) { return false; } @@ -2328,7 +2332,8 @@ protected function sendRequest(string $url, string $method = 'GET', ?string $dat $ctx = stream_context_create($stream_options); $response = @file_get_contents($url, false, $ctx); if ($response === false && $this->exceptionsEnabled) { - throw new \RuntimeException('Failed to send the tracking request to ' . $url); + // Only include the host (never the query string, which carries token_auth/PII) in the message. + throw new \RuntimeException('Failed to send the tracking request to ' . (parse_url($url, PHP_URL_HOST) ?: 'the Matomo server')); } $content = $response; @@ -2443,7 +2448,7 @@ protected function getRequest(int $idSite): string (!empty($this->hasCookies) ? '&cookie=' . (int) $this->hasCookies : '') . // Various important attributes - (!empty($this->customData) ? '&data=' . $this->customData : '') . + (!empty($this->customData) ? '&data=' . urlencode($this->customData) : '') . (!empty($this->visitorCustomVar) ? '&_cvar=' . urlencode((string) json_encode($this->visitorCustomVar)) : '') . (!empty($this->pageCustomVar) ? '&cvar=' . urlencode((string) json_encode($this->pageCustomVar)) : '') . (!empty($this->eventCustomVar) ? '&e_cvar=' . urlencode((string) json_encode($this->eventCustomVar)) : '') . @@ -2453,7 +2458,7 @@ protected function getRequest(int $idSite): string '&url=' . urlencode($this->pageUrl) . '&urlref=' . urlencode($this->urlReferrer ?? '') . ((!empty($this->pageCharset) && $this->pageCharset != self::DEFAULT_CHARSET_PARAMETER_VALUES) ? - '&cs=' . $this->pageCharset : '') . + '&cs=' . urlencode($this->pageCharset) : '') . // unique pageview id (!empty($this->idPageview) ? '&pv_id=' . urlencode($this->idPageview) : '') . @@ -2464,7 +2469,7 @@ protected function getRequest(int $idSite): string // Campaign keyword (!empty($this->attributionInfo[1]) ? '&_rck=' . urlencode(self::toStringValue($this->attributionInfo[1])) : '') . // Timestamp at which the referrer was set - (!empty($this->attributionInfo[2]) ? '&_refts=' . self::toStringValue($this->attributionInfo[2]) : '') . + (!empty($this->attributionInfo[2]) ? '&_refts=' . urlencode(self::toStringValue($this->attributionInfo[2])) : '') . // Referrer URL (!empty($this->attributionInfo[3]) ? '&_ref=' . urlencode(self::toStringValue($this->attributionInfo[3])) : '') . diff --git a/tests/Unit/MatomoTrackerTest.php b/tests/Unit/MatomoTrackerTest.php index 10f7571..5e0a040 100644 --- a/tests/Unit/MatomoTrackerTest.php +++ b/tests/Unit/MatomoTrackerTest.php @@ -689,6 +689,27 @@ public function testSetAttributionInfo(): void $this->assertSame('http://referrer.example', $query['_ref']); } + public function testUrlValuesAreEncodedAgainstInjection(): void + { + $tracker = $this->createTracker(); + + // _refts comes from (attacker-controlled) attribution JSON and must be encoded + $tracker->setAttributionInfo('["c","k","1&new_visit=1&cid=deadbeefdeadbeef","r"]'); + $tracker->customData = 'x&idsite=999'; + $tracker->setPageCharset('utf-8&foo=bar'); + + $url = $tracker->getUrlTrackPageView('title'); + $query = self::parseQueryParams($url); + + // injected params must land inside the encoded value, not as separate parameters + $this->assertSame('1&new_visit=1&cid=deadbeefdeadbeef', $query['_refts']); + $this->assertSame('x&idsite=999', $query['data']); + $this->assertSame('utf-8&foo=bar', $query['cs']); + $this->assertArrayNotHasKey('new_visit', $query); + $this->assertSame('1', $query['idsite']); // built-in idsite is untouched + $this->assertArrayNotHasKey('foo', $query); + } + public function testSetAttributionInfoThrowsOnInvalidJson(): void { $tracker = $this->createTracker(); @@ -918,6 +939,10 @@ public function testLoadVisitorIdCookie(): void $_COOKIE['_pk_id_1_f609'] = 'too-short.123'; $this->assertFalse($tracker->callLoadVisitorIdCookie()); + // a 16-char but non-hex id (e.g. containing injection chars) is rejected + $_COOKIE['_pk_id_1_f609'] = '&x=1&y=2&z=3&w=4.1'; + $this->assertFalse($tracker->callLoadVisitorIdCookie()); + $_COOKIE['_pk_id_1_f609'] = 'abcdef0123456789.1583291045'; $this->assertTrue($tracker->callLoadVisitorIdCookie()); $this->assertSame('abcdef0123456789', $tracker->getVisitorId()); From 50798be1026fc8b8416849a3793646ffc3d95fd1 Mon Sep 17 00:00:00 2001 From: sgiehl Date: Sat, 25 Jul 2026 22:33:02 +0200 Subject: [PATCH 22/28] Fix cookie header serialization/parsing; redact attribution exception - Build the outgoing Cookie header as url-encoded pairs joined with '; ' (was http_build_query, which joined with '&'), and parse every incoming Set-Cookie header by its first '=' so multiple cookies accumulate (parse_str previously overwrote the whole set per header and applied query-string bracket semantics). getIncomingTrackerCookie() now returns string|false. - setAttributionInfo() no longer echoes the supplied JSON in its exception message and marks the parameter #[\SensitiveParameter]. --- CHANGELOG.md | 2 ++ MatomoTracker.php | 62 +++++++++++++++++++++----------- tests/Unit/MatomoTrackerTest.php | 28 ++++++++++++--- 3 files changed, 66 insertions(+), 26 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 5de5033..9e60fcc 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -28,6 +28,8 @@ Attention: this is a major release with breaking changes. ### Fixed - All tracking parameter values are now consistently URL-encoded (including `_refts`, `data`/`customData` and `cs`/charset), and the visitor ID read from the first-party cookie is validated as a 16-character hexadecimal string. - Request-failure exceptions no longer include the full request URL (only the target host), so its query string is never surfaced in error messages/logs. +- Outgoing tracker cookies are now joined with `; ` (not `&`), and all incoming `Set-Cookie` response headers are parsed instead of only the last one; `getIncomingTrackerCookie()` returns `string|false`. +- `setAttributionInfo()` no longer includes the supplied payload in its exception message (the parameter is also marked `#[\SensitiveParameter]`). - Event and content tracking requests now send `&ca=1` (custom action), so Matomo no longer falls back to recording them as page views if the handling plugin is disabled (#80). - The `cip` (override IP) tracking parameter is now URL-encoded like every other value (#151). - No longer calls the deprecated `curl_close()` (it was already a no-op on the supported PHP versions) (#149). diff --git a/MatomoTracker.php b/MatomoTracker.php index bc97496..434e6f2 100644 --- a/MatomoTracker.php +++ b/MatomoTracker.php @@ -250,7 +250,7 @@ class MatomoTracker public array $outgoingTrackerCookies = []; /** - * @var array + * @var array */ public array $incomingTrackerCookies = []; @@ -441,11 +441,11 @@ public function setUrlReferer(?string $url): self * @throws Exception * @see function getAttributionInfo() in https://github.com/matomo-org/matomo/blob/master/js/matomo.js */ - public function setAttributionInfo(string $jsonEncoded): self + public function setAttributionInfo(#[\SensitiveParameter] string $jsonEncoded): self { $decoded = json_decode($jsonEncoded, true); if (!is_array($decoded)) { - throw new Exception("setAttributionInfo() is expecting a JSON encoded string, $jsonEncoded given"); + throw new Exception("setAttributionInfo() is expecting a JSON encoded string"); } $this->attributionInfo = $decoded; @@ -2116,6 +2116,20 @@ private function normalizeHeaderValue(?string $value): string return str_replace(["\r", "\n"], '', (string) $value); } + /** + * Builds a single-line Cookie header value ("a=1; b=2") from the outgoing tracker cookies, + * URL-encoding each name and value. + */ + private function buildOutgoingCookieHeader(): string + { + $pairs = []; + foreach ($this->outgoingTrackerCookies as $name => $value) { + $pairs[] = urlencode((string) $name) . '=' . urlencode($value); + } + + return implode('; ', $pairs); + } + /** * Used in tests to output useful error messages. * @@ -2183,7 +2197,7 @@ protected function prepareCurlOptions( } if (!empty($this->outgoingTrackerCookies)) { - $options[CURLOPT_COOKIE] = http_build_query($this->outgoingTrackerCookies); + $options[CURLOPT_COOKIE] = $this->buildOutgoingCookieHeader(); $this->outgoingTrackerCookies = []; } @@ -2226,7 +2240,7 @@ protected function prepareStreamOptions(string $method, ?string $data, bool $for } if (!empty($this->outgoingTrackerCookies)) { - $stream_options['http']['header'] .= 'Cookie: ' . http_build_query($this->outgoingTrackerCookies) . "\r\n"; + $stream_options['http']['header'] .= 'Cookie: ' . $this->buildOutgoingCookieHeader() . "\r\n"; $this->outgoingTrackerCookies = []; } @@ -2760,9 +2774,9 @@ public function setOutgoingTrackerCookie(string $name, ?string $value): void * * @param string $name * - * @return mixed The cookie value, or false if no cookie with the given name was received. + * @return string|false The cookie value, or false if no cookie with the given name was received. */ - public function getIncomingTrackerCookie(string $name): mixed + public function getIncomingTrackerCookie(string $name): string|false { return $this->incomingTrackerCookies[$name] ?? false; } @@ -2776,22 +2790,28 @@ protected function parseIncomingCookies(array $headers): void { $this->incomingTrackerCookies = []; - if (!empty($headers)) { - $headerName = 'set-cookie:'; - $headerNameLength = strlen($headerName); + $headerName = 'set-cookie:'; + $headerNameLength = strlen($headerName); - foreach ($headers as $header) { - $header = self::toStringValue($header); - if (strpos(strtolower($header), $headerName) !== 0) { - continue; - } - $cookies = trim(substr($header, $headerNameLength)); - $posEnd = strpos($cookies, ';'); - if ($posEnd !== false) { - $cookies = substr($cookies, 0, $posEnd); - } - parse_str($cookies, $this->incomingTrackerCookies); + foreach ($headers as $header) { + $header = self::toStringValue($header); + if (strpos(strtolower($header), $headerName) !== 0) { + continue; + } + $cookie = trim(substr($header, $headerNameLength)); + $posEnd = strpos($cookie, ';'); + if ($posEnd !== false) { + $cookie = substr($cookie, 0, $posEnd); + } + // Parse only the first "=" so each cookie accumulates (parse_str would overwrite the + // whole set per header and apply query-string bracket semantics to the names). + $eqPos = strpos($cookie, '='); + if ($eqPos === false) { + continue; } + $name = urldecode(trim(substr($cookie, 0, $eqPos))); + $value = urldecode(trim(substr($cookie, $eqPos + 1))); + $this->incomingTrackerCookies[$name] = $value; } } diff --git a/tests/Unit/MatomoTrackerTest.php b/tests/Unit/MatomoTrackerTest.php index 5e0a040..b219de0 100644 --- a/tests/Unit/MatomoTrackerTest.php +++ b/tests/Unit/MatomoTrackerTest.php @@ -710,14 +710,19 @@ public function testUrlValuesAreEncodedAgainstInjection(): void $this->assertArrayNotHasKey('foo', $query); } - public function testSetAttributionInfoThrowsOnInvalidJson(): void + public function testSetAttributionInfoThrowsOnInvalidJsonWithoutLeakingPayload(): void { $tracker = $this->createTracker(); + $payload = 'not-json-with-secret@example.com'; - $this->expectException(Exception::class); - $this->expectExceptionMessage('JSON encoded string'); - - $tracker->setAttributionInfo('not-json'); + try { + $tracker->setAttributionInfo($payload); + $this->fail('Expected an exception'); + } catch (Exception $e) { + $this->assertStringContainsString('JSON encoded string', $e->getMessage()); + // the (potentially PII-bearing) payload must not appear in the message + $this->assertStringNotContainsString($payload, $e->getMessage()); + } } public function testGetAttributionInfoFromCookie(): void @@ -1516,6 +1521,16 @@ public function testOutgoingTrackerCookieCanBeRemoved(): void $this->assertSame([], $tracker->outgoingTrackerCookies); } + public function testOutgoingCookiesAreJoinedWithSemicolon(): void + { + $tracker = $this->createTracker(); + $tracker->setOutgoingTrackerCookie('a', '1'); + $tracker->setOutgoingTrackerCookie('b', '2'); + + $options = $tracker->callPrepareCurlOptions('http://example.org', 'GET', null, false); + $this->assertSame('a=1; b=2', $options[CURLOPT_COOKIE]); + } + public function testParseIncomingCookies(): void { $tracker = $this->createTracker(); @@ -1523,10 +1538,13 @@ public function testParseIncomingCookies(): void $tracker->callParseIncomingCookies([ 'Content-Type: text/plain', 'Set-Cookie: first=value1; path=/; HttpOnly', + 'Set-Cookie: second=value2; path=/', 12345, ]); + // multiple Set-Cookie headers all accumulate (previously only the last survived) $this->assertSame('value1', $tracker->getIncomingTrackerCookie('first')); + $this->assertSame('value2', $tracker->getIncomingTrackerCookie('second')); $this->assertFalse($tracker->getIncomingTrackerCookie('missing')); $tracker->callParseIncomingCookies([]); From e6b94a6d7f15df1467f3993470712bbb137170f4 Mon Sep 17 00:00:00 2001 From: sgiehl Date: Sun, 26 Jul 2026 01:45:44 +0200 Subject: [PATCH 23/28] Address security-review findings (sensitive params, POST token, transports, bulk) - Mark sendRequest() $url and $data as #[\SensitiveParameter] so they are redacted from exception stack traces (token_auth/PII). - Force POST when token_auth is placed in the request body, so the stream transport no longer sends it as a GET body that Matomo ignores. - Stream transport sets ignore_errors so HTTP 4xx/5xx return the response body (parity with cURL) instead of a failure. - Bulk tracking uses a >=30s timeout and retains the queued actions when a batch fails to send (previously discarded), allowing retry. - Docs: clarify setUserId(null) does not unlink the current server-side visit; drop the stale 'coverage reported in CI' CHANGELOG claim. Adds tests for all of the above. --- CHANGELOG.md | 7 +++- MatomoTracker.php | 37 ++++++++++++++--- tests/Unit/MatomoTrackerTest.php | 61 ++++++++++++++++++++++++++++ tests/Unit/TestableMatomoTracker.php | 10 ++++- 4 files changed, 106 insertions(+), 9 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 9e60fcc..d2285a4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -27,7 +27,10 @@ Attention: this is a major release with breaking changes. ### Fixed - All tracking parameter values are now consistently URL-encoded (including `_refts`, `data`/`customData` and `cs`/charset), and the visitor ID read from the first-party cookie is validated as a 16-character hexadecimal string. -- Request-failure exceptions no longer include the full request URL (only the target host), so its query string is never surfaced in error messages/logs. +- Request-failure exceptions no longer include the full request URL (only the target host), so its query string is never surfaced in error messages/logs. The request URL and body are also marked `#[\SensitiveParameter]` so they are redacted from exception stack traces. +- Authenticated requests that carry `token_auth` in the request body are now sent as `POST`; previously the stream transport sent them as `GET`, so Matomo ignored the token in the body. +- The stream transport now returns the response body for HTTP 4xx/5xx responses (like cURL) instead of turning them into a failure. +- Bulk tracking uses a more generous request timeout (at least 30s) and no longer discards the queued actions when a batch fails to send, so the batch can be retried. - Outgoing tracker cookies are now joined with `; ` (not `&`), and all incoming `Set-Cookie` response headers are parsed instead of only the last one; `getIncomingTrackerCookie()` returns `string|false`. - `setAttributionInfo()` no longer includes the supplied payload in its exception message (the parameter is also marked `#[\SensitiveParameter]`). - Event and content tracking requests now send `&ca=1` (custom action), so Matomo no longer falls back to recording them as page views if the handling plugin is disabled (#80). @@ -36,7 +39,7 @@ Attention: this is a major release with breaking changes. ### Added - PHPStan static analysis at max level (`phpstan.neon.dist`) and the Matomo coding standard via PHP_CodeSniffer (`phpcs.xml.dist`), both enforced for every pull request through GitHub Actions. -- A greatly expanded unit test suite covering all tracking parameters, cookie handling and request preparation, with code coverage reported in CI. +- A greatly expanded unit test suite covering all tracking parameters, cookie handling and request preparation. - `setDebugTrackingParameter()` (`@internal` test helper) to append a raw, unvalidated tracking parameter that overrides any built-in parameter of the same name, so integration tests can verify server-side handling of malformed values. - `setCurlOptions(array)` to pass additional cURL options (e.g. `CURLOPT_IPRESOLVE`, `CURLOPT_HTTP_VERSION`) for the tracking requests; they are applied after the built-in options (#92). diff --git a/MatomoTracker.php b/MatomoTracker.php index 434e6f2..8cc4a28 100644 --- a/MatomoTracker.php +++ b/MatomoTracker.php @@ -83,6 +83,9 @@ class MatomoTracker public const DEFAULT_COOKIE_PATH = '/'; + // Minimum request timeout (seconds) used for bulk tracking requests, which can be large. + public const DEFAULT_BULK_REQUEST_TIMEOUT = 30; + /** * @var list, 3: string, 4: int}> */ @@ -1150,9 +1153,23 @@ public function doBulkTrack(): string|bool if ($postData === false) { throw new Exception("Failed to JSON encode the bulk tracking request"); } - $response = $this->sendRequest($this->getBaseUrl(), 'POST', $postData, true); - $this->storedTrackingActions = []; + // Bulk imports can carry many actions and take longer than a single in-page request, so + // give them a more generous timeout (never below the caller-configured value). + $originalTimeout = $this->requestTimeout; + $this->requestTimeout = max($this->requestTimeout, self::DEFAULT_BULK_REQUEST_TIMEOUT); + try { + $response = $this->sendRequest($this->getBaseUrl(), 'POST', $postData, true); + } finally { + $this->requestTimeout = $originalTimeout; + } + + // Only drop the queued actions once they were sent successfully, so a failed batch (in + // fail-safe mode, where sendRequest returns false) can be retried by calling doBulkTrack() + // again instead of being silently lost. + if ($response !== false) { + $this->storedTrackingActions = []; + } return $response; } @@ -1706,7 +1723,10 @@ public function setIp(string $ip): self * * A User ID can be a username, UUID or an email address, or any number or string that uniquely identifies a user or client. * - * @param string|null $userId Any user ID string (eg. email address, ID, username). Must be non-empty. Set to null to de-assign a user id previously set. + * @param string|null $userId Any user ID string (eg. email address, ID, username). Must be non-empty. + * Set to null to stop sending a User ID on subsequent requests. Note this does not retroactively + * remove the User ID from the visitor's current Matomo visit; for logout isolation, also start a + * new visit with a fresh visitor id (see setForceNewVisit() / setVisitorId()). * @return $this * @throws Exception */ @@ -2222,6 +2242,9 @@ protected function prepareStreamOptions(string $method, ?string $data, bool $for 'user_agent' => $this->normalizeHeaderValue($this->userAgent), 'header' => "Accept-Language: " . $this->normalizeHeaderValue($this->acceptLanguage) . "\r\n", 'timeout' => $this->requestTimeout, + // Return the response body for HTTP error codes (4xx/5xx) instead of returning + // false, so this transport behaves like cURL, which also returns the error body. + 'ignore_errors' => true, ], ]; @@ -2250,7 +2273,7 @@ protected function prepareStreamOptions(string $method, ?string $data, bool $for /** * @ignore */ - protected function sendRequest(string $url, string $method = 'GET', ?string $data = null, bool $force = false): string|bool + protected function sendRequest(#[\SensitiveParameter] string $url, string $method = 'GET', #[\SensitiveParameter] ?string $data = null, bool $force = false): string|bool { self::$DEBUG_LAST_REQUESTED_URL = $url; @@ -2289,8 +2312,12 @@ protected function sendRequest(string $url, string $method = 'GET', ?string $dat $appendTokenString = '&token_auth=' . urlencode($this->token_auth); if (empty($this->requestMethod) || $method === 'POST') { - // Only post token_auth but use GET URL parameters for everything else + // Only post token_auth but use GET URL parameters for everything else. + // The request must actually be a POST, otherwise Matomo reads $_GET/$_POST and + // never sees a token sent in the body (this matters on the stream transport; + // cURL forces POST via CURLOPT_POST below). $forcePostUrlEncoded = true; + $method = 'POST'; if (empty($data)) { $data = ''; } diff --git a/tests/Unit/MatomoTrackerTest.php b/tests/Unit/MatomoTrackerTest.php index b219de0..72d08cc 100644 --- a/tests/Unit/MatomoTrackerTest.php +++ b/tests/Unit/MatomoTrackerTest.php @@ -1338,6 +1338,8 @@ public function testDoBulkTrackSendsAllStoredRequests(): void $this->assertSame('http://mymatomo.com/matomo.php', $request['url']); $this->assertSame('POST', $request['method']); $this->assertTrue($request['force']); + // bulk requests use the more generous bulk timeout + $this->assertGreaterThanOrEqual(\MatomoTracker::DEFAULT_BULK_REQUEST_TIMEOUT, $request['timeout']); $this->assertIsString($request['data']); $data = json_decode($request['data'], true); @@ -1347,6 +1349,65 @@ public function testDoBulkTrackSendsAllStoredRequests(): void $this->assertCount(2, $data['requests']); } + public function testDoBulkTrackRetainsBatchOnFailureAndRestoresTimeout(): void + { + $tracker = $this->createTracker(); + $tracker->mockResponse = false; // simulate a failed send + $tracker->enableBulkTracking(); + $tracker->doTrackPageView('page'); + $originalTimeout = $tracker->getRequestTimeout(); + + $this->assertFalse($tracker->doBulkTrack()); + // the batch is kept so the caller can retry, and the (temporarily raised) timeout is restored + $this->assertCount(1, $tracker->storedTrackingActions); + $this->assertSame($originalTimeout, $tracker->getRequestTimeout()); + } + + public function testTokenAuthRequestIsSentAsPost(): void + { + // capture the transport method after sendRequest() has applied its token/method handling + $captured = new class (1, 'http://matomo.example/matomo.php') extends \MatomoTracker { + public string $capturedMethod = ''; + + protected function prepareCurlOptions(string $url, string $method, ?string $data, bool $forcePostUrlEncoded): array + { + $this->capturedMethod = $method; + throw new \RuntimeException('stop-before-network'); + } + }; + $captured->disableCookieSupport(); + $captured->setTokenAuth('0123456789abcdef0123456789abcdef'); + + try { + $captured->doTrackPageView('page'); + $this->fail('expected the network short-circuit'); + } catch (\RuntimeException $e) { + $this->assertSame('stop-before-network', $e->getMessage()); + } + + // with a token and no explicit request method, the request must be POSTed so Matomo + // reads token_auth from the body instead of ignoring a GET body + $this->assertSame('POST', $captured->capturedMethod); + } + + public function testSendRequestMarksUrlAndDataAsSensitive(): void + { + $method = new \ReflectionMethod(\MatomoTracker::class, 'sendRequest'); + $sensitive = []; + foreach ($method->getParameters() as $p) { + $sensitive[$p->getName()] = count($p->getAttributes(\SensitiveParameter::class)) > 0; + } + $this->assertTrue($sensitive['url']); + $this->assertTrue($sensitive['data']); + } + + public function testStreamOptionsIgnoreHttpErrors(): void + { + $tracker = $this->createTracker(); + $options = $tracker->callPrepareStreamOptions('GET', null, false); + $this->assertTrue($options['http']['ignore_errors']); + } + public function testDoBulkTrackThrowsWithoutStoredRequests(): void { $tracker = $this->createTracker(); diff --git a/tests/Unit/TestableMatomoTracker.php b/tests/Unit/TestableMatomoTracker.php index 1af1945..77d0261 100644 --- a/tests/Unit/TestableMatomoTracker.php +++ b/tests/Unit/TestableMatomoTracker.php @@ -25,7 +25,7 @@ class TestableMatomoTracker extends \MatomoTracker { /** - * @var list + * @var list */ public array $capturedRequests = []; @@ -44,7 +44,13 @@ protected function sendRequest(string $url, string $method = 'GET', ?string $dat return parent::sendRequest($url, $method, $data, $force); } - $this->capturedRequests[] = ['url' => $url, 'method' => $method, 'data' => $data, 'force' => $force]; + $this->capturedRequests[] = [ + 'url' => $url, + 'method' => $method, + 'data' => $data, + 'force' => $force, + 'timeout' => $this->requestTimeout, + ]; return $this->mockResponse; } From 8c1c17a9210cb1bdd9d87b68ed16e0d5ccac7cef Mon Sep 17 00:00:00 2001 From: sgiehl Date: Mon, 27 Jul 2026 12:47:01 +0200 Subject: [PATCH 24/28] Encode the action type and harden the transport selection - getUrlTrackAction()/doTrackAction() now URL-encode the action type (not just the value), so a crafted or plugin-specific action parameter cannot inject an additional query-string parameter. - Extract the visitor-id hex character set into a single HEX_CHARACTERS constant used by both setVisitorId() and loadVisitorIdCookie(). - Make cURL detection overridable via hasCurlSupport() so the stream transport can be exercised in tests; add coverage for the stream failure (host-only exception message) and fail-safe paths. --- MatomoTracker.php | 44 ++++++++++++++++++-------- tests/Unit/MatomoTrackerTest.php | 53 ++++++++++++++++++++++++++++++++ 2 files changed, 84 insertions(+), 13 deletions(-) diff --git a/MatomoTracker.php b/MatomoTracker.php index 8cc4a28..4c01d7c 100644 --- a/MatomoTracker.php +++ b/MatomoTracker.php @@ -86,6 +86,9 @@ class MatomoTracker // Minimum request timeout (seconds) used for bulk tracking requests, which can be large. public const DEFAULT_BULK_REQUEST_TIMEOUT = 30; + // Hexadecimal characters allowed in a visitor ID. + private const HEX_CHARACTERS = '0123456789abcdefABCDEF'; + /** * @var list, 3: string, 4: int}> */ @@ -133,7 +136,8 @@ class MatomoTracker public array $customParameters = []; /** - * Raw tracking parameters set via setDebugTrackingParameter(), appended verbatim. + * Raw tracking parameters set via setDebugTrackingParameter(). Their names and values are + * URL-encoded and appended after the built-in parameters, overriding any of the same name. * * @var array * @internal @@ -600,13 +604,14 @@ public function clearCustomTrackingParameters(): void } /** - * Test helper: sets a raw tracking parameter that is appended verbatim to the tracking - * request, bypassing the typed setters and any client-side validation. + * Test helper: sets a raw tracking parameter, bypassing the typed setters and any + * client-side validation, so integration tests (e.g. in Matomo itself) can verify how the + * server handles malformed or invalid parameter values. * - * This is intended for integration tests (e.g. in Matomo itself) that need to verify how - * the server handles malformed or invalid parameter values. Because the parameter is - * appended last, it also overrides any built-in parameter of the same name. Like the other - * custom parameters, it is cleared after each tracking request. Not intended for production use. + * The name and value are URL-encoded (like any other parameter) and appended after the + * built-in parameters, so this overrides any built-in parameter of the same name. Send a + * value that is invalid once decoded server-side (raw bytes are not sent unencoded). Like + * the other custom parameters, it is cleared after each tracking request. Not for production use. * * @internal * @param string $name The tracking API parameter name, eg 'idsite' or '_cvar' @@ -1613,13 +1618,14 @@ public function getUrlTrackGoal(int $idGoal, ?float $revenue = null): string * * @see doTrackAction() * @param string $actionUrl URL of the download or outlink - * @param string $actionType Type of the action: 'download' or 'link' + * @param string $actionType Type of the action, usually 'download' or 'link' (a plugin may + * define its own action parameter, so the value is URL-encoded rather than restricted). * @return string URL to matomo.php with all parameters set to track an action */ public function getUrlTrackAction(string $actionUrl, string $actionType): string { $url = $this->getRequest($this->idSite); - $url .= '&' . $actionType . '=' . urlencode($actionUrl); + $url .= '&' . urlencode($actionType) . '=' . urlencode($actionUrl); return $url; } @@ -1764,7 +1770,7 @@ public static function getUserIdHashed(string $id): string */ public function setVisitorId(string $visitorId): self { - $hexChars = '01234567890abcdefABCDEF'; + $hexChars = self::HEX_CHARACTERS; if ( strlen($visitorId) !== self::LENGTH_VISITOR_ID || strspn($visitorId, $hexChars) !== strlen($visitorId) @@ -1843,7 +1849,7 @@ protected function loadVisitorIdCookie(): bool return false; } $parts = explode('.', $idCookie); - $hexChars = '0123456789abcdefABCDEF'; + $hexChars = self::HEX_CHARACTERS; if ( strlen($parts[0]) !== self::LENGTH_VISITOR_ID || strspn($parts[0], $hexChars) !== self::LENGTH_VISITOR_ID @@ -2150,6 +2156,17 @@ private function buildOutgoingCookieHeader(): string return implode('; ', $pairs); } + /** + * Whether the cURL extension is available. Used to choose the transport in sendRequest(); + * overridable so the stream fallback can be exercised in tests. + * + * @ignore + */ + protected function hasCurlSupport(): bool + { + return function_exists('curl_init') && function_exists('curl_exec'); + } + /** * Used in tests to output useful error messages. * @@ -2332,7 +2349,7 @@ protected function sendRequest(#[\SensitiveParameter] string $url, string $metho $content = ''; - if (function_exists('curl_init') && function_exists('curl_exec')) { + if ($this->hasCurlSupport()) { $options = $this->prepareCurlOptions($url, $method, $data, $forcePostUrlEncoded); $ch = curl_init(); @@ -2385,7 +2402,8 @@ protected function sendRequest(#[\SensitiveParameter] string $url, string $metho $responseHeaders = $headers; } } elseif ($response !== false) { - // $http_response_header is only defined when an HTTP response was received + // PHP populates $http_response_header in the local scope whenever an HTTP response + // was received; the $response !== false guard guarantees that is the case here. $responseHeaders = $http_response_header; } diff --git a/tests/Unit/MatomoTrackerTest.php b/tests/Unit/MatomoTrackerTest.php index 72d08cc..a69f962 100644 --- a/tests/Unit/MatomoTrackerTest.php +++ b/tests/Unit/MatomoTrackerTest.php @@ -491,6 +491,59 @@ public function testGetUrlTrackActionAndDoTrackAction(): void $this->assertStringContainsString('&link=' . urlencode('http://example.org/out'), $tracker->lastRequestUrl()); } + public function testGetUrlTrackActionEncodesTheActionType(): void + { + $tracker = $this->createTracker(); + + // A crafted action type must be URL-encoded into a single parameter name and must not be + // able to inject an additional query-string parameter of its own. + $url = $tracker->getUrlTrackAction('http://example.org/file.zip', 'download&extra=1'); + + $this->assertStringContainsString('&' . urlencode('download&extra=1') . '=', $url); + $this->assertStringNotContainsString('&extra=1', $url); + } + + /** + * @return \MatomoTracker a tracker that always uses the stream transport (no cURL) + */ + private function createStreamTracker(string $apiUrl): \MatomoTracker + { + $tracker = new class (1, $apiUrl) extends \MatomoTracker { + protected function hasCurlSupport(): bool + { + return false; + } + }; + $tracker->setUrl('http://somesite.com'); + + return $tracker; + } + + public function testStreamTransportThrowsHostOnlyMessageOnFailure(): void + { + // Port 1 on loopback refuses the connection immediately, so the stream transport fails fast. + $tracker = $this->createStreamTracker('http://127.0.0.1:1/matomo.php'); + $tracker->setTokenAuth(str_repeat('a', 32)); + + try { + $tracker->doTrackPageView('secret title'); + $this->fail('Expected a RuntimeException from the failing stream request.'); + } catch (\RuntimeException $e) { + $this->assertStringContainsString('127.0.0.1', $e->getMessage()); + // The query string (which carries token_auth and other PII) must never leak into the message. + $this->assertStringNotContainsString('token_auth', $e->getMessage()); + $this->assertStringNotContainsString('action_name', $e->getMessage()); + } + } + + public function testStreamTransportFailSafeReturnsFalseWhenExceptionsDisabled(): void + { + $tracker = $this->createStreamTracker('http://127.0.0.1:1/matomo.php'); + $tracker->setExceptionsEnabled(false); + + $this->assertFalse($tracker->doTrackPageView('some title')); + } + public function testGetUrlTrackCrash(): void { $tracker = $this->createTracker(); From 693169a785eeb96d0bc231be4bfd366227bda314 Mon Sep 17 00:00:00 2001 From: sgiehl Date: Mon, 27 Jul 2026 12:47:09 +0200 Subject: [PATCH 25/28] Document error handling/timeouts and clarify config comments - README: add an 'Error handling and timeouts' section covering the default exceptions, the short 5s/2s timeouts, setExceptionsEnabled(), setRequestTimeout()/setRequestConnectTimeout() and bulk retention. - CHANGELOG: correct the multi-value BC note to reference pre-3.4.0 and mention that the download/link action type is now URL-encoded. - phpcs.xml.dist: explain that the 400-char line limit matches Matomo core's own coding standard rather than being an arbitrary value. --- CHANGELOG.md | 4 ++-- README.md | 23 +++++++++++++++++++++++ phpcs.xml.dist | 6 +++++- 3 files changed, 30 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index d2285a4..5a69a80 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -16,7 +16,7 @@ Attention: this is a major release with breaking changes. - All public properties are now natively typed. Assigning a legacy sentinel value such as `false` to e.g. `$tracker->userAgent` now throws a `TypeError`; the `attributionInfo` property defaults to an empty array instead of `false`. Subclasses overriding methods with the old untyped signatures may need to be updated to the new signatures. - `setUserId()` now accepts `null` to de-assign a previously set User ID, as the method documentation always promised (previously the `string` type hint made that impossible). - `setUrlReferrer()` (and the deprecated `setUrlReferer()`) accept `null` to unset the referrer. -- `setCustomTrackingParameter()` accepts an array value again (serialized via `http_build_query`, as the JS tracker does); this restores the pre-4.0 behavior for multi-value parameters. +- `setCustomTrackingParameter()` accepts an array value again (serialized via `http_build_query`, as the JS tracker does); this restores the pre-3.4.0 behavior for multi-value parameters. - `setLatitude()` / `setLongitude()` values of `0.0` (equator / prime meridian) are now sent to Matomo. Previously coordinates of exactly zero were silently dropped. - Goal and Ecommerce revenue amounts now distinguish "not set" from an explicit `0`. `doTrackGoal()` / `getUrlTrackGoal()` (and the `Matomo_`/`Piwik_` goal helpers) take `?float $revenue = null`: `null` omits `revenue` (so Matomo uses the goal's configured revenue) while `0.0` now sends `revenue=0`. Likewise the optional Ecommerce amounts (`$subTotal`, `$tax`, `$shipping`, `$discount` of `doTrackEcommerceOrder()` etc.) are `?float = null` and only sent when provided, and the required Ecommerce grand total is now always sent (a `0.0` order/cart sends `revenue=0`). Previously an explicit `0`/`0.0` was silently omitted for all of these. - The `do*` tracking methods now declare a `string|bool` return type. In bulk mode they return boolean `true` (previously the value was coerced to the string `"1"`). @@ -26,7 +26,7 @@ Attention: this is a major release with breaking changes. - Bumped the test suite to PHPUnit 10.5. ### Fixed -- All tracking parameter values are now consistently URL-encoded (including `_refts`, `data`/`customData` and `cs`/charset), and the visitor ID read from the first-party cookie is validated as a 16-character hexadecimal string. +- All tracking parameter names and values are now consistently URL-encoded (including `_refts`, `data`/`customData`, `cs`/charset and the `download`/`link` action type passed to `getUrlTrackAction()`/`doTrackAction()`), and the visitor ID read from the first-party cookie is validated as a 16-character hexadecimal string. - Request-failure exceptions no longer include the full request URL (only the target host), so its query string is never surfaced in error messages/logs. The request URL and body are also marked `#[\SensitiveParameter]` so they are redacted from exception stack traces. - Authenticated requests that carry `token_auth` in the request body are now sent as `POST`; previously the stream transport sent them as `GET`, so Matomo ignored the token in the body. - The stream transport now returns the response body for HTTP 4xx/5xx responses (like cURL) instead of turning them into a failure. diff --git a/README.md b/README.md index ce753b8..8acbf67 100644 --- a/README.md +++ b/README.md @@ -49,6 +49,29 @@ Alternatively, you can download the files and require the Matomo tracker manuall require_once("MatomoTracker.php"); ``` +## Error handling and timeouts + +By default a tracking request that fails to reach Matomo (DNS, connection or timeout errors) +throws a `RuntimeException`, so if you call the tracker inline in a page you should either wrap +it in a `try`/`catch` or opt into fail-safe behavior: + +```php +$matomoTracker->setExceptionsEnabled(false); // failed requests return false instead of throwing +``` + +The default timeouts are intentionally short so a slow or unreachable Matomo cannot block the +calling page for long: **5 seconds** total and **2 seconds** to connect. Raise them for slow +endpoints or large synchronous imports: + +```php +$matomoTracker->setRequestTimeout(30); // seconds, total +$matomoTracker->setRequestConnectTimeout(5); // seconds, connect +``` + +Combined, this means an unreachable Matomo throws (or, in fail-safe mode, returns `false`) after +at most a few seconds rather than hanging the request. Bulk tracking (`doBulkTrack()`) uses a more +generous timeout automatically and keeps the queued actions if a batch fails so it can be retried. + ## Development Install the development dependencies with Composer and use the provided scripts: diff --git a/phpcs.xml.dist b/phpcs.xml.dist index 94b8ff2..8e2d458 100644 --- a/phpcs.xml.dist +++ b/phpcs.xml.dist @@ -12,7 +12,11 @@ - + From c089a9c60deef619f5808ab225651494029534ce Mon Sep 17 00:00:00 2001 From: sgiehl Date: Mon, 27 Jul 2026 15:27:44 +0200 Subject: [PATCH 26/28] Mark request URL/body sensitive in the transport option builders sendRequest() appends token_auth into the URL/body and passes them to prepareCurlOptions()/prepareStreamOptions(). #[\SensitiveParameter] only redacts the frame where the parameter is declared, so mark those params too: should either builder (or something it calls) ever throw, the token and PII carried in $url/$data are redacted from that frame as well. --- MatomoTracker.php | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/MatomoTracker.php b/MatomoTracker.php index 4c01d7c..032dd02 100644 --- a/MatomoTracker.php +++ b/MatomoTracker.php @@ -2180,9 +2180,9 @@ protected function hasCurlSupport(): bool * @return array */ protected function prepareCurlOptions( - string $url, + #[\SensitiveParameter] string $url, string $method, - ?string $data, + #[\SensitiveParameter] ?string $data, bool $forcePostUrlEncoded ): array { $options = [ @@ -2251,7 +2251,7 @@ protected function prepareCurlOptions( * * @return array{http: array} */ - protected function prepareStreamOptions(string $method, ?string $data, bool $forcePostUrlEncoded): array + protected function prepareStreamOptions(string $method, #[\SensitiveParameter] ?string $data, bool $forcePostUrlEncoded): array { $stream_options = [ 'http' => [ From 11d64c21084220ccad90923a1ae5ee120cb50840 Mon Sep 17 00:00:00 2001 From: sgiehl Date: Mon, 27 Jul 2026 15:32:27 +0200 Subject: [PATCH 27/28] CHANGELOG: warn that the removed false sentinel now coerces to 0/'' The blanket 'mismatched scalar now throws a TypeError' claim was misleading: strict/weak mode is set by the *caller's* file, so a normal (non-strict) consumer still gets weak-mode coercion. Legacy calls that passed false to mean 'not known' (doTrackEvent value, addEcommerceItem price, setLatitude/setLongitude, ...) now silently send 0/0.0/'' instead of omitting the parameter. Add an explicit upgrade note and correct the TypeError wording so the transition is spelled out. --- CHANGELOG.md | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 5a69a80..790fef5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,12 +6,14 @@ This is the Developer Changelog for Matomo PHP Tracker. All breaking changes or Attention: this is a major release with breaking changes. +> **Upgrade note — the `false` "not known" sentinel is gone.** Tracker 3.x let you pass `false` to many optional arguments to mean "value not known" (e.g. `doTrackEvent($cat, $act, $name, false)`, `addEcommerceItem($sku, $name, $cat, false)`, `setLatitude(false)`). Those arguments are now typed (`?T` or numeric unions). If your calling code does **not** use `declare(strict_types=1)` — the usual case for a drop-in tracker — PHP's weak-mode coercion silently turns `false` into `0` / `0.0` / `''` instead of raising an error, so such calls now **send a value** (`e_v=0`, `lat=0`, item price `0`) where 3.x omitted the parameter. Replace every `false` "not known" argument with `null` or simply omit it; passing `false` no longer means "unset". + ### Removed - Support for PHP versions lower than 8.1. The tracker now requires PHP 8.1 or newer. - The `#[AllowDynamicProperties]` attribute. All properties are now declared explicitly, so setting undeclared dynamic properties on a tracker instance is no longer supported (extend `MatomoTracker` and declare the property instead). ### Changed -- `declare(strict_types=1)` is now enabled and every method has proper parameter and return type hints aligned with how Matomo core handles the corresponding tracking parameters. **Passing a mismatched scalar type now throws a `TypeError` instead of being silently coerced.** +- `declare(strict_types=1)` is now enabled and every method has proper parameter and return type hints aligned with how Matomo core handles the corresponding tracking parameters. Passing a value whose type cannot be coerced now throws a `TypeError` (for example a non-numeric string for a numeric parameter, or any type mismatch when the calling code itself declares `strict_types=1`). Note that for ordinary (non-strict) callers PHP's weak-mode coercion still applies, so e.g. `false` becomes `0`/`''` rather than raising — see the upgrade note above about the removed `false` sentinel. - Optional "unset" parameters and their corresponding properties and getters now use `null` instead of the previous `false` sentinel. For example `getUserId()`, `getUserAgent()`, `getIp()` and `getPageviewId()` now return `null` (not `false`) when no value is set, and `doTrackEvent()`/`getUrlTrackEvent()` default the event name and value to `null`. - All public properties are now natively typed. Assigning a legacy sentinel value such as `false` to e.g. `$tracker->userAgent` now throws a `TypeError`; the `attributionInfo` property defaults to an empty array instead of `false`. Subclasses overriding methods with the old untyped signatures may need to be updated to the new signatures. - `setUserId()` now accepts `null` to de-assign a previously set User ID, as the method documentation always promised (previously the `string` type hint made that impossible). From 63928c0b22180f94da5aed01f835f3f031507234 Mon Sep 17 00:00:00 2001 From: sgiehl Date: Mon, 27 Jul 2026 15:39:08 +0200 Subject: [PATCH 28/28] Merge custom CURLOPT_HTTPHEADER instead of replacing the built-in headers MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit setCurlOptions() applied caller options via array_replace(), so a caller adding a single header wholesale-replaced the tracker's own header list. For POST/bulk requests that dropped the Content-Type, making Matomo unable to parse the body — a silent failure. Merge (append) custom HTTP headers onto the built-in ones instead, and document the special-casing. Also extend the sensitive-parameter test to assert redaction on the transport option builders (prepareCurlOptions/prepareStreamOptions), not just sendRequest, since the URL/body are forwarded to them. --- CHANGELOG.md | 2 +- MatomoTracker.php | 11 +++++++ tests/Unit/MatomoTrackerTest.php | 54 +++++++++++++++++++++++++++----- 3 files changed, 59 insertions(+), 8 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 790fef5..b558746 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -43,7 +43,7 @@ Attention: this is a major release with breaking changes. - PHPStan static analysis at max level (`phpstan.neon.dist`) and the Matomo coding standard via PHP_CodeSniffer (`phpcs.xml.dist`), both enforced for every pull request through GitHub Actions. - A greatly expanded unit test suite covering all tracking parameters, cookie handling and request preparation. - `setDebugTrackingParameter()` (`@internal` test helper) to append a raw, unvalidated tracking parameter that overrides any built-in parameter of the same name, so integration tests can verify server-side handling of malformed values. -- `setCurlOptions(array)` to pass additional cURL options (e.g. `CURLOPT_IPRESOLVE`, `CURLOPT_HTTP_VERSION`) for the tracking requests; they are applied after the built-in options (#92). +- `setCurlOptions(array)` to pass additional cURL options (e.g. `CURLOPT_IPRESOLVE`, `CURLOPT_HTTP_VERSION`) for the tracking requests; they are applied after the built-in options (#92). Custom `CURLOPT_HTTPHEADER` entries are merged with the tracker's own headers rather than replacing them, so adding a header no longer drops the built-in `Content-Type` (which would otherwise break bulk requests). ## Matomo PHP Tracker 3.4.0 ### Changed diff --git a/MatomoTracker.php b/MatomoTracker.php index 032dd02..26f5a28 100644 --- a/MatomoTracker.php +++ b/MatomoTracker.php @@ -2094,6 +2094,10 @@ public function setProxy(string $proxy, int $proxyPort = 80): void * transport. Overriding core options such as CURLOPT_RETURNTRANSFER or CURLOPT_HEADER may * break response handling, so use with care. * + * `CURLOPT_HTTPHEADER` is a special case: any headers supplied here are merged with (appended + * to) the tracker's own headers rather than replacing them, so you can add a custom header + * without accidentally dropping the built-in ones (e.g. the Content-Type for POST/bulk). + * * @param array $curlOptions * @return $this */ @@ -2240,7 +2244,14 @@ protected function prepareCurlOptions( // Caller-supplied options are applied last so they can extend or override the defaults. if (!empty($this->curlOptions)) { + // Preserve the tracker's own HTTP headers: a plain array_replace() would let a caller + // that only wants to add one header silently drop the built-in headers (notably the + // Content-Type for POST/bulk requests, which would make Matomo unable to parse the body). + $ownHeaders = $options[CURLOPT_HTTPHEADER]; $options = array_replace($options, $this->curlOptions); + if (isset($this->curlOptions[CURLOPT_HTTPHEADER]) && is_array($this->curlOptions[CURLOPT_HTTPHEADER])) { + $options[CURLOPT_HTTPHEADER] = array_merge($ownHeaders, $this->curlOptions[CURLOPT_HTTPHEADER]); + } } return $options; diff --git a/tests/Unit/MatomoTrackerTest.php b/tests/Unit/MatomoTrackerTest.php index a69f962..670fe19 100644 --- a/tests/Unit/MatomoTrackerTest.php +++ b/tests/Unit/MatomoTrackerTest.php @@ -1443,15 +1443,55 @@ protected function prepareCurlOptions(string $url, string $method, ?string $data $this->assertSame('POST', $captured->capturedMethod); } - public function testSendRequestMarksUrlAndDataAsSensitive(): void + /** + * The URL/body carry token_auth and PII, so they must be redacted from stack traces not only + * in sendRequest() but also in the transport option builders they are forwarded to (otherwise + * a throw one frame down would put them straight back into the trace). + * + * @return array + */ + public static function sensitiveParameterProvider(): array { - $method = new \ReflectionMethod(\MatomoTracker::class, 'sendRequest'); - $sensitive = []; - foreach ($method->getParameters() as $p) { - $sensitive[$p->getName()] = count($p->getAttributes(\SensitiveParameter::class)) > 0; + return [ + ['sendRequest', 'url'], + ['sendRequest', 'data'], + ['prepareCurlOptions', 'url'], + ['prepareCurlOptions', 'data'], + ['prepareStreamOptions', 'data'], + ]; + } + + /** + * @dataProvider sensitiveParameterProvider + */ + public function testRequestUrlAndBodyAreMarkedSensitive(string $method, string $param): void + { + $reflection = new \ReflectionMethod(\MatomoTracker::class, $method); + foreach ($reflection->getParameters() as $p) { + if ($p->getName() === $param) { + $this->assertNotEmpty( + $p->getAttributes(\SensitiveParameter::class), + "$method(\$$param) must be marked #[\\SensitiveParameter]" + ); + return; + } } - $this->assertTrue($sensitive['url']); - $this->assertTrue($sensitive['data']); + $this->fail("Parameter \$$param not found on $method()"); + } + + public function testSetCurlOptionsMergesHttpHeadersInsteadOfReplacingThem(): void + { + $tracker = $this->createTracker(); + $tracker->setCurlOptions([CURLOPT_HTTPHEADER => ['X-Custom: 1']]); + + // A POST/bulk-style request whose Content-Type must survive the caller's extra header. + $options = $tracker->callPrepareCurlOptions('http://example.org/', 'POST', 'foo=bar', true); + $headers = $options[CURLOPT_HTTPHEADER]; + + $this->assertIsArray($headers); + $this->assertContains('X-Custom: 1', $headers); + $this->assertContains('Content-Type: application/x-www-form-urlencoded', $headers); + $this->assertContains('Accept-Language: ', $headers); } public function testStreamOptionsIgnoreHttpErrors(): void