customMetricsEventData.tsx 6.8 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228
  1. import {Fragment, useState} from 'react';
  2. import styled from '@emotion/styled';
  3. import {LinkButton} from 'sentry/components/button';
  4. import {EventDataSection} from 'sentry/components/events/eventDataSection';
  5. import KeyValueList from 'sentry/components/events/interfaces/keyValueList';
  6. import type {
  7. MetricsSummary,
  8. MetricsSummaryItem,
  9. } from 'sentry/components/events/interfaces/spans/types';
  10. import Link from 'sentry/components/links/link';
  11. import {normalizeDateTimeString} from 'sentry/components/organizations/pageFilters/parse';
  12. import Pill from 'sentry/components/pill';
  13. import Pills from 'sentry/components/pills';
  14. import TextOverflow from 'sentry/components/textOverflow';
  15. import {t} from 'sentry/locale';
  16. import {space} from 'sentry/styles/space';
  17. import type {MRI, Organization} from 'sentry/types';
  18. import {getDdmUrl, getDefaultMetricOp} from 'sentry/utils/metrics';
  19. import {hasCustomMetrics} from 'sentry/utils/metrics/features';
  20. import {
  21. formatMetricUsingUnit,
  22. getReadableMetricType,
  23. } from 'sentry/utils/metrics/formatters';
  24. import {formatMRI, parseMRI} from 'sentry/utils/metrics/mri';
  25. import {MetricDisplayType} from 'sentry/utils/metrics/types';
  26. import useOrganization from 'sentry/utils/useOrganization';
  27. function flattenMetricsSummary(
  28. metricsSummary: MetricsSummary
  29. ): {item: MetricsSummaryItem; key: string; mri: MRI}[] {
  30. return (
  31. Object.entries(metricsSummary) as [
  32. keyof MetricsSummary,
  33. MetricsSummary[keyof MetricsSummary],
  34. ][]
  35. ).flatMap(([mri, items]) =>
  36. (items || []).map((item, index) => ({item, mri, key: `${mri}${index}`}))
  37. );
  38. }
  39. function tagToQuery(tagKey: string, tagValue: string) {
  40. return `${tagKey}:"${tagValue}"`;
  41. }
  42. const HALF_HOUR_IN_MS = 30 * 60 * 1000;
  43. export function CustomMetricsEventData({
  44. metricsSummary,
  45. startTimestamp,
  46. }: {
  47. startTimestamp: number;
  48. metricsSummary?: MetricsSummary;
  49. }) {
  50. const organization = useOrganization();
  51. const metricsSummaryEntries = metricsSummary
  52. ? flattenMetricsSummary(metricsSummary)
  53. : [];
  54. const widgetStart = new Date(startTimestamp * 1000 - HALF_HOUR_IN_MS);
  55. const widgetEnd = new Date(startTimestamp * 1000 + HALF_HOUR_IN_MS);
  56. if (!hasCustomMetrics(organization) || metricsSummaryEntries.length === 0) {
  57. return null;
  58. }
  59. return (
  60. <EventDataSection type="custom-metrics" title={t('Emitted Metrics')}>
  61. {metricsSummaryEntries.map(({mri, item, key}) => {
  62. return (
  63. <Fragment key={key}>
  64. <KeyValueList
  65. shouldSort={false}
  66. data={[
  67. {
  68. key: 'name',
  69. subject: t('Name'),
  70. value: <TextOverflow>{formatMRI(mri)}</TextOverflow>,
  71. actionButton: (
  72. <LinkButton
  73. size="xs"
  74. to={getDdmUrl(organization.slug, {
  75. start: normalizeDateTimeString(widgetStart),
  76. end: normalizeDateTimeString(widgetEnd),
  77. widgets: [
  78. {
  79. mri,
  80. displayType: MetricDisplayType.LINE,
  81. op: getDefaultMetricOp(mri),
  82. query: Object.entries(item.tags ?? {})
  83. .map(([tagKey, tagValue]) => tagToQuery(tagKey, tagValue))
  84. .join(' '),
  85. },
  86. ],
  87. })}
  88. >
  89. {t('Open in Metrics')}
  90. </LinkButton>
  91. ),
  92. },
  93. {
  94. key: 'stats',
  95. subject: t('Stats'),
  96. value: <MetricStats item={item} mri={mri} />,
  97. },
  98. item.tags && Object.keys(item.tags).length > 0
  99. ? {
  100. key: 'tags',
  101. subject: t('Tags'),
  102. value: (
  103. <Tags tags={item.tags} organization={organization} mri={mri} />
  104. ),
  105. }
  106. : null,
  107. ].filter((row): row is Exclude<typeof row, null> => Boolean(row))}
  108. />
  109. </Fragment>
  110. );
  111. })}
  112. </EventDataSection>
  113. );
  114. }
  115. function MetricStats({mri, item}: {item: MetricsSummaryItem; mri: MRI}) {
  116. const parsedMRI = parseMRI(mri);
  117. const unit = parsedMRI?.unit ?? 'none';
  118. const type = parsedMRI?.type ?? 'c';
  119. const typeLine = t(`Type: %s`, getReadableMetricType(type));
  120. // We use formatMetricUsingUnit with unit 'none' to ensure uniform number formatting
  121. const countLine = t(`Count: %s`, formatMetricUsingUnit(item.count, 'none'));
  122. // For counters the other stats offer little value, so we only show type and count
  123. if (type === 'c' || !item.count) {
  124. return (
  125. <pre>
  126. {typeLine}
  127. <br />
  128. {countLine}
  129. </pre>
  130. );
  131. }
  132. // If there is only one value, min, max, avg and sum are all the same
  133. if (item.count <= 1) {
  134. return (
  135. <pre>
  136. {typeLine}
  137. <br />
  138. {t('Value: %s', formatMetricUsingUnit(item.sum, unit))}
  139. </pre>
  140. );
  141. }
  142. return (
  143. <pre>
  144. {typeLine}
  145. <br />
  146. {countLine}
  147. <br />
  148. {t('Sum: %s', formatMetricUsingUnit(item.sum, unit))}
  149. <br />
  150. {t('Min: %s', formatMetricUsingUnit(item.min, unit))}
  151. <br />
  152. {t('Max: %s', formatMetricUsingUnit(item.max, unit))}
  153. <br />
  154. {t(
  155. 'Avg: %s',
  156. formatMetricUsingUnit(item.sum && item.count && item.sum / item.count, unit)
  157. )}
  158. </pre>
  159. );
  160. }
  161. function Tags({
  162. tags,
  163. organization,
  164. mri,
  165. }: {
  166. mri: MRI;
  167. organization: Organization;
  168. tags: Record<string, string>;
  169. }) {
  170. const [showingAll, setShowingAll] = useState(false);
  171. const renderedTags = Object.entries(tags).slice(0, showingAll ? undefined : 5);
  172. const renderText = showingAll ? t('Show less') : t('Show more') + '...';
  173. return (
  174. <StyledPills>
  175. {renderedTags.map(([tagKey, tagValue]) => (
  176. <StyledPill key={tagKey} name={tagKey}>
  177. <Link
  178. to={getDdmUrl(organization.slug, {
  179. widgets: [
  180. {
  181. mri,
  182. displayType: MetricDisplayType.LINE,
  183. op: getDefaultMetricOp(mri),
  184. query: tagToQuery(tagKey, tagValue),
  185. },
  186. ],
  187. })}
  188. >
  189. {tagValue}
  190. </Link>
  191. </StyledPill>
  192. ))}
  193. {Object.entries(tags).length > 5 && (
  194. <ShowMore onClick={() => setShowingAll(prev => !prev)}>{renderText}</ShowMore>
  195. )}
  196. </StyledPills>
  197. );
  198. }
  199. const StyledPills = styled(Pills)`
  200. padding-top: ${space(1)};
  201. `;
  202. const StyledPill = styled(Pill)`
  203. width: min-content;
  204. `;
  205. const ShowMore = styled('a')`
  206. white-space: nowrap;
  207. align-self: center;
  208. margin-bottom: ${space(1)};
  209. padding: ${space(0.5)} ${space(0.5)};
  210. `;