queryBuilder.tsx 10 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342
  1. import {Fragment, memo, useCallback, useEffect, useMemo, useState} from 'react';
  2. import styled from '@emotion/styled';
  3. import uniqBy from 'lodash/uniqBy';
  4. import {ComboBox} from 'sentry/components/comboBox';
  5. import type {ComboBoxOption} from 'sentry/components/comboBox/types';
  6. import type {SelectOption} from 'sentry/components/compactSelect';
  7. import {CompactSelect} from 'sentry/components/compactSelect';
  8. import {IconLightning, IconReleases} from 'sentry/icons';
  9. import {t} from 'sentry/locale';
  10. import {space} from 'sentry/styles/space';
  11. import type {MetricMeta, MetricsOperation, MRI} from 'sentry/types';
  12. import {trackAnalytics} from 'sentry/utils/analytics';
  13. import {
  14. isAllowedOp,
  15. isCustomMetric,
  16. isSpanMeasurement,
  17. isSpanSelfTime,
  18. isTransactionDuration,
  19. isTransactionMeasurement,
  20. } from 'sentry/utils/metrics';
  21. import {hasMetricsExperimentalFeature} from 'sentry/utils/metrics/features';
  22. import {getReadableMetricType} from 'sentry/utils/metrics/formatters';
  23. import {formatMRI, parseMRI} from 'sentry/utils/metrics/mri';
  24. import type {MetricsQuery} from 'sentry/utils/metrics/types';
  25. import {useBreakpoints} from 'sentry/utils/metrics/useBreakpoints';
  26. import {useIncrementQueryMetric} from 'sentry/utils/metrics/useIncrementQueryMetric';
  27. import {useMetricsMeta} from 'sentry/utils/metrics/useMetricsMeta';
  28. import {useMetricsTags} from 'sentry/utils/metrics/useMetricsTags';
  29. import {middleEllipsis} from 'sentry/utils/middleEllipsis';
  30. import useKeyPress from 'sentry/utils/useKeyPress';
  31. import useOrganization from 'sentry/utils/useOrganization';
  32. import usePageFilters from 'sentry/utils/usePageFilters';
  33. import useProjects from 'sentry/utils/useProjects';
  34. import {MetricListItemDetails} from 'sentry/views/metrics/metricListItemDetails';
  35. import {MetricSearchBar} from 'sentry/views/metrics/metricSearchBar';
  36. type QueryBuilderProps = {
  37. metricsQuery: MetricsQuery;
  38. onChange: (data: Partial<MetricsQuery>) => void;
  39. projects: number[];
  40. };
  41. const isVisibleTransactionMetric = (metric: MetricMeta) =>
  42. isTransactionDuration(metric) || isTransactionMeasurement(metric);
  43. const isVisibleSpanMetric = (metric: MetricMeta) =>
  44. isSpanSelfTime(metric) || isSpanMeasurement(metric);
  45. const isShownByDefault = (metric: MetricMeta) =>
  46. isCustomMetric(metric) ||
  47. isVisibleTransactionMetric(metric) ||
  48. isVisibleSpanMetric(metric);
  49. function getOpsForMRI(mri: MRI, meta: MetricMeta[]) {
  50. return meta.find(metric => metric.mri === mri)?.operations.filter(isAllowedOp) ?? [];
  51. }
  52. function useMriMode() {
  53. const [mriMode, setMriMode] = useState(false);
  54. const mriModeKeyPressed = useKeyPress('`', undefined, true);
  55. useEffect(() => {
  56. if (mriModeKeyPressed) {
  57. setMriMode(value => !value);
  58. }
  59. // eslint-disable-next-line react-hooks/exhaustive-deps
  60. }, [mriModeKeyPressed]);
  61. return mriMode;
  62. }
  63. export const QueryBuilder = memo(function QueryBuilder({
  64. metricsQuery,
  65. projects: projectIds,
  66. onChange,
  67. }: QueryBuilderProps) {
  68. const organization = useOrganization();
  69. const pageFilters = usePageFilters();
  70. const breakpoints = useBreakpoints();
  71. const {projects} = useProjects();
  72. const {data: meta, isLoading: isMetaLoading} = useMetricsMeta(pageFilters.selection);
  73. const mriMode = useMriMode();
  74. const shouldUseComboBox = hasMetricsExperimentalFeature(organization);
  75. const {data: tagsData = [], isLoading: tagsIsLoading} = useMetricsTags(
  76. metricsQuery.mri,
  77. {
  78. projects: projectIds,
  79. }
  80. );
  81. const selectedProjects = useMemo(
  82. () =>
  83. projects.filter(project =>
  84. projectIds[0] === -1
  85. ? true
  86. : projectIds.length === 0
  87. ? project.isMember
  88. : projectIds.includes(parseInt(project.id, 10))
  89. ),
  90. [projectIds, projects]
  91. );
  92. const tags = useMemo(() => {
  93. return uniqBy(tagsData, 'key');
  94. }, [tagsData]);
  95. const displayedMetrics = useMemo(() => {
  96. const isSelected = (metric: MetricMeta) => metric.mri === metricsQuery.mri;
  97. const result = meta
  98. .filter(metric => isShownByDefault(metric) || isSelected(metric))
  99. .sort(metric => (isSelected(metric) ? -1 : 1));
  100. // Add the selected metric to the top of the list if it's not already there
  101. if (shouldUseComboBox && result[0]?.mri !== metricsQuery.mri) {
  102. const parsedMri = parseMRI(metricsQuery.mri)!;
  103. return [
  104. {
  105. mri: metricsQuery.mri,
  106. type: parsedMri.type,
  107. unit: parsedMri.unit,
  108. operations: [],
  109. projectIds: [],
  110. blockingStatus: [],
  111. } satisfies MetricMeta,
  112. ...result,
  113. ];
  114. }
  115. return result;
  116. }, [meta, metricsQuery.mri, shouldUseComboBox]);
  117. const selectedMeta = useMemo(() => {
  118. return meta.find(metric => metric.mri === metricsQuery.mri);
  119. }, [meta, metricsQuery.mri]);
  120. const incrementQueryMetric = useIncrementQueryMetric({
  121. ...metricsQuery,
  122. });
  123. const handleMRIChange = useCallback(
  124. ({value}) => {
  125. const availableOps = getOpsForMRI(value, meta);
  126. const selectedOp = availableOps.includes(
  127. (metricsQuery.op ?? '') as MetricsOperation
  128. )
  129. ? metricsQuery.op
  130. : availableOps?.[0];
  131. const queryChanges = {
  132. mri: value,
  133. op: selectedOp,
  134. groupBy: undefined,
  135. };
  136. trackAnalytics('ddm.widget.metric', {organization});
  137. incrementQueryMetric('ddm.widget.metric', queryChanges);
  138. onChange(queryChanges);
  139. },
  140. [incrementQueryMetric, meta, metricsQuery.op, onChange, organization]
  141. );
  142. const handleOpChange = useCallback(
  143. ({value}) => {
  144. trackAnalytics('ddm.widget.operation', {organization});
  145. incrementQueryMetric('ddm.widget.operation', {op: value});
  146. onChange({
  147. op: value,
  148. });
  149. },
  150. [incrementQueryMetric, onChange, organization]
  151. );
  152. const handleGroupByChange = useCallback(
  153. (options: SelectOption<string>[]) => {
  154. trackAnalytics('ddm.widget.group', {organization});
  155. incrementQueryMetric('ddm.widget.group', {
  156. groupBy: options.map(o => o.value),
  157. });
  158. onChange({
  159. groupBy: options.map(o => o.value),
  160. });
  161. },
  162. [incrementQueryMetric, onChange, organization]
  163. );
  164. const handleQueryChange = useCallback(
  165. (query: string) => {
  166. trackAnalytics('ddm.widget.filter', {organization});
  167. incrementQueryMetric('ddm.widget.filter', {query});
  168. onChange({query});
  169. },
  170. [incrementQueryMetric, onChange, organization]
  171. );
  172. const mriOptions = useMemo(
  173. () =>
  174. displayedMetrics.map<ComboBoxOption<MRI>>(metric => ({
  175. label: mriMode
  176. ? metric.mri
  177. : middleEllipsis(formatMRI(metric.mri) ?? '', 55, /\.|-|_/),
  178. // enable search by mri, name, unit (millisecond), type (c:), and readable type (counter)
  179. textValue: `${metric.mri}${getReadableMetricType(metric.type)}`,
  180. value: metric.mri,
  181. details:
  182. metric.projectIds.length > 0 ? (
  183. <MetricListItemDetails metric={metric} selectedProjects={selectedProjects} />
  184. ) : null,
  185. showDetailsInOverlay: true,
  186. trailingItems:
  187. mriMode || parseMRI(metric.mri)?.useCase !== 'custom' ? undefined : (
  188. <CustomMetricInfoText>{t('Custom')}</CustomMetricInfoText>
  189. ),
  190. })),
  191. [displayedMetrics, mriMode, selectedProjects]
  192. );
  193. const projectIdStrings = useMemo(() => projectIds.map(String), [projectIds]);
  194. return (
  195. <QueryBuilderWrapper>
  196. <FlexBlock>
  197. {shouldUseComboBox ? (
  198. <MetricComboBox
  199. aria-label={t('Metric')}
  200. placeholder={t('Select a metric')}
  201. sizeLimit={100}
  202. size="md"
  203. menuSize="sm"
  204. isLoading={isMetaLoading}
  205. options={mriOptions}
  206. value={metricsQuery.mri}
  207. onChange={handleMRIChange}
  208. menuWidth="400px"
  209. />
  210. ) : (
  211. <MetricSelect
  212. searchable
  213. sizeLimit={100}
  214. size="md"
  215. triggerLabel={middleEllipsis(
  216. formatMRI(metricsQuery.mri) ?? '',
  217. breakpoints.large ? (breakpoints.xlarge ? 70 : 45) : 30,
  218. /\.|-|_/
  219. )}
  220. options={mriOptions}
  221. value={metricsQuery.mri}
  222. onChange={handleMRIChange}
  223. />
  224. )}
  225. <FlexBlock>
  226. <OpSelect
  227. size="md"
  228. triggerProps={{prefix: t('Agg')}}
  229. options={
  230. selectedMeta?.operations.filter(isAllowedOp).map(op => ({
  231. label: op,
  232. value: op,
  233. })) ?? []
  234. }
  235. triggerLabel={metricsQuery.op}
  236. disabled={!selectedMeta}
  237. value={metricsQuery.op}
  238. onChange={handleOpChange}
  239. />
  240. <CompactSelect
  241. multiple
  242. size="md"
  243. triggerProps={{prefix: t('Group by')}}
  244. options={tags.map(tag => ({
  245. label: tag.key,
  246. value: tag.key,
  247. trailingItems: (
  248. <Fragment>
  249. {tag.key === 'release' && <IconReleases size="xs" />}
  250. {tag.key === 'transaction' && <IconLightning size="xs" />}
  251. </Fragment>
  252. ),
  253. }))}
  254. disabled={!metricsQuery.mri || tagsIsLoading}
  255. value={metricsQuery.groupBy}
  256. onChange={handleGroupByChange}
  257. />
  258. </FlexBlock>
  259. </FlexBlock>
  260. <SearchBarWrapper>
  261. <MetricSearchBar
  262. mri={metricsQuery.mri}
  263. disabled={!metricsQuery.mri}
  264. onChange={handleQueryChange}
  265. query={metricsQuery.query}
  266. projectIds={projectIdStrings}
  267. blockedTags={selectedMeta?.blockingStatus?.flatMap(s => s.blockedTags) ?? []}
  268. />
  269. </SearchBarWrapper>
  270. </QueryBuilderWrapper>
  271. );
  272. });
  273. const CustomMetricInfoText = styled('span')`
  274. color: ${p => p.theme.subText};
  275. `;
  276. const QueryBuilderWrapper = styled('div')`
  277. display: flex;
  278. flex-grow: 1;
  279. gap: ${space(1)};
  280. flex-wrap: wrap;
  281. `;
  282. const FlexBlock = styled('div')`
  283. display: flex;
  284. gap: ${space(1)};
  285. flex-wrap: wrap;
  286. `;
  287. const MetricComboBox = styled(ComboBox)`
  288. min-width: 200px;
  289. max-width: min(500px, 100%);
  290. `;
  291. const MetricSelect = styled(CompactSelect)`
  292. min-width: 200px;
  293. & > button {
  294. width: 100%;
  295. }
  296. `;
  297. const OpSelect = styled(CompactSelect)`
  298. width: 128px;
  299. min-width: min-content;
  300. & > button {
  301. width: 100%;
  302. }
  303. `;
  304. const SearchBarWrapper = styled('div')`
  305. flex: 1;
  306. min-width: 200px;
  307. `;