diff --git a/static/app/views/dashboards/constants.tsx b/static/app/views/dashboards/constants.tsx
index ecb766d26df4..68d9d6788b07 100644
--- a/static/app/views/dashboards/constants.tsx
+++ b/static/app/views/dashboards/constants.tsx
@@ -3,3 +3,14 @@ import {t} from 'sentry/locale';
export const DASHBOARD_SAVING_MESSAGE = t('Saving changes\u2026');
export const NUM_DESKTOP_COLS = 6;
+
+// Cache attribute-key results from the `/trace-items/attributes/` endpoint for
+// the widget builder's field pickers. Attribute keys change rarely, so a longer
+// stale time avoids re-fetching on every keystroke while searching.
+export const WIDGET_BUILDER_ATTRIBUTE_STALE_TIME = 5 * 60 * 1000;
+
+// Debounce applied to the widget builder field pickers' search input before it
+// triggers a server-side attribute fetch.
+export const WIDGET_BUILDER_ATTRIBUTE_SEARCH_DEBOUNCE_MS = 200;
+
+export const WIDGET_BUILDER_ATTRIBUTE_LOADING_MESSAGE = t('Loading…');
diff --git a/static/app/views/dashboards/widgetBuilder/components/xAxisSelector.spec.tsx b/static/app/views/dashboards/widgetBuilder/components/xAxisSelector.spec.tsx
new file mode 100644
index 000000000000..05d1ed056709
--- /dev/null
+++ b/static/app/views/dashboards/widgetBuilder/components/xAxisSelector.spec.tsx
@@ -0,0 +1,98 @@
+import {OrganizationFixture} from 'sentry-fixture/organization';
+
+import {render, screen, userEvent, waitFor} from 'sentry-test/reactTestingLibrary';
+
+import {DisplayType, WidgetType} from 'sentry/views/dashboards/types';
+import {WidgetBuilderXAxisSelector} from 'sentry/views/dashboards/widgetBuilder/components/xAxisSelector';
+import {WidgetBuilderProvider} from 'sentry/views/dashboards/widgetBuilder/contexts/widgetBuilderContext';
+
+const DASHBOARD_WIDGET_BUILDER_PATHNAME =
+ '/organizations/org-slug/dashboards/new/widget/new/';
+const DASHBOARD_WIDGET_BUILDER_ROUTE = '/organizations/:orgId/dashboards/new/widget/new/';
+
+const ATTRIBUTES_URL = '/organizations/org-slug/trace-items/attributes/';
+
+const organization = OrganizationFixture({
+ features: ['performance-view', 'visibility-explore-view'],
+});
+
+const cappedAttribute = {
+ key: 'span.description',
+ name: 'span.description',
+ attributeType: 'string',
+ attributeSource: {source_type: 'sentry'},
+};
+const genAiAttribute = {
+ key: 'gen_ai.tool.name',
+ name: 'gen_ai.tool.name',
+ attributeType: 'string',
+ attributeSource: {source_type: 'sentry'},
+};
+
+describe('WidgetBuilderXAxisSelector', () => {
+ beforeEach(() => {
+ MockApiClient.addMockResponse({
+ url: ATTRIBUTES_URL,
+ method: 'GET',
+ body: [cappedAttribute],
+ });
+ });
+
+ function renderSelector() {
+ return render(
+
+
+ ,
+ {
+ organization,
+ initialRouterConfig: {
+ location: {
+ pathname: DASHBOARD_WIDGET_BUILDER_PATHNAME,
+ query: {
+ dataset: WidgetType.SPANS,
+ displayType: DisplayType.CATEGORICAL_BAR,
+ },
+ },
+ route: DASHBOARD_WIDGET_BUILDER_ROUTE,
+ },
+ }
+ );
+ }
+
+ it('fetches attributes from the server while typing', async () => {
+ const searchAttributesMock = MockApiClient.addMockResponse({
+ url: ATTRIBUTES_URL,
+ method: 'GET',
+ body: [cappedAttribute, genAiAttribute],
+ match: [MockApiClient.matchQuery({substringMatch: 'gen_ai'})],
+ });
+
+ renderSelector();
+
+ expect(await screen.findByText('X-Axis')).toBeInTheDocument();
+
+ await userEvent.click(screen.getByRole('button', {name: 'None'}));
+
+ // The attribute is not part of the initial (capped) response.
+ expect(
+ await screen.findByRole('option', {name: /span\.description/})
+ ).toBeInTheDocument();
+ expect(
+ screen.queryByRole('option', {name: /gen_ai\.tool\.name/})
+ ).not.toBeInTheDocument();
+
+ await userEvent.type(screen.getByRole('textbox'), 'gen_ai');
+
+ await waitFor(() =>
+ expect(searchAttributesMock).toHaveBeenCalledWith(
+ ATTRIBUTES_URL,
+ expect.objectContaining({
+ query: expect.objectContaining({substringMatch: 'gen_ai'}),
+ })
+ )
+ );
+ expect(
+ await screen.findByRole('option', {name: /gen_ai\.tool\.name/})
+ ).toBeInTheDocument();
+ });
+});
diff --git a/static/app/views/dashboards/widgetBuilder/components/xAxisSelector.tsx b/static/app/views/dashboards/widgetBuilder/components/xAxisSelector.tsx
index 5a53c09fa441..9eac5c95adf5 100644
--- a/static/app/views/dashboards/widgetBuilder/components/xAxisSelector.tsx
+++ b/static/app/views/dashboards/widgetBuilder/components/xAxisSelector.tsx
@@ -1,4 +1,4 @@
-import {Fragment, useMemo} from 'react';
+import {Fragment, useMemo, useState} from 'react';
import styled from '@emotion/styled';
import {CompactSelect} from '@sentry/scraps/compactSelect';
@@ -7,8 +7,14 @@ import {t} from 'sentry/locale';
import {trackAnalytics} from 'sentry/utils/analytics';
import {WidgetBuilderVersion} from 'sentry/utils/analytics/dashboardsAnalyticsEvents';
import {prettifyTagKey} from 'sentry/utils/fields';
+import {useDebouncedValue} from 'sentry/utils/useDebouncedValue';
import {useOrganization} from 'sentry/utils/useOrganization';
import {useTags} from 'sentry/utils/useTags';
+import {
+ WIDGET_BUILDER_ATTRIBUTE_LOADING_MESSAGE,
+ WIDGET_BUILDER_ATTRIBUTE_SEARCH_DEBOUNCE_MS,
+ WIDGET_BUILDER_ATTRIBUTE_STALE_TIME,
+} from 'sentry/views/dashboards/constants';
import {getDatasetConfig} from 'sentry/views/dashboards/datasetConfig/base';
import {WidgetType} from 'sentry/views/dashboards/types';
import {SectionHeader} from 'sentry/views/dashboards/widgetBuilder/components/common/sectionHeader';
@@ -22,6 +28,7 @@ import {TypeBadge} from 'sentry/views/explore/components/typeBadge';
import {HIDDEN_PREPROD_ATTRIBUTES} from 'sentry/views/explore/constants';
import {useTraceItemDatasetAttributes} from 'sentry/views/explore/hooks/useTraceItemAttributes';
import {HiddenTraceMetricGroupByFields} from 'sentry/views/explore/metrics/constants';
+import {sortSearchedAttributes} from 'sentry/views/explore/utils/sortSearchedAttributes';
export function WidgetBuilderXAxisSelector() {
const organization = useOrganization();
@@ -40,10 +47,32 @@ export function WidgetBuilderXAxisSelector() {
}
const {traceItemType, ...traceItemOptions} = useWidgetBuilderTraceItemConfig();
+
+ const [search, setSearch] = useState('');
+ const debouncedSearch = useDebouncedValue(
+ search,
+ WIDGET_BUILDER_ATTRIBUTE_SEARCH_DEBOUNCE_MS
+ );
+
+ const searchedTraceItemOptions = {
+ ...traceItemOptions,
+ search: debouncedSearch,
+ staleTime: WIDGET_BUILDER_ATTRIBUTE_STALE_TIME,
+ };
const {attributes: stringSpanTags, isLoading: isLoadingStringTags} =
- useTraceItemDatasetAttributes(traceItemType, traceItemOptions, 'string', hiddenKeys);
+ useTraceItemDatasetAttributes(
+ traceItemType,
+ searchedTraceItemOptions,
+ 'string',
+ hiddenKeys
+ );
const {attributes: numericSpanTags, isLoading: isLoadingNumericTags} =
- useTraceItemDatasetAttributes(traceItemType, traceItemOptions, 'number', hiddenKeys);
+ useTraceItemDatasetAttributes(
+ traceItemType,
+ searchedTraceItemOptions,
+ 'number',
+ hiddenKeys
+ );
const datasetConfig = getDatasetConfig(state.dataset);
@@ -63,6 +92,7 @@ export function WidgetBuilderXAxisSelector() {
return Object.values(allTags).map(tag => ({
label: prettifyTagKey(tag.name),
value: tag.key,
+ textValue: tag.key,
trailingItems: () => ,
}));
}
@@ -101,6 +131,22 @@ export function WidgetBuilderXAxisSelector() {
return fieldEntry?.field ?? '';
}, [state.fields]);
+ // Keep the selected field present in the options even when it isn't in the
+ // current (possibly search-filtered) attribute response, so the dropdown
+ // reflects the saved state instead of showing nothing.
+ const fieldOptionsWithSelected = useMemo(() => {
+ if (
+ currentXAxisField &&
+ !fieldOptions.some(option => option.value === currentXAxisField)
+ ) {
+ return [
+ ...fieldOptions,
+ {label: prettifyTagKey(currentXAxisField), value: currentXAxisField},
+ ];
+ }
+ return fieldOptions;
+ }, [currentXAxisField, fieldOptions]);
+
const handleXAxisChange = (option: {value: string | number} | undefined) => {
if (!option) {
return;
@@ -129,10 +175,36 @@ export function WidgetBuilderXAxisSelector() {
tooltipText={t('Select the field to use for X-axis categories.')}
/>
+ sortSearchedAttributes({
+ fieldDefinitionType: traceItemType,
+ option,
+ searchText,
+ }),
+ }
+ : true
+ }
+ // CompactSelect clears its own search input on close but doesn't notify
+ // us, so reset our search state too. Otherwise the stale term keeps the
+ // previously fetched attributes merged into the options on reopen.
+ onOpenChange={isOpen => {
+ if (!isOpen) {
+ setSearch('');
+ }
+ }}
loading={isLoading}
+ emptyMessage={
+ isLoading
+ ? WIDGET_BUILDER_ATTRIBUTE_LOADING_MESSAGE
+ : t('No matching attributes')
+ }
value={currentXAxisField}
- options={fieldOptions}
+ options={fieldOptionsWithSelected}
onChange={handleXAxisChange}
/>