utils.tsx 11 KB

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