latencyChart.tsx 8.6 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304
  1. import {Component, Fragment} from 'react';
  2. import {Location} from 'history';
  3. import BarChart from 'app/components/charts/barChart';
  4. import BarChartZoom from 'app/components/charts/barChartZoom';
  5. import ErrorPanel from 'app/components/charts/errorPanel';
  6. import LoadingPanel from 'app/components/charts/loadingPanel';
  7. import OptionSelector from 'app/components/charts/optionSelector';
  8. import {HeaderTitleLegend} from 'app/components/charts/styles';
  9. import QuestionTooltip from 'app/components/questionTooltip';
  10. import {IconWarning} from 'app/icons';
  11. import {t, tct} from 'app/locale';
  12. import {OrganizationSummary} from 'app/types';
  13. import {trackAnalyticsEvent} from 'app/utils/analytics';
  14. import EventView from 'app/utils/discover/eventView';
  15. import Histogram from 'app/utils/performance/histogram';
  16. import HistogramQuery from 'app/utils/performance/histogram/histogramQuery';
  17. import {HistogramData} from 'app/utils/performance/histogram/types';
  18. import {computeBuckets, formatHistogramData} from 'app/utils/performance/histogram/utils';
  19. import {decodeInteger} from 'app/utils/queryString';
  20. import theme from 'app/utils/theme';
  21. import {filterToColour, filterToField, SpanOperationBreakdownFilter} from './filter';
  22. export const ZOOM_START = 'startDuration';
  23. export const ZOOM_END = 'endDuration';
  24. const NUM_BUCKETS = 50;
  25. const QUERY_KEYS = [
  26. 'environment',
  27. 'project',
  28. 'query',
  29. 'start',
  30. 'end',
  31. 'statsPeriod',
  32. ] as const;
  33. type ViewProps = Pick<EventView, typeof QUERY_KEYS[number]>;
  34. type Props = ViewProps & {
  35. organization: OrganizationSummary;
  36. location: Location;
  37. currentFilter: SpanOperationBreakdownFilter;
  38. };
  39. type State = {
  40. zoomError: boolean;
  41. };
  42. /**
  43. * Fetch and render a bar chart that shows event volume
  44. * for each duration bucket. We always render 50 buckets of
  45. * equal widths based on the endpoints min + max durations.
  46. *
  47. * This graph visualizes how many transactions were recorded
  48. * at each duration bucket, showing the modality of the transaction.
  49. */
  50. class LatencyChart extends Component<Props, State> {
  51. state: State = {
  52. zoomError: false,
  53. };
  54. handleMouseOver = () => {
  55. // Hide the zoom error tooltip on the next hover.
  56. if (this.state.zoomError) {
  57. this.setState({zoomError: false});
  58. }
  59. };
  60. handleDataZoom = () => {
  61. const {organization} = this.props;
  62. trackAnalyticsEvent({
  63. eventKey: 'performance_views.latency_chart.zoom',
  64. eventName: 'Performance Views: Transaction Summary Latency Chart Zoom',
  65. organization_id: parseInt(organization.id, 10),
  66. });
  67. };
  68. handleDataZoomCancelled = () => {
  69. this.setState({zoomError: true});
  70. };
  71. bucketWidth(data: HistogramData) {
  72. // We can assume that all buckets are of equal width, use the first two
  73. // buckets to get the width. The value of each histogram function indicates
  74. // the beginning of the bucket.
  75. return data.length > 2 ? data[1].bin - data[0].bin : 0;
  76. }
  77. renderLoading() {
  78. return <LoadingPanel data-test-id="histogram-loading" />;
  79. }
  80. renderError() {
  81. // Don't call super as we don't really need issues for this.
  82. return (
  83. <ErrorPanel>
  84. <IconWarning color="gray300" size="lg" />
  85. </ErrorPanel>
  86. );
  87. }
  88. renderChart(data: HistogramData) {
  89. const {location, currentFilter} = this.props;
  90. const {zoomError} = this.state;
  91. const xAxis = {
  92. type: 'category' as const,
  93. truncate: true,
  94. axisTick: {
  95. interval: 0,
  96. alignWithLabel: true,
  97. },
  98. };
  99. const colors =
  100. currentFilter === SpanOperationBreakdownFilter.None
  101. ? [...theme.charts.getColorPalette(1)]
  102. : [filterToColour(currentFilter)];
  103. // Use a custom tooltip formatter as we need to replace
  104. // the tooltip content entirely when zooming is no longer available.
  105. const tooltip = {
  106. formatter(series) {
  107. const seriesData = Array.isArray(series) ? series : [series];
  108. let contents: string[] = [];
  109. if (!zoomError) {
  110. // Replicate the necessary logic from app/components/charts/components/tooltip.jsx
  111. contents = seriesData.map(item => {
  112. const label = item.seriesName;
  113. const value = item.value[1].toLocaleString();
  114. return [
  115. '<div class="tooltip-series">',
  116. `<div><span class="tooltip-label">${item.marker} <strong>${label}</strong></span> ${value}</div>`,
  117. '</div>',
  118. ].join('');
  119. });
  120. const seriesLabel = seriesData[0].value[0];
  121. contents.push(`<div class="tooltip-date">${seriesLabel}</div>`);
  122. } else {
  123. contents = [
  124. '<div class="tooltip-series tooltip-series-solo">',
  125. t('Target zoom region too small'),
  126. '</div>',
  127. ];
  128. }
  129. contents.push('<div class="tooltip-arrow"></div>');
  130. return contents.join('');
  131. },
  132. };
  133. const series = {
  134. seriesName: t('Count'),
  135. data: formatHistogramData(data, {type: 'duration'}),
  136. };
  137. return (
  138. <BarChartZoom
  139. minZoomWidth={NUM_BUCKETS}
  140. location={location}
  141. paramStart={ZOOM_START}
  142. paramEnd={ZOOM_END}
  143. xAxisIndex={[0]}
  144. buckets={computeBuckets(data)}
  145. onDataZoomCancelled={this.handleDataZoomCancelled}
  146. >
  147. {zoomRenderProps => (
  148. <BarChart
  149. grid={{left: '10px', right: '10px', top: '40px', bottom: '0px'}}
  150. xAxis={xAxis}
  151. yAxis={{type: 'value'}}
  152. series={[series]}
  153. tooltip={tooltip}
  154. colors={colors}
  155. onMouseOver={this.handleMouseOver}
  156. {...zoomRenderProps}
  157. />
  158. )}
  159. </BarChartZoom>
  160. );
  161. }
  162. render() {
  163. const {
  164. organization,
  165. query,
  166. start,
  167. end,
  168. statsPeriod,
  169. environment,
  170. project,
  171. location,
  172. currentFilter,
  173. } = this.props;
  174. const eventView = EventView.fromNewQueryWithLocation(
  175. {
  176. id: undefined,
  177. version: 2,
  178. name: '',
  179. fields: ['transaction.duration'],
  180. projects: project,
  181. range: statsPeriod,
  182. query,
  183. environment,
  184. start,
  185. end,
  186. },
  187. location
  188. );
  189. const {min, max} = decodeHistogramZoom(location);
  190. const field = filterToField(currentFilter) ?? 'transaction.duration';
  191. const headerTitle =
  192. currentFilter === SpanOperationBreakdownFilter.None
  193. ? t('Duration Distribution')
  194. : tct('Span Operation Distribution - [operationName]', {
  195. operationName: currentFilter,
  196. });
  197. return (
  198. <Fragment>
  199. <HeaderTitleLegend>
  200. {headerTitle}
  201. <QuestionTooltip
  202. position="top"
  203. size="sm"
  204. title={t(
  205. `Duration Distribution reflects the volume of transactions per median duration.`
  206. )}
  207. />
  208. </HeaderTitleLegend>
  209. <Histogram location={location} zoomKeys={[ZOOM_START, ZOOM_END]}>
  210. {({activeFilter}) => (
  211. <HistogramQuery
  212. location={location}
  213. orgSlug={organization.slug}
  214. eventView={eventView}
  215. numBuckets={NUM_BUCKETS}
  216. fields={[field]}
  217. min={min}
  218. max={max}
  219. dataFilter={activeFilter.value}
  220. >
  221. {({histograms, isLoading, error}) => {
  222. if (isLoading) {
  223. return this.renderLoading();
  224. } else if (error) {
  225. return this.renderError();
  226. }
  227. const data = histograms?.[field] ?? [];
  228. return this.renderChart(data);
  229. }}
  230. </HistogramQuery>
  231. )}
  232. </Histogram>
  233. </Fragment>
  234. );
  235. }
  236. }
  237. export function LatencyChartControls(props: {location: Location}) {
  238. const {location} = props;
  239. return (
  240. <Histogram location={location} zoomKeys={[ZOOM_START, ZOOM_END]}>
  241. {({filterOptions, handleFilterChange, activeFilter}) => {
  242. return (
  243. <Fragment>
  244. <OptionSelector
  245. title={t('Outliers')}
  246. selected={activeFilter.value}
  247. options={filterOptions}
  248. onChange={handleFilterChange}
  249. />
  250. </Fragment>
  251. );
  252. }}
  253. </Histogram>
  254. );
  255. }
  256. export function decodeHistogramZoom(location: Location) {
  257. let min: number | undefined = undefined;
  258. let max: number | undefined = undefined;
  259. if (ZOOM_START in location.query) {
  260. min = decodeInteger(location.query[ZOOM_START], 0);
  261. }
  262. if (ZOOM_END in location.query) {
  263. const decodedMax = decodeInteger(location.query[ZOOM_END]);
  264. if (typeof decodedMax === 'number') {
  265. max = decodedMax;
  266. }
  267. }
  268. return {min, max};
  269. }
  270. export default LatencyChart;