BxAnalyticsCollector.php 58 KB

12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879808182838485868788899091929394959697989910010110210310410510610710810911011111211311411511611711811912012112212312412512612712812913013113213313413513613713813914014114214314414514614714814915015115215315415515615715815916016116216316416516616716816917017117217317417517617717817918018118218318418518618718818919019119219319419519619719819920020120220320420520620720820921021121221321421521621721821922022122222322422522622722822923023123223323423523623723823924024124224324424524624724824925025125225325425525625725825926026126226326426526626726826927027127227327427527627727827928028128228328428528628728828929029129229329429529629729829930030130230330430530630730830931031131231331431531631731831932032132232332432532632732832933033133233333433533633733833934034134234334434534634734834935035135235335435535635735835936036136236336436536636736836937037137237337437537637737837938038138238338438538638738838939039139239339439539639739839940040140240340440540640740840941041141241341441541641741841942042142242342442542642742842943043143243343443543643743843944044144244344444544644744844945045145245345445545645745845946046146246346446546646746846947047147247347447547647747847948048148248348448548648748848949049149249349449549649749849950050150250350450550650750850951051151251351451551651751851952052152252352452552652752852953053153253353453553653753853954054154254354454554654754854955055155255355455555655755855956056156256356456556656756856957057157257357457557657757857958058158258358458558658758858959059159259359459559659759859960060160260360460560660760860961061161261361461561661761861962062162262362462562662762862963063163263363463563663763863964064164264364464564664764864965065165265365465565665765865966066166266366466566666766866967067167267367467567667767867968068168268368468568668768868969069169269369469569669769869970070170270370470570670770870971071171271371471571671771871972072172272372472572672772872973073173273373473573673773873974074174274374474574674774874975075175275375475575675775875976076176276376476576676776876977077177277377477577677777877978078178278378478578678778878979079179279379479579679779879980080180280380480580680780880981081181281381481581681781881982082182282382482582682782882983083183283383483583683783883984084184284384484584684784884985085185285385485585685785885986086186286386486586686786886987087187287387487587687787887988088188288388488588688788888989089189289389489589689789889990090190290390490590690790890991091191291391491591691791891992092192292392492592692792892993093193293393493593693793893994094194294394494594694794894995095195295395495595695795895996096196296396496596696796896997097197297397497597697797897998098198298398498598698798898999099199299399499599699799899910001001100210031004100510061007100810091010101110121013101410151016101710181019102010211022102310241025102610271028102910301031103210331034103510361037103810391040104110421043104410451046104710481049105010511052105310541055105610571058105910601061106210631064106510661067106810691070107110721073107410751076107710781079108010811082108310841085108610871088108910901091109210931094109510961097109810991100110111021103110411051106110711081109111011111112111311141115111611171118111911201121112211231124112511261127112811291130113111321133113411351136113711381139114011411142114311441145114611471148114911501151115211531154115511561157115811591160116111621163116411651166116711681169117011711172117311741175117611771178117911801181118211831184118511861187118811891190119111921193119411951196119711981199120012011202120312041205120612071208120912101211121212131214121512161217121812191220122112221223122412251226122712281229123012311232123312341235123612371238123912401241124212431244124512461247124812491250125112521253125412551256125712581259126012611262126312641265126612671268126912701271127212731274127512761277127812791280128112821283128412851286128712881289129012911292129312941295129612971298129913001301130213031304130513061307130813091310131113121313131413151316
  1. <?php defined('BX_DOL') or die('hack attempt');
  2. /**
  3. * Copyright (c) UNA, Inc - https://una.io
  4. * MIT License - https://opensource.org/licenses/MIT
  5. *
  6. * @defgroup Analytics Analytics
  7. * @ingroup UnaModules
  8. *
  9. * @{
  10. */
  11. /**
  12. * Takes a batch of hits from a tracker, decides whether to keep them, adds what the server
  13. * knows (visitor id, member, country, browser, traffic source) and writes sessions and events.
  14. *
  15. * Payload, as JSON from the classic tracker, NEO or any API client:
  16. * platform core-web | next-web | rn-ios | rn-android | api
  17. * app, app_version bundle id or client name and its version (apps)
  18. * referrer document.referrer or the app's referrer, for a new session
  19. * language navigator.language
  20. * screen "1440x900"
  21. * vid random visitor id kept by the client, used in persistent identity mode only;
  22. * site requests carry it in the bx_analytics_vid cookie or the X-Analytics-Vid
  23. * header, so the site's own actions join the device's visit
  24. * os, os_version, device sent by native apps, which have no browser user agent
  25. * hits list of {type: pageview|event|leave, url, title, name, value, props, engaged,
  26. * module, object_id, context_id, age}; one hit may also be given at the top level
  27. *
  28. * Health hits (see BxAnalyticsModule::serviceGetTrackerConfig for the client settings: send
  29. * them only when `errors` is true, and vitals only for `vitals_sample` percent of page loads):
  30. *
  31. * {type: 'error', url, kind, message, source, line, col, stack, component_stack, fatal,
  32. * repeats, status, age, deferred}
  33. * kind js (uncaught error), promise (unhandled rejection), react (error boundary),
  34. * native (RN global handler), http (a request answered 500 or more), resource
  35. * (a script or style that failed to load)
  36. * message the error's message, up to 500 characters (255 are kept); for http
  37. * "HTTP 502 /api.php" (no query string), or empty with `status` set
  38. * source, line, col the script and position, when known
  39. * stack the stack as the runtime prints it (V8/Hermes "at fn (file:1:2)" or
  40. * Firefox/Safari "fn@file:1:2"), up to 8000 characters; component_stack is
  41. * React's component stack of an error boundary
  42. * fatal 1 when the app or page could not go on (a root boundary, isFatal on native)
  43. * repeats how many times it happened on this page or screen, 1 to 100 (deduplicate
  44. * on the client and count instead of sending each)
  45. * status HTTP status, http kind only
  46. * age milliseconds since it happened, up to 7 days: an app that crashed sends the
  47. * error on its next launch with `deferred: 1` and the real age
  48. * Error hits never start a visit. They join the visitor's visit on the platform they
  49. * happened in (the running one, or for a deferred error the earlier one it ended), or are
  50. * stored on their own (an app that crashes before its first page). At most 10 per request
  51. * and 50 per visit; messages matching the ignore list are dropped. Strip query strings
  52. * before sending (keep only `i` and `r`); the server strips them again and redacts tokens
  53. * and emails.
  54. *
  55. * {type: 'vitals', url, nav, metrics: {ttfb, fcp, lcp, inp, cls, screen_load, app_start}}
  56. * one per page view or screen, sent once when it is left or hidden (with the leave hit);
  57. * timings in milliseconds (0 to 120000), cls a score (0 to 10); leave out what wasn't
  58. * measured. Web pages: ttfb, fcp, lcp, inp, cls on a full load; screen_load (and cls,
  59. * inp) after a soft navigation. Apps: screen_load, from the navigation to the screen's
  60. * content laid out; app_start once per cold start, from process start to the first page.
  61. * nav: navigate, reload, back_forward, prerender, soft, restore.
  62. * Vitals join a running visit and are dropped without one; they count no pageview or event.
  63. *
  64. * Rate limits: one address (an IPv6 address by its /64 network) may send so many hits a
  65. * minute and start so many visits an hour (Settings > Tracking). Past a limit the hits are
  66. * dropped quietly and the request is answered as usual, see _isWithinHitLimit. Site actions,
  67. * custom events from server code and payments don't come through collect() and aren't limited.
  68. */
  69. class BxAnalyticsCollector
  70. {
  71. protected $_oModule;
  72. protected $_oConfig;
  73. protected $_oDb;
  74. protected $_oRequest;
  75. protected $_iNow;
  76. protected $_aSalts = [];
  77. protected $_sIgnoredParams = null;
  78. /**
  79. * Whether the last collect() dropped its hits for a rate limit.
  80. */
  81. protected $_bLimited = false;
  82. /**
  83. * Query parameters removed from stored paths: campaign tags and click ids are in the
  84. * session's source already, and would split one page into many rows.
  85. */
  86. protected $_aDropParams = [
  87. 'utm_source', 'utm_medium', 'utm_campaign', 'utm_term', 'utm_content', 'utm_id',
  88. 'gclid', 'gbraid', 'wbraid', 'dclid', 'msclkid', 'fbclid', 'ttclid', 'twclid', 'li_fat_id',
  89. 'yclid', 'mc_cid', 'mc_eid', 'igshid', 'sccid', 'epik', '_ga', '_gl', 'ref', 'source', '_r', '_t',
  90. ];
  91. /**
  92. * Query parameters that carry secrets or a person's identity, dropped from stored paths
  93. * with their values: a password reset key, an email confirmation or unsubscribe code, an
  94. * OAuth code, an invitation or API token, an address, a mail recipient's tracking id.
  95. * Reports show paths to everyone who may use analytics, and keep them for months.
  96. */
  97. protected $_aSecretParams = [
  98. 'key', 'code', 'token', 'hash', 'secret', 'password', 'pass', 'passwd', 'pwd', 'auth', 'sid', 'sk',
  99. 'otp', 'email', 'session', 'ticket', 'invite', 'icode', 'confirm', 'signature', 'sig',
  100. '_hsenc', '_hsmi', 'mkt_tok', '_ke',
  101. ];
  102. /**
  103. * Compound names of such parameters, with or without a separator: api_key, access_token,
  104. * reset-code, user.email, apikey, icode (an invitation), promocode, passcode... A few
  105. * harmless names that end the same way (zipcode) are dropped too. `sid` only after a
  106. * separator, as too many words end in it.
  107. */
  108. protected $_sSecretParamsPattern = '/(?:key|code|token|hash|secret|password|passwd|pwd|auth|session|otp|email|signature|[_.-]sid)$/';
  109. /**
  110. * Seconds between a guest visit's last hit and a member's hit for the visit to become the
  111. * member's (signing in), when the guest is known only by address and browser.
  112. */
  113. const SIGN_IN_SECONDS = 120;
  114. public function __construct($oModule, $oRequest = null, $iNow = null)
  115. {
  116. $this->_oModule = $oModule;
  117. $this->_oConfig = $oModule->_oConfig;
  118. $this->_oDb = $oModule->_oDb;
  119. $this->_oRequest = $oRequest ? $oRequest : new BxAnalyticsRequest();
  120. $this->_iNow = $iNow ? (int)$iNow : time();
  121. }
  122. /**
  123. * @param array $aPayload decoded tracker payload, see the class description
  124. * @param array $aContext what the entry point knows: `platform` (default when the payload
  125. * has none), `trusted` (the request carries a valid API key), `profile_id` (signed-in
  126. * profile), `same_origin` (hits must come from the site's own pages)
  127. * @return int number of hits stored
  128. */
  129. public function collect($aPayload, $aContext = [])
  130. {
  131. $this->_bLimited = false;
  132. if(!is_array($aPayload))
  133. return 0;
  134. $aAccept = $this->_accept($aPayload, $aContext);
  135. $bBot = !empty($aAccept['reasons']) && $aAccept['reasons'][0] == 'bot';
  136. if(!empty($aAccept['reasons']) && !$bBot)
  137. return 0;
  138. // a flood from one address, a headless bot's too, writes nothing past the limit
  139. if(!$this->_isWithinHitLimit($aAccept['ip'], count($aAccept['hits'])))
  140. return 0;
  141. // a bot that runs the tracker (a headless browser): counted with the bots
  142. if($bBot) {
  143. $this->collectBot($aAccept['user_agent'], $aAccept['hits'][0]['url'], $aAccept['platform']);
  144. return 0;
  145. }
  146. $sPlatform = $aAccept['platform'];
  147. $aHits = $aAccept['hits'];
  148. $sIp = $aAccept['ip'];
  149. $sUserAgent = $aAccept['user_agent'];
  150. $aClient = $aAccept['client'];
  151. $iProfileId = $aAccept['profile_id'];
  152. $sVid = isset($aPayload['vid']) && is_string($aPayload['vid']) && BxAnalyticsRequest::isVisitorId($aPayload['vid']) ? $aPayload['vid'] : '';
  153. $aVisitor = $this->_getVisitor($sIp, $sUserAgent, $iProfileId, $sVid);
  154. // what an error hit outside a visit records about the client
  155. $aClientRow = [
  156. 'ids' => $aVisitor,
  157. 'visitor' => $aVisitor['id'],
  158. 'profile_id' => $iProfileId,
  159. 'platform' => $sPlatform,
  160. 'app_version' => $this->_string($aPayload, 'app_version', 32, '/^[a-zA-Z0-9._+ -]+$/'),
  161. 'browser' => $aClient['browser'],
  162. 'os' => $aClient['os'],
  163. 'device' => $aClient['device'],
  164. 'country' => ($sEdge = $this->_oConfig->getGeoEdge()) !== false ? $this->_oRequest->getCountry($sEdge) : '',
  165. ];
  166. $aSession = $this->_getSession($sPlatform, $aVisitor, $iProfileId, $aPayload, $aClient, $aHits, $sIp);
  167. if($this->_bLimited)
  168. return 0;
  169. if(empty($aSession)) {
  170. // errors without a visit (an app that crashed before its first page, a beacon after
  171. // the visit timed out) are kept on their own; vitals belong to a page view
  172. $aErrors = array_filter($aHits, function($aHit) { return $aHit['type'] == BX_ANALYTICS_HIT_ERROR; });
  173. return $this->_storeErrors([], $aErrors, $aClientRow);
  174. }
  175. return $this->_store($aSession, $aHits, $aClientRow);
  176. }
  177. /**
  178. * Whether the last collect() dropped its hits because the address was over a rate limit.
  179. */
  180. public function isLimited()
  181. {
  182. return $this->_bLimited;
  183. }
  184. /**
  185. * What collect() would do with a payload, without storing anything: the reasons it would
  186. * drop the hits (or `ok`), and what it knows about the request. For the Setup section's
  187. * check of the admin's own browser.
  188. *
  189. * @param array $aPayload decoded tracker payload
  190. * @param array $aContext as for collect()
  191. * @return array reasons (tracking_off, platform_off, dnt, cross_site, excluded_path,
  192. * no_hits, excluded_ip, bot, excluded_level, rate_limited; or ok), notes
  193. * (link_members_off: stored, but not tied to the member), platform, ip (resolved),
  194. * remote_addr, country_header, country, geo_source, identity
  195. */
  196. public function check($aPayload, $aContext = [])
  197. {
  198. $aAccept = $this->_accept(is_array($aPayload) ? $aPayload : [], $aContext, true);
  199. if($this->_isOverLimit($aAccept['ip']))
  200. $aAccept['reasons'][] = 'rate_limited';
  201. $sEdge = $this->_oConfig->getGeoEdge();
  202. $aCountry = $this->_oRequest->getCountryHeader($sEdge !== false ? $sEdge : '');
  203. return [
  204. 'reasons' => !empty($aAccept['reasons']) ? $aAccept['reasons'] : ['ok'],
  205. 'notes' => $aAccept['notes'],
  206. 'platform' => $aAccept['platform'],
  207. 'ip' => $aAccept['ip'],
  208. 'remote_addr' => $this->_oRequest->getRemoteAddr(),
  209. 'country_header' => $aCountry ? $aCountry['header'] : '',
  210. 'country' => $aCountry ? $aCountry['country'] : '',
  211. 'geo_source' => $this->_oConfig->getGeoSource(),
  212. 'identity' => $this->_oConfig->getIdentityMode(),
  213. ];
  214. }
  215. /**
  216. * The guards of collect() in one place: whether a payload's hits are kept, and what the
  217. * request tells about the visitor. collect() stops at the first reason; a check collects
  218. * them all.
  219. *
  220. * @param bool $bAll go on after a reason, to list every one
  221. * @return array reasons (empty when the hits are kept), notes, platform, hits, ip,
  222. * user_agent, client (false for a bot), profile_id (0 when members aren't linked)
  223. */
  224. protected function _accept($aPayload, $aContext, $bAll = false)
  225. {
  226. $aResult = [
  227. 'reasons' => [],
  228. 'notes' => [],
  229. 'platform' => isset($aPayload['platform']) && is_string($aPayload['platform']) ? $aPayload['platform'] : (isset($aContext['platform']) ? $aContext['platform'] : BX_ANALYTICS_PLATFORM_API),
  230. 'hits' => [],
  231. 'ip' => '',
  232. 'user_agent' => '',
  233. 'client' => false,
  234. 'profile_id' => 0,
  235. ];
  236. // true when the caller should stop here
  237. $fReject = function($sReason) use (&$aResult, $bAll) {
  238. $aResult['reasons'][] = $sReason;
  239. return !$bAll;
  240. };
  241. if(!$this->_oConfig->isTrackingEnabled() && $fReject('tracking_off'))
  242. return $aResult;
  243. // `core` is the server's own platform, never a client's
  244. $sPlatform = $aResult['platform'];
  245. if(($sPlatform == BX_ANALYTICS_PLATFORM_CORE || !$this->_oConfig->isPlatformEnabled($sPlatform)) && $fReject('platform_off'))
  246. return $aResult;
  247. if($this->_oConfig->isRespectDnt() && $this->_oRequest->isDoNotTrack() && $fReject('dnt'))
  248. return $aResult;
  249. $bSameOrigin = !empty($aContext['same_origin']);
  250. if($bSameOrigin && $this->_oRequest->isCrossSite() && $fReject('cross_site'))
  251. return $aResult;
  252. $aResult['hits'] = $this->_getHits($aPayload, $bSameOrigin);
  253. if(empty($aResult['hits']) && $fReject($this->_getNoHitsReason($aPayload)))
  254. return $aResult;
  255. $aResult['ip'] = $this->_oRequest->getIp($this->_oConfig->getTrustedProxies(), !empty($aContext['trusted']));
  256. if($aResult['ip'] !== '' && BxAnalyticsRequest::inList($aResult['ip'], $this->_oConfig->getExcludedIps()) && $fReject('excluded_ip'))
  257. return $aResult;
  258. $aResult['user_agent'] = $this->_oRequest->getUserAgent();
  259. $aResult['client'] = $this->_getClient($sPlatform, $aPayload, $aResult['user_agent']);
  260. if($aResult['client'] === false && !empty($aResult['hits']) && $fReject('bot'))
  261. return $aResult;
  262. $iProfileId = isset($aContext['profile_id']) ? (int)$aContext['profile_id'] : 0;
  263. $bExcluded = $iProfileId ? $this->_isExcludedProfile($iProfileId) : in_array(MEMBERSHIP_ID_NON_MEMBER, $this->_oConfig->getExcludedLevels(), true);
  264. if($bExcluded && $fReject('excluded_level'))
  265. return $aResult;
  266. if($iProfileId && !$this->_oConfig->isLinkMembers()) {
  267. $aResult['notes'][] = 'link_members_off';
  268. $iProfileId = 0;
  269. }
  270. $aResult['profile_id'] = $iProfileId;
  271. return $aResult;
  272. }
  273. /**
  274. * Why a payload has no usable hits: all of them on excluded paths, or none valid.
  275. */
  276. protected function _getNoHitsReason($aPayload)
  277. {
  278. $aInput = isset($aPayload['hits']) && is_array($aPayload['hits']) ? $aPayload['hits'] : [$aPayload];
  279. foreach($aInput as $aHit) {
  280. $aUrl = is_array($aHit) && isset($aHit['url']) && is_string($aHit['url']) ? $this->_parseUrl($aHit['url']) : false;
  281. if($aUrl !== false && $this->_isExcludedPath($aUrl['path'], $this->_oConfig->getExcludedPaths()))
  282. return 'excluded_path';
  283. }
  284. return 'no_hits';
  285. }
  286. /**
  287. * Count a request from a bot. Bots don't run the tracker, so the page itself reports
  288. * them as it renders. They are kept as totals per day, bot and page, never as visits.
  289. *
  290. * @param string $sUserAgent the request's user agent
  291. * @param string $sUrl the requested URL or path
  292. * @param string $sPlatform where the request came to
  293. * @return bool whether it was a bot and was counted
  294. */
  295. public function collectBot($sUserAgent, $sUrl, $sPlatform = BX_ANALYTICS_PLATFORM_CORE_WEB)
  296. {
  297. if(!$this->_oConfig->isTrackingEnabled() || !$this->_oConfig->isTrackBots() || !$this->_oConfig->isPlatform($sPlatform))
  298. return false;
  299. $aBot = BxAnalyticsUserAgent::getBot($sUserAgent);
  300. if(!$aBot)
  301. return false;
  302. $aUrl = $this->_parseUrl((string)$sUrl);
  303. $sPath = $aUrl !== false ? $aUrl['path'] : '';
  304. if($sPath !== '' && $this->_isExcludedPath($sPath, $this->_oConfig->getExcludedPaths()))
  305. return false;
  306. return $this->_oDb->addBotHit(gmdate('Y-m-d', $this->_iNow), $aBot['name'], $aBot['category'], $sPlatform, $sPath, $this->_iNow);
  307. }
  308. /**
  309. * Record something done on the site (a sign-up, a post, a comment, a reaction) as an
  310. * event of type `una`, from whatever client did it. The event joins the actor's running
  311. * visit on that client when there is one, so funnels and journeys see it.
  312. *
  313. * @param string $sName action name: signup, login, profile_add, content_add, comment, vote, connection...
  314. * @param string $sModule module or object system the action happened in
  315. * @param int $iObjectId content, comment or profile the action is about
  316. * @param int $iProfileId who did it
  317. * @param array $aContext `platform` (otherwise worked out from the request), `api_key` (the
  318. * request carries an accepted API key), `props`, `value`, `type` (`event` for a custom
  319. * event from server code, which the "track site actions" setting doesn't turn off),
  320. * `request` (whether the request is the actor's own, whose Do Not Track, address and
  321. * user agent apply; by default any request but the server platform's)
  322. * @return bool whether it was stored (not when tracking is off, the platform isn't
  323. * enabled, the member's or the guest's level is excluded, or the request isn't
  324. * tracked: Do Not Track when respected, an excluded address, a bot)
  325. */
  326. public function collectAction($sName, $sModule, $iObjectId, $iProfileId, $aContext = [])
  327. {
  328. $sType = isset($aContext['type']) && $aContext['type'] === BX_ANALYTICS_HIT_EVENT ? BX_ANALYTICS_HIT_EVENT : BX_ANALYTICS_HIT_UNA;
  329. if(!$this->_oConfig->isTrackingEnabled() || ($sType == BX_ANALYTICS_HIT_UNA && !$this->_oConfig->isTrackActions()))
  330. return false;
  331. $sPlatform = !empty($aContext['platform']) ? $aContext['platform'] : $this->_oRequest->getPlatform(defined('BX_API'), !empty($aContext['api_key']));
  332. // a platform left out of Settings > Platforms collects nothing, server events included
  333. if(!$this->_oConfig->isPlatformEnabled($sPlatform))
  334. return false;
  335. // the same rules as the visitor's own hits; a cron job or a webhook (the server's
  336. // platform) has no visitor request to read them from
  337. $iProfileId = (int)$iProfileId;
  338. $bRequest = isset($aContext['request']) ? (bool)$aContext['request'] : $sPlatform != BX_ANALYTICS_PLATFORM_CORE;
  339. if(!$this->isTracked($sPlatform, $iProfileId, !empty($aContext['api_key']), $bRequest))
  340. return false;
  341. if(!$iProfileId && in_array(MEMBERSHIP_ID_NON_MEMBER, $this->_oConfig->getExcludedLevels(), true))
  342. return false;
  343. $iLinkedProfileId = $this->_oConfig->isLinkMembers() ? $iProfileId : 0;
  344. list($aVisitor, $aSession) = $this->_getRequestVisit($sPlatform, $iLinkedProfileId, !empty($aContext['api_key']));
  345. // signing in (`login`) is a member's action in the guest's visit: the visit is theirs now
  346. if(!empty($aSession))
  347. $aSession = $this->_takeOver($aSession, $aVisitor, $iLinkedProfileId);
  348. $aProps = isset($aContext['props']) && is_array($aContext['props']) ? $this->_getProps($aContext['props']) : [];
  349. $iStored = $this->_oDb->insertEvents([[
  350. 'session_id' => !empty($aSession) ? (int)$aSession['id'] : 0,
  351. 'visitor' => !empty($aSession) ? $aSession['visitor'] : $aVisitor['id'],
  352. 'profile_id' => $iLinkedProfileId,
  353. 'platform' => $sPlatform,
  354. 'ts' => $this->_iNow,
  355. 'type' => $sType,
  356. 'name' => get_mb_substr((string)$sName, 0, 64),
  357. 'hostname' => '',
  358. 'path' => '',
  359. 'title' => '',
  360. 'module' => get_mb_substr(preg_replace('/[^a-z0-9_]/', '', mb_strtolower((string)$sModule)), 0, 32),
  361. 'object_id' => max(0, (int)$iObjectId),
  362. 'context_id' => 0,
  363. 'value' => isset($aContext['value']) && is_numeric($aContext['value']) ? (float)$aContext['value'] : 0,
  364. 'engaged' => 0,
  365. 'props' => empty($aProps) ? '' : json_encode($aProps, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES),
  366. ]]);
  367. if($iStored && !empty($aSession))
  368. $this->_oDb->addSessionHits($aSession['id'], 0, 1, true, $this->_iNow, false);
  369. return $iStored > 0;
  370. }
  371. /**
  372. * Whether something a member did may be tied to their visits: tracking is on and the
  373. * member's level isn't excluded. For the member's own request (`$bRequest`) also: its
  374. * platform is enabled, it doesn't ask not to be tracked (when that is respected), its
  375. * address isn't excluded and a browser platform isn't a bot.
  376. *
  377. * @param string $sPlatform platform of the request
  378. * @param int $iProfileId the member
  379. * @param bool $bApiKey the request carries an accepted API key
  380. * @param bool $bRequest the request is the member's own, not a webhook or a cron job
  381. */
  382. public function isTracked($sPlatform, $iProfileId, $bApiKey = false, $bRequest = true)
  383. {
  384. if(!$this->_oConfig->isTrackingEnabled())
  385. return false;
  386. if((int)$iProfileId && $this->_isExcludedProfile((int)$iProfileId))
  387. return false;
  388. if(!$bRequest)
  389. return true;
  390. if(!$this->_oConfig->isPlatformEnabled($sPlatform))
  391. return false;
  392. if($this->_oConfig->isRespectDnt() && $this->_oRequest->isDoNotTrack())
  393. return false;
  394. $sIp = $this->_oRequest->getIp($this->_oConfig->getTrustedProxies(), $bApiKey);
  395. if($sIp !== '' && BxAnalyticsRequest::inList($sIp, $this->_oConfig->getExcludedIps()))
  396. return false;
  397. if($this->_oConfig->isBrowserPlatform($sPlatform) && BxAnalyticsUserAgent::isBot($this->_oRequest->getUserAgent()))
  398. return false;
  399. return true;
  400. }
  401. /**
  402. * The running visit of the request's visitor on a platform, or an empty array.
  403. *
  404. * @param string $sPlatform platform of the request
  405. * @param int $iProfileId the signed-in member, used when members are linked to visits
  406. * @param bool $bApiKey the request carries an accepted API key
  407. */
  408. public function getRequestSession($sPlatform, $iProfileId, $bApiKey = false)
  409. {
  410. list(, $aSession) = $this->_getRequestVisit($sPlatform, $this->_oConfig->isLinkMembers() ? (int)$iProfileId : 0, $bApiKey);
  411. return $aSession;
  412. }
  413. /**
  414. * Visitor ids of the current request and its running visit on a platform. The server's
  415. * own platform has no visits: its visitor is the member, or nobody.
  416. *
  417. * @return array [visitor ids, session or []]
  418. */
  419. protected function _getRequestVisit($sPlatform, $iLinkedProfileId, $bApiKey)
  420. {
  421. if($sPlatform == BX_ANALYTICS_PLATFORM_CORE)
  422. return [['id' => $iLinkedProfileId ? $this->_hash('p:' . $iLinkedProfileId, BX_DOL_SECRET) : str_repeat("\0", 16)], []];
  423. // the device's own id, in persistent mode: the classic tracker keeps it in a cookie, NEO sends it in a header
  424. $aVisitor = $this->_getVisitor($this->_oRequest->getIp($this->_oConfig->getTrustedProxies(), $bApiKey), $this->_oRequest->getUserAgent(), $iLinkedProfileId, $this->_oRequest->getVisitorId());
  425. // a visit that started after the action (a clock given by a test or an import) isn't its visit
  426. return [$aVisitor, $this->_findSession($aVisitor, $sPlatform, $this->_iNow - $this->_oConfig->getSessionTimeout(), $this->_iNow, $this->_iNow)];
  427. }
  428. /**
  429. * Visitor ids for a hit: `id` to store, `lookup` to find a running session.
  430. * Members get one id across devices and days. Persistent mode uses the client's own id.
  431. * Cookieless mode hashes the address and user agent with a salt that changes daily, and
  432. * also checks yesterday's id so a visit doesn't split at midnight.
  433. *
  434. * @return array id, lookup, anonymous (the ids of the guest), member (a member's ids),
  435. * device (the guest id is the device's own, not the address and browser)
  436. */
  437. protected function _getVisitor($sIp, $sUserAgent, $iProfileId, $sVid)
  438. {
  439. $bDevice = $sVid !== '' && $this->_oConfig->getIdentityMode() == BX_ANALYTICS_IDENTITY_PERSISTENT;
  440. if($bDevice)
  441. $aAnonymous = [$this->_hash('v:' . $sVid, BX_DOL_SECRET)];
  442. else {
  443. $sToday = gmdate('Y-m-d', $this->_iNow);
  444. $sYesterday = gmdate('Y-m-d', $this->_iNow - 86400);
  445. $this->_loadSalts([$sToday, $sYesterday]);
  446. $aAnonymous = [
  447. $this->_hash($sIp . '|' . $sUserAgent, $this->_getSalt($sToday)),
  448. $this->_hash($sIp . '|' . $sUserAgent, $this->_getSalt($sYesterday)),
  449. ];
  450. }
  451. if($iProfileId) {
  452. $sMember = $this->_hash('p:' . $iProfileId, BX_DOL_SECRET);
  453. return ['id' => $sMember, 'lookup' => array_merge([$sMember], $aAnonymous), 'anonymous' => $aAnonymous, 'member' => true, 'device' => $bDevice];
  454. }
  455. return ['id' => $aAnonymous[0], 'lookup' => $aAnonymous, 'anonymous' => $aAnonymous, 'member' => false, 'device' => $bDevice];
  456. }
  457. /**
  458. * The visitor's latest visit on a platform active since a time. A member's own visit comes
  459. * first. A guest visit is the member's (they signed in during it) when the guest id is the
  460. * device's own, or else only when the visit was active just before: people behind one
  461. * address (a mobile carrier, an office) often have the same browser, and so the same
  462. * cookieless id, and one signing in mustn't take over another's visit.
  463. *
  464. * @param int $iUntil the visit started by then, 0 for any
  465. * @param int $iAt when the member's hit happened
  466. * @return array the visit or []
  467. */
  468. protected function _findSession($aVisitor, $sPlatform, $iSince, $iUntil = 0, $iAt = 0)
  469. {
  470. if(empty($aVisitor['member']))
  471. return $this->_oDb->getActiveSession($aVisitor['lookup'], $sPlatform, $iSince, $iUntil);
  472. $aSession = $this->_oDb->getActiveSession([$aVisitor['id']], $sPlatform, $iSince, $iUntil);
  473. if(!empty($aSession))
  474. return $aSession;
  475. if(empty($aVisitor['device']))
  476. $iSince = max($iSince, ($iAt ? $iAt : $this->_iNow) - self::SIGN_IN_SECONDS);
  477. return $this->_oDb->getActiveSession($aVisitor['anonymous'], $sPlatform, $iSince, $iUntil);
  478. }
  479. /**
  480. * A guest visit found for a member (see _findSession) becomes the member's: the visit and
  481. * every event recorded in it so far get the member's visitor id and profile, so one visit
  482. * isn't counted as two people and the member's actions include those before signing in.
  483. * Error hits keep the guest's id.
  484. *
  485. * @return array the visit as it is now
  486. */
  487. protected function _takeOver($aSession, $aVisitor, $iProfileId)
  488. {
  489. if(!$iProfileId || (int)$aSession['profile_id'] || $aSession['visitor'] === $aVisitor['id'])
  490. return $aSession;
  491. $this->_oDb->updateSession($aSession['id'], ['profile_id' => $iProfileId, 'visitor' => $aVisitor['id']]);
  492. $this->_oDb->assignSessionEvents($aSession['id'], $aVisitor['id'], $iProfileId);
  493. $aSession['profile_id'] = $iProfileId;
  494. $aSession['visitor'] = $aVisitor['id'];
  495. return $aSession;
  496. }
  497. /**
  498. * The visitor's running session on this platform, or a new one. A guest session that
  499. * signs in continues as the member's (see _findSession). No new one past the address's
  500. * limit of visits an hour (`_bLimited` tells).
  501. *
  502. * @param string $sIp the client's address, for the rate limit
  503. */
  504. protected function _getSession($sPlatform, $aVisitor, $iProfileId, $aPayload, $aClient, $aHits, $sIp = '')
  505. {
  506. $aSession = $this->_findSession($aVisitor, $sPlatform, $this->_iNow - $this->_oConfig->getSessionTimeout());
  507. if(!empty($aSession))
  508. return $this->_takeOver($aSession, $aVisitor, $iProfileId);
  509. // a hit that only closes a page, reports an error or a page's timings doesn't start a visit
  510. $aFirst = null;
  511. foreach($aHits as $aHit)
  512. if(!in_array($aHit['type'], [BX_ANALYTICS_HIT_LEAVE, BX_ANALYTICS_HIT_ERROR, BX_ANALYTICS_HIT_VITALS], true)) {
  513. $aFirst = $aHit;
  514. break;
  515. }
  516. if($aFirst === null)
  517. return [];
  518. // two first requests of a visit at once (a pageview and an event, tabs opened together)
  519. // would both find nothing and start two visits: the second waits for the first and
  520. // finds its visit. Without the lock (a timeout, most likely while another request started
  521. // this visit) it looks again and then goes on as before rather than drop hits.
  522. $sLock = $this->_getVisitLockName($sPlatform, $aVisitor);
  523. $bLocked = $this->_oDb->lockVisit($sLock, 2);
  524. try {
  525. $aSession = $this->_findSession($aVisitor, $sPlatform, $this->_iNow - $this->_oConfig->getSessionTimeout());
  526. if(!empty($aSession))
  527. return $this->_takeOver($aSession, $aVisitor, $iProfileId);
  528. return $this->_startSession($sPlatform, $aVisitor, $iProfileId, $aPayload, $aClient, $aFirst, $sIp);
  529. }
  530. finally {
  531. if($bLocked)
  532. $this->_oDb->unlockVisit($sLock);
  533. }
  534. }
  535. /**
  536. * The name of the lock that starts a visit, one per visitor and platform; MySQL allows 64
  537. * characters, so the hash is cut to 40.
  538. */
  539. protected function _getVisitLockName($sPlatform, $aVisitor)
  540. {
  541. return 'bx_analytics_s_' . substr(hash('sha256', $sPlatform . '|' . $aVisitor['id']), 0, 40);
  542. }
  543. /**
  544. * A new visit that starts with a hit.
  545. */
  546. protected function _startSession($sPlatform, $aVisitor, $iProfileId, $aPayload, $aClient, $aFirst, $sIp)
  547. {
  548. // a script that changes its user agent or visitor id for every request would start a
  549. // visit with each
  550. if(!$this->_isWithinVisitLimit($sIp))
  551. return [];
  552. $sReferrer = isset($aPayload['referrer']) && is_string($aPayload['referrer']) ? $aPayload['referrer'] : '';
  553. $aSource = BxAnalyticsSource::classify($sReferrer, $aFirst['url'], $this->_oConfig->getInternalHosts());
  554. $sLanguage = isset($aPayload['language']) && is_string($aPayload['language']) && preg_match('/^[a-zA-Z]{2,3}(-[a-zA-Z0-9]{2,4})?$/', $aPayload['language']) ? $aPayload['language'] : $this->_oRequest->getLanguage();
  555. $sScreen = isset($aPayload['screen']) && is_string($aPayload['screen']) && preg_match('/^\d{2,5}x\d{2,5}$/', $aPayload['screen']) ? $aPayload['screen'] : '';
  556. $aGeo = ['country' => '', 'region' => '', 'city' => '', 'lat' => null, 'lng' => null];
  557. if(($sEdge = $this->_oConfig->getGeoEdge()) !== false) {
  558. $aCoordinates = $this->_oRequest->getCoordinates($sEdge);
  559. $aGeo = [
  560. 'country' => $this->_oRequest->getCountry($sEdge),
  561. 'region' => $this->_oRequest->getRegion($sEdge),
  562. 'city' => $this->_oRequest->getCity($sEdge),
  563. 'lat' => $aCoordinates ? $aCoordinates[0] : null,
  564. 'lng' => $aCoordinates ? $aCoordinates[1] : null,
  565. ];
  566. }
  567. $iStarted = $aFirst['ts'];
  568. $aSession = array_merge([
  569. 'visitor' => $aVisitor['id'],
  570. 'profile_id' => $iProfileId,
  571. 'platform' => $sPlatform,
  572. 'app' => $this->_string($aPayload, 'app', 64, '/^[a-zA-Z0-9._ -]+$/'),
  573. 'app_version' => $this->_string($aPayload, 'app_version', 32, '/^[a-zA-Z0-9._+ -]+$/'),
  574. 'started' => $iStarted,
  575. 'last_seen' => $iStarted,
  576. 'duration' => 0,
  577. 'pageviews' => 0,
  578. 'events' => 0,
  579. 'bounce' => 1,
  580. 'hostname' => $aFirst['hostname'],
  581. 'entry_path' => $aFirst['path'],
  582. 'exit_path' => $aFirst['path'],
  583. 'language' => get_mb_substr($sLanguage, 0, 16),
  584. 'screen' => $sScreen,
  585. ], $aSource, $aGeo, $aClient);
  586. unset($aSession['bot']);
  587. $aSession['id'] = $this->_oDb->insertSession($aSession);
  588. if(!$aSession['id'])
  589. return [];
  590. return $aSession;
  591. }
  592. /**
  593. * Write the hits and add them to the session's counters in one query. The counters change
  594. * in the database, not from `$aSession`, which another request of the visit may have
  595. * changed since it was read.
  596. *
  597. * @param array $aClientRow the client's columns for errors that don't belong to the visit
  598. */
  599. protected function _store($aSession, $aHits, $aClientRow = [])
  600. {
  601. $iPageviews = 0;
  602. $iEvents = 0;
  603. $bInteractive = false;
  604. $iLastSeen = 0;
  605. $aRows = [];
  606. $aErrors = [];
  607. $iStored = 0;
  608. foreach($aHits as $aHit) {
  609. if($aHit['type'] == BX_ANALYTICS_HIT_ERROR) {
  610. $aErrors[] = $aHit;
  611. if($this->_isSessionError($aSession, $aHit))
  612. $iLastSeen = max($iLastSeen, $aHit['ts']);
  613. continue;
  614. }
  615. $iLastSeen = max($iLastSeen, $aHit['ts']);
  616. // a page's timings: a row of their own, no pageview or event
  617. if($aHit['type'] == BX_ANALYTICS_HIT_VITALS) {
  618. $iStored += $this->_oDb->insertVitals(array_merge([
  619. 'ts' => $aHit['ts'],
  620. 'session_id' => (int)$aSession['id'],
  621. 'platform' => $aSession['platform'],
  622. 'device' => $aSession['device'],
  623. 'path' => $aHit['path'],
  624. 'route' => $aHit['route'],
  625. 'nav' => $aHit['vitals']['nav'],
  626. ], $aHit['vitals']['metrics']));
  627. continue;
  628. }
  629. if($aHit['type'] == BX_ANALYTICS_HIT_LEAVE) {
  630. if($aHit['engaged'] > 0)
  631. $this->_oDb->addEngagedTime($aSession['id'], $aHit['path'], $aHit['engaged'], $this->_oConfig->CNF['MAX_ENGAGED_MS']);
  632. $iStored++;
  633. continue;
  634. }
  635. if($aHit['type'] == BX_ANALYTICS_HIT_PAGEVIEW)
  636. $iPageviews++;
  637. else {
  638. $iEvents++;
  639. if(empty($aHit['non_interactive']))
  640. $bInteractive = true;
  641. }
  642. $aRows[] = [
  643. 'session_id' => $aSession['id'],
  644. 'visitor' => $aSession['visitor'],
  645. 'profile_id' => (int)$aSession['profile_id'],
  646. 'platform' => $aSession['platform'],
  647. 'ts' => $aHit['ts'],
  648. 'type' => $aHit['type'],
  649. 'name' => $aHit['name'],
  650. 'hostname' => $aHit['hostname'],
  651. 'path' => $aHit['path'],
  652. 'title' => $aHit['title'],
  653. 'module' => $aHit['module'],
  654. 'object_id' => $aHit['object_id'],
  655. 'context_id' => $aHit['context_id'],
  656. 'value' => $aHit['value'],
  657. 'engaged' => 0,
  658. 'props' => $aHit['props'],
  659. ];
  660. }
  661. if(!empty($aRows))
  662. $iStored += $this->_oDb->insertEvents($aRows);
  663. // after the events: the exit page is the visit's latest pageview among them
  664. $this->_oDb->addSessionHits($aSession['id'], $iPageviews, $iEvents, $bInteractive, $iLastSeen, $iPageviews > 0);
  665. if(!empty($aErrors))
  666. $iStored += $this->_storeErrors($aSession, $aErrors, $aClientRow);
  667. return $iStored;
  668. }
  669. /**
  670. * Whether an error happened in the visit: not before it started (a deferred error from an
  671. * app's previous run, sent when it starts again, belongs to an earlier visit). A minute of
  672. * slack covers an error buffered before the first pageview was sent.
  673. */
  674. protected function _isSessionError($aSession, $aHit)
  675. {
  676. return !empty($aSession) && $aHit['ts'] >= (int)$aSession['started'] - 60;
  677. }
  678. /**
  679. * The visit an error that is not part of the running one happened in: the visitor's last
  680. * visit on the platform that had started by then and had not timed out. An app that
  681. * crashed sends the error on its next launch, often after that visit timed out.
  682. *
  683. * @return array the visit, or [] when it happened outside any
  684. */
  685. protected function _getErrorSession($aClientRow, $aHit)
  686. {
  687. if(empty($aClientRow['ids']))
  688. return [];
  689. $iTime = (int)$aHit['ts'];
  690. $aSession = $this->_findSession($aClientRow['ids'], $aClientRow['platform'], $iTime - $this->_oConfig->getSessionTimeout(), $iTime + 60, $iTime);
  691. return !empty($aSession) && $this->_isSessionError($aSession, $aHit) ? $aSession : [];
  692. }
  693. /**
  694. * Store error hits under their groups. One that belongs to a visit (the running one, or
  695. * an earlier one it happened in) raises that visit's error count, and is dropped past the
  696. * limit per visit.
  697. *
  698. * @param array $aSession the running visit, or [] for none
  699. * @param array $aErrors error hits from _getHits
  700. * @param array $aClientRow visitor ids (ids, see _getVisitor), platform and client columns for errors outside a visit
  701. * @return int hits stored
  702. */
  703. protected function _storeErrors($aSession, $aErrors, $aClientRow)
  704. {
  705. $aRows = [];
  706. foreach($aErrors as $aHit) {
  707. $aVisit = $this->_isSessionError($aSession, $aHit) ? $aSession : $this->_getErrorSession($aClientRow, $aHit);
  708. $bLinked = !empty($aVisit);
  709. if($bLinked && !$this->_oDb->addSessionErrors($aVisit['id'], 1, $this->_oConfig->CNF['MAX_ERRORS_PER_SESSION']))
  710. continue;
  711. $iErrorId = $this->_getErrorGroup($aHit['error'], $aHit['ts']);
  712. if(!$iErrorId)
  713. continue;
  714. $aSource = $bLinked ? $aVisit : $aClientRow;
  715. $aRows[] = [
  716. 'error_id' => $iErrorId,
  717. 'ts' => $aHit['ts'],
  718. 'session_id' => $bLinked ? (int)$aVisit['id'] : 0,
  719. 'visitor' => $aSource['visitor'],
  720. 'profile_id' => (int)$aSource['profile_id'],
  721. 'platform' => $aSource['platform'],
  722. 'app_version' => $aSource['app_version'],
  723. 'browser' => $aSource['browser'],
  724. 'os' => $aSource['os'],
  725. 'device' => $aSource['device'],
  726. 'country' => $aSource['country'],
  727. 'path' => $aHit['path'],
  728. 'route' => $aHit['route'],
  729. 'repeats' => $aHit['error']['repeats'],
  730. 'fatal' => $aHit['error']['fatal'],
  731. ];
  732. }
  733. return $this->_oDb->insertErrorHits($aRows);
  734. }
  735. /**
  736. * The id of an error's group, created when it is new. Past the limit of new groups an
  737. * hour, a new error goes to one catch-all group, so a flood of random messages can't fill
  738. * the table. A resolved error that happens again after it was resolved is open again.
  739. */
  740. protected function _getErrorGroup($aError, $iTime)
  741. {
  742. $sFingerprint = BxAnalyticsErrors::getFingerprint($aError);
  743. $aGroup = $this->_oDb->getErrorGroupByFingerprint($sFingerprint);
  744. if(empty($aGroup) && $this->_oDb->getErrorGroupsCreated($this->_iNow - 3600, $this->_iNow) >= $this->_oConfig->CNF['MAX_NEW_ERROR_GROUPS_PER_HOUR']) {
  745. $aError = ['repeats' => $aError['repeats']] + BxAnalyticsErrors::getOverflowError();
  746. $sFingerprint = BxAnalyticsErrors::getFingerprint($aError);
  747. $aGroup = $this->_oDb->getErrorGroupByFingerprint($sFingerprint);
  748. }
  749. $iId = $this->_oDb->saveErrorGroup($sFingerprint, $aError, $iTime, $this->_iNow, !empty($aGroup) ? (int)$aGroup['id'] : 0);
  750. if($iId && !empty($aGroup) && $aGroup['status'] == 'resolved' && $iTime > (int)$aGroup['status_changed'])
  751. $this->_oDb->setErrorStatus($iId, 'open', $this->_iNow, 'resolved');
  752. return $iId;
  753. }
  754. /**
  755. * Validated hits from a payload. A hit without a usable URL, or from another site when
  756. * `$bSameOrigin`, is dropped, and so is one on an excluded path.
  757. */
  758. protected function _getHits($aPayload, $bSameOrigin)
  759. {
  760. $aInput = isset($aPayload['hits']) && is_array($aPayload['hits']) ? array_values($aPayload['hits']) : [$aPayload];
  761. $aInput = array_slice($aInput, 0, $this->_oConfig->CNF['MAX_HITS_PER_REQUEST']);
  762. $aInternalHosts = $this->_oConfig->getInternalHosts();
  763. $aExcludedPaths = $this->_oConfig->getExcludedPaths();
  764. $sPlatform = isset($aPayload['platform']) ? $aPayload['platform'] : '';
  765. $sApp = $this->_string($aPayload, 'app', 64, '/^[a-zA-Z0-9._ -]+$/');
  766. $aTypes = [BX_ANALYTICS_HIT_PAGEVIEW, BX_ANALYTICS_HIT_EVENT, BX_ANALYTICS_HIT_LEAVE];
  767. if($this->_oConfig->isTrackErrors())
  768. $aTypes[] = BX_ANALYTICS_HIT_ERROR;
  769. if($this->_oConfig->isTrackVitals())
  770. $aTypes[] = BX_ANALYTICS_HIT_VITALS;
  771. $aLimits = [
  772. BX_ANALYTICS_HIT_ERROR => $this->_oConfig->CNF['MAX_ERRORS_PER_REQUEST'],
  773. BX_ANALYTICS_HIT_VITALS => $this->_oConfig->CNF['MAX_VITALS_PER_REQUEST'],
  774. ];
  775. $aHits = [];
  776. foreach($aInput as $aHit) {
  777. if(!is_array($aHit))
  778. continue;
  779. $sType = isset($aHit['type']) && is_string($aHit['type']) ? $aHit['type'] : BX_ANALYTICS_HIT_PAGEVIEW;
  780. if(!in_array($sType, $aTypes, true))
  781. continue;
  782. if(isset($aLimits[$sType]) && $aLimits[$sType]-- <= 0)
  783. continue;
  784. $aUrl = $this->_parseUrl(isset($aHit['url']) && is_string($aHit['url']) ? $aHit['url'] : '');
  785. if($aUrl === false)
  786. continue;
  787. // native apps send paths; their "host" is the app
  788. if($aUrl['hostname'] === '' && $this->_oConfig->isAppPlatform($sPlatform))
  789. $aUrl['hostname'] = mb_strtolower($sApp);
  790. if($bSameOrigin && !in_array($aUrl['hostname'], array_map('strtolower', $aInternalHosts), true))
  791. continue;
  792. if($this->_isExcludedPath($aUrl['path'], $aExcludedPaths))
  793. continue;
  794. $sName = '';
  795. if($sType == BX_ANALYTICS_HIT_EVENT) {
  796. $sName = $this->_string($aHit, 'name', 64, '/^[\p{L}\p{N} _.:\/-]+$/u');
  797. if($sName === '')
  798. continue;
  799. }
  800. // an error may come late: an app sends the one that stopped it when it starts again
  801. $iMaxAge = $sType == BX_ANALYTICS_HIT_ERROR ? $this->_oConfig->CNF['MAX_ERROR_AGE'] : 3600;
  802. $iAge = isset($aHit['age']) && is_numeric($aHit['age']) ? (int)$aHit['age'] : 0;
  803. if($sType == BX_ANALYTICS_HIT_ERROR && $iAge >= $iMaxAge * 1000)
  804. continue;
  805. $iAge = $iAge > 0 && $iAge < $iMaxAge * 1000 ? intdiv($iAge, 1000) : 0;
  806. $aHealth = [];
  807. if($sType == BX_ANALYTICS_HIT_ERROR) {
  808. $aHealth = ['error' => $this->_getErrorInput($aHit)];
  809. if($aHealth['error'] === false)
  810. continue;
  811. }
  812. elseif($sType == BX_ANALYTICS_HIT_VITALS) {
  813. $aHealth = ['vitals' => $this->_getVitalsInput($aHit)];
  814. if($aHealth['vitals'] === false)
  815. continue;
  816. }
  817. // errors and timings keep only the page parameters of a query string, whatever
  818. // the client sent
  819. if(!empty($aHealth)) {
  820. $aUrl['path'] = get_mb_substr(BxAnalyticsErrors::getCleanPath($aUrl['path']), 0, 255);
  821. $aHealth['route'] = BxAnalyticsErrors::getRoute($aUrl['path']);
  822. }
  823. $aProps = isset($aHit['props']) && is_array($aHit['props']) ? $this->_getProps($aHit['props']) : [];
  824. $aHits[] = $aHealth + [
  825. 'type' => $sType,
  826. 'name' => $sName,
  827. 'url' => $aUrl['url'],
  828. 'hostname' => $aUrl['hostname'],
  829. 'path' => $aUrl['path'],
  830. 'title' => $this->_string($aHit, 'title', 255),
  831. 'module' => $this->_string($aHit, 'module', 32, '/^[a-z0-9_]+$/'),
  832. 'object_id' => isset($aHit['object_id']) ? max(0, (int)$aHit['object_id']) : 0,
  833. 'context_id' => isset($aHit['context_id']) ? abs((int)$aHit['context_id']) : 0,
  834. 'value' => isset($aHit['value']) && is_numeric($aHit['value']) ? (float)$aHit['value'] : 0,
  835. 'engaged' => isset($aHit['engaged']) ? min(max(0, (int)$aHit['engaged']), $this->_oConfig->CNF['MAX_ENGAGED_MS']) : 0,
  836. 'props' => empty($aProps) ? '' : json_encode($aProps, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES),
  837. 'non_interactive' => !empty($aHit['non_interactive']),
  838. 'ts' => $this->_iNow - $iAge,
  839. ];
  840. }
  841. return $aHits;
  842. }
  843. /**
  844. * An error hit's fields, sanitized, or false for noise, an ignored message or nothing usable.
  845. */
  846. protected function _getErrorInput($aHit)
  847. {
  848. $sKind = isset($aHit['kind']) && is_string($aHit['kind']) && $aHit['kind'] !== '' ? $aHit['kind'] : 'js';
  849. if(!in_array($sKind, BxAnalyticsErrors::KINDS, true))
  850. return false;
  851. $aHosts = $this->_oConfig->getInternalHosts();
  852. $fText = function($sKey, $iInput) use ($aHit) {
  853. return isset($aHit[$sKey]) && is_string($aHit[$sKey]) ? get_mb_substr($aHit[$sKey], 0, $iInput) : '';
  854. };
  855. $iStatus = $sKind == 'http' && isset($aHit['status']) && is_numeric($aHit['status']) ? min(599, max(0, (int)$aHit['status'])) : 0;
  856. $sMessage = BxAnalyticsErrors::sanitize($fText('message', BxAnalyticsErrors::MAX_MESSAGE_INPUT), BxAnalyticsErrors::MAX_MESSAGE, $aHosts);
  857. if($sMessage === '' && $iStatus)
  858. $sMessage = 'HTTP ' . $iStatus;
  859. if($sMessage === '')
  860. return false;
  861. $sSource = BxAnalyticsErrors::sanitize($fText('source', 2048), BxAnalyticsErrors::MAX_SOURCE, $aHosts);
  862. $sStack = BxAnalyticsErrors::sanitizeStack($fText('stack', BxAnalyticsErrors::MAX_STACK_INPUT), $fText('component_stack', BxAnalyticsErrors::MAX_STACK_INPUT), $aHosts);
  863. if(BxAnalyticsErrors::isNoise($sMessage, $sStack, $sSource) || BxAnalyticsErrors::isIgnored($sMessage, $this->_oConfig->getErrorsIgnore()))
  864. return false;
  865. return [
  866. 'kind' => $sKind,
  867. 'message' => $sMessage,
  868. 'source' => $sSource,
  869. 'line' => isset($aHit['line']) && is_numeric($aHit['line']) ? min(10000000, max(0, (int)$aHit['line'])) : 0,
  870. 'col' => isset($aHit['col']) && is_numeric($aHit['col']) ? min(10000000, max(0, (int)$aHit['col'])) : 0,
  871. 'status' => $iStatus,
  872. 'stack' => $sStack,
  873. 'fatal' => !empty($aHit['fatal']) ? 1 : 0,
  874. 'repeats' => isset($aHit['repeats']) && is_numeric($aHit['repeats']) ? min(100, max(1, (int)$aHit['repeats'])) : 1,
  875. ];
  876. }
  877. /**
  878. * A vitals hit's navigation type and the metrics in range, or false when none is.
  879. */
  880. protected function _getVitalsInput($aHit)
  881. {
  882. if(!isset($aHit['metrics']) || !is_array($aHit['metrics']))
  883. return false;
  884. $aMetrics = [];
  885. foreach(BxAnalyticsVitals::METRICS as $sMetric) {
  886. $mixedValue = isset($aHit['metrics'][$sMetric]) ? BxAnalyticsVitals::getValue($sMetric, $aHit['metrics'][$sMetric]) : null;
  887. if($mixedValue !== null)
  888. $aMetrics[$sMetric] = $mixedValue;
  889. }
  890. if(empty($aMetrics))
  891. return false;
  892. $sNav = isset($aHit['nav']) && is_string($aHit['nav']) && in_array($aHit['nav'], BxAnalyticsVitals::NAVS, true) ? $aHit['nav'] : '';
  893. return ['nav' => $sNav, 'metrics' => $aMetrics];
  894. }
  895. /**
  896. * Browser, system and device of the client, or false for a bot. Browsers are read from the
  897. * user agent; native apps report their system and device themselves.
  898. */
  899. protected function _getClient($sPlatform, $aPayload, $sUserAgent)
  900. {
  901. if(!$this->_oConfig->isAppPlatform($sPlatform)) {
  902. $aUserAgent = BxAnalyticsUserAgent::parse($sUserAgent);
  903. if($aUserAgent['bot'] && $sPlatform != BX_ANALYTICS_PLATFORM_API)
  904. return false;
  905. return $aUserAgent;
  906. }
  907. $sDevice = $this->_string($aPayload, 'device', 16, '/^[a-z]+$/');
  908. if(!in_array($sDevice, [BxAnalyticsUserAgent::DEVICE_MOBILE, BxAnalyticsUserAgent::DEVICE_TABLET, BxAnalyticsUserAgent::DEVICE_DESKTOP, BxAnalyticsUserAgent::DEVICE_TV], true))
  909. $sDevice = BxAnalyticsUserAgent::DEVICE_MOBILE;
  910. $sOs = $this->_string($aPayload, 'os', 32, '/^[a-zA-Z0-9 ]+$/');
  911. if($sOs === '')
  912. $sOs = $sPlatform == BX_ANALYTICS_PLATFORM_RN_IOS ? ($sDevice == BxAnalyticsUserAgent::DEVICE_TABLET ? 'iPadOS' : 'iOS') : 'Android';
  913. return [
  914. 'browser' => '',
  915. 'browser_version' => '',
  916. 'os' => $sOs,
  917. 'os_version' => $this->_string($aPayload, 'os_version', 16, '/^[0-9.]+$/'),
  918. 'device' => $sDevice,
  919. 'bot' => false,
  920. ];
  921. }
  922. protected function _isExcludedProfile($iProfileId)
  923. {
  924. $aExcluded = $this->_oConfig->getExcludedLevels();
  925. if(empty($aExcluded))
  926. return false;
  927. $aMembership = BxDolAcl::getInstance()->getMemberMembershipInfo($iProfileId);
  928. return !empty($aMembership['id']) && in_array((int)$aMembership['id'], $aExcluded, true);
  929. }
  930. /**
  931. * Excluded paths are prefixes, or patterns with * for any characters.
  932. */
  933. protected function _isExcludedPath($sPath, $aExcluded)
  934. {
  935. foreach($aExcluded as $sPattern) {
  936. if(bx_mb_strpos($sPattern, '*') === false) {
  937. if(bx_mb_strpos($sPath, $sPattern) === 0)
  938. return true;
  939. continue;
  940. }
  941. if(preg_match('/^' . str_replace('\*', '.*', preg_quote($sPattern, '/')) . '$/', $sPath))
  942. return true;
  943. }
  944. return false;
  945. }
  946. /**
  947. * Host, path and full URL of a hit. The path keeps its query string, because classic
  948. * pages are told apart by it (page.php?i=view-post&id=5), but drops tracking parameters
  949. * and secrets (a reset key, a confirmation code, a token), and what is left is redacted
  950. * as error paths are (addresses, tokens, long hashes).
  951. *
  952. * @return array|false
  953. */
  954. protected function _parseUrl($sUrl)
  955. {
  956. $sUrl = trim($sUrl);
  957. if($sUrl === '' || get_mb_len($sUrl) > 2048)
  958. return false;
  959. $aParts = parse_url($sUrl);
  960. if($aParts === false)
  961. return false;
  962. if(isset($aParts['scheme']) && !in_array(mb_strtolower($aParts['scheme']), ['http', 'https'], true))
  963. return false;
  964. $sPath = isset($aParts['path']) && $aParts['path'] !== '' ? $aParts['path'] : '/';
  965. if($sPath[0] !== '/')
  966. $sPath = '/' . $sPath;
  967. if(!empty($aParts['query'])) {
  968. $sIgnored = $this->_getIgnoredParamsPattern();
  969. $aQuery = [];
  970. foreach(explode('&', $aParts['query']) as $sPair) {
  971. if($sPair === '')
  972. continue;
  973. $sKey = mb_strtolower(urldecode(explode('=', $sPair, 2)[0]));
  974. if(!in_array($sKey, $this->_aDropParams, true) && !$this->_isSecretParam($sKey) && ($sIgnored === '' || !preg_match($sIgnored, $sKey)))
  975. $aQuery[] = $sPair;
  976. }
  977. if(!empty($aQuery))
  978. $sPath .= '?' . implode('&', $aQuery);
  979. }
  980. return [
  981. 'url' => $sUrl,
  982. 'hostname' => isset($aParts['host']) ? mb_strtolower($aParts['host']) : '',
  983. 'path' => get_mb_substr(BxAnalyticsErrors::redact(rawurldecode($sPath)), 0, 255),
  984. ];
  985. }
  986. /**
  987. * Whether a query parameter's name says it carries a secret or a person's identity.
  988. */
  989. protected function _isSecretParam($sKey)
  990. {
  991. return in_array($sKey, $this->_aSecretParams, true) || preg_match($this->_sSecretParamsPattern, $sKey);
  992. }
  993. /**
  994. * One regular expression for the ignored query parameters setting, or ''.
  995. */
  996. protected function _getIgnoredParamsPattern()
  997. {
  998. if($this->_sIgnoredParams !== null)
  999. return $this->_sIgnoredParams;
  1000. $aPatterns = [];
  1001. foreach($this->_oConfig->getIgnoredParams() as $sParam)
  1002. $aPatterns[] = str_replace('\\*', '.*', preg_quote(mb_strtolower($sParam), '/'));
  1003. return $this->_sIgnoredParams = empty($aPatterns) ? '' : '/^(' . implode('|', $aPatterns) . ')$/u';
  1004. }
  1005. /**
  1006. * Scalar properties only, with short keys, within the size limit.
  1007. */
  1008. protected function _getProps($aProps)
  1009. {
  1010. $aResult = [];
  1011. foreach($aProps as $sKey => $mixedValue) {
  1012. if(count($aResult) >= 30)
  1013. break;
  1014. if(!is_string($sKey) || !preg_match('/^[a-zA-Z0-9_.-]{1,40}$/', $sKey))
  1015. continue;
  1016. if(is_bool($mixedValue) || is_int($mixedValue) || is_float($mixedValue))
  1017. $aResult[$sKey] = $mixedValue;
  1018. elseif(is_string($mixedValue))
  1019. $aResult[$sKey] = get_mb_substr($mixedValue, 0, 255);
  1020. }
  1021. while(!empty($aResult) && get_mb_len(json_encode($aResult, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES)) > $this->_oConfig->CNF['MAX_PROPS_LENGTH'])
  1022. array_pop($aResult);
  1023. return $aResult;
  1024. }
  1025. /**
  1026. * Count a request's hits against the address's limit for the minute. Past the limit the
  1027. * hits are dropped, and the request is answered as usual (the classic tracker's 204,
  1028. * `stored: 0` over the API) rather than with 429: NEO stops sending for the rest of the
  1029. * visit on any answer but 200, and a short burst (queued hits sent at once, an office or
  1030. * a school behind one address) shouldn't silence real visitors once it is over. The
  1031. * next minute counts anew. Hits dropped still count, so a flood stays over the limit for
  1032. * the whole minute.
  1033. *
  1034. * @return bool whether the hits may be stored
  1035. */
  1036. protected function _isWithinHitLimit($sIp, $iHits)
  1037. {
  1038. $iLimit = $this->_oConfig->getLimitHitsPerMinute();
  1039. if(!$iLimit || !$iHits)
  1040. return true;
  1041. // a counter that couldn't be written (0) doesn't stop tracking
  1042. $iCount = $this->_oDb->addLimitHits($this->_getLimitKey('hits', $sIp), $this->_getLimitBucket(60), $iHits);
  1043. if($iCount > $iLimit)
  1044. $this->_bLimited = true;
  1045. return !$this->_bLimited;
  1046. }
  1047. /**
  1048. * Count a new visit against the address's limit for the hour.
  1049. *
  1050. * @return bool whether the visit may start
  1051. */
  1052. protected function _isWithinVisitLimit($sIp)
  1053. {
  1054. $iLimit = $this->_oConfig->getLimitVisitsPerHour();
  1055. if(!$iLimit)
  1056. return true;
  1057. $iCount = $this->_oDb->addLimitHits($this->_getLimitKey('visits', $sIp), $this->_getLimitBucket(3600), 1);
  1058. if($iCount > $iLimit)
  1059. $this->_bLimited = true;
  1060. return !$this->_bLimited;
  1061. }
  1062. /**
  1063. * Whether an address is at a limit now: its next hit, or its next new visit, would be
  1064. * dropped. Reads the counters without adding to them.
  1065. */
  1066. protected function _isOverLimit($sIp)
  1067. {
  1068. $iHits = $this->_oConfig->getLimitHitsPerMinute();
  1069. if($iHits && $this->_oDb->getLimitHits($this->_getLimitKey('hits', $sIp), $this->_getLimitBucket(60)) >= $iHits)
  1070. return true;
  1071. $iVisits = $this->_oConfig->getLimitVisitsPerHour();
  1072. return $iVisits && $this->_oDb->getLimitHits($this->_getLimitKey('visits', $sIp), $this->_getLimitBucket(3600)) >= $iVisits;
  1073. }
  1074. /**
  1075. * Start of the running minute or hour.
  1076. */
  1077. protected function _getLimitBucket($iSeconds)
  1078. {
  1079. return $this->_iNow - $this->_iNow % $iSeconds;
  1080. }
  1081. /**
  1082. * 8-byte key of a rate limit counter: the kind and the address hashed with the day's
  1083. * salt, so the table never holds an address. An IPv6 address counts by its /64 network,
  1084. * which one device or home usually has to itself and can change addresses within at will.
  1085. */
  1086. protected function _getLimitKey($sKind, $sIp)
  1087. {
  1088. $sAddress = (string)$sIp;
  1089. if(filter_var($sAddress, FILTER_VALIDATE_IP, FILTER_FLAG_IPV6)) {
  1090. $sBinary = inet_pton($sAddress);
  1091. // an IPv4 address written as IPv6 (::ffff:1.2.3.4) is that IPv4 address
  1092. if(substr($sBinary, 0, 12) === str_repeat("\0", 10) . "\xff\xff")
  1093. $sAddress = inet_ntop(substr($sBinary, 12));
  1094. else
  1095. $sAddress = bin2hex(substr($sBinary, 0, 8)) . '/64';
  1096. }
  1097. return substr($this->_hash('limit:' . $sKind . '|' . $sAddress, $this->_getSalt(gmdate('Y-m-d', $this->_iNow))), 0, 8);
  1098. }
  1099. protected function _getSalt($sDay)
  1100. {
  1101. $this->_loadSalts([$sDay]);
  1102. return $this->_aSalts[$sDay];
  1103. }
  1104. /**
  1105. * Read the salts of some days in one query, for this collector's request.
  1106. */
  1107. protected function _loadSalts($aDays)
  1108. {
  1109. $aMissing = array_values(array_diff($aDays, array_keys($this->_aSalts)));
  1110. if(!empty($aMissing))
  1111. $this->_aSalts = array_merge($this->_aSalts, $this->_oDb->getSalts($aMissing));
  1112. }
  1113. /**
  1114. * 16-byte visitor id.
  1115. */
  1116. protected function _hash($sValue, $sKey)
  1117. {
  1118. // a binary digest, not text
  1119. return substr(hash_hmac('sha256', $sValue, $sKey, true), 0, 16);
  1120. }
  1121. protected function _string($a, $sKey, $iMaxLength, $sPattern = '')
  1122. {
  1123. if(!isset($a[$sKey]) || !is_string($a[$sKey]))
  1124. return '';
  1125. $s = trim(preg_replace('/[\x00-\x1F\x7F]+/u', ' ', $a[$sKey]));
  1126. $s = get_mb_substr($s, 0, $iMaxLength);
  1127. if($s === '' || ($sPattern && !preg_match($sPattern, $s)))
  1128. return '';
  1129. return $s;
  1130. }
  1131. }
  1132. /** @} */