diff --git a/services/web/frontend/js/ide.js b/services/web/frontend/js/ide.js index fe99db82a0..b486aafa3f 100644 --- a/services/web/frontend/js/ide.js +++ b/services/web/frontend/js/ide.js @@ -68,6 +68,7 @@ import './features/source-editor/controllers/cm6-switch-away-survey-controller' import './features/source-editor/controllers/grammarly-warning-controller' import { cleanupServiceWorker } from './utils/service-worker-cleanup' import { reportCM6Perf } from './infrastructure/cm6-performance' +import { reportAcePerf } from './ide/editor/ace-performance' App.controller( 'IdeController', @@ -299,6 +300,27 @@ If the project has been renamed please look in your project list for a new proje } } } + } else if (editorType === 'ace') { + const acePerfData = reportAcePerf() + + if (acePerfData.numberOfEntries > 0) { + const perfProps = [ + 'NumberOfEntries', + 'MeanKeypressPaint', + 'Grammarly', + 'SessionLength', + 'Memory', + 'Release', + ] + + for (const prop of perfProps) { + const perfValue = + acePerfData[prop.charAt(0).toLowerCase() + prop.slice(1)] + if (perfValue !== null) { + segmentation['acePerf' + prop] = perfValue + } + } + } } return segmentation diff --git a/services/web/frontend/js/ide/editor/ace-performance.ts b/services/web/frontend/js/ide/editor/ace-performance.ts new file mode 100644 index 0000000000..444f6ef886 --- /dev/null +++ b/services/web/frontend/js/ide/editor/ace-performance.ts @@ -0,0 +1,158 @@ +import { round } from 'lodash' +import grammarlyExtensionPresent from '../../shared/utils/grammarly' +import getMeta from '../../utils/meta' + +const TIMER_DOM_UPDATE_NAME = 'Ace-DomUpdate' +const TIMER_MEASURE_NAME = 'Ace-Keypress-Measure' + +const sessionStart = Date.now() + +let performanceOptionsSupport = false + +// Check that performance.mark and performance.measure accept an options object +try { + const testMarkName = 'featureTestMark' + performance.mark(testMarkName, { + startTime: performance.now(), + detail: { test: 1 }, + }) + performance.clearMarks(testMarkName) + + const testMeasureName = 'featureTestMeasure' + performance.measure(testMeasureName, { + start: performance.now(), + detail: { test: 1 }, + }) + performance.clearMeasures(testMeasureName) + + performanceOptionsSupport = true +} catch (e) {} + +let performanceMemorySupport = false + +function measureMemoryUsage() { + // @ts-ignore + return performance.memory.usedJSHeapSize +} + +try { + if ('memory' in window.performance) { + measureMemoryUsage() + performanceMemorySupport = true + } +} catch (e) {} + +let keypressesSinceDomUpdateCount = 0 +const unpaintedKeypressStartTimes: number[] = [] +let animationFrameRequest: number | null = null + +function timeInputToRender() { + if (!performanceOptionsSupport) return + + ++keypressesSinceDomUpdateCount + + unpaintedKeypressStartTimes.push(performance.now()) + + if (!animationFrameRequest) { + animationFrameRequest = window.requestAnimationFrame(() => { + animationFrameRequest = null + + performance.mark(TIMER_DOM_UPDATE_NAME, { + detail: { keypressesSinceDomUpdateCount }, + }) + keypressesSinceDomUpdateCount = 0 + + const keypressEnd = performance.now() + + for (const keypressStart of unpaintedKeypressStartTimes) { + performance.measure(TIMER_MEASURE_NAME, { + start: keypressStart, + end: keypressEnd, + }) + } + unpaintedKeypressStartTimes.length = 0 + }) + } +} + +export function initAcePerfListener(textareaEl: HTMLTextAreaElement) { + textareaEl?.addEventListener('beforeinput', timeInputToRender) +} + +export function tearDownAcePerfListener(textareaEl: HTMLTextAreaElement) { + textareaEl?.removeEventListener('beforeinput', timeInputToRender) +} + +function calculateMean(durations: number[]) { + if (durations.length === 0) return 0 + + const sum = durations.reduce((acc, entry) => acc + entry, 0) + return sum / durations.length +} + +export function reportAcePerf() { + const durations = performance + .getEntriesByName(TIMER_MEASURE_NAME, 'measure') + .map(({ duration }) => duration) + + performance.clearMeasures(TIMER_MEASURE_NAME) + + const meanKeypressPaint = round(calculateMean(durations), 2) + + const grammarly = grammarlyExtensionPresent() + const sessionLength = Math.floor((Date.now() - sessionStart) / 1000) // In seconds + + const memory = performanceMemorySupport ? measureMemoryUsage() : null + + // Get entries for keypress counts between DOM updates + const domUpdateEntries = performance.getEntriesByName( + TIMER_DOM_UPDATE_NAME, + 'mark' + ) as PerformanceMark[] + + performance.clearMarks(TIMER_DOM_UPDATE_NAME) + + let lags = 0 + let nonLags = 0 + let longestLag = 0 + let totalKeypressCount = 0 + + for (const entry of domUpdateEntries) { + const keypressCount = entry.detail.keypressesSinceDomUpdateCount + if (keypressCount === 1) { + ++nonLags + } else if (keypressCount > 1) { + ++lags + } + if (keypressCount > longestLag) { + longestLag = keypressCount + } + totalKeypressCount += keypressCount + } + + const meanLagsPerMeasure = round(lags / (lags + nonLags), 4) + const meanKeypressesPerMeasure = round( + totalKeypressCount / (lags + nonLags), + 4 + ) + + const release = getMeta('ol-ExposedSettings')?.sentryRelease || null + + return { + numberOfEntries: durations.length, + meanKeypressPaint, + grammarly, + sessionLength, + memory, + lags, + nonLags, + longestLag, + meanLagsPerMeasure, + meanKeypressesPerMeasure, + release, + } +} + +window._reportAcePerf = () => { + console.log(reportAcePerf()) +} diff --git a/services/web/frontend/js/ide/editor/directives/aceEditor.js b/services/web/frontend/js/ide/editor/directives/aceEditor.js index ca741a9cb9..4083bc3832 100644 --- a/services/web/frontend/js/ide/editor/directives/aceEditor.js +++ b/services/web/frontend/js/ide/editor/directives/aceEditor.js @@ -22,6 +22,10 @@ import '../../metadata/services/metadata' import '../../graphics/services/graphics' import '../../preamble/services/preamble' import '../../files/services/files' +import { + initAcePerfListener, + tearDownAcePerfListener, +} from '../ace-performance' let syntaxValidationEnabled const { EditSession } = ace.require('ace/edit_session') const ModeList = ace.require('ace/ext/modelist') @@ -779,6 +783,8 @@ App.directive( // now attach session to editor editor.setSession(session) + initAcePerfListener(editor.textInput.getElement()) + const doc = session.getDocument() doc.on('change', onChange) @@ -840,6 +846,9 @@ App.directive( tearDownSpellCheck() tearDownTrackChanges() tearDownUndo() + + tearDownAcePerfListener(editor.textInput.getElement()) + sharejs_doc.detachFromAce() sharejs_doc.off('remoteop.recordRemote') diff --git a/services/web/types/window.ts b/services/web/types/window.ts index 6b9b1d86f1..92cf812519 100644 --- a/services/web/types/window.ts +++ b/services/web/types/window.ts @@ -27,5 +27,6 @@ declare global { } isRestrictedTokenMember: boolean _reportCM6Perf: () => void + _reportAcePerf: () => void } }