metricsExtractionRuleForm.tsx 21 KB

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