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/.github/workflows/phpunit.yml b/.github/workflows/phpunit.yml index fbcb7de..b929af7 100644 --- a/.github/workflows/phpunit.yml +++ b/.github/workflows/phpunit.yml @@ -24,15 +24,17 @@ 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 + 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 - extensions: memcached + extensions: 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..f280782 --- /dev/null +++ b/.github/workflows/quality.yml @@ -0,0 +1,57 @@ +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 + 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: 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 + 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: none + - name: "Composer install" + run: composer install --prefer-dist + - name: PHP_CodeSniffer + run: ./vendor/bin/phpcs 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/CHANGELOG.md b/CHANGELOG.md index 880e1bc..b558746 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,49 @@ 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. + +> **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 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). +- `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-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"`). +- `doTrackSiteSearch()` / `getUrlTrackSiteSearch()` accept `?int $countResults` and only send `&search_count` when a count is provided (previously `&search_count=0` was always sent). +- 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. + +### Fixed +- 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. +- 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). +- 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. +- `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). 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 989bdfe..26f5a28 100644 --- a/MatomoTracker.php +++ b/MatomoTracker.php @@ -1,4 +1,5 @@ , 3: string, 4: int}> + */ + public array $ecommerceItems = []; + + /** + * @var array + */ + public array $attributionInfo = []; - public $attributionInfo = false; + /** + * @var array + */ + public array $eventCustomVar = []; - public $eventCustomVar = []; + public ?string $forcedDatetime = null; - public $forcedDatetime = false; + public bool $forcedNewVisit = false; - public $forcedNewVisit = false; + public ?int $networkTime = null; - public $networkTime = false; + public ?int $serverTime = null; - public $serverTime = false; + public ?int $transferTime = null; - public $transferTime = false; + public ?int $domProcessingTime = null; - public $domProcessingTime = false; + public ?int $domCompletionTime = null; - public $domCompletionTime = false; + public ?int $onLoadTime = null; - public $onLoadTime = false; + /** + * @var array + */ + public array $pageCustomVar = []; - public $pageCustomVar = []; + /** + * @var array + */ + public array $ecommerceView = []; - public $ecommerceView = []; + /** + * @var array> + */ + public array $customParameters = []; - public $customParameters = []; + /** + * 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 + */ + public array $debugParameters = []; - 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 = 5; + + public int $requestConnectTimeout = 2; + + public bool $doBulkRequests = false; + + /** + * @var list + */ + public array $storedTrackingActions = []; + + public bool $sendImageResponse = true; - public $requestConnectTimeout = 300; + // When true (default), failed tracking requests throw a RuntimeException; set false to return false instead. + public bool $exceptionsEnabled = true; - public $doBulkRequests = false; + /** + * @var array + */ + public array $outgoingTrackerCookies = []; - public $storedTrackingActions = []; + /** + * @var array + */ + public array $incomingTrackerCookies = []; - public $sendImageResponse = true; + /** + * @var array + */ + public array $visitorCustomVar = []; - public $outgoingTrackerCookies = []; + private ?string $requestMethod = null; - public $incomingTrackerCookies = []; + private string $apiUrl = ''; - public $visitorCustomVar; + private ?string $proxy = null; - private $requestMethod = null; + private int $proxyPort = 80; - private $apiUrl = ''; + /** + * 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 @@ -225,18 +292,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 +332,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 +345,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; @@ -288,10 +355,10 @@ public function setUrl(string $url) /** * 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) + public function setUrlReferrer(?string $url): self { $this->urlReferrer = $url; @@ -307,7 +374,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 +398,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 +414,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,11 +448,11 @@ 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(#[\SensitiveParameter] 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"); + throw new Exception("setAttributionInfo() is expecting a JSON encoded string"); } $this->attributionInfo = $decoded; @@ -408,13 +475,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 +498,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 +519,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 +543,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; } @@ -507,7 +566,7 @@ public function clearCustomDimensions(): void */ public function getCustomDimension(int $id): ?string { - return $this->customDimensions['dimension'.$id] ?? null; + return $this->customDimensions['dimension' . $id] ?? null; } /** @@ -516,16 +575,17 @@ 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) + public function setCustomTrackingParameter(string $trackingApiParameter, string|array $value): self { $matches = []; - if (preg_match('/^dimension([0-9]+)$/', $trackingApiParameter, $matches)) { - $this->setCustomDimension($matches[1], $value); + if (is_string($value) && preg_match('/^dimension([0-9]+)$/', $trackingApiParameter, $matches)) { + $this->setCustomDimension((int) $matches[1], $value); return $this; } @@ -543,15 +603,37 @@ public function clearCustomTrackingParameters(): void $this->customParameters = []; } + /** + * 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. + * + * 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' + * @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 */ - 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((string) rand(), true)), 0, self::LENGTH_VISITOR_ID); + $this->forcedVisitorId = null; + $this->cookieVisitorId = null; return $this; } @@ -561,7 +643,7 @@ public function setNewVisitorId() * * @return $this */ - public function setIdSite(int $idSite) + public function setIdSite(int $idSite): self { $this->idSite = $idSite; @@ -574,7 +656,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 +670,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; @@ -604,8 +686,8 @@ public function setUserAgent(string $userAgent) * @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' @@ -617,10 +699,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 +713,6 @@ public function setClientHints( } $fullVersionList = $list; - } elseif (!is_array($fullVersionList)) { - $fullVersionList = []; } if (is_string($formFactors)) { @@ -643,8 +723,6 @@ function ($item) { }, $formFactors )); - } elseif (!is_array($formFactors)) { - $formFactors = []; } $this->clientHints = array_filter([ @@ -667,7 +745,7 @@ function ($item) { * * @return $this */ - public function setCountry(string $country) + public function setCountry(string $country): self { $this->country = $country; @@ -682,7 +760,7 @@ public function setCountry(string $country) * * @return $this */ - public function setRegion(string $region) + public function setRegion(string $region): self { $this->region = $region; @@ -697,7 +775,7 @@ public function setRegion(string $region) * * @return $this */ - public function setCity(string $city) + public function setCity(string $city): self { $this->city = $city; @@ -712,7 +790,7 @@ public function setCity(string $city) * * @return $this */ - public function setLatitude(float $lat) + public function setLatitude(float $lat): self { $this->lat = $lat; @@ -727,7 +805,7 @@ public function setLatitude(float $lat) * * @return $this */ - public function setLongitude(float $long) + public function setLongitude(float $long): self { $this->long = $long; @@ -788,7 +866,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 +907,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 +931,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 +958,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((string) rand(), true)), 0, 6); } /** @@ -897,16 +975,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 +995,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 +1015,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); @@ -957,15 +1035,15 @@ public function doTrackContentInteraction( * * @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); @@ -975,10 +1053,11 @@ public function doTrackSiteSearch( * Records a Goal conversion * * @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 + * @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) + public function doTrackGoal(int $idGoal, ?float $revenue = null): string|bool { $url = $this->getUrlTrackGoal($idGoal, $revenue); @@ -990,9 +1069,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 +1089,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 +1098,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 +1121,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 +1136,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,9 +1155,26 @@ 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"); + } - $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; } @@ -1094,20 +1190,20 @@ public function doBulkTrack() * 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 - * @return mixed Response or true if using bulk request + * @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( - $orderId, + 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); return $this->sendRequest($url); @@ -1121,9 +1217,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 +1241,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 +1251,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 +1264,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'; @@ -1183,14 +1279,14 @@ public function doPing() * * 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) * * @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,23 +1294,20 @@ 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); - } - } else { - $category = ""; + if (empty($category)) { + $category = ''; + } elseif (is_array($category)) { + $category = (string) json_encode($category); } $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" @@ -1224,9 +1317,6 @@ public function setEcommerceView( if (!empty($sku)) { $this->ecommerceView['_pks'] = $sku; } - if (empty($name)) { - $name = ''; - } $this->ecommerceView['_pkn'] = $name; return $this; @@ -1236,15 +1326,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 +1373,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 +1385,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 = 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"); } $url = $this->getUrlTrackEcommerce($grandTotal, $subTotal, $tax, $shipping, $discount); - $url .= '&ec_id=' . urlencode($orderId); + $url .= '&ec_id=' . urlencode((string) $orderId); return $url; } @@ -1323,38 +1409,33 @@ 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 = 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(json_encode($this->ecommerceItems)); + $url .= '&ec_items=' . urlencode((string) json_encode($this->ecommerceItems)); } - $this->ecommerceItems = array(); + $this->ecommerceItems = []; return $url; } @@ -1382,16 +1463,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) { @@ -1403,13 +1484,14 @@ 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 (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 +1503,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); @@ -1437,11 +1519,13 @@ 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) && 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 +1539,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 +1547,7 @@ public function getUrlTrackContentInteraction( string $interaction, string $contentName, string $contentPiece, - $contentTarget + ?string $contentTarget ): string { $url = $this->getRequest($this->idSite); @@ -1477,11 +1561,13 @@ 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) && 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 +1579,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; @@ -1512,16 +1598,16 @@ public function getUrlTrackSiteSearch(string $keyword, string $category, int $co * * @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)) { - $revenue = $this->forceDotAsSeparatorForDecimalPoint($revenue); - $url .= '&revenue=' . $revenue; + if ($revenue !== null) { + $url .= '&revenue=' . $this->forceDotAsSeparatorForDecimalPoint($revenue); } return $url; @@ -1532,13 +1618,14 @@ public function getUrlTrackGoal(int $idGoal, float $revenue = 0.0): 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; } @@ -1581,10 +1668,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 +1688,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 +1702,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 +1717,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; @@ -1642,11 +1729,14 @@ public function setIp(string $ip) * * 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 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 */ - public function setUserId(string $userId) + public function setUserId(?string $userId): self { if ($userId === '') { throw new Exception("User ID cannot be empty."); @@ -1660,10 +1750,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,10 +1768,11 @@ 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 + $hexChars = self::HEX_CHARACTERS; + if ( + strlen($visitorId) !== self::LENGTH_VISITOR_ID || strspn($visitorId, $hexChars) !== strlen($visitorId) ) { throw new Exception( @@ -1711,12 +1800,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 +1814,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 +1831,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; } @@ -1764,14 +1849,20 @@ protected function loadVisitorIdCookie(): bool return false; } $parts = explode('.', $idCookie); - if (strlen($parts[0]) !== self::LENGTH_VISITOR_ID) { + $hexChars = self::HEX_CHARACTERS; + if ( + strlen($parts[0]) !== self::LENGTH_VISITOR_ID + || strspn($parts[0], $hexChars) !== self::LENGTH_VISITOR_ID + ) { return false; } /* $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; } @@ -1781,7 +1872,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); } @@ -1793,11 +1884,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 +1908,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 +1921,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 +1938,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 +1952,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 +1964,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 +1984,7 @@ public function setPlugins( bool $pdf = false, bool $windowsMedia = false, bool $silverlight = false - ) { + ): self { $this->plugins = '&fla=' . (int)$flash . '&java=' . (int)$java . @@ -1918,7 +2009,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 { @@ -1932,7 +2023,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"); @@ -1945,7 +2036,7 @@ public function setRequestTimeout(int $timeout) /** * 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 { @@ -1959,7 +2050,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 +2070,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'; @@ -1996,24 +2087,96 @@ 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. + * + * `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 + */ + public function setCurlOptions(array $curlOptions): self + { + $this->curlOptions = $curlOptions; + + 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" */ 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; } + /** + * 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); + } + + /** + * 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); + } + + /** + * 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. * * @ignore */ - static public $DEBUG_LAST_REQUESTED_URL = false; + public static string|false $DEBUG_LAST_REQUESTED_URL = false; /** * Returns array of curl options for request @@ -2021,20 +2184,20 @@ private function getProxy(): ?string * @return array */ protected function prepareCurlOptions( - string $url, + #[\SensitiveParameter] string $url, string $method, - $data, + #[\SensitiveParameter] ?string $data, bool $forcePostUrlEncoded ): 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), ], ]; @@ -2075,8 +2238,20 @@ protected function prepareCurlOptions( } if (!empty($this->outgoingTrackerCookies)) { - $options[CURLOPT_COOKIE] = http_build_query($this->outgoingTrackerCookies); - $this->outgoingTrackerCookies = array(); + $options[CURLOPT_COOKIE] = $this->buildOutgoingCookieHeader(); + $this->outgoingTrackerCookies = []; + } + + // 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; @@ -2087,14 +2262,17 @@ protected function prepareCurlOptions( * * @return array{http: array} */ - protected function prepareStreamOptions(string $method, $data, bool $forcePostUrlEncoded): array + protected function prepareStreamOptions(string $method, #[\SensitiveParameter] ?string $data, bool $forcePostUrlEncoded): array { $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, + // 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, ], ]; @@ -2113,8 +2291,8 @@ protected function prepareStreamOptions(string $method, $data, bool $forcePostUr } if (!empty($this->outgoingTrackerCookies)) { - $stream_options['http']['header'] .= 'Cookie: ' . http_build_query($this->outgoingTrackerCookies) . "\r\n"; - $this->outgoingTrackerCookies = array(); + $stream_options['http']['header'] .= 'Cookie: ' . $this->buildOutgoingCookieHeader() . "\r\n"; + $this->outgoingTrackerCookies = []; } return $stream_options; @@ -2123,7 +2301,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(#[\SensitiveParameter] string $url, string $method = 'GET', #[\SensitiveParameter] ?string $data = null, bool $force = false): string|bool { self::$DEBUG_LAST_REQUESTED_URL = $url; @@ -2138,9 +2316,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; } @@ -2152,7 +2330,7 @@ protected function sendRequest(string $url, string $method = 'GET', $data = null $urlParts = explode('?', $url); $url = $urlParts[0]; - $data = $urlParts[1]; + $data = $urlParts[1] ?? ''; $forcePostUrlEncoded = true; $method = 'POST'; @@ -2162,14 +2340,18 @@ protected function sendRequest(string $url, string $method = 'GET', $data = null $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 = ''; } $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; } @@ -2178,7 +2360,7 @@ protected function sendRequest(string $url, string $method = 'GET', $data = null $content = ''; - if (function_exists('curl_init') && function_exists('curl_exec')) { + if ($this->hasCurlSupport()) { $options = $this->prepareCurlOptions($url, $method, $data, $forcePostUrlEncoded); $ch = curl_init(); @@ -2192,13 +2374,17 @@ protected function sendRequest(string $url, string $method = 'GET', $data = null 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; } } - 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 @@ -2207,25 +2393,32 @@ protected function sendRequest(string $url, string $method = 'GET', $data = null $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')) { $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); + if ($response === false && $this->exceptionsEnabled) { + // 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; + $responseHeaders = []; if (function_exists('http_get_last_response_headers')) { - $http_response_header = http_get_last_response_headers(); + $headers = http_get_last_response_headers(); + if (is_array($headers)) { + $responseHeaders = $headers; + } + } elseif ($response !== false) { + // 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; } - $this->parseIncomingCookies($http_response_header); + $this->parseIncomingCookies($responseHeaders); } return $content; @@ -2233,13 +2426,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(); } /** @@ -2259,7 +2456,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, '/'); @@ -2296,15 +2494,15 @@ 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']) ? - '&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 : '') . + ((!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' : '') . @@ -2314,60 +2512,60 @@ 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->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)) : '') . (!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 : '') . + '&cs=' . urlencode($this->pageCharset) : '') . // unique pageview id (!empty($this->idPageview) ? '&pv_id=' . urlencode($this->idPageview) : '') . // 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=' . urlencode(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) : '') . + ($this->lat !== null ? '&lat=' . urlencode((string) $this->lat) : '') . + ($this->long !== null ? '&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(); } @@ -2375,10 +2573,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(); @@ -2392,25 +2596,23 @@ 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, // 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) { - return $cookieValue; + // cookie names that are numeric strings are exposed as integer array keys + if (strpos((string) $cookieName, $name) !== false) { + return self::toStringValue($cookieValue); } } @@ -2427,18 +2629,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 = '/'; } @@ -2459,7 +2662,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'; @@ -2477,7 +2681,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 +2696,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 +2716,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 +2756,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 +2767,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 +2785,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,53 +2828,55 @@ public function setOutgoingTrackerCookie($name, $value) /** * Gets a cookie which was set by the tracking server. * - * @param $name + * @param string $name * - * @return bool|string + * @return string|false The cookie value, or false if no cookie with the given name was received. */ - public function getIncomingTrackerCookie($name) + public function getIncomingTrackerCookie(string $name): string|false { - 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 { $this->incomingTrackerCookies = []; - if (!empty($headers)) { - $headerName = 'set-cookie:'; - $headerNameLength = strlen($headerName); + $headerName = 'set-cookie:'; + $headerNameLength = strlen($headerName); - foreach($headers as $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; } } /** * 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 +2894,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 +2908,12 @@ function Matomo_getUrlTrackPageView($idSite, $documentTitle = '') /** * Helper function to quickly generate the URL to track a goal. * - * @param $idSite - * @param $idGoal - * @param float $revenue + * @param int $idSite + * @param int $idGoal + * @param float|null $revenue * @return string */ -function Matomo_getUrlTrackGoal($idSite, $idGoal, $revenue = 0.0) +function Matomo_getUrlTrackGoal(int $idSite, int $idGoal, ?float $revenue = null): string { $tracker = new MatomoTracker($idSite); diff --git a/PiwikTracker.php b/PiwikTracker.php index ea5ce68..a221dae 100644 --- a/PiwikTracker.php +++ b/PiwikTracker.php @@ -1,4 +1,5 @@ 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,43 @@ 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: + +``` +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) 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" } } diff --git a/phpcs.xml.dist b/phpcs.xml.dist new file mode 100644 index 0000000..8e2d458 --- /dev/null +++ b/phpcs.xml.dist @@ -0,0 +1,38 @@ + + + Matomo PHP Tracker Coding Standard + + + + + + MatomoTracker.php + PiwikTracker.php + tests + + + + + + + + + + + + + + MatomoTracker.php + PiwikTracker.php + + + + + MatomoTracker.php + PiwikTracker.php + + diff --git a/phpstan.neon.dist b/phpstan.neon.dist new file mode 100644 index 0000000..8228e29 --- /dev/null +++ b/phpstan.neon.dist @@ -0,0 +1,7 @@ +parameters: + level: max + phpVersion: 80100 + paths: + - MatomoTracker.php + - PiwikTracker.php + - tests diff --git a/phpunit.xml.dist b/phpunit.xml.dist index cb8caf5..4df188b 100644 --- a/phpunit.xml.dist +++ b/phpunit.xml.dist @@ -1,12 +1,14 @@ - - - - - ./tests/Unit - - + + + + ./tests/Unit + + + + + MatomoTracker.php + PiwikTracker.php + + diff --git a/tests/Unit/MatomoTrackerTest.php b/tests/Unit/MatomoTrackerTest.php index dc68871..670fe19 100644 --- a/tests/Unit/MatomoTrackerTest.php +++ b/tests/Unit/MatomoTrackerTest.php @@ -1,7 +1,5 @@ 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 test_trackingWithCookieSetsCorrectUrl() + public function testTrackingWithCookieSetsCorrectUrl(): void { $testVisitorId = substr(md5('testuuid'), 0, 16); $this->assertEquals(16, strlen($testVisitorId)); @@ -32,13 +63,12 @@ public function test_trackingWithCookieSetsCorrectUrl() $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 test_trackingWithCookieSetsCorrectUrl() $this->assertEquals($expected, $url); } - public function test_trackingWithPreMatomo4CookieSetsCorrectUrl() + public function testTrackingWithPreMatomo4CookieSetsCorrectUrl(): void { $testVisitorId = substr(md5('testother'), 0, 16); $this->assertEquals(16, strlen($testVisitorId)); @@ -60,13 +90,12 @@ public function test_trackingWithPreMatomo4CookieSetsCorrectUrl() $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 test_trackingWithPreMatomo4CookieSetsCorrectUrl() $this->assertEquals($expected, $url); } - public function test_setApiUrl() + 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,15 +125,49 @@ 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); + } + + 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 test_isUserAgentAIBot($userAgent, $expected) + public function testIsUserAgentAIBot(string $userAgent, bool $expected): void { $this->assertSame($expected, \MatomoTracker::isUserAgentAIBot($userAgent)); } - public function getTestDataForIsUserAgentAIBot(): array + /** + * @return list + */ + public static function getTestDataForIsUserAgentAIBot(): array { return [ ['', false], @@ -111,14 +185,19 @@ public function getTestDataForIsUserAgentAIBot(): array ]; } + public function testIsUserAgentAIBotWithNull(): void + { + $this->assertFalse(\MatomoTracker::isUserAgentAIBot(null)); + } + /** * @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): 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,7 +207,10 @@ public function test_getUrlTrackAIBot(?int $httpStatus, ?int $responseSizeBytes, $this->assertEquals($expected, $actual); } - public function getTestDataForGetUrlTrackAIBot(): array + /** + * @return list + */ + public static function getTestDataForGetUrlTrackAIBot(): array { return [ [ @@ -157,7 +239,32 @@ public 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,1632 @@ 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->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 + { + $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']); + $this->assertSame('1', $query['ca']); + + $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']); + $this->assertSame('1', $query['ca']); + + $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 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(); + + $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); + + // grandTotal is required, so an explicit zero total is sent as revenue=0 + $this->assertStringContainsString('&idgoal=0', $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 + { + $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 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 testSetAttributionInfoThrowsOnInvalidJsonWithoutLeakingPayload(): void + { + $tracker = $this->createTracker(); + $payload = 'not-json-with-secret@example.com'; + + 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 + { + $_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 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(); + + $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()); + + // 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()); + $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']); + + // 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 + { + $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']); + // 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); + $this->assertIsArray($data); + $this->assertSame('0123456789abcdef0123456789abcdef', $data['token_auth']); + $this->assertIsArray($data['requests']); + $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); + } + + /** + * 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 + { + 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->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 + { + $tracker = $this->createTracker(); + $options = $tracker->callPrepareStreamOptions('GET', null, false); + $this->assertTrue($options['http']['ignore_errors']); + } + + 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(5, $tracker->getRequestTimeout()); + $tracker->setRequestTimeout(10); + $this->assertSame(10, $tracker->getRequestTimeout()); + + $this->assertSame(2, $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 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]); + } + + 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(); + $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 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(); + + $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([]); + $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); } -} \ No newline at end of file +} diff --git a/tests/Unit/TestableMatomoTracker.php b/tests/Unit/TestableMatomoTracker.php new file mode 100644 index 0000000..77d0261 --- /dev/null +++ b/tests/Unit/TestableMatomoTracker.php @@ -0,0 +1,177 @@ + + */ + 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, + 'timeout' => $this->requestTimeout, + ]; + + 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(); + } +}