index.tsx 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421
  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, Fidelity} 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 {MetricsApiResponse, Organization, PageFilters} from 'sentry/types';
  23. import type {
  24. MetricMeta,
  25. MetricsApiRequestMetric,
  26. MetricsApiRequestQuery,
  27. MetricsApiRequestQueryOptions,
  28. MetricsGroup,
  29. MetricsOperation,
  30. MRI,
  31. UseCase,
  32. } from 'sentry/types/metrics';
  33. import {isMeasurement as isMeasurementName} from 'sentry/utils/discover/fields';
  34. import {generateEventSlug} from 'sentry/utils/discover/urls';
  35. import {getMeasurements} from 'sentry/utils/measurements/measurements';
  36. import {
  37. formatMRI,
  38. formatMRIField,
  39. getUseCaseFromMRI,
  40. MRIToField,
  41. parseField,
  42. parseMRI,
  43. } from 'sentry/utils/metrics/mri';
  44. import type {
  45. DdmQueryParams,
  46. MetricsQuery,
  47. MetricsQuerySubject,
  48. MetricWidgetQueryParams,
  49. } from 'sentry/utils/metrics/types';
  50. import {MetricDisplayType} from 'sentry/utils/metrics/types';
  51. import {getTransactionDetailsUrl} from 'sentry/utils/performance/urls';
  52. import useRouter from 'sentry/utils/useRouter';
  53. export function getDefaultMetricDisplayType(
  54. mri: MetricsQuery['mri'],
  55. op: MetricsQuery['op']
  56. ): MetricDisplayType {
  57. if (mri?.startsWith('c') || op === 'count') {
  58. return MetricDisplayType.BAR;
  59. }
  60. return MetricDisplayType.LINE;
  61. }
  62. export const getMetricDisplayType = (displayType: unknown): MetricDisplayType => {
  63. if (
  64. [MetricDisplayType.AREA, MetricDisplayType.BAR, MetricDisplayType.LINE].includes(
  65. displayType as MetricDisplayType
  66. )
  67. ) {
  68. return displayType as MetricDisplayType;
  69. }
  70. return MetricDisplayType.LINE;
  71. };
  72. export function getDdmUrl(
  73. orgSlug: string,
  74. {
  75. widgets,
  76. start,
  77. end,
  78. statsPeriod,
  79. project,
  80. ...otherParams
  81. }: Omit<DdmQueryParams, 'project' | 'widgets'> & {
  82. widgets: MetricWidgetQueryParams[];
  83. project?: (string | number)[];
  84. }
  85. ) {
  86. const urlParams: Partial<DdmQueryParams> = {
  87. ...otherParams,
  88. project: project?.map(id => (typeof id === 'string' ? parseInt(id, 10) : id)),
  89. widgets: JSON.stringify(widgets),
  90. };
  91. if (statsPeriod) {
  92. urlParams.statsPeriod = statsPeriod;
  93. } else {
  94. urlParams.start = start;
  95. urlParams.end = end;
  96. }
  97. return `/organizations/${orgSlug}/ddm/?${qs.stringify(urlParams)}`;
  98. }
  99. export function getMetricsApiRequestQuery(
  100. {field, query, groupBy, orderBy}: MetricsApiRequestMetric,
  101. {projects, environments, datetime}: PageFilters,
  102. {fidelity, ...overrides}: Partial<MetricsApiRequestQueryOptions> = {}
  103. ): MetricsApiRequestQuery {
  104. const {mri: mri} = parseField(field) ?? {};
  105. const useCase = getUseCaseFromMRI(mri) ?? 'custom';
  106. const interval = getDDMInterval(datetime, useCase, fidelity);
  107. const hasGroupBy = groupBy && groupBy.length > 0;
  108. const queryToSend = {
  109. ...getDateTimeParams(datetime),
  110. query: sanitizeQuery(query),
  111. project: projects,
  112. environment: environments,
  113. field,
  114. useCase,
  115. interval,
  116. groupBy,
  117. orderBy: hasGroupBy && !orderBy && field ? `-${field}` : orderBy,
  118. useNewMetricsLayer: true,
  119. };
  120. return {...queryToSend, ...overrides};
  121. }
  122. function sanitizeQuery(query?: string) {
  123. return query?.trim();
  124. }
  125. const ddmHighFidelityLadder = new GranularityLadder([
  126. [SIXTY_DAYS, '1d'],
  127. [THIRTY_DAYS, '2h'],
  128. [TWO_WEEKS, '1h'],
  129. [ONE_WEEK, '30m'],
  130. [TWENTY_FOUR_HOURS, '5m'],
  131. [ONE_HOUR, '1m'],
  132. [0, '5m'],
  133. ]);
  134. const ddmLowFidelityLadder = new GranularityLadder([
  135. [SIXTY_DAYS, '1d'],
  136. [THIRTY_DAYS, '12h'],
  137. [TWO_WEEKS, '4h'],
  138. [ONE_WEEK, '2h'],
  139. [TWENTY_FOUR_HOURS, '1h'],
  140. [SIX_HOURS, '30m'],
  141. [ONE_HOUR, '5m'],
  142. [0, '1m'],
  143. ]);
  144. // Wraps getInterval since other users of this function, and other metric use cases do not have support for 10s granularity
  145. export function getDDMInterval(
  146. datetimeObj: DateTimeObject,
  147. useCase: UseCase,
  148. fidelity: Fidelity = 'high'
  149. ) {
  150. const diffInMinutes = getDiffInMinutes(datetimeObj);
  151. if (diffInMinutes <= ONE_HOUR && useCase === 'custom' && fidelity === 'high') {
  152. return '10s';
  153. }
  154. if (fidelity === 'low') {
  155. return ddmLowFidelityLadder.getInterval(diffInMinutes);
  156. }
  157. return ddmHighFidelityLadder.getInterval(diffInMinutes);
  158. }
  159. export function getDateTimeParams({start, end, period}: PageFilters['datetime']) {
  160. return period
  161. ? {statsPeriod: period}
  162. : {start: moment(start).toISOString(), end: moment(end).toISOString()};
  163. }
  164. export function getDefaultMetricOp(mri: MRI): MetricsOperation {
  165. const parsedMRI = parseMRI(mri);
  166. switch (parsedMRI?.type) {
  167. case 'd':
  168. case 'g':
  169. return 'avg';
  170. case 's':
  171. return 'count_unique';
  172. case 'c':
  173. default:
  174. return 'sum';
  175. }
  176. }
  177. export function isAllowedOp(op: string) {
  178. return !['max_timestamp', 'min_timestamp', 'histogram'].includes(op);
  179. }
  180. // Applying these operations to a metric will result in a timeseries whose scale is different than
  181. // the original metric. Becuase of that min and max bounds can't be used and we display the fog of war
  182. export function isCumulativeOp(op: string = '') {
  183. return ['sum', 'count', 'count_unique'].includes(op);
  184. }
  185. function updateQuery(
  186. router: InjectedRouter,
  187. partialQuery: Record<string, any>,
  188. options?: {replace: boolean}
  189. ) {
  190. const updateFunction = options?.replace ? router.replace : router.push;
  191. updateFunction({
  192. ...router.location,
  193. query: {
  194. ...router.location.query,
  195. ...partialQuery,
  196. },
  197. });
  198. }
  199. export function clearQuery(router: InjectedRouter) {
  200. router.push({
  201. ...router.location,
  202. query: {},
  203. });
  204. }
  205. export function useInstantRef<T>(value: T) {
  206. const ref = useRef(value);
  207. ref.current = value;
  208. return ref;
  209. }
  210. export function useUpdateQuery() {
  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. (partialQuery: Record<string, any>, options?: {replace: boolean}) => {
  217. updateQuery(routerRef.current, partialQuery, options);
  218. },
  219. [routerRef]
  220. );
  221. }
  222. export function useClearQuery() {
  223. const router = useRouter();
  224. // Store the router in a ref so that we can use it in the callback
  225. // without needing to generate a new callback every time the location changes
  226. const routerRef = useInstantRef(router);
  227. return useCallback(() => {
  228. clearQuery(routerRef.current);
  229. }, [routerRef]);
  230. }
  231. export function getMetricsSeriesName(group: MetricsGroup) {
  232. const groupByEntries = Object.entries(group.by ?? {});
  233. if (!groupByEntries.length) {
  234. const field = Object.keys(group.series)?.[0];
  235. const {mri} = parseField(field) ?? {mri: field};
  236. const name = formatMRI(mri as MRI);
  237. return name ?? '(none)';
  238. }
  239. return groupByEntries
  240. .map(([_key, value]) => `${String(value).length ? value : t('(none)')}`)
  241. .join(', ');
  242. }
  243. export function groupByOp(metrics: MetricMeta[]): Record<string, MetricMeta[]> {
  244. const uniqueOperations = [
  245. ...new Set(metrics.flatMap(field => field.operations).filter(isAllowedOp)),
  246. ].sort();
  247. const groupedByOp = uniqueOperations.reduce((result, op) => {
  248. result[op] = metrics.filter(field => field.operations.includes(op));
  249. return result;
  250. }, {});
  251. return groupedByOp;
  252. }
  253. export function isMeasurement({mri}: {mri: MRI}) {
  254. const {name} = parseMRI(mri) ?? {name: ''};
  255. return isMeasurementName(name);
  256. }
  257. export function isCustomMeasurement({mri}: {mri: MRI}) {
  258. const DEFINED_MEASUREMENTS = new Set(Object.keys(getMeasurements()));
  259. const {name} = parseMRI(mri) ?? {name: ''};
  260. return !DEFINED_MEASUREMENTS.has(name) && isMeasurementName(name);
  261. }
  262. export function isStandardMeasurement({mri}: {mri: MRI}) {
  263. return isMeasurement({mri}) && !isCustomMeasurement({mri});
  264. }
  265. export function isTransactionDuration({mri}: {mri: MRI}) {
  266. return mri === 'd:transactions/duration@millisecond';
  267. }
  268. export function isCustomMetric({mri}: {mri: MRI}) {
  269. return mri.includes(':custom/');
  270. }
  271. export function isSpanMetric({mri}: {mri: MRI}) {
  272. return mri.includes(':spans/');
  273. }
  274. export function getFieldFromMetricsQuery(metricsQuery: MetricsQuery) {
  275. if (isCustomMetric(metricsQuery)) {
  276. return MRIToField(metricsQuery.mri, metricsQuery.op!);
  277. }
  278. return formatMRIField(MRIToField(metricsQuery.mri, metricsQuery.op!));
  279. }
  280. // TODO(ddm): remove this and all of its usages once backend sends mri fields
  281. export function mapToMRIFields(
  282. data: MetricsApiResponse | undefined,
  283. fields: string[]
  284. ): void {
  285. if (!data) {
  286. return;
  287. }
  288. data.groups.forEach(group => {
  289. group.series = swapObjectKeys(group.series, fields);
  290. group.totals = swapObjectKeys(group.totals, fields);
  291. });
  292. }
  293. function swapObjectKeys(obj: Record<string, unknown> | undefined, newKeys: string[]) {
  294. if (!obj) {
  295. return {};
  296. }
  297. return Object.keys(obj).reduce((acc, key, index) => {
  298. acc[newKeys[index]] = obj[key];
  299. return acc;
  300. }, {});
  301. }
  302. export function stringifyMetricWidget(metricWidget: MetricsQuerySubject): string {
  303. const {mri, op, query, groupBy} = metricWidget;
  304. if (!op) {
  305. return '';
  306. }
  307. let result = `${op}(${formatMRI(mri)})`;
  308. if (query) {
  309. result += `{${query.trim()}}`;
  310. }
  311. if (groupBy && groupBy.length) {
  312. result += ` by ${groupBy.join(', ')}`;
  313. }
  314. return result;
  315. }
  316. // TODO: consider moving this to utils/dates.tsx
  317. export function getAbsoluteDateTimeRange(params: PageFilters['datetime']) {
  318. const {start, end, statsPeriod, utc} = normalizeDateTimeParams(params, {
  319. allowAbsoluteDatetime: true,
  320. });
  321. if (start && end) {
  322. return {start: moment(start).toISOString(), end: moment(end).toISOString()};
  323. }
  324. const parsedStatusPeriod = parseStatsPeriod(statsPeriod || '24h');
  325. const now = utc ? moment().utc() : moment();
  326. if (!parsedStatusPeriod) {
  327. // Default to 24h
  328. return {start: moment(now).subtract(1, 'day').toISOString(), end: now.toISOString()};
  329. }
  330. const startObj = moment(now).subtract(
  331. parsedStatusPeriod.period,
  332. parsedStatusPeriod.periodLength
  333. );
  334. return {start: startObj.toISOString(), end: now.toISOString()};
  335. }
  336. export function isSupportedDisplayType(displayType: unknown) {
  337. return Object.values(MetricDisplayType).includes(displayType as MetricDisplayType);
  338. }
  339. export function getMetricsCorrelationSpanUrl(
  340. organization: Organization,
  341. projectSlug: string | undefined,
  342. spanId: string | undefined,
  343. transactionId: string,
  344. transactionSpanId: string
  345. ) {
  346. const isTransaction = spanId === transactionSpanId;
  347. const eventSlug = generateEventSlug({
  348. id: transactionId,
  349. project: projectSlug,
  350. });
  351. return getTransactionDetailsUrl(
  352. organization.slug,
  353. eventSlug,
  354. isTransaction ? transactionId : undefined,
  355. {referrer: 'metrics', openPanel: 'open'},
  356. isTransaction ? undefined : spanId
  357. );
  358. }