Merge pull request #33482 from overleaf/ar-mixpanel-labs-project

[web/analytics] Send labs user's events to separate mixpanel project

GitOrigin-RevId: 42612b71d2d7a082ffbe1ff614499a0b94553b90
This commit is contained in:
Andrew Rumble
2026-05-29 08:05:57 +00:00
committed by Copybot
parent 5f7e81aafc
commit bb7643f697
11 changed files with 227 additions and 83 deletions
@@ -1,5 +1,5 @@
import SessionManager from '../Authentication/SessionManager.mjs'
import UserAnalyticsIdCache from './UserAnalyticsIdCache.mjs'
import UserAnalyticsDataCache from './UserAnalyticsDataCache.mjs'
import Settings from '@overleaf/settings'
import Metrics from '../../infrastructure/Metrics.mjs'
import Queues from '../../infrastructure/Queues.mjs'
@@ -34,7 +34,7 @@ const ONE_MINUTE_MS = 60 * 1000
const UUID_REGEXP = /^[\w]{8}(-[\w]{4}){3}-[\w]{12}$/
function identifyUser(userId, analyticsId, isNewUser) {
function identifyUser(userId, analyticsId, isNewUser, isLabsUser = false) {
if (!userId || !analyticsId || !analyticsId.toString().match(UUID_REGEXP)) {
return
}
@@ -46,7 +46,13 @@ function identifyUser(userId, analyticsId, isNewUser) {
'analytics-events',
{
name: 'identify',
data: { userId, analyticsId, isNewUser, createdAt: new Date() },
data: {
userId,
analyticsId,
isNewUser,
isLabsUser,
createdAt: new Date(),
},
},
ONE_MINUTE_MS
)
@@ -65,12 +71,20 @@ async function recordEventForUser(userId, event, segmentation) {
if (_isAnalyticsDisabled() || _isSmokeTestUser(userId)) {
return
}
const analyticsId = await UserAnalyticsIdCache.getWithMetrics(
userId,
`recordEventForUser:${event}`
)
const { analyticsId, labsProgram } =
await UserAnalyticsDataCache.getAnalyticsData(
userId,
`recordEventForUser:${event}`
)
if (analyticsId) {
_recordEvent({ analyticsId, userId, event, segmentation, isLoggedIn: true })
_recordEvent({
analyticsId,
userId,
event,
segmentation,
isLabsUser: Boolean(labsProgram),
isLoggedIn: true,
})
}
}
@@ -91,10 +105,14 @@ function recordEventForSession(session, event, segmentation) {
if (_isAnalyticsDisabled() || _isSmokeTestUser(userId)) {
return
}
const isLabsUser = getIsLabsUserFromSession(session)
_recordEvent({
analyticsId,
userId,
event,
isLabsUser,
segmentation,
isLoggedIn: !!userId,
createdAt: new Date(),
@@ -116,12 +134,18 @@ async function setUserPropertyForUser(userId, propertyName, propertyValue) {
_checkPropertyValue(propertyValue)
const analyticsId = await UserAnalyticsIdCache.getWithMetrics(
userId,
`setUserPropertyForUser:${propertyName}`
)
const { analyticsId, labsProgram } =
await UserAnalyticsDataCache.getAnalyticsData(
userId,
`setUserPropertyForUser:${propertyName}`
)
if (analyticsId) {
await _setUserProperty({ analyticsId, propertyName, propertyValue })
await _setUserProperty({
analyticsId,
isLabsUser: Boolean(labsProgram),
propertyName,
propertyValue,
})
}
}
@@ -157,7 +181,13 @@ async function setUserPropertyForSession(session, propertyName, propertyValue) {
_checkPropertyValue(propertyValue)
if (analyticsId) {
await _setUserProperty({ analyticsId, propertyName, propertyValue })
const isLabsUser = getIsLabsUserFromSession(session)
await _setUserProperty({
analyticsId,
isLabsUser,
propertyName,
propertyValue,
})
}
}
@@ -311,7 +341,7 @@ function updateEditingSession(userId, projectId, countryCode, segmentation) {
}
function _recordEvent(
{ analyticsId, userId, event, segmentation, isLoggedIn },
{ analyticsId, userId, event, segmentation, isLabsUser, isLoggedIn },
{ delay } = {}
) {
if (!_isAttributeValid(event)) {
@@ -333,6 +363,7 @@ function _recordEvent(
analyticsId,
userId,
event,
isLabsUser,
segmentation,
isLoggedIn: !!userId,
createdAt: new Date(),
@@ -348,6 +379,7 @@ function _recordEvent(
userId,
event,
segmentation,
isLabsUser,
isLoggedIn,
createdAt: new Date(),
},
@@ -361,7 +393,12 @@ function _recordEvent(
})
}
async function _setUserProperty({ analyticsId, propertyName, propertyValue }) {
async function _setUserProperty({
analyticsId,
isLabsUser,
propertyName,
propertyValue,
}) {
if (!_isAttributeValid(propertyName)) {
logger.info(
{ analyticsId, propertyName, propertyValue },
@@ -383,6 +420,7 @@ async function _setUserProperty({ analyticsId, propertyName, propertyValue }) {
await analyticsUserPropertiesQueue
.add('user-property', {
analyticsId,
isLabsUser,
propertyName,
propertyValue,
createdAt: new Date(),
@@ -448,6 +486,11 @@ function getIdsFromSession(session) {
return { analyticsId, userId }
}
function getIsLabsUserFromSession(session) {
const user = SessionManager.getSessionUser(session)
return user?.labsProgram ?? false
}
async function analyticsIdMiddleware(req, res, next) {
const session = req.session
const sessionUser = SessionManager.getSessionUser(session)
@@ -457,7 +500,7 @@ async function analyticsIdMiddleware(req, res, next) {
session.analyticsId = sessionUser.analyticsId
if (!session.analyticsId) {
session.analyticsId = sessionUser.analyticsId =
await UserAnalyticsIdCache.getWithMetrics(
await UserAnalyticsDataCache.getAnalyticsId(
sessionUser._id,
// Do not drill down further, this middleware is on all endpoints.
'analyticsIdMiddleware'
@@ -0,0 +1,74 @@
import UserGetter from '../User/UserGetter.mjs'
import { CacheLoader } from 'cache-flow'
import Metrics from '@overleaf/metrics'
class UserAnalyticsDataCache extends CacheLoader {
constructor() {
super('user-analytics-id', {
expirationTime: 60,
maxSize: 10000,
})
}
async load(userId) {
const user = await UserGetter.promises.getUser(userId, {
analyticsId: 1,
labsProgram: 1,
})
if (user) {
return {
analyticsId: user.analyticsId || user._id.toString(),
labsProgram: user.labsProgram,
}
}
}
keyToString(userId) {
if (userId) {
return userId.toString()
}
}
get() {
throw new Error('use UserAnalyticsDataCache.getWithMetrics')
}
async getWithMetrics(userId, path) {
const { value, cached } = await this.getWithMetadata(userId)
Metrics.inc('user_analytics_id_cache', 1, {
status: cached ? 'hit' : 'miss',
path,
})
return value
}
async getIsLabsUserWithMetrics(userId, path) {
const { value, cached } = await this.getWithMetadata(userId)
Metrics.inc('user_analytics_id_cache', 1, {
status: cached ? 'hit' : 'miss',
path,
})
return value?.labsProgram
}
}
const userAnalyticsDataCache = new UserAnalyticsDataCache()
export default {
async getAnalyticsId(userId, path) {
const value = await userAnalyticsDataCache.getWithMetrics(userId, path)
return value?.analyticsId
},
async getAnalyticsData(userId, path) {
const data = await userAnalyticsDataCache.getWithMetrics(userId, path)
if (!data) {
return {}
}
return data
},
async reset() {
await userAnalyticsDataCache.reset()
},
async invalidateCache(userId) {
await userAnalyticsDataCache.delete(userId)
},
}
@@ -1,45 +0,0 @@
import UserGetter from '../User/UserGetter.mjs'
import { CacheLoader } from 'cache-flow'
import { callbackify } from 'node:util'
import Metrics from '@overleaf/metrics'
class UserAnalyticsIdCache extends CacheLoader {
constructor() {
super('user-analytics-id', {
expirationTime: 60,
maxSize: 10000,
})
}
async load(userId) {
const user = await UserGetter.promises.getUser(userId, { analyticsId: 1 })
if (user) {
return user.analyticsId || user._id.toString()
}
}
keyToString(userId) {
if (userId) {
return userId.toString()
}
}
get() {
throw new Error('use UserAnalyticsIdCache.getWithMetrics')
}
async getWithMetrics(userId, path) {
const { value, cached } = await this.getWithMetadata(userId)
Metrics.inc('user_analytics_id_cache', 1, {
status: cached ? 'hit' : 'miss',
path,
})
return value
}
}
const userAnalyticsIdCache = new UserAnalyticsIdCache()
userAnalyticsIdCache.callbacks = {
get: callbackify(userAnalyticsIdCache.get).bind(userAnalyticsIdCache),
}
export default userAnalyticsIdCache
@@ -84,6 +84,7 @@ const AuthenticationController = {
analyticsId: user.analyticsId || user._id,
alphaProgram: user.alphaProgram || undefined, // only store if set
betaProgram: user.betaProgram || undefined, // only store if set
labsProgram: user.labsProgram || undefined, // only store if set
}
if (user.isAdmin) {
lightUser.isAdmin = true
@@ -660,7 +661,12 @@ function _loginAsyncHandlers(req, user, anonymousAnalyticsId, isNewUser) {
? 'saml'
: req.user_info?.auth_provider || 'email-password',
})
Analytics.identifyUser(user._id, anonymousAnalyticsId, isNewUser)
Analytics.identifyUser(
user._id,
anonymousAnalyticsId,
isNewUser,
Boolean(user.labsProgram)
)
logger.debug(
{ email: user.email, userId: user._id.toString() },
@@ -7,7 +7,7 @@ import UserGetter from '../User/UserGetter.mjs'
import ClsiManager from './ClsiManager.mjs'
import Metrics from '@overleaf/metrics'
import { RateLimiter } from '../../infrastructure/RateLimiter.mjs'
import UserAnalyticsIdCache from '../Analytics/UserAnalyticsIdCache.mjs'
import UserAnalyticsDataCache from '../Analytics/UserAnalyticsDataCache.mjs'
import { callbackify, callbackifyMultiResult } from '@overleaf/promise-utils'
let CompileManager
const rclient = RedisWrapper.client('clsi_recently_compiled')
@@ -138,7 +138,7 @@ async function _getUserCompileLimits(userId) {
ownerFeatures.compileGroup = 'alpha'
}
const analyticsId = await UserAnalyticsIdCache.getWithMetrics(
const analyticsId = await UserAnalyticsDataCache.getAnalyticsId(
owner._id,
'_getUserCompileLimits'
)
@@ -7,7 +7,7 @@ import _ from 'lodash'
import { callbackify } from 'node:util'
import SplitTestCache from './SplitTestCache.mjs'
import { SplitTest } from '../../models/SplitTest.mjs'
import UserAnalyticsIdCache from '../Analytics/UserAnalyticsIdCache.mjs'
import UserAnalyticsDataCache from '../Analytics/UserAnalyticsDataCache.mjs'
import Features from '../../infrastructure/Features.mjs'
import SplitTestUtils from './SplitTestUtils.mjs'
import Settings from '@overleaf/settings'
@@ -140,7 +140,7 @@ async function getAssignmentForUser(
return _getNonSaasAssignment(splitTestName)
}
const analyticsId = await UserAnalyticsIdCache.getWithMetrics(
const analyticsId = await UserAnalyticsDataCache.getAnalyticsId(
userId,
`getAssignmentForUser:${splitTestName}`
)