utils.tsx 12 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418
  1. import {useMemo} from 'react';
  2. import * as Sentry from '@sentry/react';
  3. import type {LegendComponentOption, LineSeriesOption} from 'echarts';
  4. import type {Location} from 'history';
  5. import orderBy from 'lodash/orderBy';
  6. import moment from 'moment';
  7. import {DEFAULT_STATS_PERIOD} from 'sentry/constants';
  8. import type {EventsStats, MultiSeriesEventsStats, PageFilters} from 'sentry/types';
  9. import type {ReactEchartsRef, Series} from 'sentry/types/echarts';
  10. import {defined, escape} from 'sentry/utils';
  11. import {getFormattedDate, parsePeriodToHours} from 'sentry/utils/dates';
  12. import type {TableDataWithTitle} from 'sentry/utils/discover/discoverQuery';
  13. import oxfordizeArray from 'sentry/utils/oxfordizeArray';
  14. import {decodeList} from 'sentry/utils/queryString';
  15. const DEFAULT_TRUNCATE_LENGTH = 80;
  16. // In minutes
  17. export const SIXTY_DAYS = 86400;
  18. export const THIRTY_DAYS = 43200;
  19. export const TWO_WEEKS = 20160;
  20. export const ONE_WEEK = 10080;
  21. export const FORTY_EIGHT_HOURS = 2880;
  22. export const TWENTY_FOUR_HOURS = 1440;
  23. export const SIX_HOURS = 360;
  24. export const ONE_HOUR = 60;
  25. /**
  26. * If there are more releases than this number we hide "Releases" series by default
  27. */
  28. export const RELEASE_LINES_THRESHOLD = 50;
  29. export type DateTimeObject = Partial<PageFilters['datetime']>;
  30. export function truncationFormatter(
  31. value: string,
  32. truncate: number | boolean | undefined
  33. ): string {
  34. if (!truncate) {
  35. return escape(value);
  36. }
  37. const truncationLength =
  38. truncate && typeof truncate === 'number' ? truncate : DEFAULT_TRUNCATE_LENGTH;
  39. const truncated =
  40. value.length > truncationLength ? value.substring(0, truncationLength) + '…' : value;
  41. return escape(truncated);
  42. }
  43. /**
  44. * Use a shorter interval if the time difference is <= 24 hours.
  45. */
  46. function computeShortInterval(datetimeObj: DateTimeObject): boolean {
  47. const diffInMinutes = getDiffInMinutes(datetimeObj);
  48. return diffInMinutes <= TWENTY_FOUR_HOURS;
  49. }
  50. export function useShortInterval(datetimeObj: DateTimeObject): boolean {
  51. return computeShortInterval(datetimeObj);
  52. }
  53. export type GranularityStep = [timeDiff: number, interval: string];
  54. export class GranularityLadder {
  55. steps: GranularityStep[];
  56. constructor(steps: GranularityStep[]) {
  57. if (
  58. !steps.some(step => {
  59. return step[0] === 0;
  60. })
  61. ) {
  62. throw new Error('At least one step in the ladder must start at 0');
  63. }
  64. this.steps = orderBy(steps, step => step[0], 'desc');
  65. }
  66. getInterval(minutes: number): string {
  67. if (minutes < 0) {
  68. // Sometimes this happens, in unknown circumstances. See the `getIntervalForMetricFunction` function span in Sentry for more info, the reason might appear there. For now, a reasonable fallback in these rare cases is to return the finest granularity, since it'll either fulfill the request or time out.
  69. Sentry.withScope(scope => {
  70. scope.setFingerprint(['invalid-duration-for-interval']);
  71. Sentry.captureException(
  72. new Error('Invalid duration supplied to interval function')
  73. );
  74. });
  75. return (this.steps.at(-1) as GranularityStep)[1];
  76. }
  77. const step = this.steps.find(([threshold]) => {
  78. return minutes >= threshold;
  79. }) as GranularityStep;
  80. return step[1];
  81. }
  82. }
  83. export type Fidelity = 'high' | 'medium' | 'low' | 'metrics';
  84. export function getInterval(datetimeObj: DateTimeObject, fidelity: Fidelity = 'medium') {
  85. const diffInMinutes = getDiffInMinutes(datetimeObj);
  86. return {
  87. high: highFidelityLadder,
  88. medium: mediumFidelityLadder,
  89. low: lowFidelityLadder,
  90. metrics: metricsFidelityLadder,
  91. }[fidelity].getInterval(diffInMinutes);
  92. }
  93. const highFidelityLadder = new GranularityLadder([
  94. [SIXTY_DAYS, '4h'],
  95. [THIRTY_DAYS, '1h'],
  96. [TWENTY_FOUR_HOURS + 1, '30m'],
  97. [ONE_HOUR + 1, '5m'],
  98. [0, '1m'],
  99. ]);
  100. const mediumFidelityLadder = new GranularityLadder([
  101. [SIXTY_DAYS, '1d'],
  102. [THIRTY_DAYS, '4h'],
  103. [TWENTY_FOUR_HOURS + 1, '1h'],
  104. [ONE_HOUR + 1, '15m'],
  105. [0, '5m'],
  106. ]);
  107. const lowFidelityLadder = new GranularityLadder([
  108. [SIXTY_DAYS, '2d'],
  109. [THIRTY_DAYS, '1d'],
  110. [TWO_WEEKS, '12h'],
  111. [TWENTY_FOUR_HOURS + 1, '6h'],
  112. [ONE_HOUR + 1, '1h'],
  113. [0, '10m'],
  114. ]);
  115. const metricsFidelityLadder = new GranularityLadder([
  116. [SIXTY_DAYS, '1d'],
  117. [THIRTY_DAYS, '12h'],
  118. [TWO_WEEKS, '4h'],
  119. [TWENTY_FOUR_HOURS, '30m'],
  120. [SIX_HOURS, '5m'],
  121. [ONE_HOUR, '1m'],
  122. [0, '1m'],
  123. ]);
  124. /**
  125. * Duplicate of getInterval, except that we do not support <1h granularity
  126. * Used by OrgStatsV2 API
  127. */
  128. const seriesAPILadder = new GranularityLadder([
  129. [SIXTY_DAYS, '1d'],
  130. [THIRTY_DAYS, '4h'],
  131. [SIX_HOURS, '1h'],
  132. [0, '5m'],
  133. ]);
  134. export function getSeriesApiInterval(datetimeObj: DateTimeObject) {
  135. return seriesAPILadder.getInterval(getDiffInMinutes(datetimeObj));
  136. }
  137. export function getDiffInMinutes(datetimeObj: DateTimeObject): number {
  138. const {period, start, end} = datetimeObj;
  139. if (start && end) {
  140. return moment(end).diff(start, 'minutes');
  141. }
  142. return (
  143. parsePeriodToHours(typeof period === 'string' ? period : DEFAULT_STATS_PERIOD) * 60
  144. );
  145. }
  146. // Max period (in hours) before we can no long include previous period
  147. const MAX_PERIOD_HOURS_INCLUDE_PREVIOUS = 45 * 24;
  148. export function canIncludePreviousPeriod(
  149. includePrevious: boolean | undefined,
  150. period: string | null | undefined
  151. ) {
  152. if (!includePrevious) {
  153. return false;
  154. }
  155. if (period && parsePeriodToHours(period) > MAX_PERIOD_HOURS_INCLUDE_PREVIOUS) {
  156. return false;
  157. }
  158. // otherwise true
  159. return !!includePrevious;
  160. }
  161. export function shouldFetchPreviousPeriod({
  162. includePrevious = true,
  163. period,
  164. start,
  165. end,
  166. }: {
  167. includePrevious?: boolean;
  168. } & Pick<DateTimeObject, 'start' | 'end' | 'period'>) {
  169. return !start && !end && canIncludePreviousPeriod(includePrevious, period);
  170. }
  171. /**
  172. * Generates a series selection based on the query parameters defined by the location.
  173. */
  174. export function getSeriesSelection(
  175. location: Location
  176. ): LegendComponentOption['selected'] {
  177. const unselectedSeries = decodeList(location?.query.unselectedSeries);
  178. return unselectedSeries.reduce((selection, series) => {
  179. selection[series] = false;
  180. return selection;
  181. }, {});
  182. }
  183. function isSingleSeriesStats(
  184. data: MultiSeriesEventsStats | EventsStats
  185. ): data is EventsStats {
  186. return (
  187. (defined(data.data) || defined(data.totals)) &&
  188. defined(data.start) &&
  189. defined(data.end)
  190. );
  191. }
  192. export function isMultiSeriesStats(
  193. data: MultiSeriesEventsStats | EventsStats | null | undefined,
  194. isTopN?: boolean
  195. ): data is MultiSeriesEventsStats {
  196. return (
  197. defined(data) &&
  198. ((data.data === undefined && data.totals === undefined) ||
  199. (defined(isTopN) && isTopN && defined(data) && !isSingleSeriesStats(data))) // the isSingleSeriesStats check is for topN queries returning null data
  200. );
  201. }
  202. // If dimension is a number convert it to pixels, otherwise use dimension
  203. // without transform
  204. export const getDimensionValue = (dimension?: number | string | null) => {
  205. if (typeof dimension === 'number') {
  206. return `${dimension}px`;
  207. }
  208. if (dimension === null) {
  209. return undefined;
  210. }
  211. return dimension;
  212. };
  213. const RGB_LIGHTEN_VALUE = 30;
  214. export const lightenHexToRgb = (colors: string[]) =>
  215. colors.map(hex => {
  216. const rgb = [
  217. Math.min(parseInt(hex.slice(1, 3), 16) + RGB_LIGHTEN_VALUE, 255),
  218. Math.min(parseInt(hex.slice(3, 5), 16) + RGB_LIGHTEN_VALUE, 255),
  219. Math.min(parseInt(hex.slice(5, 7), 16) + RGB_LIGHTEN_VALUE, 255),
  220. ];
  221. return `rgb(${rgb[0]}, ${rgb[1]}, ${rgb[2]})`;
  222. });
  223. const DEFAULT_GEO_DATA = {
  224. title: '',
  225. data: [],
  226. };
  227. export const processTableResults = (tableResults?: TableDataWithTitle[]) => {
  228. if (!tableResults || !tableResults.length) {
  229. return DEFAULT_GEO_DATA;
  230. }
  231. const tableResult = tableResults[0];
  232. const {data} = tableResult;
  233. if (!data || !data.length) {
  234. return DEFAULT_GEO_DATA;
  235. }
  236. const preAggregate = Object.keys(data[0]).find(column => {
  237. return column !== 'geo.country_code';
  238. });
  239. if (!preAggregate) {
  240. return DEFAULT_GEO_DATA;
  241. }
  242. return {
  243. title: tableResult.title ?? '',
  244. data: data.map(row => {
  245. return {
  246. name: row['geo.country_code'] as string,
  247. value: row[preAggregate] as number,
  248. };
  249. }),
  250. };
  251. };
  252. export const getPreviousSeriesName = (seriesName: string) => {
  253. return `previous ${seriesName}`;
  254. };
  255. function formatList(items: Array<string | number | undefined>) {
  256. const filteredItems = items.filter((item): item is string | number => !!item);
  257. return oxfordizeArray(filteredItems.map(item => item.toString()));
  258. }
  259. export function computeEchartsAriaLabels(
  260. {series, useUTC}: {series: unknown; useUTC: boolean | undefined},
  261. isGroupedByDate: boolean
  262. ) {
  263. const filteredSeries = Array.isArray(series)
  264. ? series.filter(s => s && !!s.data && s.data.length > 0)
  265. : [series];
  266. const dateFormat = computeShortInterval({
  267. start: filteredSeries[0]?.data?.[0][0],
  268. end: filteredSeries[0]?.data?.slice(-1)[0][0],
  269. })
  270. ? `MMMM D, h:mm A`
  271. : 'MMMM Do';
  272. function formatDate(date) {
  273. return getFormattedDate(date, dateFormat, {
  274. local: !useUTC,
  275. });
  276. }
  277. // Generate title (first sentence)
  278. const chartTypes = new Set(filteredSeries.map(s => s.type));
  279. const title =
  280. filteredSeries.length > 0
  281. ? [
  282. `${formatList([...chartTypes])} chart`,
  283. isGroupedByDate
  284. ? `with ${formatDate(filteredSeries[0].data?.[0][0])} to ${formatDate(
  285. filteredSeries[0].data?.slice(-1)[0][0]
  286. )}`
  287. : '',
  288. `featuring ${filteredSeries.length} data series: ${formatList(
  289. filteredSeries.filter(s => s.data && s.data.length > 0).map(s => s.name)
  290. )}`,
  291. ].join(' ')
  292. : '';
  293. // Generate series descriptions
  294. const seriesDescriptions = filteredSeries
  295. .map(s => {
  296. if (!s.data || s.data.length === 0) {
  297. return '';
  298. }
  299. let highestValue: NonNullable<LineSeriesOption['data']>[0] = [0, -Infinity];
  300. let lowestValue: NonNullable<LineSeriesOption['data']>[0] = [0, Infinity];
  301. s.data.forEach(datum => {
  302. if (!Array.isArray(datum)) {
  303. return;
  304. }
  305. if (datum[1] > highestValue[1]) {
  306. highestValue = datum;
  307. }
  308. if (datum[1] < lowestValue[1]) {
  309. lowestValue = datum;
  310. }
  311. });
  312. const lowestX = isGroupedByDate ? formatDate(lowestValue[0]) : lowestValue[0];
  313. const highestX = isGroupedByDate ? formatDate(lowestValue[0]) : lowestValue[0];
  314. const lowestY =
  315. typeof lowestValue[1] === 'number' ? +lowestValue[1].toFixed(3) : lowestValue[1];
  316. const highestY =
  317. typeof highestValue[1] === 'number'
  318. ? +highestValue[1].toFixed(3)
  319. : lowestValue[1];
  320. return `The ${s.name} series contains ${
  321. s.data?.length
  322. } data points. Its lowest value is ${lowestY} ${
  323. isGroupedByDate ? 'on' : 'at'
  324. } ${lowestX} and highest value is ${highestY} ${
  325. isGroupedByDate ? 'on' : 'at'
  326. } ${highestX}`;
  327. })
  328. .filter(s => !!s);
  329. if (!filteredSeries[0]) {
  330. return {enabled: false};
  331. }
  332. return {
  333. enabled: true,
  334. label: {description: [title].concat(seriesDescriptions).join('. ')},
  335. };
  336. }
  337. export function useEchartsAriaLabels(
  338. {series, useUTC}: {series: unknown; useUTC: boolean | undefined},
  339. isGroupedByDate: boolean
  340. ) {
  341. return useMemo(() => {
  342. return computeEchartsAriaLabels({series, useUTC}, isGroupedByDate);
  343. }, [series, useUTC, isGroupedByDate]);
  344. }
  345. export function isEmptySeries(series: Series) {
  346. return series.data.every(dataPoint => dataPoint.value === 0);
  347. }
  348. /**
  349. * Used to determine which chart in a group is currently hovered.
  350. */
  351. export function isChartHovered(chartRef: ReactEchartsRef | null) {
  352. const hoveredEchartElement = document.querySelector('.echarts-for-react:hover');
  353. return hoveredEchartElement === chartRef?.ele;
  354. }