index.tsx 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440
  1. import {useCallback, useRef} from 'react';
  2. import type {InjectedRouter} from 'react-router';
  3. import moment from 'moment';
  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. MetricMeta,
  25. MetricsAggregate,
  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?: MetricsQuery['mri'],
  50. op?: MetricsQuery['op']
  51. ): MetricDisplayType {
  52. if (mri?.startsWith('c') || op === '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 getDefaultAggregate(mri: MRI): MetricsAggregate {
  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. export function isAllowedOp(op: string) {
  149. return !['max_timestamp', 'min_timestamp', 'histogram'].includes(op);
  150. }
  151. // Applying these operations to a metric will result in a timeseries whose scale is different than
  152. // the original metric. Becuase of that min and max bounds can't be used and we display the fog of war
  153. export function isCumulativeOp(op: string = '') {
  154. return ['sum', 'count', 'count_unique'].includes(op);
  155. }
  156. function updateQuery(
  157. router: InjectedRouter,
  158. partialQuery: Record<string, any>,
  159. options?: {replace: boolean}
  160. ) {
  161. const updateFunction = options?.replace ? router.replace : router.push;
  162. updateFunction({
  163. ...router.location,
  164. query: {
  165. ...router.location.query,
  166. ...partialQuery,
  167. },
  168. });
  169. }
  170. export function clearQuery(router: InjectedRouter) {
  171. router.push({
  172. ...router.location,
  173. query: {},
  174. });
  175. }
  176. export function useInstantRef<T>(value: T) {
  177. const ref = useRef(value);
  178. ref.current = value;
  179. return ref;
  180. }
  181. export function useUpdateQuery() {
  182. const router = useRouter();
  183. // Store the router in a ref so that we can use it in the callback
  184. // without needing to generate a new callback every time the location changes
  185. const routerRef = useInstantRef(router);
  186. return useCallback(
  187. (partialQuery: Record<string, any>, options?: {replace: boolean}) => {
  188. updateQuery(routerRef.current, partialQuery, options);
  189. },
  190. [routerRef]
  191. );
  192. }
  193. export function useClearQuery() {
  194. const router = useRouter();
  195. // Store the router in a ref so that we can use it in the callback
  196. // without needing to generate a new callback every time the location changes
  197. const routerRef = useInstantRef(router);
  198. return useCallback(() => {
  199. clearQuery(routerRef.current);
  200. }, [routerRef]);
  201. }
  202. export function unescapeMetricsFormula(formula: string) {
  203. // Remove the $ from variable names
  204. return formula.replaceAll('$', '');
  205. }
  206. export function getMetricsSeriesName(
  207. query: MetricsQueryApiQueryParams,
  208. groupBy?: Record<string, string>,
  209. isMultiQuery: boolean = true
  210. ) {
  211. let name = getMetricQueryName(query);
  212. if (isMultiQuery) {
  213. name = `${query.name}: ${name}`;
  214. }
  215. const groupByEntries = Object.entries(groupBy ?? {});
  216. if (!groupByEntries || !groupByEntries.length) {
  217. return name;
  218. }
  219. const formattedGrouping = groupByEntries
  220. .map(([_key, value]) => `${String(value).length ? value : t('(none)')}`)
  221. .join(', ');
  222. if (isMultiQuery) {
  223. return `${name} - ${formattedGrouping}`;
  224. }
  225. return formattedGrouping;
  226. }
  227. export function getMetricQueryName(query: MetricsQueryApiQueryParams): string {
  228. return (
  229. query.alias ??
  230. (isMetricFormula(query)
  231. ? unescapeMetricsFormula(query.formula)
  232. : formatMRIField(MRIToField(query.mri, query.op)))
  233. );
  234. }
  235. export function getMetricsSeriesId(
  236. query: MetricsQueryApiQueryParams,
  237. groupBy?: Record<string, string>
  238. ) {
  239. if (Object.keys(groupBy ?? {}).length === 0) {
  240. return `${query.name}`;
  241. }
  242. return `${query.name}-${JSON.stringify(groupBy)}`;
  243. }
  244. export function groupByOp(metrics: MetricMeta[]): Record<string, MetricMeta[]> {
  245. const uniqueOperations = [
  246. ...new Set(metrics.flatMap(field => field.operations).filter(isAllowedOp)),
  247. ].sort();
  248. const groupedByOp = uniqueOperations.reduce((result, op) => {
  249. result[op] = metrics.filter(field => field.operations.includes(op));
  250. return result;
  251. }, {});
  252. return groupedByOp;
  253. }
  254. export function isTransactionMeasurement({mri}: {mri: MRI}) {
  255. const {name} = parseMRI(mri) ?? {name: ''};
  256. return isMeasurement(name);
  257. }
  258. export function isSpanMeasurement({mri}: {mri: MRI}) {
  259. if (
  260. mri === 'd:spans/http.response_content_length@byte' ||
  261. mri === 'd:spans/http.decoded_response_content_length@byte' ||
  262. mri === 'd:spans/http.response_transfer_size@byte'
  263. ) {
  264. return true;
  265. }
  266. const parsedMRI = parseMRI(mri);
  267. if (
  268. parsedMRI &&
  269. parsedMRI.useCase === 'spans' &&
  270. parsedMRI.name.startsWith('webvital.')
  271. ) {
  272. return true;
  273. }
  274. return false;
  275. }
  276. export function isCustomMeasurement({mri}: {mri: MRI}) {
  277. const DEFINED_MEASUREMENTS = new Set(Object.keys(getMeasurements()));
  278. const {name} = parseMRI(mri) ?? {name: ''};
  279. return !DEFINED_MEASUREMENTS.has(name) && isMeasurement(name);
  280. }
  281. export function isStandardMeasurement({mri}: {mri: MRI}) {
  282. return isTransactionMeasurement({mri}) && !isCustomMeasurement({mri});
  283. }
  284. export function isTransactionDuration({mri}: {mri: MRI}) {
  285. return mri === 'd:transactions/duration@millisecond';
  286. }
  287. export function isCustomMetric({mri}: {mri: MRI}) {
  288. return mri.includes(':custom/');
  289. }
  290. export function isSpanDuration({mri}: {mri: MRI}) {
  291. return mri === 'd:spans/duration@millisecond';
  292. }
  293. export function getFieldFromMetricsQuery(metricsQuery: MetricsQuery) {
  294. if (isCustomMetric(metricsQuery)) {
  295. return MRIToField(metricsQuery.mri, metricsQuery.op);
  296. }
  297. return formatMRIField(MRIToField(metricsQuery.mri, metricsQuery.op));
  298. }
  299. export function getFormattedMQL({mri, op, query, groupBy}: MetricsQuery): string {
  300. if (!op) {
  301. return '';
  302. }
  303. let result = `${op}(${formatMRI(mri)})`;
  304. if (query) {
  305. result += `{${query.trim()}}`;
  306. }
  307. if (groupBy?.length) {
  308. result += ` by ${groupBy.join(', ')}`;
  309. }
  310. return result;
  311. }
  312. export function isFormattedMQL(mql: string) {
  313. const regex = /^(\w+\([\w\.]+\))(?:\{\w+\:\w+\})*(?:\sby\s\w+)*/;
  314. const matches = mql.match(regex);
  315. const [, field, query, groupBy] = matches ?? [];
  316. if (!field) {
  317. return false;
  318. }
  319. if (query) {
  320. return query.includes(':');
  321. }
  322. if (groupBy) {
  323. // TODO check groupbys
  324. }
  325. return true;
  326. }
  327. // TODO: consider moving this to utils/dates.tsx
  328. export function getAbsoluteDateTimeRange(params: PageFilters['datetime']) {
  329. const {start, end, statsPeriod, utc} = normalizeDateTimeParams(params, {
  330. allowAbsoluteDatetime: true,
  331. });
  332. if (start && end) {
  333. return {start: moment(start).toISOString(), end: moment(end).toISOString()};
  334. }
  335. const parsedStatusPeriod = parseStatsPeriod(statsPeriod || '24h');
  336. const now = utc ? moment().utc() : moment();
  337. if (!parsedStatusPeriod) {
  338. // Default to 24h
  339. return {start: moment(now).subtract(1, 'day').toISOString(), end: now.toISOString()};
  340. }
  341. const startObj = moment(now).subtract(
  342. parsedStatusPeriod.period,
  343. parsedStatusPeriod.periodLength
  344. );
  345. return {start: startObj.toISOString(), end: now.toISOString()};
  346. }
  347. // TODO(metrics): remove this when we switch tags to the new meta
  348. export function getMetaDateTimeParams(datetime?: PageFilters['datetime']) {
  349. if (datetime?.period) {
  350. if (statsPeriodToDays(datetime.period) < 14) {
  351. return {statsPeriod: '14d'};
  352. }
  353. return {statsPeriod: datetime.period};
  354. }
  355. if (datetime?.start && datetime?.end) {
  356. return {
  357. start: moment(datetime.start).toISOString(),
  358. end: moment(datetime.end).toISOString(),
  359. };
  360. }
  361. return {statsPeriod: '14d'};
  362. }
  363. export function areResultsLimited(response: MetricsQueryApiResponse) {
  364. return response.meta.some(
  365. meta => (meta[meta.length - 1] as MetricsQueryApiResponseLastMeta).has_more
  366. );
  367. }
  368. export function isNotQueryOnly(query: MetricsQueryApiQueryParams) {
  369. return !('isQueryOnly' in query) || !query.isQueryOnly;
  370. }