diff --git a/src/config/discovery.config.js b/src/config/discovery.config.js index ecfb1d4ff..5c398db16 100644 --- a/src/config/discovery.config.js +++ b/src/config/discovery.config.js @@ -175,6 +175,7 @@ require.config({ router: 'js/apps/discovery/router', analytics: 'js/components/analytics', utils: 'js/utils', + performance: 'js/utils/performance', reactify: 'js/plugins/reactify', es6: 'js/plugins/es6', suit: 'shared/dist/index.umd.development', diff --git a/src/config/sentry.js b/src/config/sentry.js index f120593eb..6bd811b89 100644 --- a/src/config/sentry.js +++ b/src/config/sentry.js @@ -182,6 +182,11 @@ tracesSampleRate: window.ENV === 'development' ? 1.0 : 0.75, debug: false, enableLogs: true, + initialScope: { + tags: { + app: 'bumblebee', + }, + }, _experiments: { enableStandaloneLcpSpans: true, enableStandaloneClsSpans: true, diff --git a/src/js/components/library_controller.js b/src/js/components/library_controller.js index 1908cc8c0..b28c9b9bc 100644 --- a/src/js/components/library_controller.js +++ b/src/js/components/library_controller.js @@ -8,6 +8,7 @@ define([ 'js/components/api_query', 'js/mixins/dependon', 'utils', + 'performance', ], function( Backbone, GenericModule, @@ -17,7 +18,8 @@ define([ ApiFeedback, ApiQuery, Dependon, - utils + utils, + performance ) { var LibraryModel = Backbone.Model.extend({ defaults: function() { @@ -269,10 +271,15 @@ define([ var that = this; var endpoint = ApiTargets.LIBRARIES; - return this.composeRequest(endpoint, 'GET').done(function(data) { - that._metadataLoaded = true; - that.collection.reset(data.libraries); - }); + return performance.trackDeferred( + performance.PERF_SPANS.LIBRARY_LIST_LOAD, + function() { + return that.composeRequest(endpoint, 'GET').done(function(data) { + that._metadataLoaded = true; + that.collection.reset(data.libraries); + }); + } + ); }, /* @@ -435,10 +442,15 @@ define([ var that = this; var endpoint = ApiTargets.LIBRARIES; - return this.composeRequest(endpoint, 'POST', { data: data }).done( + return performance.trackDeferred( + performance.PERF_SPANS.LIBRARY_CREATE_TOTAL, function() { - // refresh collection - that._fetchAllMetadata(); + return that.composeRequest(endpoint, 'POST', { data: data }).done( + function() { + // refresh collection + that._fetchAllMetadata(); + } + ); } ); }, @@ -644,29 +656,32 @@ define([ */ addBibcodesToLib: function(data) { var that = this; - var promise = this._getBibcodes(data).then(function(bibcodes) { - // should return success or fail message - return that - .updateLibraryContents(data.library, { - bibcode: bibcodes, - action: 'add', - }) - .fail(function() { - var message = - 'Library ' + - that.collection.get(data.library).title + - ' could not be updated'; - that - .getBeeHive() - .getService('PubSub') - .publish( - that.getBeeHive().getService('PubSub').ALERT, - new ApiFeedback({ code: 0, msg: message, type: 'danger' }) - ); + return performance.trackDeferred( + performance.PERF_SPANS.LIBRARY_ADD_TOTAL, + function() { + return that._getBibcodes(data).then(function(bibcodes) { + // should return success or fail message + return that + .updateLibraryContents(data.library, { + bibcode: bibcodes, + action: 'add', + }) + .fail(function() { + var message = + 'Library ' + + that.collection.get(data.library).title + + ' could not be updated'; + that + .getBeeHive() + .getService('PubSub') + .publish( + that.getBeeHive().getService('PubSub').ALERT, + new ApiFeedback({ code: 0, msg: message, type: 'danger' }) + ); + }); }); - }); - - return promise; + } + ); }, /* fetch the bibcodes, then POST to the create endpoint with the bibcodes diff --git a/src/js/components/navigator.js b/src/js/components/navigator.js index cda9098e5..eb0a5ceea 100644 --- a/src/js/components/navigator.js +++ b/src/js/components/navigator.js @@ -21,6 +21,7 @@ define([ 'js/components/transition', 'js/components/transition_catalog', 'analytics', + 'performance', ], function ( _, $, @@ -30,6 +31,7 @@ define([ Transition, TransitionCatalog, analytics, + performance, ) { // Document Title Constants var APP_TITLE = 'Astrophysics Data System'; @@ -108,6 +110,11 @@ define([ _onCustomEvent: function (ev, data) { switch (ev) { case 'timing:results-loaded': + // End search submit span if one was started + if (this._searchSubmitSpan) { + this._searchSubmitSpan.end(); + this._searchSubmitSpan = null; + } withSentry((sentry) => { try { const span = sentry.getActiveSpan && sentry.getActiveSpan(); @@ -126,6 +133,13 @@ define([ } catch (_) {} }); break; + case 'timing:search-started': + // Start search submit span + this._searchSubmitSpan = performance.startRenderSpan( + performance.PERF_SPANS.SEARCH_SUBMIT_TOTAL, + data + ); + break; case 'update-document-title': this._updateDocumentTitle(data); break; diff --git a/src/js/modules/orcid/orcid_api.js b/src/js/modules/orcid/orcid_api.js index c286b391f..65a508ebf 100644 --- a/src/js/modules/orcid/orcid_api.js +++ b/src/js/modules/orcid/orcid_api.js @@ -55,6 +55,7 @@ define([ 'js/modules/orcid/work', 'js/modules/orcid/profile', 'js/modules/orcid/bio', + 'performance', ], function( _, Bootstrap, @@ -72,7 +73,8 @@ define([ ApiFeedback, Work, Profile, - Bio + Bio, + performance ) { var OrcidApi = GenericModule.extend({ /** @@ -422,30 +424,39 @@ define([ */ _getUserProfile: function() { var self = this; - var request = this.createRequest(this.getUrl('profile_full')); // get everything so far in the cache var cache = self.getUserProfileCache.splice(0); - request.done(function(profile) { - _.forEach(cache, function(promise) { - orcidProfile = new Profile(profile); - promise.resolve( - orcidProfile.setWorks( - _.map(profile, function(profile, idx) { - return new Work(profile); - }) - ) - ); - }); - }); + // Track the profile load with performance span + performance.trackDeferred( + performance.PERF_SPANS.ORCID_PROFILE_LOAD, + function() { + var request = self.createRequest(self.getUrl('profile_full')); + + request.done(function(profile) { + _.forEach(cache, function(promise) { + orcidProfile = new Profile(profile); + promise.resolve( + orcidProfile.setWorks( + _.map(profile, function(profile, idx) { + return new Work(profile); + }) + ) + ); + }); + }); - request.fail(function() { - var args = arguments; - _.forEach(cache, function(promise) { - promise.reject.apply(promise, args); - }); - }); + request.fail(function() { + var args = arguments; + _.forEach(cache, function(promise) { + promise.reject.apply(promise, args); + }); + }); + + return request; + } + ); }, /** diff --git a/src/js/utils/performance.js b/src/js/utils/performance.js new file mode 100644 index 000000000..d664473ef --- /dev/null +++ b/src/js/utils/performance.js @@ -0,0 +1,212 @@ +/** + * Performance tracking utilities for Sentry instrumentation. + * Provides consistent span names and tags for comparison with Nectar (SciX). + */ +define([], function () { + 'use strict'; + + /** + * Span name constants for performance tracking. + * Naming convention: {domain}.{action}.{phase} + */ + var PERF_SPANS = { + SEARCH_SUBMIT_TOTAL: 'search.submit.total', + SEARCH_QUERY_REQUEST: 'search.query.request', + SEARCH_RESULTS_RENDER: 'search.results.render', + SEARCH_FACETS_RENDER: 'search.facets.render', + SEARCH_PAGINATION_TOTAL: 'search.pagination.total', + ABSTRACT_LOAD_TOTAL: 'abstract.load.total', + ABSTRACT_METRICS_REQUEST: 'abstract.metrics.request', + ABSTRACT_CITATIONS_LOAD: 'abstract.citations.load', + ABSTRACT_REFERENCES_LOAD: 'abstract.references.load', + EXPORT_GENERATE_TOTAL: 'export.generate.total', + EXPORT_API_REQUEST: 'export.api.request', + LIBRARY_LIST_LOAD: 'library.list.load', + LIBRARY_ADD_TOTAL: 'library.add.total', + LIBRARY_CREATE_TOTAL: 'library.create.total', + AUTH_LOGIN_TOTAL: 'auth.login.total', + AUTH_REGISTER_TOTAL: 'auth.register.total', + AUTH_SESSION_VALIDATE: 'auth.session.validate', + ORCID_OAUTH_TOTAL: 'orcid.oauth.total', + ORCID_SYNC_TOTAL: 'orcid.sync.total', + ORCID_CLAIM_TOTAL: 'orcid.claim.total', + ORCID_PROFILE_LOAD: 'orcid.profile.load', + }; + + /** + * Get the result count bucket for tagging. + * @param {number} count - The number of results. + * @returns {string} The bucket label. + */ + function getResultCountBucket(count) { + if (count === 0) { + return '0'; + } + if (count <= 10) { + return '1-10'; + } + if (count <= 100) { + return '11-100'; + } + return '100+'; + } + + /** + * Get the query type based on query string analysis. + * @param {string} query - The search query string. + * @returns {string} The query type: 'simple', 'fielded', or 'boolean'. + */ + function getQueryType(query) { + if (!query || typeof query !== 'string') { + return 'simple'; + } + if (/\b(AND|OR|NOT)\b/.test(query)) { + return 'boolean'; + } + if (/\w+:/.test(query)) { + return 'fielded'; + } + return 'simple'; + } + + /** + * Track a user flow by wrapping an async operation in a Sentry span. + * Works with both Promises and jQuery Deferreds. + * + * @param {string} name - The span name from PERF_SPANS. + * @param {Function} fn - Function returning a Promise or Deferred. + * @param {Object} [tags] - Optional tags to attach to the span. + * @returns {Promise|Deferred} The result of fn(). + */ + function trackUserFlow(name, fn, tags) { + if (typeof window.whenSentryReady !== 'function') { + return fn(); + } + + var result; + var spanRef = { span: null }; + + window.whenSentryReady().then(function (Sentry) { + try { + if (typeof Sentry.startSpan !== 'function') { + return; + } + + Sentry.startSpan( + { + name: name, + op: 'user.flow', + attributes: tags || {}, + }, + function (span) { + spanRef.span = span; + } + ); + } catch (_) { + // Never let span creation break the main flow + } + }); + + var endSpan = function (status, message) { + try { + if (spanRef.span) { + spanRef.span.setStatus({ code: status, message: message }); + spanRef.span.end(); + } + } catch (_) { + // Never let span operations break the main flow + } + }; + + try { + result = fn(); + } catch (err) { + endSpan(2, err.message || 'Error'); + throw err; + } + + if (result && typeof result.then === 'function') { + var handleSuccess = function (data) { + endSpan(1); + return data; + }; + + var handleError = function (err) { + var msg = err && err.message ? err.message : 'Unknown error'; + endSpan(2, msg); + throw err; + }; + + if (typeof result.done === 'function' && typeof result.fail === 'function') { + result.done(handleSuccess).fail(handleError); + } else { + result.then(handleSuccess, handleError); + } + } else { + endSpan(1); + } + + return result; + } + + /** + * Start a render span for tracking UI rendering performance. + * Returns an object with an end() method to call when rendering completes. + * + * @param {string} name - The span name from PERF_SPANS. + * @param {Object} [tags] - Optional tags to attach to the span. + * @returns {Object} Object with end() method. + */ + function startRenderSpan(name, tags) { + var spanRef = { span: null }; + + if (typeof window.whenSentryReady === 'function') { + window.whenSentryReady().then(function (Sentry) { + try { + if (typeof Sentry.startInactiveSpan === 'function') { + spanRef.span = Sentry.startInactiveSpan({ + name: name, + op: 'ui.render', + attributes: tags || {}, + }); + } + } catch (_) { + // Never let span creation break the main flow + } + }); + } + + return { + end: function () { + try { + if (spanRef.span && typeof spanRef.span.end === 'function') { + spanRef.span.end(); + } + } catch (_) { + // Never let span operations break the main flow + } + }, + }; + } + + /** + * Wrap a jQuery Deferred-returning function with performance tracking. + * + * @param {string} name - The span name from PERF_SPANS. + * @param {Function} fn - Function returning a jQuery Deferred. + * @param {Object} [tags] - Optional tags to attach to the span. + * @returns {jQuery.Deferred} The Deferred from fn(). + */ + function trackDeferred(name, fn, tags) { + return trackUserFlow(name, fn, tags); + } + + return { + PERF_SPANS: PERF_SPANS, + getResultCountBucket: getResultCountBucket, + getQueryType: getQueryType, + trackUserFlow: trackUserFlow, + startRenderSpan: startRenderSpan, + trackDeferred: trackDeferred, + }; +}); diff --git a/src/js/widgets/export/actions/index.js b/src/js/widgets/export/actions/index.js index 68c062c31..9ce69b609 100644 --- a/src/js/widgets/export/actions/index.js +++ b/src/js/widgets/export/actions/index.js @@ -7,7 +7,8 @@ define([ 'js/components/api_query', 'js/components/api_targets', 'filesaver', -], function(_, $, ApiQuery, ApiTargets) { + 'performance', +], function(_, $, ApiQuery, ApiTargets, saveAs, performance) { // set of action names const actions = { SET_TAB: 'SET_TAB', @@ -290,22 +291,25 @@ define([ }, }); - // send off the request - return ( - widget - ._executeApiRequest(req) - .done((res) => { - // if we are ignoring, then don't bother with the response - if (!exports.ignore) { - dispatch(receiveExport(res.get('export'))); - } - - // stop ignoring - dispatch(setIgnore(false)); - }) - - // on failure, send off to our handler - .fail((...args) => dispatch(requestFailed(...args))) + // send off the request with performance tracking + return performance.trackDeferred( + performance.PERF_SPANS.EXPORT_API_REQUEST, + () => + widget + ._executeApiRequest(req) + .done((res) => { + // if we are ignoring, then don't bother with the response + if (!exports.ignore) { + dispatch(receiveExport(res.get('export'))); + } + + // stop ignoring + dispatch(setIgnore(false)); + }) + + // on failure, send off to our handler + .fail((...args) => dispatch(requestFailed(...args))), + { export_format: format.value } ); }; diff --git a/src/js/widgets/results/widget.js b/src/js/widgets/results/widget.js index b6a0ff5ca..05b941764 100644 --- a/src/js/widgets/results/widget.js +++ b/src/js/widgets/results/widget.js @@ -19,6 +19,7 @@ define([ 'js/components/api_request', 'js/components/api_query', 'js/components/api_targets', + 'performance', ], function( ListOfThingsWidget, AbstractWidget, @@ -33,7 +34,8 @@ define([ ApiFeedback, ApiRequest, ApiQuery, - ApiTargets + ApiTargets, + performance ) { var ResultsWidget = ListOfThingsWidget.extend({ initialize: function() { @@ -200,6 +202,20 @@ define([ this.focusInterval = focusInterval; } this.queryTimer = +new Date(); + + // Publish search started event with performance tags + var pubsub = this.getPubSub(); + if (pubsub) { + var qArr = apiQuery && apiQuery.get('q'); + var qStr = Array.isArray(qArr) ? (qArr[0] || '') : ''; + pubsub.publish( + pubsub.CUSTOM_EVENT, + 'timing:search-started', + { + query_type: performance.getQueryType(qStr), + } + ); + } }, _onToggleSidebars: function() {