BxAnalyticsModule.php 98 KB

1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586878889909192939495969798991001011021031041051061071081091101111121131141151161171181191201211221231241251261271281291301311321331341351361371381391401411421431441451461471481491501511521531541551561571581591601611621631641651661671681691701711721731741751761771781791801811821831841851861871881891901911921931941951961971981992002012022032042052062072082092102112122132142152162172182192202212222232242252262272282292302312322332342352362372382392402412422432442452462472482492502512522532542552562572582592602612622632642652662672682692702712722732742752762772782792802812822832842852862872882892902912922932942952962972982993003013023033043053063073083093103113123133143153163173183193203213223233243253263273283293303313323333343353363373383393403413423433443453463473483493503513523533543553563573583593603613623633643653663673683693703713723733743753763773783793803813823833843853863873883893903913923933943953963973983994004014024034044054064074084094104114124134144154164174184194204214224234244254264274284294304314324334344354364374384394404414424434444454464474484494504514524534544554564574584594604614624634644654664674684694704714724734744754764774784794804814824834844854864874884894904914924934944954964974984995005015025035045055065075085095105115125135145155165175185195205215225235245255265275285295305315325335345355365375385395405415425435445455465475485495505515525535545555565575585595605615625635645655665675685695705715725735745755765775785795805815825835845855865875885895905915925935945955965975985996006016026036046056066076086096106116126136146156166176186196206216226236246256266276286296306316326336346356366376386396406416426436446456466476486496506516526536546556566576586596606616626636646656666676686696706716726736746756766776786796806816826836846856866876886896906916926936946956966976986997007017027037047057067077087097107117127137147157167177187197207217227237247257267277287297307317327337347357367377387397407417427437447457467477487497507517527537547557567577587597607617627637647657667677687697707717727737747757767777787797807817827837847857867877887897907917927937947957967977987998008018028038048058068078088098108118128138148158168178188198208218228238248258268278288298308318328338348358368378388398408418428438448458468478488498508518528538548558568578588598608618628638648658668678688698708718728738748758768778788798808818828838848858868878888898908918928938948958968978988999009019029039049059069079089099109119129139149159169179189199209219229239249259269279289299309319329339349359369379389399409419429439449459469479489499509519529539549559569579589599609619629639649659669679689699709719729739749759769779789799809819829839849859869879889899909919929939949959969979989991000100110021003100410051006100710081009101010111012101310141015101610171018101910201021102210231024102510261027102810291030103110321033103410351036103710381039104010411042104310441045104610471048104910501051105210531054105510561057105810591060106110621063106410651066106710681069107010711072107310741075107610771078107910801081108210831084108510861087108810891090109110921093109410951096109710981099110011011102110311041105110611071108110911101111111211131114111511161117111811191120112111221123112411251126112711281129113011311132113311341135113611371138113911401141114211431144114511461147114811491150115111521153115411551156115711581159116011611162116311641165116611671168116911701171117211731174117511761177117811791180118111821183118411851186118711881189119011911192119311941195119611971198119912001201120212031204120512061207120812091210121112121213121412151216121712181219122012211222122312241225122612271228122912301231123212331234123512361237123812391240124112421243124412451246124712481249125012511252125312541255125612571258125912601261126212631264126512661267126812691270127112721273127412751276127712781279128012811282128312841285128612871288128912901291129212931294129512961297129812991300130113021303130413051306130713081309131013111312131313141315131613171318131913201321132213231324132513261327132813291330133113321333133413351336133713381339134013411342134313441345134613471348134913501351135213531354135513561357135813591360136113621363136413651366136713681369137013711372137313741375137613771378137913801381138213831384138513861387138813891390139113921393139413951396139713981399140014011402140314041405140614071408140914101411141214131414141514161417141814191420142114221423142414251426142714281429143014311432143314341435143614371438143914401441144214431444144514461447144814491450145114521453145414551456145714581459146014611462146314641465146614671468146914701471147214731474147514761477147814791480148114821483148414851486148714881489149014911492149314941495149614971498149915001501150215031504150515061507150815091510151115121513151415151516151715181519152015211522152315241525152615271528152915301531153215331534153515361537153815391540154115421543154415451546154715481549155015511552155315541555155615571558155915601561156215631564156515661567156815691570157115721573157415751576157715781579158015811582158315841585158615871588158915901591159215931594159515961597159815991600160116021603160416051606160716081609161016111612161316141615161616171618161916201621162216231624162516261627162816291630163116321633163416351636163716381639164016411642164316441645164616471648164916501651165216531654165516561657165816591660166116621663166416651666166716681669167016711672167316741675167616771678167916801681168216831684168516861687168816891690169116921693169416951696169716981699170017011702170317041705170617071708170917101711171217131714171517161717171817191720172117221723172417251726172717281729173017311732173317341735173617371738173917401741174217431744174517461747174817491750175117521753175417551756175717581759176017611762176317641765176617671768176917701771177217731774177517761777177817791780178117821783178417851786178717881789179017911792179317941795179617971798179918001801180218031804180518061807180818091810181118121813181418151816181718181819182018211822182318241825182618271828182918301831183218331834183518361837183818391840184118421843184418451846184718481849185018511852185318541855185618571858185918601861186218631864186518661867186818691870187118721873187418751876187718781879188018811882188318841885188618871888188918901891189218931894189518961897189818991900190119021903190419051906190719081909191019111912191319141915191619171918191919201921192219231924192519261927192819291930193119321933193419351936193719381939194019411942194319441945194619471948194919501951195219531954195519561957195819591960196119621963196419651966196719681969197019711972197319741975197619771978197919801981198219831984198519861987198819891990199119921993199419951996199719981999200020012002200320042005200620072008200920102011201220132014201520162017201820192020202120222023202420252026202720282029203020312032203320342035203620372038203920402041204220432044204520462047204820492050205120522053205420552056205720582059206020612062206320642065206620672068206920702071207220732074207520762077207820792080208120822083208420852086208720882089209020912092209320942095209620972098209921002101210221032104210521062107210821092110211121122113211421152116211721182119212021212122212321242125212621272128212921302131213221332134213521362137213821392140214121422143214421452146214721482149215021512152215321542155215621572158215921602161216221632164216521662167216821692170217121722173217421752176217721782179218021812182218321842185218621872188218921902191219221932194219521962197219821992200220122022203220422052206220722082209221022112212221322142215221622172218221922202221222222232224222522262227222822292230223122322233223422352236223722382239224022412242224322442245224622472248224922502251225222532254225522562257225822592260226122622263226422652266226722682269227022712272227322742275227622772278227922802281228222832284228522862287228822892290229122922293229422952296229722982299230023012302230323042305230623072308230923102311231223132314231523162317231823192320232123222323232423252326232723282329233023312332233323342335233623372338233923402341234223432344234523462347234823492350235123522353235423552356235723582359236023612362236323642365236623672368236923702371237223732374237523762377237823792380238123822383238423852386238723882389239023912392239323942395239623972398239924002401240224032404240524062407240824092410241124122413241424152416241724182419242024212422242324242425242624272428242924302431243224332434
  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. * Analytics module
  13. */
  14. define('BX_ANALYTICS_SYSTEM', 'system');
  15. define('BX_ANALYTICS_TOP_BY_LIKES', 'top_by_likes');
  16. define('BX_ANALYTICS_TOP_BY_VIEWS', 'top_by_views');
  17. define('BX_ANALYTICS_CONTENT_TOTAL', 'content_total');
  18. define('BX_ANALYTICS_CONTENT_SPEED', 'content_speed');
  19. define('BX_ANALYTICS_MOST_FOLLOWED_PROFILES', 'top_by_followers');
  20. define('BX_ANALYTICS_MOST_ACTIVE_PROFILES', 'top_by_activity');
  21. class BxAnalyticsModule extends BxDolModule
  22. {
  23. protected $aColors = array('#3366CC', '#DC3912', '#FF9900', '#109618', '#990099');
  24. protected $_oReports = null;
  25. /**
  26. * Reports that blocks, Studio and the API may ask for; each is a BxAnalyticsReports method.
  27. */
  28. protected $_aReports = ['filters', 'overview', 'top', 'table', 'realtime', 'map', 'bots', 'sessions', 'users', 'events', 'journeys', 'retention', 'goals', 'funnels', 'revenue', 'subscriptions', 'sales', 'credits', 'errors', 'performance', 'query'];
  29. /**
  30. * Reports that show members by name.
  31. */
  32. protected $_aPeopleReports = ['sessions', 'users'];
  33. /**
  34. * Reports that show money and buyers.
  35. */
  36. protected $_aRevenueReports = ['revenue', 'subscriptions', 'sales', 'credits'];
  37. /**
  38. * Reports with a CSV download. None of them names members: one that does must stay
  39. * behind the same checks as getReportData (checkAllowedPeople).
  40. */
  41. protected $_aExportReports = ['query', 'table', 'top'];
  42. /**
  43. * Payment alerts (unit `system`) that keep the revenue ledger.
  44. */
  45. protected $_aPaymentAlerts = ['register_payment', 'refund_payment', 'create_subscription', 'prolong_subscription', 'overdue_subscription', 'cancel_subscription', 'cancel_order', 'finalize_checkout'];
  46. /**
  47. * Studio sections: page => list of [report, block params, spans all columns].
  48. */
  49. protected $_aStudioSections = [
  50. 'overview' => [['filters', [], true], ['overview', [], true], ['top', ['group' => 'pages']], ['top', ['group' => 'sources']], ['top', ['group' => 'locations']], ['top', ['group' => 'devices']], ['top', ['group' => 'platforms']]],
  51. 'realtime' => [['realtime', [], true]],
  52. 'pages' => [['filters', [], true], ['table', ['table' => 'pages'], true]],
  53. 'sources' => [['filters', [], true], ['table', ['table' => 'sources'], true]],
  54. 'audience' => [['filters', [], true], ['top', ['group' => 'locations']], ['top', ['group' => 'devices']], ['top', ['group' => 'platforms']]],
  55. 'map' => [['filters', [], true], ['map', [], true]],
  56. 'bots' => [['filters', [], true], ['bots', [], true]],
  57. 'sessions' => [['filters', [], true], ['sessions', [], true]],
  58. 'users' => [['filters', [], true], ['users', [], true]],
  59. 'events' => [['filters', [], true], ['events', [], true]],
  60. 'journeys' => [['filters', [], true], ['journeys', [], true]],
  61. 'retention' => [['filters', [], true], ['retention', [], true]],
  62. 'goals' => [['filters', [], true], ['goals', [], true]],
  63. 'funnels' => [['filters', [], true], ['funnels', [], true]],
  64. 'revenue' => [['filters', [], true], ['revenue', [], true]],
  65. 'subscriptions' => [['filters', [], true], ['subscriptions', [], true]],
  66. 'sales' => [['filters', [], true], ['sales', [], true]],
  67. 'credits' => [['filters', [], true], ['credits', [], true]],
  68. 'errors' => [['filters', [], true], ['errors', [], true]],
  69. 'performance' => [['filters', [], true], ['performance', [], true]],
  70. 'query' => [['filters', [], true], ['query', [], true]],
  71. ];
  72. /**
  73. * Studio sections that are admin tools, not reports: the API playground, the pages built
  74. * from analytics blocks, and the tracker setup. They show page objects, addresses and
  75. * settings, so they stay out of $_aReports, which get_report (any API client) can ask for.
  76. */
  77. protected $_aStudioTools = ['api', 'dashboards', 'setup'];
  78. /**
  79. * The page whose blocks a new analytics page starts with, and the cells copied: the
  80. * center and the right bar of layout 12, the cells NEO's dashboard tabs show.
  81. */
  82. protected $_sDashboardTemplate = 'bx_analytics_page';
  83. protected $_aDashboardCells = [2, 3];
  84. protected $_iDashboardLayout = 12;
  85. /**
  86. * The event the Setup section's test sends.
  87. */
  88. const SETUP_TEST_EVENT = 'analytics_setup_test';
  89. /**
  90. * The start of a cached report: the cache directory holds .php files, and one requested
  91. * over HTTP shows nothing past it.
  92. */
  93. const REPORT_CACHE_GUARD = "<?php exit; ?>\n";
  94. /**
  95. * Cache name of the reports' generation (not under REPORT_CACHE_PREFIX, so pruning the
  96. * expired reports keeps it) and how long it is kept: long past any report.
  97. */
  98. const REPORT_CACHE_GENERATION = 'bx_analytics_reports_generation';
  99. const REPORT_CACHE_GENERATION_TTL = 2592000;
  100. protected $_sReportCacheGeneration = null;
  101. function __construct(&$aModule)
  102. {
  103. parent::__construct($aModule);
  104. }
  105. /**
  106. * Service methods
  107. */
  108. /**
  109. * @page service Service Calls
  110. * @section bx_analytics Analytics
  111. * @subsection bx_analytics-other Other
  112. * @subsubsection bx_analytics get_canvas
  113. *
  114. * @code bx_srv('bx_analytics', 'get_canvas', [...]); @endcode
  115. *
  116. * Get canvas
  117. *
  118. * @return an html.
  119. *
  120. * @see BxAnalyticsModule::serviceGetCanvas
  121. */
  122. /**
  123. * @ref bx_analytics-get_canvas "get_canvas"
  124. */
  125. public function serviceGetCanvas()
  126. {
  127. if(bx_is_api() || $this->checkAllowed() !== CHECK_ACTION_RESULT_ALLOWED)
  128. return bx_is_api() ? [] : '';
  129. return $this->_oTemplate->getCanvas();
  130. }
  131. /**
  132. * @page service Service Calls
  133. * @section bx_analytics Analytics
  134. * @subsection bx_analytics-other Other
  135. * @subsubsection bx_analytics is_avaliable
  136. *
  137. * @code bx_srv('bx_analytics', 'is_avaliable', [...]); @endcode
  138. *
  139. * Check avalability for current profile
  140. *
  141. * @return boolean.
  142. *
  143. * @see BxAnalyticsModule::serviceIsAvaliable
  144. */
  145. /**
  146. * @ref bx_analytics-is_avaliable "is_avaliable"
  147. */
  148. public function serviceIsAvaliable()
  149. {
  150. return $this->checkAllowed() === CHECK_ACTION_RESULT_ALLOWED ? true : false;
  151. }
  152. /**
  153. * @page service Service Calls
  154. * @section bx_analytics Analytics
  155. * @subsection bx_analytics-other Other
  156. * @subsubsection bx_analytics-is_revenue_avaliable is_revenue_avaliable
  157. *
  158. * @code bx_srv('bx_analytics', 'is_revenue_avaliable'); @endcode
  159. *
  160. * Whether the current profile may see the revenue reports ("view analytics revenue",
  161. * administrators by default). Shows the Revenue item of the account dashboard menu.
  162. *
  163. * @return boolean
  164. *
  165. * @see BxAnalyticsModule::serviceIsRevenueAvaliable
  166. */
  167. /**
  168. * @ref bx_analytics-is_revenue_avaliable "is_revenue_avaliable"
  169. */
  170. public function serviceIsRevenueAvaliable()
  171. {
  172. return $this->checkAllowedRevenue() === CHECK_ACTION_RESULT_ALLOWED;
  173. }
  174. /**
  175. * @page service Service Calls
  176. * @section bx_analytics Analytics
  177. * @subsection bx_analytics-other Other
  178. * @subsubsection bx_analytics get_modules
  179. *
  180. * @code bx_srv('bx_analytics', 'get_modules', [...]); @endcode
  181. *
  182. * Get modules list
  183. *
  184. * @return an html.
  185. *
  186. * @see BxAnalyticsModule::serviceGetModules
  187. */
  188. /**
  189. * @ref bx_analytics-get_modules "get_modules"
  190. */
  191. public function serviceGetModules()
  192. {
  193. $aResult = array();
  194. $aResult[BX_ANALYTICS_SYSTEM] = _t('_bx_analytics_system_text');
  195. $BxDolModuleQuery = BxDolModuleQuery::getInstance();
  196. $aModules = $BxDolModuleQuery->getModulesBy(array('type' => 'modules', 'active' => 1));
  197. foreach($aModules as $aModule){
  198. $oModule = BxDolModule::getInstance($aModule['name']);
  199. if($oModule instanceof iBxDolContentInfoService){
  200. $aResult[$aModule['name']] = $aModule['title'];
  201. }
  202. }
  203. /**
  204. * @hooks
  205. * @hookdef hook-bx_analytics-get_modules 'bx_analytics', 'get_modules' - hook on get modules list with avalaible analytics
  206. * - $unit_name - equals `get_modules`
  207. * - $action - equals `get_modules`
  208. * - $object_id - not used
  209. * - $sender_id -not used
  210. * - $extra_params - array of additional params with the following array keys:
  211. * - `list` - [array] by ref, array of modules for analytics as [module_name] = module_title, can be overridden in hook processing
  212. * @hook @ref hook-bx_analytics-get_modules
  213. */
  214. bx_alert($this->_aModule['name'], 'get_modules', 0, 0, array(
  215. 'list' => &$aResult,
  216. 'list_ref' => &$aResult,
  217. ));
  218. return $aResult;
  219. }
  220. public function serviceGetSafeServices()
  221. {
  222. return array_merge(parent::serviceGetSafeServices(), [
  223. 'GetReport' => '',
  224. 'GetTrackerConfig' => '',
  225. ]);
  226. }
  227. public function serviceGetPublicServices()
  228. {
  229. return array_merge(parent::serviceGetPublicServices(), [
  230. 'Collect' => '',
  231. ]);
  232. }
  233. /**
  234. * @page service Service Calls
  235. * @section bx_analytics Analytics
  236. * @subsection bx_analytics-tracking Tracking
  237. * @subsubsection bx_analytics-collect collect
  238. *
  239. * @code bx_srv('bx_analytics', 'collect', [$aPayload]); @endcode
  240. * @code api.php?r=bx_analytics/collect with the JSON payload as the request body @endcode
  241. *
  242. * Store pageviews and events sent by NEO, the apps or another API client.
  243. * The payload is described in BxAnalyticsCollector. Over the API the payload is read from
  244. * the request body when no parameter is given.
  245. *
  246. * One address may send so many hits a minute and start so many visits an hour (Settings >
  247. * Tracking). Past a limit the hits are dropped, and the answer is still a success with
  248. * `stored: 0` and `limited: true`, not an error: a tracker goes on, and its hits are
  249. * stored again from the next minute. Send fewer, batched requests when you see it.
  250. *
  251. * @param $mixedPayload payload array or JSON string
  252. * @return array with `stored` - the number of hits stored; `limited` - true when they
  253. * were dropped for a rate limit
  254. *
  255. * @see BxAnalyticsModule::serviceCollect
  256. */
  257. /**
  258. * @ref bx_analytics-collect "collect"
  259. */
  260. public function serviceCollect($mixedPayload = '')
  261. {
  262. if(empty($mixedPayload))
  263. $mixedPayload = $this->_getCollectBody();
  264. if(is_string($mixedPayload) && !$this->isCollectBodyAllowed($mixedPayload))
  265. return ['stored' => 0];
  266. $aPayload = is_array($mixedPayload) ? $mixedPayload : json_decode((string)$mixedPayload, true);
  267. if(!is_array($aPayload))
  268. return ['stored' => 0];
  269. $oCollector = $this->getCollector();
  270. $iStored = $oCollector->collect($aPayload, [
  271. 'platform' => BX_ANALYTICS_PLATFORM_API,
  272. 'trusted' => $this->_isApiKeyRequest(),
  273. 'profile_id' => bx_get_logged_profile_id(),
  274. ]);
  275. return $oCollector->isLimited() ? ['stored' => 0, 'limited' => true] : ['stored' => $iStored];
  276. }
  277. /**
  278. * @page service Service Calls
  279. * @section bx_analytics Analytics
  280. * @subsection bx_analytics-tracking Tracking
  281. * @subsubsection bx_analytics-track track
  282. *
  283. * @code bx_srv('bx_analytics', 'track', ['export_done', ['rows' => 120]]); @endcode
  284. *
  285. * Record a custom event from server code, such as a finished import or a webhook. It is
  286. * stored like a site action: on the platform of the request (core for cron jobs), joined
  287. * to the member's running visit on that platform when there is one. For PHP only: not
  288. * available over the API. Don't call `collect` from server code instead, which records
  289. * the server's own address and user agent as an API client.
  290. *
  291. * @param $sName event name: up to 64 letters, digits, spaces and _ . : / -
  292. * @param $aProps properties: up to 30 keys of letters, digits, _ . -, with scalar values
  293. * @param $iProfileId who did it; 0 for the logged in member, if any
  294. * @param $fValue a number to sum in reports, such as an amount
  295. * @return bool whether it was stored (false when tracking is off, the name is invalid, or
  296. * the platform or the member's level is excluded)
  297. *
  298. * @see BxAnalyticsModule::serviceTrack
  299. */
  300. /**
  301. * @ref bx_analytics-track "track"
  302. */
  303. public function serviceTrack($sName, $aProps = [], $iProfileId = 0, $fValue = 0)
  304. {
  305. if(!is_string($sName) || !preg_match('/^[\p{L}\p{N} _.:\/-]{1,64}$/u', $sName))
  306. return false;
  307. $iProfileId = (int)$iProfileId;
  308. if(!$iProfileId)
  309. $iProfileId = (int)bx_get_logged_profile_id();
  310. return $this->getCollector()->collectAction($sName, '', 0, $iProfileId, [
  311. 'type' => BX_ANALYTICS_HIT_EVENT,
  312. 'api_key' => $this->_isApiKeyRequest(),
  313. 'props' => is_array($aProps) ? $aProps : [],
  314. 'value' => is_numeric($fValue) ? (float)$fValue : 0,
  315. ]);
  316. }
  317. /**
  318. * @page service Service Calls
  319. * @section bx_analytics Analytics
  320. * @subsection bx_analytics-tracking Tracking
  321. * @subsubsection bx_analytics-get_tracker_config get_tracker_config
  322. *
  323. * @code bx_srv('bx_analytics', 'get_tracker_config', ['rn-ios']); @endcode
  324. * @code api.php?r=bx_analytics/get_tracker_config&params[]=next-web @endcode
  325. *
  326. * Tracker settings for NEO and the apps, read once when the client starts:
  327. * whether to send hits at all, whether to keep a visitor id (persistent identity mode),
  328. * whether to honour Do Not Track, the session timeout, and what to report about the
  329. * app's health. The hits are described in BxAnalyticsCollector.
  330. *
  331. * @param $sPlatform client platform code (next-web, rn-ios, rn-android, api); when empty,
  332. * the platform of the request
  333. * @return array with
  334. * - `enabled`, `persistent`, `respect_dnt` (booleans), `session_timeout` (seconds)
  335. * - `errors` (boolean): send `error` hits
  336. * - `errors_ignore` (array of strings): messages not to send, patterns with * for any
  337. * characters, matched against the whole message, case-insensitively; the server
  338. * drops them too, so filtering on the client only saves requests
  339. * - `vitals` (boolean): send `vitals` hits
  340. * - `vitals_sample` (0 to 100): percent of page loads (app launches on native) that
  341. * measure vitals, decided once per load with a random roll from 0 to 100 below vitals_sample
  342. *
  343. * @see BxAnalyticsModule::serviceGetTrackerConfig
  344. */
  345. /**
  346. * @ref bx_analytics-get_tracker_config "get_tracker_config"
  347. */
  348. public function serviceGetTrackerConfig($sPlatform = '')
  349. {
  350. if(!is_string($sPlatform) || $sPlatform === '') {
  351. bx_import('Request', $this->_aModule);
  352. $oRequest = new BxAnalyticsRequest();
  353. $sPlatform = $oRequest->getPlatform(defined('BX_API'), $this->_isApiKeyRequest());
  354. }
  355. // `core` is the server's own platform, never a client's
  356. $bEnabled = $this->_oConfig->isTrackingEnabled() && $sPlatform != BX_ANALYTICS_PLATFORM_CORE && $this->_oConfig->isPlatformEnabled($sPlatform);
  357. return [
  358. 'enabled' => $bEnabled,
  359. 'persistent' => $bEnabled && $this->_oConfig->getIdentityMode() == BX_ANALYTICS_IDENTITY_PERSISTENT,
  360. 'respect_dnt' => $this->_oConfig->isRespectDnt(),
  361. 'session_timeout' => $this->_oConfig->getSessionTimeout(),
  362. 'errors' => $bEnabled && $this->_oConfig->isTrackErrors(),
  363. 'errors_ignore' => $this->_oConfig->getErrorsIgnore(),
  364. 'vitals' => $bEnabled && $this->_oConfig->isTrackVitals() && $this->_oConfig->getVitalsSample() > 0,
  365. 'vitals_sample' => $this->_oConfig->getVitalsSample(),
  366. ];
  367. }
  368. /**
  369. * Hits from the classic tracker: same origin, a JSON body sent with sendBeacon. Always
  370. * 204, hits dropped for a rate limit too (see BxAnalyticsCollector::_isWithinHitLimit).
  371. */
  372. public function actionCollect()
  373. {
  374. $sBody = $this->_getCollectBody();
  375. $aPayload = $sBody !== false ? json_decode($sBody, true) : null;
  376. if(is_array($aPayload))
  377. $this->getCollector()->collect($aPayload, [
  378. 'platform' => BX_ANALYTICS_PLATFORM_CORE_WEB,
  379. 'same_origin' => true,
  380. 'profile_id' => bx_get_logged_profile_id(),
  381. ]);
  382. header('HTTP/1.1 204 No Content');
  383. header('Cache-Control: no-store');
  384. exit;
  385. }
  386. /**
  387. * Whether a collect request's body is small enough to decode: no larger than a tracker
  388. * ever sends (MAX_COLLECT_BYTES). The endpoint is open to anyone, and decoding takes
  389. * memory and time before any rate limit applies.
  390. */
  391. public function isCollectBodyAllowed($sBody)
  392. {
  393. return is_string($sBody) && mb_strlen($sBody, '8bit') <= $this->_oConfig->CNF['MAX_COLLECT_BYTES'];
  394. }
  395. /**
  396. * The body of a collect request, or false when it is too large (see isCollectBodyAllowed),
  397. * told by its length header before it is read when there is one.
  398. */
  399. protected function _getCollectBody()
  400. {
  401. $iMax = $this->_oConfig->CNF['MAX_COLLECT_BYTES'];
  402. if(isset($_SERVER['CONTENT_LENGTH']) && (int)$_SERVER['CONTENT_LENGTH'] > $iMax)
  403. return false;
  404. $sBody = file_get_contents('php://input', false, null, 0, $iMax + 1);
  405. return $this->isCollectBodyAllowed($sBody) ? $sBody : false;
  406. }
  407. /**
  408. * @page service Service Calls
  409. * @section bx_analytics Analytics
  410. * @subsection bx_analytics-tracking Tracking
  411. * @subsubsection bx_analytics-get_tracker_code get_tracker_code
  412. *
  413. * @code bx_srv('bx_analytics', 'get_tracker_code', [...]); @endcode
  414. *
  415. * The classic UI tracker, injected at the end of every page.
  416. *
  417. * @return HTML string, empty when tracking is off for the classic UI or for the viewer
  418. *
  419. * @see BxAnalyticsModule::serviceGetTrackerCode
  420. */
  421. /**
  422. * @ref bx_analytics-get_tracker_code "get_tracker_code"
  423. */
  424. public function serviceGetTrackerCode()
  425. {
  426. if(!$this->_oConfig->isTrackingEnabled() || !$this->_oConfig->isPlatformEnabled(BX_ANALYTICS_PLATFORM_CORE_WEB))
  427. return '';
  428. // bots don't run the tracker: the page counts them as it renders
  429. bx_import('UserAgent', $this->_aModule);
  430. $sUserAgent = isset($_SERVER['HTTP_USER_AGENT']) ? (string)$_SERVER['HTTP_USER_AGENT'] : '';
  431. if(BxAnalyticsUserAgent::isBot($sUserAgent)) {
  432. $this->getCollector()->collectBot($sUserAgent, isset($_SERVER['REQUEST_URI']) ? (string)$_SERVER['REQUEST_URI'] : '');
  433. return '';
  434. }
  435. $aLevels = $this->_oConfig->getExcludedLevels();
  436. if(!empty($aLevels)) {
  437. $iProfileId = bx_get_logged_profile_id();
  438. $aMembership = BxDolAcl::getInstance()->getMemberMembershipInfo($iProfileId ? $iProfileId : 0);
  439. if(!empty($aMembership['id']) && in_array((int)$aMembership['id'], $aLevels, true))
  440. return '';
  441. }
  442. return $this->_oTemplate->getTrackerCode();
  443. }
  444. /**
  445. * @page service Service Calls
  446. * @section bx_analytics Analytics
  447. * @subsection bx_analytics-reports Reports
  448. * @subsubsection bx_analytics-get_report get_report
  449. *
  450. * @code bx_srv('bx_analytics', 'get_report', ['overview', ['range' => '30d', 'platform' => 'rn-ios']]); @endcode
  451. * @code api.php?r=bx_analytics/get_report&params[]=top&params[]={"group":"pages","tab":"entry","range":"7d"} @endcode
  452. *
  453. * Report data for NEO and other clients. Reports: filters, overview, top, table, realtime
  454. * and the other sections listed in BxAnalyticsModule::$_aReports. Sessions and users need
  455. * the "view analytics people" permission; revenue, subscriptions, sales and credits need
  456. * "view analytics revenue". Errors and performance need only "use analytics", but an
  457. * error's stack trace (visitor text) is left out without "view analytics people".
  458. *
  459. * @param $sReport report name
  460. * @param $mixedParams filter parameters (see BxAnalyticsFilter) and the report's own
  461. * (group, table, tab, page, limit...); an array or a JSON string
  462. * @return array report data, or an error array when the viewer may not use analytics
  463. *
  464. * @see BxAnalyticsModule::serviceGetReport
  465. */
  466. /**
  467. * @ref bx_analytics-get_report "get_report"
  468. */
  469. public function serviceGetReport($sReport, $mixedParams = [])
  470. {
  471. if(!$this->serviceIsAvaliable())
  472. return ['error' => _t('_sys_txt_access_denied'), 'code' => 403];
  473. if((in_array($sReport, $this->_aRevenueReports, true) && $this->checkAllowedRevenue() !== CHECK_ACTION_RESULT_ALLOWED) || (in_array($sReport, $this->_aPeopleReports, true) && $this->checkAllowedPeople() !== CHECK_ACTION_RESULT_ALLOWED))
  474. return ['error' => _t('_sys_txt_access_denied'), 'code' => 403];
  475. $aParams = is_array($mixedParams) ? $mixedParams : json_decode((string)$mixedParams, true);
  476. if(!is_array($aParams))
  477. $aParams = [];
  478. $aData = $this->getReportData($sReport, $aParams);
  479. if($aData === false)
  480. return ['error' => _t('_sys_request_page_not_found_cpt'), 'code' => 404];
  481. return $aData;
  482. }
  483. /**
  484. * A report block reloaded by the dashboard script: data plus rendered HTML. The block's
  485. * own parameters (group, table...) come as JSON in `bp`, the filters as the page's URL
  486. * parameters.
  487. */
  488. public function actionReport($sReport = '')
  489. {
  490. header('Content-Type: application/json; charset=utf-8');
  491. header('Cache-Control: no-store');
  492. if(!$this->serviceIsAvaliable() || (in_array($sReport, $this->_aRevenueReports, true) && $this->checkAllowedRevenue() !== CHECK_ACTION_RESULT_ALLOWED)) {
  493. http_response_code(403);
  494. echo json_encode(['error' => _t('_sys_txt_access_denied')]);
  495. exit;
  496. }
  497. $aBlockParams = json_decode((string)bx_get('bp'), true);
  498. if(!is_array($aBlockParams))
  499. $aBlockParams = [];
  500. $aParams = array_merge($_GET, $aBlockParams);
  501. unset($aParams['bp']);
  502. $aData = $this->getReportData($sReport, $aParams);
  503. if($aData === false) {
  504. http_response_code(404);
  505. echo json_encode(['error' => _t('_sys_request_page_not_found_cpt')]);
  506. exit;
  507. }
  508. echo json_encode([
  509. 'data' => $aData,
  510. 'html' => $this->_oTemplate->getReportHtml($sReport, $aData, $this->getFilter($aParams), $aBlockParams),
  511. ]);
  512. exit;
  513. }
  514. /**
  515. * A report as a CSV download: GET export/<report>/ with the page's filters and the
  516. * report's own parameters (q_dim, q_metric, q_dir for the query; table or group and tab
  517. * otherwise). Every value of the dimension, up to MAX_EXPORT_ROWS rows. Reading, so the
  518. * analytics permission is checked without counting a use of it.
  519. */
  520. public function actionExport($sReport = '')
  521. {
  522. $aExport = $this->getReportExport((string)$sReport, $_GET);
  523. if($aExport === false)
  524. $this->_outputJson(['error' => _t('_sys_txt_access_denied'), 'code' => 403]);
  525. header('Content-Type: text/csv; charset=utf-8');
  526. header('Content-Disposition: attachment; filename="' . $aExport['filename'] . '"');
  527. header('Cache-Control: no-store');
  528. header('X-Content-Type-Options: nosniff');
  529. $rOutput = fopen('php://output', 'w');
  530. BxAnalyticsCsv::write($rOutput, $aExport['header'], $aExport['rows']);
  531. fclose($rOutput);
  532. exit;
  533. }
  534. /**
  535. * @page service Service Calls
  536. * @section bx_analytics Analytics
  537. * @subsection bx_analytics-blocks Blocks
  538. * @subsubsection bx_analytics-get_block_filters get_block_filters
  539. *
  540. * @code bx_srv('bx_analytics', 'get_block_filters'); @endcode
  541. *
  542. * The filter bar: period, platform, active filters and visitors online. It keeps the
  543. * filters in the page URL, and every analytics block on the page follows them.
  544. *
  545. * @return HTML string, or a block description for the API; empty for viewers without access
  546. *
  547. * @see BxAnalyticsModule::serviceGetBlockFilters
  548. */
  549. /**
  550. * @ref bx_analytics-get_block_filters "get_block_filters"
  551. */
  552. public function serviceGetBlockFilters()
  553. {
  554. return $this->_getBlock('filters');
  555. }
  556. /**
  557. * @page service Service Calls
  558. * @section bx_analytics Analytics
  559. * @subsection bx_analytics-blocks Blocks
  560. * @subsubsection bx_analytics-get_block_overview get_block_overview
  561. *
  562. * @code bx_srv('bx_analytics', 'get_block_overview'); @endcode
  563. *
  564. * Key figures with the change against the previous period, and the main chart.
  565. *
  566. * @return HTML string, or a block description for the API; empty for viewers without access
  567. *
  568. * @see BxAnalyticsModule::serviceGetBlockOverview
  569. */
  570. /**
  571. * @ref bx_analytics-get_block_overview "get_block_overview"
  572. */
  573. public function serviceGetBlockOverview()
  574. {
  575. return $this->_getBlock('overview');
  576. }
  577. /**
  578. * @page service Service Calls
  579. * @section bx_analytics Analytics
  580. * @subsection bx_analytics-blocks Blocks
  581. * @subsubsection bx_analytics-get_block_top get_block_top
  582. *
  583. * @code bx_srv('bx_analytics', 'get_block_top', ['sources']); @endcode
  584. *
  585. * A tabbed top list: pages, sources, locations, devices or platforms.
  586. *
  587. * @param $sGroup list group
  588. * @return HTML string, or a block description for the API; empty for viewers without access
  589. *
  590. * @see BxAnalyticsModule::serviceGetBlockTop
  591. */
  592. /**
  593. * @ref bx_analytics-get_block_top "get_block_top"
  594. */
  595. public function serviceGetBlockTop($sGroup = 'pages')
  596. {
  597. return $this->_getBlock('top', ['group' => $sGroup]);
  598. }
  599. /**
  600. * @page service Service Calls
  601. * @section bx_analytics Analytics
  602. * @subsection bx_analytics-blocks Blocks
  603. * @subsubsection bx_analytics-get_block_table get_block_table
  604. *
  605. * @code bx_srv('bx_analytics', 'get_block_table', ['pages']); @endcode
  606. *
  607. * A full table with every metric, a page at a time: pages or sources.
  608. *
  609. * @param $sTable table name
  610. * @return HTML string, or a block description for the API; empty for viewers without access
  611. *
  612. * @see BxAnalyticsModule::serviceGetBlockTable
  613. */
  614. /**
  615. * @ref bx_analytics-get_block_table "get_block_table"
  616. */
  617. public function serviceGetBlockTable($sTable = 'pages')
  618. {
  619. return $this->_getBlock('table', ['table' => $sTable]);
  620. }
  621. /**
  622. * @page service Service Calls
  623. * @section bx_analytics Analytics
  624. * @subsection bx_analytics-blocks Blocks
  625. * @subsubsection bx_analytics-get_block_report get_block_report
  626. *
  627. * @code bx_srv('bx_analytics', 'get_block_report', ['realtime']); @endcode
  628. *
  629. * Any other report as a block: realtime, map, bots, sessions, users, events, goals,
  630. * funnels, journeys, retention, revenue, subscriptions, sales, credits, errors,
  631. * performance, query.
  632. *
  633. * @param $sReport report name
  634. * @param $aParams the report's own parameters
  635. * @return HTML string, or a block description for the API; empty for viewers without access
  636. *
  637. * @see BxAnalyticsModule::serviceGetBlockReport
  638. */
  639. /**
  640. * @ref bx_analytics-get_block_report "get_block_report"
  641. */
  642. public function serviceGetBlockReport($sReport, $aParams = [])
  643. {
  644. return $this->_getBlock($sReport, is_array($aParams) ? $aParams : []);
  645. }
  646. /**
  647. * A Studio section: the filter bar (where the section uses it) and the section's blocks.
  648. */
  649. public function getStudioSection($sSection)
  650. {
  651. // admin tools: not reports, so get_report and the blocks never reach them
  652. if(in_array($sSection, $this->_aStudioTools, true)) {
  653. if(!isAdmin())
  654. return '';
  655. $sMethod = '_getStudio' . bx_gen_method_name($sSection);
  656. return $this->_oTemplate->getStudioSection([['title' => '', 'content' => $this->$sMethod(), 'wide' => true]]);
  657. }
  658. if(!isset($this->_aStudioSections[$sSection]))
  659. return '';
  660. // one report under the filter bar needs no heading: the section title names it
  661. $iReports = 0;
  662. foreach($this->_aStudioSections[$sSection] as $aBlock)
  663. if($aBlock[0] != 'filters')
  664. $iReports++;
  665. $aBlocks = [];
  666. foreach($this->_aStudioSections[$sSection] as $aBlock) {
  667. list($sReport, $aParams, $bWide) = array_pad($aBlock, 3, false);
  668. $sTitle = '';
  669. if($iReports > 1 && $sReport == 'top')
  670. $sTitle = _t('_bx_analytics_group_' . $aParams['group']);
  671. elseif($iReports > 1 && !in_array($sReport, ['filters', 'overview', 'table'], true))
  672. $sTitle = _t('_bx_analytics_report_' . (isset($aParams['title']) ? $aParams['title'] : $sReport));
  673. $aBlocks[] = [
  674. 'title' => $sTitle,
  675. 'content' => $this->_getBlock($sReport, $aParams ? $aParams : []),
  676. 'wide' => (bool)$bWide,
  677. ];
  678. }
  679. return $this->_oTemplate->getStudioSection($aBlocks);
  680. }
  681. /**
  682. * A report as a page block: a description NEO renders from for the API, HTML otherwise.
  683. */
  684. protected function _getBlock($sReport, $aBlockParams = [])
  685. {
  686. if(!$this->serviceIsAvaliable() || !in_array($sReport, $this->_aReports, true))
  687. return bx_is_api() ? [] : '';
  688. if(in_array($sReport, $this->_aPeopleReports, true) && $this->checkAllowedPeople() !== CHECK_ACTION_RESULT_ALLOWED)
  689. return bx_is_api() ? [] : '';
  690. if(in_array($sReport, $this->_aRevenueReports, true) && $this->checkAllowedRevenue() !== CHECK_ACTION_RESULT_ALLOWED)
  691. return bx_is_api() ? [] : '';
  692. if(bx_is_api())
  693. return [bx_api_get_block('analytics_' . $sReport, $this->_getApiBlockData($sReport, $aBlockParams))];
  694. $aParams = array_merge($_GET, $aBlockParams);
  695. $aData = $this->getReportData($sReport, $aParams);
  696. if($aData === false)
  697. return '';
  698. return $this->_oTemplate->getBlock($sReport, $aData, $this->getFilter($aParams), $aBlockParams);
  699. }
  700. /*
  701. * Studio tools: API playground, dashboards, setup. Site administrators only.
  702. */
  703. /**
  704. * Reports the viewer may ask get_report for, with their parameters: common (the filter)
  705. * and reports => [report => parameters]. Reports with members or money are left out
  706. * without the permission to see them.
  707. */
  708. public function getApiCatalog()
  709. {
  710. $aSchema = $this->getReports()->getParamsSchema();
  711. $aReports = [];
  712. foreach($this->_aReports as $sReport)
  713. if($this->_isReportAllowed($sReport) && isset($aSchema['reports'][$sReport]))
  714. $aReports[$sReport] = $aSchema['reports'][$sReport];
  715. return ['common' => $aSchema['common'], 'reports' => $aReports];
  716. }
  717. /**
  718. * Run get_report as api.php would for the current viewer and answer with api.php's
  719. * envelope: status, module, method, params and data, or status and error. Only a report
  720. * of the catalog is run: the playground never takes a service or a method name.
  721. *
  722. * @param string $sReport report name
  723. * @param mixed $mixedParams the report's parameters, an array or a JSON object string
  724. */
  725. public function getApiResponse($sReport, $mixedParams)
  726. {
  727. if(is_array($mixedParams))
  728. $aParams = $mixedParams;
  729. else
  730. $aParams = trim((string)$mixedParams) === '' ? [] : json_decode((string)$mixedParams, true);
  731. if(!is_array($aParams) || (!empty($aParams) && array_values($aParams) === $aParams))
  732. return ['status' => 400, 'error' => _t('_bx_analytics_txt_api_bad_params')];
  733. $aCatalog = $this->getApiCatalog();
  734. if(!is_string($sReport) || !isset($aCatalog['reports'][$sReport]))
  735. return ['status' => 404, 'error' => _t('_sys_request_page_not_found_cpt')];
  736. $mixedRet = $this->serviceGetReport($sReport, $aParams);
  737. if(is_array($mixedRet) && isset($mixedRet['error']))
  738. return [
  739. 'status' => isset($mixedRet['code']) ? (int)$mixedRet['code'] : 500,
  740. 'error' => $mixedRet['error'],
  741. 'data' => '',
  742. ];
  743. return [
  744. 'status' => 200,
  745. 'module' => $this->getName(),
  746. 'method' => 'get_report',
  747. 'params' => [$sReport, (object)$aParams],
  748. 'data' => $mixedRet,
  749. ];
  750. }
  751. /**
  752. * Run a report from the API playground: POST report, params (JSON), csrf_token.
  753. */
  754. public function actionApi()
  755. {
  756. $this->_checkAdminRequest();
  757. $iStart = microtime(true);
  758. $aResponse = $this->getApiResponse((string)bx_get('report', 'post'), (string)bx_get('params', 'post'));
  759. $this->_outputJson([
  760. 'ok' => true,
  761. 'ms' => (int)round(1000 * (microtime(true) - $iStart)),
  762. 'response' => $aResponse,
  763. ]);
  764. }
  765. /**
  766. * Site pages with analytics blocks: title, blocks, blocks NEO renders, view and builder
  767. * links, and whether the page goes away with the module (created by it).
  768. */
  769. public function getDashboards()
  770. {
  771. $oPermalinks = BxDolPermalinks::getInstance();
  772. $aResult = [];
  773. foreach($this->_oDb->getPagesWithBlocks($this->getName()) as $aPage)
  774. $aResult[] = [
  775. 'object' => $aPage['object'],
  776. 'title' => _t($aPage['title']),
  777. 'blocks' => (int)$aPage['blocks'],
  778. 'api_blocks' => (int)$aPage['api_blocks'],
  779. 'view_url' => bx_absolute_url($oPermalinks->permalink($aPage['url'] !== '' ? $aPage['url'] : 'page.php?i=' . $aPage['uri'])),
  780. 'edit_url' => BX_DOL_URL_STUDIO . 'builder_page.php?type=' . rawurlencode($aPage['module']) . '&page=' . rawurlencode($aPage['object']),
  781. 'module_page' => $aPage['module'] == $this->getName(),
  782. ];
  783. return $aResult;
  784. }
  785. /**
  786. * A new site page of the module with the analytics page's blocks in the center and the
  787. * right bar, all shown in NEO. Its title is a language string in every language. Pages
  788. * of the module are removed when the module is disabled.
  789. *
  790. * @param string $sTitle page title, 1 to 100 characters
  791. * @return array|false object, uri, edit_url and view_url, or false
  792. */
  793. public function createDashboard($sTitle)
  794. {
  795. $sTitle = trim((string)$sTitle);
  796. if($sTitle === '' || get_mb_len($sTitle) > 100)
  797. return false;
  798. $sUri = uriGenerate(get_mb_substr($sTitle, 0, 50), 'sys_objects_page', 'uri', ['empty' => 'analytics']);
  799. // the object names language keys: Latin letters, digits and _ only, whatever the title
  800. $sName = trim(preg_replace('/[^a-z0-9_]+/', '_', get_mb_substr($sUri, 0, 40)), '_');
  801. $sObject = uriGenerate($this->getName() . '_' . ($sName !== '' ? $sName : 'dashboard'), 'sys_objects_page', 'object', ['divider' => '_']);
  802. if($this->_oDb->isPageObject($sObject) || $this->_oDb->isPageUri($sUri))
  803. return false;
  804. $sKey = '_' . $sObject . '_page_title';
  805. $sKeySystem = '_' . $sObject . '_page_title_sys';
  806. $oLanguages = BxDolStudioLanguagesUtils::getInstance();
  807. // disabling the module removes its pages but not their titles: keys of a page that is
  808. // gone are stale, and adding a string to an existing key fails
  809. $oLanguages->deleteLanguageString($sKey);
  810. $oLanguages->deleteLanguageString($sKeySystem);
  811. if(!$oLanguages->addLanguageString($sKey, $sTitle) || !$oLanguages->addLanguageString($sKeySystem, $sTitle)) {
  812. $oLanguages->deleteLanguageString($sKey);
  813. $oLanguages->deleteLanguageString($sKeySystem);
  814. return false;
  815. }
  816. $mixedBlocks = $this->_oDb->insertPageWithBlocks([
  817. 'object' => $sObject,
  818. 'uri' => $sUri,
  819. 'title_system' => $sKeySystem,
  820. 'title' => $sKey,
  821. 'module' => $this->getName(),
  822. 'layout_id' => $this->_iDashboardLayout,
  823. 'author' => bx_get_logged_profile_id(),
  824. ], $this->_sDashboardTemplate, $this->_aDashboardCells);
  825. // page objects and their URIs are cached
  826. foreach(['sys_pages_objects_data', 'sys_pages_uri_object_map', 'sys_pages_urirewrite_object_map'] as $sCache)
  827. $this->_oDb->cleanCache($sCache);
  828. if($mixedBlocks === false) {
  829. $oLanguages->deleteLanguageString($sKey);
  830. $oLanguages->deleteLanguageString($sKeySystem);
  831. return false;
  832. }
  833. return [
  834. 'object' => $sObject,
  835. 'uri' => $sUri,
  836. 'blocks' => $mixedBlocks,
  837. 'edit_url' => BX_DOL_URL_STUDIO . 'builder_page.php?type=' . rawurlencode($this->getName()) . '&page=' . rawurlencode($sObject),
  838. 'view_url' => bx_absolute_url(BxDolPermalinks::getInstance()->permalink('page.php?i=' . $sUri)),
  839. ];
  840. }
  841. /**
  842. * Create an analytics page: POST title, csrf_token. Answers with its builder link.
  843. */
  844. public function actionDashboardCreate()
  845. {
  846. $this->_checkAdminRequest();
  847. $aPage = $this->createDashboard((string)bx_get('title', 'post'));
  848. if($aPage === false)
  849. $this->_outputJson(['error' => _t('_bx_analytics_txt_dash_create_failed')]);
  850. $this->_outputJson(['ok' => true, 'url' => $aPage['edit_url']]);
  851. }
  852. /**
  853. * Activity per platform: last (latest hit), hits (last 24 hours), visitors (in visits
  854. * active in the last 24 hours), sessions (started in the last 7 days), app_version (the
  855. * highest seen in those visits, apps only). Kept for a minute.
  856. *
  857. * @param int $iNow the time to count back from
  858. * @param bool $bCache use and fill the minute's copy
  859. */
  860. public function getPlatformActivity($iNow, $bCache = true)
  861. {
  862. $sCache = 'bx_analytics_platform_activity';
  863. if($bCache) {
  864. $aCached = $this->_oDb->getCache($sCache, '', 60);
  865. if(is_array($aCached))
  866. return $aCached;
  867. }
  868. $iDay = $iNow - 86400;
  869. $iWeek = $iNow - 7 * 86400;
  870. $aHits = $this->_oDb->getRecentHits($iDay, $iNow);
  871. $aLast = $this->_oDb->getLastHits($iNow);
  872. $aVisitors = $this->_oDb->getActiveVisitors($iDay, $iNow);
  873. $aSessions = $this->_oDb->getStartedSessions($iWeek, $iNow);
  874. $aApps = array_values(array_filter(array_keys($this->_oConfig->getPlatforms()), [$this->_oConfig, 'isAppPlatform']));
  875. $aVersions = [];
  876. foreach($this->_oDb->getAppVersions($iWeek, $iNow, $aApps) as $aRow)
  877. if(!isset($aVersions[$aRow['platform']]) || version_compare($aRow['app_version'], $aVersions[$aRow['platform']], '>'))
  878. $aVersions[$aRow['platform']] = $aRow['app_version'];
  879. $aResult = [];
  880. foreach(array_keys($this->_oConfig->getPlatforms()) as $sPlatform) {
  881. $iLast = max(isset($aLast[$sPlatform]) ? (int)$aLast[$sPlatform] : 0, isset($aHits[$sPlatform]) ? (int)$aHits[$sPlatform]['last'] : 0);
  882. // hits outside a visit (the server's own, actions without a running visit) are
  883. // only in events: without a hit in the last 24 hours, look up the latest one
  884. if(!isset($aHits[$sPlatform]))
  885. $iLast = max($iLast, $this->_oDb->getLastEvent($sPlatform, $iNow));
  886. $aResult[$sPlatform] = [
  887. 'last' => $iLast,
  888. 'hits' => isset($aHits[$sPlatform]) ? (int)$aHits[$sPlatform]['hits'] : 0,
  889. 'visitors' => isset($aVisitors[$sPlatform]) ? (int)$aVisitors[$sPlatform] : 0,
  890. 'sessions' => isset($aSessions[$sPlatform]) ? (int)$aSessions[$sPlatform] : 0,
  891. 'app_version' => isset($aVersions[$sPlatform]) ? $aVersions[$sPlatform] : '',
  892. ];
  893. }
  894. if($bCache)
  895. $this->_oDb->setCache($sCache, $aResult, 60);
  896. return $aResult;
  897. }
  898. /**
  899. * Tracker status per platform: enabled, status (receiving: a hit in the last hour,
  900. * quiet: in the last 7 days, nodata, off) and the activity.
  901. *
  902. * @param int $iNow the time to count back from
  903. * @param bool $bCache use the minute's copy of the activity
  904. */
  905. public function getSetupStatus($iNow, $bCache = true)
  906. {
  907. $bTracking = $this->_oConfig->isTrackingEnabled();
  908. $aActivity = $this->getPlatformActivity($iNow, $bCache);
  909. $aResult = [];
  910. foreach($aActivity as $sPlatform => $aRow) {
  911. $bEnabled = $bTracking && $this->_oConfig->isPlatformEnabled($sPlatform);
  912. $sStatus = 'nodata';
  913. if(!$bEnabled)
  914. $sStatus = 'off';
  915. elseif($aRow['last'] >= $iNow - 3600)
  916. $sStatus = 'receiving';
  917. elseif($aRow['last'] >= $iNow - 7 * 86400)
  918. $sStatus = 'quiet';
  919. $aResult[$sPlatform] = ['platform' => $sPlatform, 'enabled' => $bEnabled, 'status' => $sStatus] + $aRow;
  920. }
  921. return $aResult;
  922. }
  923. /**
  924. * What the classic tracker's collector would do with a pageview of the site's home page
  925. * from the current request: a dry run that stores nothing.
  926. */
  927. public function checkRequest()
  928. {
  929. return $this->getCollector()->check($this->_getSetupPayload(BX_ANALYTICS_HIT_PAGEVIEW), $this->_getSetupContext());
  930. }
  931. /**
  932. * Dry run of the tracker for the admin's browser: POST csrf_token. Answers with the
  933. * result rendered.
  934. */
  935. public function actionCheck()
  936. {
  937. $this->_checkAdminRequest();
  938. $this->_outputJson(['ok' => true, 'html' => $this->_oTemplate->getSetupCheckHtml($this->checkRequest())]);
  939. }
  940. /**
  941. * Send a test event from the admin's browser through the real collector: POST
  942. * csrf_token. It is stored like any hit, so it adds a visit. Answers with whether it
  943. * was stored and the status table again.
  944. */
  945. public function actionTestEvent()
  946. {
  947. $this->_checkAdminRequest();
  948. $iStored = $this->getCollector()->collect($this->_getSetupPayload(BX_ANALYTICS_HIT_EVENT), $this->_getSetupContext());
  949. $this->_oDb->cleanCache('bx_analytics_platform_activity');
  950. $this->_outputJson([
  951. 'ok' => true,
  952. 'stored' => $iStored,
  953. 'message' => _t($iStored ? '_bx_analytics_txt_setup_test_stored' : '_bx_analytics_txt_setup_test_dropped'),
  954. 'html' => $this->_oTemplate->getSetupStatusHtml($this->getSetupStatus(time(), false)),
  955. ]);
  956. }
  957. /**
  958. * A classic tracker hit on the site's home page, for the Setup checks.
  959. */
  960. protected function _getSetupPayload($sType)
  961. {
  962. $aHit = ['type' => $sType, 'url' => BX_DOL_URL_ROOT, 'title' => _t('_bx_analytics_txt_setup_test_title')];
  963. if($sType == BX_ANALYTICS_HIT_EVENT)
  964. $aHit['name'] = self::SETUP_TEST_EVENT;
  965. return ['platform' => BX_ANALYTICS_PLATFORM_CORE_WEB, 'hits' => [$aHit]];
  966. }
  967. protected function _getSetupContext()
  968. {
  969. return [
  970. 'platform' => BX_ANALYTICS_PLATFORM_CORE_WEB,
  971. 'same_origin' => true,
  972. 'profile_id' => bx_get_logged_profile_id(),
  973. ];
  974. }
  975. /**
  976. * Studio > API: the playground.
  977. */
  978. protected function _getStudioApi()
  979. {
  980. return $this->_oTemplate->getStudioApi([
  981. 'catalog' => $this->getApiCatalog(),
  982. 'api_enabled' => (bool)getParam('sys_api_enable'),
  983. 'api_access' => getParam('sys_api_access_by_origin') || getParam('sys_api_access_by_key'),
  984. ]);
  985. }
  986. /**
  987. * Studio > Dashboards: pages with analytics blocks, and a new one.
  988. */
  989. protected function _getStudioDashboards()
  990. {
  991. return $this->_oTemplate->getStudioDashboards([
  992. 'pages' => $this->getDashboards(),
  993. ]);
  994. }
  995. /**
  996. * Studio > Setup: tracker status per platform, what each client needs, custom events,
  997. * and the checks of the admin's own browser.
  998. */
  999. protected function _getStudioSetup()
  1000. {
  1001. $aAlerts = [];
  1002. foreach($this->_oDb->getHandlerAlerts($this->getName()) as $aAlert) {
  1003. // the alerts that record site actions, not the ones that keep data in order; new
  1004. // content of each module (sync_actions) is said in words, not listed
  1005. if(in_array($aAlert['unit'] . ':' . $aAlert['action'], ['profile:delete', 'system:enable', 'system:disable'], true) || ($aAlert['unit'] == 'system' && in_array($aAlert['action'], $this->_aPaymentAlerts, true)) || ($aAlert['action'] == 'added' && $aAlert['unit'] != 'comment'))
  1006. continue;
  1007. $aAlerts[] = $aAlert['unit'] . ':' . $aAlert['action'];
  1008. }
  1009. return $this->_oTemplate->getStudioSetup([
  1010. 'status' => $this->getSetupStatus(time()),
  1011. 'tracking' => $this->_oConfig->isTrackingEnabled(),
  1012. 'injection' => $this->_oDb->isInjectionActive('bx_analytics_tracker'),
  1013. 'track_actions' => $this->_oConfig->isTrackActions(),
  1014. 'alerts' => $aAlerts,
  1015. 'api_enabled' => (bool)getParam('sys_api_enable'),
  1016. 'api_key' => (bool)getParam('sys_api_access_by_key'),
  1017. 'api_origin' => (bool)getParam('sys_api_access_by_origin'),
  1018. ]);
  1019. }
  1020. /**
  1021. * Whether the request is a POST from a site administrator with a valid form token: what
  1022. * every Studio tool action needs.
  1023. */
  1024. public function isAdminRequest()
  1025. {
  1026. return isAdmin() && isset($_SERVER['REQUEST_METHOD']) && $_SERVER['REQUEST_METHOD'] == 'POST' && BxDolForm::isCsrfTokenValid((string)bx_get('csrf_token', 'post'), false);
  1027. }
  1028. /**
  1029. * Answer 403 and stop, unless the request is an administrator's POST with a valid token.
  1030. */
  1031. protected function _checkAdminRequest()
  1032. {
  1033. if(!$this->isAdminRequest())
  1034. $this->_outputJson(['error' => _t('_sys_txt_access_denied'), 'code' => 403]);
  1035. }
  1036. /**
  1037. * @page service Service Calls
  1038. * @section bx_analytics Analytics
  1039. * @subsection bx_analytics-blocks Blocks
  1040. * @subsubsection bx_analytics-get_studio_dashboard_traffic get_studio_dashboard_traffic
  1041. *
  1042. * @code bx_srv('bx_analytics', 'get_studio_dashboard_traffic'); @endcode
  1043. *
  1044. * Traffic block of the Studio dashboard: visitors online on every platform and the last 7 days.
  1045. *
  1046. * @return array with `content` for the Studio dashboard
  1047. *
  1048. * @see BxAnalyticsModule::serviceGetStudioDashboardTraffic
  1049. */
  1050. /**
  1051. * @ref bx_analytics-get_studio_dashboard_traffic "get_studio_dashboard_traffic"
  1052. */
  1053. public function serviceGetStudioDashboardTraffic()
  1054. {
  1055. if(!isAdmin())
  1056. return '';
  1057. // the week's figures come from the report cache; only the visitors online are live
  1058. $aOverview = $this->getReportData('overview', ['range' => '7d']);
  1059. $aPlatforms = $this->getReportData('top', ['range' => '7d', 'group' => 'platforms', 'limit' => 5]);
  1060. if($aOverview === false || $aPlatforms === false)
  1061. return '';
  1062. return [
  1063. 'content' => $this->_oTemplate->getStudioDashboardTraffic($aOverview, $this->getReports()->getRealtime($this->getFilter(['range' => '7d']), ['limit' => 0]), $aPlatforms),
  1064. ];
  1065. }
  1066. /**
  1067. * @page service Service Calls
  1068. * @section bx_analytics Analytics
  1069. * @subsection bx_analytics-other Other
  1070. * @subsubsection bx_analytics-pruning pruning
  1071. *
  1072. * @code bx_srv('bx_analytics', 'pruning'); @endcode
  1073. *
  1074. * Hourly cron job: deletes old daily salts, then data past the retention period in
  1075. * small batches, within a time limit so other cron jobs aren't held up. The revenue
  1076. * ledger is never pruned. While past payments are being imported, the cron imports
  1077. * another step of them.
  1078. *
  1079. * @return int rows deleted
  1080. *
  1081. * @see BxAnalyticsModule::servicePruning
  1082. */
  1083. /**
  1084. * @ref bx_analytics-pruning "pruning"
  1085. */
  1086. public function servicePruning()
  1087. {
  1088. $CNF = &$this->_oConfig->CNF;
  1089. // yesterday's salt is still needed to continue visits that crossed midnight
  1090. $this->_oDb->deleteSaltsBefore(gmdate('Y-m-d', time() - 86400));
  1091. $iBefore = time() - 86400 * $this->_oConfig->getRetentionDays();
  1092. $this->_oDb->deleteBotsBefore(gmdate('Y-m-d', $iBefore));
  1093. $iDeadline = time() + $CNF['PRUNING_SECONDS'];
  1094. $iDeleted = 0;
  1095. foreach($this->getPruningCutoffs(time()) as $sMethod => $iTime)
  1096. do {
  1097. $iBatch = $this->_oDb->$sMethod($iTime, $CNF['PRUNING_BATCH']);
  1098. $iDeleted += $iBatch;
  1099. } while($iBatch >= $CNF['PRUNING_BATCH'] && time() < $iDeadline);
  1100. $this->pruneReportCache(time());
  1101. try {
  1102. $oRevenue = $this->getRevenue();
  1103. if($oRevenue->getBackfillStatus()['state'] == 'running') {
  1104. $oRevenue->backfill(time() + $CNF['BACKFILL_SECONDS']);
  1105. $this->cleanReportCache();
  1106. }
  1107. }
  1108. catch(Throwable $e) {
  1109. bx_log('sys_payments', 'bx_analytics: import of past payments failed: ' . $e->getMessage(), BX_LOG_ERR);
  1110. }
  1111. return $iDeleted;
  1112. }
  1113. /**
  1114. * What pruning deletes and from when: a BxAnalyticsDb delete method => the time before
  1115. * which its rows go. Vitals have a shorter retention of their own (a row per sampled
  1116. * page view), never longer than the rest. Rate limit counters are kept for 2 hours.
  1117. * Error groups that go on happening keep their sample stack as long as the rest.
  1118. *
  1119. * The run's time limit is shared, in this order: the small tables that grow fastest
  1120. * come first, so a backlog of events (after the retention was lowered) can't leave the
  1121. * rate limit counters growing for hours.
  1122. *
  1123. * @param int $iNow the time pruning runs at
  1124. */
  1125. public function getPruningCutoffs($iNow)
  1126. {
  1127. $iBefore = (int)$iNow - 86400 * $this->_oConfig->getRetentionDays();
  1128. return [
  1129. // the collector's rate limit counters: the last full hour and the running one
  1130. 'deleteLimitsBefore' => (int)$iNow - 7200,
  1131. 'deleteVitalsBefore' => (int)$iNow - 86400 * $this->_oConfig->getVitalsRetentionDays(),
  1132. 'deleteErrorHitsBefore' => $iBefore,
  1133. 'deleteErrorGroupsBefore' => $iBefore,
  1134. 'clearErrorStacksBefore' => $iBefore,
  1135. 'deleteEventsBefore' => $iBefore,
  1136. 'deleteSessionsBefore' => $iBefore,
  1137. ];
  1138. }
  1139. /**
  1140. * @page service Service Calls
  1141. * @section bx_analytics Analytics
  1142. * @subsection bx_analytics-tracking Tracking
  1143. * @subsubsection bx_analytics-sync_actions sync_actions
  1144. *
  1145. * @code bx_srv('bx_analytics', 'sync_actions'); @endcode
  1146. *
  1147. * Subscribe to "added" of every content module on the site, so new content becomes an
  1148. * event (comments and votes have site-wide alerts). Runs when the module is enabled and
  1149. * whenever another module is enabled or disabled.
  1150. *
  1151. * @return int subscriptions written
  1152. *
  1153. * @see BxAnalyticsModule::serviceSyncActions
  1154. */
  1155. /**
  1156. * @ref bx_analytics-sync_actions "sync_actions"
  1157. */
  1158. public function serviceSyncActions()
  1159. {
  1160. $aUnits = [];
  1161. foreach(BxDolModuleQuery::getInstance()->getModulesBy(['type' => 'modules', 'active' => 1]) as $aModule) {
  1162. $oModule = BxDolModule::getInstance($aModule['name']);
  1163. if($oModule instanceof iBxDolContentInfoService)
  1164. $aUnits[$aModule['name']][] = 'added';
  1165. }
  1166. $iWritten = $this->_oDb->setActionAlerts($this->getName(), ['added'], $aUnits, ['comment']);
  1167. BxDolAlerts::cacheInvalidate();
  1168. return $iWritten;
  1169. }
  1170. /**
  1171. * Alerts the module listens to: deleted profiles, modules switched on and off, and the
  1172. * site actions it records as events.
  1173. */
  1174. public function onAlert($oAlert)
  1175. {
  1176. $sUnit = $oAlert->sUnit;
  1177. $sAction = $oAlert->sAction;
  1178. // money: whatever goes wrong here must never break a checkout
  1179. if($sUnit == 'system' && !empty($oAlert->aExtras['module']) && in_array($sAction, $this->_aPaymentAlerts, true)) {
  1180. try {
  1181. $this->getRevenue()->onPaymentAlert($oAlert);
  1182. }
  1183. catch(Throwable $e) {
  1184. bx_log('sys_payments', 'bx_analytics: payment alert ' . $sAction . ' failed: ' . $e->getMessage(), BX_LOG_ERR);
  1185. }
  1186. return;
  1187. }
  1188. if($sUnit == 'profile' && $sAction == 'delete') {
  1189. $this->onProfileDelete($oAlert->iObject);
  1190. return;
  1191. }
  1192. if($sUnit == 'system' && in_array($sAction, ['enable', 'disable'], true)) {
  1193. if(empty($oAlert->aExtras['config']['name']) || $oAlert->aExtras['config']['name'] != $this->getName())
  1194. $this->serviceSyncActions();
  1195. return;
  1196. }
  1197. $aContext = ['api_key' => $this->_isApiKeyRequest()];
  1198. $iSender = (int)$oAlert->iSender;
  1199. switch($sAction) {
  1200. case 'add':
  1201. if($sUnit == 'account') {
  1202. $this->getCollector()->collectAction('signup', 'account', $oAlert->iObject, 0, $aContext);
  1203. return;
  1204. }
  1205. if($sUnit == 'profile') {
  1206. $sType = isset($oAlert->aExtras['module']) ? $oAlert->aExtras['module'] : '';
  1207. $this->getCollector()->collectAction('profile_add', $sType, $oAlert->iObject, $oAlert->iObject, $aContext);
  1208. }
  1209. return;
  1210. case 'login':
  1211. if($sUnit == 'account') {
  1212. $oProfile = BxDolProfile::getInstanceAccountProfile($oAlert->iObject);
  1213. $this->getCollector()->collectAction('login', 'account', $oAlert->iObject, $oProfile ? $oProfile->id() : 0, $aContext);
  1214. }
  1215. return;
  1216. case 'connection_added':
  1217. if($sUnit == 'system')
  1218. $this->getCollector()->collectAction('connection', isset($oAlert->aExtras['object_name']) ? $oAlert->aExtras['object_name'] : '', isset($oAlert->aExtras['content']) ? (int)$oAlert->aExtras['content'] : 0, $iSender, $aContext);
  1219. return;
  1220. case 'added':
  1221. // comments of any system, or new content of a module
  1222. if($sUnit == 'comment')
  1223. $this->getCollector()->collectAction('comment', isset($oAlert->aExtras['object_system']) ? $oAlert->aExtras['object_system'] : '', isset($oAlert->aExtras['object_id']) ? (int)$oAlert->aExtras['object_id'] : 0, $iSender, $aContext);
  1224. else
  1225. $this->getCollector()->collectAction('content_add', $sUnit, $oAlert->iObject, $iSender, $aContext);
  1226. return;
  1227. case 'do':
  1228. if($sUnit == 'vote') {
  1229. $aProps = [];
  1230. if(!empty($oAlert->aExtras['reaction']) && is_string($oAlert->aExtras['reaction']))
  1231. $aProps['reaction'] = $oAlert->aExtras['reaction'];
  1232. $this->getCollector()->collectAction('vote', isset($oAlert->aExtras['object_system']) ? $oAlert->aExtras['object_system'] : '', isset($oAlert->aExtras['object_id']) ? (int)$oAlert->aExtras['object_id'] : 0, $iSender, $aContext + ['props' => $aProps]);
  1233. }
  1234. return;
  1235. }
  1236. }
  1237. /**
  1238. * Forget a deleted profile: its visits stay in reports as an anonymous visitor's. Its
  1239. * payments stay in the revenue ledger, as they stay in the payment module, but lose the
  1240. * visit and the traffic sources they were tied to.
  1241. */
  1242. public function onProfileDelete($iProfileId)
  1243. {
  1244. $iProfileId = (int)$iProfileId;
  1245. if(!$iProfileId)
  1246. return 0;
  1247. $iChanged = $this->_oDb->forgetProfile($iProfileId, random_bytes(16)) + $this->_oDb->forgetRevenueProfile($iProfileId);
  1248. // cached reports may still show the member's name and link
  1249. if($iChanged)
  1250. $this->cleanReportCache();
  1251. return $iChanged;
  1252. }
  1253. public function serviceGetOptionsPlatforms()
  1254. {
  1255. $aResult = [];
  1256. foreach($this->_oConfig->getPlatforms() as $sCode => $sTitle)
  1257. $aResult[$sCode] = _t($sTitle);
  1258. return $aResult;
  1259. }
  1260. public function serviceGetOptionsIdentity()
  1261. {
  1262. return [
  1263. BX_ANALYTICS_IDENTITY_COOKIELESS => _t('_bx_analytics_identity_cookieless'),
  1264. BX_ANALYTICS_IDENTITY_PERSISTENT => _t('_bx_analytics_identity_persistent'),
  1265. ];
  1266. }
  1267. public function serviceGetOptionsGeoSource()
  1268. {
  1269. return [
  1270. BX_ANALYTICS_GEO_NONE => _t('_bx_analytics_geo_none'),
  1271. BX_ANALYTICS_GEO_HEADERS => _t('_bx_analytics_geo_headers'),
  1272. BX_ANALYTICS_GEO_CLOUDFLARE => _t('_bx_analytics_geo_cloudflare'),
  1273. BX_ANALYTICS_GEO_VERCEL => _t('_bx_analytics_geo_vercel'),
  1274. BX_ANALYTICS_GEO_CLOUDFRONT => _t('_bx_analytics_geo_cloudfront'),
  1275. ];
  1276. }
  1277. public function getCollector()
  1278. {
  1279. bx_import('UserAgent', $this->_aModule);
  1280. bx_import('Source', $this->_aModule);
  1281. bx_import('Request', $this->_aModule);
  1282. bx_import('Errors', $this->_aModule);
  1283. bx_import('Vitals', $this->_aModule);
  1284. bx_import('Collector', $this->_aModule);
  1285. return new BxAnalyticsCollector($this);
  1286. }
  1287. public function getFilter($aParams)
  1288. {
  1289. bx_import('Filter', $this->_aModule);
  1290. return new BxAnalyticsFilter($this, $aParams);
  1291. }
  1292. /**
  1293. * The revenue ledger, for the current request.
  1294. */
  1295. public function getRevenue()
  1296. {
  1297. $this->getCollector(); // loads the request, user agent and collector classes
  1298. bx_import('Revenue', $this->_aModule);
  1299. return new BxAnalyticsRevenue($this);
  1300. }
  1301. public function getReports()
  1302. {
  1303. if($this->_oReports === null) {
  1304. bx_import('Errors', $this->_aModule);
  1305. bx_import('Vitals', $this->_aModule);
  1306. bx_import('Reports', $this->_aModule);
  1307. $this->_oReports = new BxAnalyticsReports($this);
  1308. }
  1309. return $this->_oReports;
  1310. }
  1311. /**
  1312. * Report data by name, or false for an unknown report or parameters it doesn't know.
  1313. */
  1314. public function getReportData($sReport, $aParams)
  1315. {
  1316. if(!$this->_isReportAllowed($sReport))
  1317. return false;
  1318. $oReports = $this->getReports();
  1319. $sMethod = 'get' . bx_gen_method_name($sReport);
  1320. if(!method_exists($oReports, $sMethod))
  1321. return false;
  1322. $oFilter = $this->getFilter($aParams);
  1323. // the same report with the same filters and parameters, for the same kind of viewer,
  1324. // comes from the cache for a few minutes; live figures never do
  1325. $sCache = $oReports->isLive($sReport, $aParams) ? '' : $this->_getReportCacheName($sReport, $oFilter, $aParams);
  1326. if($sCache !== '') {
  1327. $aCached = $this->_getReportCache($sCache);
  1328. if(is_array($aCached))
  1329. return $aCached;
  1330. }
  1331. $aData = $oReports->$sMethod($oFilter, $aParams);
  1332. if($aData === false)
  1333. return false;
  1334. $aData['filter'] = $oFilter->getParams();
  1335. if($sCache !== '')
  1336. $this->_setReportCache($sCache, $aData);
  1337. return $aData;
  1338. }
  1339. /**
  1340. * Drop every cached report, after a change that reports show (goals, funnels, the status
  1341. * of an error, a deleted member). Report names carry a generation, and a new one leaves
  1342. * the old copies to expire: Memcache and Memcached delete by prefix by emptying the whole
  1343. * server, and Redis by a KEYS scan of every key, so nothing is deleted by prefix there.
  1344. * Files are deleted too (keys of the database cache are `db_<name>_<site hash>`, see
  1345. * BxDolDb::genDbCacheKey, so the prefix is the name's start after `db_`).
  1346. */
  1347. public function cleanReportCache()
  1348. {
  1349. $oCache = $this->_getReportCacheEngine();
  1350. if(!$oCache)
  1351. return;
  1352. if($oCache instanceof BxDolCacheFileHtml)
  1353. $oCache->removeAllByPrefix('db_' . $this->_oConfig->CNF['REPORT_CACHE_PREFIX']);
  1354. $this->_sReportCacheGeneration = sprintf('%.6F', microtime(true));
  1355. $oCache->setData($this->_oDb->genDbCacheKey(self::REPORT_CACHE_GENERATION), $this->_sReportCacheGeneration, self::REPORT_CACHE_GENERATION_TTL);
  1356. }
  1357. /**
  1358. * The generation of cached reports: changed by cleanReportCache(), '' before the first.
  1359. */
  1360. protected function _getReportCacheGeneration()
  1361. {
  1362. if($this->_sReportCacheGeneration !== null)
  1363. return $this->_sReportCacheGeneration;
  1364. $oCache = $this->_getReportCacheEngine();
  1365. $sGeneration = $oCache ? $oCache->getData($this->_oDb->genDbCacheKey(self::REPORT_CACHE_GENERATION)) : '';
  1366. return $this->_sReportCacheGeneration = is_string($sGeneration) ? $sGeneration : '';
  1367. }
  1368. /**
  1369. * Delete cached reports past their time to live, in files: a file is deleted when its own
  1370. * report is read again, and most never are (yesterday's filters, a tab opened once).
  1371. * Other cache engines expire entries themselves.
  1372. *
  1373. * @param int $iNow the current time
  1374. * @return int files deleted
  1375. */
  1376. public function pruneReportCache($iNow)
  1377. {
  1378. $oCache = $this->_getReportCacheEngine();
  1379. if(!$oCache)
  1380. $oCache = $this->_oDb->getDbCacheObject();
  1381. if(!($oCache instanceof BxDolCacheFile) || !($rDir = opendir(BX_DIRECTORY_PATH_CACHE)))
  1382. return 0;
  1383. $sPrefix = 'db_' . $this->_oConfig->CNF['REPORT_CACHE_PREFIX'];
  1384. $iBefore = (int)$iNow - $this->_oConfig->CNF['REPORT_CACHE_TTL'];
  1385. $iDeleted = 0;
  1386. while(($sFile = readdir($rDir)) !== false)
  1387. if(bx_mb_strpos($sFile, $sPrefix) === 0 && @filemtime(BX_DIRECTORY_PATH_CACHE . $sFile) < $iBefore && $oCache->delData($sFile))
  1388. $iDeleted++;
  1389. closedir($rDir);
  1390. return $iDeleted;
  1391. }
  1392. /**
  1393. * The cache of reports: the database cache's engine, but with files as plain text. The
  1394. * file engine writes PHP scripts read with include(), and opcache would keep a compiled
  1395. * copy of every report (a new one for every filter, page and day) until it ran out of
  1396. * memory. Null when the database cache is off.
  1397. */
  1398. protected function _getReportCacheEngine()
  1399. {
  1400. if(!getParam('sys_db_cache_enable'))
  1401. return null;
  1402. $oCache = $this->_oDb->getDbCacheObject();
  1403. return $oCache instanceof BxDolCacheFile ? bx_instance('BxDolCacheFileHtml') : $oCache;
  1404. }
  1405. /**
  1406. * A cached report, as JSON after REPORT_CACHE_GUARD, or null.
  1407. */
  1408. protected function _getReportCache($sName)
  1409. {
  1410. $oCache = $this->_getReportCacheEngine();
  1411. if(!$oCache)
  1412. return null;
  1413. $sData = $oCache->getData($this->_oDb->genDbCacheKey($sName), $this->_oConfig->CNF['REPORT_CACHE_TTL']);
  1414. if(!is_string($sData) || bx_mb_strpos($sData, self::REPORT_CACHE_GUARD) !== 0)
  1415. return null;
  1416. $aData = json_decode(get_mb_substr($sData, get_mb_len(self::REPORT_CACHE_GUARD), get_mb_len($sData)), true);
  1417. return is_array($aData) ? $aData : null;
  1418. }
  1419. protected function _setReportCache($sName, $aData)
  1420. {
  1421. $oCache = $this->_getReportCacheEngine();
  1422. $sData = json_encode($aData, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES | JSON_PRESERVE_ZERO_FRACTION);
  1423. if(!$oCache || $sData === false)
  1424. return false;
  1425. return $oCache->setData($this->_oDb->genDbCacheKey($sName), self::REPORT_CACHE_GUARD . $sData, $this->_oConfig->CNF['REPORT_CACHE_TTL']);
  1426. }
  1427. /**
  1428. * The cache name of a report: the report, the resolved filter (not the current second,
  1429. * so a cached copy serves the whole period), the parameters the report reads, the
  1430. * language, what the viewer may see (money, people, managing, administration), since
  1431. * reports differ by those, and the generation (see cleanReportCache).
  1432. */
  1433. protected function _getReportCacheName($sReport, $oFilter, $aParams)
  1434. {
  1435. $aFilter = $oFilter->get();
  1436. unset($aFilter['now'], $aFilter['buckets']);
  1437. $aOwn = [];
  1438. foreach($this->getReports()->getCacheParams($sReport, $aParams) as $sKey => $mixedValue)
  1439. if(is_string($sKey) && $sKey !== '' && $sKey[0] != '_')
  1440. $aOwn[$sKey] = $mixedValue;
  1441. ksort($aOwn);
  1442. $aViewer = [
  1443. isAdmin(),
  1444. $this->checkAllowedRevenue() === CHECK_ACTION_RESULT_ALLOWED,
  1445. $this->checkAllowedPeople() === CHECK_ACTION_RESULT_ALLOWED,
  1446. $this->checkAllowedManage() === CHECK_ACTION_RESULT_ALLOWED,
  1447. ];
  1448. return $this->_oConfig->CNF['REPORT_CACHE_PREFIX'] . hash('sha256', json_encode([$sReport, $aFilter, $aOwn, bx_lang_name(), $aViewer, $this->_getReportCacheGeneration()]));
  1449. }
  1450. /**
  1451. * A report's rows for a CSV download, behind the same checks as getReportData: header,
  1452. * rows, total, truncated, and filename (made of the report, the dimension and the
  1453. * period's dates only). False when the viewer may not use analytics or see the report,
  1454. * and for a report without a download. The use isn't counted: the action is countable,
  1455. * and reading a report again is not another use.
  1456. */
  1457. public function getReportExport($sReport, $aParams)
  1458. {
  1459. if(!$this->serviceIsAvaliable() || !in_array($sReport, $this->_aExportReports, true) || !$this->_isReportAllowed($sReport))
  1460. return false;
  1461. $oFilter = $this->getFilter($aParams);
  1462. $aExport = $this->getReports()->getExport($sReport, $oFilter, $aParams, $this->_oConfig->CNF['MAX_EXPORT_ROWS']);
  1463. if($aExport === false)
  1464. return false;
  1465. $aFilter = $oFilter->get();
  1466. bx_import('Csv', $this->_aModule);
  1467. $aExport['filename'] = BxAnalyticsCsv::getFilename([$sReport, $aExport['dimension'], $aFilter['from_date'], $aFilter['to_date']]);
  1468. return $aExport;
  1469. }
  1470. /**
  1471. * Whether a report exists and the viewer may see it: reports with members need "view
  1472. * analytics people", reports with money "view analytics revenue".
  1473. */
  1474. protected function _isReportAllowed($sReport)
  1475. {
  1476. if(!in_array($sReport, $this->_aReports, true))
  1477. return false;
  1478. if(in_array($sReport, $this->_aPeopleReports, true) && $this->checkAllowedPeople() !== CHECK_ACTION_RESULT_ALLOWED)
  1479. return false;
  1480. if(in_array($sReport, $this->_aRevenueReports, true) && $this->checkAllowedRevenue() !== CHECK_ACTION_RESULT_ALLOWED)
  1481. return false;
  1482. return true;
  1483. }
  1484. /**
  1485. * What an API block carries: the report and the endpoint NEO calls for its data.
  1486. */
  1487. protected function _getApiBlockData($sReport, $aParams = [])
  1488. {
  1489. $aRanges = [];
  1490. foreach(['today', 'yesterday', '24h', '7d', '30d', '90d', '12m'] as $sRange)
  1491. $aRanges[] = ['value' => $sRange, 'title' => _t('_bx_analytics_range_' . $sRange)];
  1492. $aPlatforms = [];
  1493. foreach($this->_oConfig->getPlatforms() as $sCode => $sTitle)
  1494. $aPlatforms[] = ['value' => $sCode, 'title' => _t($sTitle)];
  1495. $aMetrics = [];
  1496. foreach($this->getReports()->getMetrics() as $sMetric)
  1497. $aMetrics[$sMetric] = _t('_bx_analytics_metric_' . $sMetric);
  1498. return [
  1499. 'report' => $sReport,
  1500. 'params' => (object)$aParams,
  1501. // the element asks for its data itself, with the page's filters, from get_report
  1502. 'request_url' => 'bx_analytics/get_report',
  1503. 'ranges' => $aRanges,
  1504. 'platforms' => $aPlatforms,
  1505. 'metrics' => $aMetrics,
  1506. 'strings' => [
  1507. 'range' => _t('_bx_analytics_txt_range'),
  1508. 'platform' => _t('_bx_analytics_txt_platform'),
  1509. 'all_platforms' => _t('_bx_analytics_txt_all_platforms'),
  1510. 'online' => _t('_bx_analytics_txt_online_now', '{0}'),
  1511. 'remove_filter' => _t('_bx_analytics_txt_remove_filter', '{0}'),
  1512. 'filter_by' => _t('_bx_analytics_txt_filter_by', '{0}'),
  1513. 'previous_period' => _t('_bx_analytics_txt_previous_period', '{0}'),
  1514. 'key_figures' => _t('_bx_analytics_txt_key_figures'),
  1515. 'visitors' => _t('_bx_analytics_metric_visitors'),
  1516. 'no_data' => _t('_bx_analytics_txt_no_data'),
  1517. // NEO shows a Retry button next to it, so no "reload the page"
  1518. 'error' => _t('_bx_analytics_txt_error_report'),
  1519. 'duration_h' => _t('_bx_analytics_txt_duration_h'),
  1520. 'duration_m' => _t('_bx_analytics_txt_duration_m'),
  1521. 'duration_s' => _t('_bx_analytics_txt_duration_s'),
  1522. ],
  1523. ];
  1524. }
  1525. public function isApiKeyRequest()
  1526. {
  1527. return $this->_isApiKeyRequest();
  1528. }
  1529. /**
  1530. * The request came through api.php with an API key that api.php has already checked:
  1531. * the NEO server, whose proxy headers carry the visitor's address.
  1532. */
  1533. protected function _isApiKeyRequest()
  1534. {
  1535. if(!defined('BX_API') || !getParam('sys_api_access_by_key'))
  1536. return false;
  1537. $sAuth = isset($_SERVER['HTTP_AUTHORIZATION']) ? $_SERVER['HTTP_AUTHORIZATION'] : '';
  1538. if($sAuth === '' && function_exists('getallheaders')) {
  1539. $aHeaders = array_change_key_case((array)getallheaders(), CASE_LOWER);
  1540. $sAuth = isset($aHeaders['authorization']) ? $aHeaders['authorization'] : '';
  1541. }
  1542. return strpos($sAuth, 'Bearer ') === 0;
  1543. }
  1544. /**
  1545. * Action methods
  1546. */
  1547. public function actionGetReports($sModuleName)
  1548. {
  1549. if(!$this->serviceIsAvaliable())
  1550. return '';
  1551. header('Content-Type: application/json');
  1552. $aSystemsVote = BxDolVote::getSystems();
  1553. $aRv = array();
  1554. if ($sModuleName == BX_ANALYTICS_SYSTEM){
  1555. $aRv[BX_ANALYTICS_TOP_BY_LIKES] = _t('_bx_analytics_type_top_contents_by_likes');
  1556. $aRv[BX_ANALYTICS_TOP_BY_VIEWS] = _t('_bx_analytics_type_top_by_views');
  1557. $aRv[BX_ANALYTICS_CONTENT_TOTAL] = _t('_bx_analytics_type_accounts_growth');
  1558. $aRv[BX_ANALYTICS_CONTENT_SPEED] = _t('_bx_analytics_type_accounts_speed');
  1559. }
  1560. else{
  1561. $oModule = BxDolModule::getInstance($sModuleName);
  1562. if(BxDolRequest::serviceExists($sModuleName, 'act_as_profile') && BxDolService::call($sModuleName, 'act_as_profile') == true){
  1563. $aRv[BX_ANALYTICS_MOST_FOLLOWED_PROFILES] = _t('_bx_analytics_type_most_followed_profiles');
  1564. $aRv[BX_ANALYTICS_MOST_ACTIVE_PROFILES] = _t('_bx_analytics_type_most_active_profiles');
  1565. }
  1566. if(isset($oModule->_oConfig->CNF['OBJECT_VOTES']) && isset($aSystemsVote[$oModule->_oConfig->CNF['OBJECT_VOTES']])){
  1567. $aRv[BX_ANALYTICS_TOP_BY_LIKES] = _t('_bx_analytics_type_top_contents_by_likes');
  1568. }
  1569. if(isset($oModule->_oConfig->CNF['OBJECT_VIEWS'])){
  1570. $aRv[BX_ANALYTICS_TOP_BY_VIEWS] = _t('_bx_analytics_type_top_by_views');
  1571. }
  1572. $aRv[BX_ANALYTICS_CONTENT_TOTAL] = _t('_bx_analytics_type_content_growth');
  1573. $aRv[BX_ANALYTICS_CONTENT_SPEED] = _t('_bx_analytics_type_content_speed');
  1574. }
  1575. /**
  1576. * @hooks
  1577. * @hookdef hook-bx_analytics-get_reports 'bx_analytics', 'get_reports' - hook on get avalaible reports for specified module
  1578. * - $unit_name - equals `bx_analytics`
  1579. * - $action - equals `get_reports`
  1580. * - $object_id - not used
  1581. * - $sender_id -not used
  1582. * - $extra_params - array of additional params with the following array keys:
  1583. * - `module` - [string] module name
  1584. * - `list` - [array] by ref, array of avalaible reports for analytics as [report_name] = report_title, can be overridden in hook processing
  1585. * @hook @ref hook-bx_analytics-get_reports
  1586. */
  1587. bx_alert($this->_aModule['name'], 'get_reports', 0, 0, array(
  1588. 'module' => $sModuleName,
  1589. 'list' => &$aRv,
  1590. 'list_ref' => &$aRv,
  1591. ));
  1592. echo json_encode($aRv);
  1593. }
  1594. public function actionGetReportsData($sModuleName, $sReportName, $sDateFrom, $sDateTo, $sType = '')
  1595. {
  1596. if(!$this->serviceIsAvaliable())
  1597. return '';
  1598. if ($sType == 'csv'){
  1599. header('Content-type: text/csv');
  1600. header('Content-disposition: attachment;filename=' . $sModuleName . '_' . $sReportName . '_' . $sDateFrom . '_' . $sDateTo . '.csv');
  1601. echo $this->actionGetReportsDataCsv($sModuleName, $sReportName, $sDateFrom, $sDateTo);
  1602. }
  1603. if ($sType == ''){
  1604. header('Content-Type: application/json');
  1605. echo $this->actionGetReportsDataJson($sModuleName, $sReportName, $sDateFrom, $sDateTo);
  1606. }
  1607. }
  1608. private function actionGetReportsDataCsv($sModuleName, $sReportName, $sDateFrom, $sDateTo)
  1609. {
  1610. $sRv = '';
  1611. $sColumn = ";";
  1612. $sRow = "\n";
  1613. $sData = $this->actionGetReportsDataJson($sModuleName, $sReportName, $sDateFrom, $sDateTo);
  1614. $oRv = json_decode($sData, true);
  1615. $oData = $oRv['data'];
  1616. $sCol1 = $oRv['strings'][0];
  1617. $iColumnCount = count($oData['datasets']);
  1618. $sRv .= $sCol1 . $sColumn;
  1619. foreach($oData['datasets'] as $aItem){
  1620. $sRv .= ($aItem['label'] != '' ? $aItem['label'] : $oRv['strings'][1]) . $sColumn;
  1621. }
  1622. $sRv .= $sRow;
  1623. $iK = 0;
  1624. foreach($oData['datasets'][0]['data'] as $sDx => $aItemData){
  1625. $sTxt = "";
  1626. if (is_array($oData['datasets'][0]['data'][$sDx]))
  1627. $sTxt = $oData['datasets'][0]['data'][$sDx]['x'];
  1628. else
  1629. $sTxt = $oData['labels'][$sDx];
  1630. if (isset($oRv['links']) && count($oRv['links'])>0) {
  1631. $sTxt .= ' (' . $oRv['links'][$sDx] . ')';
  1632. }
  1633. $sRv .= $sTxt . $sColumn;
  1634. for ($i = 0; $i < $iColumnCount; $i++){
  1635. $sText = '';
  1636. if (is_array($aItemData)) {
  1637. $sText = $aItemData['y'];
  1638. }
  1639. else {
  1640. $sText = $aItemData;
  1641. }
  1642. $sRv .= $sText . $sColumn;
  1643. }
  1644. $sRv .= $sRow;
  1645. $iK++;
  1646. }
  1647. echo $sRv;
  1648. }
  1649. private function actionGetReportsDataJson($sModuleName, $sReportName, $sDateFrom, $sDateTo)
  1650. {
  1651. $iDateFrom = strtotime($sDateFrom);
  1652. $iDateTo = strtotime($sDateTo) + 86400;
  1653. $sType = "bar";
  1654. $bIsTimeX = false;
  1655. $iMinValueY = 0;
  1656. $iMaxValueY = 0;
  1657. $bDefaultFillData = true;
  1658. $sReportType = $sReportName;
  1659. $aValues = array('labels' => array(), 'values' => array(array('legend' => '', 'data' => array())), 'links' => array(), 'strings' => array(0 => _t('_bx_analytics_txt_item'), 1 => _t('_bx_analytics_txt_value')));
  1660. if ($sModuleName != BX_ANALYTICS_SYSTEM){
  1661. $oModule = BxDolModule::getInstance($sModuleName);
  1662. $aValues['strings'][0] = $oModule->_aModule['title'];
  1663. $aValues['strings'][1] = _t('_bx_analytics_type_' . $sReportType . '_label');
  1664. }
  1665. else{
  1666. $aValues['strings'][0] = _t('_bx_analytics_txt_total_content');
  1667. $aValues['strings'][1] = _t('_bx_analytics_type_' . $sReportType . '_label');
  1668. }
  1669. switch ($sReportType) {
  1670. case BX_ANALYTICS_TOP_BY_LIKES:
  1671. $bDefaultFillData = false;
  1672. $this->getViewsOrVotes(BxDolVote::getSystems(), 'OBJECT_VOTES', $sModuleName, $iDateFrom, $iDateTo, $aValues, $iMaxValueY, $iMinValueY);
  1673. break;
  1674. case BX_ANALYTICS_TOP_BY_VIEWS:
  1675. $bDefaultFillData = false;
  1676. $this->getViewsOrVotes(BxDolView::getSystems(), 'OBJECT_VIEWS', $sModuleName, $iDateFrom, $iDateTo, $aValues, $iMaxValueY, $iMinValueY);
  1677. break;
  1678. case BX_ANALYTICS_MOST_FOLLOWED_PROFILES:
  1679. $oModule = BxDolModule::getInstance($sModuleName);
  1680. $aData = $this->_oDb->getMostFollowedProfiles($sModuleName, $iDateFrom, $iDateTo);
  1681. break;
  1682. case BX_ANALYTICS_MOST_ACTIVE_PROFILES:
  1683. $aData = array();
  1684. $oModule = BxDolModule::getInstance($sModuleName);
  1685. $aTmp = $this->getSelectedModules();
  1686. foreach($aTmp[0] as $sContentModule){
  1687. $oContentModule = BxDolModule::getInstance($sContentModule);
  1688. if ($oContentModule && isset($oContentModule->_oConfig->CNF['TABLE_ENTRIES']) && isset($oContentModule->_oConfig->CNF['FIELD_AUTHOR'])){
  1689. $sContentTable = $oContentModule->_oConfig->CNF['TABLE_ENTRIES'];
  1690. $sColumnAuthor = $oContentModule->_oConfig->CNF['FIELD_AUTHOR'];
  1691. $sColumnAdded = $oContentModule->_oConfig->CNF['FIELD_ADDED'];
  1692. if (!empty($sContentTable) && !empty($sColumnAuthor) && !empty($sColumnAdded) && (!BxDolRequest::serviceExists($sContentModule, 'act_as_profile') || BxDolService::call($sContentModule, 'act_as_profile') == false)){
  1693. $aData1 = $this->_oDb->getMostActiveProfiles($sModuleName, $sContentModule, $sContentTable, $sColumnAuthor, $sColumnAdded, $iDateFrom, $iDateTo);
  1694. foreach($aData1 as $aTmp3){
  1695. if (array_key_exists($aTmp3['object_id'], $aData))
  1696. $aData[$aTmp3['object_id']]['value'] += $aTmp3['value'];
  1697. else
  1698. $aData[$aTmp3['object_id']] = $aTmp3;
  1699. }
  1700. }
  1701. }
  1702. }
  1703. $aData = $this->sortArrayOfArraysAndSlice($aData, 'value', intval(getParam('bx_analytics_items_count')));
  1704. break;
  1705. default:
  1706. $sType = "line";
  1707. $sTableName = '';
  1708. $aValues['strings'][0] = _t('_bx_analytics_txt_date');
  1709. $bDefaultFillData = false;
  1710. if ($sModuleName != BX_ANALYTICS_SYSTEM){
  1711. $oModule = BxDolModule::getInstance($sModuleName);
  1712. if (isset($oModule->_oConfig->CNF['TABLE_ENTRIES']))
  1713. $sTableName = $oModule->_oConfig->CNF['TABLE_ENTRIES'];
  1714. $aValues['strings'][1] = ($sReportType == BX_ANALYTICS_CONTENT_TOTAL ? _t('_bx_analytics_txt_total_count', $oModule->_aModule['title']) : _t('_bx_analytics_txt_count', $oModule->_aModule['title']));
  1715. }
  1716. else{
  1717. $sTableName = 'sys_accounts';
  1718. $aValues['strings'][1] = ($sReportType == BX_ANALYTICS_CONTENT_TOTAL ? _t('_bx_analytics_txt_total_count', _t('_bx_analytics_txt_accounts')) : _t('_bx_analytics_txt_count', _t('_bx_analytics_txt_accounts')));
  1719. }
  1720. if (isset($oModule) && isset($oModule->_oConfig->CNF['FIELD_ADDED']))
  1721. $sColumnAdded = $oModule->_oConfig->CNF['FIELD_ADDED'];
  1722. else
  1723. $sColumnAdded = 'added';
  1724. $aData = array();
  1725. $iValuePrev = 0;
  1726. /**
  1727. * @hooks
  1728. * @hookdef hook-bx_analytics-get_chart_data_line 'bx_analytics', 'get_chart_data_line' - hook on get avalaible reports for specified module
  1729. * - $unit_name - equals `bx_analytics`
  1730. * - $action - equals `get_chart_data_line`
  1731. * - $object_id - not used
  1732. * - $sender_id -not used
  1733. * - $extra_params - array of additional params with the following array keys:
  1734. * - `module` - [string] module name
  1735. * - `report_name` - [string] report name
  1736. * - `report_type` - [string] by ref, report name, can be overridden in hook processing
  1737. * - `date_from` - [int] report's start of interval in php time format
  1738. * - `date_to` - [int] report's end of interval in php time format
  1739. * - `data` - [array] by ref, array of data for chart, can be overridden in hook processing
  1740. * - `prev_value` - [int] by ref, previous value for chart (started value), can be overridden in hook processing
  1741. * @hook @ref hook-bx_analytics-get_chart_data_line
  1742. */
  1743. bx_alert($this->_aModule['name'], 'get_chart_data_line', 0, 0, array(
  1744. 'module' => $sModuleName,
  1745. 'report_name' => $sReportName,
  1746. 'report_type' => &$sReportType,
  1747. 'report_type_ref' => &$sReportType,
  1748. 'date_from' => $iDateFrom,
  1749. 'date_to' => $iDateTo,
  1750. 'data' => &$aData,
  1751. 'data_ref' => &$aData,
  1752. 'prev_value' => &$iValuePrev,
  1753. 'prev_value_ref' => &$iValuePrev,
  1754. ));
  1755. if ($sColumnAdded != '' && count($aData) == 0){
  1756. $aData = $this->_oDb->getGrowth($sTableName, $sColumnAdded, $iDateFrom, $iDateTo);
  1757. $iValuePrev = $this->_oDb->getGrowthInitValue($sTableName, $sColumnAdded, $iDateFrom);
  1758. }
  1759. if (count($aData) > 0){
  1760. $aTmpDates = array();
  1761. foreach ($aData as $aValue) {
  1762. $sX = $aValue['period'];
  1763. $aTmpDates[$sX] = $aValue['count'];
  1764. }
  1765. for ($i = $iDateFrom; $i < $iDateTo ; $i = $i + 86400 ){
  1766. $sX = date('Y-m-d', $i);
  1767. if (!array_key_exists($sX, $aTmpDates)){
  1768. array_push($aValues['values'][0]['data'], array('x' => $sX, 'y' => $sReportType == BX_ANALYTICS_CONTENT_TOTAL ? $iValuePrev : 0));
  1769. }
  1770. else{
  1771. array_push($aValues['values'][0]['data'], array('x' => $sX, 'y' => $sReportType == BX_ANALYTICS_CONTENT_TOTAL ? ($iValuePrev + $aTmpDates[$sX]) : $aTmpDates[$sX]));
  1772. $iValuePrev += $aTmpDates[$sX];
  1773. }
  1774. //array_push($aValues['labels'], $sX);
  1775. if ($sReportType == BX_ANALYTICS_CONTENT_TOTAL){
  1776. if ($iValuePrev > $iMaxValueY)
  1777. $iMaxValueY = $iValuePrev;
  1778. }
  1779. else{
  1780. if (array_key_exists($sX, $aTmpDates) && $aTmpDates[$sX] > $iMaxValueY)
  1781. $iMaxValueY = $aTmpDates[$sX];
  1782. }
  1783. }
  1784. }
  1785. break;
  1786. }
  1787. if ($bDefaultFillData){
  1788. foreach ($aData as $aValue) {
  1789. array_push($aValues['labels'], $this->getItemName($oModule->serviceGetTitle($aValue['object_id'])));
  1790. array_push($aValues['values'][0]['data'], $aValue['value']);
  1791. array_push($aValues['links'], $oModule->serviceGetLink($aValue['object_id']));
  1792. if ($aValue['value'] > $iMaxValueY)
  1793. $iMaxValueY = $aValue['value'];
  1794. }
  1795. }
  1796. $iMaxValueY = ceil($iMaxValueY * 1.1);
  1797. $aDataForChartXAxes = array();
  1798. if ($bIsTimeX){
  1799. $sUnit = 'day';
  1800. $iInterval = ($iDateTo - $iDateFrom) / 86400;
  1801. if ($iInterval > 50)
  1802. $sUnit = 'week';
  1803. if ($iInterval > 100)
  1804. $sUnit = 'month';
  1805. $aDataForChartXAxes = array(
  1806. 'type' => 'time',
  1807. 'time' => array(
  1808. 'tooltipFormat' => 'DD.MM.YYYY',
  1809. 'unit' => $sUnit,
  1810. ),
  1811. 'display' => true,
  1812. 'distribution' => 'linear',
  1813. 'ticks' => array(
  1814. 'display' => true,
  1815. 'autoSkip' => true,
  1816. )
  1817. );
  1818. }
  1819. else{
  1820. $aDataForChartXAxes = array(
  1821. 'ticks' => array(
  1822. 'autoSkip' => true
  1823. ),
  1824. 'display' => true
  1825. );
  1826. }
  1827. $aDataForChartDatasets = array();
  1828. for($i = 0; $i < count($aValues['values']); $i++){
  1829. $aDataForChartDatasets[] = array(
  1830. 'label' => $aValues['values'][$i]['legend'],
  1831. 'fill' => false,
  1832. 'backgroundColor' => $this->aColors[$i],
  1833. 'borderColor' => $this->aColors[$i],
  1834. 'borderWidth' => 2,
  1835. 'data' => $aValues['values'][$i]['data']
  1836. );
  1837. }
  1838. $aDataForChart = array(
  1839. 'type' => $sType,
  1840. 'data' => array(
  1841. 'labels' => $aValues['labels'],
  1842. 'datasets' => $aDataForChartDatasets
  1843. ),
  1844. 'options' => array(
  1845. 'legend' => array(
  1846. 'position' => 'bottom',
  1847. 'display' => count($aValues['values']) == 1 ? false :true
  1848. ),
  1849. 'elements' => array(
  1850. 'point'=> array(
  1851. 'radius' => 0
  1852. )
  1853. ),
  1854. 'scales' => array(
  1855. 'y' => array(
  1856. 'ticks' => array(
  1857. 'max' => $iMaxValueY,
  1858. 'min' => $iMinValueY,
  1859. 'stepSize' => $this->getStep($iMinValueY, $iMaxValueY),
  1860. 'autoSkip' => true
  1861. )
  1862. ),
  1863. 'x' => $aDataForChartXAxes
  1864. )
  1865. ),
  1866. 'links' => $aValues['links'],
  1867. 'strings' => $aValues['strings'],
  1868. );
  1869. return json_encode($aDataForChart);
  1870. }
  1871. /**
  1872. * Who may define goals and funnels: site administrators, and levels with "manage analytics"
  1873. * besides "use analytics".
  1874. */
  1875. public function checkAllowedManage()
  1876. {
  1877. if(isAdmin())
  1878. return CHECK_ACTION_RESULT_ALLOWED;
  1879. if($this->checkAllowed() !== CHECK_ACTION_RESULT_ALLOWED)
  1880. return _t('_sys_txt_access_denied');
  1881. $aCheck = checkActionModule(bx_get_logged_profile_id(), 'manage analytics', $this->getName(), false);
  1882. return $aCheck[CHECK_ACTION_RESULT] === CHECK_ACTION_RESULT_ALLOWED ? CHECK_ACTION_RESULT_ALLOWED : _t('_sys_txt_access_denied');
  1883. }
  1884. /**
  1885. * Save or delete a goal: POST op (save, delete), id, name, type (page, event), pattern, csrf_token.
  1886. */
  1887. public function actionGoal()
  1888. {
  1889. $aResult = $this->_checkManageRequest();
  1890. if($aResult === true) {
  1891. $iId = (int)bx_get('id', 'post');
  1892. if(bx_get('op', 'post') == 'delete')
  1893. $aResult = ['ok' => $this->_oDb->deleteGoal($iId)];
  1894. else {
  1895. $aGoal = $this->_getMatchInput(bx_get('type', 'post'), bx_get('pattern', 'post'));
  1896. $sName = trim((string)bx_get('name', 'post'));
  1897. if($aGoal === false || $sName === '' || get_mb_len($sName) > 128)
  1898. $aResult = ['error' => _t('_bx_analytics_txt_goal_invalid')];
  1899. else if(!$iId && $this->isGoalLimitReached())
  1900. $aResult = ['error' => _t('_bx_analytics_txt_goal_limit', $this->_oConfig->CNF['MAX_GOALS'])];
  1901. else {
  1902. $iId = $this->_oDb->saveGoal($iId, ['name' => $sName] + $aGoal);
  1903. $aResult = ['ok' => (bool)$iId, 'id' => $iId];
  1904. }
  1905. }
  1906. $this->cleanReportCache();
  1907. }
  1908. $this->_outputJson($aResult);
  1909. }
  1910. /**
  1911. * Whether there are as many goals as there can be: the Goals report counts each one in
  1912. * both periods, a query per goal and period.
  1913. */
  1914. public function isGoalLimitReached()
  1915. {
  1916. return $this->_oDb->getGoalsCount() >= $this->_oConfig->CNF['MAX_GOALS'];
  1917. }
  1918. /**
  1919. * Save or delete a funnel: POST op, id, name, step_type[], step_pattern[], csrf_token.
  1920. */
  1921. public function actionFunnel()
  1922. {
  1923. $aResult = $this->_checkManageRequest();
  1924. if($aResult === true) {
  1925. $iId = (int)bx_get('id', 'post');
  1926. if(bx_get('op', 'post') == 'delete')
  1927. $aResult = ['ok' => $this->_oDb->deleteFunnel($iId), 'id' => 0];
  1928. else {
  1929. $aTypes = bx_get('step_type', 'post');
  1930. $aPatterns = bx_get('step_pattern', 'post');
  1931. $aSteps = [];
  1932. if(is_array($aTypes) && is_array($aPatterns))
  1933. foreach(array_values($aTypes) as $i => $sType) {
  1934. if(!isset($aPatterns[$i]) || trim((string)$aPatterns[$i]) === '')
  1935. continue;
  1936. $aStep = $this->_getMatchInput($sType, $aPatterns[$i]);
  1937. if($aStep === false) {
  1938. $aSteps = false;
  1939. break;
  1940. }
  1941. $aSteps[] = $aStep;
  1942. }
  1943. $sName = trim((string)bx_get('name', 'post'));
  1944. if($aSteps === false || count($aSteps) < 2 || count($aSteps) > $this->_oConfig->CNF['MAX_FUNNEL_STEPS'] || $sName === '' || get_mb_len($sName) > 128)
  1945. $aResult = ['error' => _t('_bx_analytics_txt_funnel_invalid')];
  1946. else {
  1947. $iId = $this->_oDb->saveFunnel($iId, $sName, $aSteps);
  1948. $aResult = ['ok' => (bool)$iId, 'id' => $iId];
  1949. }
  1950. }
  1951. $this->cleanReportCache();
  1952. }
  1953. $this->_outputJson($aResult);
  1954. }
  1955. /**
  1956. * Mark an error group open, resolved or ignored: POST op (the status), id, csrf_token.
  1957. * An ignored error is left out of the lists but keeps counting; a resolved one opens
  1958. * again when it happens after that.
  1959. */
  1960. public function actionErrorStatus()
  1961. {
  1962. $aResult = $this->_checkManageRequest();
  1963. if($aResult === true) {
  1964. $sStatus = (string)bx_get('op', 'post');
  1965. $iId = (int)bx_get('id', 'post');
  1966. if(!in_array($sStatus, ['open', 'resolved', 'ignored'], true) || !$iId || !$this->_oDb->getErrorGroup($iId))
  1967. $aResult = ['error' => _t('_sys_request_page_not_found_cpt'), 'code' => 404];
  1968. else {
  1969. $aResult = ['ok' => $this->_oDb->setErrorStatus($iId, $sStatus, time()), 'id' => $iId];
  1970. $this->cleanReportCache();
  1971. }
  1972. }
  1973. $this->_outputJson($aResult);
  1974. }
  1975. /**
  1976. * A POST from a manager with a valid form token, or an error array.
  1977. */
  1978. protected function _checkManageRequest()
  1979. {
  1980. if($_SERVER['REQUEST_METHOD'] != 'POST' || $this->checkAllowedManage() !== CHECK_ACTION_RESULT_ALLOWED)
  1981. return ['error' => _t('_sys_txt_access_denied'), 'code' => 403];
  1982. if(!BxDolForm::isCsrfTokenValid((string)bx_get('csrf_token', 'post'), false))
  1983. return ['error' => _t('_sys_txt_access_denied'), 'code' => 403];
  1984. return true;
  1985. }
  1986. /**
  1987. * A goal or funnel step from form input: a page path (starting with / or *, * for any
  1988. * characters) or an event name (name, or name@module), or false.
  1989. */
  1990. protected function _getMatchInput($sType, $sPattern)
  1991. {
  1992. $sPattern = trim((string)$sPattern);
  1993. if($sPattern === '' || get_mb_len($sPattern) > 255)
  1994. return false;
  1995. if($sType == 'page')
  1996. return in_array(get_mb_substr($sPattern, 0, 1), ['/', '*'], true) ? ['type' => 'page', 'pattern' => $sPattern] : false;
  1997. if($sType == 'event')
  1998. return preg_match('/^[\p{L}\p{N} _.:\/-]{1,64}(@[a-z0-9_]{1,32})?$/u', $sPattern) ? ['type' => 'event', 'pattern' => $sPattern] : false;
  1999. return false;
  2000. }
  2001. protected function _outputJson($aResult)
  2002. {
  2003. header('Content-Type: application/json; charset=utf-8');
  2004. header('Cache-Control: no-store');
  2005. if(isset($aResult['code']))
  2006. http_response_code((int)$aResult['code']);
  2007. echo json_encode($aResult);
  2008. exit;
  2009. }
  2010. /**
  2011. * Reports that show members by name: sessions and users. Site administrators always
  2012. * see them; everyone else needs "view analytics people" besides "use analytics".
  2013. */
  2014. public function checkAllowedPeople()
  2015. {
  2016. if(isAdmin())
  2017. return CHECK_ACTION_RESULT_ALLOWED;
  2018. if($this->checkAllowed() !== CHECK_ACTION_RESULT_ALLOWED)
  2019. return _t('_sys_txt_access_denied');
  2020. $aCheck = checkActionModule(bx_get_logged_profile_id(), 'view analytics people', $this->getName(), false);
  2021. return $aCheck[CHECK_ACTION_RESULT] === CHECK_ACTION_RESULT_ALLOWED ? CHECK_ACTION_RESULT_ALLOWED : _t('_sys_txt_access_denied');
  2022. }
  2023. /**
  2024. * Reports that show money and buyers: revenue, subscriptions, sales, credits, and members'
  2025. * lifetime value. Site administrators always see them; everyone else needs "view analytics
  2026. * revenue" besides "use analytics".
  2027. */
  2028. public function checkAllowedRevenue()
  2029. {
  2030. if(isAdmin())
  2031. return CHECK_ACTION_RESULT_ALLOWED;
  2032. if($this->checkAllowed() !== CHECK_ACTION_RESULT_ALLOWED)
  2033. return _t('_sys_txt_access_denied');
  2034. $aCheck = checkActionModule(bx_get_logged_profile_id(), 'view analytics revenue', $this->getName(), false);
  2035. return $aCheck[CHECK_ACTION_RESULT] === CHECK_ACTION_RESULT_ALLOWED ? CHECK_ACTION_RESULT_ALLOWED : _t('_sys_txt_access_denied');
  2036. }
  2037. /**
  2038. * Import past payments from the payment module now: POST op (import, restart), csrf_token.
  2039. * Site administrators only. One step runs within a time limit; the hourly cron continues.
  2040. */
  2041. public function actionBackfill()
  2042. {
  2043. if(!isAdmin() || $_SERVER['REQUEST_METHOD'] != 'POST' || !BxDolForm::isCsrfTokenValid((string)bx_get('csrf_token', 'post'), false))
  2044. $this->_outputJson(['error' => _t('_sys_txt_access_denied'), 'code' => 403]);
  2045. $oRevenue = $this->getRevenue();
  2046. $aStatus = $oRevenue->getBackfillStatus();
  2047. if($aStatus['state'] == 'unavailable')
  2048. $this->_outputJson(['error' => _t('_bx_analytics_txt_revenue_no_payment')]);
  2049. if(bx_get('op', 'post') == 'restart' || $aStatus['state'] == 'done')
  2050. $oRevenue->restartBackfill();
  2051. $aResult = $oRevenue->backfill(time() + $this->_oConfig->CNF['BACKFILL_SECONDS']);
  2052. // cached revenue reports hold the old totals and the import's state
  2053. $this->cleanReportCache();
  2054. $this->_outputJson(['ok' => true] + $aResult);
  2055. }
  2056. public function checkAllowed($isPerformAction = false)
  2057. {
  2058. // site administrators always see analytics, whatever their profile's level
  2059. if(isAdmin())
  2060. return CHECK_ACTION_RESULT_ALLOWED;
  2061. $aCheck = checkActionModule(bx_get_logged_profile_id(), 'use analytics', $this->getName(), $isPerformAction);
  2062. if ($aCheck[CHECK_ACTION_RESULT] !== CHECK_ACTION_RESULT_ALLOWED)
  2063. return _t('_sys_txt_access_denied');
  2064. return CHECK_ACTION_RESULT_ALLOWED;
  2065. }
  2066. public function getSelectedModules()
  2067. {
  2068. $aModulesList = $this->serviceGetModules();
  2069. $aModules = array_keys($aModulesList);
  2070. $sModulesDisabled = explode(',', getParam('bx_analytics_modules_disabled'));
  2071. return array(array_diff($aModules, $sModulesDisabled), $aModulesList);
  2072. }
  2073. private function getViewsOrVotes($aSystems, $sSystem, $sModuleName, $iDateFrom, $iDateTo, &$aValues, &$iMaxValueY, &$iMinValueY)
  2074. {
  2075. $aData = array();
  2076. if ($sModuleName != BX_ANALYTICS_SYSTEM){
  2077. $oModule = BxDolModule::getInstance($sModuleName);
  2078. $sSystem = $oModule->_oConfig->CNF['OBJECT_VIEWS'];
  2079. if(isset($aSystems[$sSystem]))
  2080. $aData = $this->_oDb->getTopContentByLikes($sModuleName, $aSystems[$sSystem]['table_track'], $iDateFrom, $iDateTo);
  2081. }
  2082. else{
  2083. $aTmp = $this->getSelectedModules();
  2084. foreach ($aTmp[0] as $sModule) {
  2085. if ($sModule != BX_ANALYTICS_SYSTEM){
  2086. $oModule = BxDolModule::getInstance($sModule);
  2087. if (isset($oModule->_oConfig->CNF['OBJECT_VIEWS'])){
  2088. $sSystem = $oModule->_oConfig->CNF['OBJECT_VIEWS'];
  2089. if(isset($aSystems[$sSystem])){
  2090. $aData1 = $this->_oDb->getTopContentByLikes($sModule, $aSystems[$sSystem]['table_track'], $iDateFrom, $iDateTo);
  2091. $aData = array_merge($aData, $aData1);
  2092. }
  2093. }
  2094. }
  2095. }
  2096. $aData = $this->sortArrayOfArraysAndSlice($aData, 'value', intval(getParam('bx_analytics_items_count')));
  2097. }
  2098. foreach ($aData as $aValue) {
  2099. $oModule = BxDolModule::getInstance($aValue['module']);
  2100. array_push($aValues['labels'], $this->getItemName($oModule->serviceGetTitle($aValue['object_id'])));
  2101. array_push($aValues['values'][0]['data'], $aValue['value']);
  2102. array_push($aValues['links'], $oModule->serviceGetLink($aValue['object_id']));
  2103. if ($aValue['value'] > $iMaxValueY)
  2104. $iMaxValueY = $aValue['value'];
  2105. }
  2106. }
  2107. private function getStep($iMin, $iMax)
  2108. {
  2109. $iCount = 8;
  2110. return ceil(($iMax - $iMin) / $iCount);
  2111. }
  2112. private function sortArrayOfArraysAndSlice($aArr, $sFld, $Count)
  2113. {
  2114. $aDataSort = array();
  2115. foreach ($aArr as $sKey => $oRow)
  2116. {
  2117. $aDataSort[$sKey] = $oRow[$sFld];
  2118. }
  2119. array_multisort($aDataSort, SORT_DESC, $aArr);
  2120. return array_slice($aArr, 0, $Count);
  2121. }
  2122. private function getItemName($sString)
  2123. {
  2124. $sString = strip_tags($sString);
  2125. if ($sString == ''){
  2126. $sString = _t('_bx_analytics_txt_empty_title_item');
  2127. }
  2128. return $sString;
  2129. }
  2130. }
  2131. /** @} */