From 9ddccf000b2d1f8cd09d97042729b7c425287a37 Mon Sep 17 00:00:00 2001 From: Stefan Giehl Date: Tue, 2 Dec 2025 15:46:41 +0100 Subject: [PATCH 1/5] Fix deprecation notice for $http_response_header (#143) fixes #142 --- MatomoTracker.php | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/MatomoTracker.php b/MatomoTracker.php index 0e4f41b..d3cb209 100644 --- a/MatomoTracker.php +++ b/MatomoTracker.php @@ -2129,6 +2129,10 @@ protected function sendRequest(string $url, string $method = 'GET', $data = null $response = file_get_contents($url, 0, $ctx); $content = $response; + if (function_exists('http_get_last_response_headers')) { + $http_response_header = http_get_last_response_headers(); + } + $this->parseIncomingCookies($http_response_header); } From b029aa2b8dcc113ddcff575a127010a5585321a2 Mon Sep 17 00:00:00 2001 From: George Steel Date: Fri, 12 Dec 2025 12:39:49 +0000 Subject: [PATCH 2/5] Declare supported PHP versions explicitly and run tests on all supported versions (#147) * Declare supported PHP versions explicitly and run tests on all supported versions * Apply suggestion from @sgiehl --------- Co-authored-by: Stefan Giehl --- .github/workflows/phpunit.yml | 38 ++--------------------------------- composer.json | 2 +- 2 files changed, 3 insertions(+), 37 deletions(-) diff --git a/.github/workflows/phpunit.yml b/.github/workflows/phpunit.yml index d68cd40..fbcb7de 100644 --- a/.github/workflows/phpunit.yml +++ b/.github/workflows/phpunit.yml @@ -23,42 +23,8 @@ jobs: runs-on: ${{ matrix.operating-system }} strategy: matrix: - operating-system: [ubuntu-latest, windows-latest] - php-version: ['7.2', '8.3'] - include: - - php-version: 7.3 - operating-system: ubuntu-latest - - php-version: 7.4 - operating-system: ubuntu-latest - - php-version: 8.0 - operating-system: ubuntu-latest - - php-version: 8.1 - operating-system: ubuntu-latest - - php-version: 8.2 - operating-system: ubuntu-latest - steps: - - uses: actions/checkout@v2 - - name: Install PHP - uses: shivammathur/setup-php@v2 - with: - php-version: ${{ matrix.php-version }} - tools: composer:v2 - extensions: memcached - - name: "Composer install" - run: | - composer install --prefer-dist - - name: PHPUnit / PHP ${{ matrix.php-version }} - run: | - php -v - ./vendor/bin/phpunit - - build2: - name: PHPUnit - runs-on: ${{ matrix.operating-system }} - strategy: - matrix: - operating-system: [ macOS-latest ] - php-version: [ '7.2', '8.3' ] + 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'] steps: - uses: actions/checkout@v2 - name: Install PHP diff --git a/composer.json b/composer.json index f07c24e..c740a25 100644 --- a/composer.json +++ b/composer.json @@ -17,7 +17,7 @@ "source": "https://github.com/matomo-org/matomo-php-tracker" }, "require": { - "php": "^7.2 || ^8.0", + "php": "~7.2 || ~7.3 || ~7.4 || ~8.0 || ~8.1 || ~8.2 || ~8.3 || ~8.4 || ~8.5", "ext-json": "*" }, "suggest": { From 9462dc6eb718c711545ea1b0f590b9ae892a4212 Mon Sep 17 00:00:00 2001 From: Andrii Lutskevych Date: Sat, 20 Dec 2025 19:55:41 +0100 Subject: [PATCH 3/5] Fix: Not possible to create multiple Piwik tracker instances having different API urls (#145) * Allow setting form factors client hint (#136) * Allow settting form factors client hint * update changelog * Fix handling of some attributes * Apply review feedback Co-authored-by: Michal Kleiner --------- Co-authored-by: Michal Kleiner * deprecate $URL * use old $URL if apiUrl is empty --------- Co-authored-by: Stefan Giehl Co-authored-by: Michal Kleiner Co-authored-by: Andrii Lutskevych --- CHANGELOG.md | 10 +++++++ MatomoTracker.php | 48 +++++++++++++++++++++++++------- tests/Unit/MatomoTrackerTest.php | 9 ++++++ 3 files changed, 57 insertions(+), 10 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 0c9fa36..880e1bc 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,9 +4,19 @@ This is the Developer Changelog for Matomo PHP Tracker. All breaking changes or ## Matomo PHP Tracker 3.4.0 ### Changed + +- Fixed PHP 8.5 deprecation notice +- static `$URL` is deprecated - a lot of arguments of `MatomoTracker` methods have explicitly types - a lot of `MatomoTracker` method return types have strict types +### Added +- new private property `apiUrl` for storing API URL + +## Matomo PHP Tracker 3.3.2 +### Changed +- Support for formFactors client hint parameter, supported as of Matomo 5.2.0 + ## Matomo PHP Tracker 3.3.1 ### Fixed - closed curl connection diff --git a/MatomoTracker.php b/MatomoTracker.php index d3cb209..d2ce799 100644 --- a/MatomoTracker.php +++ b/MatomoTracker.php @@ -28,6 +28,7 @@ class MatomoTracker * MatomoTracker::$URL = 'http://yourwebsite.org/matomo/'; * * @var string + * @deprecated */ static public $URL = ''; @@ -202,6 +203,8 @@ class MatomoTracker private $requestMethod = null; + private $apiUrl = ''; + /** * Builds a MatomoTracker object, used to track visits, pages and Goal conversions * for a specific website, by using the Matomo Tracking API. @@ -223,10 +226,12 @@ public function __construct(int $idSite, string $apiUrl = '') !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_FULL_VERSION']) ? $_SERVER['HTTP_SEC_CH_UA_FULL_VERSION'] : '', + !empty($_SERVER['HTTP_SEC_CH_UA_FORM_FACTORS']) ? $_SERVER['HTTP_SEC_CH_UA_FORM_FACTORS'] : '' ); if (!empty($apiUrl)) { self::$URL = $apiUrl; + $this->apiUrl = $apiUrl; } $this->setNewVisitorId(); @@ -240,6 +245,7 @@ public function __construct(int $idSite, string $apiUrl = '') public function setApiUrl(string $url): void { self::$URL = $url; + $this->apiUrl = $url; } /** @@ -593,6 +599,8 @@ public function setUserAgent(string $userAgent) * 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' + * or an array containing all form factors with structure ["Desktop", "XR"] * * @return $this */ @@ -601,7 +609,8 @@ public function setClientHints( string $platform = '', string $platformVersion = '', $fullVersionList = '', - string $uaFullVersion = '' + string $uaFullVersion = '', + $formFactors = '' ) { if (is_string($fullVersionList)) { $reg = '/^"([^"]+)"; ?v="([^"]+)"(?:, )?/'; @@ -617,12 +626,25 @@ public function setClientHints( $fullVersionList = []; } + if (is_string($formFactors)) { + $formFactors = explode(',', $formFactors); + $formFactors = array_filter(array_map( + function ($item) { + return trim($item, '" '); + }, + $formFactors + )); + } elseif (!is_array($formFactors)) { + $formFactors = []; + } + $this->clientHints = array_filter([ 'model' => $model, 'platform' => $platform, 'platformVersion' => $platformVersion, 'uaFullVersion' => $uaFullVersion, 'fullVersionList' => $fullVersionList, + 'formFactors' => $formFactors, ]); return $this; @@ -810,7 +832,7 @@ public function doTrackPageView(string $documentTitle) return $this->sendRequest($url); } - + /** * Override PageView id for every use of `doTrackPageView()`. Do not use this if you call `doTrackPageView()` * multiple times during tracking (if, for example, you are tracking a single page application). @@ -2097,7 +2119,7 @@ protected function sendRequest(string $url, string $method = 'GET', $data = null curl_setopt_array($ch, $options); ob_start(); $response = @curl_exec($ch); - + try { $header = ''; @@ -2152,23 +2174,29 @@ protected function getTimestamp() /** * Returns the base URL for the Matomo server. + * + * @throws Exception */ protected function getBaseUrl(): string { - if (empty(self::$URL)) { + $apiUrl = $this->apiUrl === '' + ? self::$URL + : $this->apiUrl; + + if ($apiUrl === '') { throw new Exception( 'You must first set the Matomo Tracker URL by calling MatomoTracker::$URL = \'http://your-website.org/matomo/\';' ); } - if (strpos(self::$URL, '/matomo.php') === false - && strpos(self::$URL, '/proxy-matomo.php') === false + if (strpos($apiUrl, '/matomo.php') === false + && strpos($apiUrl, '/proxy-matomo.php') === false ) { - self::$URL = rtrim(self::$URL, '/'); - self::$URL .= '/matomo.php'; + $apiUrl = rtrim($apiUrl, '/'); + $apiUrl .= '/matomo.php'; } - return self::$URL; + return $apiUrl; } /** diff --git a/tests/Unit/MatomoTrackerTest.php b/tests/Unit/MatomoTrackerTest.php index 703b631..75b1669 100644 --- a/tests/Unit/MatomoTrackerTest.php +++ b/tests/Unit/MatomoTrackerTest.php @@ -84,4 +84,13 @@ public function test_setApiUrl() $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); + } } \ No newline at end of file From 262acfdd6dab5e70e7c72b8bdbb2055be23a30d5 Mon Sep 17 00:00:00 2001 From: George Steel Date: Fri, 16 Jan 2026 16:19:23 +0000 Subject: [PATCH 4/5] Conditionally close the curl handle (#150) `curl_close` has no effect since PHP 8.0 and is deprecated since 8.5 https://www.php.net/curl_close --- MatomoTracker.php | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/MatomoTracker.php b/MatomoTracker.php index d2ce799..b40606b 100644 --- a/MatomoTracker.php +++ b/MatomoTracker.php @@ -2141,7 +2141,11 @@ protected function sendRequest(string $url, string $method = 'GET', $data = null $this->parseIncomingCookies(explode("\r\n", $header)); } finally { - curl_close($ch); + // 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')) { From f2afbb8f15056fdd607384763b2addad2162215e Mon Sep 17 00:00:00 2001 From: dizzy Date: Fri, 24 Jul 2026 13:53:43 +0000 Subject: [PATCH 5/5] Add method to track an AI bot request, if the current user agent is a known AI bot (#148) * add methods to detect and track pageviews if the current user agent is an AI bot * add supported query parameters for AI bot tracking * add more tests for AI bot tracking methods * use recMode parameter * make user agent substrings array a public const and fill out missing php docs * remove phpstorm added spacing * update tests * set url in test * remove Devin user agent check Co-authored-by: Thomas ZILLIOX * Apply suggestions from code review Co-authored-by: Stefan Giehl --------- Co-authored-by: Thomas ZILLIOX Co-authored-by: Stefan Giehl --- MatomoTracker.php | 122 ++++++++++++++++++++++++++----- tests/Unit/MatomoTrackerTest.php | 86 ++++++++++++++++++++++ 2 files changed, 190 insertions(+), 18 deletions(-) diff --git a/MatomoTracker.php b/MatomoTracker.php index b40606b..989bdfe 100644 --- a/MatomoTracker.php +++ b/MatomoTracker.php @@ -32,6 +32,15 @@ class MatomoTracker */ static public $URL = ''; + public const AI_BOT_USER_AGENT_SUBSTRINGS = [ + 'ChatGPT-User', + 'MistralAI-User', + 'Gemini-Deep-Research', + 'Claude-User', + 'Perplexity-User', + 'Google-NotebookLM', + ]; + /** * API Version * @@ -161,11 +170,11 @@ class MatomoTracker // Visitor Ids in order public $userId = false; - + public $forcedVisitorId = false; - + public $cookieVisitorId = false; - + public $randomVisitorId = false; public $configCookiesDisabled = false; @@ -186,11 +195,11 @@ class MatomoTracker // Allow debug while blocking the request public $requestTimeout = 600; - + public $requestConnectTimeout = 300; - + public $doBulkRequests = false; - + public $storedTrackingActions = []; public $sendImageResponse = true; @@ -238,7 +247,7 @@ public function __construct(int $idSite, string $apiUrl = '') $this->currentTs = time(); $this->createTs = $this->currentTs; - + $this->visitorCustomVar = $this->getCustomVariablesFromCookie(); } @@ -735,7 +744,7 @@ public function enableBulkTracking(): void } /** - * Disables the bulk request feature. Make sure to call `doBulkTrack()` before disabling it if you have stored + * Disables the bulk request feature. Make sure to call `doBulkTrack()` before disabling it if you have stored * tracking actions previously as this method won't be sending any previously stored actions before disabling it. */ public function disableBulkTracking(): void @@ -833,6 +842,29 @@ public function doTrackPageView(string $documentTitle) return $this->sendRequest($url); } + /** + * If the current user agent belongs to a known AI bot, tracks a pageview action. + * + * This method should be used server side to track AI bots that do not execute + * JavaScript. If the current user agent is not a known AI bot, nothing is tracked + * and null is returned. + * + * @param int|null $httpStatus the request's HTTP status code, if known. + * @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. + */ + public function doTrackPageViewIfAIBot(?int $httpStatus = null, ?int $responseSizeBytes = null, ?int $serverTimeMs = null, ?string $source = null) + { + if (!self::isUserAgentAIBot($this->userAgent)) { + return null; + } + + $url = $this->getUrlTrackAIBot($httpStatus, $responseSizeBytes, $serverTimeMs, $source); + return $this->sendRequest($url); + } + /** * Override PageView id for every use of `doTrackPageView()`. Do not use this if you call `doTrackPageView()` * multiple times during tracking (if, for example, you are tracking a single page application). @@ -847,7 +879,7 @@ public function setPageviewId(string $idPageview): void * Returns the PageView id. If the id was manually set using `setPageViewId()`, that id will be returned. * 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. */ public function getPageviewId() @@ -891,7 +923,7 @@ public function doTrackEvent( public function doTrackContentImpression( string $contentName, string $contentPiece = 'Unknown', - $contentTarget = false + $contentTarget = false ) { $url = $this->getUrlTrackContentImpression($contentName, $contentPiece, $contentTarget); @@ -1215,6 +1247,40 @@ private function forceDotAsSeparatorForDecimalPoint($value): string return str_replace(',', '.', $value); } + /** + * Builds a URL to track a request from an AI bot. + * + * @param int|null $httpStatus the request's HTTP status code, if it is known. + * @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 + */ + public function getUrlTrackAIBot(?int $httpStatus = null, ?int $responseSizeBytes = null, ?int $serverTimeMs = null, ?string $source = null): string + { + $url = $this->getRequest($this->idSite); + + $url .= '&recMode=1'; + + if ($httpStatus !== null) { + $url .= '&http_status=' . $httpStatus; + } + + if ($responseSizeBytes !== null) { + $url .= '&bw_bytes=' . $responseSizeBytes; + } + + if ($serverTimeMs !== null) { + $url .= '&pf_srv=' . $serverTimeMs; + } + + if ($source !== null && $source !== '') { + $url .= '&source=' . rawurlencode(substr($source, 0, 50)); + } + + return $url; + } + /** * Returns URL used to track Ecommerce Cart updates * Calling this function will reinitializes the property ecommerceItems to empty array @@ -1362,7 +1428,7 @@ public function getUrlTrackEvent( public function getUrlTrackContentImpression( string $contentName, string $contentPiece, - $contentTarget + $contentTarget ): string { $url = $this->getRequest($this->idSite); @@ -1876,7 +1942,7 @@ public function setRequestTimeout(int $timeout) return $this; } - + /** * Returns the maximum number of seconds the tracker will spend trying to connect to Matomo. * Defaults to 300 seconds. @@ -1904,7 +1970,7 @@ public function setRequestConnectTimeout(int $timeout) return $this; } - /** + /** * Sets the request method to POST, which is recommended when using setTokenAuth() * to prevent the token from being recorded in server logs. Avoid using redirects * when using POST to prevent the loss of POST values. When using Log Analytics, @@ -1957,7 +2023,7 @@ private function getProxy(): ?string protected function prepareCurlOptions( string $url, string $method, - $data, + $data, bool $forcePostUrlEncoded ): array { $options = [ @@ -2374,7 +2440,7 @@ protected static function getCurrentScriptName(): string if (empty($url) && isset($_SERVER['SCRIPT_NAME'])) { $url = $_SERVER['SCRIPT_NAME']; } elseif (empty($url)) { - $url = '/'; + $url = '/'; } if (!empty($url) && $url[0] !== '/') { @@ -2443,9 +2509,9 @@ protected static function getCurrentQueryString(): string protected static function getCurrentUrl(): string { return self::getCurrentScheme() . '://' - . self::getCurrentHost() - . self::getCurrentScriptName() - . self::getCurrentQueryString(); + . self::getCurrentHost() + . self::getCurrentScriptName() + . self::getCurrentQueryString(); } /** @@ -2576,6 +2642,26 @@ protected function parseIncomingCookies(array $headers): void } } } + + /** + * Returns true if the given user agent belongs to a known AI bot. + * + * @param string $userAgent + * @return bool + */ + public static function isUserAgentAIBot(string $userAgent): bool + { + if (empty($userAgent)) { + return false; + } + + foreach (self::AI_BOT_USER_AGENT_SUBSTRINGS as $substring) { + if (stripos($userAgent, $substring) !== false) { + return true; + } + } + return false; + } } /** diff --git a/tests/Unit/MatomoTrackerTest.php b/tests/Unit/MatomoTrackerTest.php index 75b1669..dc68871 100644 --- a/tests/Unit/MatomoTrackerTest.php +++ b/tests/Unit/MatomoTrackerTest.php @@ -85,6 +85,92 @@ public function test_setApiUrl() $this->assertSame(substr($url, 0, strlen($newApiUrl)), $newApiUrl); } + /** + * @dataProvider getTestDataForIsUserAgentAIBot + */ + public function test_isUserAgentAIBot($userAgent, $expected) + { + $this->assertSame($expected, \MatomoTracker::isUserAgentAIBot($userAgent)); + } + + public function getTestDataForIsUserAgentAIBot(): array + { + return [ + ['', false], + + ['Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/134.0.0.0 Safari/537.3', false], + ['Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/122.0.0.0 Mobile Safari/537.3', false], + + ['Mozilla/5.0 AppleWebKit/537.36 (KHTML, like Gecko); compatible; ChatGPT-User/1.0; +https://openai.com/bot', true], + ['Mozilla/5.0 AppleWebKit/537.36 (KHTML, like Gecko); compatible; GPTBot/1.1; +https://openai.com/gptbot', false], + ['Mozilla/5.0 AppleWebKit/537.36 (KHTML, like Gecko; compatible; MistralAI-User/1.0; +https://docs.mistral.ai/robots)', true], + ['Mozilla/5.0 AppleWebKit/537.36 (KHTML, like Gecko; compatible; Gemini-Deep-Research; +https://gemini.google/overview/deep-research/) Chrome/135.0.0.0 Safari/537.36', true], + ['Mozilla/5.0 AppleWebKit/537.36 (KHTML, like Gecko; compatible; Claude-User/1.0; +Claude-User@anthropic.com)', true], + ['Mozilla/5.0 AppleWebKit/537.36 (KHTML, like Gecko; compatible; Perplexity-User/1.0; +https://perplexity.ai/perplexity-user)', true], + ['Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/137.0.0.0 Safari/537.36; Devin/1.0; +devin.ai', false], + ]; + } + + /** + * @dataProvider getTestDataForGetUrlTrackAIBot + */ + public function test_getUrlTrackAIBot(?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'; + + $tracker = new \MatomoTracker(1, $apiUrl = self::TEST_URL); + $tracker->setUrl('https://example.com/page'); + $tracker->setVisitorId('abcdef01234517ab'); + + $actual = $tracker->getUrlTrackAIBot($httpStatus, $responseSizeBytes, $serverTimeMs, $source); + $actual = $this->normalizeTrackingUrl($actual); + + $this->assertEquals($expected, $actual); + } + + public function getTestDataForGetUrlTrackAIBot(): array + { + return [ + [ + 200, + 34567, + 123, + 'wordpress', + 'http://mymatomo.com/matomo.php?idsite=1&rec=1&apiv=1&r=&r=&cid=abcdef01234517ab&url=https%3A%2F%2Fexample.com%2Fpage&urlref=&recMode=1&http_status=200&bw_bytes=34567&pf_srv=123&source=wordpress', + ], + + [ + null, + 34567, + null, + 'something else', + 'http://mymatomo.com/matomo.php?idsite=1&rec=1&apiv=1&r=&r=&cid=abcdef01234517ab&url=https%3A%2F%2Fexample.com%2Fpage&urlref=&recMode=1&bw_bytes=34567&source=something%20else', + ], + + [ + null, + null, + null, + null, + 'http://mymatomo.com/matomo.php?idsite=1&rec=1&apiv=1&r=&r=&cid=abcdef01234517ab&url=https%3A%2F%2Fexample.com%2Fpage&urlref=&recMode=1', + ], + ]; + } + + private function normalizeTrackingUrl(string $url) + { + $nonDeterministicParams = [ + 'r', + '_idts', + ]; + + foreach ($nonDeterministicParams as $param) { + $url = preg_replace('/&' . preg_quote($param) . '=[^&]+/', '&r=', $url); + } + + return $url; + } + public function testUsageApiUrl(): void { $newApiUrl = 'https://NEW-API-URL.com';