eventVitals.tsx 5.9 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199
  1. import {Fragment} from 'react';
  2. import styled from '@emotion/styled';
  3. import {SectionHeading} from 'sentry/components/charts/styles';
  4. import Panel from 'sentry/components/panels/panel';
  5. import {Tooltip} from 'sentry/components/tooltip';
  6. import {IconFire, IconWarning} from 'sentry/icons';
  7. import {t} from 'sentry/locale';
  8. import {space} from 'sentry/styles/space';
  9. import type {Event} from 'sentry/types/event';
  10. import {defined} from 'sentry/utils';
  11. import {formattedValue} from 'sentry/utils/measurements/index';
  12. import {
  13. MOBILE_VITAL_DETAILS,
  14. WEB_VITAL_DETAILS,
  15. } from 'sentry/utils/performance/vitals/constants';
  16. import type {Vital} from 'sentry/utils/performance/vitals/types';
  17. import type {IconSize} from 'sentry/utils/theme';
  18. function isOutdatedSdk(event: Event): boolean {
  19. if (!event.sdk?.version) {
  20. return false;
  21. }
  22. const sdkVersion = event.sdk.version;
  23. return (
  24. sdkVersion.startsWith('5.26.') ||
  25. sdkVersion.startsWith('5.27.0') ||
  26. sdkVersion.startsWith('5.27.1') ||
  27. sdkVersion.startsWith('5.27.2')
  28. );
  29. }
  30. type Props = {
  31. event: Event;
  32. };
  33. export default function EventVitals({event}: Props) {
  34. return (
  35. <Fragment>
  36. <WebVitals event={event} />
  37. <MobileVitals event={event} />
  38. </Fragment>
  39. );
  40. }
  41. function WebVitals({event}: Props) {
  42. const measurementNames = Object.keys(event.measurements ?? {})
  43. .filter(name => Boolean(WEB_VITAL_DETAILS[`measurements.${name}`]))
  44. .sort();
  45. if (measurementNames.length === 0) {
  46. return null;
  47. }
  48. return (
  49. <Container>
  50. <SectionHeading>
  51. {t('Web Vitals')}
  52. {isOutdatedSdk(event) && (
  53. <WarningIconContainer data-test-id="outdated-sdk-warning" size="sm">
  54. <Tooltip
  55. title={t(
  56. 'These vitals were collected using an outdated SDK version and may not be accurate. To ensure accurate web vitals in new transaction events, please update your SDK to the latest version.'
  57. )}
  58. position="top"
  59. containerDisplayMode="inline-block"
  60. >
  61. <IconWarning size="sm" />
  62. </Tooltip>
  63. </WarningIconContainer>
  64. )}
  65. </SectionHeading>
  66. <Measurements>
  67. {measurementNames.map(name => {
  68. // Measurements are referred to by their full name `measurements.<name>`
  69. // here but are stored using their abbreviated name `<name>`. Make sure
  70. // to convert it appropriately.
  71. const measurement = `measurements.${name}`;
  72. const vital = WEB_VITAL_DETAILS[measurement];
  73. return <EventVital key={name} event={event} name={name} vital={vital} />;
  74. })}
  75. </Measurements>
  76. </Container>
  77. );
  78. }
  79. function MobileVitals({event}: Props) {
  80. const measurementNames = Object.keys(event.measurements ?? {})
  81. .filter(name => Boolean(MOBILE_VITAL_DETAILS[`measurements.${name}`]))
  82. .sort();
  83. if (measurementNames.length === 0) {
  84. return null;
  85. }
  86. return (
  87. <Container>
  88. <SectionHeading>{t('Mobile Vitals')}</SectionHeading>
  89. <Measurements>
  90. {measurementNames.map(name => {
  91. // Measurements are referred to by their full name `measurements.<name>`
  92. // here but are stored using their abbreviated name `<name>`. Make sure
  93. // to convert it appropriately.
  94. const measurement = `measurements.${name}`;
  95. const vital = MOBILE_VITAL_DETAILS[measurement];
  96. return <EventVital key={name} event={event} name={name} vital={vital} />;
  97. })}
  98. </Measurements>
  99. </Container>
  100. );
  101. }
  102. interface EventVitalProps extends Props {
  103. name: string;
  104. vital?: Vital;
  105. }
  106. function EventVital({event, name, vital}: EventVitalProps) {
  107. const value = event.measurements?.[name].value ?? null;
  108. if (value === null || !vital) {
  109. return null;
  110. }
  111. const failedThreshold = defined(vital.poorThreshold) && value >= vital.poorThreshold;
  112. const currentValue = formattedValue(vital, value);
  113. const thresholdValue = formattedValue(vital, vital?.poorThreshold ?? 0);
  114. return (
  115. <EventVitalContainer>
  116. <StyledPanel failedThreshold={failedThreshold}>
  117. <Name>{vital.name ?? name}</Name>
  118. <ValueRow>
  119. {failedThreshold ? (
  120. <FireIconContainer data-test-id="threshold-failed-warning" size="sm">
  121. <Tooltip
  122. title={t('Fails threshold at %s.', thresholdValue)}
  123. position="top"
  124. containerDisplayMode="inline-block"
  125. >
  126. <IconFire size="sm" />
  127. </Tooltip>
  128. </FireIconContainer>
  129. ) : null}
  130. <Value failedThreshold={failedThreshold}>{currentValue}</Value>
  131. </ValueRow>
  132. </StyledPanel>
  133. </EventVitalContainer>
  134. );
  135. }
  136. const Measurements = styled('div')`
  137. display: grid;
  138. grid-column-gap: ${space(1)};
  139. `;
  140. const Container = styled('div')`
  141. font-size: ${p => p.theme.fontSizeMedium};
  142. margin-bottom: ${space(4)};
  143. `;
  144. const StyledPanel = styled(Panel)<{failedThreshold: boolean}>`
  145. padding: ${space(1)} ${space(1.5)};
  146. margin-bottom: ${space(1)};
  147. ${p => p.failedThreshold && `border: 1px solid ${p.theme.red300};`}
  148. `;
  149. const Name = styled('div')``;
  150. const ValueRow = styled('div')`
  151. display: flex;
  152. align-items: center;
  153. `;
  154. const WarningIconContainer = styled('span')<{size: IconSize | string}>`
  155. display: inline-block;
  156. height: ${p => p.theme.iconSizes[p.size] ?? p.size};
  157. line-height: ${p => p.theme.iconSizes[p.size] ?? p.size};
  158. margin-left: ${space(0.5)};
  159. color: ${p => p.theme.errorText};
  160. `;
  161. const FireIconContainer = styled('span')<{size: IconSize | string}>`
  162. display: inline-block;
  163. height: ${p => p.theme.iconSizes[p.size] ?? p.size};
  164. line-height: ${p => p.theme.iconSizes[p.size] ?? p.size};
  165. margin-right: ${space(0.5)};
  166. color: ${p => p.theme.errorText};
  167. `;
  168. const Value = styled('span')<{failedThreshold: boolean}>`
  169. font-size: ${p => p.theme.fontSizeExtraLarge};
  170. ${p => p.failedThreshold && `color: ${p.theme.errorText};`}
  171. `;
  172. export const EventVitalContainer = styled('div')``;