errorsAndTransactions.tsx 18 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605
  1. import trimStart from 'lodash/trimStart';
  2. import {doEventsRequest} from 'sentry/actionCreators/events';
  3. import {Client} from 'sentry/api';
  4. import {isMultiSeriesStats} from 'sentry/components/charts/utils';
  5. import Link from 'sentry/components/links/link';
  6. import Tooltip from 'sentry/components/tooltip';
  7. import {t} from 'sentry/locale';
  8. import {
  9. EventsStats,
  10. MultiSeriesEventsStats,
  11. Organization,
  12. PageFilters,
  13. SelectValue,
  14. TagCollection,
  15. } from 'sentry/types';
  16. import {Series} from 'sentry/types/echarts';
  17. import {CustomMeasurementCollection} from 'sentry/utils/customMeasurements/customMeasurements';
  18. import {EventsTableData, TableData} from 'sentry/utils/discover/discoverQuery';
  19. import {MetaType} from 'sentry/utils/discover/eventView';
  20. import {
  21. getFieldRenderer,
  22. RenderFunctionBaggage,
  23. } from 'sentry/utils/discover/fieldRenderers';
  24. import {
  25. errorsAndTransactionsAggregateFunctionOutputType,
  26. getAggregateAlias,
  27. isEquation,
  28. isEquationAlias,
  29. isLegalYAxisType,
  30. QueryFieldValue,
  31. SPAN_OP_BREAKDOWN_FIELDS,
  32. stripEquationPrefix,
  33. } from 'sentry/utils/discover/fields';
  34. import {
  35. DiscoverQueryRequestParams,
  36. doDiscoverQuery,
  37. } from 'sentry/utils/discover/genericDiscoverQuery';
  38. import {Container} from 'sentry/utils/discover/styles';
  39. import {TOP_N} from 'sentry/utils/discover/types';
  40. import {
  41. eventDetailsRouteWithEventView,
  42. generateEventSlug,
  43. } from 'sentry/utils/discover/urls';
  44. import {getShortEventId} from 'sentry/utils/events';
  45. import {getMeasurements} from 'sentry/utils/measurements/measurements';
  46. import {FieldValueOption} from 'sentry/views/eventsV2/table/queryField';
  47. import {FieldValue, FieldValueKind} from 'sentry/views/eventsV2/table/types';
  48. import {generateFieldOptions} from 'sentry/views/eventsV2/utils';
  49. import {getTraceDetailsUrl} from 'sentry/views/performance/traceDetails/utils';
  50. import {DisplayType, Widget, WidgetQuery} from '../types';
  51. import {
  52. eventViewFromWidget,
  53. getDashboardsMEPQueryParams,
  54. getNumEquations,
  55. getWidgetInterval,
  56. } from '../utils';
  57. import {EventsSearchBar} from '../widgetBuilder/buildSteps/filterResultsStep/eventsSearchBar';
  58. import {CUSTOM_EQUATION_VALUE} from '../widgetBuilder/buildSteps/sortByStep';
  59. import {
  60. flattenMultiSeriesDataWithGrouping,
  61. transformSeries,
  62. } from '../widgetCard/widgetQueries';
  63. import {DatasetConfig, handleOrderByReset} from './base';
  64. const DEFAULT_WIDGET_QUERY: WidgetQuery = {
  65. name: '',
  66. fields: ['count()'],
  67. columns: [],
  68. fieldAliases: [],
  69. aggregates: ['count()'],
  70. conditions: '',
  71. orderby: '-count()',
  72. };
  73. type SeriesWithOrdering = [order: number, series: Series];
  74. export const ErrorsAndTransactionsConfig: DatasetConfig<
  75. EventsStats | MultiSeriesEventsStats,
  76. TableData | EventsTableData
  77. > = {
  78. defaultWidgetQuery: DEFAULT_WIDGET_QUERY,
  79. enableEquations: true,
  80. getCustomFieldRenderer: getCustomEventsFieldRenderer,
  81. SearchBar: EventsSearchBar,
  82. filterSeriesSortOptions,
  83. filterYAxisAggregateParams,
  84. filterYAxisOptions,
  85. getTableFieldOptions: getEventsTableFieldOptions,
  86. getTimeseriesSortOptions,
  87. getTableSortOptions,
  88. getGroupByFieldOptions: getEventsTableFieldOptions,
  89. handleOrderByReset,
  90. supportedDisplayTypes: [
  91. DisplayType.AREA,
  92. DisplayType.BAR,
  93. DisplayType.BIG_NUMBER,
  94. DisplayType.LINE,
  95. DisplayType.TABLE,
  96. DisplayType.TOP_N,
  97. DisplayType.WORLD_MAP,
  98. ],
  99. getTableRequest: (
  100. api: Client,
  101. query: WidgetQuery,
  102. organization: Organization,
  103. pageFilters: PageFilters,
  104. limit?: number,
  105. cursor?: string,
  106. referrer?: string
  107. ) => {
  108. const shouldUseEvents = organization.features.includes(
  109. 'discover-frontend-use-events-endpoint'
  110. );
  111. const url = shouldUseEvents
  112. ? `/organizations/${organization.slug}/events/`
  113. : `/organizations/${organization.slug}/eventsv2/`;
  114. return getEventsRequest(
  115. url,
  116. api,
  117. query,
  118. organization,
  119. pageFilters,
  120. limit,
  121. cursor,
  122. referrer
  123. );
  124. },
  125. getSeriesRequest: getEventsSeriesRequest,
  126. getWorldMapRequest: (
  127. api: Client,
  128. query: WidgetQuery,
  129. organization: Organization,
  130. pageFilters: PageFilters,
  131. limit?: number,
  132. cursor?: string,
  133. referrer?: string
  134. ) => {
  135. return getEventsRequest(
  136. `/organizations/${organization.slug}/events-geo/`,
  137. api,
  138. query,
  139. organization,
  140. pageFilters,
  141. limit,
  142. cursor,
  143. referrer
  144. );
  145. },
  146. transformSeries: transformEventsResponseToSeries,
  147. transformTable: transformEventsResponseToTable,
  148. filterTableOptions,
  149. filterAggregateParams,
  150. getSeriesResultType,
  151. };
  152. function getTableSortOptions(_organization: Organization, widgetQuery: WidgetQuery) {
  153. const {columns, aggregates} = widgetQuery;
  154. const options: SelectValue<string>[] = [];
  155. let equations = 0;
  156. [...aggregates, ...columns]
  157. .filter(field => !!field)
  158. .forEach(field => {
  159. let alias;
  160. const label = stripEquationPrefix(field);
  161. // Equations are referenced via a standard alias following this pattern
  162. if (isEquation(field)) {
  163. alias = `equation[${equations}]`;
  164. equations += 1;
  165. }
  166. options.push({label, value: alias ?? field});
  167. });
  168. return options;
  169. }
  170. function filterSeriesSortOptions(columns: Set<string>) {
  171. return (option: FieldValueOption) => {
  172. if (
  173. option.value.kind === FieldValueKind.FUNCTION ||
  174. option.value.kind === FieldValueKind.EQUATION
  175. ) {
  176. return true;
  177. }
  178. return (
  179. columns.has(option.value.meta.name) ||
  180. option.value.meta.name === CUSTOM_EQUATION_VALUE
  181. );
  182. };
  183. }
  184. function getTimeseriesSortOptions(
  185. organization: Organization,
  186. widgetQuery: WidgetQuery,
  187. tags?: TagCollection
  188. ) {
  189. const options: Record<string, SelectValue<FieldValue>> = {};
  190. options[`field:${CUSTOM_EQUATION_VALUE}`] = {
  191. label: 'Custom Equation',
  192. value: {
  193. kind: FieldValueKind.EQUATION,
  194. meta: {name: CUSTOM_EQUATION_VALUE},
  195. },
  196. };
  197. let equations = 0;
  198. [...widgetQuery.aggregates, ...widgetQuery.columns]
  199. .filter(field => !!field)
  200. .forEach(field => {
  201. let alias;
  202. const label = stripEquationPrefix(field);
  203. // Equations are referenced via a standard alias following this pattern
  204. if (isEquation(field)) {
  205. alias = `equation[${equations}]`;
  206. equations += 1;
  207. options[`equation:${alias}`] = {
  208. label,
  209. value: {
  210. kind: FieldValueKind.EQUATION,
  211. meta: {
  212. name: alias ?? field,
  213. },
  214. },
  215. };
  216. }
  217. });
  218. const fieldOptions = getEventsTableFieldOptions(organization, tags);
  219. return {...options, ...fieldOptions};
  220. }
  221. function getEventsTableFieldOptions(
  222. organization: Organization,
  223. tags?: TagCollection,
  224. customMeasurements?: CustomMeasurementCollection
  225. ) {
  226. const measurements = getMeasurements();
  227. return generateFieldOptions({
  228. organization,
  229. tagKeys: Object.values(tags ?? {}).map(({key}) => key),
  230. measurementKeys: Object.values(measurements).map(({key}) => key),
  231. spanOperationBreakdownKeys: SPAN_OP_BREAKDOWN_FIELDS,
  232. customMeasurements:
  233. organization.features.includes('dashboards-mep') ||
  234. organization.features.includes('mep-rollout-flag')
  235. ? Object.values(customMeasurements ?? {}).map(({key, functions}) => ({
  236. key,
  237. functions,
  238. }))
  239. : undefined,
  240. });
  241. }
  242. function transformEventsResponseToTable(
  243. data: TableData | EventsTableData,
  244. _widgetQuery: WidgetQuery,
  245. organization: Organization
  246. ): TableData {
  247. let tableData = data;
  248. const shouldUseEvents = organization.features.includes(
  249. 'discover-frontend-use-events-endpoint'
  250. );
  251. // events api uses a different response format so we need to construct tableData differently
  252. if (shouldUseEvents) {
  253. const {fields, ...otherMeta} = (data as EventsTableData).meta ?? {};
  254. tableData = {
  255. ...data,
  256. meta: {...fields, ...otherMeta},
  257. } as TableData;
  258. }
  259. return tableData as TableData;
  260. }
  261. function filterYAxisAggregateParams(
  262. fieldValue: QueryFieldValue,
  263. displayType: DisplayType
  264. ) {
  265. return (option: FieldValueOption) => {
  266. // Only validate function parameters for timeseries widgets and
  267. // world map widgets.
  268. if (displayType === DisplayType.BIG_NUMBER) {
  269. return true;
  270. }
  271. if (fieldValue.kind !== FieldValueKind.FUNCTION) {
  272. return true;
  273. }
  274. const functionName = fieldValue.function[0];
  275. const primaryOutput = errorsAndTransactionsAggregateFunctionOutputType(
  276. functionName as string,
  277. option.value.meta.name
  278. );
  279. if (primaryOutput) {
  280. return isLegalYAxisType(primaryOutput);
  281. }
  282. if (
  283. option.value.kind === FieldValueKind.FUNCTION ||
  284. option.value.kind === FieldValueKind.EQUATION
  285. ) {
  286. // Functions and equations are not legal options as an aggregate/function parameter.
  287. return false;
  288. }
  289. return isLegalYAxisType(option.value.meta.dataType);
  290. };
  291. }
  292. function filterYAxisOptions(displayType: DisplayType) {
  293. return (option: FieldValueOption) => {
  294. // Only validate function names for timeseries widgets and
  295. // world map widgets.
  296. if (
  297. !(displayType === DisplayType.BIG_NUMBER) &&
  298. option.value.kind === FieldValueKind.FUNCTION
  299. ) {
  300. const primaryOutput = errorsAndTransactionsAggregateFunctionOutputType(
  301. option.value.meta.name,
  302. undefined
  303. );
  304. if (primaryOutput) {
  305. // If a function returns a specific type, then validate it.
  306. return isLegalYAxisType(primaryOutput);
  307. }
  308. }
  309. return option.value.kind === FieldValueKind.FUNCTION;
  310. };
  311. }
  312. function transformEventsResponseToSeries(
  313. data: EventsStats | MultiSeriesEventsStats,
  314. widgetQuery: WidgetQuery,
  315. organization: Organization
  316. ): Series[] {
  317. let output: Series[] = [];
  318. const queryAlias = widgetQuery.name;
  319. const widgetBuilderNewDesign =
  320. organization.features.includes('new-widget-builder-experience-design') || false;
  321. if (isMultiSeriesStats(data)) {
  322. let seriesWithOrdering: SeriesWithOrdering[] = [];
  323. const isMultiSeriesDataWithGrouping =
  324. widgetQuery.aggregates.length > 1 && widgetQuery.columns.length;
  325. // Convert multi-series results into chartable series. Multi series results
  326. // are created when multiple yAxis are used. Convert the timeseries
  327. // data into a multi-series data set. As the server will have
  328. // replied with a map like: {[titleString: string]: EventsStats}
  329. if (widgetBuilderNewDesign && isMultiSeriesDataWithGrouping) {
  330. seriesWithOrdering = flattenMultiSeriesDataWithGrouping(data, queryAlias);
  331. } else {
  332. seriesWithOrdering = Object.keys(data).map((seriesName: string) => {
  333. const prefixedName = queryAlias ? `${queryAlias} : ${seriesName}` : seriesName;
  334. const seriesData: EventsStats = data[seriesName];
  335. return [
  336. seriesData.order || 0,
  337. transformSeries(seriesData, prefixedName, seriesName),
  338. ];
  339. });
  340. }
  341. output = [
  342. ...seriesWithOrdering
  343. .sort((itemA, itemB) => itemA[0] - itemB[0])
  344. .map(item => item[1]),
  345. ];
  346. } else {
  347. const field = widgetQuery.aggregates[0];
  348. const prefixedName = queryAlias ? `${queryAlias} : ${field}` : field;
  349. const transformed = transformSeries(data, prefixedName, field);
  350. output.push(transformed);
  351. }
  352. return output;
  353. }
  354. // Get the series result type from the EventsStats meta
  355. function getSeriesResultType(
  356. data: EventsStats | MultiSeriesEventsStats,
  357. widgetQuery: WidgetQuery
  358. ) {
  359. const field = widgetQuery.aggregates[0];
  360. // Need to use getAggregateAlias since events-stats still uses aggregate alias format
  361. if (isMultiSeriesStats(data)) {
  362. return data[Object.keys(data)[0]].meta?.fields[getAggregateAlias(field)];
  363. }
  364. return data.meta?.fields[getAggregateAlias(field)];
  365. }
  366. function renderEventIdAsLinkable(data, {eventView, organization}: RenderFunctionBaggage) {
  367. const id: string | unknown = data?.id;
  368. if (!eventView || typeof id !== 'string') {
  369. return null;
  370. }
  371. const eventSlug = generateEventSlug(data);
  372. const target = eventDetailsRouteWithEventView({
  373. orgSlug: organization.slug,
  374. eventSlug,
  375. eventView,
  376. });
  377. return (
  378. <Tooltip title={t('View Event')}>
  379. <Link data-test-id="view-event" to={target}>
  380. <Container>{getShortEventId(id)}</Container>
  381. </Link>
  382. </Tooltip>
  383. );
  384. }
  385. function renderTraceAsLinkable(
  386. data,
  387. {eventView, organization, location}: RenderFunctionBaggage
  388. ) {
  389. const id: string | unknown = data?.trace;
  390. if (!eventView || typeof id !== 'string') {
  391. return null;
  392. }
  393. const dateSelection = eventView.normalizeDateSelection(location);
  394. const target = getTraceDetailsUrl(organization, String(data.trace), dateSelection, {});
  395. return (
  396. <Tooltip title={t('View Trace')}>
  397. <Link data-test-id="view-trace" to={target}>
  398. <Container>{getShortEventId(id)}</Container>
  399. </Link>
  400. </Tooltip>
  401. );
  402. }
  403. export function getCustomEventsFieldRenderer(
  404. field: string,
  405. meta: MetaType,
  406. organization?: Organization
  407. ) {
  408. const isAlias = !organization?.features.includes(
  409. 'discover-frontend-use-events-endpoint'
  410. );
  411. if (field === 'id') {
  412. return renderEventIdAsLinkable;
  413. }
  414. if (field === 'trace') {
  415. return renderTraceAsLinkable;
  416. }
  417. return getFieldRenderer(field, meta, isAlias);
  418. }
  419. function getEventsRequest(
  420. url: string,
  421. api: Client,
  422. query: WidgetQuery,
  423. organization: Organization,
  424. pageFilters: PageFilters,
  425. limit?: number,
  426. cursor?: string,
  427. referrer?: string
  428. ) {
  429. const isMEPEnabled = organization.features.includes('dashboards-mep');
  430. const eventView = eventViewFromWidget('', query, pageFilters);
  431. const params: DiscoverQueryRequestParams = {
  432. per_page: limit,
  433. cursor,
  434. referrer,
  435. ...getDashboardsMEPQueryParams(isMEPEnabled),
  436. };
  437. if (query.orderby) {
  438. params.sort = typeof query.orderby === 'string' ? [query.orderby] : query.orderby;
  439. }
  440. // TODO: eventually need to replace this with just EventsTableData as we deprecate eventsv2
  441. return doDiscoverQuery<TableData | EventsTableData>(api, url, {
  442. ...eventView.generateQueryStringObject(),
  443. ...params,
  444. });
  445. }
  446. function getEventsSeriesRequest(
  447. api: Client,
  448. widget: Widget,
  449. queryIndex: number,
  450. organization: Organization,
  451. pageFilters: PageFilters,
  452. referrer?: string
  453. ) {
  454. const widgetQuery = widget.queries[queryIndex];
  455. const {displayType, limit} = widget;
  456. const {environments, projects} = pageFilters;
  457. const {start, end, period: statsPeriod} = pageFilters.datetime;
  458. const interval = getWidgetInterval(displayType, {start, end, period: statsPeriod});
  459. const isMEPEnabled = organization.features.includes('dashboards-mep');
  460. let requestData;
  461. if (displayType === DisplayType.TOP_N) {
  462. requestData = {
  463. organization,
  464. interval,
  465. start,
  466. end,
  467. project: projects,
  468. environment: environments,
  469. period: statsPeriod,
  470. query: widgetQuery.conditions,
  471. yAxis: widgetQuery.aggregates[widgetQuery.aggregates.length - 1],
  472. includePrevious: false,
  473. referrer,
  474. partial: true,
  475. field: [...widgetQuery.columns, ...widgetQuery.aggregates],
  476. queryExtras: getDashboardsMEPQueryParams(isMEPEnabled),
  477. includeAllArgs: true,
  478. topEvents: TOP_N,
  479. };
  480. if (widgetQuery.orderby) {
  481. requestData.orderby = widgetQuery.orderby;
  482. }
  483. } else {
  484. requestData = {
  485. organization,
  486. interval,
  487. start,
  488. end,
  489. project: projects,
  490. environment: environments,
  491. period: statsPeriod,
  492. query: widgetQuery.conditions,
  493. yAxis: widgetQuery.aggregates,
  494. orderby: widgetQuery.orderby,
  495. includePrevious: false,
  496. referrer,
  497. partial: true,
  498. queryExtras: getDashboardsMEPQueryParams(isMEPEnabled),
  499. includeAllArgs: true,
  500. };
  501. if (widgetQuery.columns?.length !== 0) {
  502. requestData.topEvents = limit ?? TOP_N;
  503. requestData.field = [...widgetQuery.columns, ...widgetQuery.aggregates];
  504. // Compare field and orderby as aliases to ensure requestData has
  505. // the orderby selected
  506. // If the orderby is an equation alias, do not inject it
  507. const orderby = trimStart(widgetQuery.orderby, '-');
  508. if (
  509. widgetQuery.orderby &&
  510. !isEquationAlias(orderby) &&
  511. !requestData.field.includes(orderby)
  512. ) {
  513. requestData.field.push(orderby);
  514. }
  515. // The "Other" series is only included when there is one
  516. // y-axis and one widgetQuery
  517. requestData.excludeOther =
  518. widgetQuery.aggregates.length !== 1 || widget.queries.length !== 1;
  519. if (isEquation(trimStart(widgetQuery.orderby, '-'))) {
  520. const nextEquationIndex = getNumEquations(widgetQuery.aggregates);
  521. const isDescending = widgetQuery.orderby.startsWith('-');
  522. const prefix = isDescending ? '-' : '';
  523. // Construct the alias form of the equation and inject it into the request
  524. requestData.orderby = `${prefix}equation[${nextEquationIndex}]`;
  525. requestData.field = [
  526. ...widgetQuery.columns,
  527. ...widgetQuery.aggregates,
  528. trimStart(widgetQuery.orderby, '-'),
  529. ];
  530. }
  531. }
  532. }
  533. return doEventsRequest<true>(api, requestData);
  534. }
  535. // Custom Measurements aren't selectable as columns/yaxis without using an aggregate
  536. function filterTableOptions(option: FieldValueOption) {
  537. return option.value.kind !== FieldValueKind.CUSTOM_MEASUREMENT;
  538. }
  539. // Checks fieldValue to see what function is being used and only allow supported custom measurements
  540. function filterAggregateParams(option: FieldValueOption, fieldValue?: QueryFieldValue) {
  541. if (
  542. option.value.kind === FieldValueKind.CUSTOM_MEASUREMENT &&
  543. fieldValue?.kind === 'function' &&
  544. fieldValue?.function &&
  545. !option.value.meta.functions.includes(fieldValue.function[0])
  546. ) {
  547. return false;
  548. }
  549. return true;
  550. }