import * as Sentry from '@sentry/react'; import trimStart from 'lodash/trimStart'; import {doEventsRequest} from 'sentry/actionCreators/events'; import type {Client, ResponseMeta} from 'sentry/api'; import {isMultiSeriesStats} from 'sentry/components/charts/utils'; import Link from 'sentry/components/links/link'; import {Tooltip} from 'sentry/components/tooltip'; import {t} from 'sentry/locale'; import type { EventsStats, MultiSeriesEventsStats, Organization, PageFilters, SelectValue, TagCollection, } from 'sentry/types'; import type {Series} from 'sentry/types/echarts'; import {defined} from 'sentry/utils'; import type {CustomMeasurementCollection} from 'sentry/utils/customMeasurements/customMeasurements'; import {getTimeStampFromTableDateField} from 'sentry/utils/dates'; import type {EventsTableData, TableData} from 'sentry/utils/discover/discoverQuery'; import type {MetaType} from 'sentry/utils/discover/eventView'; import type {RenderFunctionBaggage} from 'sentry/utils/discover/fieldRenderers'; import {getFieldRenderer} from 'sentry/utils/discover/fieldRenderers'; import type {AggregationOutputType, QueryFieldValue} from 'sentry/utils/discover/fields'; import { errorsAndTransactionsAggregateFunctionOutputType, getAggregateAlias, isEquation, isEquationAlias, isLegalYAxisType, SPAN_OP_BREAKDOWN_FIELDS, stripEquationPrefix, } from 'sentry/utils/discover/fields'; import type { DiscoverQueryExtras, DiscoverQueryRequestParams, } from 'sentry/utils/discover/genericDiscoverQuery'; import {doDiscoverQuery} from 'sentry/utils/discover/genericDiscoverQuery'; import {Container} from 'sentry/utils/discover/styles'; import {TOP_N} from 'sentry/utils/discover/types'; import { eventDetailsRouteWithEventView, generateEventSlug, } from 'sentry/utils/discover/urls'; import {getShortEventId} from 'sentry/utils/events'; import {FieldKey} from 'sentry/utils/fields'; import {getMeasurements} from 'sentry/utils/measurements/measurements'; import {MEPState} from 'sentry/utils/performance/contexts/metricsEnhancedSetting'; import type {OnDemandControlContext} from 'sentry/utils/performance/contexts/onDemandControl'; import {shouldUseOnDemandMetrics} from 'sentry/utils/performance/contexts/onDemandControl'; import type {FieldValueOption} from 'sentry/views/discover/table/queryField'; import type {FieldValue} from 'sentry/views/discover/table/types'; import {FieldValueKind} from 'sentry/views/discover/table/types'; import {generateFieldOptions} from 'sentry/views/discover/utils'; import {getTraceDetailsUrl} from 'sentry/views/performance/traceDetails/utils'; import { createUnnamedTransactionsDiscoverTarget, DiscoverQueryPageSource, UNPARAMETERIZED_TRANSACTION, } from 'sentry/views/performance/utils'; import type {Widget, WidgetQuery} from '../types'; import {DisplayType} from '../types'; import { eventViewFromWidget, getDashboardsMEPQueryParams, getNumEquations, getWidgetInterval, } from '../utils'; import {EventsSearchBar} from '../widgetBuilder/buildSteps/filterResultsStep/eventsSearchBar'; import {CUSTOM_EQUATION_VALUE} from '../widgetBuilder/buildSteps/sortByStep'; import { flattenMultiSeriesDataWithGrouping, transformSeries, } from '../widgetCard/widgetQueries'; import type {DatasetConfig} from './base'; import {handleOrderByReset} from './base'; const DEFAULT_WIDGET_QUERY: WidgetQuery = { name: '', fields: ['count()'], columns: [], fieldAliases: [], aggregates: ['count()'], conditions: '', orderby: '-count()', }; export type SeriesWithOrdering = [order: number, series: Series]; export const ErrorsAndTransactionsConfig: DatasetConfig< EventsStats | MultiSeriesEventsStats, TableData | EventsTableData > = { defaultWidgetQuery: DEFAULT_WIDGET_QUERY, enableEquations: true, getCustomFieldRenderer: getCustomEventsFieldRenderer, SearchBar: EventsSearchBar, filterSeriesSortOptions, filterYAxisAggregateParams, filterYAxisOptions, getTableFieldOptions: getEventsTableFieldOptions, getTimeseriesSortOptions, getTableSortOptions, getGroupByFieldOptions: getEventsTableFieldOptions, handleOrderByReset, supportedDisplayTypes: [ DisplayType.AREA, DisplayType.BAR, DisplayType.BIG_NUMBER, DisplayType.LINE, DisplayType.TABLE, DisplayType.TOP_N, ], getTableRequest: ( api: Client, widget: Widget, query: WidgetQuery, organization: Organization, pageFilters: PageFilters, onDemandControlContext?: OnDemandControlContext, limit?: number, cursor?: string, referrer?: string, mepSetting?: MEPState | null ) => { const url = `/organizations/${organization.slug}/events/`; const useOnDemandMetrics = shouldUseOnDemandMetrics( organization, widget, onDemandControlContext ); const queryExtras = { useOnDemandMetrics, ...getQueryExtraForSplittingDiscover(widget, organization, !!useOnDemandMetrics), onDemandType: 'dynamic_query', }; return getEventsRequest( url, api, query, organization, pageFilters, limit, cursor, referrer, mepSetting, queryExtras ); }, getSeriesRequest: getEventsSeriesRequest, transformSeries: transformEventsResponseToSeries, transformTable: transformEventsResponseToTable, filterAggregateParams, getSeriesResultType, }; function getTableSortOptions(_organization: Organization, widgetQuery: WidgetQuery) { const {columns, aggregates} = widgetQuery; const options: SelectValue[] = []; let equations = 0; [...aggregates, ...columns] .filter(field => !!field) .forEach(field => { let alias; const label = stripEquationPrefix(field); // Equations are referenced via a standard alias following this pattern if (isEquation(field)) { alias = `equation[${equations}]`; equations += 1; } options.push({label, value: alias ?? field}); }); return options; } function filterSeriesSortOptions(columns: Set) { return (option: FieldValueOption) => { if ( option.value.kind === FieldValueKind.FUNCTION || option.value.kind === FieldValueKind.EQUATION ) { return true; } return ( columns.has(option.value.meta.name) || option.value.meta.name === CUSTOM_EQUATION_VALUE ); }; } function getTimeseriesSortOptions( organization: Organization, widgetQuery: WidgetQuery, tags?: TagCollection ) { const options: Record> = {}; options[`field:${CUSTOM_EQUATION_VALUE}`] = { label: 'Custom Equation', value: { kind: FieldValueKind.EQUATION, meta: {name: CUSTOM_EQUATION_VALUE}, }, }; let equations = 0; [...widgetQuery.aggregates, ...widgetQuery.columns] .filter(field => !!field) .forEach(field => { let alias; const label = stripEquationPrefix(field); // Equations are referenced via a standard alias following this pattern if (isEquation(field)) { alias = `equation[${equations}]`; equations += 1; options[`equation:${alias}`] = { label, value: { kind: FieldValueKind.EQUATION, meta: { name: alias ?? field, }, }, }; } }); const fieldOptions = getEventsTableFieldOptions(organization, tags); return {...options, ...fieldOptions}; } function getEventsTableFieldOptions( organization: Organization, tags?: TagCollection, customMeasurements?: CustomMeasurementCollection ) { const measurements = getMeasurements(); return generateFieldOptions({ organization, tagKeys: Object.values(tags ?? {}).map(({key}) => key), measurementKeys: Object.values(measurements).map(({key}) => key), spanOperationBreakdownKeys: SPAN_OP_BREAKDOWN_FIELDS, customMeasurements: Object.values(customMeasurements ?? {}).map( ({key, functions}) => ({ key, functions, }) ), }); } function transformEventsResponseToTable( data: TableData | EventsTableData, _widgetQuery: WidgetQuery ): TableData { let tableData = data; // events api uses a different response format so we need to construct tableData differently const {fields, ...otherMeta} = (data as EventsTableData).meta ?? {}; tableData = { ...data, meta: {...fields, ...otherMeta}, } as TableData; return tableData as TableData; } function filterYAxisAggregateParams( fieldValue: QueryFieldValue, displayType: DisplayType ) { return (option: FieldValueOption) => { // Only validate function parameters for timeseries widgets and // world map widgets. if (displayType === DisplayType.BIG_NUMBER) { return true; } if (fieldValue.kind !== FieldValueKind.FUNCTION) { return true; } const functionName = fieldValue.function[0]; const primaryOutput = errorsAndTransactionsAggregateFunctionOutputType( functionName as string, option.value.meta.name ); if (primaryOutput) { return isLegalYAxisType(primaryOutput); } if ( option.value.kind === FieldValueKind.FUNCTION || option.value.kind === FieldValueKind.EQUATION ) { // Functions and equations are not legal options as an aggregate/function parameter. return false; } return isLegalYAxisType(option.value.meta.dataType); }; } function filterYAxisOptions(displayType: DisplayType) { return (option: FieldValueOption) => { // Only validate function names for timeseries widgets and // world map widgets. if ( !(displayType === DisplayType.BIG_NUMBER) && option.value.kind === FieldValueKind.FUNCTION ) { const primaryOutput = errorsAndTransactionsAggregateFunctionOutputType( option.value.meta.name, undefined ); if (primaryOutput) { // If a function returns a specific type, then validate it. return isLegalYAxisType(primaryOutput); } } return option.value.kind === FieldValueKind.FUNCTION; }; } function transformEventsResponseToSeries( data: EventsStats | MultiSeriesEventsStats, widgetQuery: WidgetQuery ): Series[] { let output: Series[] = []; const queryAlias = widgetQuery.name; if (isMultiSeriesStats(data)) { let seriesWithOrdering: SeriesWithOrdering[] = []; const isMultiSeriesDataWithGrouping = widgetQuery.aggregates.length > 1 && widgetQuery.columns.length; // Convert multi-series results into chartable series. Multi series results // are created when multiple yAxis are used. Convert the timeseries // data into a multi-series data set. As the server will have // replied with a map like: {[titleString: string]: EventsStats} if (isMultiSeriesDataWithGrouping) { seriesWithOrdering = flattenMultiSeriesDataWithGrouping(data, queryAlias); } else { seriesWithOrdering = Object.keys(data).map((seriesName: string) => { const prefixedName = queryAlias ? `${queryAlias} : ${seriesName}` : seriesName; const seriesData: EventsStats = data[seriesName]; return [ seriesData.order || 0, transformSeries(seriesData, prefixedName, seriesName), ]; }); } output = [ ...seriesWithOrdering .sort((itemA, itemB) => itemA[0] - itemB[0]) .map(item => item[1]), ]; } else { const field = widgetQuery.aggregates[0]; const prefixedName = queryAlias ? `${queryAlias} : ${field}` : field; const transformed = transformSeries(data, prefixedName, field); output.push(transformed); } return output; } // Get the series result type from the EventsStats meta function getSeriesResultType( data: EventsStats | MultiSeriesEventsStats, widgetQuery: WidgetQuery ): Record { const field = widgetQuery.aggregates[0]; const resultTypes = {}; // Need to use getAggregateAlias since events-stats still uses aggregate alias format if (isMultiSeriesStats(data)) { Object.keys(data).forEach( key => (resultTypes[key] = data[key].meta?.fields[getAggregateAlias(key)]) ); } else { resultTypes[field] = data.meta?.fields[getAggregateAlias(field)]; } return resultTypes; } function renderEventIdAsLinkable(data, {eventView, organization}: RenderFunctionBaggage) { const id: string | unknown = data?.id; if (!eventView || typeof id !== 'string') { return null; } const eventSlug = generateEventSlug(data); const target = eventDetailsRouteWithEventView({ orgSlug: organization.slug, eventSlug, eventView, }); return ( {getShortEventId(id)} ); } function renderTraceAsLinkable( data, {eventView, organization, location}: RenderFunctionBaggage ) { const id: string | unknown = data?.trace; if (!eventView || typeof id !== 'string') { return null; } const dateSelection = eventView.normalizeDateSelection(location); const target = getTraceDetailsUrl( organization, String(data.trace), dateSelection, {}, getTimeStampFromTableDateField(data.timestamp), data.id || data.eventID ); return ( {getShortEventId(id)} ); } export function getCustomEventsFieldRenderer(field: string, meta: MetaType) { if (field === 'id') { return renderEventIdAsLinkable; } if (field === 'trace') { return renderTraceAsLinkable; } // When title or transaction are << unparameterized >>, link out to discover showing unparameterized transactions if (['title', 'transaction'].includes(field)) { return function (data, baggage) { if (data[field] === UNPARAMETERIZED_TRANSACTION) { return ( {data[field]} ); } return getFieldRenderer(field, meta, false)(data, baggage); }; } return getFieldRenderer(field, meta, false); } function getEventsRequest( url: string, api: Client, query: WidgetQuery, _organization: Organization, pageFilters: PageFilters, limit?: number, cursor?: string, referrer?: string, mepSetting?: MEPState | null, queryExtras?: DiscoverQueryExtras ) { const isMEPEnabled = defined(mepSetting) && mepSetting !== MEPState.TRANSACTIONS_ONLY; const eventView = eventViewFromWidget('', query, pageFilters); const params: DiscoverQueryRequestParams = { per_page: limit, cursor, referrer, ...getDashboardsMEPQueryParams(isMEPEnabled), ...queryExtras, }; if (query.orderby) { params.sort = typeof query.orderby === 'string' ? [query.orderby] : query.orderby; } // TODO: eventually need to replace this with just EventsTableData as we deprecate eventsv2 return doDiscoverQuery( api, url, { ...eventView.generateQueryStringObject(), ...params, }, // Tries events request up to 3 times on rate limit { retry: { statusCodes: [429], tries: 3, }, } ); } function getEventsSeriesRequest( api: Client, widget: Widget, queryIndex: number, organization: Organization, pageFilters: PageFilters, onDemandControlContext?: OnDemandControlContext, referrer?: string, mepSetting?: MEPState | null ) { const widgetQuery = widget.queries[queryIndex]; const {displayType, limit} = widget; const {environments, projects} = pageFilters; const {start, end, period: statsPeriod} = pageFilters.datetime; const interval = getWidgetInterval( displayType, {start, end, period: statsPeriod}, '1m' ); const isMEPEnabled = defined(mepSetting) && mepSetting !== MEPState.TRANSACTIONS_ONLY; let requestData; if (displayType === DisplayType.TOP_N) { requestData = { organization, interval, start, end, project: projects, environment: environments, period: statsPeriod, query: widgetQuery.conditions, yAxis: widgetQuery.aggregates[widgetQuery.aggregates.length - 1], includePrevious: false, referrer, partial: true, field: [...widgetQuery.columns, ...widgetQuery.aggregates], queryExtras: getDashboardsMEPQueryParams(isMEPEnabled), includeAllArgs: true, topEvents: TOP_N, }; if (widgetQuery.orderby) { requestData.orderby = widgetQuery.orderby; } } else { requestData = { organization, interval, start, end, project: projects, environment: environments, period: statsPeriod, query: widgetQuery.conditions, yAxis: widgetQuery.aggregates, orderby: widgetQuery.orderby, includePrevious: false, referrer, partial: true, queryExtras: getDashboardsMEPQueryParams(isMEPEnabled), includeAllArgs: true, }; if (widgetQuery.columns?.length !== 0) { requestData.topEvents = limit ?? TOP_N; requestData.field = [...widgetQuery.columns, ...widgetQuery.aggregates]; // Compare field and orderby as aliases to ensure requestData has // the orderby selected // If the orderby is an equation alias, do not inject it const orderby = trimStart(widgetQuery.orderby, '-'); if ( widgetQuery.orderby && !isEquationAlias(orderby) && !requestData.field.includes(orderby) ) { requestData.field.push(orderby); } // The "Other" series is only included when there is one // y-axis and one widgetQuery requestData.excludeOther = widgetQuery.aggregates.length !== 1 || widget.queries.length !== 1; if (isEquation(trimStart(widgetQuery.orderby, '-'))) { const nextEquationIndex = getNumEquations(widgetQuery.aggregates); const isDescending = widgetQuery.orderby.startsWith('-'); const prefix = isDescending ? '-' : ''; // Construct the alias form of the equation and inject it into the request requestData.orderby = `${prefix}equation[${nextEquationIndex}]`; requestData.field = [ ...widgetQuery.columns, ...widgetQuery.aggregates, trimStart(widgetQuery.orderby, '-'), ]; } } } if (shouldUseOnDemandMetrics(organization, widget, onDemandControlContext)) { requestData.queryExtras = { ...requestData.queryExtras, ...getQueryExtraForSplittingDiscover(widget, organization, true), }; return doOnDemandMetricsRequest(api, requestData); } return doEventsRequest(api, requestData); } async function doOnDemandMetricsRequest( api, requestData ): Promise< [EventsStats | MultiSeriesEventsStats, string | undefined, ResponseMeta | undefined] > { try { const isEditing = location.pathname.endsWith('/edit/'); const fetchEstimatedStats = () => `/organizations/${requestData.organization.slug}/metrics-estimation-stats/`; const response = await doEventsRequest(api, { ...requestData, queryExtras: { ...requestData.queryExtras, useOnDemandMetrics: true, onDemandType: 'dynamic_query', }, dataset: 'metricsEnhanced', generatePathname: isEditing ? fetchEstimatedStats : undefined, }); response[0] = {...response[0]}; return [response[0], response[1], response[2]]; } catch (err) { Sentry.captureMessage('Failed to fetch metrics estimation stats', {extra: err}); return doEventsRequest(api, requestData); } } // Checks fieldValue to see what function is being used and only allow supported custom measurements function filterAggregateParams(option: FieldValueOption, fieldValue?: QueryFieldValue) { if ( (option.value.kind === FieldValueKind.CUSTOM_MEASUREMENT && fieldValue?.kind === 'function' && fieldValue?.function && !option.value.meta.functions.includes(fieldValue.function[0])) || option.value.meta.name === FieldKey.TOTAL_COUNT ) { return false; } return true; } const shouldSendWidgetForSplittingDiscover = (organization: Organization) => { return organization.features.includes('performance-discover-widget-split-ui'); }; const getQueryExtraForSplittingDiscover = ( widget: Widget, organization: Organization, useOnDemandMetrics: boolean ) => { if (!useOnDemandMetrics || !shouldSendWidgetForSplittingDiscover(organization)) { return {}; } if (widget.id) { return {dashboardWidgetId: widget.id}; } return {}; };