AnalyticsManager.js 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461
  1. const SessionManager = require('../Authentication/SessionManager')
  2. const UserAnalyticsIdCache = require('./UserAnalyticsIdCache')
  3. const Settings = require('@overleaf/settings')
  4. const Metrics = require('../../infrastructure/Metrics')
  5. const Queues = require('../../infrastructure/Queues')
  6. const crypto = require('crypto')
  7. const _ = require('lodash')
  8. const { expressify } = require('@overleaf/promise-utils')
  9. const logger = require('@overleaf/logger')
  10. const { createHash } = require('crypto')
  11. const analyticsEventsQueue = Queues.getQueue('analytics-events')
  12. const analyticsEditingSessionsQueue = Queues.getQueue(
  13. 'analytics-editing-sessions'
  14. )
  15. const analyticsUserPropertiesQueue = Queues.getQueue(
  16. 'analytics-user-properties'
  17. )
  18. const analyticsAccountMappingQueue = Queues.getQueue(
  19. 'analytics-account-mapping'
  20. )
  21. const analyticsEmailChangeQueue = Queues.getQueue('analytics-email-change')
  22. const ONE_MINUTE_MS = 60 * 1000
  23. const UUID_REGEXP = /^[\w]{8}(-[\w]{4}){3}-[\w]{12}$/
  24. function identifyUser(userId, analyticsId, isNewUser) {
  25. if (!userId || !analyticsId || !analyticsId.toString().match(UUID_REGEXP)) {
  26. return
  27. }
  28. if (_isAnalyticsDisabled() || _isSmokeTestUser(userId)) {
  29. return
  30. }
  31. Metrics.analyticsQueue.inc({ status: 'adding', event_type: 'identify' })
  32. Queues.createScheduledJob(
  33. 'analytics-events',
  34. {
  35. name: 'identify',
  36. data: { userId, analyticsId, isNewUser, createdAt: new Date() },
  37. },
  38. ONE_MINUTE_MS
  39. )
  40. .then(() => {
  41. Metrics.analyticsQueue.inc({ status: 'added', event_type: 'identify' })
  42. })
  43. .catch(() => {
  44. Metrics.analyticsQueue.inc({ status: 'error', event_type: 'identify' })
  45. })
  46. }
  47. async function recordEventForUser(userId, event, segmentation) {
  48. if (!userId) {
  49. return
  50. }
  51. if (_isAnalyticsDisabled() || _isSmokeTestUser(userId)) {
  52. return
  53. }
  54. const analyticsId = await UserAnalyticsIdCache.get(userId)
  55. if (analyticsId) {
  56. _recordEvent({ analyticsId, userId, event, segmentation, isLoggedIn: true })
  57. }
  58. }
  59. function recordEventForUserInBackground(userId, event, segmentation) {
  60. recordEventForUser(userId, event, segmentation).catch(err => {
  61. logger.warn(
  62. { err, userId, event, segmentation },
  63. 'failed to record event for user'
  64. )
  65. })
  66. }
  67. function recordEventForSession(session, event, segmentation) {
  68. const { analyticsId, userId } = getIdsFromSession(session)
  69. if (!analyticsId) {
  70. return
  71. }
  72. if (_isAnalyticsDisabled() || _isSmokeTestUser(userId)) {
  73. return
  74. }
  75. _recordEvent({
  76. analyticsId,
  77. userId,
  78. event,
  79. segmentation,
  80. isLoggedIn: !!userId,
  81. createdAt: new Date(),
  82. })
  83. }
  84. async function setUserPropertyForUser(userId, propertyName, propertyValue) {
  85. if (_isAnalyticsDisabled() || _isSmokeTestUser(userId)) {
  86. return
  87. }
  88. _checkPropertyValue(propertyValue)
  89. const analyticsId = await UserAnalyticsIdCache.get(userId)
  90. if (analyticsId) {
  91. await _setUserProperty({ analyticsId, propertyName, propertyValue })
  92. }
  93. }
  94. function setUserPropertyForUserInBackground(userId, property, value) {
  95. setUserPropertyForUser(userId, property, value).catch(err => {
  96. logger.warn(
  97. { err, userId, property, value },
  98. 'failed to set user property for user'
  99. )
  100. })
  101. }
  102. async function setUserPropertyForAnalyticsId(
  103. analyticsId,
  104. propertyName,
  105. propertyValue
  106. ) {
  107. if (_isAnalyticsDisabled()) {
  108. return
  109. }
  110. _checkPropertyValue(propertyValue)
  111. await _setUserProperty({ analyticsId, propertyName, propertyValue })
  112. }
  113. async function setUserPropertyForSession(session, propertyName, propertyValue) {
  114. const { analyticsId, userId } = getIdsFromSession(session)
  115. if (_isAnalyticsDisabled() || _isSmokeTestUser(userId)) {
  116. return
  117. }
  118. _checkPropertyValue(propertyValue)
  119. if (analyticsId) {
  120. await _setUserProperty({ analyticsId, propertyName, propertyValue })
  121. }
  122. }
  123. function setUserPropertyForSessionInBackground(session, property, value) {
  124. setUserPropertyForSession(session, property, value).catch(err => {
  125. const { analyticsId, userId } = getIdsFromSession(session)
  126. logger.warn(
  127. { err, analyticsId, userId, property, value },
  128. 'failed to set user property for session'
  129. )
  130. })
  131. }
  132. /**
  133. * @typedef {(import('./types').AccountMapping)} AccountMapping
  134. * @typedef {(import('./types').EmailChangePayload)} EmailChangePayload
  135. */
  136. /**
  137. * Register mapping between two accounts.
  138. *
  139. * @param {AccountMapping} payload - The event payload to send to Analytics
  140. */
  141. function registerAccountMapping({
  142. source,
  143. sourceEntity,
  144. sourceEntityId,
  145. target,
  146. targetEntity,
  147. targetEntityId,
  148. createdAt,
  149. }) {
  150. Metrics.analyticsQueue.inc({
  151. status: 'adding',
  152. event_type: 'account-mapping',
  153. })
  154. analyticsAccountMappingQueue
  155. .add('account-mapping', {
  156. source,
  157. sourceEntity,
  158. sourceEntityId,
  159. target,
  160. targetEntity,
  161. targetEntityId,
  162. createdAt: createdAt ?? new Date(),
  163. })
  164. .then(() => {
  165. Metrics.analyticsQueue.inc({
  166. status: 'added',
  167. event_type: 'account-mapping',
  168. })
  169. })
  170. .catch(() => {
  171. Metrics.analyticsQueue.inc({
  172. status: 'error',
  173. event_type: 'account-mapping',
  174. })
  175. })
  176. }
  177. /**
  178. * Registers operations relating to email addresses.
  179. *
  180. * Generally, this should be called through the EmailChangeHelper module to ensure
  181. * that the right data is sent.
  182. *
  183. * This will be called whenever an email is created, updated or deleted.
  184. *
  185. * @param {EmailChangePayload} payload
  186. */
  187. function registerEmailChange({
  188. userId,
  189. email,
  190. action,
  191. createdAt = new Date(),
  192. emailCreatedAt,
  193. isPrimary,
  194. emailConfirmedAt,
  195. emailDeletedAt,
  196. }) {
  197. Metrics.analyticsQueue.inc({ status: 'adding', event_type: 'email-change' })
  198. const salt = Settings.analytics?.hashedEmailSalt ?? ''
  199. const hashedEmail = createHash('sha256')
  200. .update(`${email}${salt}`)
  201. .digest('hex')
  202. analyticsEmailChangeQueue
  203. .add('email-change', {
  204. action,
  205. createdAt,
  206. emailDeletedAt,
  207. email: hashedEmail,
  208. emailConfirmedAt,
  209. emailCreatedAt,
  210. isPrimary,
  211. userId,
  212. })
  213. .then(() => {
  214. Metrics.analyticsQueue.inc({
  215. status: 'added',
  216. event_type: 'email-change',
  217. })
  218. })
  219. .catch(() => {
  220. Metrics.analyticsQueue.inc({
  221. status: 'error',
  222. event_type: 'email-change',
  223. })
  224. })
  225. }
  226. function updateEditingSession(userId, projectId, countryCode, segmentation) {
  227. if (!userId) {
  228. return
  229. }
  230. if (_isAnalyticsDisabled() || _isSmokeTestUser(userId)) {
  231. return
  232. }
  233. if (!_isSegmentationValid(segmentation)) {
  234. logger.info(
  235. { userId, projectId, segmentation },
  236. 'rejecting analytics editing session due to bad segmentation'
  237. )
  238. return
  239. }
  240. Metrics.analyticsQueue.inc({
  241. status: 'adding',
  242. event_type: 'editing-session',
  243. })
  244. analyticsEditingSessionsQueue
  245. .add('editing-session', {
  246. userId,
  247. projectId,
  248. countryCode,
  249. segmentation,
  250. createdAt: new Date(),
  251. })
  252. .then(() => {
  253. Metrics.analyticsQueue.inc({
  254. status: 'added',
  255. event_type: 'editing-session',
  256. })
  257. })
  258. .catch(() => {
  259. Metrics.analyticsQueue.inc({
  260. status: 'error',
  261. event_type: 'editing-session',
  262. })
  263. })
  264. }
  265. function _recordEvent(
  266. { analyticsId, userId, event, segmentation, isLoggedIn },
  267. { delay } = {}
  268. ) {
  269. if (!_isAttributeValid(event)) {
  270. logger.info(
  271. { analyticsId, event, segmentation },
  272. 'rejecting analytics event due to bad event name'
  273. )
  274. return
  275. }
  276. if (!_isSegmentationValid(segmentation)) {
  277. logger.info(
  278. { analyticsId, event, segmentation },
  279. 'rejecting analytics event due to bad segmentation'
  280. )
  281. return
  282. }
  283. logger.debug(
  284. {
  285. analyticsId,
  286. userId,
  287. event,
  288. segmentation,
  289. isLoggedIn: !!userId,
  290. createdAt: new Date(),
  291. },
  292. 'queueing analytics event'
  293. )
  294. Metrics.analyticsQueue.inc({ status: 'adding', event_type: 'event' })
  295. analyticsEventsQueue
  296. .add(
  297. 'event',
  298. {
  299. analyticsId,
  300. userId,
  301. event,
  302. segmentation,
  303. isLoggedIn,
  304. createdAt: new Date(),
  305. },
  306. { delay }
  307. )
  308. .then(() => {
  309. Metrics.analyticsQueue.inc({ status: 'added', event_type: 'event' })
  310. })
  311. .catch(() => {
  312. Metrics.analyticsQueue.inc({ status: 'error', event_type: 'event' })
  313. })
  314. }
  315. async function _setUserProperty({ analyticsId, propertyName, propertyValue }) {
  316. if (!_isAttributeValid(propertyName)) {
  317. logger.info(
  318. { analyticsId, propertyName, propertyValue },
  319. 'rejecting analytics user property due to bad name'
  320. )
  321. return
  322. }
  323. if (!_isAttributeValueValid(propertyValue)) {
  324. logger.info(
  325. { analyticsId, propertyName, propertyValue },
  326. 'rejecting analytics user property due to bad value'
  327. )
  328. return
  329. }
  330. Metrics.analyticsQueue.inc({
  331. status: 'adding',
  332. event_type: 'user-property',
  333. })
  334. await analyticsUserPropertiesQueue
  335. .add('user-property', {
  336. analyticsId,
  337. propertyName,
  338. propertyValue,
  339. createdAt: new Date(),
  340. })
  341. .then(() => {
  342. Metrics.analyticsQueue.inc({
  343. status: 'added',
  344. event_type: 'user-property',
  345. })
  346. })
  347. .catch(() => {
  348. Metrics.analyticsQueue.inc({
  349. status: 'error',
  350. event_type: 'user-property',
  351. })
  352. })
  353. }
  354. function _isSmokeTestUser(userId) {
  355. const smokeTestUserId = Settings.smokeTest && Settings.smokeTest.userId
  356. return (
  357. smokeTestUserId != null &&
  358. userId != null &&
  359. userId.toString() === smokeTestUserId
  360. )
  361. }
  362. function _isAnalyticsDisabled() {
  363. return !(Settings.analytics && Settings.analytics.enabled)
  364. }
  365. function _checkPropertyValue(propertyValue) {
  366. if (propertyValue === undefined) {
  367. throw new Error(
  368. 'propertyValue cannot be undefined, use null to unset a property'
  369. )
  370. }
  371. }
  372. function _isAttributeValid(attribute) {
  373. return !attribute || /^[a-zA-Z0-9-_.:;,/]+$/.test(attribute)
  374. }
  375. function _isAttributeValueValid(attributeValue) {
  376. return _isAttributeValid(attributeValue) || attributeValue instanceof Date
  377. }
  378. function _isSegmentationValid(segmentation) {
  379. if (segmentation) {
  380. for (const key of Object.keys(segmentation)) {
  381. if (!_isAttributeValid(key)) {
  382. return false
  383. }
  384. }
  385. }
  386. return true
  387. }
  388. function getIdsFromSession(session) {
  389. const analyticsId = _.get(session, ['analyticsId'])
  390. const userId = SessionManager.getLoggedInUserId(session)
  391. return { analyticsId, userId }
  392. }
  393. async function analyticsIdMiddleware(req, res, next) {
  394. const session = req.session
  395. const sessionUser = SessionManager.getSessionUser(session)
  396. if (sessionUser) {
  397. session.analyticsId = await UserAnalyticsIdCache.get(sessionUser._id)
  398. } else if (!session.analyticsId) {
  399. // generate an `analyticsId` if needed
  400. session.analyticsId = crypto.randomUUID()
  401. }
  402. res.locals.getSessionAnalyticsId = () => session.analyticsId
  403. next()
  404. }
  405. module.exports = {
  406. identifyUser,
  407. recordEventForSession,
  408. recordEventForUser,
  409. recordEventForUserInBackground,
  410. setUserPropertyForUser,
  411. setUserPropertyForUserInBackground,
  412. setUserPropertyForSession,
  413. setUserPropertyForSessionInBackground,
  414. setUserPropertyForAnalyticsId,
  415. updateEditingSession,
  416. getIdsFromSession,
  417. registerAccountMapping,
  418. registerEmailChange,
  419. analyticsIdMiddleware: expressify(analyticsIdMiddleware),
  420. }