AnalyticsHealthTest.php 33 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526
  1. <?php
  2. /**
  3. * Errors and web vitals through the collector, into their tables and back out through the
  4. * Errors and Performance reports. Test hits are dated in September 2001, far from real
  5. * traffic, and deleted afterwards; error messages start with "Test 2001" so no real group
  6. * is touched.
  7. */
  8. class AnalyticsHealthTest extends BxDolIntegrationTestCase
  9. {
  10. // 2001-09-09 01:46:40 UTC
  11. const NOW = 1000000000;
  12. 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';
  13. // what a real catch-all group's fingerprint is while a test makes its own
  14. const OVERFLOW_ASIDE = 'test-2001-aside!';
  15. protected $_oModule;
  16. protected $_aOptions = [];
  17. protected $_aCnf = [];
  18. protected $_sOverflow = '';
  19. protected function setUp(): void
  20. {
  21. parent::setUp();
  22. if (!BxDolModuleQuery::getInstance()->isEnabledByName('bx_analytics'))
  23. $this->markTestSkipped('bx_analytics is not installed');
  24. $this->_oModule = BxDolModule::getInstance('bx_analytics');
  25. $this->_oModule->getCollector(); // loads the collector classes
  26. $this->_oModule->getReports();
  27. $this->_oModule->getFilter([]);
  28. $this->_cleanup();
  29. foreach (['bx_analytics_tracking' => 'on', 'bx_analytics_platforms' => 'core-web,next-web,rn-ios,rn-android,api', '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' => '', 'bx_analytics_geo_source' => 'headers', 'bx_analytics_track_errors' => 'on', 'bx_analytics_track_vitals' => 'on', 'bx_analytics_vitals_sample' => '100', 'bx_analytics_errors_ignore' => "ResizeObserver loop*\nScript error.*\n*Non-Error promise rejection*"] as $sName => $sValue)
  30. $this->_setOption($sName, $sValue);
  31. }
  32. protected function tearDown(): void
  33. {
  34. if ($this->_oModule) {
  35. $this->_cleanup();
  36. if ($this->_sOverflow !== '')
  37. BxDolDb::getInstance()->query("UPDATE `bx_analytics_errors` SET `fingerprint` = :fingerprint WHERE `fingerprint` = :aside", ['fingerprint' => $this->_sOverflow, 'aside' => self::OVERFLOW_ASIDE]);
  38. foreach ($this->_aOptions as $sName => $sValue)
  39. setParam($sName, $sValue);
  40. foreach ($this->_aCnf as $sName => $mixedValue)
  41. $this->_oModule->_oConfig->CNF[$sName] = $mixedValue;
  42. }
  43. parent::tearDown();
  44. }
  45. public function testErrorJoinsTheVisit()
  46. {
  47. $aServer = $this->_server('81.2.69.142', ['HTTP_CF_IPCOUNTRY' => 'GB']);
  48. $this->_collect($aServer, ['platform' => 'core-web', 'hits' => [['type' => 'pageview', 'url' => 'http://una.test/page/view-post?id=5']]], self::NOW);
  49. $this->assertSame(1, $this->_collect($aServer, ['platform' => 'core-web', 'hits' => [$this->_error('Test 2001 TypeError: a is undefined', [
  50. 'url' => 'http://una.test/page/view-post?id=5',
  51. 'source' => 'https://una.example.com/js/app.js?v=3',
  52. 'line' => 10,
  53. 'col' => 5,
  54. 'stack' => "TypeError: a is undefined\n at render (https://cdn.example.net/app.js?token=abc:10:5)",
  55. 'repeats' => 3,
  56. ])]], self::NOW + 5));
  57. $aSession = $this->_sessions()[0];
  58. $this->assertSame(1, (int)$aSession['errors']);
  59. $this->assertSame(1, (int)$aSession['pageviews'], 'an error is no pageview');
  60. $this->assertSame(1, (int)$aSession['bounce'], 'nor an interaction');
  61. $aHits = $this->_errorHits();
  62. $this->assertCount(1, $aHits);
  63. $this->assertSame((int)$aSession['id'], (int)$aHits[0]['session_id']);
  64. $this->assertSame($aSession['visitor'], $aHits[0]['visitor']);
  65. $this->assertSame(['core-web', 'Safari', 'macOS', 'desktop', 'GB', '/page/view-post', '/page/view-post', 3], [$aHits[0]['platform'], $aHits[0]['browser'], $aHits[0]['os'], $aHits[0]['device'], $aHits[0]['country'], $aHits[0]['path'], $aHits[0]['route'], (int)$aHits[0]['repeats']]);
  66. $aGroup = $this->_oModule->_oDb->getErrorGroup($aHits[0]['error_id']);
  67. $this->assertSame('js', $aGroup['kind']);
  68. $this->assertSame(3, (int)$aGroup['occurrences']);
  69. $this->assertSame("TypeError: a is undefined\n at render (https://cdn.example.net/app.js:10:5)", $aGroup['stack'], 'no query string or token');
  70. $this->assertSame(self::NOW + 5, (int)$aGroup['first_seen']);
  71. }
  72. public function testErrorsPerVisitAreCapped()
  73. {
  74. $this->_setCnf('MAX_ERRORS_PER_SESSION', 4);
  75. $aServer = $this->_server('81.2.69.142');
  76. $this->_collect($aServer, ['platform' => 'core-web', 'hits' => [['type' => 'pageview', 'url' => 'http://una.test/']]], self::NOW);
  77. // 12 hits in one request: 10 are read, 4 fit the visit
  78. $aHits = [];
  79. for ($i = 0; $i < 12; $i++)
  80. $aHits[] = $this->_error('Test 2001 loop ' . $i);
  81. $this->assertSame(4, $this->_collect($aServer, ['platform' => 'core-web', 'hits' => $aHits], self::NOW + 5));
  82. $this->assertSame(0, $this->_collect($aServer, ['platform' => 'core-web', 'hits' => [$this->_error('Test 2001 one more')]], self::NOW + 6));
  83. $this->assertSame(4, (int)$this->_sessions()[0]['errors']);
  84. $this->assertCount(4, $this->_errorHits());
  85. }
  86. public function testErrorWithoutVisit()
  87. {
  88. // an app that crashed before its first page, and a deferred error from its last run
  89. $aServer = $this->_server('93.184.216.34', ['HTTP_CF_IPCOUNTRY' => 'DE'], 'okhttp/4.12.0');
  90. $aPayload = ['platform' => 'rn-android', 'app' => 'com.neo.so', 'app_version' => '1.5.0', 'os_version' => '15'];
  91. $this->assertSame(1, $this->_collect($aServer, $aPayload + ['hits' => [$this->_error('Test 2001 crash at start', ['kind' => 'native', 'fatal' => 1, 'url' => '/'])]], self::NOW));
  92. $this->assertCount(0, $this->_sessions(), 'an error starts no visit');
  93. $aHit = $this->_errorHits()[0];
  94. $this->assertSame(0, (int)$aHit['session_id']);
  95. $this->assertSame(16, strlen($aHit['visitor']));
  96. $this->assertSame(['rn-android', '1.5.0', 'Android', 'mobile', 'DE', 1], [$aHit['platform'], $aHit['app_version'], $aHit['os'], $aHit['device'], $aHit['country'], (int)$aHit['fatal']]);
  97. // the next launch: a deferred error from two days ago arrives with the first page
  98. $this->_collect($aServer, $aPayload + ['hits' => [
  99. $this->_error('Test 2001 crash on the last run', ['kind' => 'native', 'fatal' => 1, 'url' => '/feed', 'age' => 2 * 86400 * 1000, 'deferred' => 1]),
  100. ['type' => 'pageview', 'url' => '/feed'],
  101. ]], self::NOW + 3 * 86400);
  102. $aSessions = $this->_sessions();
  103. $this->assertCount(1, $aSessions);
  104. $this->assertSame(0, (int)$aSessions[0]['errors'], 'the old error is not part of this visit');
  105. $aHits = $this->_errorHits();
  106. $this->assertSame(self::NOW + 86400, (int)$aHits[1]['ts'], 'dated when it happened');
  107. $this->assertSame(0, (int)$aHits[1]['session_id']);
  108. // older than 7 days: dropped
  109. $this->assertSame(0, $this->_collect($aServer, $aPayload + ['hits' => [$this->_error('Test 2001 ancient', ['age' => 8 * 86400 * 1000])]], self::NOW + 3 * 86400));
  110. }
  111. public function testDeferredCrashJoinsItsVisit()
  112. {
  113. $aServer = $this->_server('93.184.216.34', [], 'okhttp/4.12.0');
  114. $aPayload = ['platform' => 'rn-android', 'app' => 'com.neo.so', 'app_version' => '1.5.0'];
  115. // a visit, a crash five minutes into it, and the next launch two hours later
  116. $this->_collect($aServer, $aPayload + ['hits' => [['type' => 'pageview', 'url' => '/feed']]], self::NOW);
  117. $this->_collect($aServer, $aPayload + ['hits' => [
  118. $this->_error('Test 2001 crash reported later', ['kind' => 'native', 'fatal' => 1, 'url' => '/feed?id=5', 'age' => (7200 - 300) * 1000, 'deferred' => 1]),
  119. ['type' => 'pageview', 'url' => '/feed'],
  120. ]], self::NOW + 7200);
  121. $aSessions = $this->_sessions();
  122. $this->assertCount(2, $aSessions);
  123. $this->assertSame([1, 0], [(int)$aSessions[0]['errors'], (int)$aSessions[1]['errors']], 'the crash counts in the visit it ended');
  124. $aHit = $this->_errorHits()[0];
  125. $this->assertSame((int)$aSessions[0]['id'], (int)$aHit['session_id']);
  126. $this->assertSame([self::NOW + 300, '/feed'], [(int)$aHit['ts'], $aHit['path']]);
  127. $this->assertSame((int)$aSessions[0]['last_seen'], self::NOW, 'the old visit is not extended');
  128. $aTiles = array_column($this->_oModule->getReports()->getErrors($this->_filter(['range' => 'custom', 'from' => '2001-09-09', 'to' => '2001-09-09']))['tiles'], 'value', 'title');
  129. $this->assertEquals(50, $aTiles[_t('_bx_analytics_metric_crash_free')], 'one of two app visits crashed');
  130. // a crash with no visit found counts as a crashed visit too
  131. $this->_collect($this->_server('93.184.216.40', [], 'okhttp/4.12.0'), $aPayload + ['hits' => [$this->_error('Test 2001 crash at start', ['kind' => 'native', 'fatal' => 1, 'url' => '/'])]], self::NOW + 7300);
  132. $aTotals = $this->_oModule->_oDb->getErrorTotals($this->_filter(['range' => 'custom', 'from' => '2001-09-09', 'to' => '2001-09-09'])->get(), ['open', 'resolved'], ['rn-android']);
  133. $this->assertSame(2, (int)$aTotals['fatal_sessions']);
  134. }
  135. public function testGroupsCountAndReopen()
  136. {
  137. $aServer = $this->_server('81.2.69.142');
  138. $aError = $this->_error('Test 2001 item 5 failed', ['stack' => "Error: item 5 failed\n at load (/_next/static/chunks/app/page-3f9a1b2c4d5e6f70.js:1:200)"]);
  139. $this->_collect($aServer, ['platform' => 'next-web', 'hits' => [$aError + ['repeats' => 3]]], self::NOW);
  140. // the next build, another item: the same error
  141. $aNext = $this->_error('Test 2001 item 77 failed', ['stack' => "Error: item 77 failed\n at load (/_next/static/chunks/app/page-a0b1c2d3e4f5a6b7.js:1:180)"]);
  142. $this->_collect($aServer, ['platform' => 'next-web', 'hits' => [$aNext + ['repeats' => 2]]], self::NOW + 60);
  143. $aHits = $this->_errorHits();
  144. $this->assertCount(2, $aHits);
  145. $this->assertSame($aHits[0]['error_id'], $aHits[1]['error_id']);
  146. $oDb = $this->_oModule->_oDb;
  147. $iId = (int)$aHits[0]['error_id'];
  148. $this->assertSame(5, (int)$oDb->getErrorGroup($iId)['occurrences']);
  149. $this->assertSame(self::NOW + 60, (int)$oDb->getErrorGroup($iId)['last_seen']);
  150. $this->assertSame('Test 2001 item 77 failed', $oDb->getErrorGroup($iId)['message'], 'the latest occurrence\'s text, none older than the group\'s last occurrence');
  151. $this->assertTrue($oDb->setErrorStatus($iId, 'resolved', self::NOW + 100));
  152. // a deferred report of an older occurrence doesn't reopen it, a new one does
  153. $this->_collect($aServer, ['platform' => 'next-web', 'hits' => [$aError + ['age' => 90 * 1000]]], self::NOW + 120);
  154. $this->assertSame('resolved', $oDb->getErrorGroup($iId)['status']);
  155. $this->assertSame('Test 2001 item 77 failed', $oDb->getErrorGroup($iId)['message'], 'an older occurrence reported late leaves it');
  156. $this->_collect($aServer, ['platform' => 'next-web', 'hits' => [$aError]], self::NOW + 200);
  157. $this->assertSame('open', $oDb->getErrorGroup($iId)['status']);
  158. // an ignored error keeps counting
  159. $oDb->setErrorStatus($iId, 'ignored', self::NOW + 300);
  160. $this->_collect($aServer, ['platform' => 'next-web', 'hits' => [$aError]], self::NOW + 400);
  161. $this->assertSame('ignored', $oDb->getErrorGroup($iId)['status']);
  162. $this->assertSame(8, (int)$oDb->getErrorGroup($iId)['occurrences']);
  163. }
  164. public function testNewGroupsAreCapped()
  165. {
  166. // the catch-all group has one fingerprint on every site: a real one is set aside
  167. // while the test makes its own, and restored in tearDown
  168. $sOverflow = BxAnalyticsErrors::getFingerprint(BxAnalyticsErrors::getOverflowError());
  169. if (BxDolDb::getInstance()->query("UPDATE `bx_analytics_errors` SET `fingerprint` = :aside WHERE `fingerprint` = :fingerprint", ['aside' => self::OVERFLOW_ASIDE, 'fingerprint' => $sOverflow]))
  170. $this->_sOverflow = $sOverflow;
  171. $this->_setCnf('MAX_NEW_ERROR_GROUPS_PER_HOUR', 2);
  172. $aServer = $this->_server('81.2.69.142');
  173. $aHits = [];
  174. foreach (['alpha', 'beta', 'gamma', 'delta'] as $sName)
  175. $aHits[] = $this->_error('Test 2001 random ' . $sName, ['stack' => "Error\n at " . $sName . " (/js/app.js:1:1)"]);
  176. $this->assertSame(4, $this->_collect($aServer, ['platform' => 'core-web', 'hits' => $aHits], self::NOW));
  177. $aGroups = [];
  178. foreach (array_column($this->_errorHits(), 'error_id') as $iId)
  179. $aGroups[] = $this->_oModule->_oDb->getErrorGroup($iId);
  180. $this->assertSame(['js', 'js', 'overflow', 'overflow'], array_column($aGroups, 'kind'), 'past the limit, new errors share one group');
  181. $this->assertSame($aGroups[2]['id'], $aGroups[3]['id']);
  182. $this->assertSame(2, (int)$aGroups[2]['occurrences']);
  183. // a known error still gets its own group
  184. $this->_collect($aServer, ['platform' => 'core-web', 'hits' => [$aHits[0]]], self::NOW + 10);
  185. $this->assertSame(2, (int)BxDolDb::getInstance()->getOne("SELECT `occurrences` FROM `bx_analytics_errors` WHERE `message` = 'Test 2001 random alpha'"));
  186. }
  187. public function testDroppedErrors()
  188. {
  189. $aServer = $this->_server('81.2.69.142');
  190. $aPayload = ['platform' => 'core-web'];
  191. $this->assertSame(0, $this->_collect($aServer, $aPayload + ['hits' => [$this->_error('ResizeObserver loop completed with undelivered notifications.')]], self::NOW), 'ignore list');
  192. $this->assertSame(0, $this->_collect($aServer, $aPayload + ['hits' => [$this->_error('Script error.', ['stack' => ''])]], self::NOW), 'a hidden cross-origin error');
  193. $this->assertSame(0, $this->_collect($aServer, $aPayload + ['hits' => [$this->_error('Test 2001 ext', ['source' => 'chrome-extension://abc/content.js'])]], self::NOW), 'an extension');
  194. $this->assertSame(0, $this->_collect($aServer, $aPayload + ['hits' => [$this->_error('Test 2001 kind', ['kind' => 'php'])]], self::NOW), 'kinds a client may not send');
  195. $this->assertSame(0, $this->_collect($aServer, $aPayload + ['hits' => [$this->_error('')]], self::NOW), 'no message');
  196. $this->_setOption('bx_analytics_errors_ignore', '');
  197. $this->assertSame(1, $this->_collect($aServer, $aPayload + ['hits' => [$this->_error('ResizeObserver loop completed with undelivered notifications.')]], self::NOW + 1), 'an empty ignore list');
  198. $this->_setOption('bx_analytics_track_errors', '');
  199. $this->assertSame(0, $this->_collect($aServer, $aPayload + ['hits' => [$this->_error('Test 2001 off')]], self::NOW + 2), 'errors switched off');
  200. $this->assertCount(1, $this->_errorHits());
  201. // http errors get a message from their status
  202. $this->_setOption('bx_analytics_track_errors', 'on');
  203. $this->assertSame(1, $this->_collect($aServer, $aPayload + ['hits' => [$this->_error('', ['kind' => 'http', 'status' => 502, 'source' => '/api.php'])]], self::NOW + 3));
  204. $this->assertSame('HTTP 502', BxDolDb::getInstance()->getOne("SELECT `message` FROM `bx_analytics_errors` WHERE `kind` = 'http' AND `created` < :to", ['to' => self::NOW + 86400]));
  205. }
  206. public function testVitals()
  207. {
  208. $aServer = $this->_server('81.2.69.142');
  209. $this->assertSame(0, $this->_collect($aServer, ['platform' => 'core-web', 'hits' => [$this->_vitals(['lcp' => 2000])]], self::NOW), 'no visit, no vitals');
  210. $this->_collect($aServer, ['platform' => 'core-web', 'hits' => [['type' => 'pageview', 'url' => 'http://una.test/page/view-post?id=5']]], self::NOW);
  211. $this->assertSame(2, $this->_collect($aServer, ['platform' => 'core-web', 'hits' => [
  212. ['type' => 'leave', 'url' => 'http://una.test/page/view-post?id=5', 'engaged' => 4000],
  213. $this->_vitals(['ttfb' => 310.4, 'fcp' => 900, 'lcp' => 2400, 'inp' => 180, 'cls' => 0.1049, 'speed_index' => 3]),
  214. $this->_vitals(['lcp' => 1]),
  215. ]], self::NOW + 30), 'one vitals hit per request');
  216. $aSession = $this->_sessions()[0];
  217. $this->assertSame([1, 0, 1], [(int)$aSession['pageviews'], (int)$aSession['events'], (int)$aSession['bounce']], 'vitals are no pageview or event');
  218. $this->assertSame(30, (int)$aSession['duration']);
  219. $aRows = BxDolDb::getInstance()->getAll("SELECT * FROM `bx_analytics_vitals` WHERE `ts` < :to", ['to' => self::NOW + 30 * 86400]);
  220. $this->assertCount(1, $aRows);
  221. $a = $aRows[0];
  222. $this->assertSame([(int)$aSession['id'], 'core-web', 'desktop', '/page/view-post', '/page/view-post', 'navigate'], [(int)$a['session_id'], $a['platform'], $a['device'], $a['path'], $a['route'], $a['nav']]);
  223. $this->assertSame([310, 900, 2400, 180, 105], [(int)$a['ttfb'], (int)$a['fcp'], (int)$a['lcp'], (int)$a['inp'], (int)$a['cls']], 'whole ms, CLS in thousandths');
  224. $this->assertNull($a['screen_load'], 'what was not measured stays empty');
  225. $this->assertArrayNotHasKey('visitor', $a, 'no visitor is stored with vitals');
  226. $this->_setOption('bx_analytics_track_vitals', '');
  227. $this->assertSame(0, $this->_collect($aServer, ['platform' => 'core-web', 'hits' => [$this->_vitals(['lcp' => 2000])]], self::NOW + 40), 'vitals switched off');
  228. $this->assertSame(0, $this->_collect($aServer, ['platform' => 'core-web', 'hits' => [$this->_vitals(['lcp' => 999999, 'cls' => 50])]], self::NOW + 40), 'out of range');
  229. }
  230. public function testErrorsReport()
  231. {
  232. $aParams = ['range' => 'custom', 'from' => '2001-09-09', 'to' => '2001-09-09'];
  233. $oReports = $this->_oModule->getReports();
  234. // two visitors hit one error, one of them another error once; an iOS visit crashes
  235. $this->_collect($this->_server('81.2.69.142'), ['platform' => 'core-web', 'hits' => [['type' => 'pageview', 'url' => 'http://una.test/a'], $this->_error('Test 2001 common', ['url' => 'http://una.test/a', 'repeats' => 2]), $this->_error('Test 2001 rare', ['url' => 'http://una.test/a'])]], self::NOW);
  236. $this->_collect($this->_server('93.184.216.34'), ['platform' => 'core-web', 'hits' => [['type' => 'pageview', 'url' => 'http://una.test/b'], $this->_error('Test 2001 common', ['url' => 'http://una.test/b'])]], self::NOW + 10);
  237. $this->_collect($this->_server('93.184.216.35', [], 'NEO/1 CFNetwork/1.0 Darwin/24.0.0'), ['platform' => 'rn-ios', 'app' => 'com.neo.so', 'hits' => [['type' => 'pageview', 'url' => '/feed'], $this->_error('Test 2001 crash', ['kind' => 'native', 'url' => '/feed', 'fatal' => 1])]], self::NOW + 20);
  238. $this->_collect($this->_server('93.184.216.36', [], 'NEO/1 CFNetwork/1.0 Darwin/24.0.0'), ['platform' => 'rn-ios', 'app' => 'com.neo.so', 'hits' => [['type' => 'pageview', 'url' => '/feed']]], self::NOW + 30);
  239. $aList = $oReports->getErrors($this->_filter($aParams));
  240. $this->assertSame('list', $aList['view']);
  241. $this->assertSame('open', $aList['tab']);
  242. $this->assertSame(['Test 2001 common', 'Test 2001 crash', 'Test 2001 rare'], array_slice(array_column($aList['rows'], 'label'), 0, 3));
  243. $this->assertSame(3, $aList['rows'][0]['values']['count'], 'repeats count');
  244. $this->assertSame(2, $aList['rows'][0]['values']['users']);
  245. $this->assertSame(['param' => 'error', 'value' => $this->_groupId('Test 2001 common')], $aList['rows'][0]['open']);
  246. $this->assertSame(3, array_sum($aList['rows'][0]['values']['trend']), 'the trend adds up to the count');
  247. $aTiles = array_column($aList['tiles'], 'value', 'title');
  248. $this->assertSame(5, $aTiles[_t('_bx_analytics_metric_errors')]);
  249. $this->assertEquals(75, $aTiles[_t('_bx_analytics_metric_error_sessions')], 'three of four visits');
  250. $this->assertEquals(50, $aTiles[_t('_bx_analytics_metric_crash_free')], 'one of two app visits crashed');
  251. $this->assertSame(3, $aTiles[_t('_bx_analytics_metric_new_errors')]);
  252. // filters: the hit's own columns, and the visit's
  253. $aIos = $oReports->getErrors($this->_filter($aParams + ['platform' => 'rn-ios']));
  254. $this->assertSame(['Test 2001 crash'], array_column($aIos['rows'], 'label'));
  255. $aPage = $oReports->getErrors($this->_filter($aParams + ['f' => ['path' => '/b']]));
  256. $this->assertSame(['Test 2001 common'], array_column($aPage['rows'], 'label'));
  257. $aPage = $oReports->getErrors($this->_filter($aParams + ['f' => ['path' => '/b?id=7&ref=x']]));
  258. $this->assertSame(['Test 2001 common'], array_column($aPage['rows'], 'label'), "a pageview's path with its query string");
  259. $aChannel = $oReports->getErrors($this->_filter($aParams + ['f' => ['channel' => 'direct']]));
  260. $this->assertCount(3, $aChannel['rows']);
  261. // resolved and ignored errors move to their tabs
  262. $this->_oModule->_oDb->setErrorStatus($this->_groupId('Test 2001 rare'), 'ignored', self::NOW + 100);
  263. $this->assertNotContains('Test 2001 rare', array_column($oReports->getErrors($this->_filter($aParams))['rows'], 'label'));
  264. $this->assertSame(['Test 2001 rare'], array_column($oReports->getErrors($this->_filter($aParams), ['tab_errors' => 'ignored'])['rows'], 'label'));
  265. $this->assertCount(3, $oReports->getErrors($this->_filter($aParams), ['tab_errors' => 'all'])['rows']);
  266. $aDetail = $oReports->getErrors($this->_filter($aParams), ['error' => $this->_groupId('Test 2001 common')]);
  267. $this->assertSame('detail', $aDetail['view']);
  268. $this->assertSame([3, 2, 2], array_column($aDetail['tiles'], 'value'));
  269. $this->assertSame(3, array_sum($aDetail['chart']['datasets'][0]['values']));
  270. $aRoutes = array_column($aDetail['breakdowns'], null, 'name')['error_route'];
  271. $this->assertEqualsCanonicalizing(['/a', '/b'], array_column($aRoutes['rows'], 'label'));
  272. $this->assertCount(2, $aDetail['occurrences']['rows']);
  273. $this->assertSame('', $aDetail['stack'], 'the stack needs the people permission');
  274. $this->assertTrue($aDetail['stack_hidden']);
  275. $this->assertSame([], $aDetail['actions'], 'status changes need the manage permission');
  276. $this->assertTrue($oReports->getErrors($this->_filter($aParams), ['error' => 999999999])['missing']);
  277. }
  278. public function testPerformanceReport()
  279. {
  280. $aParams = ['range' => 'custom', 'from' => '2001-09-09', 'to' => '2001-09-09'];
  281. // 40 visits: /fast pages load in 1 s, /slow ones in 1 to 5 s
  282. for ($i = 0; $i < 40; $i++) {
  283. $bSlow = $i % 2 == 1;
  284. $sUrl = 'http://una.test/page/' . ($bSlow ? 'slow' : 'fast') . '/' . $i . '?id=' . $i;
  285. $this->_collect($this->_server('81.2.' . intdiv($i, 200) . '.' . ($i % 200 + 1), [], $i % 4 < 2 ? self::UA_SAFARI : 'Mozilla/5.0 (iPhone; CPU iPhone OS 17_4 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.4 Mobile/15E148 Safari/604.1'), ['platform' => 'core-web', 'hits' => [
  286. ['type' => 'pageview', 'url' => $sUrl],
  287. $this->_vitals(['lcp' => $bSlow ? 1000 + 200 * intdiv($i, 2) : 1000, 'cls' => 0, 'ttfb' => 200], $sUrl),
  288. ]], self::NOW + $i * 60);
  289. }
  290. $aReport = $this->_oModule->getReports()->getPerformance($this->_filter($aParams));
  291. $this->assertSame('lcp', $aReport['metric']);
  292. $aCards = array_column($aReport['cards'], null, 'metric');
  293. $this->assertSame(40, $aCards['lcp']['samples']);
  294. $this->assertSame(['lcp', 'inp', 'cls', 'fcp', 'ttfb', 'screen_load', 'app_start'], array_keys($aCards), 'every metric on all platforms');
  295. // exact p75 of 20 x 1000 and 1000..4800 is the 30th of 40: 2800
  296. $this->assertEqualsWithDelta(2800, $aCards['lcp']['value'], 2800 * 0.03);
  297. $this->assertSame('needs_improvement', $aCards['lcp']['rating']);
  298. $this->assertEquals(round(100 * 28 / 40, 1), $aCards['lcp']['good'], 'up to 2.5 s is good');
  299. $this->assertSame(0.0, $aCards['cls']['value']);
  300. $this->assertNull($aCards['inp']['value'], 'not measured');
  301. $aPages = array_column($aReport['pages']['rows'], null, 'label');
  302. $this->assertSame(['/page/fast/*', '/page/slow/*'], array_keys($aPages), 'pages by route: the ids go');
  303. $this->assertEqualsWithDelta(1000, $aPages['/page/fast/*']['values']['p75'], 30);
  304. $this->assertSame('good', $aPages['/page/fast/*']['values']['rating']);
  305. $this->assertEqualsWithDelta(3800, $aPages['/page/slow/*']['values']['p75'], 3800 * 0.03, 'p75 of 1000..4800 by 200');
  306. $this->assertSame('needs_improvement', $aPages['/page/slow/*']['values']['rating']);
  307. // a pageview's path filters the timings of its page
  308. $aPage = $this->_oModule->getReports()->getPerformance($this->_filter($aParams + ['f' => ['path' => '/page/slow/3?id=3']]));
  309. $this->assertSame(1, array_column($aPage['cards'], null, 'metric')['lcp']['samples']);
  310. $aOverTime = $aReport['chart']['datasets'][0]['values'];
  311. $this->assertCount(24, $aOverTime, 'hourly buckets for one day');
  312. $this->assertNotNull($aOverTime[1], 'the hour with the visits has a value');
  313. $aMobile = $this->_oModule->getReports()->getPerformance($this->_filter($aParams), ['vital_device' => 'mobile']);
  314. $this->assertSame(20, array_column($aMobile['cards'], null, 'metric')['lcp']['samples']);
  315. $aPaths = $this->_oModule->getReports()->getPerformance($this->_filter($aParams), ['tab_performance' => 'path', 'vital' => 'ttfb']);
  316. $this->assertSame('ttfb', $aPaths['metric']);
  317. $this->assertSame(25, count($aPaths['pages']['rows']), 'a page of paths');
  318. $this->assertSame(2, $aPaths['pages']['pages']);
  319. $this->assertSame('performance', $aPaths['pages']['name'], 'the list pages under the block name its tabs reset');
  320. $this->assertSame(15, count($this->_oModule->getReports()->getPerformance($this->_filter($aParams), ['tab_performance' => 'path', 'page_performance' => 2])['pages']['rows']));
  321. $aApps = $this->_oModule->getReports()->getPerformance($this->_filter($aParams + ['platform' => 'rn-ios']));
  322. $this->assertSame(['screen_load', 'app_start'], array_column($aApps['cards'], 'metric'));
  323. }
  324. public function testPruningAndForget()
  325. {
  326. $iProfileId = 987654321;
  327. $aServer = $this->_server('81.2.69.142');
  328. $this->_collect($aServer, ['platform' => 'core-web', 'hits' => [['type' => 'pageview', 'url' => 'http://una.test/'], $this->_error('Test 2001 member error'), $this->_vitals(['lcp' => 1500])]], self::NOW, ['profile_id' => $iProfileId]);
  329. $aHit = $this->_errorHits()[0];
  330. $this->assertSame($iProfileId, (int)$aHit['profile_id']);
  331. $this->_oModule->onProfileDelete($iProfileId);
  332. $aHit = $this->_errorHits()[0];
  333. $this->assertSame(0, (int)$aHit['profile_id']);
  334. $this->assertSame($this->_sessions()[0]['visitor'], $aHit['visitor'], 'the same anonymous visitor as the visit');
  335. $oDb = $this->_oModule->_oDb;
  336. $this->assertSame(1, $oDb->deleteErrorHitsBefore(self::NOW + 1, 100));
  337. $this->assertSame(1, $oDb->deleteVitalsBefore(self::NOW + 1, 100));
  338. $this->assertSame(1, $oDb->deleteErrorGroupsBefore(self::NOW + 1, 100));
  339. $this->assertCount(0, $this->_errorHits());
  340. }
  341. public function testPruningCutoffs()
  342. {
  343. $this->_setOption('bx_analytics_retention_days', '30');
  344. $this->_setOption('bx_analytics_vitals_retention_days', '5');
  345. $this->_collect($this->_server('81.2.69.142'), ['platform' => 'core-web', 'hits' => [['type' => 'pageview', 'url' => 'http://una.test/'], $this->_error('Test 2001 pruned error'), $this->_vitals(['lcp' => 1500])]], self::NOW);
  346. // ten days later: vitals are past their 5 days, the rest is within its 30
  347. $iNow = self::NOW + 10 * 86400;
  348. $aCutoffs = $this->_oModule->getPruningCutoffs($iNow);
  349. $this->assertSame(['deleteLimitsBefore', 'deleteVitalsBefore', 'deleteErrorHitsBefore', 'deleteErrorGroupsBefore', 'clearErrorStacksBefore', 'deleteEventsBefore', 'deleteSessionsBefore'], array_keys($aCutoffs), 'the small, fast growing tables first: they share the run\'s time limit');
  350. $this->assertSame($iNow - 5 * 86400, $aCutoffs['deleteVitalsBefore']);
  351. foreach (['deleteEventsBefore', 'deleteSessionsBefore', 'deleteErrorHitsBefore', 'deleteErrorGroupsBefore', 'clearErrorStacksBefore'] as $sMethod)
  352. $this->assertSame($iNow - 30 * 86400, $aCutoffs[$sMethod], $sMethod);
  353. $oDb = $this->_oModule->_oDb;
  354. foreach ($aCutoffs as $sMethod => $iTime)
  355. $oDb->$sMethod($iTime, 100);
  356. $this->assertCount(1, $this->_sessions());
  357. $this->assertCount(1, $this->_errorHits());
  358. $this->assertSame(1, (int)BxDolDb::getInstance()->getOne("SELECT COUNT(*) FROM `bx_analytics_errors` WHERE `message` = 'Test 2001 pruned error'"));
  359. $this->assertNotSame('', $oDb->getErrorGroup($this->_groupId('Test 2001 pruned error'))['stack'], 'a sample within the retention stays');
  360. // a group that goes on happening keeps its sample stack no longer than the rest
  361. $this->assertSame(1, $oDb->clearErrorStacksBefore(self::NOW + 1, 100));
  362. $aGroup = BxDolDb::getInstance()->getRow("SELECT `stack`, `stack_ts` FROM `bx_analytics_errors` WHERE `id` = :id", ['id' => $this->_groupId('Test 2001 pruned error')]);
  363. $this->assertSame(['', 0], [$aGroup['stack'], (int)$aGroup['stack_ts']]);
  364. $this->assertSame(0, (int)BxDolDb::getInstance()->getOne("SELECT COUNT(*) FROM `bx_analytics_vitals` WHERE `ts` < :to", ['to' => self::NOW + 30 * 86400]));
  365. // vitals are never kept longer than the rest
  366. $this->_setOption('bx_analytics_vitals_retention_days', '400');
  367. $this->assertSame($iNow - 30 * 86400, $this->_oModule->getPruningCutoffs($iNow)['deleteVitalsBefore']);
  368. }
  369. protected function _error($sMessage, $aExtra = [])
  370. {
  371. return array_merge(['type' => 'error', 'url' => 'http://una.test/', 'kind' => 'js', 'message' => $sMessage, 'stack' => "Error: x\n at f (/js/app.js:1:1)"], $aExtra);
  372. }
  373. protected function _vitals($aMetrics, $sUrl = 'http://una.test/page/view-post?id=5')
  374. {
  375. return ['type' => 'vitals', 'url' => $sUrl, 'nav' => 'navigate', 'metrics' => $aMetrics];
  376. }
  377. protected function _groupId($sMessage)
  378. {
  379. return (int)BxDolDb::getInstance()->getOne("SELECT `id` FROM `bx_analytics_errors` WHERE `message` = :message", ['message' => $sMessage]);
  380. }
  381. protected function _collect($aServer, $aPayload, $iNow, $aContext = [])
  382. {
  383. $oCollector = new BxAnalyticsCollector($this->_oModule, new BxAnalyticsRequest($aServer), $iNow);
  384. return $oCollector->collect($aPayload, $aContext + ['platform' => 'api']);
  385. }
  386. protected function _filter($aParams)
  387. {
  388. return new BxAnalyticsFilter($this->_oModule, $aParams, self::NOW + 86400);
  389. }
  390. protected function _server($sIp, $aExtra = [], $sUserAgent = self::UA_SAFARI)
  391. {
  392. return array_merge(['REMOTE_ADDR' => $sIp, 'HTTP_USER_AGENT' => $sUserAgent], $aExtra);
  393. }
  394. protected function _sessions()
  395. {
  396. return BxDolDb::getInstance()->getAll("SELECT * FROM `bx_analytics_sessions` WHERE `started` < :to ORDER BY `id`", ['to' => self::NOW + 30 * 86400]);
  397. }
  398. protected function _errorHits()
  399. {
  400. return BxDolDb::getInstance()->getAll("SELECT * FROM `bx_analytics_error_hits` WHERE `ts` < :to ORDER BY `id`", ['to' => self::NOW + 30 * 86400]);
  401. }
  402. protected function _setOption($sName, $sValue)
  403. {
  404. if (!array_key_exists($sName, $this->_aOptions))
  405. $this->_aOptions[$sName] = getParam($sName);
  406. setParam($sName, $sValue);
  407. }
  408. protected function _setCnf($sName, $mixedValue)
  409. {
  410. if (!array_key_exists($sName, $this->_aCnf))
  411. $this->_aCnf[$sName] = $this->_oModule->_oConfig->CNF[$sName];
  412. $this->_oModule->_oConfig->CNF[$sName] = $mixedValue;
  413. }
  414. protected function _cleanup()
  415. {
  416. $oDb = BxDolDb::getInstance();
  417. $iTo = self::NOW + 30 * 86400;
  418. $oDb->query("DELETE FROM `bx_analytics_events` WHERE `ts` < :to", ['to' => $iTo]);
  419. $oDb->query("DELETE FROM `bx_analytics_sessions` WHERE `started` < :to", ['to' => $iTo]);
  420. $oDb->query("DELETE FROM `bx_analytics_error_hits` WHERE `ts` < :to", ['to' => $iTo]);
  421. $oDb->query("DELETE FROM `bx_analytics_vitals` WHERE `ts` < :to", ['to' => $iTo]);
  422. $oDb->query("DELETE FROM `bx_analytics_errors` WHERE `created` < :to AND `last_seen` < :to2", ['to' => $iTo, 'to2' => $iTo]);
  423. $oDb->query("DELETE FROM `bx_analytics_salts` WHERE `day` < '2002-01-01'");
  424. $oDb->query("DELETE FROM `bx_analytics_limits` WHERE `bucket` < 1009843200");
  425. $this->_oModule->cleanReportCache(); // reports of the test data cached by the run before
  426. }
  427. }