vitalChart.tsx 8.8 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303
  1. import {useTheme} from '@emotion/react';
  2. import ChartZoom from 'sentry/components/charts/chartZoom';
  3. import ErrorPanel from 'sentry/components/charts/errorPanel';
  4. import EventsRequest from 'sentry/components/charts/eventsRequest';
  5. import type {LineChartProps} from 'sentry/components/charts/lineChart';
  6. import {LineChart} from 'sentry/components/charts/lineChart';
  7. import ReleaseSeries from 'sentry/components/charts/releaseSeries';
  8. import {ChartContainer, HeaderTitleLegend} from 'sentry/components/charts/styles';
  9. import TransitionChart from 'sentry/components/charts/transitionChart';
  10. import TransparentLoadingMask from 'sentry/components/charts/transparentLoadingMask';
  11. import Panel from 'sentry/components/panels/panel';
  12. import QuestionTooltip from 'sentry/components/questionTooltip';
  13. import {IconWarning} from 'sentry/icons';
  14. import {t} from 'sentry/locale';
  15. import type {DateString} from 'sentry/types/core';
  16. import type {Series} from 'sentry/types/echarts';
  17. import type {OrganizationSummary} from 'sentry/types/organization';
  18. import {browserHistory} from 'sentry/utils/browserHistory';
  19. import {axisLabelFormatter, tooltipFormatter} from 'sentry/utils/discover/charts';
  20. import {aggregateOutputType} from 'sentry/utils/discover/fields';
  21. import {WebVital} from 'sentry/utils/fields';
  22. import getDynamicText from 'sentry/utils/getDynamicText';
  23. import useApi from 'sentry/utils/useApi';
  24. import {useLocation} from 'sentry/utils/useLocation';
  25. import {replaceSeriesName, transformEventStatsSmoothed} from '../trends/utils';
  26. import type {ViewProps} from '../types';
  27. import {
  28. getMaxOfSeries,
  29. getVitalChartDefinitions,
  30. getVitalChartTitle,
  31. vitalNameFromLocation,
  32. VitalState,
  33. vitalStateColors,
  34. } from './utils';
  35. type Props = Omit<ViewProps, 'start' | 'end'> & {
  36. end: DateString | null;
  37. interval: string;
  38. organization: OrganizationSummary;
  39. start: DateString | null;
  40. };
  41. function VitalChart({
  42. project,
  43. environment,
  44. organization,
  45. query,
  46. statsPeriod,
  47. start,
  48. end,
  49. interval,
  50. }: Props) {
  51. const location = useLocation();
  52. const api = useApi();
  53. const theme = useTheme();
  54. const vitalName = vitalNameFromLocation(location);
  55. const yAxis = `p75(${vitalName})`;
  56. const {utc, legend, vitalPoor, markLines, chartOptions} = getVitalChartDefinitions({
  57. theme,
  58. location,
  59. yAxis,
  60. vital: vitalName,
  61. });
  62. function handleLegendSelectChanged(legendChange: {
  63. name: string;
  64. selected: Record<string, boolean>;
  65. type: string;
  66. }) {
  67. const {selected} = legendChange;
  68. const unselected = Object.keys(selected).filter(key => !selected[key]);
  69. const to = {
  70. ...location,
  71. query: {
  72. ...location.query,
  73. unselectedSeries: unselected,
  74. },
  75. };
  76. browserHistory.push(to);
  77. }
  78. return (
  79. <Panel>
  80. <ChartContainer>
  81. <HeaderTitleLegend>
  82. {getVitalChartTitle(vitalName)}
  83. <QuestionTooltip
  84. size="sm"
  85. position="top"
  86. title={t('The durations shown should fall under the vital threshold.')}
  87. />
  88. </HeaderTitleLegend>
  89. <ChartZoom period={statsPeriod} start={start} end={end} utc={utc}>
  90. {zoomRenderProps => (
  91. <EventsRequest
  92. api={api}
  93. organization={organization}
  94. period={statsPeriod}
  95. project={project}
  96. environment={environment}
  97. start={start}
  98. end={end}
  99. interval={interval}
  100. showLoading={false}
  101. query={query}
  102. includePrevious={false}
  103. yAxis={[yAxis]}
  104. partial
  105. >
  106. {({timeseriesData: results, errored, loading, reloading}) => {
  107. if (errored) {
  108. return (
  109. <ErrorPanel>
  110. <IconWarning color="gray500" size="lg" />
  111. </ErrorPanel>
  112. );
  113. }
  114. const colors =
  115. (results && theme.charts.getColorPalette(results.length - 2)) || [];
  116. const {smoothedResults} = transformEventStatsSmoothed(results);
  117. const smoothedSeries = smoothedResults
  118. ? smoothedResults.map(({seriesName, ...rest}, i: number) => {
  119. return {
  120. seriesName: replaceSeriesName(seriesName) || 'p75',
  121. ...rest,
  122. color: colors[i],
  123. lineStyle: {
  124. opacity: 1,
  125. width: 2,
  126. },
  127. };
  128. })
  129. : [];
  130. const seriesMax = getMaxOfSeries(smoothedSeries);
  131. const yAxisMax = Math.max(seriesMax, vitalPoor);
  132. chartOptions.yAxis!.max = yAxisMax * 1.1;
  133. return (
  134. <ReleaseSeries
  135. start={start}
  136. end={end}
  137. period={statsPeriod}
  138. utc={utc}
  139. projects={project}
  140. environments={environment}
  141. >
  142. {({releaseSeries}) => (
  143. <TransitionChart loading={loading} reloading={reloading}>
  144. <TransparentLoadingMask visible={reloading} />
  145. {getDynamicText({
  146. value: (
  147. <LineChart
  148. {...zoomRenderProps}
  149. {...chartOptions}
  150. legend={legend}
  151. onLegendSelectChanged={handleLegendSelectChanged}
  152. series={[...markLines, ...releaseSeries, ...smoothedSeries]}
  153. />
  154. ),
  155. fixed: 'Web Vitals Chart',
  156. })}
  157. </TransitionChart>
  158. )}
  159. </ReleaseSeries>
  160. );
  161. }}
  162. </EventsRequest>
  163. )}
  164. </ChartZoom>
  165. </ChartContainer>
  166. </Panel>
  167. );
  168. }
  169. export default VitalChart;
  170. export type _VitalChartProps = {
  171. field: string;
  172. grid: LineChartProps['grid'];
  173. loading: boolean;
  174. reloading: boolean;
  175. data?: Series[];
  176. height?: number;
  177. utc?: boolean;
  178. vitalFields?: {
  179. goodCountField: string;
  180. mehCountField: string;
  181. poorCountField: string;
  182. };
  183. };
  184. function fieldToVitalType(
  185. seriesName: string,
  186. vitalFields: _VitalChartProps['vitalFields']
  187. ): VitalState | undefined {
  188. if (seriesName === vitalFields?.poorCountField.replace('equation|', '')) {
  189. return VitalState.POOR;
  190. }
  191. if (seriesName === vitalFields?.mehCountField.replace('equation|', '')) {
  192. return VitalState.MEH;
  193. }
  194. if (seriesName === vitalFields?.goodCountField.replace('equation|', '')) {
  195. return VitalState.GOOD;
  196. }
  197. return undefined;
  198. }
  199. export function _VitalChart(props: _VitalChartProps) {
  200. const {
  201. field: yAxis,
  202. data: _results,
  203. loading,
  204. reloading,
  205. height,
  206. grid,
  207. utc,
  208. vitalFields,
  209. } = props;
  210. const theme = useTheme();
  211. if (!_results || !vitalFields) {
  212. return null;
  213. }
  214. const chartOptions: Omit<LineChartProps, 'series'> = {
  215. grid,
  216. seriesOptions: {
  217. showSymbol: false,
  218. },
  219. tooltip: {
  220. trigger: 'axis',
  221. valueFormatter: (value: number, seriesName?: string) => {
  222. return tooltipFormatter(
  223. value,
  224. aggregateOutputType(vitalFields[0] === WebVital.CLS ? seriesName : yAxis)
  225. );
  226. },
  227. },
  228. xAxis: {
  229. show: false,
  230. },
  231. xAxes: undefined,
  232. yAxis: {
  233. axisLabel: {
  234. color: theme.chartLabel,
  235. showMaxLabel: false,
  236. formatter: (value: number) =>
  237. axisLabelFormatter(value, aggregateOutputType(yAxis)),
  238. },
  239. },
  240. utc,
  241. isGroupedByDate: true,
  242. showTimeInTooltip: true,
  243. };
  244. const results = _results.filter(s => !!fieldToVitalType(s.seriesName, vitalFields));
  245. const smoothedSeries = results?.length
  246. ? results.map(({seriesName, ...rest}) => {
  247. const adjustedSeries = fieldToVitalType(seriesName, vitalFields) || 'count';
  248. return {
  249. seriesName: adjustedSeries,
  250. ...rest,
  251. color: theme[vitalStateColors[adjustedSeries]],
  252. lineStyle: {
  253. opacity: 1,
  254. width: 2,
  255. },
  256. };
  257. })
  258. : [];
  259. return (
  260. <div>
  261. <TransitionChart loading={loading} reloading={reloading}>
  262. <TransparentLoadingMask visible={reloading} />
  263. {getDynamicText({
  264. value: (
  265. <LineChart
  266. height={height}
  267. {...chartOptions}
  268. onLegendSelectChanged={() => {}}
  269. series={[...smoothedSeries]}
  270. isGroupedByDate
  271. />
  272. ),
  273. fixed: 'Web Vitals Chart',
  274. })}
  275. </TransitionChart>
  276. </div>
  277. );
  278. }