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() {