AnalyticsManager.mjs 12 KB

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