diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..0d46871 --- /dev/null +++ b/.gitattributes @@ -0,0 +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 new file mode 100644 index 0000000..b929af7 --- /dev/null +++ b/.github/workflows/phpunit.yml @@ -0,0 +1,44 @@ +name: PHPUnit + +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: + build: + name: PHPUnit + runs-on: ${{ matrix.operating-system }} + strategy: + matrix: + operating-system: [ubuntu-latest, windows-latest, macOS-latest] + php-version: ['8.1', '8.2', '8.3', '8.4', '8.5'] + steps: + - uses: actions/checkout@v4 + with: + persist-credentials: false + - name: Install PHP + uses: shivammathur/setup-php@f3e473d116dcccaddc5834248c87452386958240 # 2.37.2 + with: + php-version: ${{ matrix.php-version }} + tools: composer:v2 + extensions: curl + - name: "Composer install" + run: | + composer install --prefer-dist + - name: PHPUnit / PHP ${{ matrix.php-version }} + run: | + php -v + ./vendor/bin/phpunit 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 57f1cb2..ce9dcbc 100644 --- a/.gitignore +++ b/.gitignore @@ -1 +1,6 @@ -/.idea/ \ No newline at end of file +/.idea/ +/vendor/ +.phpunit.result.cache +/tests/.phpunit.result.cache +/.phpunit.cache/ +composer.lock diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..6eef919 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,90 @@ +# Matomo PHP Tracker Changelog + +This is the Developer Changelog for Matomo PHP Tracker. All breaking changes or new features are listed below. + +## Matomo PHP Tracker 4.0.1 + +### Fixed +- Loading `MatomoTracker.php` no longer emits a deprecation notice for the predefined `$http_response_header` variable on PHP 8.5. PHP reports it at compile time, so it was emitted on every include (#155). + +## 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). +- Auto-detection of the tracked page URL now uses `REQUEST_URI` as the source instead of `PATH_INFO`. With front-controller / path-info routing (e.g. `/dir1/page` handled by `dir1/index.php`), `PATH_INFO` only holds the trailing `/page`, so the tracker previously recorded a truncated URL; it now records the full requested path. `PATH_INFO` is no longer used at all (`SCRIPT_NAME` remains the fallback when `REQUEST_URI` is unavailable) (#141). + +### 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 + +- Fixed PHP 8.5 deprecation notice +- static `$URL` is deprecated +- a lot of arguments of `MatomoTracker` methods have explicitly types +- a lot of `MatomoTracker` method return types have strict types + +### Added +- new private property `apiUrl` for storing API URL + +## Matomo PHP Tracker 3.3.2 +### Changed +- Support for formFactors client hint parameter, supported as of Matomo 5.2.0 + +## Matomo PHP Tracker 3.3.1 +### Fixed +- closed curl connection + +## Matomo PHP Tracker 3.3.0 +### Removed +- support for PHP versions lower than 7.2 +### Changed +- all `MatomoTracker` class constants are now explicitly public +- all `MatomoTracker` dynamic properties are now explicitly public + +## Matomo PHP Tracker 3.0.0 + +Attention: This version of Matomo PHP Tracker is no longer compatible with Matomo 3.x or earlier + +- Support for new page performance metrics (added in Matomo 4) has been added. You can use `setPerformanceTimings()` to set them for page views. +- Setting page generation time using `setGenerationTime()` has been discontinued. The method still exists to not break applications still using it, but it does not have any effect. Please use new page performance metrics as replacement. +- Sending requests using cURL will now throw an exception if an error occurs in a request. +- Matomo does not longer support tracking of these browser plugins: Gears, Director. Therefor the signature of `setPlugins()` changed. +- Implementation of ecommerce views changed from custom variables to raw parameters +- It is now possible to configure cookie options for Secure, HTTPOnly and SameSite. +- Add method setRequestMethodNonBulk() to allow (non bulk) POST requests. diff --git a/LICENSE b/LICENSE index f7cc808..6efca76 100644 --- a/LICENSE +++ b/LICENSE @@ -1,4 +1,4 @@ -Copyright (c) 2014, Piwik Open Source Analytics +Copyright (c) 2014, Matomo Open Source Analytics All rights reserved. Redistribution and use in source and binary forms, with or without diff --git a/MatomoTracker.php b/MatomoTracker.php new file mode 100644 index 0000000..92a43e6 --- /dev/null +++ b/MatomoTracker.php @@ -0,0 +1,2939 @@ +, 3: string, 4: int}> + */ + public array $ecommerceItems = []; + + /** + * @var array + */ + public array $attributionInfo = []; + + /** + * @var array + */ + public array $eventCustomVar = []; + + public ?string $forcedDatetime = null; + + public bool $forcedNewVisit = false; + + public ?int $networkTime = null; + + public ?int $serverTime = null; + + public ?int $transferTime = null; + + public ?int $domProcessingTime = null; + + public ?int $domCompletionTime = null; + + public ?int $onLoadTime = null; + + /** + * @var array + */ + public array $pageCustomVar = []; + + /** + * @var array + */ + public array $ecommerceView = []; + + /** + * @var array> + */ + public array $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 = []; + + /** + * @var array + */ + public array $customDimensions = []; + + public ?string $customData = null; + + public bool $hasCookies = false; + + public ?string $token_auth = null; + + public ?string $userAgent = null; + + public ?string $country = null; + + public ?string $region = null; + + public ?string $city = null; + + public ?float $lat = null; + + public ?float $long = null; + + public ?int $width = null; + + public ?int $height = null; + + public ?string $plugins = null; + + public ?int $localHour = null; + + public ?int $localMinute = null; + + public ?int $localSecond = null; + + public ?string $idPageview = null; + + public bool $idPageviewSetManually = false; + + public int $idSite; + + public ?string $urlReferrer = null; + + public string $pageCharset = self::DEFAULT_CHARSET_PARAMETER_VALUES; + + public string $pageUrl = ''; + + public ?string $ip = null; + + public ?string $acceptLanguage = null; + + /** + * @var array + */ + public array $clientHints = []; + + // Life of the visitor cookie (in sec) + public int $configVisitorCookieTimeout = 33955200; // 13 months (365 + 28 days) + + // Life of the session cookie (in sec) + public int $configSessionCookieTimeout = 1800; // 30 minutes + + // Life of the session cookie (in sec) + public int $configReferralCookieTimeout = 15768000; // 6 months + + // Visitor Ids in order + public ?string $userId = null; + + public ?string $forcedVisitorId = null; + + public ?string $cookieVisitorId = null; + + public string $randomVisitorId = ''; + + public bool $configCookiesDisabled = false; + + public string $configCookiePath = self::DEFAULT_COOKIE_PATH; + + public string $configCookieDomain = ''; + + public string $configCookieSameSite = ''; + + public bool $configCookieSecure = false; + + public bool $configCookieHTTPOnly = false; + + public int $currentTs; + + public int $createTs; + + // Allow debug while blocking the request + public int $requestTimeout = 5; + + public int $requestConnectTimeout = 2; + + public bool $doBulkRequests = false; + + /** + * @var list + */ + public array $storedTrackingActions = []; + + public bool $sendImageResponse = true; + + // When true (default), failed tracking requests throw a RuntimeException; set false to return false instead. + public bool $exceptionsEnabled = true; + + /** + * @var array + */ + public array $outgoingTrackerCookies = []; + + /** + * @var array + */ + public array $incomingTrackerCookies = []; + + /** + * @var array + */ + public array $visitorCustomVar = []; + + private ?string $requestMethod = null; + + private string $apiUrl = ''; + + private ?string $proxy = null; + + private int $proxyPort = 80; + + /** + * Additional cURL options set via setCurlOptions(), applied last so they override the defaults. + * + * @var array + */ + private array $curlOptions = []; + + /** + * Builds a MatomoTracker object, used to track visits, pages and Goal conversions + * for a specific website, by using the Matomo Tracking API. + * + * @param int $idSite Id site to be tracked + * @param string $apiUrl "http://example.org/matomo/" or "http://matomo.example.org/" + * If set, will overwrite MatomoTracker::$URL + */ + public function __construct(int $idSite, string $apiUrl = '') + { + $this->idSite = $idSite; + $this->urlReferrer = !empty($_SERVER['HTTP_REFERER']) ? self::toStringValue($_SERVER['HTTP_REFERER']) : null; + $this->pageUrl = self::getCurrentUrl(); + $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']) ? 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; + $this->apiUrl = $apiUrl; + } + + $this->setNewVisitorId(); + + $this->currentTs = time(); + $this->createTs = $this->currentTs; + + $this->visitorCustomVar = $this->getCustomVariablesFromCookie(); + } + + public function setApiUrl(string $url): void + { + self::$URL = $url; + $this->apiUrl = $url; + } + + /** + * By default, Matomo expects utf-8 encoded values, for example + * for the page URL parameter values, Page Title, etc. + * It is recommended to only send UTF-8 data to Matomo. + * If required though, you can also specify another charset using this function. + * + * @return $this + */ + public function setPageCharset(string $charset = ''): self + { + $this->pageCharset = $charset; + + return $this; + } + + /** + * Sets the current URL being tracked + * + * @param string $url Raw URL (not URL encoded) + * @return $this + */ + public function setUrl(string $url): self + { + $this->pageUrl = $url; + + return $this; + } + + /** + * Sets the URL referrer used to track Referrers details for new visits. + * + * @param string|null $url Raw URL (not URL encoded), or null to unset the referrer + * @return $this + */ + public function setUrlReferrer(?string $url): self + { + $this->urlReferrer = $url; + + return $this; + } + + /** + * This method is deprecated and does nothing. It used to set the time that it took to generate the document on the server side. + * + * @param int $timeMs Generation time in ms + * @return $this + * + * @deprecated this metric is deprecated please use performance timings instead + * @see setPerformanceTimings + */ + public function setGenerationTime(int $timeMs): self + { + return $this; + } + + /** + * Sets timings for various browser performance metrics. + * @see https://developer.mozilla.org/en-US/docs/Web/API/PerformanceTiming + * + * @param null|int $network Network time in ms (connectEnd – fetchStart) + * @param null|int $server Server time in ms (responseStart – requestStart) + * @param null|int $transfer Transfer time in ms (responseEnd – responseStart) + * @param null|int $domProcessing DOM Processing to Interactive time in ms (domInteractive – domLoading) + * @param null|int $domCompletion DOM Interactive to Complete time in ms (domComplete – domInteractive) + * @param null|int $onload Onload time in ms (loadEventEnd – loadEventStart) + * @return $this + */ + public function setPerformanceTimings( + ?int $network = null, + ?int $server = null, + ?int $transfer = null, + ?int $domProcessing = null, + ?int $domCompletion = null, + ?int $onload = null + ): self { + $this->networkTime = $network; + $this->serverTime = $server; + $this->transferTime = $transfer; + $this->domProcessingTime = $domProcessing; + $this->domCompletionTime = $domCompletion; + $this->onLoadTime = $onload; + + return $this; + } + + /** + * Clear / reset all previously set performance metrics. + */ + public function clearPerformanceTimings(): void + { + $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): self + { + $this->setUrlReferrer($url); + + return $this; + } + + /** + * Sets the attribution information to the visit, so that subsequent Goal conversions are + * properly attributed to the right Referrer URL, timestamp, Campaign Name & Keyword. + * + * This must be a JSON encoded string that would typically be fetched from the JS API: + * matomoTracker.getAttributionInfo() and that you have JSON encoded via JSON2.stringify() + * + * If you call enableCookies() then these referral attribution values will be set + * to the 'ref' first party cookie storing referral information. + * + * @param string $jsonEncoded JSON encoded array containing Attribution info + * @return $this + * @throws Exception + * @see function getAttributionInfo() in https://github.com/matomo-org/matomo/blob/master/js/matomo.js + */ + public function setAttributionInfo(#[\SensitiveParameter] string $jsonEncoded): self + { + $decoded = json_decode($jsonEncoded, true); + if (!is_array($decoded)) { + throw new Exception("setAttributionInfo() is expecting a JSON encoded string"); + } + $this->attributionInfo = $decoded; + + return $this; + } + + /** + * Sets Visit Custom Variable. + * See https://matomo.org/docs/custom-variables/ + * + * @param int $id Custom variable slot ID from 1-5 + * @param string $name Custom variable name + * @param string $value Custom variable value + * @param string $scope Custom variable scope. Possible values: visit, page, event + * @return $this + * @throws Exception + */ + public function setCustomVariable( + int $id, + string $name, + string $value, + string $scope = 'visit' + ): self { + if ($scope === 'page') { + $this->pageCustomVar[$id] = [$name, $value]; + } elseif ($scope === 'event') { + $this->eventCustomVar[$id] = [$name, $value]; + } elseif ($scope === 'visit') { + $this->visitorCustomVar[$id] = [$name, $value]; + } else { + throw new Exception("Invalid 'scope' parameter value"); + } + return $this; + } + + /** + * Returns the currently assigned Custom Variable. + * + * If scope is 'visit', it will attempt to read the value set in the first party cookie created by Matomo Tracker + * ($_COOKIE array). + * + * @param int $id Custom Variable integer index to fetch from cookie. Should be a value from 1 to 5 + * @param string $scope Custom variable scope. Possible values: visit, page, event + * + * @throws Exception + * @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'): array|false + { + if ($scope === 'page') { + return $this->pageCustomVar[$id] ?? false; + } + + if ($scope === 'event') { + return $this->eventCustomVar[$id] ?? false; + } + + if ($scope !== 'visit') { + throw new Exception("Invalid 'scope' parameter value"); + } + + if (!empty($this->visitorCustomVar[$id])) { + return $this->visitorCustomVar[$id]; + } + + return $this->getCustomVariablesFromCookie()[$id] ?? false; + } + + /** + * Clears any Custom Variable that may be have been set. + * + * This can be useful when you have enabled bulk requests, + * and you wish to clear Custom Variables of 'visit' scope. + */ + public function clearCustomVariables(): void + { + $this->visitorCustomVar = []; + $this->pageCustomVar = []; + $this->eventCustomVar = []; + } + + /** + * Sets a specific custom dimension + * + * @param int $id id of custom dimension + * @param string $value value for custom dimension + * @return $this + */ + public function setCustomDimension(int $id, string $value): self + { + $this->customDimensions['dimension' . $id] = $value; + + return $this; + } + + /** + * Clears all previously set custom dimensions + */ + public function clearCustomDimensions(): void + { + $this->customDimensions = []; + } + + /** + * Returns the value of the custom dimension with the given id + * + * @param int $id id of custom dimension + * @return string|null + */ + public function getCustomDimension(int $id): ?string + { + return $this->customDimensions['dimension' . $id] ?? null; + } + + /** + * Sets a custom tracking parameter. This is useful if you need to send any tracking parameters for a 3rd party + * plugin that is not shipped with Matomo itself. Please note that custom parameters are cleared after each + * tracking request. + * + * @param string $trackingApiParameter The name of the tracking API parameter, eg 'bw_bytes' + * @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|array $value): self + { + $matches = []; + + if (is_string($value) && preg_match('/^dimension([0-9]+)$/', $trackingApiParameter, $matches)) { + $this->setCustomDimension((int) $matches[1], $value); + + return $this; + } + + $this->customParameters[$trackingApiParameter] = $value; + + return $this; + } + + /** + * Clear / reset all previously set custom tracking parameters. + */ + 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(): self + { + $this->randomVisitorId = substr(md5(uniqid((string) rand(), true)), 0, self::LENGTH_VISITOR_ID); + $this->forcedVisitorId = null; + $this->cookieVisitorId = null; + + return $this; + } + + /** + * Sets the current site ID. + * + * @return $this + */ + public function setIdSite(int $idSite): self + { + $this->idSite = $idSite; + + return $this; + } + + /** + * Sets the Browser language. Used to guess visitor countries when GeoIP is not enabled + * + * @param string $acceptLanguage For example "fr-fr" + * @return $this + */ + public function setBrowserLanguage(string $acceptLanguage): self + { + $this->acceptLanguage = $acceptLanguage; + + return $this; + } + + /** + * Sets the user agent, used to detect OS and browser. + * If this function is not called, the User Agent will default to the current user agent. + * + * @param string $userAgent + * @return $this + */ + public function setUserAgent(string $userAgent): self + { + $this->userAgent = $userAgent; + + return $this; + } + + /** + * Sets the client hints, used to detect OS and browser. + * If this function is not called, the client hints sent with the current request will be used. + * + * Supported as of Matomo 4.12.0 + * + * @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|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' + * or an array containing all form factors with structure ["Desktop", "XR"] + * + * @return $this + */ + public function setClientHints( + string $model = '', + string $platform = '', + string $platformVersion = '', + string|array $fullVersionList = '', + string $uaFullVersion = '', + string|array $formFactors = '' + ): self { + if (is_string($fullVersionList)) { + $reg = '/^"([^"]+)"; ?v="([^"]+)"(?:, )?/'; + $list = []; + + while (\preg_match($reg, $fullVersionList, $matches)) { + $list[] = ['brand' => $matches[1], 'version' => $matches[2]]; + $fullVersionList = \substr($fullVersionList, \strlen($matches[0])); + } + + $fullVersionList = $list; + } + + if (is_string($formFactors)) { + $formFactors = explode(',', $formFactors); + $formFactors = array_filter(array_map( + function ($item) { + return trim($item, '" '); + }, + $formFactors + )); + } + + $this->clientHints = array_filter([ + 'model' => $model, + 'platform' => $platform, + 'platformVersion' => $platformVersion, + 'uaFullVersion' => $uaFullVersion, + 'fullVersionList' => $fullVersionList, + 'formFactors' => $formFactors, + ]); + + return $this; + } + + /** + * Sets the country of the visitor. If not used, Matomo will try to find the country + * using either the visitor's IP address or language. + * + * Allowed only for Admin/Super User, must be used along with setTokenAuth(). + * + * @return $this + */ + public function setCountry(string $country): self + { + $this->country = $country; + + return $this; + } + + /** + * Sets the region of the visitor. If not used, Matomo may try to find the region + * using the visitor's IP address (if configured to do so). + * + * Allowed only for Admin/Super User, must be used along with setTokenAuth(). + * + * @return $this + */ + public function setRegion(string $region): self + { + $this->region = $region; + + return $this; + } + + /** + * Sets the city of the visitor. If not used, Matomo may try to find the city + * using the visitor's IP address (if configured to do so). + * + * Allowed only for Admin/Super User, must be used along with setTokenAuth(). + * + * @return $this + */ + public function setCity(string $city): self + { + $this->city = $city; + + return $this; + } + + /** + * Sets the latitude of the visitor. If not used, Matomo may try to find the visitor's + * latitude using the visitor's IP address (if configured to do so). + * + * Allowed only for Admin/Super User, must be used along with setTokenAuth(). + * + * @return $this + */ + public function setLatitude(float $lat): self + { + $this->lat = $lat; + + return $this; + } + + /** + * Sets the longitude of the visitor. If not used, Matomo may try to find the visitor's + * longitude using the visitor's IP address (if configured to do so). + * + * Allowed only for Admin/Super User, must be used along with setTokenAuth(). + * + * @return $this + */ + public function setLongitude(float $long): self + { + $this->long = $long; + + return $this; + } + + /** + * Enables the bulk request feature. When used, each tracking action is stored until the + * doBulkTrack method is called. This method will send all tracking data at once. + */ + public function enableBulkTracking(): void + { + $this->doBulkRequests = true; + } + + /** + * Disables the bulk request feature. Make sure to call `doBulkTrack()` before disabling it if you have stored + * tracking actions previously as this method won't be sending any previously stored actions before disabling it. + */ + public function disableBulkTracking(): void + { + $this->doBulkRequests = false; + } + + /** + * Enable Cookie Creation - this will cause a first party VisitorId cookie to be set when the VisitorId is set or reset + * + * @param string $domain (optional) Set first-party cookie domain. + * Accepted values: example.com, *.example.com (same as .example.com) or subdomain.example.com + * @param string $path (optional) Set first-party cookie path + * @param bool $secure (optional) Set secure flag for cookies + * @param bool $httpOnly (optional) Set HTTPOnly flag for cookies + * @param string $sameSite (optional) Set SameSite flag for cookies + */ + public function enableCookies( + string $domain = '', + string $path = '/', + bool $secure = false, + bool $httpOnly = false, + string $sameSite = '' + ): void { + $this->configCookiesDisabled = false; + $this->configCookieDomain = self::domainFixup($domain); + $this->configCookiePath = $path; + $this->configCookieSecure = $secure; + $this->configCookieHTTPOnly = $httpOnly; + $this->configCookieSameSite = $sameSite; + } + + /** + * If image response is disabled Matomo will respond with a HTTP 204 header instead of responding with a gif. + */ + public function disableSendImageResponse(): void + { + $this->sendImageResponse = false; + } + + /** + * Fix-up domain + */ + protected static function domainFixup(string $domain): string + { + if (strlen($domain) > 0) { + $dl = strlen($domain) - 1; + // remove trailing '.' + if ($domain[$dl] === '.') { + $domain = substr($domain, 0, $dl); + } + // remove leading '*' + if (substr($domain, 0, 2) === '*.') { + $domain = substr($domain, 1); + } + } + + return $domain; + } + + /** + * Get cookie name with prefix and domain hash + */ + protected function getCookieName(string $cookieName): string + { + // NOTE: If the cookie name is changed, we must also update the method in matomo.js with the same name. + $hash = substr( + sha1( + ($this->configCookieDomain === '' + ? self::getCurrentHost() + : $this->configCookieDomain + ) . $this->configCookiePath + ), + 0, + 4 + ); + + return self::FIRST_PARTY_COOKIES_PREFIX . $cookieName . '.' . $this->idSite . '.' . $hash; + } + + /** + * Tracks a page view + * + * @param string $documentTitle Page title as it will appear in the Actions > Page titles report + * @return string|bool Response string or true if using bulk requests. + */ + public function doTrackPageView(string $documentTitle): string|bool + { + if (!$this->idPageviewSetManually) { + $this->generateNewPageviewId(); + } + + $url = $this->getUrlTrackPageView($documentTitle); + + return $this->sendRequest($url); + } + + /** + * If the current user agent belongs to a known AI bot, tracks a pageview action. + * + * This method should be used server side to track AI bots that do not execute + * JavaScript. If the current user agent is not a known AI bot, nothing is tracked + * and null is returned. + * + * @param int|null $httpStatus the request's HTTP status code, if known. + * @param int|null $responseSizeBytes the size of the response sent to the AI bot, if known. + * @param int|null $serverTimeMs the number of milliseconds it took to process the request, if known. + * @param string|null $source the source/proxy that served the request (max 50 chars), if known. + * @return string|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): string|bool|null + { + if (!self::isUserAgentAIBot($this->userAgent)) { + return null; + } + + $url = $this->getUrlTrackAIBot($httpStatus, $responseSizeBytes, $serverTimeMs, $source); + return $this->sendRequest($url); + } + + /** + * Override PageView id for every use of `doTrackPageView()`. Do not use this if you call `doTrackPageView()` + * multiple times during tracking (if, for example, you are tracking a single page application). + */ + public function setPageviewId(string $idPageview): void + { + $this->idPageview = $idPageview; + $this->idPageviewSetManually = true; + } + + /** + * Returns the PageView id. If the id was manually set using `setPageViewId()`, that id will be returned. + * If the id was not set manually, the id that was automatically generated in last `doTrackPageView()` will + * be returned. If there was no last page view, this will be false. + * + * @return string|null The PageView id as string or null if there is none yet. + */ + public function getPageviewId(): ?string + { + return $this->idPageview; + } + + private function generateNewPageviewId(): void + { + $this->idPageview = substr(md5(uniqid((string) rand(), true)), 0, 6); + } + + /** + * Tracks an event + * + * @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|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, + ?string $name = null, + int|float|null $value = null + ): string|bool { + $url = $this->getUrlTrackEvent($category, $action, $name, $value); + + return $this->sendRequest($url); + } + + /** + * Tracks a content impression + * + * @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|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', + ?string $contentTarget = null + ): string|bool { + $url = $this->getUrlTrackContentImpression($contentName, $contentPiece, $contentTarget); + + return $this->sendRequest($url); + } + + /** + * Tracks a content interaction. Make sure you have tracked a content impression using the same content name and + * content piece, otherwise it will not count. To do so you should call the method 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|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', + ?string $contentTarget = null + ): string|bool { + $url = $this->getUrlTrackContentInteraction($interaction, $contentName, $contentPiece, $contentTarget); + + return $this->sendRequest($url); + } + + /** + * Tracks an internal Site Search query, and optionally tracks the Search Category, and Search results Count. + * These are used to populate reports in Actions > Site Search. + * + * @param string $keyword Searched query on the site + * @param string $category (optional) Search engine category if applicable + * @param int|null $countResults (optional) results displayed on the search result page. Used to track "zero result" keywords. + * + * @return string|bool Response or true if using bulk requests. + */ + public function doTrackSiteSearch( + string $keyword, + string $category = '', + ?int $countResults = null + ): string|bool { + $url = $this->getUrlTrackSiteSearch($keyword, $category, $countResults); + + return $this->sendRequest($url); + } + + /** + * Records a Goal conversion + * + * @param int $idGoal Id Goal to record a conversion + * @param float|null $revenue Revenue for this conversion. Pass null (default) to omit the + * revenue so Matomo uses the goal's configured revenue; pass 0.0 to force a zero revenue. + * @return string|bool Response or true if using bulk request + */ + public function doTrackGoal(int $idGoal, ?float $revenue = null): string|bool + { + $url = $this->getUrlTrackGoal($idGoal, $revenue); + + return $this->sendRequest($url); + } + + /** + * Tracks a download or outlink + * + * @param string $actionUrl URL of the download or outlink + * @param string $actionType Type of the action: 'download' or 'link' + * @return string|bool Response or true if using bulk request + */ + 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); + + return $this->sendRequest($url); + } + + /** + * Adds an item in the Ecommerce order. + * + * This should be called before doTrackEcommerceOrder(), or before doTrackEcommerceCartUpdate(). + * This function can be called for all individual products in the cart (or order). + * SKU parameter is mandatory. Other parameters are optional (set to false if value not known). + * Ecommerce items added via this function are automatically cleared when doTrackEcommerceOrder() or getUrlTrackEcommerceOrder() is called. + * + * @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 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 + */ + public function addEcommerceItem( + string $sku, + string $name = '', + 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"); + } + + $priceNormalized = $this->forceDotAsSeparatorForDecimalPoint($price); + + $this->ecommerceItems[] = [$sku, $name, $category, $priceNormalized, $quantity]; + + return $this; + } + + /** + * Tracks a Cart Update (add item, remove item, update item). + * + * On every Cart update, you must call addEcommerceItem() for each item (product) in the cart, + * including the items that haven't been updated since the last cart update. + * 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 string|bool Response or true if using bulk request + */ + public function doTrackEcommerceCartUpdate(float $grandTotal): string|bool + { + $url = $this->getUrlTrackEcommerceCartUpdate($grandTotal); + + return $this->sendRequest($url); + } + + /** + * Sends all stored tracking actions at once. Only has an effect if bulk tracking is enabled. + * + * To enable bulk tracking, call enableBulkTracking(). + * + * @throws Exception + * @return string|bool Response + */ + public function doBulkTrack(): string|bool + { + if (empty($this->storedTrackingActions)) { + throw new Exception( + "Error: you must call the function doTrackPageView or doTrackGoal from this class, + before calling this method doBulkTrack()" + ); + } + + $data = ['requests' => $this->storedTrackingActions]; + + // token_auth is not required by default, except if bulk_requests_require_authentication=1 + if (!empty($this->token_auth)) { + $data['token_auth'] = $this->token_auth; + } + + $postData = json_encode($data); + if ($postData === false) { + throw new Exception("Failed to JSON encode the bulk tracking request"); + } + + // 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; + } + + /** + * Tracks an Ecommerce order. + * + * If the Ecommerce order contains items (products), you must call first the addEcommerceItem() for each item in the order. + * All revenues (grandTotal, subTotal, tax, shipping, discount) will be individually summed and reported in Matomo reports. + * Only the parameters $orderId and $grandTotal are required. + * + * @param string|int $orderId (required) Unique Order ID. + * 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|null $subTotal (optional) Sub total amount, typically the sum of items prices for all items in this order (before Tax and Shipping costs are applied). Pass null to omit, 0.0 to send an explicit zero. + * @param float|null $tax (optional) Tax amount for this order + * @param float|null $shipping (optional) Shipping amount for this order + * @param float|null $discount (optional) Discounted amount in this order + * @return string|bool Response or true if using bulk request + */ + public function doTrackEcommerceOrder( + string|int $orderId, + float $grandTotal, + ?float $subTotal = 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); + } + + /** + * Tracks a PHP Throwable a crash (requires CrashAnalytics to be enabled in the target Matomo) + * + * @param Throwable $throwable (required) the throwable to track. The message, stack trace, file location and line number + * of the crash are deduced from this parameter. The crash type is set to the class name of + * 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 string|bool Response or true if using bulk request + */ + public function doTrackPhpThrowable(Throwable $throwable, ?string $category = null): string|bool + { + $message = $throwable->getMessage(); + $stack = $throwable->getTraceAsString(); + $type = get_class($throwable); + $location = $throwable->getFile(); + $line = $throwable->getLine(); + + return $this->doTrackCrash($message, $type, $category, $stack, $location, $line); + } + + /** + * Track a crash (requires CrashAnalytics to be enabled in the target Matomo) + * + * @param string $message (required) the error message. + * @param string|null $type (optional) the error type, such as the class name of an Exception. + * @param string|null $category (optional) a category value for this crash. This can be any information you want + * to attach to the crash. + * @param string|null $stack (optional) the stack trace of the crash. + * @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 string|bool Response or true if using bulk request + */ + public function doTrackCrash( + string $message, + ?string $type = null, + ?string $category = null, + ?string $stack = null, + ?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); + } + + /** + * Sends a ping request. + * + * Ping requests do not track new actions. If they are sent within the standard visit length (see global.ini.php), + * 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 string|bool Response or true if using bulk request + */ + public function doPing(): string|bool + { + $url = $this->getRequest($this->idSite); + $url .= '&ping=1'; + + return $this->sendRequest($url); + } + + /** + * Sets the current page view as an item (product) page view, or an Ecommerce Category page view. + * + * This must be called before doTrackPageView() on this product/category page. + * + * 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. + * 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 + */ + public function setEcommerceView( + string $sku = '', + string $name = '', + string|array $category = '', + float $price = 0.0 + ): self { + $this->ecommerceView = []; + + if (empty($category)) { + $category = ''; + } elseif (is_array($category)) { + $category = (string) json_encode($category); + } + $this->ecommerceView['_pkc'] = $category; + + if (!empty($price)) { + $this->ecommerceView['_pkp'] = $this->forceDotAsSeparatorForDecimalPoint($price); + } + + // On a category page, do not record "Product name not defined" + if (empty($sku) && empty($name)) { + return $this; + } + if (!empty($sku)) { + $this->ecommerceView['_pks'] = $sku; + } + $this->ecommerceView['_pkn'] = $name; + + return $this; + } + + /** + * 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 int|float|string $value + */ + private function forceDotAsSeparatorForDecimalPoint(int|float|string $value): string + { + return str_replace(',', '.', (string) $value); + } + + /** + * Builds a URL to track a request from an AI bot. + * + * @param int|null $httpStatus the request's HTTP status code, if it is known. + * @param int|null $responseSizeBytes the size of the response sent to the AI bot, if known. + * @param int|null $serverTimeMs the number of milliseconds it took to process the request, if known. + * @param string|null $source the source/proxy that served the request (max 50 chars), if known. + * @return string + */ + public function getUrlTrackAIBot(?int $httpStatus = null, ?int $responseSizeBytes = null, ?int $serverTimeMs = null, ?string $source = null): string + { + $url = $this->getRequest($this->idSite); + + $url .= '&recMode=1'; + + if ($httpStatus !== null) { + $url .= '&http_status=' . $httpStatus; + } + + if ($responseSizeBytes !== null) { + $url .= '&bw_bytes=' . $responseSizeBytes; + } + + if ($serverTimeMs !== null) { + $url .= '&pf_srv=' . $serverTimeMs; + } + + if ($source !== null && $source !== '') { + $url .= '&source=' . rawurlencode(substr($source, 0, 50)); + } + + return $url; + } + + /** + * Returns URL used to track Ecommerce Cart updates + * Calling this function will reinitializes the property ecommerceItems to empty array + * so items will have to be added again via addEcommerceItem() + * @ignore + */ + public function getUrlTrackEcommerceCartUpdate(float $grandTotal): string + { + return $this->getUrlTrackEcommerce($grandTotal); + } + + /** + * Returns URL used to track Ecommerce Orders + * Calling this function will reinitializes the property ecommerceItems to empty array + * so items will have to be added again via addEcommerceItem() + * @ignore + */ + public function getUrlTrackEcommerceOrder( + 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((string) $orderId); + + return $url; + } + + /** + * Returns URL used to track Ecommerce orders + * + * Calling this function will reinitializes the property ecommerceItems to empty array + * so items will have to be added again via addEcommerceItem() + * + * @ignore + */ + 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'; + // 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 ($tax !== null) { + $url .= '&ec_tx=' . $this->forceDotAsSeparatorForDecimalPoint($tax); + } + if ($shipping !== null) { + $url .= '&ec_sh=' . $this->forceDotAsSeparatorForDecimalPoint($shipping); + } + if ($discount !== null) { + $url .= '&ec_dt=' . $this->forceDotAsSeparatorForDecimalPoint($discount); + } + if (!empty($this->ecommerceItems)) { + $url .= '&ec_items=' . urlencode((string) json_encode($this->ecommerceItems)); + } + $this->ecommerceItems = []; + + return $url; + } + + /** + * Builds URL to track a page view. + * + * @see doTrackPageView() + * @param string $documentTitle Page view name as it will appear in Matomo reports + * @return string URL to matomo.php with all parameters set to track the pageview + */ + public function getUrlTrackPageView(string $documentTitle = ''): string + { + $url = $this->getRequest($this->idSite); + if (strlen($documentTitle) > 0) { + $url .= '&action_name=' . urlencode($documentTitle); + } + + return $url; + } + + /** + * Builds URL to track a custom event. + * + * @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|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 Exception + */ + public function getUrlTrackEvent( + string $category, + string $action, + ?string $name = null, + int|float|null $value = null + ): string { + $url = $this->getRequest($this->idSite); + if (strlen($category) === 0) { + throw new Exception("You must specify an Event Category name (Music, Videos, Games...)."); + } + if (strlen($action) === 0) { + throw new Exception("You must specify an Event action (click, view, add...)."); + } + + $url .= '&e_c=' . urlencode($category); + $url .= '&e_a=' . urlencode($action); + // mark as a custom action so Matomo does not fall back to tracking it as a page view + $url .= '&ca=1'; + + if ($name !== null && $name !== '') { + $url .= '&e_n=' . urlencode($name); + } + if ($value !== null) { + $url .= '&e_v=' . $this->forceDotAsSeparatorForDecimalPoint($value); + } + + return $url; + } + + /** + * Builds URL to track a content impression. + * + * @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|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, + ?string $contentTarget + ): string { + $url = $this->getRequest($this->idSite); + + if (strlen($contentName) === 0) { + throw new Exception("You must specify a content name"); + } + + $url .= '&c_n=' . urlencode($contentName); + // mark as a custom action so Matomo does not fall back to tracking it as a page view + $url .= '&ca=1'; + + if (!empty($contentPiece)) { + $url .= '&c_p=' . urlencode($contentPiece); + } + if (!empty($contentTarget)) { + $url .= '&c_t=' . urlencode($contentTarget); + } + + return $url; + } + + /** + * Builds URL to track a content interaction. + * + * @see doTrackContentInteraction() + * @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|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 + */ + public function getUrlTrackContentInteraction( + string $interaction, + string $contentName, + string $contentPiece, + ?string $contentTarget + ): string { + $url = $this->getRequest($this->idSite); + + if (strlen($interaction) === 0) { + throw new Exception("You must specify a name for the interaction"); + } + + if (strlen($contentName) === 0) { + throw new Exception("You must specify a content name"); + } + + $url .= '&c_i=' . urlencode($interaction); + $url .= '&c_n=' . urlencode($contentName); + // mark as a custom action so Matomo does not fall back to tracking it as a page view + $url .= '&ca=1'; + + if (!empty($contentPiece)) { + $url .= '&c_p=' . urlencode($contentPiece); + } + if (!empty($contentTarget)) { + $url .= '&c_t=' . urlencode($contentTarget); + } + + return $url; + } + + /** + * Builds URL to track a site search. + * + * @see doTrackSiteSearch() + */ + 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 ($countResults !== null) { + $url .= '&search_count=' . $countResults; + } + + return $url; + } + + /** + * Builds URL to track a goal with idGoal and revenue. + * + * @see doTrackGoal() + * @param int $idGoal Id Goal to record a 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 = null): string + { + $url = $this->getRequest($this->idSite); + $url .= '&idgoal=' . $idGoal; + if ($revenue !== null) { + $url .= '&revenue=' . $this->forceDotAsSeparatorForDecimalPoint($revenue); + } + + return $url; + } + + /** + * Builds URL to track a new action. + * + * @see doTrackAction() + * @param string $actionUrl URL of the download or outlink + * @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 .= '&' . urlencode($actionType) . '=' . urlencode($actionUrl); + + return $url; + } + + /** + * Builds URL to track a crash. + * + * @see doTrackCrash() + * @param string $message (required) the error message. + * @param string|null $type (optional) the error type, such as the class name of an Exception. + * @param string|null $category (optional) a category value for this crash. This can be any information you want + * to attach to the crash. + * @param string|null $stack (optional) the stack trace of the crash. + * @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 string URL to matomo.php with all parameters set to track an action + */ + public function getUrlTrackCrash( + string $message, + ?string $type = null, + ?string $category = null, + ?string $stack = null, + ?string $location = null, + ?int $line = null, + ?int $column = null + ): string { + $url = $this->getRequest($this->idSite); + $url .= '&ca=1&cra=' . urlencode($message); + if ($type) { + $url .= '&cra_tp=' . urlencode($type); + } + if ($category) { + $url .= '&cra_ct=' . urlencode($category); + } + if ($stack) { + $url .= '&cra_st=' . urlencode($stack); + } + if ($location) { + $url .= '&cra_ru=' . urlencode($location); + } + if ($line) { + $url .= '&cra_rl=' . urlencode((string) $line); + } + if ($column) { + $url .= '&cra_rc=' . urlencode((string) $column); + } + + return $url; + } + + /** + * Overrides server date and time for the tracking requests. + * By default Matomo will track requests for the "current datetime" but this function allows you + * to track visits in the past. All times are in UTC. + * + * Allowed only for Admin/Super User, must be used along with setTokenAuth() + * @see setTokenAuth() + * @param string $dateTime Date with the format 'Y-m-d H:i:s', or a UNIX timestamp. + * 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): self + { + $this->forcedDatetime = $dateTime; + + return $this; + } + + /** + * Forces Matomo to create a new visit for the tracking request. + * + * By default, Matomo will create a new visit if the last request by this user was more than 30 minutes ago. + * If you call setForceNewVisit() before calling doTrack*, then a new visit will be created for this request. + * @return $this + */ + public function setForceNewVisit(): self + { + $this->forcedNewVisit = true; + + return $this; + } + + /** + * Overrides IP address + * + * Allowed only for Admin/Super User, must be used along with setTokenAuth() + * @see setTokenAuth() + * @param string $ip IP string, eg. 130.54.2.1 + * @return $this + */ + public function setIp(string $ip): self + { + $this->ip = $ip; + + return $this; + } + + /** + * Force the action to be recorded for a specific User. The User ID is a string representing a given user in your system. + * + * A User ID can be a username, UUID or an email address, or any number or string that uniquely identifies a user or client. + * + * @param string|null $userId Any user ID string (eg. email address, ID, username). Must be non-empty. + * Set to null to 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): self + { + if ($userId === '') { + throw new Exception("User ID cannot be empty."); + } + $this->userId = $userId; + + return $this; + } + + /** + * Hash function used internally by Matomo to hash a User ID into the Visitor ID. + * + * Note: matches implementation of Tracker\Request->getUserIdHashed() + */ + public static function getUserIdHashed(string $id): string + { + return substr(sha1($id), 0, 16); + } + + /** + * Forces the requests to be recorded for the specified Visitor ID. + * + * Rather than letting Matomo attribute the user with a heuristic based on IP and other user fingeprinting attributes, + * force the action to be recorded for a particular visitor. + * + * If not set, the visitor ID will be fetched from the 1st party cookie, or will be set to a random UUID. + * + * @param string $visitorId 16 hexadecimal characters visitor ID, eg. "33c31e01394bdc63" + * @return $this + * @throws Exception + */ + public function setVisitorId(string $visitorId): self + { + $hexChars = self::HEX_CHARACTERS; + if ( + strlen($visitorId) !== self::LENGTH_VISITOR_ID + || strspn($visitorId, $hexChars) !== strlen($visitorId) + ) { + throw new Exception( + "setVisitorId() expects a " + . self::LENGTH_VISITOR_ID + . " characters hexadecimal string (containing only the following: " + . $hexChars + . ")" + ); + } + $this->forcedVisitorId = $visitorId; + + return $this; + } + + /** + * If the user initiating the request has the Matomo first party cookie, + * this function will try and return the ID parsed from this first party cookie (found in $_COOKIE). + * + * If you call this function from a server, where the call is triggered by a cron or script + * not initiated by the actual visitor being tracked, then it will return + * the random Visitor ID that was assigned to this visit object. + * + * This can be used if you wish to record more visits, actions or goals for this visitor ID later on. + * + * @return string 16 hex chars visitor ID string + */ + public function getVisitorId(): string + { + if (!empty($this->forcedVisitorId)) { + return $this->forcedVisitorId; + } + if ($this->loadVisitorIdCookie() && $this->cookieVisitorId !== null) { + return $this->cookieVisitorId; + } + + return $this->randomVisitorId; + } + + /** + * Returns the currently set user agent. + */ + public function getUserAgent(): ?string + { + return $this->userAgent; + } + + /** + * Returns the currently set IP address. + */ + public function getIp(): ?string + { + return $this->ip; + } + + /** + * Returns the User ID string, which may have been set via: + * $v->setUserId('username@example.org'); + */ + public function getUserId(): ?string + { + return $this->userId; + } + + /** + * Loads values from the VisitorId Cookie + * + * @return bool True if cookie exists and is valid, False otherwise + */ + protected function loadVisitorIdCookie(): bool + { + $idCookie = $this->getCookieMatchingName('id'); + if ($idCookie === false) { + return false; + } + $parts = explode('.', $idCookie); + $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]; + if (isset($parts[1])) { + $this->createTs = (int) $parts[1]; + } + + return true; + } + + /** + * Deletes all first party cookies from the client + */ + public function deleteCookies(): void + { + $cookies = ['id', 'ses', 'cvar', 'ref']; + foreach ($cookies as $cookie) { + $this->setCookie($cookie, '', -86400); + } + } + + /** + * Returns the currently assigned Attribution Information stored in a first party cookie. + * + * 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|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(): string|false + { + if (!empty($this->attributionInfo)) { + return json_encode($this->attributionInfo); + } + + return $this->getCookieMatchingName('ref'); + } + + /** + * Some Tracking API functionality requires express authentication, using either the + * Super User token_auth, or a user with 'admin' access to the website. + * + * The following features require access: + * - force the visitor IP + * - force the date & time of the tracking requests rather than track for the current datetime + * + * @param string $token_auth token_auth 32 chars token_auth string + * @return $this + */ + public function setTokenAuth(#[\SensitiveParameter] string $token_auth): self + { + $this->token_auth = $token_auth; + + return $this; + } + + /** + * Sets local visitor time + * + * @param string $time HH:MM:SS format + * @return $this + */ + public function setLocalTime(string $time): self + { + [$hour, $minute, $second] = explode(':', $time); + $this->localHour = (int)$hour; + $this->localMinute = (int)$minute; + $this->localSecond = (int)$second; + + return $this; + } + + /** + * Sets user resolution width and height. + * + * @param int $width + * @param int $height + * @return $this + */ + public function setResolution(int $width, int $height): self + { + $this->width = $width; + $this->height = $height; + + return $this; + } + + /** + * Sets if the browser supports cookies + * This is reported in "List of plugins" report in Matomo. + * + * @return $this + */ + public function setBrowserHasCookies(bool $hasCookies): self + { + $this->hasCookies = $hasCookies; + + return $this; + } + + /** + * Will append a custom string at the end of the Tracking request. + * + * @return $this + */ + public function setDebugStringAppend(string $debugString): self + { + $this->DEBUG_APPEND_URL = '&' . $debugString; + + return $this; + } + + /** + * Sets visitor browser supported plugins + * + * @return $this + */ + public function setPlugins( + bool $flash = false, + bool $java = false, + bool $quickTime = false, + bool $realPlayer = false, + bool $pdf = false, + bool $windowsMedia = false, + bool $silverlight = false + ): self { + $this->plugins = + '&fla=' . (int)$flash . + '&java=' . (int)$java . + '&qt=' . (int)$quickTime . + '&realp=' . (int)$realPlayer . + '&pdf=' . (int)$pdf . + '&wma=' . (int)$windowsMedia . + '&ag=' . (int)$silverlight; + + return $this; + } + + /** + * By default, MatomoTracker will read first party cookies + * from the request and write updated cookies in the response (using setrawcookie). + * This can be disabled by calling this function. + */ + public function disableCookieSupport(): void + { + $this->configCookiesDisabled = true; + } + + /** + * Returns the maximum number of seconds the tracker will spend waiting for a response + * from Matomo. Defaults to 5 seconds. + */ + public function getRequestTimeout(): int + { + return $this->requestTimeout; + } + + /** + * Sets the maximum number of seconds that the tracker will spend waiting for a response + * from Matomo. + * + * @return $this + * @throws Exception + */ + public function setRequestTimeout(int $timeout): self + { + if ($timeout < 0) { + throw new Exception("Invalid value supplied for request timeout: $timeout"); + } + + $this->requestTimeout = $timeout; + + return $this; + } + + /** + * Returns the maximum number of seconds the tracker will spend trying to connect to Matomo. + * Defaults to 2 seconds. + */ + public function getRequestConnectTimeout(): int + { + return $this->requestConnectTimeout; + } + + /** + * Sets the maximum number of seconds that the tracker will spend tryint to connect to Matomo. + * + * @param int $timeout + * @return $this + * @throws Exception + */ + public function setRequestConnectTimeout(int $timeout): self + { + if ($timeout < 0) { + throw new Exception("Invalid value supplied for request connect timeout: $timeout"); + } + + $this->requestConnectTimeout = $timeout; + + return $this; + } + + /** + * Sets the request method to POST, which is recommended when using setTokenAuth() + * to prevent the token from being recorded in server logs. Avoid using redirects + * when using POST to prevent the loss of POST values. When using Log Analytics, + * be aware that POST requests are not parseable/replayable. + * + * @param string $method Either 'POST' or 'GET' + * @return $this + */ + public function setRequestMethodNonBulk(string $method): self + { + $this->requestMethod = strtoupper($method) === 'POST' ? 'POST' : 'GET'; + + return $this; + } + + /** + * If a proxy is needed to look up the address of the Matomo site, set it with this + * @param string $proxy IP as string, for example "173.234.92.107" + */ + public function setProxy(string $proxy, int $proxyPort = 80): void + { + $this->proxy = $proxy; + $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 ($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 + */ + public static string|false $DEBUG_LAST_REQUESTED_URL = false; + + /** + * Returns array of curl options for request + * + * @return array + */ + protected function prepareCurlOptions( + #[\SensitiveParameter] string $url, + string $method, + #[\SensitiveParameter] ?string $data, + bool $forcePostUrlEncoded + ): array { + $options = [ + CURLOPT_URL => $url, + 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->normalizeHeaderValue($this->acceptLanguage), + ], + ]; + + if ($method === 'GET') { + $options[CURLOPT_FOLLOWLOCATION] = true; + } + + if (defined('PATH_TO_CERTIFICATES_FILE')) { + $options[CURLOPT_CAINFO] = PATH_TO_CERTIFICATES_FILE; + } + + $proxy = $this->getProxy(); + if (isset($proxy)) { + $options[CURLOPT_PROXY] = $proxy; + } + + switch ($method) { + case 'POST': + $options[CURLOPT_POST] = true; + break; + default: + break; + } + + // only supports JSON data + if (!empty($data) && $forcePostUrlEncoded) { + $options[CURLOPT_HTTPHEADER][] = 'Content-Type: application/x-www-form-urlencoded'; + $options[CURLOPT_POSTFIELDS] = $data; + $options[CURLOPT_POST] = true; + if (defined('CURL_REDIR_POST_ALL')) { + $options[CURLOPT_POSTREDIR] = CURL_REDIR_POST_ALL; + $options[CURLOPT_FOLLOWLOCATION] = true; + } + } elseif (!empty($data)) { + $options[CURLOPT_HTTPHEADER][] = 'Content-Type: application/json'; + $options[CURLOPT_HTTPHEADER][] = 'Expect:'; + $options[CURLOPT_POSTFIELDS] = $data; + } + + if (!empty($this->outgoingTrackerCookies)) { + $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; + } + + /** + * Returns array of stream options for request + * + * @return array{http: array} + */ + protected function prepareStreamOptions(string $method, #[\SensitiveParameter] ?string $data, bool $forcePostUrlEncoded): array + { + $stream_options = [ + 'http' => [ + 'method' => $method, + '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, + ], + ]; + + $proxy = $this->getProxy(); + if (isset($proxy)) { + $stream_options['http']['proxy'] = $proxy; + } + + // only supports JSON data + if (!empty($data) && $forcePostUrlEncoded) { + $stream_options['http']['header'] .= "Content-Type: application/x-www-form-urlencoded \r\n"; + $stream_options['http']['content'] = $data; + } elseif (!empty($data)) { + $stream_options['http']['header'] .= "Content-Type: application/json \r\n"; + $stream_options['http']['content'] = $data; + } + + if (!empty($this->outgoingTrackerCookies)) { + $stream_options['http']['header'] .= 'Cookie: ' . $this->buildOutgoingCookieHeader() . "\r\n"; + $this->outgoingTrackerCookies = []; + } + + return $stream_options; + } + + /** + * @ignore + */ + protected function sendRequest(#[\SensitiveParameter] string $url, string $method = 'GET', #[\SensitiveParameter] ?string $data = null, bool $force = false): string|bool + { + self::$DEBUG_LAST_REQUESTED_URL = $url; + + // if doing a bulk request, store the url + if ($this->doBulkRequests && !$force) { + $this->storedTrackingActions[] + = $url + . (!empty($this->userAgent) ? ('&ua=' . urlencode($this->userAgent)) : '') + . (!empty($this->acceptLanguage) ? ('&lang=' . urlencode($this->acceptLanguage)) : ''); + + // Clear custom variables & dimensions so they don't get copied over to other users in the bulk request + $this->clearCustomVariables(); + $this->clearCustomDimensions(); + $this->clearCustomTrackingParameters(); + $this->userAgent = null; + $this->clientHints = []; + $this->acceptLanguage = null; + + return true; + } + + $forcePostUrlEncoded = false; + if (!$this->doBulkRequests) { + if (!empty($this->requestMethod) && strtoupper($this->requestMethod) === 'POST') { + // POST ALL parameters and have no GET parameters + $urlParts = explode('?', $url); + + $url = $urlParts[0]; + $data = $urlParts[1] ?? ''; + $forcePostUrlEncoded = true; + + $method = 'POST'; + } + + if (!empty($this->token_auth)) { + $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. + // 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 '&' + } else { + // Use GET for all URL parameters + $url .= $appendTokenString; + } + } + } + + $content = ''; + + if ($this->hasCurlSupport()) { + $options = $this->prepareCurlOptions($url, $method, $data, $forcePostUrlEncoded); + + $ch = curl_init(); + curl_setopt_array($ch, $options); + ob_start(); + $response = @curl_exec($ch); + + try { + $header = ''; + + if ($response === false) { + $curlError = curl_error($ch); + if (!empty($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) && is_string($response)) { + // extract header + $headerSize = (int) curl_getinfo($ch, CURLINFO_HEADER_SIZE); + $header = substr($response, 0, $headerSize); + + // extract content + $content = substr($response, $headerSize); + } + + $this->parseIncomingCookies(explode("\r\n", $header)); + } finally { + ob_end_clean(); + } + } elseif (function_exists('stream_context_create')) { + $stream_options = $this->prepareStreamOptions($method, $data, $forcePostUrlEncoded); + + $ctx = stream_context_create($stream_options); + + // $http_response_header must be assigned before the fallback read below: PHP 8.5 + // deprecated the predefined variable and reports it at compile time, so the read would + // otherwise emit a notice merely by loading this file. PHP still overwrites the value. + $http_response_header = []; + + $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')) { + $headers = http_get_last_response_headers(); + if (is_array($headers)) { + $responseHeaders = $headers; + } + } elseif ($response !== false) { + // PHP < 8.5 has no http_get_last_response_headers() and populates the local + // variable instead, which it only does when a response was actually received. + $responseHeaders = $http_response_header; + } + + $this->parseIncomingCookies($responseHeaders); + } + + return $content; + } + + /** + * Returns current timestamp, or forced timestamp/datetime if it was set + */ + protected function getTimestamp(): int + { + if (!empty($this->forcedDatetime)) { + $timestamp = strtotime($this->forcedDatetime); + if ($timestamp !== false) { + return $timestamp; + } + } + + return time(); + } + + /** + * Returns the base URL for the Matomo server. + * + * @throws Exception + */ + protected function getBaseUrl(): string + { + $apiUrl = $this->apiUrl === '' + ? self::$URL + : $this->apiUrl; + + if ($apiUrl === '') { + throw new Exception( + 'You must first set the Matomo Tracker URL by calling + MatomoTracker::$URL = \'http://your-website.org/matomo/\';' + ); + } + if ( + strpos($apiUrl, '/matomo.php') === false + && strpos($apiUrl, '/proxy-matomo.php') === false + ) { + $apiUrl = rtrim($apiUrl, '/'); + $apiUrl .= '/matomo.php'; + } + + return $apiUrl; + } + + /** + * @ignore + */ + protected function getRequest(int $idSite): string + { + $this->setFirstPartyCookies(); + + $customFields = ''; + if (!empty($this->customParameters)) { + $customFields = '&' . http_build_query($this->customParameters, '', '&'); + } + + $customDimensions = ''; + if (!empty($this->customDimensions)) { + $customDimensions = '&' . http_build_query($this->customDimensions, '', '&'); + } + + $baseUrl = $this->getBaseUrl(); + $start = '?'; + if (strpos($baseUrl, '?') !== false) { + $start = '&'; + } + + $url = $baseUrl . $start . + 'idsite=' . $idSite . + '&rec=1' . + '&apiv=' . self::VERSION . + '&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(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=' . urlencode($this->ip) : '') . + (!empty($this->userId) ? '&uid=' . urlencode($this->userId) : '') . + (!empty($this->forcedDatetime) ? '&cdt=' . urlencode($this->forcedDatetime) : '') . + (!empty($this->forcedNewVisit) ? '&new_visit=1' : '') . + + // Values collected from cookie + '&_idts=' . $this->createTs . + + // These parameters are set by the JS, but optional when using API + (!empty($this->plugins) ? $this->plugins : '') . + (($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=' . (int) $this->hasCookies : '') . + + // Various important attributes + (!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) . + '&urlref=' . urlencode($this->urlReferrer ?? '') . + ((!empty($this->pageCharset) && $this->pageCharset != self::DEFAULT_CHARSET_PARAMETER_VALUES) ? + '&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(self::toStringValue($this->attributionInfo[0])) : '') . + // Campaign keyword + (!empty($this->attributionInfo[1]) ? '&_rck=' . urlencode(self::toStringValue($this->attributionInfo[1])) : '') . + // Timestamp at which the referrer was set + (!empty($this->attributionInfo[2]) ? '&_refts=' . urlencode(self::toStringValue($this->attributionInfo[2])) : '') . + // Referrer URL + (!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) : '') . + ($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((string) json_encode($this->clientHints))) : '') . + + // DEBUG + $this->DEBUG_APPEND_URL; + + if (!empty($this->idPageview)) { + $url .= + ($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(); + } + + foreach ($this->ecommerceView as $param => $value) { + $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(); + + // force new visit only once, user must call again setForceNewVisit() + $this->forcedNewVisit = false; + + return $url; + } + + + /** + * Returns a first party cookie which name contains $name + * + * @return string|false String value of cookie, or false if not found + * @ignore + */ + protected function getCookieMatchingName(string $name): string|false + { + if ($this->configCookiesDisabled) { + 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) { + // cookie names that are numeric strings are exposed as integer array keys + if (strpos((string) $cookieName, $name) !== false) { + return self::toStringValue($cookieValue); + } + } + + return false; + } + + /** + * Returns the path portion of the URL the visitor requested (everything between the host and + * the query string). For "http://example.org/dir1/dir2/index.php?param1=value1" this returns + * "/dir1/dir2/index.php"; for a front-controller URL such as "http://example.org/dir1/page" + * (where "/page" is handled by dir1/index.php) it returns "/dir1/page". + * + * The full request path is taken from REQUEST_URI. PATH_INFO is deliberately not used: it only + * holds the trailing path-info segment (e.g. "/page"), so it would drop the directory/script + * prefix and yield a truncated URL. SCRIPT_NAME is the fallback when REQUEST_URI is unavailable. + * + * @ignore + */ + protected static function getCurrentScriptName(): string + { + $url = ''; + if (!empty($_SERVER['REQUEST_URI'])) { + $requestUri = self::toStringValue($_SERVER['REQUEST_URI']); + if (($pos = strpos($requestUri, '?')) !== false) { + $url = substr($requestUri, 0, $pos); + } else { + $url = $requestUri; + } + } + if (empty($url) && isset($_SERVER['SCRIPT_NAME'])) { + $url = self::toStringValue($_SERVER['SCRIPT_NAME']); + } elseif (empty($url)) { + $url = '/'; + } + + if (!empty($url) && $url[0] !== '/') { + $url = '/' . $url; + } + + return $url; + } + + /** + * If the current URL is 'http://example.org/dir1/dir2/index.php?param1=value1¶m2=value2" + * will return 'http' + * + * @return string 'https' or 'http' + * @ignore + */ + protected static function getCurrentScheme(): string + { + if ( + isset($_SERVER['HTTPS']) + && ($_SERVER['HTTPS'] === 'on' || $_SERVER['HTTPS'] === true) + ) { + return 'https'; + } + + return 'http'; + } + + /** + * If current URL is "http://example.org/dir1/dir2/index.php?param1=value1¶m2=value2" + * will return "http://example.org" + * + * @ignore + */ + protected static function getCurrentHost(): string + { + if (isset($_SERVER['HTTP_HOST'])) { + return self::toStringValue($_SERVER['HTTP_HOST']); + } + + return 'unknown'; + } + + /** + * If current URL is "http://example.org/dir1/dir2/index.php?param1=value1¶m2=value2" + * will return "?param1=value1¶m2=value2" + * + * @ignore + */ + protected static function getCurrentQueryString(): string + { + $url = ''; + if (!empty($_SERVER['QUERY_STRING'])) { + $url .= '?' . self::toStringValue($_SERVER['QUERY_STRING']); + } + + return $url; + } + + /** + * Returns the current full URL (scheme, host, path and query string. + * + * @ignore + */ + protected static function getCurrentUrl(): string + { + return self::getCurrentScheme() . '://' + . self::getCurrentHost() + . self::getCurrentScriptName() + . 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(): self + { + if ($this->configCookiesDisabled) { + return $this; + } + + if (empty($this->cookieVisitorId)) { + $this->loadVisitorIdCookie(); + } + + // Set the 'ref' cookie + $attributionInfo = $this->getAttributionInfo(); + if (!empty($attributionInfo)) { + $this->setCookie('ref', $attributionInfo, $this->configReferralCookieTimeout); + } + + // Set the 'ses' cookie + $this->setCookie('ses', '*', $this->configSessionCookieTimeout); + + // Set the 'id' cookie + $cookieValue = $this->getVisitorId() . '.' . $this->createTs; + $this->setCookie('id', $cookieValue, $this->configVisitorCookieTimeout); + + // Set the 'cvar' cookie + $this->setCookie('cvar', (string) json_encode($this->visitorCustomVar), $this->configSessionCookieTimeout); + return $this; + } + + /** + * Sets a first party cookie to the client to improve dual JS-PHP tracking. + * + * This replicates the matomo.js tracker algorithms for consistency and better accuracy. + * + * @return $this + */ + protected function setCookie(string $cookieName, string $cookieValue, int $cookieTTL): self + { + $cookieExpire = $this->currentTs + $cookieTTL; + if (!headers_sent()) { + $header = 'Set-Cookie: ' . rawurlencode($this->getCookieName($cookieName)) . '=' . rawurlencode($cookieValue) + . (empty($cookieExpire) ? '' : '; expires=' . gmdate('D, d-M-Y H:i:s', $cookieExpire) . ' GMT') + . (empty($this->configCookiePath) ? '' : '; path=' . $this->configCookiePath) + . (empty($this->configCookieDomain) ? '' : '; domain=' . rawurlencode($this->configCookieDomain)) + . (!$this->configCookieSecure ? '' : '; secure') + . (!$this->configCookieHTTPOnly ? '' : '; HttpOnly') + . (!$this->configCookieSameSite ? '' : '; SameSite=' . rawurlencode($this->configCookieSameSite)); + + header($header, false); + } + return $this; + } + + /** + * @return array + */ + protected function getCustomVariablesFromCookie(): array + { + $cookie = $this->getCookieMatchingName('cvar'); + if (!$cookie) { + return []; + } + + $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 string $name + * @param string|null $value Cookie value, or null to remove a previously set cookie. + */ + public function setOutgoingTrackerCookie(string $name, ?string $value): void + { + if ($value === null) { + unset($this->outgoingTrackerCookies[$name]); + } else { + $this->outgoingTrackerCookies[$name] = $value; + } + } + + /** + * Gets a cookie which was set by the tracking server. + * + * @param string $name + * + * @return string|false The cookie value, or false if no cookie with the given name was received. + */ + public function getIncomingTrackerCookie(string $name): string|false + { + return $this->incomingTrackerCookies[$name] ?? false; + } + + /** + * Reads incoming tracking server cookies. + * + * @param array $headers Array with HTTP response headers as values + */ + protected function parseIncomingCookies(array $headers): void + { + $this->incomingTrackerCookies = []; + + $headerName = 'set-cookie:'; + $headerNameLength = strlen($headerName); + + 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|null $userAgent + */ + public static function isUserAgentAIBot(?string $userAgent): bool + { + if (empty($userAgent)) { + return false; + } + + foreach (self::AI_BOT_USER_AGENT_SUBSTRINGS as $substring) { + if (stripos($userAgent, $substring) !== false) { + return true; + } + } + return false; + } +} + +/** + * Helper function to quickly generate the URL to track a page view. + * + * @param int $idSite + * @param string $documentTitle + * @return string + */ +function Matomo_getUrlTrackPageView(int $idSite, string $documentTitle = ''): string +{ + $tracker = new MatomoTracker($idSite); + + return $tracker->getUrlTrackPageView($documentTitle); +} + +/** + * Helper function to quickly generate the URL to track a goal. + * + * @param int $idSite + * @param int $idGoal + * @param float|null $revenue + * @return string + */ +function Matomo_getUrlTrackGoal(int $idSite, int $idGoal, ?float $revenue = null): string +{ + $tracker = new MatomoTracker($idSite); + + return $tracker->getUrlTrackGoal($idGoal, $revenue); +} + +/** + * Ensure PiwikTracker class is available as well + * + * @deprecated + */ +if (!class_exists('\PiwikTracker')) { + include_once('PiwikTracker.php'); +} diff --git a/PiwikTracker.php b/PiwikTracker.php index 30ba6d4..a221dae 100644 --- a/PiwikTracker.php +++ b/PiwikTracker.php @@ -1,2054 +1,55 @@ ecommerceItems = array(); - $this->attributionInfo = false; - $this->eventCustomVar = false; - $this->forcedDatetime = false; - $this->forcedNewVisit = false; - $this->generationTime = false; - $this->pageCustomVar = false; - $this->customParameters = array(); - $this->customData = false; - $this->hasCookies = false; - $this->token_auth = false; - $this->userAgent = false; - $this->country = false; - $this->region = false; - $this->city = false; - $this->lat = false; - $this->long = false; - $this->width = false; - $this->height = false; - $this->plugins = false; - $this->localHour = false; - $this->localMinute = false; - $this->localSecond = false; - $this->idPageview = false; - - $this->idSite = $idSite; - $this->urlReferrer = !empty($_SERVER['HTTP_REFERER']) ? $_SERVER['HTTP_REFERER'] : false; - $this->pageCharset = self::DEFAULT_CHARSET_PARAMETER_VALUES; - $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; - if (!empty($apiUrl)) { - self::$URL = $apiUrl; - } - - // Life of the visitor cookie (in sec) - $this->configVisitorCookieTimeout = 33955200; // 13 months (365 + 28 days) - // Life of the session cookie (in sec) - $this->configSessionCookieTimeout = 1800; // 30 minutes - // Life of the session cookie (in sec) - $this->configReferralCookieTimeout = 15768000; // 6 months - - // Visitor Ids in order - $this->userId = false; - $this->forcedVisitorId = false; - $this->cookieVisitorId = false; - $this->randomVisitorId = false; - - $this->setNewVisitorId(); - - $this->configCookiesDisabled = false; - $this->configCookiePath = self::DEFAULT_COOKIE_PATH; - $this->configCookieDomain = ''; - - $this->currentTs = time(); - $this->createTs = $this->currentTs; - $this->visitCount = 0; - $this->currentVisitTs = false; - $this->lastVisitTs = false; - $this->ecommerceLastOrderTimestamp = false; - - // Allow debug while blocking the request - $this->requestTimeout = 600; - $this->doBulkRequests = false; - $this->storedTrackingActions = array(); - - $this->sendImageResponse = true; - - $this->visitorCustomVar = $this->getCustomVariablesFromCookie(); - - $this->outgoingTrackerCookies = array(); - $this->incomingTrackerCookies = array(); - } - - /** - * By default, Piwik expects utf-8 encoded values, for example - * for the page URL parameter values, Page Title, etc. - * It is recommended to only send UTF-8 data to Piwik. - * If required though, you can also specify another charset using this function. - * - * @param string $charset - * @return $this - */ - public function setPageCharset($charset = '') - { - $this->pageCharset = $charset; - return $this; - } - - /** - * Sets the current URL being tracked - * - * @param string $url Raw URL (not URL encoded) - * @return $this - */ - public function setUrl($url) - { - $this->pageUrl = $url; - return $this; - } - - /** - * Sets the URL referrer used to track Referrers details for new visits. - * - * @param string $url Raw URL (not URL encoded) - * @return $this - */ - public function setUrlReferrer($url) - { - $this->urlReferrer = $url; - return $this; - } - - /** - * Sets the time that generating the document on the server side took. - * - * @param int $timeMs Generation time in ms - * @return $this - */ - public function setGenerationTime($timeMs) - { - $this->generationTime = $timeMs; - return $this; - } - - /** - * @deprecated - * @ignore - */ - public function setUrlReferer($url) - { - $this->setUrlReferrer($url); - return $this; - } - - /** - * Sets the attribution information to the visit, so that subsequent Goal conversions are - * properly attributed to the right Referrer URL, timestamp, Campaign Name & Keyword. - * - * This must be a JSON encoded string that would typically be fetched from the JS API: - * piwikTracker.getAttributionInfo() and that you have JSON encoded via JSON2.stringify() - * - * If you call enableCookies() then these referral attribution values will be set - * to the 'ref' first party cookie storing referral information. - * - * @param string $jsonEncoded JSON encoded array containing Attribution info - * @return $this - * @throws Exception - * @see function getAttributionInfo() in https://github.com/piwik/piwik/blob/master/js/piwik.js - */ - public function setAttributionInfo($jsonEncoded) - { - $decoded = json_decode($jsonEncoded, $assoc = true); - if (!is_array($decoded)) { - throw new Exception("setAttributionInfo() is expecting a JSON encoded string, $jsonEncoded given"); - } - $this->attributionInfo = $decoded; - return $this; - } - - /** - * Sets Visit Custom Variable. - * See http://piwik.org/docs/custom-variables/ - * - * @param int $id Custom variable slot ID from 1-5 - * @param string $name Custom variable name - * @param string $value Custom variable value - * @param string $scope Custom variable scope. Possible values: visit, page, event - * @return $this - * @throws Exception - */ - public function setCustomVariable($id, $name, $value, $scope = 'visit') - { - if (!is_int($id)) { - throw new Exception("Parameter id to setCustomVariable should be an integer"); - } - if ($scope == 'page') { - $this->pageCustomVar[$id] = array($name, $value); - } elseif ($scope == 'event') { - $this->eventCustomVar[$id] = array($name, $value); - } elseif ($scope == 'visit') { - $this->visitorCustomVar[$id] = array($name, $value); - } else { - throw new Exception("Invalid 'scope' parameter value"); - } - return $this; - } - - /** - * Returns the currently assigned Custom Variable. - * - * If scope is 'visit', it will attempt to read the value set in the first party cookie created by Piwik Tracker - * ($_COOKIE array). - * - * @param int $id Custom Variable integer index to fetch from cookie. Should be a value from 1 to 5 - * @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 - * @see Piwik.js getCustomVariable() - */ - public function getCustomVariable($id, $scope = 'visit') - { - if ($scope == 'page') { - return isset($this->pageCustomVar[$id]) ? $this->pageCustomVar[$id] : false; - } elseif ($scope == 'event') { - return isset($this->eventCustomVar[$id]) ? $this->eventCustomVar[$id] : false; - } else { - if ($scope != 'visit') { - throw new Exception("Invalid 'scope' parameter value"); - } - } - if (!empty($this->visitorCustomVar[$id])) { - return $this->visitorCustomVar[$id]; - } - $cookieDecoded = $this->getCustomVariablesFromCookie(); - if (!is_int($id)) { - throw new Exception("Parameter to getCustomVariable should be an integer"); - } - if (!is_array($cookieDecoded) - || !isset($cookieDecoded[$id]) - || !is_array($cookieDecoded[$id]) - || count($cookieDecoded[$id]) != 2 - ) { - return false; - } - - return $cookieDecoded[$id]; - } - - /** - * Clears any Custom Variable that may be have been set. - * - * This can be useful when you have enabled bulk requests, - * and you wish to clear Custom Variables of 'visit' scope. - */ - public function clearCustomVariables() - { - $this->visitorCustomVar = array(); - $this->pageCustomVar = array(); - $this->eventCustomVar = array(); - } - - /** - * Sets a custom tracking parameter. This is useful if you need to send any tracking parameters for a 3rd party - * plugin that is not shipped with Piwik itself. Please note that custom parameters are cleared after each - * tracking request. - * - * @param string $trackingApiParameter The name of the tracking API parameter, eg 'dimension1' - * @param string $value Tracking parameter value that shall be sent for this tracking parameter. - * @return $this - * @throws Exception - */ - public function setCustomTrackingParameter($trackingApiParameter, $value) - { - $this->customParameters[$trackingApiParameter] = $value; - return $this; - } - - /** - * Clear / reset all previously set custom tracking parameters. - */ - public function clearCustomTrackingParameters() - { - $this->customParameters = array(); - } - - /** - * Sets the current visitor ID to a random new one. - * @return $this - */ - public function setNewVisitorId() - { - $this->randomVisitorId = substr(md5(uniqid(rand(), true)), 0, self::LENGTH_VISITOR_ID); - $this->userId = false; - $this->forcedVisitorId = false; - $this->cookieVisitorId = false; - return $this; - } - - /** - * Sets the current site ID. - * - * @param int $idSite - * @return $this - */ - public function setIdSite($idSite) - { - $this->idSite = $idSite; - return $this; - } - - /** - * Sets the Browser language. Used to guess visitor countries when GeoIP is not enabled - * - * @param string $acceptLanguage For example "fr-fr" - * @return $this - */ - public function setBrowserLanguage($acceptLanguage) - { - $this->acceptLanguage = $acceptLanguage; - return $this; - } - - /** - * Sets the user agent, used to detect OS and browser. - * If this function is not called, the User Agent will default to the current user agent. - * - * @param string $userAgent - * @return $this - */ - public function setUserAgent($userAgent) - { - $this->userAgent = $userAgent; - return $this; - } - - /** - * Sets the country of the visitor. If not used, Piwik will try to find the country - * using either the visitor's IP address or language. - * - * Allowed only for Admin/Super User, must be used along with setTokenAuth(). - * @param string $country - * @return $this - */ - public function setCountry($country) - { - $this->country = $country; - return $this; - } - - /** - * Sets the region of the visitor. If not used, Piwik may try to find the region - * using the visitor's IP address (if configured to do so). - * - * Allowed only for Admin/Super User, must be used along with setTokenAuth(). - * @param string $region - * @return $this - */ - public function setRegion($region) - { - $this->region = $region; - return $this; - } - - /** - * Sets the city of the visitor. If not used, Piwik may try to find the city - * using the visitor's IP address (if configured to do so). - * - * Allowed only for Admin/Super User, must be used along with setTokenAuth(). - * @param string $city - * @return $this - */ - public function setCity($city) - { - $this->city = $city; - return $this; - } - - /** - * Sets the latitude of the visitor. If not used, Piwik may try to find the visitor's - * latitude using the visitor's IP address (if configured to do so). - * - * Allowed only for Admin/Super User, must be used along with setTokenAuth(). - * @param float $lat - * @return $this - */ - public function setLatitude($lat) - { - $this->lat = $lat; - return $this; - } - - /** - * Sets the longitude of the visitor. If not used, Piwik may try to find the visitor's - * longitude using the visitor's IP address (if configured to do so). - * - * Allowed only for Admin/Super User, must be used along with setTokenAuth(). - * @param float $long - * @return $this - */ - public function setLongitude($long) - { - $this->long = $long; - return $this; - } - - /** - * Enables the bulk request feature. When used, each tracking action is stored until the - * doBulkTrack method is called. This method will send all tracking data at once. - * - */ - public function enableBulkTracking() - { - $this->doBulkRequests = true; - } - - /** - * Enable Cookie Creation - this will cause a first party VisitorId cookie to be set when the VisitorId is set or reset - * - * @param string $domain (optional) Set first-party cookie domain. - * Accepted values: example.com, *.example.com (same as .example.com) or subdomain.example.com - * @param string $path (optional) Set first-party cookie path - */ - public function enableCookies($domain = '', $path = '/') - { - $this->configCookiesDisabled = false; - $this->configCookieDomain = self::domainFixup($domain); - $this->configCookiePath = $path; - } - - /** - * If image response is disabled Piwik will respond with a HTTP 204 header instead of responding with a gif. - */ - public function disableSendImageResponse() - { - $this->sendImageResponse = false; - } - - /** - * Fix-up domain - */ - protected static function domainFixup($domain) - { - if (strlen($domain) > 0) { - $dl = strlen($domain) - 1; - // remove trailing '.' - if ($domain[$dl] === '.') { - $domain = substr($domain, 0, $dl); - } - // remove leading '*' - if (substr($domain, 0, 2) === '*.') { - $domain = substr($domain, 1); - } - } - - return $domain; - } - - /** - * Get cookie name with prefix and domain hash - * @param string $cookieName - * @return string - */ - protected function getCookieName($cookieName) - { - // NOTE: If the cookie name is changed, we must also update the method in piwik.js with the same name. - $hash = substr( - sha1( - ($this->configCookieDomain == '' ? self::getCurrentHost() : $this->configCookieDomain) . $this->configCookiePath - ), - 0, - 4 - ); - - return self::FIRST_PARTY_COOKIES_PREFIX . $cookieName . '.' . $this->idSite . '.' . $hash; - } - - /** - * 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. - */ - public function doTrackPageView($documentTitle) - { - $this->generateNewPageviewId(); - - $url = $this->getUrlTrackPageView($documentTitle); - - return $this->sendRequest($url); - } - - private function generateNewPageviewId() - { - $this->idPageview = substr(md5(uniqid(rand(), true)), 0, 6); - } - - /** - * Tracks an event - * - * @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. - */ - public function doTrackEvent($category, $action, $name = false, $value = false) - { - $url = $this->getUrlTrackEvent($category, $action, $name, $value); - - return $this->sendRequest($url); - } - - /** - * Tracks a content impression - * - * @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. - */ - public function doTrackContentImpression($contentName, $contentPiece = 'Unknown', $contentTarget = false) - { - $url = $this->getUrlTrackContentImpression($contentName, $contentPiece, $contentTarget); - - return $this->sendRequest($url); - } - - /** - * Tracks a content interaction. Make sure you have tracked a content impression using the same content name and - * content piece, otherwise it will not count. To do so you should call the method 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. - */ - public function doTrackContentInteraction( - $interaction, - $contentName, - $contentPiece = 'Unknown', - $contentTarget = false - ) - { - $url = $this->getUrlTrackContentInteraction($interaction, $contentName, $contentPiece, $contentTarget); - - return $this->sendRequest($url); - } - - /** - * Tracks an internal Site Search query, and optionally tracks the Search Category, and Search results Count. - * These are used to populate reports in Actions > Site Search. - * - * @param string $keyword Searched query on the site - * @param string $category (optional) Search engine category if applicable - * @param bool|int $countResults (optional) results displayed on the search result page. Used to track "zero result" keywords. - * - * @return mixed Response or true if using bulk requests. - */ - public function doTrackSiteSearch($keyword, $category = '', $countResults = false) - { - $url = $this->getUrlTrackSiteSearch($keyword, $category, $countResults); - - return $this->sendRequest($url); - } - - /** - * 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 - */ - public function doTrackGoal($idGoal, $revenue = 0.0) - { - $url = $this->getUrlTrackGoal($idGoal, $revenue); - - return $this->sendRequest($url); - } - - /** - * Tracks a download or outlink - * - * @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 - */ - public function doTrackAction($actionUrl, $actionType) - { - // Referrer could be udpated to be the current URL temporarily (to mimic JS behavior) - $url = $this->getUrlTrackAction($actionUrl, $actionType); - - return $this->sendRequest($url); - } - - /** - * Adds an item in the Ecommerce order. - * - * This should be called before doTrackEcommerceOrder(), or before doTrackEcommerceCartUpdate(). - * This function can be called for all individual products in the cart (or order). - * SKU parameter is mandatory. Other parameters are optional (set to false if value not known). - * Ecommerce items added via this function are automatically cleared when doTrackEcommerceOrder() or getUrlTrackEcommerceOrder() is called. - * - * @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 int $quantity (optional) Product quantity. If not specified, will default to 1 in the Reports - * @throws Exception - */ - public function addEcommerceItem($sku, $name = '', $category = '', $price = 0.0, $quantity = 1) - { - if (empty($sku)) { - throw new Exception("You must specify a SKU for the Ecommerce item"); - } - - $price = $this->forceDotAsSeparatorForDecimalPoint($price); - - $this->ecommerceItems[] = array($sku, $name, $category, $price, $quantity); - } - - /** - * Tracks a Cart Update (add item, remove item, update item). - * - * On every Cart update, you must call addEcommerceItem() for each item (product) in the cart, - * including the items that haven't been updated since the last cart update. - * 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 - */ - public function doTrackEcommerceCartUpdate($grandTotal) - { - $url = $this->getUrlTrackEcommerceCartUpdate($grandTotal); - - return $this->sendRequest($url); - } - - /** - * Sends all stored tracking actions at once. Only has an effect if bulk tracking is enabled. - * - * To enable bulk tracking, call enableBulkTracking(). - * - * @throws Exception - * @return string Response - */ - public function doBulkTrack() - { - if (empty($this->storedTrackingActions)) { - throw new Exception( - "Error: you must call the function doTrackPageView or doTrackGoal from this class, - before calling this method doBulkTrack()" - ); - } - - $data = array('requests' => $this->storedTrackingActions); - - // token_auth is not required by default, except if bulk_requests_require_authentication=1 - if (!empty($this->token_auth)) { - $data['token_auth'] = $this->token_auth; - } - - $postData = json_encode($data); - $response = $this->sendRequest($this->getBaseUrl(), 'POST', $postData, $force = true); - - $this->storedTrackingActions = array(); - - return $response; - } - - /** - * Tracks an Ecommerce order. - * - * If the Ecommerce order contains items (products), you must call first the addEcommerceItem() for each item in the order. - * All revenues (grandTotal, subTotal, tax, shipping, discount) will be individually summed and reported in Piwik reports. - * Only the parameters $orderId and $grandTotal are required. - * - * @param string|int $orderId (required) Unique Order ID. - * 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 Piwik. - * @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 - */ - public function doTrackEcommerceOrder( - $orderId, - $grandTotal, - $subTotal = 0.0, - $tax = 0.0, - $shipping = 0.0, - $discount = 0.0 - ) - { - $url = $this->getUrlTrackEcommerceOrder($orderId, $grandTotal, $subTotal, $tax, $shipping, $discount); - - return $this->sendRequest($url); - } - - /** - * Sends a ping request. - * - * Ping requests do not track new actions. If they are sent within the standard visit length (see global.ini.php), - * 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 - */ - public function doPing() - { - $url = $this->getRequest($this->idSite); - $url .= '&ping=1'; - - return $this->sendRequest($url); - } - - /** - * Sets the current page view as an item (product) page view, or an Ecommerce Category page view. - * - * This must be called before doTrackPageView() on this product/category page. - * It will set 3 custom variables of scope "page" with the SKU, Name and Category for this page view. - * Note: Custom Variables of scope "page" slots 3, 4 and 5 will be used. - * - * On a category page, you may set the parameter $category only and set the other parameters to false. - * - * Tracking Product/Category page views will allow Piwik 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. - * 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 - */ - public function setEcommerceView($sku = '', $name = '', $category = '', $price = 0.0) - { - if (!empty($category)) { - if (is_array($category)) { - $category = json_encode($category); - } - } else { - $category = ""; - } - $this->pageCustomVar[self::CVAR_INDEX_ECOMMERCE_ITEM_CATEGORY] = array('_pkc', $category); - - if (!empty($price)) { - $price = (float)$price; - $price = $this->forceDotAsSeparatorForDecimalPoint($price); - $this->pageCustomVar[self::CVAR_INDEX_ECOMMERCE_ITEM_PRICE] = array('_pkp', $price); - } - - // On a category page, do not record "Product name not defined" - if (empty($sku) && empty($name)) { - return $this; - } - if (!empty($sku)) { - $this->pageCustomVar[self::CVAR_INDEX_ECOMMERCE_ITEM_SKU] = array('_pks', $sku); - } - if (empty($name)) { - $name = ""; - } - $this->pageCustomVar[self::CVAR_INDEX_ECOMMERCE_ITEM_NAME] = array('_pkn', $name); - return $this; - } - - /** - * Force the separator for decimal point to be a dot. See https://github.com/piwik/piwik/issues/6435 - * If for instance a German locale is used it would be a comma otherwise. - * - * @param float|string $value - * @return string - */ - private function forceDotAsSeparatorForDecimalPoint($value) - { - if (null === $value || false === $value) { - return $value; - } - - return str_replace(',', '.', $value); - } - - /** - * Returns URL used to track Ecommerce Cart updates - * Calling this function will reinitializes the property ecommerceItems to empty array - * so items will have to be added again via addEcommerceItem() - * @ignore - */ - public function getUrlTrackEcommerceCartUpdate($grandTotal) - { - $url = $this->getUrlTrackEcommerce($grandTotal); - - return $url; - } - - /** - * Returns URL used to track Ecommerce Orders - * Calling this function will reinitializes the property ecommerceItems to empty array - * so items will have to be added again via addEcommerceItem() - * @ignore - */ - public function getUrlTrackEcommerceOrder( - $orderId, - $grandTotal, - $subTotal = 0.0, - $tax = 0.0, - $shipping = 0.0, - $discount = 0.0 - ) - { - 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); - $this->ecommerceLastOrderTimestamp = $this->getTimestamp(); - - return $url; - } - - /** - * Returns URL used to track Ecommerce orders - * - * Calling this function will reinitializes the property ecommerceItems to empty array - * so items will have to be added again via addEcommerceItem() - * - * @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)"); - } - - $url = $this->getRequest($this->idSite); - $url .= '&idgoal=0'; - if (!empty($grandTotal)) { - $grandTotal = $this->forceDotAsSeparatorForDecimalPoint($grandTotal); - $url .= '&revenue=' . $grandTotal; - } - if (!empty($subTotal)) { - $subTotal = $this->forceDotAsSeparatorForDecimalPoint($subTotal); - $url .= '&ec_st=' . $subTotal; - } - if (!empty($tax)) { - $tax = $this->forceDotAsSeparatorForDecimalPoint($tax); - $url .= '&ec_tx=' . $tax; - } - if (!empty($shipping)) { - $shipping = $this->forceDotAsSeparatorForDecimalPoint($shipping); - $url .= '&ec_sh=' . $shipping; - } - if (!empty($discount)) { - $discount = $this->forceDotAsSeparatorForDecimalPoint($discount); - $url .= '&ec_dt=' . $discount; - } - if (!empty($this->ecommerceItems)) { - $url .= '&ec_items=' . urlencode(json_encode($this->ecommerceItems)); - } - $this->ecommerceItems = array(); - - return $url; - } - - /** - * Builds URL to track a page view. - * - * @see doTrackPageView() - * @param string $documentTitle Page view name as it will appear in Piwik reports - * @return string URL to piwik.php with all parameters set to track the pageview - */ - public function getUrlTrackPageView($documentTitle = '') - { - $url = $this->getRequest($this->idSite); - if (strlen($documentTitle) > 0) { - $url .= '&action_name=' . urlencode($documentTitle); - } - - return $url; - } - - /** - * Builds URL to track a custom event. - * - * @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 - * @return string URL to piwik.php with all parameters set to track the pageview - * @throws - */ - public function getUrlTrackEvent($category, $action, $name = false, $value = false) - { - $url = $this->getRequest($this->idSite); - if (strlen($category) == 0) { - throw new Exception("You must specify an Event Category name (Music, Videos, Games...)."); - } - if (strlen($action) == 0) { - throw new Exception("You must specify an Event action (click, view, add...)."); - } - - $url .= '&e_c=' . urlencode($category); - $url .= '&e_a=' . urlencode($action); - - if (strlen($name) > 0) { - $url .= '&e_n=' . urlencode($name); - } - if (strlen($value) > 0) { - $value = $this->forceDotAsSeparatorForDecimalPoint($value); - $url .= '&e_v=' . $value; - } - - return $url; - } - - /** - * Builds URL to track a content impression. - * - * @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. - * @throws Exception In case $contentName is empty - * @return string URL to piwik.php with all parameters set to track the pageview - */ - public function getUrlTrackContentImpression($contentName, $contentPiece, $contentTarget) - { - $url = $this->getRequest($this->idSite); - - if (strlen($contentName) == 0) { - throw new Exception("You must specify a content name"); - } - - $url .= '&c_n=' . urlencode($contentName); - - if (!empty($contentPiece) && strlen($contentPiece) > 0) { - $url .= '&c_p=' . urlencode($contentPiece); - } - if (!empty($contentTarget) && strlen($contentTarget) > 0) { - $url .= '&c_t=' . urlencode($contentTarget); - } - - return $url; - } - - /** - * Builds URL to track a content impression. - * - * @see doTrackContentInteraction() - * @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. - * @throws Exception In case $interaction or $contentName is empty - * @return string URL to piwik.php with all parameters set to track the pageview - */ - public function getUrlTrackContentInteraction($interaction, $contentName, $contentPiece, $contentTarget) - { - $url = $this->getRequest($this->idSite); +declare(strict_types=1); - if (strlen($interaction) == 0) { - throw new Exception("You must specify a name for the interaction"); - } - - if (strlen($contentName) == 0) { - throw new Exception("You must specify a content name"); - } - - $url .= '&c_i=' . urlencode($interaction); - $url .= '&c_n=' . urlencode($contentName); - - if (!empty($contentPiece) && strlen($contentPiece) > 0) { - $url .= '&c_p=' . urlencode($contentPiece); - } - if (!empty($contentTarget) && strlen($contentTarget) > 0) { - $url .= '&c_t=' . urlencode($contentTarget); - } - - return $url; - } - - /** - * Builds URL to track a site search. - * - * @see doTrackSiteSearch() - * @param string $keyword - * @param string $category - * @param int $countResults - * @return string - */ - public function getUrlTrackSiteSearch($keyword, $category, $countResults) - { - $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; - } - - return $url; - } - - /** - * Builds URL to track a goal with idGoal and revenue. - * - * @see doTrackGoal() - * @param int $idGoal Id Goal to record a conversion - * @param float $revenue Revenue for this conversion - * @return string URL to piwik.php with all parameters set to track the goal conversion - */ - public function getUrlTrackGoal($idGoal, $revenue = 0.0) - { - $url = $this->getRequest($this->idSite); - $url .= '&idgoal=' . $idGoal; - if (!empty($revenue)) { - $revenue = $this->forceDotAsSeparatorForDecimalPoint($revenue); - $url .= '&revenue=' . $revenue; - } - - return $url; - } - - /** - * Builds URL to track a new action. - * - * @see doTrackAction() - * @param string $actionUrl URL of the download or outlink - * @param string $actionType Type of the action: 'download' or 'link' - * @return string URL to piwik.php with all parameters set to track an action - */ - public function getUrlTrackAction($actionUrl, $actionType) - { - $url = $this->getRequest($this->idSite); - $url .= '&' . $actionType . '=' . urlencode($actionUrl); - - return $url; - } - - /** - * Overrides server date and time for the tracking requests. - * By default Piwik will track requests for the "current datetime" but this function allows you - * to track visits in the past. All times are in UTC. - * - * Allowed only for Admin/Super User, must be used along with setTokenAuth() - * @see setTokenAuth() - * @param string $dateTime Date with the format 'Y-m-d H:i:s', or a UNIX timestamp. - * 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($dateTime) - { - $this->forcedDatetime = $dateTime; - return $this; - } - - /** - * Forces Piwik to create a new visit for the tracking request. - * - * By default, Piwik will create a new visit if the last request by this user was more than 30 minutes ago. - * If you call setForceNewVisit() before calling doTrack*, then a new visit will be created for this request. - * @return $this - */ - public function setForceNewVisit() - { - $this->forcedNewVisit = true; - return $this; - } - - /** - * Overrides IP address - * - * Allowed only for Admin/Super User, must be used along with setTokenAuth() - * @see setTokenAuth() - * @param string $ip IP string, eg. 130.54.2.1 - * @return $this - */ - public function setIp($ip) - { - $this->ip = $ip; - return $this; - } - - /** - * Force the action to be recorded for a specific User. The User ID is a string representing a given user in your system. - * - * 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. - * @return $this - * @throws Exception - */ - public function setUserId($userId) - { - if ($userId === false) { - $this->setNewVisitorId(); - return $this; - } - if ($userId === '') { - throw new Exception("User ID cannot be empty."); - } - $this->userId = $userId; - return $this; - } - - /** - * Hash function used internally by Piwik to hash a User ID into the Visitor ID. - * - * Note: matches implementation of Tracker\Request->getUserIdHashed() - * - * @param $id - * @return string - */ - public static function getUserIdHashed($id) - { - return substr(sha1($id), 0, 16); - } - - /** - * Forces the requests to be recorded for the specified Visitor ID. - * Note: it is recommended to use ->setUserId($userId); instead. - * - * Rather than letting Piwik attribute the user with a heuristic based on IP and other user fingeprinting attributes, - * force the action to be recorded for a particular visitor. - * - * If you use both setVisitorId and setUserId, setUserId will take precedence. - * If not set, the visitor ID will be fetched from the 1st party cookie, or will be set to a random UUID. - * - * @deprecated We recommend to use ->setUserId($userId). - * @param string $visitorId 16 hexadecimal characters visitor ID, eg. "33c31e01394bdc63" - * @return $this - * @throws Exception - */ - public function setVisitorId($visitorId) - { - $hexChars = '01234567890abcdefABCDEF'; - if (strlen($visitorId) != self::LENGTH_VISITOR_ID - || strspn($visitorId, $hexChars) !== strlen($visitorId) - ) { - throw new Exception( - "setVisitorId() expects a " - . self::LENGTH_VISITOR_ID - . " characters hexadecimal string (containing only the following: " - . $hexChars - . ")" - ); - } - $this->forcedVisitorId = $visitorId; - return $this; - } - - /** - * If the user initiating the request has the Piwik first party cookie, - * this function will try and return the ID parsed from this first party cookie (found in $_COOKIE). - * - * If you call this function from a server, where the call is triggered by a cron or script - * not initiated by the actual visitor being tracked, then it will return - * the random Visitor ID that was assigned to this visit object. - * - * This can be used if you wish to record more visits, actions or goals for this visitor ID later on. - * - * @return string 16 hex chars visitor ID string - */ - public function getVisitorId() - { - if (!empty($this->userId)) { - return $this->getUserIdHashed($this->userId); - } - if (!empty($this->forcedVisitorId)) { - return $this->forcedVisitorId; - } - if ($this->loadVisitorIdCookie()) { - return $this->cookieVisitorId; - } - - return $this->randomVisitorId; - } - - /** - * Returns the currently set user agent. - * @return string - */ - public function getUserAgent() - { - return $this->userAgent; - } - - /** - * Returns the currently set IP address. - * @return string - */ - public function getIp() - { - return $this->ip; - } - - /** - * Returns the User ID string, which may have been set via: - * $v->setUserId('username@example.org'); - * - * @return bool - */ - public function getUserId() - { - return $this->userId; - } - - /** - * Loads values from the VisitorId Cookie - * - * @return bool True if cookie exists and is valid, False otherwise - */ - protected function loadVisitorIdCookie() - { - $idCookie = $this->getCookieMatchingName('id'); - if ($idCookie === false) { - return false; - } - $parts = explode('.', $idCookie); - if (strlen($parts[0]) != 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]; - $this->visitCount = (int)$parts[2]; - $this->currentVisitTs = $parts[3]; - $this->lastVisitTs = $parts[4]; - if (isset($parts[5])) { - $this->ecommerceLastOrderTimestamp = $parts[5]; - } - - return true; - } - - /** - * Deletes all first party cookies from the client - */ - public function deleteCookies() - { - $cookies = array('id', 'ses', 'cvar', 'ref'); - foreach ($cookies as $cookie) { - $this->setCookie($cookie, '', -86400); - } - } - - /** - * Returns the currently assigned Attribution Information stored in a first party cookie. - * - * 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. - * Will return false if the cookie could not be found - * @see Piwik.js getAttributionInfo() - */ - public function getAttributionInfo() - { - if (!empty($this->attributionInfo)) { - return json_encode($this->attributionInfo); - } - - return $this->getCookieMatchingName('ref'); - } - - /** - * Some Tracking API functionality requires express authentication, using either the - * Super User token_auth, or a user with 'admin' access to the website. - * - * The following features require access: - * - force the visitor IP - * - force the date & time of the tracking requests rather than track for the current datetime - * - * @param string $token_auth token_auth 32 chars token_auth string - * @return $this - */ - public function setTokenAuth($token_auth) - { - $this->token_auth = $token_auth; - return $this; - } - - /** - * Sets local visitor time - * - * @param string $time HH:MM:SS format - * @return $this - */ - public function setLocalTime($time) - { - list($hour, $minute, $second) = explode(':', $time); - $this->localHour = (int)$hour; - $this->localMinute = (int)$minute; - $this->localSecond = (int)$second; - return $this; - } - - /** - * Sets user resolution width and height. - * - * @param int $width - * @param int $height - * @return $this - */ - public function setResolution($width, $height) - { - $this->width = $width; - $this->height = $height; - return $this; - } - - /** - * Sets if the browser supports cookies - * This is reported in "List of plugins" report in Piwik. - * - * @param bool $bool - * @return $this - */ - public function setBrowserHasCookies($bool) - { - $this->hasCookies = $bool; - return $this; - } - - /** - * Will append a custom string at the end of the Tracking request. - * @param string $string - * @return $this - */ - public function setDebugStringAppend($string) - { - $this->DEBUG_APPEND_URL = '&' . $string; - return $this; - } - - /** - * Sets visitor browser supported plugins - * - * @param bool $flash - * @param bool $java - * @param bool $director - * @param bool $quickTime - * @param bool $realPlayer - * @param bool $pdf - * @param bool $windowsMedia - * @param bool $gears - * @param bool $silverlight - * @return $this - */ - public function setPlugins( - $flash = false, - $java = false, - $director = false, - $quickTime = false, - $realPlayer = false, - $pdf = false, - $windowsMedia = false, - $gears = false, - $silverlight = false - ) - { - $this->plugins = - '&fla=' . (int)$flash . - '&java=' . (int)$java . - '&dir=' . (int)$director . - '&qt=' . (int)$quickTime . - '&realp=' . (int)$realPlayer . - '&pdf=' . (int)$pdf . - '&wma=' . (int)$windowsMedia . - '&gears=' . (int)$gears . - '&ag=' . (int)$silverlight; - return $this; - } - - /** - * By default, PiwikTracker will read first party cookies - * from the request and write updated cookies in the response (using setrawcookie). - * This can be disabled by calling this function. - */ - public function disableCookieSupport() - { - $this->configCookiesDisabled = true; - } - - /** - * Returns the maximum number of seconds the tracker will spend waiting for a response - * from Piwik. Defaults to 600 seconds. - */ - public function getRequestTimeout() - { - return $this->requestTimeout; - } - - /** - * Sets the maximum number of seconds that the tracker will spend waiting for a response - * from Piwik. - * - * @param int $timeout - * @return $this - * @throws Exception - */ - public function setRequestTimeout($timeout) - { - if (!is_int($timeout) || $timeout < 0) { - throw new Exception("Invalid value supplied for request timeout: $timeout"); - } - - $this->requestTimeout = $timeout; - return $this; - } - - /** - * If a proxy is needed to look up the address of the Piwik site, set it with this - * @param string $proxy IP as string, for example "173.234.92.107" - * @param int $proxyPort - */ - public function setProxy($proxy, $proxyPort = 80) - { - $this->proxy = $proxy; - $this->proxyPort = $proxyPort; - } - - /** - * 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() - { - if (isset($this->proxy) && isset($this->proxyPort)) { - return $this->proxy.":".$this->proxyPort; - } - return null; - } - - /** - * Used in tests to output useful error messages. - * - * @ignore - */ - static public $DEBUG_LAST_REQUESTED_URL = false; - - /** - * @ignore - */ - protected function sendRequest($url, $method = 'GET', $data = null, $force = false) - { - self::$DEBUG_LAST_REQUESTED_URL = $url; - - // if doing a bulk request, store the url - if ($this->doBulkRequests && !$force) { - $this->storedTrackingActions[] - = $url - . (!empty($this->userAgent) ? ('&ua=' . urlencode($this->userAgent)) : '') - . (!empty($this->acceptLanguage) ? ('&lang=' . urlencode($this->acceptLanguage)) : ''); - - // Clear custom variables so they don't get copied over to other users in the bulk request - $this->clearCustomVariables(); - $this->clearCustomTrackingParameters(); - $this->userAgent = false; - $this->acceptLanguage = false; - - return true; - } - - $proxy = $this->getProxy(); - - if (function_exists('curl_init') && function_exists('curl_exec')) { - $options = array( - CURLOPT_URL => $url, - CURLOPT_USERAGENT => $this->userAgent, - CURLOPT_HEADER => true, - CURLOPT_TIMEOUT => $this->requestTimeout, - CURLOPT_HTTPHEADER => array( - 'Accept-Language: ' . $this->acceptLanguage, - ), - ); - - if ($method === 'GET') { - $options[CURLOPT_RETURNTRANSFER] = true; - } - - if (defined('PATH_TO_CERTIFICATES_FILE')) { - $options[CURLOPT_CAINFO] = PATH_TO_CERTIFICATES_FILE; - } - - if (isset($proxy)) { - $options[CURLOPT_PROXY] = $proxy; - } - - switch ($method) { - case 'POST': - $options[CURLOPT_POST] = true; - break; - default: - break; - } - - // only supports JSON data - if (!empty($data)) { - $options[CURLOPT_HTTPHEADER][] = 'Content-Type: application/json'; - $options[CURLOPT_HTTPHEADER][] = 'Expect:'; - $options[CURLOPT_POSTFIELDS] = $data; - } - - if (!empty($this->outgoingTrackerCookies)) { - $options[CURLOPT_COOKIE] = http_build_query($this->outgoingTrackerCookies); - $this->outgoingTrackerCookies = array(); - } - - $ch = curl_init(); - curl_setopt_array($ch, $options); - ob_start(); - $response = @curl_exec($ch); - ob_end_clean(); - $header = ''; - $content = ''; - if (!empty($response)) { - list($header, $content) = explode("\r\n\r\n", $response, $limitCount = 2); - } - - $this->parseIncomingCookies(explode("\r\n", $header)); - - } elseif (function_exists('stream_context_create')) { - $stream_options = array( - 'http' => array( - 'method' => $method, - 'user_agent' => $this->userAgent, - 'header' => "Accept-Language: " . $this->acceptLanguage . "\r\n", - 'timeout' => $this->requestTimeout, // PHP 5.2.1 - ), - ); - - if (isset($proxy)) { - $stream_options['http']['proxy'] = $proxy; - } - - // only supports JSON data - if (!empty($data)) { - $stream_options['http']['header'] .= "Content-Type: application/json \r\n"; - $stream_options['http']['content'] = $data; - } - - if (!empty($this->outgoingTrackerCookies)) { - $stream_options['http']['header'] .= 'Cookie: ' . http_build_query($this->outgoingTrackerCookies) . "\r\n"; - $this->outgoingTrackerCookies = array(); - } - - $ctx = stream_context_create($stream_options); - $response = file_get_contents($url, 0, $ctx); - $content = $response; - - $this->parseIncomingCookies($http_response_header); - } - - return $content; - } - - /** - * Returns current timestamp, or forced timestamp/datetime if it was set - * @return string|int - */ - protected function getTimestamp() - { - return !empty($this->forcedDatetime) - ? strtotime($this->forcedDatetime) - : time(); - } - - /** - * Returns the base URL for the piwik server. - */ - protected function getBaseUrl() - { - if (empty(self::$URL)) { - throw new Exception( - 'You must first set the Piwik Tracker URL by calling - PiwikTracker::$URL = \'http://your-website.org/piwik/\';' - ); - } - if (strpos(self::$URL, '/piwik.php') === false - && strpos(self::$URL, '/proxy-piwik.php') === false - && strpos(self::$URL, '/matomo.php') === false - && strpos(self::$URL, '/proxy-matomo.php') === false - ) { - self::$URL .= '/piwik.php'; - } - - return self::$URL; - } - - /** - * @ignore - */ - protected function getRequest($idSite) - { - $this->setFirstPartyCookies(); - - $customFields = ''; - if (!empty($this->customParameters)) { - $customFields = '&' . http_build_query($this->customParameters, '', '&'); - } - - $url = $this->getBaseUrl() . - '?idsite=' . $idSite . - '&rec=1' . - '&apiv=' . self::VERSION . - '&r=' . substr(strval(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']) : '') . - - // Only allowed for Admin/Super User, token_auth required, - ((!empty($this->ip) && !empty($this->token_auth)) ? '&cip=' . $this->ip : '') . - (!empty($this->userId) ? '&uid=' . urlencode($this->userId) : '') . - (!empty($this->forcedDatetime) ? '&cdt=' . urlencode($this->forcedDatetime) : '') . - (!empty($this->forcedNewVisit) ? '&new_visit=1' : '') . - ((!empty($this->token_auth) && !$this->doBulkRequests) ? - '&token_auth=' . urlencode($this->token_auth) : '') . - - // Values collected from cookie - '&_idts=' . $this->createTs . - '&_idvc=' . $this->visitCount . - (!empty($this->lastVisitTs) ? '&_viewts=' . $this->lastVisitTs : '') . - (!empty($this->ecommerceLastOrderTimestamp) ? - '&_ects=' . urlencode($this->ecommerceLastOrderTimestamp) : '') . - - // 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) ? - '&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 : '') . - - // 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->generationTime) ? '>_ms=' . ((int)$this->generationTime) : '') . - (!empty($this->forcedVisitorId) ? '&cid=' . $this->forcedVisitorId : '&_id=' . $this->getVisitorId()) . - - // URL parameters - '&url=' . urlencode($this->pageUrl) . - '&urlref=' . urlencode($this->urlReferrer) . - ((!empty($this->pageCharset) && $this->pageCharset != self::DEFAULT_CHARSET_PARAMETER_VALUES) ? - '&cs=' . $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]) : '') . - // Campaign keyword - (!empty($this->attributionInfo[1]) ? '&_rck=' . urlencode($this->attributionInfo[1]) : '') . - // Timestamp at which the referrer was set - (!empty($this->attributionInfo[2]) ? '&_refts=' . $this->attributionInfo[2] : '') . - // Referrer URL - (!empty($this->attributionInfo[3]) ? '&_ref=' . urlencode($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) : '') . - $customFields . - (!$this->sendImageResponse ? '&send_image=0' : '') . - - // DEBUG - $this->DEBUG_APPEND_URL; - - - // Reset page level custom variables after this page view - $this->pageCustomVar = array(); - $this->eventCustomVar = array(); - $this->clearCustomTrackingParameters(); - - // force new visit only once, user must call again setForceNewVisit() - $this->forcedNewVisit = false; - - return $url; - } - - - /** - * Returns a first party cookie which name contains $name - * - * @param string $name - * @return string String value of cookie, or false if not found - * @ignore - */ - protected function getCookieMatchingName($name) - { - if ($this->configCookiesDisabled) { - return false; - } - if (!is_array($_COOKIE)) { - return false; - } - $name = $this->getCookieName($name); - - // Piwik cookie names use dots separators in piwik.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; - } - } - - return false; - } - - /** - * If current URL is "http://example.org/dir1/dir2/index.php?param1=value1¶m2=value2" - * will return "/dir1/dir2/index.php" - * - * @return string - * @ignore - */ - protected static function getCurrentScriptName() - { - $url = ''; - if (!empty($_SERVER['PATH_INFO'])) { - $url = $_SERVER['PATH_INFO']; - } else { - if (!empty($_SERVER['REQUEST_URI'])) { - if (($pos = strpos($_SERVER['REQUEST_URI'], '?')) !== false) { - $url = substr($_SERVER['REQUEST_URI'], 0, $pos); - } else { - $url = $_SERVER['REQUEST_URI']; - } - } - } - if (empty($url)) { - $url = $_SERVER['SCRIPT_NAME']; - } - - if ($url[0] !== '/') { - $url = '/' . $url; - } - - return $url; - } - - /** - * If the current URL is 'http://example.org/dir1/dir2/index.php?param1=value1¶m2=value2" - * will return 'http' - * - * @return string 'https' or 'http' - * @ignore - */ - protected static function getCurrentScheme() - { - if (isset($_SERVER['HTTPS']) - && ($_SERVER['HTTPS'] == 'on' || $_SERVER['HTTPS'] === true) - ) { - return 'https'; - } - - return 'http'; - } - - /** - * If current URL is "http://example.org/dir1/dir2/index.php?param1=value1¶m2=value2" - * will return "http://example.org" - * - * @return string - * @ignore - */ - protected static function getCurrentHost() - { - if (isset($_SERVER['HTTP_HOST'])) { - return $_SERVER['HTTP_HOST']; - } - - return 'unknown'; - } - - /** - * If current URL is "http://example.org/dir1/dir2/index.php?param1=value1¶m2=value2" - * will return "?param1=value1¶m2=value2" - * - * @return string - * @ignore - */ - protected static function getCurrentQueryString() - { - $url = ''; - if (isset($_SERVER['QUERY_STRING']) - && !empty($_SERVER['QUERY_STRING']) - ) { - $url .= '?' . $_SERVER['QUERY_STRING']; - } - - return $url; - } - - /** - * Returns the current full URL (scheme, host, path and query string. - * - * @return string - * @ignore - */ - protected static function getCurrentUrl() - { - return self::getCurrentScheme() . '://' - . self::getCurrentHost() - . self::getCurrentScriptName() - . self::getCurrentQueryString(); - } - - /** - * Sets the first party cookies as would the piwik.js - * All cookies are supported: 'id' and 'ses' and 'ref' and 'cvar' cookies. - * @return $this - */ - protected function setFirstPartyCookies() - { - if ($this->configCookiesDisabled) { - return $this; - } - - if (empty($this->cookieVisitorId)) { - $this->loadVisitorIdCookie(); - } - - // Set the 'ref' cookie - $attributionInfo = $this->getAttributionInfo(); - if (!empty($attributionInfo)) { - $this->setCookie('ref', $attributionInfo, $this->configReferralCookieTimeout); - } - - // Set the 'ses' cookie - $this->setCookie('ses', '*', $this->configSessionCookieTimeout); - - // Set the 'id' cookie - $visitCount = $this->visitCount + 1; - $cookieValue = $this->getVisitorId() . '.' . $this->createTs . '.' . $visitCount . '.' . $this->currentTs . - '.' . $this->lastVisitTs . '.' . $this->ecommerceLastOrderTimestamp; - $this->setCookie('id', $cookieValue, $this->configVisitorCookieTimeout); - - // Set the 'cvar' cookie - $this->setCookie('cvar', json_encode($this->visitorCustomVar), $this->configSessionCookieTimeout); - return $this; - } - - /** - * Sets a first party cookie to the client to improve dual JS-PHP tracking. - * - * This replicates the piwik.js tracker algorithms for consistency and better accuracy. - * - * @param $cookieName - * @param $cookieValue - * @param $cookieTTL - * @return $this - */ - protected function setCookie($cookieName, $cookieValue, $cookieTTL) - { - $cookieExpire = $this->currentTs + $cookieTTL; - if (!headers_sent()) { - setcookie( - $this->getCookieName($cookieName), - $cookieValue, - $cookieExpire, - $this->configCookiePath, - $this->configCookieDomain - ); - } - return $this; - } - - /** - * @return bool|mixed - */ - protected function getCustomVariablesFromCookie() - { - $cookie = $this->getCookieMatchingName('cvar'); - if (!$cookie) { - return false; - } - - return json_decode($cookie, $assoc = true); - } - - /** - * Sets a cookie to be sent to the tracking server. - * - * @param $name - * @param $value - */ - public function setOutgoingTrackerCookie($name, $value) - { - if ($value === null) { - unset($this->outgoingTrackerCookies[$name]); - } - else { - $this->outgoingTrackerCookies[$name] = $value; - } - } - - /** - * Gets a cookie which was set by the tracking server. - * - * @param $name - * - * @return bool|string - */ - public function getIncomingTrackerCookie($name) - { - if (isset($this->incomingTrackerCookies[$name])) { - return $this->incomingTrackerCookies[$name]; - } - - return false; - } - - /** - * Reads incoming tracking server cookies. - * - * @param $headers Array with HTTP response headers as values - */ - protected function parseIncomingCookies($headers) - { - $this->incomingTrackerCookies = array(); - - if (!empty($headers)) { - $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); - } - } - } +if (!class_exists('\MatomoTracker')) { + include_once('MatomoTracker.php'); } /** * Helper function to quickly generate the URL to track a page view. * - * @param $idSite + * @deprecated + * @param int $idSite * @param string $documentTitle * @return string */ -function Piwik_getUrlTrackPageView($idSite, $documentTitle = '') +function Piwik_getUrlTrackPageView(int $idSite, string $documentTitle = ''): string { - $tracker = new PiwikTracker($idSite); - - return $tracker->getUrlTrackPageView($documentTitle); + return Matomo_getUrlTrackPageView($idSite, $documentTitle); } /** * Helper function to quickly generate the URL to track a goal. * - * @param $idSite - * @param $idGoal - * @param float $revenue + * @deprecated + * @param int $idSite + * @param int $idGoal + * @param float|null $revenue * @return string */ -function Piwik_getUrlTrackGoal($idSite, $idGoal, $revenue = 0.0) +function Piwik_getUrlTrackGoal(int $idSite, int $idGoal, ?float $revenue = null): string { - $tracker = new PiwikTracker($idSite); + return Matomo_getUrlTrackGoal($idSite, $idGoal, $revenue); +} - return $tracker->getUrlTrackGoal($idGoal, $revenue); +/** + * For BC only + * + * @deprecated use MatomoTracker instead + */ +class PiwikTracker extends MatomoTracker +{ } diff --git a/README.md b/README.md index d458bc4..8acbf67 100644 --- a/README.md +++ b/README.md @@ -1,14 +1,91 @@ # PHP Client for Matomo Analytics Tracking API -The PHP Tracker Client provides all features of the [Matomo Javascript Tracker](http://developer.matomo.org/api-reference/tracking-javascript), such as Ecommerce Tracking, Custom Variable, Event tracking and more. +The PHP Tracker Client provides all features of the [Matomo Javascript Tracker](https://developer.matomo.org/api-reference/tracking-javascript), such as Ecommerce Tracking, Custom Variables, Event Tracking and more. ## Documentation and examples -Check out our [Matomo-PHP-Tracker developer documentation](http://developer.matomo.org/api-reference/PHP-Piwik-Tracker) and [Matomo Tracking API guide](http://matomo.org/docs/tracking-api/). +Check out our [Matomo-PHP-Tracker developer documentation](https://developer.matomo.org/api-reference/PHP-Piwik-Tracker) and [Matomo Tracking API guide](https://matomo.org/docs/tracking-api/). + + +```php +// Required variables +$matomoSiteId = 6; // Site ID +$matomoUrl = "https://example.tld"; // Your matomo URL +$matomoToken = ""; // Your authentication token + +// Optional variable +$matomoPageTitle = ""; // The title of the page + +// Load object +require_once("MatomoTracker.php"); + +// Matomo object +$matomoTracker = new MatomoTracker((int)$matomoSiteId, $matomoUrl); + +// Set authentication token +$matomoTracker->setTokenAuth($matomoToken); + +// Track page view +$matomoTracker->doTrackPageView($matomoPageTitle); +``` ## Requirements: -* json extension (json_decode, json_encode) -* CURL or STREAM extensions (to issue the HTTPS request to Matomo) +* PHP 8.1 or newer +* JSON extension (json_decode, json_encode) +* cURL or stream extension (to issue the HTTPS request to Matomo) + +## Installation + +### Composer + +``` +composer require matomo/matomo-php-tracker +``` + +### Manually + +Alternatively, you can download the files and require the Matomo tracker manually: + +``` +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](http://www.opensource.org/licenses/bsd-license.php) +Released under the [BSD License](https://opensource.org/licenses/BSD-3-Clause) diff --git a/composer.json b/composer.json index 4b7babd..e54bbfd 100644 --- a/composer.json +++ b/composer.json @@ -1,22 +1,61 @@ { - "name": "piwik/piwik-php-tracker", - "description": "PHP Client for Piwik Analytics Tracking API", - "keywords": ["piwik","tracker","analytics"], - "homepage": "http://piwik.org", - "license": "BSD-2-Clause", + "name": "matomo/matomo-php-tracker", + "description": "PHP Client for Matomo Analytics Tracking API", + "keywords": ["matomo","piwik","tracker","analytics"], + "homepage": "https://matomo.org", + "license": "BSD-3-Clause", "authors": [ { - "name": "The Piwik Team", - "email": "hello@piwik.org", - "homepage": "http://piwik.org/the-piwik-team/" + "name": "The Matomo Team", + "email": "hello@matomo.org", + "homepage": "https://matomo.org/team/" } ], "support": { - "forum": "http://forum.piwik.org/", - "issues": "https://github.com/piwik/piwik-php-tracker/issues", - "source": "https://github.com/piwik/piwik-php-tracker" + "forum": "https://forum.matomo.org/", + "issues": "https://github.com/matomo-org/matomo-php-tracker/issues", + "source": "https://github.com/matomo-org/matomo-php-tracker" }, + "require": { + "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": ["."] + }, + "autoload-dev": { + "psr-4": { + "\\": "tests/" + } + }, + "require-dev": { + "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 new file mode 100644 index 0000000..4df188b --- /dev/null +++ b/phpunit.xml.dist @@ -0,0 +1,14 @@ + + + + + ./tests/Unit + + + + + MatomoTracker.php + PiwikTracker.php + + + diff --git a/run_tests.sh b/run_tests.sh new file mode 100755 index 0000000..fc551d1 --- /dev/null +++ b/run_tests.sh @@ -0,0 +1,3 @@ +#!/usr/bin/env bash + +php vendor/bin/phpunit diff --git a/tests/Unit/MatomoTrackerTest.php b/tests/Unit/MatomoTrackerTest.php new file mode 100644 index 0000000..152eb72 --- /dev/null +++ b/tests/Unit/MatomoTrackerTest.php @@ -0,0 +1,1936 @@ +setUrl('http://somesite.com'); + + return $tracker; + } + + /** + * @return array|string> + */ + private static function parseQueryParams(string $url): array + { + $queryStr = parse_url($url, PHP_URL_QUERY); + self::assertIsString($queryStr); + parse_str($queryStr, $query); + + /** @var array|string> $query */ + return $query; + } + + public function testTrackingWithCookieSetsCorrectUrl(): void + { + $testVisitorId = substr(md5('testuuid'), 0, 16); + $this->assertEquals(16, strlen($testVisitorId)); + + $createTs = strtotime('2020-03-04 03:04:05'); + + $cookieName = '_pk_id_1_f609'; + $_COOKIE[$cookieName] = $testVisitorId . '.' . $createTs; + + $tracker = new \MatomoTracker(1, self::TEST_URL); + $tracker->setUrl('http://somesite.com'); + $url = $tracker->getUrlTrackPageView('test title'); + $url = (string) preg_replace('/&r=\d+/', "", $url); + + $query = self::parseQueryParams($url); + + $this->assertEquals($testVisitorId, $query['_id']); + $this->assertEquals($createTs, $query['_idts']); + + $expected = 'http://mymatomo.com/matomo.php?idsite=1&rec=1&apiv=1&_idts=1583291045&_id=0958f111f2588a1b&url=http%3A%2F%2Fsomesite.com&urlref=&action_name=test+title'; + $this->assertEquals($expected, $url); + } + + public function testTrackingWithPreMatomo4CookieSetsCorrectUrl(): void + { + $testVisitorId = substr(md5('testother'), 0, 16); + $this->assertEquals(16, strlen($testVisitorId)); + + $createTs = strtotime('2020-03-04 05:04:05'); + $currentTs = strtotime('2020-03-05 05:04:05'); + $lastVisitTs = strtotime('2020-03-06 05:04:05'); + $ecommerceLastOrderTs = strtotime('2020-03-06 06:04:05'); + + $cookieName = '_pk_id_1_f609'; + $_COOKIE[$cookieName] = $testVisitorId . '.' . $createTs . '.5.' . $currentTs . '.' . $lastVisitTs . '.' . $ecommerceLastOrderTs; + + $tracker = new \MatomoTracker(1, self::TEST_URL); + $tracker->setUrl('http://somesite.com'); + $url = $tracker->getUrlTrackPageView('test title'); + $url = (string) preg_replace('/&r=\d+/', "", $url); + + $query = self::parseQueryParams($url); + + $this->assertEquals($testVisitorId, $query['_id']); + $this->assertEquals($createTs, $query['_idts']); + + $expected = 'http://mymatomo.com/matomo.php?idsite=1&rec=1&apiv=1&_idts=1583298245&_id=b446c233274f79f0&url=http%3A%2F%2Fsomesite.com&urlref=&action_name=test+title'; + $this->assertEquals($expected, $url); + } + + 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); + $tracker->setApiUrl($newApiUrl); + $url = $tracker->getUrlTrackPageView('test title'); + + $this->assertSame(substr($url, 0, strlen($newApiUrl)), $newApiUrl); + } + + public function testUsageApiUrl(): void + { + $newApiUrl = 'https://NEW-API-URL.com'; + $tracker = new \MatomoTracker(1, $newApiUrl); + $url = $tracker->getUrlTrackPageView('test title'); + + $this->assertSame(substr($url, 0, strlen($newApiUrl)), $newApiUrl); + } + + public function testGetBaseUrlThrowsWhenNoUrlConfigured(): void + { + $tracker = new TestableMatomoTracker(1); + + $this->expectException(Exception::class); + $this->expectExceptionMessage('You must first set the Matomo Tracker URL'); + + $tracker->callGetBaseUrl(); + } + + public function testGetBaseUrlAppendsMatomoPhp(): void + { + $tracker = new TestableMatomoTracker(1, 'http://example.org/matomo/'); + $this->assertSame('http://example.org/matomo/matomo.php', $tracker->callGetBaseUrl()); + + $tracker = new TestableMatomoTracker(1, 'http://example.org/matomo.php'); + $this->assertSame('http://example.org/matomo.php', $tracker->callGetBaseUrl()); + + $tracker = new TestableMatomoTracker(1, 'http://example.org/proxy-matomo.php'); + $this->assertSame('http://example.org/proxy-matomo.php', $tracker->callGetBaseUrl()); + } + + /** + * @dataProvider getTestDataForIsUserAgentAIBot + */ + public function testIsUserAgentAIBot(string $userAgent, bool $expected): void + { + $this->assertSame($expected, \MatomoTracker::isUserAgentAIBot($userAgent)); + } + + /** + * @return list + */ + public static function getTestDataForIsUserAgentAIBot(): array + { + return [ + ['', false], + + ['Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/134.0.0.0 Safari/537.3', false], + ['Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/122.0.0.0 Mobile Safari/537.3', false], + + ['Mozilla/5.0 AppleWebKit/537.36 (KHTML, like Gecko); compatible; ChatGPT-User/1.0; +https://openai.com/bot', true], + ['Mozilla/5.0 AppleWebKit/537.36 (KHTML, like Gecko); compatible; GPTBot/1.1; +https://openai.com/gptbot', false], + ['Mozilla/5.0 AppleWebKit/537.36 (KHTML, like Gecko; compatible; MistralAI-User/1.0; +https://docs.mistral.ai/robots)', true], + ['Mozilla/5.0 AppleWebKit/537.36 (KHTML, like Gecko; compatible; Gemini-Deep-Research; +https://gemini.google/overview/deep-research/) Chrome/135.0.0.0 Safari/537.36', true], + ['Mozilla/5.0 AppleWebKit/537.36 (KHTML, like Gecko; compatible; Claude-User/1.0; +Claude-User@anthropic.com)', true], + ['Mozilla/5.0 AppleWebKit/537.36 (KHTML, like Gecko; compatible; Perplexity-User/1.0; +https://perplexity.ai/perplexity-user)', true], + ['Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/137.0.0.0 Safari/537.36 (compatible; Google-GeminiNotebook; +https://developers.google.com/crawling/docs/crawlers-fetchers/google-gemininotebook)', true], + ['Google-NotebookLM/1.0', true], + ['Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/137.0.0.0 Safari/537.36; Devin/1.0; +devin.ai', false], + ]; + } + + public function testIsUserAgentAIBotWithNull(): void + { + $this->assertFalse(\MatomoTracker::isUserAgentAIBot(null)); + } + + /** + * @dataProvider getTestDataForGetUrlTrackAIBot + */ + 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, self::TEST_URL); + $tracker->setUrl('https://example.com/page'); + $tracker->setVisitorId('abcdef01234517ab'); + + $actual = $tracker->getUrlTrackAIBot($httpStatus, $responseSizeBytes, $serverTimeMs, $source); + $actual = $this->normalizeTrackingUrl($actual); + + $this->assertEquals($expected, $actual); + } + + /** + * @return list + */ + public static function getTestDataForGetUrlTrackAIBot(): array + { + return [ + [ + 200, + 34567, + 123, + 'wordpress', + 'http://mymatomo.com/matomo.php?idsite=1&rec=1&apiv=1&r=&r=&cid=abcdef01234517ab&url=https%3A%2F%2Fexample.com%2Fpage&urlref=&recMode=1&http_status=200&bw_bytes=34567&pf_srv=123&source=wordpress', + ], + + [ + null, + 34567, + null, + 'something else', + 'http://mymatomo.com/matomo.php?idsite=1&rec=1&apiv=1&r=&r=&cid=abcdef01234517ab&url=https%3A%2F%2Fexample.com%2Fpage&urlref=&recMode=1&bw_bytes=34567&source=something%20else', + ], + + [ + null, + null, + null, + null, + 'http://mymatomo.com/matomo.php?idsite=1&rec=1&apiv=1&r=&r=&cid=abcdef01234517ab&url=https%3A%2F%2Fexample.com%2Fpage&urlref=&recMode=1', + ], + ]; + } + + 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', + '_idts', + ]; + + foreach ($nonDeterministicParams as $param) { + $url = (string) preg_replace('/&' . preg_quote($param, '/') . '=[^&]+/', '&r=', $url); + } + + return $url; + } + + public function testDoTrackPageViewGeneratesNewPageviewId(): void + { + $tracker = $this->createTracker(); + $tracker->doTrackPageView('page one'); + $firstId = $tracker->getPageviewId(); + + $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')); + } + + /** + * Loading the tracker must not emit any notice, as tools that turn those into exceptions + * (e.g. Psalm) would abort while autoloading the class. This is what the `$http_response_header` + * assignment in `sendRequest()` guards, so that assignment must stay above the read following it. + */ + public function testLoadingTheTrackerEmitsNoDeprecationNotice(): void + { + // -n ignores the environment's php.ini, so that unrelated startup diagnostics (e.g. a + // dangling extension line) cannot fail this test + $command = escapeshellarg(PHP_BINARY) + . ' -n -d error_reporting=-1 -d display_errors=1 -d log_errors=0 -r ' + . escapeshellarg('include ' . var_export(dirname(__DIR__, 2) . '/MatomoTracker.php', true) . '; echo \'loaded\';') + . ' 2>&1'; + + $output = []; + $exitCode = -1; + exec($command, $output, $exitCode); + + // expecting the marker rather than just no output also catches a child that never ran + $this->assertSame('loaded', trim(implode("\n", $output)), 'Loading the tracker must not emit any notice'); + $this->assertSame(0, $exitCode); + } + + 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()); + + // SCRIPT_NAME is only the fallback when REQUEST_URI is unavailable. + $_SERVER['SCRIPT_NAME'] = 'script.php'; + $this->assertSame('/script.php', TestableMatomoTracker::callGetCurrentScriptName()); + + // REQUEST_URI is the primary source; the query string is stripped. + $_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()); + + // Front-controller / path-info routing: with a request for /dir1/page handled by + // dir1/index.php, PATH_INFO is only "/page". The full requested path must still be tracked, + // so REQUEST_URI wins and PATH_INFO is ignored (previously it truncated the URL to "/page"). + $_SERVER['REQUEST_URI'] = '/dir1/page'; + $_SERVER['PATH_INFO'] = '/page'; + $_SERVER['SCRIPT_NAME'] = '/dir1/index.php'; + $this->assertSame('/dir1/page', 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'; + unset($_SERVER['PATH_INFO']); + $_SERVER['REQUEST_URI'] = '/page'; + $this->assertSame('https://matomo.example/page?a=b&c=d', TestableMatomoTracker::callGetCurrentUrl()); + } + + public function testHelperFunctions(): void + { + \MatomoTracker::$URL = self::TEST_URL; + + $url = \Matomo_getUrlTrackPageView(5, 'my title'); + $this->assertStringContainsString('idsite=5', $url); + $this->assertStringContainsString('&action_name=my+title', $url); + + $url = \Matomo_getUrlTrackGoal(5, 3, 1.5); + $this->assertStringContainsString('idsite=5', $url); + $this->assertStringContainsString('&idgoal=3', $url); + $this->assertStringContainsString('&revenue=1.5', $url); + } + + public function testPiwikCompatibilityShim(): void + { + \MatomoTracker::$URL = self::TEST_URL; + + $tracker = new \PiwikTracker(1, self::TEST_URL); + $this->assertInstanceOf(\MatomoTracker::class, $tracker); + + $url = \Piwik_getUrlTrackPageView(5, 'my title'); + $this->assertStringContainsString('idsite=5', $url); + + $url = \Piwik_getUrlTrackGoal(5, 3, 1.5); + $this->assertStringContainsString('&idgoal=3', $url); + } +} diff --git a/tests/Unit/TestableMatomoTracker.php b/tests/Unit/TestableMatomoTracker.php new file mode 100644 index 0000000..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(); + } +}