metrics.tsx 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432
  1. import omit from 'lodash/omit';
  2. import type {Client, ResponseMeta} from 'sentry/api';
  3. import {t} from 'sentry/locale';
  4. import type {
  5. MetricsQueryApiResponse,
  6. Organization,
  7. PageFilters,
  8. TagCollection,
  9. } from 'sentry/types';
  10. import type {Series} from 'sentry/types/echarts';
  11. import type {CustomMeasurementCollection} from 'sentry/utils/customMeasurements/customMeasurements';
  12. import type {TableData} from 'sentry/utils/discover/discoverQuery';
  13. import type {EventData} from 'sentry/utils/discover/eventView';
  14. import {NumberContainer} from 'sentry/utils/discover/styles';
  15. import {getMetricsSeriesName, groupByOp} from 'sentry/utils/metrics';
  16. import {formatMetricUsingUnit} from 'sentry/utils/metrics/formatters';
  17. import {
  18. formatMRIField,
  19. getMRI,
  20. getUseCaseFromMRI,
  21. isMRIField,
  22. parseField,
  23. parseMRI,
  24. } from 'sentry/utils/metrics/mri';
  25. import {getMetricsQueryApiRequestPayload} from 'sentry/utils/metrics/useMetricsQuery';
  26. import type {OnDemandControlContext} from 'sentry/utils/performance/contexts/onDemandControl';
  27. import {MetricSearchBar} from 'sentry/views/dashboards/widgetBuilder/buildSteps/filterResultsStep/metricSearchBar';
  28. import type {FieldValueOption} from 'sentry/views/discover/table/queryField';
  29. import {FieldValueKind} from 'sentry/views/discover/table/types';
  30. import type {Widget, WidgetQuery} from '../types';
  31. import {DisplayType} from '../types';
  32. import type {DatasetConfig} from './base';
  33. import {handleOrderByReset} from './base';
  34. const DEFAULT_WIDGET_QUERY: WidgetQuery = {
  35. name: '',
  36. fields: [''],
  37. columns: [''],
  38. fieldAliases: [],
  39. aggregates: [''],
  40. conditions: '',
  41. orderby: '',
  42. };
  43. export const MetricsConfig: DatasetConfig<
  44. MetricsQueryApiResponse,
  45. MetricsQueryApiResponse
  46. > = {
  47. defaultWidgetQuery: DEFAULT_WIDGET_QUERY,
  48. enableEquations: false,
  49. getTableRequest: (
  50. api: Client,
  51. _: Widget,
  52. query: WidgetQuery,
  53. organization: Organization,
  54. pageFilters: PageFilters,
  55. __?: OnDemandControlContext,
  56. limit?: number
  57. ) => getMetricRequest(api, query, organization, pageFilters, limit),
  58. getSeriesRequest: getMetricSeriesRequest,
  59. getCustomFieldRenderer: field => (data: EventData) =>
  60. renderMetricField(field, data[field]),
  61. SearchBar: MetricSearchBar,
  62. handleOrderByReset: handleMetricTableOrderByReset,
  63. supportedDisplayTypes: [
  64. DisplayType.AREA,
  65. DisplayType.BAR,
  66. DisplayType.BIG_NUMBER,
  67. DisplayType.LINE,
  68. DisplayType.TABLE,
  69. DisplayType.TOP_N,
  70. ],
  71. transformSeries: transformMetricsResponseToSeries,
  72. transformTable: transformMetricsResponseToTable,
  73. getTableFieldOptions: getFields,
  74. getTimeseriesSortOptions: getMetricTimeseriesSortOptions,
  75. getTableSortOptions: getMetricTableSortOptions,
  76. filterTableOptions: filterMetricOperations,
  77. filterYAxisOptions: () => option => filterMetricOperations(option),
  78. filterAggregateParams: filterMetricMRIs,
  79. filterYAxisAggregateParams: () => option => filterMetricMRIs(option),
  80. getGroupByFieldOptions: getTagsForMetric,
  81. getFieldHeaderMap: getFormattedMRIHeaders,
  82. };
  83. export function renderMetricField(field: string, value: any) {
  84. const parsedField = parseField(field);
  85. if (parsedField) {
  86. const unit = parseMRI(parsedField.mri)?.unit ?? '';
  87. return <NumberContainer>{formatMetricUsingUnit(value, unit)}</NumberContainer>;
  88. }
  89. return value;
  90. }
  91. export function formatMetricAxisValue(field: string, value: number) {
  92. const unit = parseMRI(parseField(field)?.mri)?.unit ?? '';
  93. return formatMetricUsingUnit(value, unit);
  94. }
  95. function getFormattedMRIHeaders(query?: WidgetQuery) {
  96. if (!query) {
  97. return {};
  98. }
  99. return (query.fields || []).reduce((acc, field, index) => {
  100. const fieldAlias = query.fieldAliases?.[index];
  101. acc[field] = fieldAlias || formatMRIField(field);
  102. return acc;
  103. }, {});
  104. }
  105. function getMetricTimeseriesSortOptions(_, widgetQuery: WidgetQuery) {
  106. if (!widgetQuery.fields?.[0]) {
  107. return [];
  108. }
  109. return widgetQuery.fields.reduce((acc, field) => {
  110. if (!isMRIField(field)) {
  111. return acc;
  112. }
  113. return {
  114. ...acc,
  115. [`field:${field}`]: {
  116. label: formatMRIField(field),
  117. value: {
  118. kind: FieldValueKind.FIELD,
  119. value: field,
  120. meta: {
  121. name: field,
  122. dataType: 'number',
  123. },
  124. },
  125. },
  126. };
  127. }, {});
  128. }
  129. function getMetricTableSortOptions(_, widgetQuery: WidgetQuery) {
  130. if (!widgetQuery.fields?.[0]) {
  131. return [];
  132. }
  133. return widgetQuery.fields
  134. .map((field, i) => {
  135. const alias = widgetQuery.fieldAliases?.[i];
  136. return {
  137. label: alias || formatMRIField(field),
  138. value: field,
  139. };
  140. })
  141. .filter(option => isMRIField(option.value));
  142. }
  143. function getFields(
  144. organization: Organization,
  145. _?: TagCollection | undefined,
  146. __?: CustomMeasurementCollection,
  147. api?: Client
  148. ) {
  149. if (!api) {
  150. return {};
  151. }
  152. return api
  153. .requestPromise(`/organizations/${organization.slug}/metrics/meta/`, {
  154. query: {useCase: 'custom'},
  155. })
  156. .then(metaReponse => {
  157. const groupedByOp = groupByOp(metaReponse);
  158. const typesByOp: Record<string, Set<string>> = Object.entries(groupedByOp).reduce(
  159. (acc, [operation, fields]) => {
  160. const types = new Set();
  161. fields.forEach(field => types.add(field.type));
  162. acc[operation] = types;
  163. return acc;
  164. },
  165. {}
  166. );
  167. const fieldOptions: Record<string, any> = {};
  168. Object.entries(groupedByOp).forEach(([operation, fields]) => {
  169. fieldOptions[`function:${operation}`] = {
  170. label: `${operation}(${'\u2026'})`,
  171. value: {
  172. kind: FieldValueKind.FUNCTION,
  173. meta: {
  174. name: operation,
  175. parameters: [
  176. {
  177. kind: 'column',
  178. columnTypes: [...typesByOp[operation]],
  179. defaultValue: fields[0].mri,
  180. required: true,
  181. },
  182. ],
  183. },
  184. },
  185. };
  186. });
  187. metaReponse
  188. .sort((a, b) => a.name.localeCompare(b.name))
  189. .forEach(field => {
  190. fieldOptions[`field:${field.mri}`] = {
  191. label: field.name,
  192. value: {
  193. kind: FieldValueKind.METRICS,
  194. meta: {
  195. name: field.mri,
  196. dataType: field.type,
  197. },
  198. },
  199. };
  200. });
  201. return fieldOptions;
  202. });
  203. }
  204. function filterMetricOperations(option: FieldValueOption) {
  205. return option.value.kind === FieldValueKind.FUNCTION;
  206. }
  207. function filterMetricMRIs(option: FieldValueOption) {
  208. return option.value.kind === FieldValueKind.METRICS;
  209. }
  210. function getTagsForMetric(
  211. organization: Organization,
  212. _?: TagCollection,
  213. __?: CustomMeasurementCollection,
  214. api?: Client,
  215. queries?: WidgetQuery[]
  216. ) {
  217. const fieldOptions = {};
  218. if (!api) {
  219. return fieldOptions;
  220. }
  221. const field = queries?.[0].aggregates[0] ?? '';
  222. const mri = getMRI(field);
  223. const useCase = getUseCaseFromMRI(mri);
  224. return api
  225. .requestPromise(`/organizations/${organization.slug}/metrics/tags/`, {
  226. query: {metric: mri, useCase},
  227. })
  228. .then(tagsResponse => {
  229. tagsResponse.forEach(tag => {
  230. fieldOptions[`field:${tag.key}`] = {
  231. label: tag.key,
  232. value: {
  233. kind: FieldValueKind.TAG,
  234. meta: {name: tag.key, dataType: 'string'},
  235. },
  236. };
  237. });
  238. return fieldOptions;
  239. });
  240. }
  241. function getMetricSeriesRequest(
  242. api: Client,
  243. widget: Widget,
  244. queryIndex: number,
  245. organization: Organization,
  246. pageFilters: PageFilters
  247. ) {
  248. const query = widget.queries[queryIndex];
  249. return getMetricRequest(
  250. api,
  251. query,
  252. organization,
  253. pageFilters,
  254. widget.limit,
  255. widget.displayType
  256. );
  257. }
  258. function handleMetricTableOrderByReset(widgetQuery: WidgetQuery, newFields: string[]) {
  259. const disableSortBy = widgetQuery.columns.includes('session.status');
  260. if (disableSortBy) {
  261. widgetQuery.orderby = '';
  262. }
  263. return handleOrderByReset(widgetQuery, newFields);
  264. }
  265. export function transformMetricsResponseToTable(
  266. data: MetricsQueryApiResponse,
  267. widgetQuery: WidgetQuery
  268. ): TableData {
  269. const field = widgetQuery.aggregates[0];
  270. const rows = data.data.flatMap((group, index) =>
  271. group.map(entry => {
  272. const groupColumn = mapMetricGroupsToFields(entry.by);
  273. const value = {
  274. [field]: entry.totals,
  275. };
  276. return {
  277. id: String(index),
  278. ...groupColumn,
  279. ...value,
  280. };
  281. })
  282. );
  283. const singleRow = rows[0];
  284. const meta = {
  285. ...changeObjectValuesToTypes(omit(singleRow, 'id')),
  286. };
  287. return {meta, data: rows};
  288. }
  289. function mapMetricGroupsToFields(
  290. results: Record<string, number | string | null> | undefined
  291. ) {
  292. if (!results) {
  293. return {};
  294. }
  295. const mappedResults: typeof results = {};
  296. for (const [key, value] of Object.entries(results)) {
  297. mappedResults[key] = value;
  298. }
  299. return mappedResults;
  300. }
  301. function changeObjectValuesToTypes(
  302. obj: Record<string, number | string | null> | undefined
  303. ) {
  304. return Object.entries(obj ?? {}).reduce((acc, [key, value]) => {
  305. acc[key] = typeof value;
  306. return acc;
  307. }, {});
  308. }
  309. export function transformMetricsResponseToSeries(
  310. data: MetricsQueryApiResponse,
  311. widgetQuery: WidgetQuery
  312. ) {
  313. if (data === null) {
  314. return [];
  315. }
  316. const field = widgetQuery.aggregates[0];
  317. const results: Series[] = [];
  318. const queryAlias = widgetQuery.name;
  319. if (!data.data.length) {
  320. return [
  321. {
  322. seriesName: `(${t('no results')})`,
  323. data: data.intervals.map(interval => ({
  324. name: interval,
  325. value: 0,
  326. })),
  327. },
  328. ];
  329. }
  330. data.data.forEach(group =>
  331. group.forEach(entry => {
  332. results.push({
  333. seriesName: getMetricsSeriesName(queryAlias || field, entry.by),
  334. data: data.intervals.map((interval, index) => ({
  335. name: interval,
  336. value: entry.series[field][index] ?? 0,
  337. })),
  338. });
  339. })
  340. );
  341. return results;
  342. }
  343. function getMetricRequest(
  344. api: Client,
  345. query: WidgetQuery,
  346. organization: Organization,
  347. pageFilters: PageFilters,
  348. limit?: number,
  349. displayType?: DisplayType
  350. ): Promise<[MetricsQueryApiResponse, string | undefined, ResponseMeta | undefined]> {
  351. if (!query.aggregates[0]) {
  352. // No aggregate selected, return empty response
  353. return Promise.resolve([
  354. {
  355. intervals: [],
  356. groups: [],
  357. meta: [],
  358. },
  359. 'OK',
  360. {
  361. getResponseHeader: () => '',
  362. },
  363. ] as any);
  364. }
  365. const payload = getMetricsQueryApiRequestPayload(
  366. [
  367. {
  368. field: query.aggregates[0],
  369. query: query.conditions || undefined,
  370. groupBy: query.columns || undefined,
  371. orderBy: query.orderby
  372. ? query.orderby.indexOf('-') === 0
  373. ? 'desc'
  374. : 'asc'
  375. : undefined,
  376. limit: limit || undefined,
  377. },
  378. ],
  379. pageFilters,
  380. {
  381. intervalLadder: displayType === DisplayType.BAR ? 'bar' : 'dashboard',
  382. }
  383. );
  384. const pathname = `/organizations/${organization.slug}/metrics/query/`;
  385. return api.requestPromise(pathname, {
  386. method: 'POST',
  387. query: payload.query,
  388. data: payload.body,
  389. includeAllArgs: true,
  390. });
  391. }