index.tsx 12 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472
  1. import {useCallback, useRef} from 'react';
  2. import type {InjectedRouter} from 'react-router';
  3. import moment from 'moment-timezone';
  4. import * as qs from 'query-string';
  5. import type {DateTimeObject} from 'sentry/components/charts/utils';
  6. import {
  7. getDiffInMinutes,
  8. GranularityLadder,
  9. ONE_HOUR,
  10. ONE_WEEK,
  11. SIX_HOURS,
  12. SIXTY_DAYS,
  13. THIRTY_DAYS,
  14. TWENTY_FOUR_HOURS,
  15. TWO_WEEKS,
  16. } from 'sentry/components/charts/utils';
  17. import {
  18. normalizeDateTimeParams,
  19. parseStatsPeriod,
  20. } from 'sentry/components/organizations/pageFilters/parse';
  21. import {t} from 'sentry/locale';
  22. import type {PageFilters} from 'sentry/types/core';
  23. import type {
  24. MetricAggregation,
  25. MetricMeta,
  26. MetricsDataIntervalLadder,
  27. MetricsQueryApiResponse,
  28. MetricsQueryApiResponseLastMeta,
  29. MRI,
  30. UseCase,
  31. } from 'sentry/types/metrics';
  32. import {isMeasurement} from 'sentry/utils/discover/fields';
  33. import {statsPeriodToDays} from 'sentry/utils/duration/statsPeriodToDays';
  34. import {getMeasurements} from 'sentry/utils/measurements/measurements';
  35. import {DEFAULT_AGGREGATES} from 'sentry/utils/metrics/constants';
  36. import {formatMRI, formatMRIField, MRIToField, parseMRI} from 'sentry/utils/metrics/mri';
  37. import type {
  38. MetricsQuery,
  39. MetricsQueryParams,
  40. MetricsWidget,
  41. } from 'sentry/utils/metrics/types';
  42. import {MetricDisplayType} from 'sentry/utils/metrics/types';
  43. import {
  44. isMetricFormula,
  45. type MetricsQueryApiQueryParams,
  46. } from 'sentry/utils/metrics/useMetricsQuery';
  47. import useRouter from 'sentry/utils/useRouter';
  48. export function getDefaultMetricDisplayType(
  49. mri?: MRI,
  50. aggregation?: MetricAggregation
  51. ): MetricDisplayType {
  52. if (mri?.startsWith('c') || aggregation === 'count') {
  53. return MetricDisplayType.BAR;
  54. }
  55. return MetricDisplayType.LINE;
  56. }
  57. export const getMetricDisplayType = (displayType: unknown): MetricDisplayType => {
  58. if (
  59. [MetricDisplayType.AREA, MetricDisplayType.BAR, MetricDisplayType.LINE].includes(
  60. displayType as MetricDisplayType
  61. )
  62. ) {
  63. return displayType as MetricDisplayType;
  64. }
  65. return MetricDisplayType.LINE;
  66. };
  67. export function getMetricsUrl(
  68. orgSlug: string,
  69. {
  70. widgets,
  71. start,
  72. end,
  73. statsPeriod,
  74. project,
  75. ...otherParams
  76. }: Omit<MetricsQueryParams, 'project' | 'widgets'> & {
  77. widgets: Partial<MetricsWidget>[];
  78. project?: (string | number)[];
  79. }
  80. ) {
  81. const urlParams: Partial<MetricsQueryParams> = {
  82. ...otherParams,
  83. project: project?.map(id => (typeof id === 'string' ? parseInt(id, 10) : id)),
  84. widgets: JSON.stringify(widgets),
  85. };
  86. if (statsPeriod) {
  87. urlParams.statsPeriod = statsPeriod;
  88. } else {
  89. urlParams.start = start;
  90. urlParams.end = end;
  91. }
  92. return `/organizations/${orgSlug}/metrics/?${qs.stringify(urlParams)}`;
  93. }
  94. const intervalLadders: Record<MetricsDataIntervalLadder, GranularityLadder> = {
  95. metrics: new GranularityLadder([
  96. [SIXTY_DAYS, '1d'],
  97. [THIRTY_DAYS, '2h'],
  98. [TWO_WEEKS, '1h'],
  99. [ONE_WEEK, '30m'],
  100. [TWENTY_FOUR_HOURS, '5m'],
  101. [ONE_HOUR, '1m'],
  102. [0, '1m'],
  103. ]),
  104. bar: new GranularityLadder([
  105. [SIXTY_DAYS, '1d'],
  106. [THIRTY_DAYS, '12h'],
  107. [TWO_WEEKS, '4h'],
  108. [ONE_WEEK, '2h'],
  109. [TWENTY_FOUR_HOURS, '1h'],
  110. [SIX_HOURS, '30m'],
  111. [ONE_HOUR, '5m'],
  112. [0, '1m'],
  113. ]),
  114. dashboard: new GranularityLadder([
  115. [SIXTY_DAYS, '1d'],
  116. [THIRTY_DAYS, '1h'],
  117. [TWO_WEEKS, '30m'],
  118. [ONE_WEEK, '30m'],
  119. [TWENTY_FOUR_HOURS, '5m'],
  120. [0, '5m'],
  121. ]),
  122. };
  123. // Wraps getInterval since other users of this function, and other metric use cases do not have support for 10s granularity
  124. export function getMetricsInterval(
  125. datetimeObj: DateTimeObject,
  126. useCase: UseCase,
  127. ladder: MetricsDataIntervalLadder = 'metrics'
  128. ) {
  129. const diffInMinutes = getDiffInMinutes(datetimeObj);
  130. if (diffInMinutes <= ONE_HOUR && useCase === 'custom' && ladder === 'metrics') {
  131. return '10s';
  132. }
  133. return intervalLadders[ladder].getInterval(diffInMinutes);
  134. }
  135. export function getDateTimeParams({start, end, period}: PageFilters['datetime']) {
  136. return period
  137. ? {statsPeriod: period}
  138. : {start: moment(start).toISOString(), end: moment(end).toISOString()};
  139. }
  140. export function getDefaultAggregation(mri: MRI): MetricAggregation {
  141. const parsedMRI = parseMRI(mri);
  142. const fallbackAggregate = 'sum';
  143. if (!parsedMRI) {
  144. return fallbackAggregate;
  145. }
  146. return DEFAULT_AGGREGATES[parsedMRI.type] || fallbackAggregate;
  147. }
  148. // Using Records to ensure all MetricAggregations are covered
  149. const metricAggregationsCheck: Record<MetricAggregation, boolean> = {
  150. count: true,
  151. count_unique: true,
  152. sum: true,
  153. avg: true,
  154. min: true,
  155. max: true,
  156. p50: true,
  157. p75: true,
  158. p90: true,
  159. p95: true,
  160. p99: true,
  161. };
  162. export function isMetricsAggregation(value: string): value is MetricAggregation {
  163. return !!metricAggregationsCheck[value as MetricAggregation];
  164. }
  165. export function isAllowedAggregation(aggregation: MetricAggregation) {
  166. return !['max_timestamp', 'min_timestamp', 'histogram'].includes(aggregation);
  167. }
  168. // Applying these aggregations to a metric will result in a timeseries whose scale is different than
  169. // the original metric.
  170. export function isCumulativeAggregation(aggregation: MetricAggregation) {
  171. return ['sum', 'count', 'count_unique'].includes(aggregation);
  172. }
  173. function updateQuery(
  174. router: InjectedRouter,
  175. partialQuery: Record<string, any>,
  176. options?: {replace: boolean}
  177. ) {
  178. const updateFunction = options?.replace ? router.replace : router.push;
  179. updateFunction({
  180. ...router.location,
  181. query: {
  182. ...router.location.query,
  183. ...partialQuery,
  184. },
  185. });
  186. }
  187. export function clearQuery(router: InjectedRouter) {
  188. router.push({
  189. ...router.location,
  190. query: {},
  191. });
  192. }
  193. export function useInstantRef<T>(value: T) {
  194. const ref = useRef(value);
  195. ref.current = value;
  196. return ref;
  197. }
  198. export function useUpdateQuery() {
  199. const router = useRouter();
  200. // Store the router in a ref so that we can use it in the callback
  201. // without needing to generate a new callback every time the location changes
  202. const routerRef = useInstantRef(router);
  203. return useCallback(
  204. (partialQuery: Record<string, any>, options?: {replace: boolean}) => {
  205. updateQuery(routerRef.current, partialQuery, options);
  206. },
  207. [routerRef]
  208. );
  209. }
  210. export function useClearQuery() {
  211. const router = useRouter();
  212. // Store the router in a ref so that we can use it in the callback
  213. // without needing to generate a new callback every time the location changes
  214. const routerRef = useInstantRef(router);
  215. return useCallback(() => {
  216. clearQuery(routerRef.current);
  217. }, [routerRef]);
  218. }
  219. export function unescapeMetricsFormula(formula: string) {
  220. // Remove the $ from variable names
  221. return formula.replaceAll('$', '');
  222. }
  223. export function getMetricsSeriesName(
  224. query: MetricsQueryApiQueryParams,
  225. groupBy?: Record<string, string>,
  226. isMultiQuery: boolean = true
  227. ) {
  228. let name = getMetricQueryName(query);
  229. if (isMultiQuery) {
  230. name = `${query.name}: ${name}`;
  231. }
  232. const groupByEntries = Object.entries(groupBy ?? {});
  233. if (!groupByEntries || !groupByEntries.length) {
  234. return name;
  235. }
  236. const formattedGrouping = groupByEntries
  237. .map(([_key, value]) => `${String(value).length ? value : t('(none)')}`)
  238. .join(', ');
  239. if (isMultiQuery) {
  240. return `${name} - ${formattedGrouping}`;
  241. }
  242. return formattedGrouping;
  243. }
  244. export function getMetricQueryName(query: MetricsQueryApiQueryParams): string {
  245. return (
  246. query.alias ??
  247. (isMetricFormula(query)
  248. ? unescapeMetricsFormula(query.formula)
  249. : formatMRIField(MRIToField(query.mri, query.aggregation)))
  250. );
  251. }
  252. export function getMetricsSeriesId(
  253. query: MetricsQueryApiQueryParams,
  254. groupBy?: Record<string, string>
  255. ) {
  256. if (Object.keys(groupBy ?? {}).length === 0) {
  257. return `${query.name}`;
  258. }
  259. return `${query.name}-${JSON.stringify(groupBy)}`;
  260. }
  261. export function groupByOp(metrics: MetricMeta[]): Record<string, MetricMeta[]> {
  262. const uniqueOperations = [
  263. ...new Set(metrics.flatMap(field => field.operations).filter(isAllowedAggregation)),
  264. ].sort();
  265. const groupedByAggregation = uniqueOperations.reduce((result, aggregation) => {
  266. result[aggregation] = metrics.filter(field => field.operations.includes(aggregation));
  267. return result;
  268. }, {});
  269. return groupedByAggregation;
  270. }
  271. export function isTransactionMeasurement({mri}: {mri: MRI}) {
  272. const {name} = parseMRI(mri) ?? {name: ''};
  273. return isMeasurement(name);
  274. }
  275. export function isSpanMeasurement({mri}: {mri: MRI}) {
  276. if (
  277. mri === 'd:spans/http.response_content_length@byte' ||
  278. mri === 'd:spans/http.decoded_response_content_length@byte' ||
  279. mri === 'd:spans/http.response_transfer_size@byte'
  280. ) {
  281. return true;
  282. }
  283. const parsedMRI = parseMRI(mri);
  284. if (
  285. parsedMRI &&
  286. parsedMRI.useCase === 'spans' &&
  287. parsedMRI.name.startsWith('webvital.')
  288. ) {
  289. return true;
  290. }
  291. return false;
  292. }
  293. export function isCustomMeasurement({mri}: {mri: MRI}) {
  294. const DEFINED_MEASUREMENTS = new Set(Object.keys(getMeasurements()));
  295. const {name} = parseMRI(mri) ?? {name: ''};
  296. return !DEFINED_MEASUREMENTS.has(name) && isMeasurement(name);
  297. }
  298. export function isStandardMeasurement({mri}: {mri: MRI}) {
  299. return isTransactionMeasurement({mri}) && !isCustomMeasurement({mri});
  300. }
  301. export function isTransactionDuration({mri}: {mri: MRI}) {
  302. return mri === 'd:transactions/duration@millisecond';
  303. }
  304. export function isCustomMetric({mri}: {mri: MRI}) {
  305. return mri.includes(':custom/');
  306. }
  307. export function isVirtualMetric({mri}: {mri: MRI}) {
  308. return mri.startsWith('v:');
  309. }
  310. export function isCounterMetric({mri}: {mri: MRI}) {
  311. return mri.startsWith('c:');
  312. }
  313. export function isSpanDuration({mri}: {mri: MRI}) {
  314. return mri === 'd:spans/duration@millisecond';
  315. }
  316. export function getFieldFromMetricsQuery(metricsQuery: MetricsQuery) {
  317. if (isCustomMetric(metricsQuery)) {
  318. return MRIToField(metricsQuery.mri, metricsQuery.aggregation);
  319. }
  320. return formatMRIField(MRIToField(metricsQuery.mri, metricsQuery.aggregation));
  321. }
  322. export function getFormattedMQL({
  323. mri,
  324. aggregation,
  325. query,
  326. groupBy,
  327. }: MetricsQuery): string {
  328. if (!aggregation) {
  329. return '';
  330. }
  331. let result = `${aggregation}(${formatMRI(mri)})`;
  332. if (query) {
  333. result += `{${query.trim()}}`;
  334. }
  335. if (groupBy?.length) {
  336. result += ` by ${groupBy.join(', ')}`;
  337. }
  338. return result;
  339. }
  340. export function isFormattedMQL(mql: string) {
  341. const regex = /^(\w+\([\w\.]+\))(?:\{\w+\:\w+\})*(?:\sby\s\w+)*/;
  342. const matches = mql.match(regex);
  343. const [, field, query, groupBy] = matches ?? [];
  344. if (!field) {
  345. return false;
  346. }
  347. if (query) {
  348. return query.includes(':');
  349. }
  350. if (groupBy) {
  351. // TODO check groupbys
  352. }
  353. return true;
  354. }
  355. // TODO: consider moving this to utils/dates.tsx
  356. export function getAbsoluteDateTimeRange(params: PageFilters['datetime']) {
  357. const {start, end, statsPeriod, utc} = normalizeDateTimeParams(params, {
  358. allowAbsoluteDatetime: true,
  359. });
  360. if (start && end) {
  361. return {start: moment(start).toISOString(), end: moment(end).toISOString()};
  362. }
  363. const parsedStatusPeriod = parseStatsPeriod(statsPeriod || '24h');
  364. const now = utc ? moment().utc() : moment();
  365. if (!parsedStatusPeriod) {
  366. // Default to 24h
  367. return {start: moment(now).subtract(1, 'day').toISOString(), end: now.toISOString()};
  368. }
  369. const startObj = moment(now).subtract(
  370. parsedStatusPeriod.period,
  371. parsedStatusPeriod.periodLength
  372. );
  373. return {start: startObj.toISOString(), end: now.toISOString()};
  374. }
  375. // TODO(metrics): remove this when we switch tags to the new meta
  376. export function getMetaDateTimeParams(datetime?: PageFilters['datetime']) {
  377. if (datetime?.period) {
  378. if (statsPeriodToDays(datetime.period) < 14) {
  379. return {statsPeriod: '14d'};
  380. }
  381. return {statsPeriod: datetime.period};
  382. }
  383. if (datetime?.start && datetime?.end) {
  384. return {
  385. start: moment(datetime.start).toISOString(),
  386. end: moment(datetime.end).toISOString(),
  387. };
  388. }
  389. return {statsPeriod: '14d'};
  390. }
  391. export function areResultsLimited(response: MetricsQueryApiResponse) {
  392. return response.meta.some(
  393. meta => (meta[meta.length - 1] as MetricsQueryApiResponseLastMeta).has_more
  394. );
  395. }
  396. export function isNotQueryOnly(query: MetricsQueryApiQueryParams) {
  397. return !('isQueryOnly' in query) || !query.isQueryOnly;
  398. }