queryBuilder.tsx 14 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432
  1. import {Fragment, memo, useCallback, useEffect, useMemo, useState} from 'react';
  2. import styled from '@emotion/styled';
  3. import * as Sentry from '@sentry/react';
  4. import {navigateTo} from 'sentry/actionCreators/navigation';
  5. import {Button} from 'sentry/components/button';
  6. import {HeaderTitle} from 'sentry/components/charts/styles';
  7. import {CompactSelect} from 'sentry/components/compactSelect';
  8. import PageFilterBar from 'sentry/components/organizations/pageFilterBar';
  9. import {BooleanOperator} from 'sentry/components/searchSyntax/parser';
  10. import SmartSearchBar, {SmartSearchBarProps} from 'sentry/components/smartSearchBar';
  11. import Tag from 'sentry/components/tag';
  12. import TextOverflow from 'sentry/components/textOverflow';
  13. import {IconLightning, IconReleases, IconSettings} from 'sentry/icons';
  14. import {t} from 'sentry/locale';
  15. import {space} from 'sentry/styles/space';
  16. import {MetricMeta, MRI, SavedSearchType, TagCollection} from 'sentry/types';
  17. import {
  18. defaultMetricDisplayType,
  19. getReadableMetricType,
  20. isAllowedOp,
  21. isCustomMetric,
  22. isMeasurement,
  23. isTransactionDuration,
  24. MetricDisplayType,
  25. MetricsQuery,
  26. MetricsQuerySubject,
  27. MetricWidgetQueryParams,
  28. stringifyMetricWidget,
  29. } from 'sentry/utils/metrics';
  30. import {formatMRI, getUseCaseFromMRI, parseMRI} from 'sentry/utils/metrics/mri';
  31. import {useMetricsMeta} from 'sentry/utils/metrics/useMetricsMeta';
  32. import {useMetricsTags} from 'sentry/utils/metrics/useMetricsTags';
  33. import useApi from 'sentry/utils/useApi';
  34. import useKeyPress from 'sentry/utils/useKeyPress';
  35. import useOrganization from 'sentry/utils/useOrganization';
  36. import usePageFilters from 'sentry/utils/usePageFilters';
  37. import useRouter from 'sentry/utils/useRouter';
  38. type QueryBuilderProps = {
  39. displayType: MetricDisplayType;
  40. isEdit: boolean;
  41. // TODO(ddm): move display type out of the query builder
  42. metricsQuery: MetricsQuerySubject;
  43. onChange: (data: Partial<MetricWidgetQueryParams>) => void;
  44. projects: number[];
  45. powerUserMode?: boolean;
  46. };
  47. const isShownByDefault = (metric: MetricMeta) =>
  48. isMeasurement(metric) || isCustomMetric(metric) || isTransactionDuration(metric);
  49. function stopPropagation(e: React.MouseEvent) {
  50. e.stopPropagation();
  51. }
  52. export const QueryBuilder = memo(function QueryBuilder({
  53. metricsQuery,
  54. projects,
  55. displayType,
  56. powerUserMode,
  57. onChange,
  58. isEdit,
  59. }: QueryBuilderProps) {
  60. const {data: meta, isLoading: isMetaLoading} = useMetricsMeta(projects);
  61. const router = useRouter();
  62. const mriModeKeyPressed = useKeyPress('`', undefined, true);
  63. const [mriMode, setMriMode] = useState(powerUserMode); // power user mode that shows raw MRI instead of metrics names
  64. useEffect(() => {
  65. if (mriModeKeyPressed && !powerUserMode) {
  66. setMriMode(!mriMode);
  67. }
  68. // eslint-disable-next-line react-hooks/exhaustive-deps
  69. }, [mriModeKeyPressed, powerUserMode]);
  70. const {data: tags = []} = useMetricsTags(metricsQuery.mri, projects);
  71. const displayedMetrics = useMemo(() => {
  72. if (mriMode) {
  73. return meta;
  74. }
  75. const isSelected = (metric: MetricMeta) => metric.mri === metricsQuery.mri;
  76. return meta
  77. .filter(metric => isShownByDefault(metric) || isSelected(metric))
  78. .sort(metric => (isSelected(metric) ? -1 : 1));
  79. }, [meta, metricsQuery.mri, mriMode]);
  80. const selectedMeta = useMemo(() => {
  81. return meta.find(metric => metric.mri === metricsQuery.mri);
  82. }, [meta, metricsQuery.mri]);
  83. // Reset the query data if the selected metric is no longer available
  84. useEffect(() => {
  85. if (
  86. metricsQuery.mri &&
  87. !isMetaLoading &&
  88. !displayedMetrics.find(metric => metric.mri === metricsQuery.mri)
  89. ) {
  90. onChange({mri: '' as MRI, op: '', groupBy: []});
  91. }
  92. }, [isMetaLoading, displayedMetrics, metricsQuery.mri, onChange]);
  93. const stringifiedMetricWidget = stringifyMetricWidget(metricsQuery);
  94. const readableType = getReadableMetricType(parseMRI(metricsQuery.mri)?.type);
  95. if (!isEdit) {
  96. return (
  97. <QueryBuilderWrapper>
  98. <WidgetTitle>
  99. <TextOverflow>{metricsQuery.title || stringifiedMetricWidget}</TextOverflow>
  100. </WidgetTitle>
  101. </QueryBuilderWrapper>
  102. );
  103. }
  104. return (
  105. <QueryBuilderWrapper>
  106. <QueryBuilderRow>
  107. <WrapPageFilterBar>
  108. <CompactSelect
  109. searchable
  110. sizeLimit={100}
  111. triggerProps={{prefix: t('Metric'), size: 'sm'}}
  112. options={displayedMetrics.map(metric => ({
  113. label: mriMode ? metric.mri : formatMRI(metric.mri),
  114. // enable search by mri, name, unit (millisecond), type (c:), and readable type (counter)
  115. textValue: `${metric.mri}${getReadableMetricType(metric.type)}`,
  116. value: metric.mri,
  117. trailingItems: mriMode
  118. ? undefined
  119. : ({isFocused}) => (
  120. <Fragment>
  121. {isFocused && isCustomMetric({mri: metric.mri}) && (
  122. <Button
  123. borderless
  124. size="zero"
  125. icon={<IconSettings />}
  126. aria-label={t('Metric Settings')}
  127. onPointerDown={() => {
  128. // not using onClick to beat the dropdown listener
  129. navigateTo(
  130. `/settings/projects/:projectId/metrics/${encodeURIComponent(
  131. metric.mri
  132. )}`,
  133. router
  134. );
  135. }}
  136. />
  137. )}
  138. <Tag tooltipText={t('Type')}>
  139. {getReadableMetricType(metric.type)}
  140. </Tag>
  141. <Tag tooltipText={t('Unit')}>{metric.unit}</Tag>
  142. </Fragment>
  143. ),
  144. }))}
  145. value={metricsQuery.mri}
  146. onChange={option => {
  147. const availableOps =
  148. meta
  149. .find(metric => metric.mri === option.value)
  150. ?.operations.filter(isAllowedOp) ?? [];
  151. // @ts-expect-error .op is an operation
  152. const selectedOp = availableOps.includes(metricsQuery.op ?? '')
  153. ? metricsQuery.op
  154. : availableOps?.[0];
  155. Sentry.metrics.increment('ddm.widget.metric', 1, {
  156. tags: {
  157. display: displayType ?? defaultMetricDisplayType,
  158. type: readableType,
  159. operation: selectedOp,
  160. isGrouped: !!metricsQuery.groupBy?.length,
  161. isFiltered: !!metricsQuery.query,
  162. },
  163. });
  164. onChange({
  165. mri: option.value,
  166. op: selectedOp,
  167. groupBy: undefined,
  168. focusedSeries: undefined,
  169. displayType: getWidgetDisplayType(option.value, selectedOp),
  170. });
  171. }}
  172. />
  173. <CompactSelect
  174. triggerProps={{prefix: t('Op'), size: 'sm'}}
  175. options={
  176. selectedMeta?.operations.filter(isAllowedOp).map(op => ({
  177. label: op,
  178. value: op,
  179. })) ?? []
  180. }
  181. disabled={!metricsQuery.mri}
  182. value={metricsQuery.op}
  183. onChange={option => {
  184. Sentry.metrics.increment('ddm.widget.operation', 1, {
  185. tags: {
  186. display: displayType ?? defaultMetricDisplayType,
  187. type: readableType,
  188. operation: option.value,
  189. isGrouped: !!metricsQuery.groupBy?.length,
  190. isFiltered: !!metricsQuery.query,
  191. },
  192. });
  193. onChange({
  194. op: option.value,
  195. });
  196. }}
  197. />
  198. <CompactSelect
  199. multiple
  200. triggerProps={{prefix: t('Group by'), size: 'sm'}}
  201. options={tags.map(tag => ({
  202. label: tag.key,
  203. value: tag.key,
  204. trailingItems: (
  205. <Fragment>
  206. {tag.key === 'release' && <IconReleases size="xs" />}
  207. {tag.key === 'transaction' && <IconLightning size="xs" />}
  208. </Fragment>
  209. ),
  210. }))}
  211. disabled={!metricsQuery.mri}
  212. value={metricsQuery.groupBy}
  213. onChange={options => {
  214. Sentry.metrics.increment('ddm.widget.group', 1, {
  215. tags: {
  216. display: displayType ?? defaultMetricDisplayType,
  217. type: readableType,
  218. operation: metricsQuery.op,
  219. isGrouped: !!metricsQuery.groupBy?.length,
  220. isFiltered: !!metricsQuery.query,
  221. },
  222. });
  223. onChange({
  224. groupBy: options.map(o => o.value),
  225. focusedSeries: undefined,
  226. });
  227. }}
  228. />
  229. <CompactSelect
  230. triggerProps={{prefix: t('Display'), size: 'sm'}}
  231. value={displayType ?? defaultMetricDisplayType}
  232. options={[
  233. {
  234. value: MetricDisplayType.LINE,
  235. label: t('Line'),
  236. },
  237. {
  238. value: MetricDisplayType.AREA,
  239. label: t('Area'),
  240. },
  241. {
  242. value: MetricDisplayType.BAR,
  243. label: t('Bar'),
  244. },
  245. ]}
  246. onChange={({value}) => {
  247. Sentry.metrics.increment('ddm.widget.display', 1, {
  248. tags: {
  249. display: value,
  250. type: readableType,
  251. operation: metricsQuery.op,
  252. isGrouped: !!metricsQuery.groupBy?.length,
  253. isFiltered: !!metricsQuery.query,
  254. },
  255. });
  256. onChange({displayType: value});
  257. }}
  258. />
  259. </WrapPageFilterBar>
  260. </QueryBuilderRow>
  261. {/* Stop propagation so widget does not get selected immediately */}
  262. <QueryBuilderRow onClick={stopPropagation}>
  263. <MetricSearchBar
  264. // TODO(aknaus): clean up projectId type in ddm
  265. projectIds={projects.map(id => id.toString())}
  266. mri={metricsQuery.mri}
  267. disabled={!metricsQuery.mri}
  268. onChange={query => {
  269. Sentry.metrics.increment('ddm.widget.filter', 1, {
  270. tags: {
  271. display: displayType ?? defaultMetricDisplayType,
  272. type: readableType,
  273. operation: metricsQuery.op,
  274. isGrouped: !!metricsQuery.groupBy?.length,
  275. isFiltered: !!query,
  276. },
  277. });
  278. onChange({query});
  279. }}
  280. query={metricsQuery.query}
  281. />
  282. </QueryBuilderRow>
  283. </QueryBuilderWrapper>
  284. );
  285. });
  286. interface MetricSearchBarProps extends Partial<SmartSearchBarProps> {
  287. onChange: (value: string) => void;
  288. projectIds: string[];
  289. disabled?: boolean;
  290. mri?: MRI;
  291. query?: string;
  292. }
  293. const EMPTY_ARRAY = [];
  294. const EMPTY_SET = new Set<never>();
  295. const DISSALLOWED_LOGICAL_OPERATORS = new Set([BooleanOperator.OR]);
  296. export function MetricSearchBar({
  297. mri,
  298. disabled,
  299. onChange,
  300. query,
  301. projectIds,
  302. ...props
  303. }: MetricSearchBarProps) {
  304. const org = useOrganization();
  305. const api = useApi();
  306. const {selection} = usePageFilters();
  307. const projectIdNumbers = useMemo(
  308. () => projectIds.map(id => parseInt(id, 10)),
  309. [projectIds]
  310. );
  311. const {data: tags = EMPTY_ARRAY, isLoading} = useMetricsTags(mri, projectIdNumbers);
  312. const supportedTags: TagCollection = useMemo(
  313. () => tags.reduce((acc, tag) => ({...acc, [tag.key]: tag}), {}),
  314. [tags]
  315. );
  316. // TODO(ddm): try to use useApiQuery here
  317. const getTagValues = useCallback(
  318. async tag => {
  319. const useCase = getUseCaseFromMRI(mri);
  320. const tagsValues = await api.requestPromise(
  321. `/organizations/${org.slug}/metrics/tags/${tag.key}/`,
  322. {
  323. query: {
  324. metric: mri,
  325. useCase,
  326. project: selection.projects,
  327. },
  328. }
  329. );
  330. return tagsValues.filter(tv => tv.value !== '').map(tv => tv.value);
  331. },
  332. [api, mri, org.slug, selection.projects]
  333. );
  334. const handleChange = useCallback(
  335. (value: string, {validSearch} = {validSearch: true}) => {
  336. if (validSearch) {
  337. onChange(value);
  338. }
  339. },
  340. [onChange]
  341. );
  342. return (
  343. <WideSearchBar
  344. disabled={disabled}
  345. maxMenuHeight={220}
  346. organization={org}
  347. onGetTagValues={getTagValues}
  348. supportedTags={supportedTags}
  349. // don't highlight tags while loading as we don't know yet if they are supported
  350. highlightUnsupportedTags={!isLoading}
  351. disallowedLogicalOperators={DISSALLOWED_LOGICAL_OPERATORS}
  352. disallowFreeText
  353. onClose={handleChange}
  354. onSearch={handleChange}
  355. placeholder={t('Filter by tags')}
  356. query={query}
  357. savedSearchType={SavedSearchType.METRIC}
  358. durationKeys={EMPTY_SET}
  359. percentageKeys={EMPTY_SET}
  360. numericKeys={EMPTY_SET}
  361. dateKeys={EMPTY_SET}
  362. booleanKeys={EMPTY_SET}
  363. sizeKeys={EMPTY_SET}
  364. textOperatorKeys={EMPTY_SET}
  365. {...props}
  366. />
  367. );
  368. }
  369. function getWidgetDisplayType(
  370. mri: MetricsQuery['mri'],
  371. op: MetricsQuery['op']
  372. ): MetricDisplayType {
  373. if (mri?.startsWith('c') || op === 'count') {
  374. return MetricDisplayType.BAR;
  375. }
  376. return MetricDisplayType.LINE;
  377. }
  378. const QueryBuilderWrapper = styled('div')`
  379. display: flex;
  380. flex-grow: 1;
  381. flex-direction: column;
  382. `;
  383. const QueryBuilderRow = styled('div')`
  384. padding: ${space(1)};
  385. padding-bottom: 0;
  386. `;
  387. const WideSearchBar = styled(SmartSearchBar)`
  388. width: 100%;
  389. opacity: ${p => (p.disabled ? '0.6' : '1')};
  390. `;
  391. const WrapPageFilterBar = styled(PageFilterBar)`
  392. max-width: max-content;
  393. height: auto;
  394. flex-wrap: wrap;
  395. `;
  396. const WidgetTitle = styled(HeaderTitle)`
  397. padding-left: ${space(2)};
  398. padding-top: ${space(1.5)};
  399. padding-right: ${space(1)};
  400. `;