Merge pull request #30366 from overleaf/mj-em-package-meta

[web] Add event for package usage

GitOrigin-RevId: e994becf01e7e4c8642cd1815ffe05907a5fd63c
This commit is contained in:
Eric Mc Sween
2026-01-08 09:05:53 +00:00
committed by Copybot
parent 75734993e7
commit f2a70de6ef
7 changed files with 82 additions and 9 deletions
@@ -28,6 +28,7 @@ const analyticsAccountMappingQueue = Queues.getQueue(
'analytics-account-mapping' 'analytics-account-mapping'
) )
const analyticsEmailChangeQueue = Queues.getQueue('analytics-email-change') const analyticsEmailChangeQueue = Queues.getQueue('analytics-email-change')
const analyticsPackageUsageQueue = Queues.getQueue('analytics-package-usage')
const ONE_MINUTE_MS = 60 * 1000 const ONE_MINUTE_MS = 60 * 1000
@@ -97,6 +98,14 @@ function recordEventForSession(session, event, segmentation) {
}) })
} }
function emitPackageUsage(projectId, { documentClasses, packages }) {
analyticsPackageUsageQueue
.add('package-usage', { projectId, documentClasses, packages })
.catch(err => {
logger.warn({ err, projectId }, 'Failed to emit package usage')
})
}
async function setUserPropertyForUser(userId, propertyName, propertyValue) { async function setUserPropertyForUser(userId, propertyName, propertyValue) {
if (_isAnalyticsDisabled() || _isSmokeTestUser(userId)) { if (_isAnalyticsDisabled() || _isSmokeTestUser(userId)) {
return return
@@ -454,6 +463,7 @@ export default {
recordEventForSession, recordEventForSession,
recordEventForUser, recordEventForUser,
recordEventForUserInBackground, recordEventForUserInBackground,
emitPackageUsage,
setUserPropertyForUser, setUserPropertyForUser,
setUserPropertyForUserInBackground, setUserPropertyForUserInBackground,
setUserPropertyForSession, setUserPropertyForSession,
@@ -3,6 +3,8 @@ import EditorRealTimeController from '../Editor/EditorRealTimeController.mjs'
import MetaHandler from './MetaHandler.mjs' import MetaHandler from './MetaHandler.mjs'
import logger from '@overleaf/logger' import logger from '@overleaf/logger'
import { expressify } from '@overleaf/promise-utils' import { expressify } from '@overleaf/promise-utils'
import Analytics from '../Analytics/AnalyticsManager.mjs'
import SplitTestHandler from '../SplitTests/SplitTestHandler.mjs'
async function getMetadata(req, res) { async function getMetadata(req, res) {
const { project_id: projectId } = req.params const { project_id: projectId } = req.params
@@ -22,9 +24,37 @@ async function getMetadata(req, res) {
) )
} }
const assignment = await SplitTestHandler.promises.getAssignment(
req,
res,
'emit-package-usage-statistics'
)
if (assignment.variant === 'enabled') {
sendAnalyticsEventForPackageUsage(projectId, projectMeta)
}
res.json({ projectId, projectMeta }) res.json({ projectId, projectMeta })
} }
function sendAnalyticsEventForPackageUsage(projectId, projectMeta) {
const packagesSet = new Set()
const documentClassesSet = new Set()
for (const docMeta of Object.values(projectMeta)) {
if (docMeta.documentClass != null) {
documentClassesSet.add(docMeta.documentClass)
}
for (const packageName of docMeta.packageNames) {
if (packageName.match(/^[a-zA-Z0-9-_]+$/)) {
packagesSet.add(packageName)
}
}
}
const packages = Array.from(packagesSet)
const documentClasses = Array.from(documentClassesSet)
Analytics.emitPackageUsage(projectId, { documentClasses, packages })
}
async function broadcastMetadataForDoc(req, res) { async function broadcastMetadataForDoc(req, res) {
const { project_id: projectId } = req.params const { project_id: projectId } = req.params
const { doc_id: docId } = req.params const { doc_id: docId } = req.params
@@ -7,9 +7,16 @@ import { callbackify } from '@overleaf/promise-utils'
* labels: string[] * labels: string[]
* packages: Record<string, Record<string, any>>, * packages: Record<string, Record<string, any>>,
* packageNames: string[], * packageNames: string[],
* documentClass: string | null
* }} DocMeta * }} DocMeta
*/ */
const LABEL_RE = /\\label{(.{0,80}?)}/g
const LABEL_OPTION_RE = /\blabel={?(.{0,80}?)[\s},\]]/g
const PACKAGE_RE = /^\\usepackage(?:\[.{0,80}?])?{(.{0,80}?)}/g
const REQ_PACKAGE_RE = /^\\RequirePackage(?:\[.{0,80}?])?{(.{0,80}?)}/g
const DOCUMENT_CLASS_RE = /^\\documentclass(?:\[.{0,80}?])?{(.{0,80}?)}/
/** /**
* @param {string[]} lines * @param {string[]} lines
* @return {Promise<DocMeta>} * @return {Promise<DocMeta>}
@@ -20,31 +27,34 @@ async function extractMetaFromDoc(lines) {
labels: [], labels: [],
packages: {}, packages: {},
packageNames: [], packageNames: [],
documentClass: null,
} }
const labelRe = /\\label{(.{0,80}?)}/g
const labelOptionRe = /\blabel={?(.{0,80}?)[\s},\]]/g
const packageRe = /^\\usepackage(?:\[.{0,80}?])?{(.{0,80}?)}/g
const reqPackageRe = /^\\RequirePackage(?:\[.{0,80}?])?{(.{0,80}?)}/g
for (const rawLine of lines) { for (const rawLine of lines) {
const line = getNonCommentedContent(rawLine) const line = getNonCommentedContent(rawLine)
for (const label of lineMatches(labelRe, line)) { for (const label of lineMatches(LABEL_RE, line)) {
docMeta.labels.push(label) docMeta.labels.push(label)
} }
for (const label of lineMatches(labelOptionRe, line)) { for (const label of lineMatches(LABEL_OPTION_RE, line)) {
docMeta.labels.push(label) docMeta.labels.push(label)
} }
for (const pkg of lineMatches(packageRe, line, ',')) { for (const pkg of lineMatches(PACKAGE_RE, line, ',')) {
docMeta.packageNames.push(pkg) docMeta.packageNames.push(pkg)
} }
for (const pkg of lineMatches(reqPackageRe, line, ',')) { for (const pkg of lineMatches(REQ_PACKAGE_RE, line, ',')) {
docMeta.packageNames.push(pkg) docMeta.packageNames.push(pkg)
} }
if (docMeta.documentClass == null) {
const match = line.match(DOCUMENT_CLASS_RE)
if (match != null) {
docMeta.documentClass = match[1]
}
}
} }
for (const packageName of docMeta.packageNames) { for (const packageName of docMeta.packageNames) {
@@ -30,6 +30,9 @@ const QUEUES_JOB_OPTIONS = {
'analytics-email-change': { 'analytics-email-change': {
removeOnFail: MAX_FAILED_JOBS_RETAINED_ANALYTICS, removeOnFail: MAX_FAILED_JOBS_RETAINED_ANALYTICS,
}, },
'analytics-package-usage': {
removeOnFail: MAX_FAILED_JOBS_RETAINED_ANALYTICS,
},
'emails-onboarding': { 'emails-onboarding': {
removeOnFail: MAX_FAILED_JOBS_RETAINED, removeOnFail: MAX_FAILED_JOBS_RETAINED,
}, },
@@ -73,6 +76,7 @@ const ANALYTICS_QUEUES = [
'analytics-email-change', 'analytics-email-change',
'analytics-events', 'analytics-events',
'analytics-editing-sessions', 'analytics-editing-sessions',
'analytics-package-usage',
'analytics-user-properties', 'analytics-user-properties',
'analytics-user-exports', 'analytics-user-exports',
'post-registration-analytics', 'post-registration-analytics',
@@ -50,6 +50,10 @@ describe('AnalyticsManager', function () {
add: sinon.stub().resolves(), add: sinon.stub().resolves(),
process: sinon.stub().resolves(), process: sinon.stub().resolves(),
} }
ctx.analyticsPackageUsageQueue = {
add: sinon.stub().resolves(),
process: sinon.stub().resolves(),
}
ctx.Queues = { ctx.Queues = {
getQueue: queueName => { getQueue: queueName => {
switch (queueName) { switch (queueName) {
@@ -65,6 +69,8 @@ describe('AnalyticsManager', function () {
return ctx.analyticsAccountMappingQueue return ctx.analyticsAccountMappingQueue
case 'analytics-email-change': case 'analytics-email-change':
return ctx.analyticsEmailChangeQueue return ctx.analyticsEmailChangeQueue
case 'analytics-package-usage':
return ctx.analyticsPackageUsageQueue
default: default:
throw new Error('Unexpected queue name') throw new Error('Unexpected queue name')
} }
@@ -372,6 +378,8 @@ describe('AnalyticsManager', function () {
return ctx.analyticsAccountMappingQueue return ctx.analyticsAccountMappingQueue
case 'analytics-email-change': case 'analytics-email-change':
return ctx.analyticsEmailChangeQueue return ctx.analyticsEmailChangeQueue
case 'analytics-package-usage':
return ctx.analyticsPackageUsageQueue
default: default:
throw new Error('Unexpected queue name') throw new Error('Unexpected queue name')
} }
@@ -27,6 +27,11 @@ describe('MetaController', function () {
default: ctx.MetaHandler, default: ctx.MetaHandler,
})) }))
vi.doMock(
'../../../../app/src/Features/Analytics/AnalyticsManager',
() => ({ default: {} })
)
ctx.MetadataController = (await import(modulePath)).default ctx.MetadataController = (await import(modulePath)).default
}) })
@@ -103,6 +103,7 @@ describe('MetaHandler', function () {
baz: ctx.packageMapping.baz, baz: ctx.packageMapping.baz,
}, },
packageNames: ['foo', 'bar', 'baz'], packageNames: ['foo', 'bar', 'baz'],
documentClass: null,
}) })
ctx.DocumentUpdaterHandler.promises.flushDocToMongo.should.be.calledWith( ctx.DocumentUpdaterHandler.promises.flushDocToMongo.should.be.calledWith(
@@ -161,16 +162,19 @@ describe('MetaHandler', function () {
labels: ['aaa'], labels: ['aaa'],
packages: {}, packages: {},
packageNames: [], packageNames: [],
documentClass: null,
}, },
id_two: { id_two: {
labels: [], labels: [],
packages: {}, packages: {},
packageNames: [], packageNames: [],
documentClass: null,
}, },
id_three: { id_three: {
labels: ['bbb', 'ccc'], labels: ['bbb', 'ccc'],
packages: {}, packages: {},
packageNames: [], packageNames: [],
documentClass: null,
}, },
id_four: { id_four: {
labels: [], labels: [],
@@ -185,6 +189,7 @@ describe('MetaHandler', function () {
], ],
}, },
packageNames: ['baz', 'amsmath'], packageNames: ['baz', 'amsmath'],
documentClass: null,
}, },
id_five: { id_five: {
labels: ['sec:intro'], labels: ['sec:intro'],
@@ -213,6 +218,7 @@ describe('MetaHandler', function () {
], ],
}, },
packageNames: ['foo', 'baz', 'hello'], packageNames: ['foo', 'baz', 'hello'],
documentClass: null,
}, },
}) })