Visit.php 25,33 Kio
<?php
/**
* Piwik - free/libre analytics platform
*
* @link http://piwik.org
* @license http://www.gnu.org/licenses/gpl-3.0.html GPL v3 or later
*
*/
namespace Piwik\Tracker;
use Piwik\Archive\ArchiveInvalidator;
use Piwik\Common;
use Piwik\Config;
use Piwik\Date;
use Piwik\Exception\UnexpectedWebsiteFoundException;
use Piwik\Network\IPUtils;
use Piwik\Piwik;
use Piwik\Plugin\Dimension\VisitDimension;
use Piwik\SettingsPiwik;
use Piwik\Tracker;
/**
* Class used to handle a Visit.
* A visit is either NEW or KNOWN.
* - If a visit is NEW then we process the visitor information (settings, referrers, etc.) and save
* a new line in the log_visit table.
* - If a visit is KNOWN then we update the visit row in the log_visit table, updating the number of pages
* views, time spent, etc.
*
* Whether a visit is NEW or KNOWN we also save the action in the DB.
* One request to the piwik.php script is associated to one action.
*
*/
class Visit implements VisitInterface
{
const UNKNOWN_CODE = 'xx';
/**
* @var GoalManager
*/
protected $goalManager;
/**
* @var Request
*/
protected $request;
protected $visitorInfo = array();
/**
* @var Settings
*/
protected $userSettings;
protected $visitorCustomVariables = array();
public static $dimensions;
/**
* @param Request $request
*/
public function setRequest(Request $request)
{
$this->request = $request;
}
/**
* Main algorithm to handle the visit.
*
* Once we have the visitor information, we have to determine if the visit is a new or a known visit.
*
* 1) When the last action was done more than 30min ago,
* or if the visitor is new, then this is a new visit.
*
* 2) If the last action is less than 30min ago, then the same visit is going on.
* Because the visit goes on, we can get the time spent during the last action.
*
* NB:
* - In the case of a new visit, then the time spent
* during the last action of the previous visit is unknown.
*
* - In the case of a new visit but with a known visitor,
* we can set the 'returning visitor' flag.
*
* In all the cases we set a cookie to the visitor with the new information.
*/
public function handle()
{
// the IP is needed by isExcluded() and GoalManager->recordGoals()
$this->visitorInfo['location_ip'] = $this->request->getIp();
$excluded = new VisitExcluded($this->request, $this->visitorInfo['location_ip']);
if ($excluded->isExcluded()) {
return;
}
/**
* Triggered after visits are tested for exclusion so plugins can modify the IP address
* persisted with a visit.
*
* This event is primarily used by the **PrivacyManager** plugin to anonymize IP addresses.
*
* @param string &$ip The visitor's IP address.
*/
Piwik::postEvent('Tracker.setVisitorIp', array(&$this->visitorInfo['location_ip']));
$this->visitorCustomVariables = $this->request->getCustomVariables($scope = 'visit');
if (!empty($this->visitorCustomVariables)) {
Common::printDebug("Visit level Custom Variables: ");
Common::printDebug($this->visitorCustomVariables);
}
/**
* Goals & Ecommerce conversions
*/
$isManualGoalConversion = $requestIsEcommerce = $visitIsConverted = $someGoalsConverted = false;
$action = null;
$goalManager = null;
if($this->isPingRequest()) {
// on a ping request that is received before the standard visit length, we just update the visit time w/o adding a new action
Common::printDebug("-> ping=1 request: we do not track a new action nor a new visit nor any goal.");
} else {
$goalManager = new GoalManager($this->request);
$isManualGoalConversion = $goalManager->isManualGoalConversion();
$requestIsEcommerce = $goalManager->requestIsEcommerce;
if ($requestIsEcommerce) {
$someGoalsConverted = true;
// Mark the visit as Converted only if it is an order (not for a Cart update)
if ($goalManager->isGoalAnOrder()) {
$visitIsConverted = true;
}
} elseif ($isManualGoalConversion) {
// this request is from the JS call to piwikTracker.trackGoal()
$someGoalsConverted = $goalManager->detectGoalId($this->request->getIdSite());
$visitIsConverted = $someGoalsConverted;
// if we find a idgoal in the URL, but then the goal is not valid, this is most likely a fake request
if (!$someGoalsConverted) {
Common::printDebug('Invalid goal tracking request for goal id = ' . $goalManager->idGoal);
return;
}
} else {
// normal page view, potentially triggering a URL matching goal
$action = Action::factory($this->request);
$action->writeDebugInfo();
$someGoalsConverted = $goalManager->detectGoalsMatchingUrl($this->request->getIdSite(), $action);
$visitIsConverted = $someGoalsConverted;
$action->loadIdsFromLogActionTable();
}
}
/***
* Visitor recognition
*/
$visitorId = $this->getSettingsObject()->getConfigId();
$visitor = new Visitor($this->request, $visitorId, $this->visitorInfo, $this->visitorCustomVariables);
$visitor->recognize();
$this->visitorInfo = $visitor->getVisitorInfo();
$isNewVisit = $this->isVisitNew($visitor, $action);
// Known visit when:
// ( - the visitor has the Piwik cookie with the idcookie ID used by Piwik to match the visitor
// OR
// - the visitor doesn't have the Piwik cookie but could be match using heuristics @see recognizeTheVisitor()
// )
// AND
// - the last page view for this visitor was less than 30 minutes ago @see isLastActionInTheSameVisit()
if (!$isNewVisit) {
$idReferrerActionUrl = $this->visitorInfo['visit_exit_idaction_url'];
$idReferrerActionName = $this->visitorInfo['visit_exit_idaction_name'];
try {
if($goalManager) {
$goalManager->detectIsThereExistingCartInVisit($this->visitorInfo);
}
$this->handleExistingVisit($visitor, $action, $visitIsConverted);
if (!is_null($action)) {
$action->record($visitor, $idReferrerActionUrl, $idReferrerActionName);
}
} catch (VisitorNotFoundInDb $e) {
// There is an edge case when:
// - two manual goal conversions happen in the same second
// - which result in handleExistingVisit throwing the exception
// because the UPDATE didn't affect any rows (one row was found, but not updated since no field changed)
// - the exception is caught here and will result in a new visit incorrectly
// In this case, we cancel the current conversion to be recorded:
if ($isManualGoalConversion
|| $requestIsEcommerce
) {
$someGoalsConverted = $visitIsConverted = false;
} // When the row wasn't found in the logs, and this is a pageview or
// goal matching URL, we force a new visitor
else {
$visitor->setIsVisitorKnown(false);
}
}
}
// New visit when:
// - the visitor has the Piwik cookie but the last action was performed more than 30 min ago @see isLastActionInTheSameVisit()
// - the visitor doesn't have the Piwik cookie, and couldn't be matched in @see recognizeTheVisitor()
// - the visitor does have the Piwik cookie but the idcookie and idvisit found in the cookie didn't match to any existing visit in the DB
if ($isNewVisit) {
// When a ping request is received more than 30 min after the last request/ping,
// we choose not to create a new visit.
if ($this->isPingRequest()) {
Common::printDebug("-> ping=1 request: we do _not_ create a new visit.");
return;
}
$this->handleNewVisit($visitor, $action, $visitIsConverted);
if (!is_null($action)) {
$action->record($visitor, 0, 0);
}
}
// update the cookie with the new visit information
$this->request->setThirdPartyCookie($this->visitorInfo['idvisitor']);
// record the goals if applicable
if ($someGoalsConverted) {
$goalManager->recordGoals(
$visitor,
$this->visitorInfo,
$this->visitorCustomVariables,
$action
);
}
unset($action);
$this->markArchivedReportsAsInvalidIfArchiveAlreadyFinished();
}
/**
* In the case of a known visit, we have to do the following actions:
*
* 1) Insert the new action
* 2) Update the visit information
*
* @param Visitor $visitor
* @param Action $action
* @param $visitIsConverted
* @throws VisitorNotFoundInDb
*/
protected function handleExistingVisit($visitor, $action, $visitIsConverted)
{
Common::printDebug("Visit is known (IP = " . IPUtils::binaryToStringIP($this->getVisitorIp()) . ")");
$valuesToUpdate = $this->getExistingVisitFieldsToUpdate($visitor, $action, $visitIsConverted);
// TODO we should not have to sync this->visitorInfo and $visitor columns.
// TODO it should be its own dimension
$this->setVisitorColumn($visitor, 'time_spent_ref_action', $this->getTimeSpentReferrerAction());
// update visitorInfo
foreach ($valuesToUpdate as $name => $value) {
$this->visitorInfo[$name] = $value;
}
/**
* Triggered before a [visit entity](/guides/persistence-and-the-mysql-backend#visits) is updated when
* tracking an action for an existing visit.
*
* This event can be used to modify the visit properties that will be updated before the changes
* are persisted.
*
* @param array &$valuesToUpdate Visit entity properties that will be updated.
* @param array $visit The entire visit entity. Read [this](/guides/persistence-and-the-mysql-backend#visits)
* to see what it contains.
*/
Piwik::postEvent('Tracker.existingVisitInformation', array(&$valuesToUpdate, $this->visitorInfo));
$this->updateExistingVisit($valuesToUpdate);
$this->setVisitorColumn($visitor, 'visit_last_action_time', $this->request->getCurrentTimestamp());
}
/**
* @return int Time in seconds
*/
protected function getTimeSpentReferrerAction()
{
$timeSpent = $this->request->getCurrentTimestamp() - $this->visitorInfo['visit_last_action_time'];
if ($timeSpent < 0) {
$timeSpent = 0;
}
$visitStandardLength = $this->getVisitStandardLength();
if ($timeSpent > $visitStandardLength) {
$timeSpent = $visitStandardLength;
}
return $timeSpent;
}
/**
* In the case of a new visit, we have to do the following actions:
*
* 1) Insert the new action
*
* 2) Insert the visit information
*
* @param Visitor $visitor
* @param Action $action
* @param bool $visitIsConverted
*/
protected function handleNewVisit($visitor, $action, $visitIsConverted)
{
Common::printDebug("New Visit (IP = " . IPUtils::binaryToStringIP($this->getVisitorIp()) . ")");
$this->setNewVisitorInformation($visitor);
$dimensions = $this->getAllVisitDimensions();
$this->triggerHookOnDimensions($dimensions, 'onNewVisit', $visitor, $action);
if ($visitIsConverted) {
$this->triggerHookOnDimensions($dimensions, 'onConvertedVisit', $visitor, $action);
}
/**
* Triggered before a new [visit entity](/guides/persistence-and-the-mysql-backend#visits) is persisted.
*
* This event can be used to modify the visit entity or add new information to it before it is persisted.
* The UserCountry plugin, for example, uses this event to add location information for each visit.
*
* @param array &$visit The visit entity. Read [this](/guides/persistence-and-the-mysql-backend#visits) to see
* what information it contains.
* @param \Piwik\Tracker\Request $request An object describing the tracking request being processed.
*/
Piwik::postEvent('Tracker.newVisitorInformation', array(&$this->visitorInfo, $this->request));
$this->printVisitorInformation();
$idVisit = $this->insertNewVisit($this->visitorInfo);
$this->setVisitorColumn($visitor, 'idvisit', $idVisit);
$this->setVisitorColumn($visitor, 'visit_first_action_time', $this->request->getCurrentTimestamp());
$this->setVisitorColumn($visitor, 'visit_last_action_time', $this->request->getCurrentTimestamp());
}
private function getModel()
{
return new Model();
}
/**
* Returns visitor cookie
*
* @return string binary
*/
protected function getVisitorIdcookie(Visitor $visitor)
{
if ($visitor->isVisitorKnown()) {
return $this->visitorInfo['idvisitor'];
}
// If the visitor had a first party ID cookie, then we use this value
if (!empty($this->visitorInfo['idvisitor'])
&& Tracker::LENGTH_BINARY_ID == strlen($this->visitorInfo['idvisitor'])
) {
return $this->visitorInfo['idvisitor'];
}
return Common::hex2bin($this->generateUniqueVisitorId());
}
/**
* @return string returns random 16 chars hex string
*/
public static function generateUniqueVisitorId()
{
return substr(Common::generateUniqId(), 0, Tracker::LENGTH_HEX_ID_STRING);
}
/**
* Returns the visitor's IP address
*
* @return string
*/
protected function getVisitorIp()
{
return $this->visitorInfo['location_ip'];
}
/**
* Gets the UserSettings object
*
* @return Settings
*/
protected function getSettingsObject()
{
if (is_null($this->userSettings)) {
$this->userSettings = new Settings($this->request, $this->getVisitorIp(),
SettingsPiwik::isSameFingerprintAcrossWebsites());
}
return $this->userSettings;
}
/**
* Returns true if the last action was done during the last 30 minutes
* @return bool
*/
protected function isLastActionInTheSameVisit(Visitor $visitor)
{
$lastActionTime = $visitor->getVisitorColumn('visit_last_action_time');
return isset($lastActionTime)
&& false !== $lastActionTime
&& ($lastActionTime > ($this->request->getCurrentTimestamp() - Config::getInstance()->Tracker['visit_standard_length']));
}
/**
* Returns true if the last action was not today.
* @param Visitor $visitor
* @return bool
*/
private function wasLastActionNotToday(Visitor $visitor)
{
$lastActionTime = $visitor->getVisitorColumn('visit_last_action_time');
if (empty($lastActionTime)) {
return false;
}
$idSite = $this->request->getIdSite();
$timezone = $this->getTimezoneForSite($idSite);
if (empty($timezone)) {
throw new UnexpectedWebsiteFoundException('An unexpected website was found, check idSite in the request');
}
$date = Date::factory((int)$lastActionTime, $timezone);
$now = $this->request->getCurrentTimestamp();
$now = Date::factory((int)$now, $timezone);
return $date->toString() !== $now->toString();
}
// is the referrer host any of the registered URLs for this website?
public static function isHostKnownAliasHost($urlHost, $idSite)
{
$websiteData = Cache::getCacheWebsiteAttributes($idSite);
if (isset($websiteData['hosts'])) {
$canonicalHosts = array();
foreach ($websiteData['hosts'] as $host) {
$canonicalHosts[] = self::toCanonicalHost($host);
}
$canonicalHost = self::toCanonicalHost($urlHost);
if (in_array($canonicalHost, $canonicalHosts)) {
return true;
}
}
return false;
}
private static function toCanonicalHost($host)
{
$hostLower = Common::mb_strtolower($host, 'UTF-8');
return str_replace('www.', '', $hostLower);
}
/**
* @param $valuesToUpdate
* @throws VisitorNotFoundInDb
*/
protected function updateExistingVisit($valuesToUpdate)
{
$idSite = $this->request->getIdSite();
$idVisit = (int)$this->visitorInfo['idvisit'];
$wasInserted = $this->getModel()->updateVisit($idSite, $idVisit, $valuesToUpdate);
// Debug output
if (isset($valuesToUpdate['idvisitor'])) {
$valuesToUpdate['idvisitor'] = bin2hex($valuesToUpdate['idvisitor']);
}
if ($wasInserted) {
Common::printDebug('Updated existing visit: ' . var_export($valuesToUpdate, true));
} else {
throw new VisitorNotFoundInDb(
"The visitor with idvisitor=" . bin2hex($this->visitorInfo['idvisitor']) . " and idvisit=" . $this->visitorInfo['idvisit']
. " wasn't found in the DB, we fallback to a new visitor");
}
}
private function setVisitorColumn(Visitor $visitor, $key, $value)
{
$this->visitorInfo[$key] = $value;
$visitor->setVisitorColumn($key, $value);
}
private function printVisitorInformation()
{
$debugVisitInfo = $this->visitorInfo;
$debugVisitInfo['idvisitor'] = bin2hex($debugVisitInfo['idvisitor']);
$debugVisitInfo['config_id'] = bin2hex($debugVisitInfo['config_id']);
$debugVisitInfo['location_ip'] = IPUtils::binaryToStringIP($debugVisitInfo['location_ip']);
Common::printDebug($debugVisitInfo);
}
private function setNewVisitorInformation(Visitor $visitor)
{
$idVisitor = $this->getVisitorIdcookie($visitor);
$visitorIp = $this->getVisitorIp();
$configId = $this->getSettingsObject()->getConfigId();
$this->visitorInfo = array();
$visitor->clearVisitorInfo();
$this->setVisitorColumn($visitor, 'idvisitor', $idVisitor);
$this->setVisitorColumn($visitor, 'config_id', $configId);
$this->setVisitorColumn($visitor, 'location_ip', $visitorIp);
foreach ($this->visitorCustomVariables as $key => $value) {
$this->setVisitorColumn($visitor, $key, $value);
}
}
/**
* Gather fields=>values that needs to be updated for the existing visit in log_visit
*
* @param Visitor $visitor
* @param Action|null $action
* @param $visitIsConverted
* @return array
*/
private function getExistingVisitFieldsToUpdate($visitor, $action, $visitIsConverted)
{
$valuesToUpdate = array();
$valuesToUpdate = $this->setIdVisitorForExistingVisit($visitor, $valuesToUpdate);
$dimensions = $this->getAllVisitDimensions();
$valuesToUpdate = $this->triggerHookOnDimensions($dimensions, 'onExistingVisit', $visitor, $action,
$valuesToUpdate);
if ($visitIsConverted) {
$valuesToUpdate = $this->triggerHookOnDimensions($dimensions, 'onConvertedVisit', $visitor, $action,
$valuesToUpdate);
}
// Custom Variables overwrite previous values on each page view
return array_merge($valuesToUpdate, $this->visitorCustomVariables);
}
/**
* @param VisitDimension[] $dimensions
* @param string $hook
* @param Visitor $visitor
* @param Action|null $action
* @param array|null $valuesToUpdate If null, $this->visitorInfo will be updated
*
* @return array|null The updated $valuesToUpdate or null if no $valuesToUpdate given
*/
private function triggerHookOnDimensions($dimensions, $hook, $visitor, $action, $valuesToUpdate = null)
{
foreach ($dimensions as $dimension) {
$value = $dimension->$hook($this->request, $visitor, $action);
if ($value !== false) {
$fieldName = $dimension->getColumnName();
$visitor->setVisitorColumn($fieldName, $value);
if (is_float($value)) {
$value = Common::forceDotAsSeparatorForDecimalPoint($value);
}
if ($valuesToUpdate !== null) {
$valuesToUpdate[$fieldName] = $value;
} else {
$this->visitorInfo[$fieldName] = $value;
}
}
}
return $valuesToUpdate;
}
private function triggerPredicateHookOnDimensions($dimensions, $hook, Visitor $visitor, Action $action = null)
{
foreach ($dimensions as $dimension) {
if ($dimension->$hook($this->request, $visitor, $action)) {
return true;
}
}
return false;
}
protected function getAllVisitDimensions()
{
if (is_null(self::$dimensions)) {
self::$dimensions = VisitDimension::getAllDimensions();
$dimensionNames = array();
foreach (self::$dimensions as $dimension) {
$dimensionNames[] = $dimension->getColumnName();
}
Common::printDebug("Following dimensions have been collected from plugins: " . implode(", ",
$dimensionNames));
}
return self::$dimensions;
}
private function getVisitStandardLength()
{
return Config::getInstance()->Tracker['visit_standard_length'];
}
/**
* @param $visitor
* @param $valuesToUpdate
* @return mixed
*/
private function setIdVisitorForExistingVisit($visitor, $valuesToUpdate)
{
// Might update the idvisitor when it was forced or overwritten for this visit
if (strlen($this->visitorInfo['idvisitor']) == Tracker::LENGTH_BINARY_ID) {
$binIdVisitor = $this->visitorInfo['idvisitor'];
$visitor->setVisitorColumn('idvisitor', $binIdVisitor);
$valuesToUpdate['idvisitor'] = $binIdVisitor;
}
// User ID takes precedence and overwrites idvisitor value
$userId = $this->request->getForcedUserId();
if ($userId) {
$userIdHash = $this->request->getUserIdHashed($userId);
$binIdVisitor = Common::hex2bin($userIdHash);
$visitor->setVisitorColumn('idvisitor', $binIdVisitor);
$valuesToUpdate['idvisitor'] = $binIdVisitor;
}
return $valuesToUpdate;
}
protected function insertNewVisit($visit)
{
return $this->getModel()->createVisit($visit);
}
/**
* Determines if the tracker if the current action should be treated as the start of a new visit or
* an action in an existing visit.
*
* @param Visitor $visitor The current visit/visitor information.
* @param Action|null $action The current action being tracked.
* @return bool
*/
public function isVisitNew(Visitor $visitor, Action $action = null)
{
if (!$visitor->isVisitorKnown()) {
return true;
}
$isLastActionInTheSameVisit = $this->isLastActionInTheSameVisit($visitor);
if (!$isLastActionInTheSameVisit) {
Common::printDebug("Visitor detected, but last action was more than 30 minutes ago...");
return true;
}
$wasLastActionYesterday = $this->wasLastActionNotToday($visitor);
if ($wasLastActionYesterday) {
Common::printDebug("Visitor detected, but last action was yesterday...");
return true;
}
$shouldForceNewVisit = $this->triggerPredicateHookOnDimensions($this->getAllVisitDimensions(),
'shouldForceNewVisit', $visitor, $action);
if ($shouldForceNewVisit) {
return true;
}
return false;
}
private function markArchivedReportsAsInvalidIfArchiveAlreadyFinished()
{
$idSite = (int)$this->request->getIdSite();
$time = $this->request->getCurrentTimestamp();
$timezone = $this->getTimezoneForSite($idSite);
if (!isset($timezone)) {
return;
}
$date = Date::factory((int)$time, $timezone);
if (!$date->isToday()) { // we don't have to handle in case date is in future as it is not allowed by tracker
$invalidReport = new ArchiveInvalidator();
$invalidReport->rememberToInvalidateArchivedReportsLater($idSite, $date);
}
}
private function getTimezoneForSite($idSite)
{
try {
$site = Cache::getCacheWebsiteAttributes($idSite);
} catch (UnexpectedWebsiteFoundException $e) {
return;
}
if (!empty($site['timezone'])) {
return $site['timezone'];
}
}
private function isPingRequest()
{
return $this->request->getParam('ping') == 1;
}
}