metricsExtractionRuleForm.tsx 25 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799
  1. import {Fragment, useCallback, useId, useMemo, useState} from 'react';
  2. import {components} from 'react-select';
  3. import {css, useTheme} from '@emotion/react';
  4. import styled from '@emotion/styled';
  5. import {Observer} from 'mobx-react';
  6. import Alert from 'sentry/components/alert';
  7. import {Button} from 'sentry/components/button';
  8. import SearchBar from 'sentry/components/events/searchBar';
  9. import SelectField from 'sentry/components/forms/fields/selectField';
  10. import Form, {type FormProps} from 'sentry/components/forms/form';
  11. import FormField from 'sentry/components/forms/formField';
  12. import type FormModel from 'sentry/components/forms/model';
  13. import ExternalLink from 'sentry/components/links/externalLink';
  14. import {Tooltip} from 'sentry/components/tooltip';
  15. import {IconAdd, IconClose, IconQuestion, IconWarning} from 'sentry/icons';
  16. import {t, tct} from 'sentry/locale';
  17. import {space} from 'sentry/styles/space';
  18. import type {SelectValue} from 'sentry/types/core';
  19. import type {MetricAggregation, MetricsExtractionCondition} from 'sentry/types/metrics';
  20. import {trackAnalytics} from 'sentry/utils/analytics';
  21. import {hasDuplicates} from 'sentry/utils/array/hasDuplicates';
  22. import {DiscoverDatasets} from 'sentry/utils/discover/types';
  23. import useOrganization from 'sentry/utils/useOrganization';
  24. import {SpanIndexedField, SpanMetricsField} from 'sentry/views/insights/types';
  25. import {useSpanFieldSupportedTags} from 'sentry/views/performance/utils/useSpanFieldSupportedTags';
  26. import {openExtractionRuleEditModal} from 'sentry/views/settings/projectMetrics/metricsExtractionRuleEditModal';
  27. import {useMetricsExtractionRules} from 'sentry/views/settings/projectMetrics/utils/useMetricsExtractionRules';
  28. export type AggregateGroup = 'count' | 'count_unique' | 'min_max' | 'percentiles';
  29. export interface FormData {
  30. aggregates: AggregateGroup[];
  31. conditions: MetricsExtractionCondition[];
  32. spanAttribute: string | null;
  33. tags: string[];
  34. unit: string;
  35. }
  36. interface Props extends Omit<FormProps, 'onSubmit'> {
  37. initialData: FormData;
  38. projectId: string | number;
  39. cardinality?: Record<string, number>;
  40. isEdit?: boolean;
  41. onSubmit?: (
  42. data: FormData,
  43. onSubmitSuccess: (data: FormData) => void,
  44. onSubmitError: (error: any) => void,
  45. event: React.FormEvent,
  46. model: FormModel
  47. ) => void;
  48. }
  49. const HIGH_CARDINALITY_TAGS = new Set([
  50. SpanIndexedField.HTTP_RESPONSE_CONTENT_LENGTH,
  51. SpanIndexedField.SPAN_DURATION,
  52. SpanIndexedField.SPAN_SELF_TIME,
  53. SpanIndexedField.SPAN_GROUP,
  54. SpanIndexedField.ID,
  55. SpanIndexedField.SPAN_AI_PIPELINE_GROUP,
  56. SpanIndexedField.TRANSACTION_ID,
  57. SpanIndexedField.PROJECT_ID,
  58. SpanIndexedField.PROFILE_ID,
  59. SpanIndexedField.REPLAY_ID,
  60. SpanIndexedField.TIMESTAMP,
  61. SpanIndexedField.USER,
  62. SpanIndexedField.USER_ID,
  63. SpanIndexedField.USER_EMAIL,
  64. SpanIndexedField.USER_USERNAME,
  65. SpanIndexedField.INP,
  66. SpanIndexedField.INP_SCORE,
  67. SpanIndexedField.INP_SCORE_WEIGHT,
  68. SpanIndexedField.TOTAL_SCORE,
  69. SpanIndexedField.CACHE_ITEM_SIZE,
  70. SpanIndexedField.MESSAGING_MESSAGE_ID,
  71. SpanIndexedField.MESSAGING_MESSAGE_BODY_SIZE,
  72. SpanIndexedField.MESSAGING_MESSAGE_RECEIVE_LATENCY,
  73. SpanIndexedField.MESSAGING_MESSAGE_RETRY_COUNT,
  74. SpanMetricsField.AI_TOTAL_TOKENS_USED,
  75. SpanMetricsField.AI_PROMPT_TOKENS_USED,
  76. SpanMetricsField.AI_COMPLETION_TOKENS_USED,
  77. SpanMetricsField.AI_INPUT_MESSAGES,
  78. SpanMetricsField.HTTP_DECODED_RESPONSE_CONTENT_LENGTH,
  79. SpanMetricsField.HTTP_RESPONSE_TRANSFER_SIZE,
  80. SpanMetricsField.CACHE_ITEM_SIZE,
  81. SpanMetricsField.CACHE_KEY,
  82. SpanMetricsField.THREAD_ID,
  83. SpanMetricsField.SENTRY_FRAMES_FROZEN,
  84. SpanMetricsField.SENTRY_FRAMES_SLOW,
  85. SpanMetricsField.SENTRY_FRAMES_TOTAL,
  86. SpanMetricsField.FRAMES_DELAY,
  87. SpanMetricsField.URL_FULL,
  88. SpanMetricsField.USER_AGENT_ORIGINAL,
  89. SpanMetricsField.FRAMES_DELAY,
  90. ]);
  91. const isHighCardinalityTag = (tag: string): boolean => {
  92. return HIGH_CARDINALITY_TAGS.has(tag as SpanIndexedField);
  93. };
  94. const AGGREGATE_OPTIONS: {label: string; value: AggregateGroup}[] = [
  95. {
  96. label: t('count'),
  97. value: 'count',
  98. },
  99. {
  100. label: t('count_unique'),
  101. value: 'count_unique',
  102. },
  103. {
  104. label: t('min, max, sum, avg'),
  105. value: 'min_max',
  106. },
  107. {
  108. label: t('percentiles'),
  109. value: 'percentiles',
  110. },
  111. ];
  112. export function explodeAggregateGroup(group: AggregateGroup): MetricAggregation[] {
  113. switch (group) {
  114. case 'count':
  115. return ['count'];
  116. case 'count_unique':
  117. return ['count_unique'];
  118. case 'min_max':
  119. return ['min', 'max', 'sum', 'avg'];
  120. case 'percentiles':
  121. return ['p50', 'p75', 'p90', 'p95', 'p99'];
  122. default:
  123. throw new Error(`Unknown aggregate group: ${group}`);
  124. }
  125. }
  126. export function aggregatesToGroups(aggregates: MetricAggregation[]): AggregateGroup[] {
  127. const groups: AggregateGroup[] = [];
  128. if (aggregates.includes('count')) {
  129. groups.push('count');
  130. }
  131. if (aggregates.includes('count_unique')) {
  132. groups.push('count_unique');
  133. }
  134. const minMaxAggregates = new Set<MetricAggregation>(['min', 'max', 'sum', 'avg']);
  135. if (aggregates.find(aggregate => minMaxAggregates.has(aggregate))) {
  136. groups.push('min_max');
  137. }
  138. const percentileAggregates = new Set<MetricAggregation>([
  139. 'p50',
  140. 'p75',
  141. 'p90',
  142. 'p95',
  143. 'p99',
  144. ]);
  145. if (aggregates.find(aggregate => percentileAggregates.has(aggregate))) {
  146. groups.push('percentiles');
  147. }
  148. return groups;
  149. }
  150. let currentTempId = 0;
  151. function createTempId(): number {
  152. currentTempId -= 1;
  153. return currentTempId;
  154. }
  155. export function createCondition(): MetricsExtractionCondition {
  156. return {
  157. value: '',
  158. // id and mris will be set by the backend after creation
  159. id: createTempId(),
  160. mris: [],
  161. };
  162. }
  163. const SUPPORTED_UNITS = [
  164. 'none',
  165. 'nanosecond',
  166. 'microsecond',
  167. 'millisecond',
  168. 'second',
  169. 'minute',
  170. 'hour',
  171. 'day',
  172. 'week',
  173. 'ratio',
  174. 'percent',
  175. 'bit',
  176. 'byte',
  177. 'kibibyte',
  178. 'kilobyte',
  179. 'mebibyte',
  180. 'megabyte',
  181. 'gibibyte',
  182. 'gigabyte',
  183. 'tebibyte',
  184. 'terabyte',
  185. 'pebibyte',
  186. 'petabyte',
  187. 'exbibyte',
  188. 'exabyte',
  189. ] as const;
  190. const isSupportedUnit = (unit: string): unit is (typeof SUPPORTED_UNITS)[number] => {
  191. return SUPPORTED_UNITS.includes(unit as (typeof SUPPORTED_UNITS)[number]);
  192. };
  193. const EMPTY_SET = new Set<never>();
  194. const SPAN_SEARCH_CONFIG = {
  195. booleanKeys: EMPTY_SET,
  196. dateKeys: EMPTY_SET,
  197. durationKeys: EMPTY_SET,
  198. numericKeys: EMPTY_SET,
  199. percentageKeys: EMPTY_SET,
  200. sizeKeys: EMPTY_SET,
  201. textOperatorKeys: EMPTY_SET,
  202. disallowFreeText: true,
  203. disallowWildcard: true,
  204. disallowNegation: true,
  205. };
  206. const FIXED_UNITS_BY_ATTRIBUTE: Record<string, (typeof SUPPORTED_UNITS)[number]> = {
  207. [SpanIndexedField.SPAN_DURATION]: 'millisecond',
  208. };
  209. export function MetricsExtractionRuleForm({
  210. isEdit,
  211. projectId,
  212. onSubmit,
  213. cardinality,
  214. ...props
  215. }: Props) {
  216. const organization = useOrganization();
  217. const [customAttributes, setCustomAttributes] = useState<string[]>(() => {
  218. const {spanAttribute, tags} = props.initialData;
  219. return [...new Set(spanAttribute ? [...tags, spanAttribute] : tags)];
  220. });
  221. const [customUnit, setCustomUnit] = useState<string | null>(() => {
  222. const {unit} = props.initialData;
  223. return unit && !isSupportedUnit(unit) ? unit : null;
  224. });
  225. const [isUnitDisabled, setIsUnitDisabled] = useState(() => {
  226. const {spanAttribute} = props.initialData;
  227. return !!(spanAttribute && spanAttribute in FIXED_UNITS_BY_ATTRIBUTE);
  228. });
  229. const {data: extractionRules} = useMetricsExtractionRules({
  230. orgId: organization.slug,
  231. projectId: projectId,
  232. });
  233. const tags = useSpanFieldSupportedTags({projects: [Number(projectId)]});
  234. // TODO(aknaus): Make this nicer
  235. const supportedTags = useMemo(() => {
  236. const copy = {...tags};
  237. delete copy.has;
  238. return copy;
  239. }, [tags]);
  240. const allAttributeOptions = useMemo(() => {
  241. let keys = Object.keys(supportedTags);
  242. if (customAttributes.length) {
  243. keys = [...new Set(keys.concat(customAttributes))];
  244. }
  245. return keys.sort((a, b) => a.localeCompare(b));
  246. }, [customAttributes, supportedTags]);
  247. const attributeOptions = useMemo(() => {
  248. const disabledKeys = new Set(extractionRules?.map(rule => rule.spanAttribute) || []);
  249. return (
  250. allAttributeOptions
  251. .map<SelectValue<string>>(key => {
  252. const disabledRule = disabledKeys.has(key)
  253. ? extractionRules?.find(rule => rule.spanAttribute === key)
  254. : undefined;
  255. return {
  256. label: key,
  257. value: key,
  258. disabled: disabledKeys.has(key),
  259. tooltip: disabledKeys.has(key)
  260. ? tct(
  261. 'This attribute is already in use. Please select another one or [link:edit the existing metric].',
  262. {
  263. link: disabledRule ? (
  264. <Button
  265. priority="link"
  266. aria-label={t('Edit %s metric', disabledRule.spanAttribute)}
  267. onClick={() => {
  268. openExtractionRuleEditModal({
  269. organization,
  270. metricExtractionRule: disabledRule,
  271. source: 'span-metric.form.already-in-use',
  272. });
  273. }}
  274. />
  275. ) : null,
  276. }
  277. )
  278. : undefined,
  279. tooltipOptions: {position: 'left', isHoverable: true},
  280. };
  281. })
  282. // Sort disabled attributes to bottom
  283. .sort((a, b) => Number(a.disabled) - Number(b.disabled))
  284. );
  285. }, [allAttributeOptions, extractionRules, organization]);
  286. const tagOptions = useMemo(() => {
  287. return allAttributeOptions.map<SelectValue<string>>(option => ({
  288. label: option,
  289. value: option,
  290. disabled: isHighCardinalityTag(option),
  291. tooltip: isHighCardinalityTag(option)
  292. ? t('This tag has high cardinality.')
  293. : undefined,
  294. tooltipOptions: {position: 'left'},
  295. }));
  296. }, [allAttributeOptions]);
  297. const unitOptions = useMemo(() => {
  298. const options: SelectValue<string>[] = SUPPORTED_UNITS.map(unit => ({
  299. label: unit + (unit === 'none' ? '' : 's'),
  300. value: unit,
  301. }));
  302. if (customUnit) {
  303. options.push({
  304. label: customUnit,
  305. value: customUnit,
  306. });
  307. }
  308. return options;
  309. }, [customUnit]);
  310. const handleSubmit = useCallback(
  311. (
  312. data: Record<string, any>,
  313. onSubmitSuccess: (data: Record<string, any>) => void,
  314. onSubmitError: (error: any) => void,
  315. event: React.FormEvent,
  316. model: FormModel
  317. ) => {
  318. const errors: Record<string, [string]> = {};
  319. if (!data.spanAttribute) {
  320. errors.spanAttribute = [t('Span attribute is required.')];
  321. }
  322. if (!data.aggregates.length) {
  323. errors.aggregates = [t('At least one aggregate is required.')];
  324. }
  325. const conditions = [...data.conditions].map(condition => condition.value.trim());
  326. if (hasDuplicates(conditions)) {
  327. errors.conditions = [
  328. t('Each filter must be unique; duplicates are not allowed.'),
  329. ];
  330. }
  331. if (Object.keys(errors).length) {
  332. onSubmitError({responseJSON: errors});
  333. return;
  334. }
  335. onSubmit?.(data as FormData, onSubmitSuccess, onSubmitError, event, model);
  336. },
  337. [onSubmit]
  338. );
  339. const isNewCustomSpanAttribute = useCallback((value?: string) => {
  340. if (!value) {
  341. return false;
  342. }
  343. return !attributeOptions.some(option => option.value === value);
  344. // attributeOptions is being mutated when a new custom attribute is created
  345. }, []); // eslint-disable-line react-hooks/exhaustive-deps
  346. const isCardinalityLimited = useMemo(() => {
  347. if (!cardinality) {
  348. return false;
  349. }
  350. const {conditions} = props.initialData;
  351. return conditions.some(condition =>
  352. condition.mris.some(conditionMri => cardinality[conditionMri] > 0)
  353. );
  354. }, [cardinality, props.initialData]);
  355. return (
  356. <Form onSubmit={onSubmit && handleSubmit} {...props}>
  357. {({model}) => (
  358. <Fragment>
  359. <SpanAttributeUnitWrapper>
  360. <SelectField
  361. inline={false}
  362. stacked
  363. name="spanAttribute"
  364. options={attributeOptions}
  365. disabled={isEdit}
  366. label={
  367. <TooltipIconLabel
  368. isHoverable
  369. label={t('Measure')}
  370. help={tct(
  371. 'Define the span attribute you want to track. Learn how to instrument custom attributes in [link:our docs].',
  372. {
  373. // TODO(telemetry-experience): add the correct link here once we have it!!!
  374. link: (
  375. <ExternalLink href="https://docs.sentry.io/product/explore/metrics/" />
  376. ),
  377. }
  378. )}
  379. />
  380. }
  381. onCreateOption={value => {
  382. setCustomAttributes(curr => [...curr, value]);
  383. model.setValue('spanAttribute', value);
  384. }}
  385. components={{
  386. MenuList: (
  387. menuListProps: React.ComponentProps<typeof components.MenuList>
  388. ) => {
  389. return (
  390. <MenuList
  391. {...menuListProps}
  392. info={tct(
  393. 'Select an attribute or create one. [link:See how to instrument a custom attribute.]',
  394. {
  395. link: (
  396. <ExternalLink href="https://docs.sentry.io/product/explore/metrics/metrics-set-up/" />
  397. ),
  398. }
  399. )}
  400. />
  401. );
  402. },
  403. }}
  404. placeholder={t('Select span attribute')}
  405. creatable
  406. onChange={value => {
  407. model.setValue('spanAttribute', value);
  408. if (value in FIXED_UNITS_BY_ATTRIBUTE) {
  409. model.setValue('unit', FIXED_UNITS_BY_ATTRIBUTE[value]);
  410. setIsUnitDisabled(true);
  411. } else {
  412. setIsUnitDisabled(false);
  413. }
  414. }}
  415. required
  416. />
  417. <StyledFieldConnector>in</StyledFieldConnector>
  418. <SelectField
  419. inline={false}
  420. stacked
  421. allowEmpty
  422. name="unit"
  423. options={unitOptions}
  424. disabled={isUnitDisabled}
  425. placeholder={t('Select unit')}
  426. creatable
  427. onCreateOption={value => {
  428. setCustomUnit(value);
  429. model.setValue('unit', value);
  430. }}
  431. css={css`
  432. min-width: 150px;
  433. `}
  434. />
  435. </SpanAttributeUnitWrapper>
  436. <SelectField
  437. inline={false}
  438. stacked
  439. name="aggregates"
  440. required
  441. options={AGGREGATE_OPTIONS}
  442. placeholder={t('Select aggregations')}
  443. label={
  444. <TooltipIconLabel
  445. isHoverable
  446. label={t('Aggregate')}
  447. help={tct(
  448. 'Select the aggregations you’d like to view. For more information, read [link:our docs]',
  449. {
  450. // TODO(telemetry-experience): add the correct link here once we have it!!!
  451. link: (
  452. <ExternalLink href="https://docs.sentry.io/product/explore/metrics/" />
  453. ),
  454. }
  455. )}
  456. />
  457. }
  458. multiple
  459. />
  460. <SelectField
  461. inline={false}
  462. stacked
  463. name="tags"
  464. aria-label={t('Select tags')}
  465. options={tagOptions}
  466. multiple
  467. placeholder={t('Select tags')}
  468. label={
  469. <Fragment>
  470. {isCardinalityLimited && (
  471. <Tooltip
  472. title={t(
  473. 'One of the selected tags is exceeding the cardinality limit. Remove tags or add more conditions to receive accurate data.'
  474. )}
  475. >
  476. <StyledIconWarning size="xs" color="yellow300" />
  477. </Tooltip>
  478. )}
  479. <TooltipIconLabel
  480. label={t('Group by')}
  481. help={t(
  482. 'Select the tags that can be used to group and filter the metric. Tag values have to be non-numeric.'
  483. )}
  484. />
  485. </Fragment>
  486. }
  487. creatable
  488. onCreateOption={value => {
  489. setCustomAttributes(curr => [...curr, value]);
  490. const currentTags = model.getValue('tags') as string[];
  491. model.setValue('tags', [...currentTags, value]);
  492. }}
  493. components={{
  494. MenuList: (
  495. menuListProps: React.ComponentProps<typeof components.MenuList>
  496. ) => {
  497. return (
  498. <MenuList
  499. {...menuListProps}
  500. info={tct(
  501. 'Select a tag or create one. [link:See how to instrument a custom tag.]',
  502. {
  503. link: (
  504. <ExternalLink href="https://docs.sentry.io/product/explore/metrics/metrics-set-up/" />
  505. ),
  506. }
  507. )}
  508. />
  509. );
  510. },
  511. }}
  512. />
  513. <FormField
  514. stacked
  515. label={
  516. <TooltipIconLabel
  517. label={t('Filters')}
  518. help={t(
  519. 'Define filters to narrow down the metric to a specific set of spans.'
  520. )}
  521. />
  522. }
  523. name="conditions"
  524. inline={false}
  525. hasControlState={false}
  526. flexibleControlStateSize
  527. >
  528. {({onChange, initialData, value}) => {
  529. const conditions = (value ||
  530. initialData ||
  531. []) as MetricsExtractionCondition[];
  532. const handleChange = (queryString: string, index: number) => {
  533. onChange(
  534. conditions.toSpliced(index, 1, {
  535. ...conditions[index],
  536. value: queryString,
  537. }),
  538. {}
  539. );
  540. };
  541. return (
  542. <Fragment>
  543. <ConditionsWrapper hasDelete={value.length > 1}>
  544. {conditions.map((condition, index) => {
  545. const hasSiblings = conditions.length > 1;
  546. return (
  547. <Fragment key={condition.id}>
  548. <SearchWrapper hasPrefix={hasSiblings}>
  549. {hasSiblings ? (
  550. <ConditionSymbol>{index + 1}</ConditionSymbol>
  551. ) : null}
  552. <SearchBarWithId
  553. {...SPAN_SEARCH_CONFIG}
  554. searchSource="metrics-extraction"
  555. query={condition.value}
  556. onSearch={(queryString: string) =>
  557. handleChange(queryString, index)
  558. }
  559. onClose={(queryString: string, {validSearch}) => {
  560. if (validSearch) {
  561. handleChange(queryString, index);
  562. }
  563. }}
  564. placeholder={t('Add span attributes')}
  565. organization={organization}
  566. supportedTags={supportedTags}
  567. excludedTags={[]}
  568. dataset={DiscoverDatasets.SPANS_INDEXED}
  569. projectIds={[Number(projectId)]}
  570. hasRecentSearches={false}
  571. savedSearchType={undefined}
  572. useFormWrapper={false}
  573. />
  574. </SearchWrapper>
  575. {value.length > 1 && (
  576. <Button
  577. aria-label={t('Remove Filter')}
  578. onClick={() => {
  579. trackAnalytics('ddm.span-metric.form.remove-filter', {
  580. organization,
  581. });
  582. onChange(conditions.toSpliced(index, 1), {});
  583. }}
  584. icon={<IconClose />}
  585. />
  586. )}
  587. </Fragment>
  588. );
  589. })}
  590. </ConditionsWrapper>
  591. <ConditionsButtonBar>
  592. <Button
  593. size="sm"
  594. onClick={() => {
  595. trackAnalytics('ddm.span-metric.form.add-filter', {organization});
  596. onChange([...conditions, createCondition()], {});
  597. }}
  598. icon={<IconAdd />}
  599. >
  600. {t('Add Filter')}
  601. </Button>
  602. </ConditionsButtonBar>
  603. </Fragment>
  604. );
  605. }}
  606. </FormField>
  607. <Observer>
  608. {() => {
  609. if (!isEdit && isNewCustomSpanAttribute(model.getValue('spanAttribute'))) {
  610. return (
  611. <Alert type="info" showIcon>
  612. {tct(
  613. 'You want to track a custom attribute, so if you haven’t already, please [link:add it to your span data].',
  614. {
  615. link: (
  616. <ExternalLink href="https://docs.sentry.io/product/explore/metrics/metrics-set-up/" />
  617. ),
  618. }
  619. )}
  620. </Alert>
  621. );
  622. }
  623. if (isEdit && model.formChanged) {
  624. return (
  625. <Alert type="info" showIcon>
  626. {t('The changes you made will only be reflected on future data.')}
  627. </Alert>
  628. );
  629. }
  630. return null;
  631. }}
  632. </Observer>
  633. </Fragment>
  634. )}
  635. </Form>
  636. );
  637. }
  638. function MenuList({
  639. children,
  640. info,
  641. ...props
  642. }: React.ComponentProps<typeof components.MenuList> & {info: React.ReactNode}) {
  643. const theme = useTheme();
  644. return (
  645. <components.MenuList {...props}>
  646. <div
  647. css={css`
  648. /* The padding must align with the values specified for the option in the forms/controls/selectOption component */
  649. padding: ${space(1)};
  650. padding-left: calc(${space(0.5)} + ${space(1.5)});
  651. color: ${theme.gray300};
  652. `}
  653. >
  654. {info}
  655. </div>
  656. {children}
  657. </components.MenuList>
  658. );
  659. }
  660. function TooltipIconLabel({
  661. label,
  662. help,
  663. isHoverable,
  664. }: {
  665. help: React.ReactNode;
  666. label: React.ReactNode;
  667. isHoverable?: boolean;
  668. }) {
  669. return (
  670. <TooltipIconLabelWrapper>
  671. {label}
  672. <Tooltip title={help} isHoverable={isHoverable}>
  673. <IconQuestion size="sm" color="gray200" />
  674. </Tooltip>
  675. </TooltipIconLabelWrapper>
  676. );
  677. }
  678. const TooltipIconLabelWrapper = styled('span')`
  679. display: inline-flex;
  680. font-weight: bold;
  681. color: ${p => p.theme.gray300};
  682. gap: ${space(0.5)};
  683. & > span {
  684. margin-top: 1px;
  685. }
  686. & > span:hover {
  687. cursor: pointer;
  688. }
  689. `;
  690. const StyledFieldConnector = styled('div')`
  691. color: ${p => p.theme.gray300};
  692. padding-bottom: ${space(1)};
  693. `;
  694. const SpanAttributeUnitWrapper = styled('div')`
  695. display: flex;
  696. align-items: flex-end;
  697. gap: ${space(1)};
  698. padding-bottom: ${space(2)};
  699. & > div:first-child {
  700. flex: 1;
  701. padding-bottom: 0;
  702. }
  703. `;
  704. function SearchBarWithId(props: React.ComponentProps<typeof SearchBar>) {
  705. const id = useId();
  706. return <SearchBar id={id} {...props} />;
  707. }
  708. const ConditionsWrapper = styled('div')<{hasDelete: boolean}>`
  709. display: grid;
  710. align-items: center;
  711. gap: ${space(1)};
  712. ${p =>
  713. p.hasDelete
  714. ? `
  715. grid-template-columns: 1fr min-content;
  716. `
  717. : `
  718. grid-template-columns: 1fr;
  719. `}
  720. `;
  721. const SearchWrapper = styled('div')<{hasPrefix?: boolean}>`
  722. display: grid;
  723. gap: ${space(1)};
  724. align-items: center;
  725. grid-template-columns: ${p => (p.hasPrefix ? 'max-content' : '')} 1fr;
  726. `;
  727. const ConditionSymbol = styled('div')`
  728. background-color: ${p => p.theme.purple100};
  729. color: ${p => p.theme.purple400};
  730. text-align: center;
  731. align-content: center;
  732. height: ${space(3)};
  733. width: ${space(3)};
  734. border-radius: 50%;
  735. `;
  736. const StyledIconWarning = styled(IconWarning)`
  737. margin: 0 ${space(0.5)};
  738. `;
  739. const ConditionsButtonBar = styled('div')`
  740. margin-top: ${space(1)};
  741. `;