, 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); $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 populates $http_response_header in the local scope whenever an HTTP response // was received; the $response !== false guard guarantees that is the case here. $responseHeaders = $http_response_header; } $this->parseIncomingCookies($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'); }