vitalChart.tsx 8.9 KB

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