| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461 |
- const SessionManager = require('../Authentication/SessionManager')
- const UserAnalyticsIdCache = require('./UserAnalyticsIdCache')
- const Settings = require('@overleaf/settings')
- const Metrics = require('../../infrastructure/Metrics')
- const Queues = require('../../infrastructure/Queues')
- const crypto = require('crypto')
- const _ = require('lodash')
- const { expressify } = require('@overleaf/promise-utils')
- const logger = require('@overleaf/logger')
- const { createHash } = require('crypto')
- const analyticsEventsQueue = Queues.getQueue('analytics-events')
- const analyticsEditingSessionsQueue = Queues.getQueue(
- 'analytics-editing-sessions'
- )
- const analyticsUserPropertiesQueue = Queues.getQueue(
- 'analytics-user-properties'
- )
- const analyticsAccountMappingQueue = Queues.getQueue(
- 'analytics-account-mapping'
- )
- const analyticsEmailChangeQueue = Queues.getQueue('analytics-email-change')
- const ONE_MINUTE_MS = 60 * 1000
- const UUID_REGEXP = /^[\w]{8}(-[\w]{4}){3}-[\w]{12}$/
- function identifyUser(userId, analyticsId, isNewUser) {
- if (!userId || !analyticsId || !analyticsId.toString().match(UUID_REGEXP)) {
- return
- }
- if (_isAnalyticsDisabled() || _isSmokeTestUser(userId)) {
- return
- }
- Metrics.analyticsQueue.inc({ status: 'adding', event_type: 'identify' })
- Queues.createScheduledJob(
- 'analytics-events',
- {
- name: 'identify',
- data: { userId, analyticsId, isNewUser, createdAt: new Date() },
- },
- ONE_MINUTE_MS
- )
- .then(() => {
- Metrics.analyticsQueue.inc({ status: 'added', event_type: 'identify' })
- })
- .catch(() => {
- Metrics.analyticsQueue.inc({ status: 'error', event_type: 'identify' })
- })
- }
- async function recordEventForUser(userId, event, segmentation) {
- if (!userId) {
- return
- }
- if (_isAnalyticsDisabled() || _isSmokeTestUser(userId)) {
- return
- }
- const analyticsId = await UserAnalyticsIdCache.get(userId)
- if (analyticsId) {
- _recordEvent({ analyticsId, userId, event, segmentation, isLoggedIn: true })
- }
- }
- function recordEventForUserInBackground(userId, event, segmentation) {
- recordEventForUser(userId, event, segmentation).catch(err => {
- logger.warn(
- { err, userId, event, segmentation },
- 'failed to record event for user'
- )
- })
- }
- function recordEventForSession(session, event, segmentation) {
- const { analyticsId, userId } = getIdsFromSession(session)
- if (!analyticsId) {
- return
- }
- if (_isAnalyticsDisabled() || _isSmokeTestUser(userId)) {
- return
- }
- _recordEvent({
- analyticsId,
- userId,
- event,
- segmentation,
- isLoggedIn: !!userId,
- createdAt: new Date(),
- })
- }
- async function setUserPropertyForUser(userId, propertyName, propertyValue) {
- if (_isAnalyticsDisabled() || _isSmokeTestUser(userId)) {
- return
- }
- _checkPropertyValue(propertyValue)
- const analyticsId = await UserAnalyticsIdCache.get(userId)
- if (analyticsId) {
- await _setUserProperty({ analyticsId, propertyName, propertyValue })
- }
- }
- function setUserPropertyForUserInBackground(userId, property, value) {
- setUserPropertyForUser(userId, property, value).catch(err => {
- logger.warn(
- { err, userId, property, value },
- 'failed to set user property for user'
- )
- })
- }
- async function setUserPropertyForAnalyticsId(
- analyticsId,
- propertyName,
- propertyValue
- ) {
- if (_isAnalyticsDisabled()) {
- return
- }
- _checkPropertyValue(propertyValue)
- await _setUserProperty({ analyticsId, propertyName, propertyValue })
- }
- async function setUserPropertyForSession(session, propertyName, propertyValue) {
- const { analyticsId, userId } = getIdsFromSession(session)
- if (_isAnalyticsDisabled() || _isSmokeTestUser(userId)) {
- return
- }
- _checkPropertyValue(propertyValue)
- if (analyticsId) {
- await _setUserProperty({ analyticsId, propertyName, propertyValue })
- }
- }
- function setUserPropertyForSessionInBackground(session, property, value) {
- setUserPropertyForSession(session, property, value).catch(err => {
- const { analyticsId, userId } = getIdsFromSession(session)
- logger.warn(
- { err, analyticsId, userId, property, value },
- 'failed to set user property for session'
- )
- })
- }
- /**
- * @typedef {(import('./types').AccountMapping)} AccountMapping
- * @typedef {(import('./types').EmailChangePayload)} EmailChangePayload
- */
- /**
- * Register mapping between two accounts.
- *
- * @param {AccountMapping} payload - The event payload to send to Analytics
- */
- function registerAccountMapping({
- source,
- sourceEntity,
- sourceEntityId,
- target,
- targetEntity,
- targetEntityId,
- createdAt,
- }) {
- Metrics.analyticsQueue.inc({
- status: 'adding',
- event_type: 'account-mapping',
- })
- analyticsAccountMappingQueue
- .add('account-mapping', {
- source,
- sourceEntity,
- sourceEntityId,
- target,
- targetEntity,
- targetEntityId,
- createdAt: createdAt ?? new Date(),
- })
- .then(() => {
- Metrics.analyticsQueue.inc({
- status: 'added',
- event_type: 'account-mapping',
- })
- })
- .catch(() => {
- Metrics.analyticsQueue.inc({
- status: 'error',
- event_type: 'account-mapping',
- })
- })
- }
- /**
- * Registers operations relating to email addresses.
- *
- * Generally, this should be called through the EmailChangeHelper module to ensure
- * that the right data is sent.
- *
- * This will be called whenever an email is created, updated or deleted.
- *
- * @param {EmailChangePayload} payload
- */
- function registerEmailChange({
- userId,
- email,
- action,
- createdAt = new Date(),
- emailCreatedAt,
- isPrimary,
- emailConfirmedAt,
- emailDeletedAt,
- }) {
- Metrics.analyticsQueue.inc({ status: 'adding', event_type: 'email-change' })
- const salt = Settings.analytics?.hashedEmailSalt ?? ''
- const hashedEmail = createHash('sha256')
- .update(`${email}${salt}`)
- .digest('hex')
- analyticsEmailChangeQueue
- .add('email-change', {
- action,
- createdAt,
- emailDeletedAt,
- email: hashedEmail,
- emailConfirmedAt,
- emailCreatedAt,
- isPrimary,
- userId,
- })
- .then(() => {
- Metrics.analyticsQueue.inc({
- status: 'added',
- event_type: 'email-change',
- })
- })
- .catch(() => {
- Metrics.analyticsQueue.inc({
- status: 'error',
- event_type: 'email-change',
- })
- })
- }
- function updateEditingSession(userId, projectId, countryCode, segmentation) {
- if (!userId) {
- return
- }
- if (_isAnalyticsDisabled() || _isSmokeTestUser(userId)) {
- return
- }
- if (!_isSegmentationValid(segmentation)) {
- logger.info(
- { userId, projectId, segmentation },
- 'rejecting analytics editing session due to bad segmentation'
- )
- return
- }
- Metrics.analyticsQueue.inc({
- status: 'adding',
- event_type: 'editing-session',
- })
- analyticsEditingSessionsQueue
- .add('editing-session', {
- userId,
- projectId,
- countryCode,
- segmentation,
- createdAt: new Date(),
- })
- .then(() => {
- Metrics.analyticsQueue.inc({
- status: 'added',
- event_type: 'editing-session',
- })
- })
- .catch(() => {
- Metrics.analyticsQueue.inc({
- status: 'error',
- event_type: 'editing-session',
- })
- })
- }
- function _recordEvent(
- { analyticsId, userId, event, segmentation, isLoggedIn },
- { delay } = {}
- ) {
- if (!_isAttributeValid(event)) {
- logger.info(
- { analyticsId, event, segmentation },
- 'rejecting analytics event due to bad event name'
- )
- return
- }
- if (!_isSegmentationValid(segmentation)) {
- logger.info(
- { analyticsId, event, segmentation },
- 'rejecting analytics event due to bad segmentation'
- )
- return
- }
- logger.debug(
- {
- analyticsId,
- userId,
- event,
- segmentation,
- isLoggedIn: !!userId,
- createdAt: new Date(),
- },
- 'queueing analytics event'
- )
- Metrics.analyticsQueue.inc({ status: 'adding', event_type: 'event' })
- analyticsEventsQueue
- .add(
- 'event',
- {
- analyticsId,
- userId,
- event,
- segmentation,
- isLoggedIn,
- createdAt: new Date(),
- },
- { delay }
- )
- .then(() => {
- Metrics.analyticsQueue.inc({ status: 'added', event_type: 'event' })
- })
- .catch(() => {
- Metrics.analyticsQueue.inc({ status: 'error', event_type: 'event' })
- })
- }
- async function _setUserProperty({ analyticsId, propertyName, propertyValue }) {
- if (!_isAttributeValid(propertyName)) {
- logger.info(
- { analyticsId, propertyName, propertyValue },
- 'rejecting analytics user property due to bad name'
- )
- return
- }
- if (!_isAttributeValueValid(propertyValue)) {
- logger.info(
- { analyticsId, propertyName, propertyValue },
- 'rejecting analytics user property due to bad value'
- )
- return
- }
- Metrics.analyticsQueue.inc({
- status: 'adding',
- event_type: 'user-property',
- })
- await analyticsUserPropertiesQueue
- .add('user-property', {
- analyticsId,
- propertyName,
- propertyValue,
- createdAt: new Date(),
- })
- .then(() => {
- Metrics.analyticsQueue.inc({
- status: 'added',
- event_type: 'user-property',
- })
- })
- .catch(() => {
- Metrics.analyticsQueue.inc({
- status: 'error',
- event_type: 'user-property',
- })
- })
- }
- function _isSmokeTestUser(userId) {
- const smokeTestUserId = Settings.smokeTest && Settings.smokeTest.userId
- return (
- smokeTestUserId != null &&
- userId != null &&
- userId.toString() === smokeTestUserId
- )
- }
- function _isAnalyticsDisabled() {
- return !(Settings.analytics && Settings.analytics.enabled)
- }
- function _checkPropertyValue(propertyValue) {
- if (propertyValue === undefined) {
- throw new Error(
- 'propertyValue cannot be undefined, use null to unset a property'
- )
- }
- }
- function _isAttributeValid(attribute) {
- return !attribute || /^[a-zA-Z0-9-_.:;,/]+$/.test(attribute)
- }
- function _isAttributeValueValid(attributeValue) {
- return _isAttributeValid(attributeValue) || attributeValue instanceof Date
- }
- function _isSegmentationValid(segmentation) {
- if (segmentation) {
- for (const key of Object.keys(segmentation)) {
- if (!_isAttributeValid(key)) {
- return false
- }
- }
- }
- return true
- }
- function getIdsFromSession(session) {
- const analyticsId = _.get(session, ['analyticsId'])
- const userId = SessionManager.getLoggedInUserId(session)
- return { analyticsId, userId }
- }
- async function analyticsIdMiddleware(req, res, next) {
- const session = req.session
- const sessionUser = SessionManager.getSessionUser(session)
- if (sessionUser) {
- session.analyticsId = await UserAnalyticsIdCache.get(sessionUser._id)
- } else if (!session.analyticsId) {
- // generate an `analyticsId` if needed
- session.analyticsId = crypto.randomUUID()
- }
- res.locals.getSessionAnalyticsId = () => session.analyticsId
- next()
- }
- module.exports = {
- identifyUser,
- recordEventForSession,
- recordEventForUser,
- recordEventForUserInBackground,
- setUserPropertyForUser,
- setUserPropertyForUserInBackground,
- setUserPropertyForSession,
- setUserPropertyForSessionInBackground,
- setUserPropertyForAnalyticsId,
- updateEditingSession,
- getIdsFromSession,
- registerAccountMapping,
- registerEmailChange,
- analyticsIdMiddleware: expressify(analyticsIdMiddleware),
- }
|