AnalyticsActionPatternsTest.php 13 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267
  1. <?php
  2. /**
  3. * Goal and funnel patterns for the site actions the module records, and the examples the
  4. * goal and funnel forms show. Site actions go through the module's own alert handler with a
  5. * collector dated in September 2001, like the other analytics tests, and are deleted afterwards.
  6. */
  7. class AnalyticsActionPatternsTest extends BxDolIntegrationTestCase
  8. {
  9. // 2001-09-09 01:46:40 UTC
  10. const NOW = 1000000000;
  11. const UA_SAFARI = 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.4 Safari/605.1.15';
  12. const UA_APP = 'NEO/1 CFNetwork/1.0 Darwin/24.0.0';
  13. const ACCOUNT = 987654351;
  14. const PROFILE = 987654352;
  15. // the hints that show what a goal or a funnel step can match
  16. const HINTS = ['_bx_analytics_txt_match_hint', '_bx_analytics_txt_funnel_hint'];
  17. // the generic form the English hint explains the syntax with, not an example
  18. const PLACEHOLDER = 'name@module';
  19. protected $_oModule;
  20. protected $_oAlertModule;
  21. protected $_aOptions = [];
  22. protected $_aGoals = [];
  23. protected $_aFunnels = [];
  24. protected function setUp(): void
  25. {
  26. parent::setUp();
  27. if (!BxDolModuleQuery::getInstance()->isEnabledByName('bx_analytics'))
  28. $this->markTestSkipped('bx_analytics is not installed');
  29. $this->_oModule = BxDolModule::getInstance('bx_analytics');
  30. $this->_oModule->getCollector(); // loads the collector classes
  31. $this->_oModule->getFilter([]);
  32. // the module's alert handler, with a collector for the test's request and time
  33. $aModule = BxDolModuleQuery::getInstance()->getModuleByName('bx_analytics');
  34. $this->_oAlertModule = new class($aModule) extends BxAnalyticsModule {
  35. public $oCollector = null;
  36. public function getCollector()
  37. {
  38. return $this->oCollector ? $this->oCollector : parent::getCollector();
  39. }
  40. };
  41. $this->_cleanup();
  42. foreach (['bx_analytics_track_actions' => 'on', 'bx_analytics_tracking' => 'on', 'bx_analytics_platforms' => 'core-web,next-web,rn-ios,rn-android,api,core', 'bx_analytics_respect_dnt' => 'on', 'bx_analytics_link_members' => 'on', 'bx_analytics_identity' => 'cookieless', 'bx_analytics_exclude_ips' => '', 'bx_analytics_exclude_levels' => '', 'bx_analytics_exclude_paths' => ''] as $sName => $sValue)
  43. $this->_setOption($sName, $sValue);
  44. }
  45. protected function tearDown(): void
  46. {
  47. if ($this->_oModule) {
  48. $this->_cleanup();
  49. foreach ($this->_aOptions as $sName => $sValue)
  50. setParam($sName, $sValue);
  51. }
  52. parent::tearDown();
  53. }
  54. public function testGoalAndFunnelCountAVisitThatAddedAPost()
  55. {
  56. $oDb = $this->_oModule->_oDb;
  57. $aGoal = ['type' => 'event', 'pattern' => 'content_add@bx_posts'];
  58. $iGoal = $oDb->saveGoal(0, ['name' => 'Test 2001 new post'] + $aGoal);
  59. $iFunnel = $oDb->saveFunnel(0, 'Test 2001 posting', [
  60. ['type' => 'page', 'pattern' => '/page/create-post'],
  61. $aGoal,
  62. ]);
  63. $this->_aGoals = [$iGoal];
  64. $this->_aFunnels = [$iFunnel];
  65. // A adds a post, B adds a photo, C only looks at the form
  66. $aVisits = [['93.184.216.61', 'bx_posts'], ['93.184.216.62', 'bx_photos'], ['93.184.216.63', '']];
  67. foreach ($aVisits as $iVisit => list($sIp, $sUnit)) {
  68. $aServer = $this->_server($sIp);
  69. $iNow = self::NOW + $iVisit * 100;
  70. $this->_collect($aServer, ['platform' => 'core-web', 'hits' => [['type' => 'pageview', 'url' => 'https://una.test/page/create-post']]], $iNow);
  71. if ($sUnit !== '')
  72. $this->_alert($aServer, $iNow + 30, $sUnit, 'added', 70 + $iVisit, self::PROFILE + $iVisit);
  73. }
  74. $aSessions = $this->_sessions();
  75. $this->assertCount(3, $aSessions);
  76. $aEvents = BxDolDb::getInstance()->getAll("SELECT `session_id`, `name`, `module`, `object_id` FROM `bx_analytics_events` WHERE `type` = 'una' AND `ts` < :to ORDER BY `id`", ['to' => self::NOW + 86400]);
  77. $this->assertEquals([
  78. ['session_id' => $aSessions[0]['id'], 'name' => 'content_add', 'module' => 'bx_posts', 'object_id' => 70],
  79. ['session_id' => $aSessions[1]['id'], 'name' => 'content_add', 'module' => 'bx_photos', 'object_id' => 71],
  80. ], $aEvents, 'a new post is the site action content_add of the module, in the visit');
  81. $oReports = $this->_oModule->getReports();
  82. $oFilter = $this->_filter();
  83. $aGoals = array_column($oReports->getGoals($oFilter)['goals'], null, 'id');
  84. $this->assertSame(1, $aGoals[$iGoal]['conversions']);
  85. $this->assertEquals(33.33, $aGoals[$iGoal]['rate']);
  86. $aFilter = $oFilter->get();
  87. $this->assertSame(2, (int)$oDb->getGoalTotals($aFilter, ['type' => 'event', 'pattern' => 'content_add'])['sessions'], 'without a module, new content of any module');
  88. $this->assertSame(0, (int)$oDb->getGoalTotals($aFilter, ['type' => 'event', 'pattern' => 'added@bx_posts'])['sessions'], 'the alert\'s name is not the action\'s');
  89. $aFunnel = $oReports->getFunnels($oFilter, ['funnel' => $iFunnel]);
  90. $this->assertSame([3, 1], array_column($aFunnel['steps'], 'sessions'));
  91. }
  92. public function testHintExamplesAreRecordedActions()
  93. {
  94. // one visit that does every site action the alert handler records
  95. $aServer = $this->_server('93.184.216.64');
  96. $this->_collect($aServer, ['platform' => 'core-web', 'hits' => [['type' => 'pageview', 'url' => 'https://una.test/page/view-post?id=70']]], self::NOW);
  97. $aAlerts = [
  98. // the test account has no profile, so it signs in as the guest the visit started as
  99. ['account', 'add', self::ACCOUNT, 0, []],
  100. ['account', 'login', self::ACCOUNT, 0, []],
  101. ['profile', 'add', self::PROFILE, self::PROFILE, ['module' => 'bx_persons']],
  102. ['bx_posts', 'added', 70, self::PROFILE, []],
  103. ['comment', 'added', 1, self::PROFILE, ['object_system' => 'bx_posts', 'object_id' => 70]],
  104. ['vote', 'do', 1, self::PROFILE, ['object_system' => 'bx_posts_reactions', 'object_id' => 70, 'reaction' => 'like']],
  105. ['system', 'connection_added', 5, self::PROFILE, ['object_name' => 'sys_profiles_friends', 'content' => 6]],
  106. ];
  107. foreach ($aAlerts as $i => list($sUnit, $sAction, $iObject, $iSender, $aExtras))
  108. $this->_alert($aServer, self::NOW + 10 + $i, $sUnit, $sAction, $iObject, $iSender, $aExtras);
  109. // the same post page as the other clients record it: the classic site without
  110. // permalinks, NEO on the web and the apps (the UNA page path, see NEO's toPagePath)
  111. $aPostPages = [
  112. ['93.184.216.65', self::UA_SAFARI, ['platform' => 'core-web', 'url' => 'https://una.test/page.php?i=view-post&id=70']],
  113. ['93.184.216.66', self::UA_SAFARI, ['platform' => 'next-web', 'url' => 'https://neo.test/view-post?id=70']],
  114. ['93.184.216.67', self::UA_APP, ['platform' => 'rn-ios', 'app' => 'com.neo.so', 'url' => '/view-post?id=70']],
  115. ['93.184.216.68', self::UA_APP, ['platform' => 'rn-android', 'app' => 'com.neo.so', 'url' => '/view-post?id=70']],
  116. ];
  117. foreach ($aPostPages as $i => list($sIp, $sUserAgent, $aHit)) {
  118. $sUrl = $aHit['url'];
  119. unset($aHit['url']);
  120. $this->_collect($this->_server($sIp, $sUserAgent), $aHit + ['hits' => [['type' => 'pageview', 'url' => $sUrl]]], self::NOW + 100 + $i);
  121. }
  122. $aSessions = $this->_sessions();
  123. $this->assertCount(1 + count($aPostPages), $aSessions);
  124. $this->assertEquals(['core-web', 'core-web', 'next-web', 'rn-ios', 'rn-android'], array_column($aSessions, 'platform'));
  125. $aRecorded = BxDolDb::getInstance()->getColumn("SELECT DISTINCT `name` FROM `bx_analytics_events` WHERE `type` = 'una' AND `session_id` = :id", ['id' => $aSessions[0]['id']]);
  126. $this->assertCount(count($aAlerts), $aRecorded, 'every alert is a site action of the visit');
  127. // every action has a title of its own in the reports
  128. $aEn = $this->_strings(BX_DIRECTORY_PATH_MODULES . 'boonex/analytics/install/langs/en.xml');
  129. $aRu = $this->_strings(BX_DIRECTORY_PATH_MODULES . 'boonex/russian/data/langs/bx_analytics/ru.xml');
  130. foreach ($aRecorded as $sName) {
  131. $this->assertArrayHasKey('_bx_analytics_action_' . $sName, $aEn, $sName);
  132. $this->assertArrayHasKey('_bx_analytics_action_' . $sName, $aRu, $sName);
  133. }
  134. $oDb = $this->_oModule->_oDb;
  135. $aFilter = $this->_filter()->get();
  136. foreach (self::HINTS as $sKey) {
  137. $this->assertArrayHasKey($sKey, $aEn);
  138. $this->assertArrayHasKey($sKey, $aRu);
  139. // Russian prose is Cyrillic, so its Latin words are the examples
  140. $aExamples = $this->_examples($aRu[$sKey], '/(?<![\p{L}\d_@])[a-z][a-z0-9_]*(?:@[a-z0-9_]+)?/u');
  141. $this->assertNotEmpty($aExamples['page'], $sKey . ': a page example');
  142. $this->assertNotEmpty(array_filter($aExamples['event'], function ($s) { return strpos($s, '@') !== false; }), $sKey . ': an action of one module');
  143. // the English hint shows the same examples
  144. $aEnExamples = $this->_examples($aEn[$sKey], '/(?<![\p{L}\d_@])[a-z][a-z0-9_]*@[a-z0-9_]+/u');
  145. $this->assertSame($aExamples['page'], $aEnExamples['page'], $sKey);
  146. $this->assertSame(array_values(array_filter($aExamples['event'], function ($s) { return strpos($s, '@') !== false; })), array_values(array_diff($aEnExamples['event'], [self::PLACEHOLDER])), $sKey);
  147. foreach ($aExamples['event'] as $sExample)
  148. $this->assertMatchesRegularExpression('/(?<![\p{L}\d_@])' . preg_quote($sExample, '/') . '(?![\p{L}\d_@])/u', $aEn[$sKey], $sKey . ': ' . $sExample);
  149. foreach ($aExamples['event'] as $sPattern)
  150. $this->assertContains(explode('@', $sPattern)[0], $aRecorded, $sKey . ': ' . $sPattern . ' names an action the module records');
  151. // and each of them, as a goal, counts the visit that did it; a page example
  152. // counts the post page on every client
  153. foreach ($aExamples as $sType => $aPatterns)
  154. foreach ($aPatterns as $sPattern)
  155. $this->assertSame($sType == 'page' ? count($aSessions) : 1, (int)$oDb->getGoalTotals($aFilter, ['type' => $sType, 'pattern' => $sPattern])['sessions'], $sKey . ': ' . $sPattern . ' matches');
  156. }
  157. }
  158. /**
  159. * Page paths and action names a hint gives as examples.
  160. */
  161. protected function _examples($sText, $sActionRegExp)
  162. {
  163. $aPages = [];
  164. if (preg_match_all('#(?<![\p{L}\d*/])[/*][\p{L}\d_\-/*?=.]*[\p{L}\d_\-/*?=]#u', $sText, $aMatches))
  165. $aPages = array_values(array_unique($aMatches[0]));
  166. $sProse = str_replace($aPages, ' ', $sText);
  167. $aEvents = preg_match_all($sActionRegExp, $sProse, $aMatches) ? array_values(array_unique($aMatches[0])) : [];
  168. return ['page' => $aPages, 'event' => $aEvents];
  169. }
  170. protected function _strings($sFile)
  171. {
  172. $this->assertFileExists($sFile);
  173. $aResult = [];
  174. foreach (simplexml_load_file($sFile)->xpath('//string') as $oString)
  175. $aResult[(string)$oString['name']] = (string)$oString;
  176. return $aResult;
  177. }
  178. protected function _alert($aServer, $iNow, $sUnit, $sAction, $iObject, $iSender, $aExtras = [])
  179. {
  180. $this->_oAlertModule->oCollector = new BxAnalyticsCollector($this->_oModule, new BxAnalyticsRequest($aServer), $iNow);
  181. $this->_oAlertModule->onAlert(new BxDolAlerts($sUnit, $sAction, $iObject, $iSender, $aExtras));
  182. $this->_oAlertModule->oCollector = null;
  183. }
  184. protected function _collect($aServer, $aPayload, $iNow)
  185. {
  186. $oCollector = new BxAnalyticsCollector($this->_oModule, new BxAnalyticsRequest($aServer), $iNow);
  187. return $oCollector->collect($aPayload, ['platform' => 'api']);
  188. }
  189. protected function _filter()
  190. {
  191. return new BxAnalyticsFilter($this->_oModule, ['range' => 'custom', 'from' => '2001-09-09', 'to' => '2001-09-09'], self::NOW + 86400);
  192. }
  193. protected function _server($sIp, $sUserAgent = self::UA_SAFARI)
  194. {
  195. return ['REMOTE_ADDR' => $sIp, 'HTTP_USER_AGENT' => $sUserAgent, 'REQUEST_METHOD' => 'POST'];
  196. }
  197. protected function _sessions()
  198. {
  199. return BxDolDb::getInstance()->getAll("SELECT * FROM `bx_analytics_sessions` WHERE `started` < :to ORDER BY `id`", ['to' => self::NOW + 30 * 86400]);
  200. }
  201. protected function _setOption($sName, $sValue)
  202. {
  203. if (!array_key_exists($sName, $this->_aOptions))
  204. $this->_aOptions[$sName] = getParam($sName);
  205. setParam($sName, $sValue);
  206. }
  207. protected function _cleanup()
  208. {
  209. $oDb = BxDolDb::getInstance();
  210. $iTo = self::NOW + 30 * 86400;
  211. $oDb->query("DELETE FROM `bx_analytics_events` WHERE `ts` < :to", ['to' => $iTo]);
  212. $oDb->query("DELETE FROM `bx_analytics_sessions` WHERE `started` < :to", ['to' => $iTo]);
  213. $oDb->query("DELETE FROM `bx_analytics_salts` WHERE `day` < '2002-01-01'");
  214. $oDb->query("DELETE FROM `bx_analytics_limits` WHERE `bucket` < 1009843200");
  215. foreach ($this->_aGoals as $iId)
  216. $this->_oModule->_oDb->deleteGoal($iId);
  217. foreach ($this->_aFunnels as $iId)
  218. $this->_oModule->_oDb->deleteFunnel($iId);
  219. $this->_aGoals = $this->_aFunnels = [];
  220. $this->_oModule->cleanReportCache(); // reports of the test data cached by the run before
  221. }
  222. }