errorsAndTransactions.tsx 19 KB

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