1
0

AnalyticsScaleTest.php 17 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295
  1. <?php
  2. /**
  3. * What keeps reports fast on large data: the report cache, retention cohorts counted in the
  4. * database, and filters across visits and pageviews. Test visits are dated in August and
  5. * September 2001, far from real traffic, and deleted afterwards.
  6. */
  7. class AnalyticsScaleTest extends BxDolIntegrationTestCase
  8. {
  9. // 2001-09-09 01:46:40 UTC
  10. const NOW = 1000000000;
  11. protected $_oModule;
  12. protected $_aOptions = [];
  13. protected $_aDay = ['range' => 'custom', 'from' => '2001-09-09', 'to' => '2001-09-09', 'tz' => 'UTC'];
  14. protected function setUp(): void
  15. {
  16. parent::setUp();
  17. if (!BxDolModuleQuery::getInstance()->isEnabledByName('bx_analytics'))
  18. $this->markTestSkipped('bx_analytics is not installed');
  19. $this->_oModule = BxDolModule::getInstance('bx_analytics');
  20. $this->_oModule->getFilter([]);
  21. $this->_oModule->getReports();
  22. $this->_cleanup();
  23. }
  24. protected function tearDown(): void
  25. {
  26. if ($this->_oModule) {
  27. $this->_cleanup();
  28. foreach ($this->_aOptions as $sName => $sValue)
  29. setParam($sName, $sValue);
  30. $this->_oModule->cleanReportCache();
  31. }
  32. parent::tearDown();
  33. }
  34. public function testRetentionCohorts()
  35. {
  36. $iFrom = $this->_monday() - 4 * 604800;
  37. $iDay = 86400;
  38. // visitor => [platform, profile, [visit times]]
  39. $aVisitors = [
  40. 'a' => ['core-web', 0, [$iFrom + $iDay, $iFrom + 2 * $iDay, $iFrom + 2 * 604800 + $iDay]],
  41. 'b' => ['core-web', 0, [$iFrom + 3 * $iDay]],
  42. 'c' => ['core-web', 0, [$iFrom + 604800 + $iDay, $iFrom + 3 * 604800 + $iDay]],
  43. // first came before the period: no cohort
  44. 'd' => ['core-web', 0, [$iFrom - $iDay, $iFrom + 604800 + 2 * $iDay]],
  45. 'e' => ['core-web', 7, [$iFrom + 2 * 604800 + 2 * $iDay]],
  46. 'f' => ['rn-ios', 0, [$iFrom + 4 * $iDay]],
  47. ];
  48. foreach ($aVisitors as $sVisitor => list($sPlatform, $iProfile, $aTimes))
  49. foreach ($aTimes as $iTime)
  50. $this->_session($sVisitor, $iTime, ['platform' => $sPlatform, 'profile_id' => $iProfile]);
  51. $oDb = $this->_oModule->_oDb;
  52. $fRows = function ($aRows) {
  53. $aResult = [];
  54. foreach ($aRows as $aRow)
  55. $aResult[] = [(int)$aRow['cohort'], (int)$aRow['weeks'], (int)$aRow['visitors']];
  56. sort($aResult);
  57. return $aResult;
  58. };
  59. // cohort (week of the first visit), the weeks visited as bits, visitors
  60. $iTo = $iFrom + 4 * 604800;
  61. $this->assertSame([[0, 1, 2], [0, 5, 1], [1, 10, 1], [2, 4, 1]], $fRows($oDb->getRetentionCohorts($iFrom, $iTo, '', false)));
  62. $this->assertSame([[0, 1, 1]], $fRows($oDb->getRetentionCohorts($iFrom, $iTo, 'rn-ios', false)));
  63. $this->assertSame([[2, 4, 1]], $fRows($oDb->getRetentionCohorts($iFrom, $iTo, '', true)), 'members only');
  64. $this->assertSame([[0, 1, 3]], $fRows($oDb->getRetentionCohorts($iFrom, $iFrom + 604800, '', false)), 'visits after the end are left out');
  65. // the report: 12 weekly cohorts up to the current week, the last 4 of them seeded
  66. $oFilter = new BxAnalyticsFilter($this->_oModule, ['range' => '7d', 'tz' => 'UTC'], $iFrom + 3 * 604800 + 2 * $iDay);
  67. $a = $this->_oModule->getReports()->getRetention($oFilter, ['tab' => 'visitors']);
  68. $this->assertSame(12, $a['weeks']);
  69. $this->assertCount(12, $a['rows']);
  70. $aRows = array_slice($a['rows'], 8);
  71. $this->assertSame($iFrom, $aRows[0]['start']);
  72. $this->assertSame([3, 1, 1, 0], array_column($aRows, 'size'));
  73. $this->assertEquals([100, 0, 33.3, 0], $aRows[0]['weeks'], 'a of a, b and f came back in week 2');
  74. $this->assertEquals([100, 0, 100], $aRows[1]['weeks']);
  75. $this->assertEquals([100, 0], $aRows[2]['weeks']);
  76. $this->assertSame([null], $aRows[3]['weeks']);
  77. $a = $this->_oModule->getReports()->getRetention($oFilter, ['tab' => 'members']);
  78. $this->assertSame([0, 0, 1, 0], array_column(array_slice($a['rows'], 8), 'size'));
  79. }
  80. public function testReportCache()
  81. {
  82. $this->_setOption('sys_db_cache_enable', 'on');
  83. $this->_oModule->cleanReportCache();
  84. $iDay = 999993600; // 2001-09-09 00:00 UTC
  85. $this->_session('a', $iDay + 3600);
  86. $this->_session('b', $iDay + 7200);
  87. $a = $this->_oModule->getReportData('overview', $this->_aDay);
  88. $this->assertSame(2, $a['kpis']['sessions']['value']);
  89. $this->assertSame(['range' => 'custom', 'tz' => 'UTC', 'from' => '2001-09-09', 'to' => '2001-09-09'], $a['filter']);
  90. // a new visit shows after the cached copy expires or is dropped
  91. $this->_session('c', $iDay + 10800);
  92. $this->assertSame(2, $this->_oModule->getReportData('overview', $this->_aDay)['kpis']['sessions']['value'], 'from the cache');
  93. $this->assertSame(3, $this->_oModule->getReportData('overview', $this->_aDay + ['platform' => 'core-web'])['kpis']['sessions']['value'], 'other filters, another copy');
  94. $this->_oModule->cleanReportCache();
  95. $this->assertSame(3, $this->_oModule->getReportData('overview', $this->_aDay)['kpis']['sessions']['value'], 'dropped');
  96. // in files, reports are plain text: as PHP scripts read with include(), opcache would
  97. // keep every one of them
  98. if ($this->_oModule->_oDb->getDbCacheObject() instanceof BxDolCacheFile) {
  99. $sPattern = BX_DIRECTORY_PATH_CACHE . 'db_' . $this->_oModule->_oConfig->CNF['REPORT_CACHE_PREFIX'] . '*';
  100. $aFiles = glob($sPattern);
  101. $this->assertNotEmpty($aFiles);
  102. foreach ($aFiles as $sFile) {
  103. $sData = file_get_contents($sFile);
  104. $this->assertStringStartsWith(BxAnalyticsModule::REPORT_CACHE_GUARD, $sData);
  105. $this->assertStringNotContainsString('$mixedData', $sData);
  106. }
  107. // past their time to live they are pruned, whether read again or not
  108. $this->assertSame(0, $this->_oModule->pruneReportCache(time()));
  109. foreach ($aFiles as $sFile)
  110. touch($sFile, time() - 3600);
  111. $this->assertSame(count($aFiles), $this->_oModule->pruneReportCache(time()));
  112. $this->assertSame([], array_values(array_intersect($aFiles, glob($sPattern))));
  113. }
  114. // the cache name holds what the report reads: blocks send the whole page URL, with
  115. // other blocks' tabs and pages, the chart's metric and opened rows
  116. $oName = new ReflectionMethod($this->_oModule, '_getReportCacheName');
  117. $fName = function ($sReport, $aParams) use ($oName) {
  118. return $oName->invoke($this->_oModule, $sReport, $this->_oModule->getFilter($this->_aDay + $aParams), $this->_aDay + $aParams);
  119. };
  120. $aNoise = ['metric' => 'pageviews', 'tab_pages' => 'entry', 'page_sessions' => '2', 'session' => '7', 'i' => 'analytics', 'r' => 'analytics/report'];
  121. $this->assertSame($fName('overview', []), $fName('overview', $aNoise));
  122. $this->assertSame($fName('top', ['group' => 'sources']), $fName('top', ['group' => 'sources', 'page_sources' => '2'] + $aNoise));
  123. $this->assertNotSame($fName('top', ['group' => 'sources']), $fName('top', ['group' => 'sources', 'tab_sources' => 'channels']));
  124. $this->assertNotSame($fName('table', ['table' => 'sources']), $fName('table', ['table' => 'sources', 'page_sources' => '2']));
  125. $this->assertNotSame($fName('sessions', []), $fName('sessions', ['session' => '7']));
  126. $this->assertNotSame($fName('overview', []), $fName('overview', ['platform' => 'core-web']), 'filters count');
  127. // dropping the cache starts a new generation instead of deleting by prefix, which
  128. // empties a whole Memcache(d) server or scans every Redis key
  129. $sBefore = $fName('overview', []);
  130. $this->_oModule->cleanReportCache();
  131. $this->assertNotSame($sBefore, $fName('overview', []));
  132. $aModule = $this->_oModule->_aModule;
  133. $oShared = new class extends BxDolCache {
  134. public $aData = [];
  135. public $iFlushes = 0;
  136. public function getData($sKey, $iTTL = false) { return $this->aData[$sKey] ?? null; }
  137. public function setData($sKey, $mixedData, $iTTL = false) { $this->aData[$sKey] = $mixedData; return true; }
  138. public function delData($sKey) { unset($this->aData[$sKey]); return true; }
  139. public function removeAllByPrefix($s) { $this->iFlushes++; $this->aData = []; return true; }
  140. };
  141. $oModule = new class($aModule, $oShared) extends BxAnalyticsModule {
  142. protected $_oShared;
  143. public function __construct(&$aModule, $oShared) { parent::__construct($aModule); $this->_oShared = $oShared; }
  144. protected function _getReportCacheEngine() { return $this->_oShared; }
  145. };
  146. $oShared->aData['db_other_module_key'] = 'kept';
  147. $this->assertSame(3, $oModule->getReportData('overview', $this->_aDay)['kpis']['sessions']['value']);
  148. $this->_session('e', $iDay + 12600);
  149. $this->assertSame(3, $oModule->getReportData('overview', $this->_aDay)['kpis']['sessions']['value'], 'from the shared cache');
  150. $oModule->cleanReportCache();
  151. $this->assertSame(4, $oModule->getReportData('overview', $this->_aDay)['kpis']['sessions']['value'], 'a new generation');
  152. $this->assertSame(0, $oShared->iFlushes);
  153. $this->assertSame('kept', $oShared->aData['db_other_module_key']);
  154. // live figures are never cached
  155. $oReports = $this->_oModule->getReports();
  156. foreach (['realtime' => [], 'filters' => [], 'map' => ['tab' => 'live']] as $sReport => $aParams)
  157. $this->assertTrue($oReports->isLive($sReport, $aParams), $sReport);
  158. foreach (['overview' => [], 'top' => [], 'map' => []] as $sReport => $aParams)
  159. $this->assertFalse($oReports->isLive($sReport, $aParams), $sReport);
  160. // nothing is cached with the database cache off
  161. $this->_setOption('sys_db_cache_enable', '');
  162. $this->_session('d', $iDay + 14400);
  163. $this->assertSame(5, $this->_oModule->getReportData('overview', $this->_aDay)['kpis']['sessions']['value']);
  164. }
  165. public function testFiltersAcrossVisitsAndPageviews()
  166. {
  167. $iDay = 999993600;
  168. $oDb = BxDolDb::getInstance();
  169. // a visit that began the day before still counts its pageviews of the day
  170. $aVisits = [
  171. ['a', $iDay - 600, 'DE', ['/x' => $iDay - 300, '/y' => $iDay + 60]],
  172. ['b', $iDay + 3600, 'DE', ['/x' => $iDay + 3600, '/z' => $iDay + 3700]],
  173. ['c', $iDay + 7200, 'FR', ['/x' => $iDay + 7200]],
  174. ];
  175. foreach ($aVisits as list($sVisitor, $iStarted, $sCountry, $aViews)) {
  176. $iSession = $this->_session($sVisitor, $iStarted, ['country' => $sCountry, 'last_seen' => max($aViews), 'pageviews' => count($aViews)]);
  177. foreach ($aViews as $sPath => $iTime)
  178. $oDb->query("INSERT INTO `bx_analytics_events` SET `session_id` = :s, `visitor` = :v, `platform` = 'core-web', `ts` = :ts, `type` = 'pageview', `path` = :path, `title` = :title, `props` = ''", ['s' => $iSession, 'v' => $this->_visitor($sVisitor), 'ts' => $iTime, 'path' => $sPath, 'title' => 'Page ' . $sPath]);
  179. }
  180. $oData = $this->_oModule->_oDb;
  181. $fFilter = function ($aDims) { return (new BxAnalyticsFilter($this->_oModule, $this->_aDay + ['f' => $aDims], self::NOW + 86400))->get(); };
  182. $fPages = function ($aRows) {
  183. $aResult = array_column($aRows, 'pageviews', 'value');
  184. ksort($aResult);
  185. return array_map('intval', $aResult);
  186. };
  187. // pageviews of the day in visits from Germany, the one that began the day before too
  188. $this->assertSame(['/x' => 1, '/y' => 1, '/z' => 1], $fPages($oData->getTop($fFilter(['country' => 'DE']), 'path', 10)));
  189. $this->assertSame(['/x' => 1], $fPages($oData->getTop($fFilter(['country' => 'FR']), 'path', 10)));
  190. $this->assertSame(2, (int)$oData->getTotals($fFilter(['country' => 'DE']))['pageviews'], 'visits of the day: b with its 2 views; a began the day before');
  191. // goals and events through the visit: a short period looks each visit up, a longer one
  192. // finds the visits first; both count the same
  193. $oDb->query("INSERT INTO `bx_analytics_events` SET `session_id` = :s, `visitor` = :v, `platform` = 'core-web', `ts` = :ts, `type` = 'event', `name` = 'phpunit_scale', `props` = ''", ['s' => $oDb->getOne("SELECT `id` FROM `bx_analytics_sessions` WHERE `visitor` = :v", ['v' => $this->_visitor('b')]), 'v' => $this->_visitor('b'), 'ts' => $iDay + 3800]);
  194. $aGoal = ['type' => 'page', 'pattern' => '/x'];
  195. $fLong = function ($aDims) { return (new BxAnalyticsFilter($this->_oModule, ['range' => 'custom', 'from' => '2001-08-30', 'to' => '2001-09-09', 'tz' => 'UTC', 'f' => $aDims], self::NOW + 86400))->get(); };
  196. $this->assertSame(['sessions', 'visitors'], array_keys($oData->getGoalTotals($fFilter(['country' => 'DE']), $aGoal)), 'columns of the covering key only');
  197. $this->assertSame(1, (int)$oData->getGoalTotals($fFilter(['country' => 'DE']), $aGoal)['sessions'], 'b viewed /x in the day');
  198. $this->assertSame(2, (int)$oData->getGoalTotals($fLong(['country' => 'DE']), $aGoal)['sessions'], 'a too over 11 days');
  199. $this->assertSame(1, (int)$oData->getGoalTotals($fLong(['country' => 'FR']), $aGoal)['sessions']);
  200. foreach ([$fFilter(['country' => 'DE']), $fLong(['country' => 'DE'])] as $aFilter)
  201. $this->assertSame(['phpunit_scale' => 1], array_map('intval', array_column($oData->getEventList($aFilter, ['event'], 10), 'count', 'name')));
  202. $this->assertSame([], $oData->getEventList($fLong(['country' => 'FR']), ['event'], 10));
  203. // visits of the day that viewed a page, that page's views of the day
  204. $aTotals = $oData->getTotals($fFilter(['path' => '/x']));
  205. $this->assertSame([2, 2], [(int)$aTotals['sessions'], (int)$aTotals['pageviews']]);
  206. $this->assertSame(1, (int)$oData->getTotals($fFilter(['path' => '/z']))['sessions']);
  207. $this->assertSame(0, (int)$oData->getTotals($fFilter(['path' => '/y']))['sessions'], 'only in the visit that began the day before');
  208. // the chart of a page counts that page's views, as the key figure does, in the hour
  209. // they happened: b views /x again at 05:00, when no visit started
  210. $oDb->query("INSERT INTO `bx_analytics_events` SET `session_id` = :s, `visitor` = :v, `platform` = 'core-web', `ts` = :ts, `type` = 'pageview', `path` = '/x', `title` = 'Page /x', `props` = ''", ['s' => $oDb->getOne("SELECT `id` FROM `bx_analytics_sessions` WHERE `visitor` = :v", ['v' => $this->_visitor('b')]), 'v' => $this->_visitor('b'), 'ts' => $iDay + 5 * 3600]);
  211. foreach ([['path' => '/x'], ['title' => 'Page /x']] as $aDims) {
  212. $aFilter = $fFilter($aDims);
  213. $this->assertSame('hour', $aFilter['bucket']);
  214. $aSeries = $oData->getSeries($aFilter, $aFilter['bucket'], $aFilter['offset'], $aFilter['starts']);
  215. $this->assertSame(3, (int)$oData->getTotals($aFilter)['pageviews']);
  216. $this->assertSame([$iDay + 3600, $iDay + 7200, $iDay + 5 * 3600], array_map('intval', array_keys($aSeries)), 'in time order');
  217. $this->assertSame([1, 1, 1], array_map('intval', array_column($aSeries, 'pageviews')));
  218. $this->assertSame([1, 1, 0], array_map('intval', array_column($aSeries, 'sessions')));
  219. $aOverview = $this->_oModule->getReportData('overview', $this->_aDay + ['f' => $aDims]);
  220. $this->assertSame(3, $aOverview['kpis']['pageviews']['value']);
  221. $this->assertSame(3, (int)array_sum(array_column($aOverview['series'], 'pageviews')), 'the chart adds up to the key figure');
  222. }
  223. }
  224. protected function _monday()
  225. {
  226. return (int)(floor((self::NOW - 345600) / 604800) * 604800 + 345600);
  227. }
  228. protected function _visitor($sName)
  229. {
  230. return str_pad('phpunit-scale-' . $sName, 16, "\0");
  231. }
  232. protected function _session($sVisitor, $iStarted, $aSet = [])
  233. {
  234. return $this->_oModule->_oDb->insertSession(array_merge([
  235. 'visitor' => $this->_visitor($sVisitor),
  236. 'platform' => 'core-web',
  237. 'started' => $iStarted,
  238. 'last_seen' => $iStarted,
  239. 'pageviews' => 1,
  240. ], $aSet));
  241. }
  242. protected function _setOption($sName, $sValue)
  243. {
  244. if (!array_key_exists($sName, $this->_aOptions))
  245. $this->_aOptions[$sName] = getParam($sName);
  246. setParam($sName, $sValue);
  247. }
  248. protected function _cleanup()
  249. {
  250. $oDb = BxDolDb::getInstance();
  251. $iTo = self::NOW + 30 * 86400;
  252. $oDb->query("DELETE FROM `bx_analytics_events` WHERE `ts` < :to", ['to' => $iTo]);
  253. $oDb->query("DELETE FROM `bx_analytics_sessions` WHERE `started` < :to", ['to' => $iTo]);
  254. }
  255. }