|
@@ -0,0 +1,420 @@
|
|
|
+import {ReactNode, useMemo} from 'react';
|
|
|
+import {Location} from 'history';
|
|
|
+import moment from 'moment';
|
|
|
+
|
|
|
+import GridEditable, {
|
|
|
+ COL_WIDTH_UNDEFINED,
|
|
|
+ GridColumnOrder,
|
|
|
+} from 'sentry/components/gridEditable';
|
|
|
+import SortLink from 'sentry/components/gridEditable/sortLink';
|
|
|
+import {t} from 'sentry/locale';
|
|
|
+import {Organization} from 'sentry/types';
|
|
|
+import {parsePeriodToHours} from 'sentry/utils/dates';
|
|
|
+import {TableData, useDiscoverQuery} from 'sentry/utils/discover/discoverQuery';
|
|
|
+import EventView from 'sentry/utils/discover/eventView';
|
|
|
+import {
|
|
|
+ AggregationKeyWithAlias,
|
|
|
+ ColumnType,
|
|
|
+ fieldAlignment,
|
|
|
+ QueryFieldValue,
|
|
|
+} from 'sentry/utils/discover/fields';
|
|
|
+import {Container} from 'sentry/utils/discover/styles';
|
|
|
+import {DiscoverDatasets} from 'sentry/utils/discover/types';
|
|
|
+import {formatPercentage} from 'sentry/utils/formatters';
|
|
|
+import theme from 'sentry/utils/theme';
|
|
|
+import {MutableSearch} from 'sentry/utils/tokenizeSearch';
|
|
|
+import {TransactionThresholdMetric} from 'sentry/views/performance/transactionSummary/transactionThresholdModal';
|
|
|
+import {ExplorerText} from 'sentry/views/performance/trends/changeExplorer';
|
|
|
+import {
|
|
|
+ NormalizedTrendsTransaction,
|
|
|
+ TrendFunctionField,
|
|
|
+ TrendsTransaction,
|
|
|
+ TrendView,
|
|
|
+} from 'sentry/views/performance/trends/types';
|
|
|
+
|
|
|
+type MetricsTableProps = {
|
|
|
+ isLoading: boolean;
|
|
|
+ location: Location;
|
|
|
+ organization: Organization;
|
|
|
+ transaction: NormalizedTrendsTransaction;
|
|
|
+ trendFunction: string;
|
|
|
+ trendView: TrendView;
|
|
|
+};
|
|
|
+
|
|
|
+const fieldsNeeded: AggregationKeyWithAlias[] = ['tps', 'p50', 'p95'];
|
|
|
+
|
|
|
+type MetricColumnKey = 'metric' | 'before' | 'after' | 'change';
|
|
|
+
|
|
|
+type MetricColumn = GridColumnOrder<MetricColumnKey>;
|
|
|
+
|
|
|
+type TableDataRow = Record<MetricColumnKey, any>;
|
|
|
+
|
|
|
+const MetricColumnOrder = ['metric', 'before', 'after', 'change'];
|
|
|
+
|
|
|
+export const COLUMNS: Record<MetricColumnKey, MetricColumn> = {
|
|
|
+ metric: {
|
|
|
+ key: 'metric',
|
|
|
+ name: t('Metric'),
|
|
|
+ width: COL_WIDTH_UNDEFINED,
|
|
|
+ },
|
|
|
+ before: {
|
|
|
+ key: 'before',
|
|
|
+ name: t('Before'),
|
|
|
+ width: COL_WIDTH_UNDEFINED,
|
|
|
+ },
|
|
|
+ after: {
|
|
|
+ key: 'after',
|
|
|
+ name: t('After'),
|
|
|
+ width: COL_WIDTH_UNDEFINED,
|
|
|
+ },
|
|
|
+ change: {
|
|
|
+ key: 'change',
|
|
|
+ name: t('Change'),
|
|
|
+ width: COL_WIDTH_UNDEFINED,
|
|
|
+ },
|
|
|
+};
|
|
|
+
|
|
|
+const COLUMN_TYPE: Record<MetricColumnKey, ColumnType> = {
|
|
|
+ metric: 'string',
|
|
|
+ before: 'duration',
|
|
|
+ after: 'duration',
|
|
|
+ change: 'percentage',
|
|
|
+};
|
|
|
+
|
|
|
+export function MetricsTable(props: MetricsTableProps) {
|
|
|
+ const {trendFunction, transaction, trendView, organization, location, isLoading} =
|
|
|
+ props;
|
|
|
+ const p50 =
|
|
|
+ trendFunction === TrendFunctionField.P50
|
|
|
+ ? getTrendsRowData(transaction, TrendFunctionField.P50)
|
|
|
+ : undefined;
|
|
|
+ const p95 =
|
|
|
+ trendFunction === TrendFunctionField.P95
|
|
|
+ ? getTrendsRowData(transaction, TrendFunctionField.P95)
|
|
|
+ : undefined;
|
|
|
+
|
|
|
+ const breakpoint = transaction.breakpoint;
|
|
|
+
|
|
|
+ const hours = trendView.statsPeriod ? parsePeriodToHours(trendView.statsPeriod) : 0;
|
|
|
+ const startTime = useMemo(
|
|
|
+ () =>
|
|
|
+ trendView.start ? trendView.start : moment().subtract(hours, 'h').toISOString(),
|
|
|
+ [hours, trendView.start]
|
|
|
+ );
|
|
|
+ const breakpointTime = breakpoint ? new Date(breakpoint * 1000).toISOString() : '';
|
|
|
+ const endTime = useMemo(
|
|
|
+ () => (trendView.end ? trendView.end : moment().toISOString()),
|
|
|
+ [trendView.end]
|
|
|
+ );
|
|
|
+
|
|
|
+ const {data: beforeBreakpoint, isLoading: isLoadingBefore} = useDiscoverQuery(
|
|
|
+ getQueryParams(
|
|
|
+ startTime,
|
|
|
+ breakpointTime,
|
|
|
+ fieldsNeeded,
|
|
|
+ 'transaction',
|
|
|
+ DiscoverDatasets.METRICS,
|
|
|
+ organization,
|
|
|
+ trendView,
|
|
|
+ transaction.transaction,
|
|
|
+ location
|
|
|
+ )
|
|
|
+ );
|
|
|
+
|
|
|
+ const {data: afterBreakpoint, isLoading: isLoadingAfter} = useDiscoverQuery(
|
|
|
+ getQueryParams(
|
|
|
+ breakpointTime,
|
|
|
+ endTime,
|
|
|
+ fieldsNeeded,
|
|
|
+ 'transaction',
|
|
|
+ DiscoverDatasets.METRICS,
|
|
|
+ organization,
|
|
|
+ trendView,
|
|
|
+ transaction.transaction,
|
|
|
+ location
|
|
|
+ )
|
|
|
+ );
|
|
|
+
|
|
|
+ const {data: beforeBreakpointErrors, isLoading: isLoadingBeforeErrors} =
|
|
|
+ useDiscoverQuery(
|
|
|
+ getQueryParams(
|
|
|
+ startTime,
|
|
|
+ breakpointTime,
|
|
|
+ ['count'],
|
|
|
+ 'error',
|
|
|
+ DiscoverDatasets.DISCOVER,
|
|
|
+ organization,
|
|
|
+ trendView,
|
|
|
+ transaction.transaction,
|
|
|
+ location
|
|
|
+ )
|
|
|
+ );
|
|
|
+
|
|
|
+ const {data: afterBreakpointErrors, isLoading: isLoadingAfterErrors} = useDiscoverQuery(
|
|
|
+ getQueryParams(
|
|
|
+ breakpointTime,
|
|
|
+ endTime,
|
|
|
+ ['count'],
|
|
|
+ 'error',
|
|
|
+ DiscoverDatasets.DISCOVER,
|
|
|
+ organization,
|
|
|
+ trendView,
|
|
|
+ transaction.transaction,
|
|
|
+ location
|
|
|
+ )
|
|
|
+ );
|
|
|
+
|
|
|
+ const throughput: TableDataRow = getEventsRowData(
|
|
|
+ 'tps()',
|
|
|
+ 'Throughput',
|
|
|
+ 'ps',
|
|
|
+ '-',
|
|
|
+ false,
|
|
|
+ beforeBreakpoint,
|
|
|
+ afterBreakpoint
|
|
|
+ );
|
|
|
+
|
|
|
+ const p50Events = !p50
|
|
|
+ ? getEventsRowData(
|
|
|
+ 'p50()',
|
|
|
+ 'P50',
|
|
|
+ 'ms',
|
|
|
+ '-',
|
|
|
+ false,
|
|
|
+ beforeBreakpoint,
|
|
|
+ afterBreakpoint
|
|
|
+ )
|
|
|
+ : p50;
|
|
|
+
|
|
|
+ const p95Events = !p95
|
|
|
+ ? getEventsRowData(
|
|
|
+ 'p95()',
|
|
|
+ 'P95',
|
|
|
+ 'ms',
|
|
|
+ '-',
|
|
|
+ false,
|
|
|
+ beforeBreakpoint,
|
|
|
+ afterBreakpoint
|
|
|
+ )
|
|
|
+ : p95;
|
|
|
+
|
|
|
+ const errors: TableDataRow = getEventsRowData(
|
|
|
+ 'count()',
|
|
|
+ 'Errors',
|
|
|
+ '',
|
|
|
+ 0,
|
|
|
+ true,
|
|
|
+ beforeBreakpointErrors,
|
|
|
+ afterBreakpointErrors
|
|
|
+ );
|
|
|
+
|
|
|
+ const columnOrder = MetricColumnOrder.map(column => COLUMNS[column]);
|
|
|
+
|
|
|
+ return (
|
|
|
+ <GridEditable
|
|
|
+ data={[throughput, p50Events, p95Events, errors]}
|
|
|
+ columnOrder={columnOrder}
|
|
|
+ columnSortBy={[]}
|
|
|
+ grid={{
|
|
|
+ renderHeadCell,
|
|
|
+ renderBodyCell,
|
|
|
+ }}
|
|
|
+ location={location}
|
|
|
+ isLoading={
|
|
|
+ isLoadingBefore ||
|
|
|
+ isLoadingAfter ||
|
|
|
+ isLoading ||
|
|
|
+ isLoadingBeforeErrors ||
|
|
|
+ isLoadingAfterErrors
|
|
|
+ }
|
|
|
+ />
|
|
|
+ );
|
|
|
+}
|
|
|
+
|
|
|
+function getEventsRowData(
|
|
|
+ field: string,
|
|
|
+ rowTitle: string,
|
|
|
+ suffix: string,
|
|
|
+ nullValue: string | number,
|
|
|
+ wholeNumbers: boolean,
|
|
|
+ beforeData?: TableData,
|
|
|
+ afterData?: TableData
|
|
|
+): TableDataRow {
|
|
|
+ if (beforeData?.data[0][field] && afterData?.data[0][field]) {
|
|
|
+ return {
|
|
|
+ metric: rowTitle,
|
|
|
+ before: !wholeNumbers
|
|
|
+ ? toFormattedNumber(beforeData.data[0][field].toString(), 1) + ' ' + suffix
|
|
|
+ : beforeData.data[0][field],
|
|
|
+ after: !wholeNumbers
|
|
|
+ ? toFormattedNumber(afterData.data[0][field].toString(), 1) + ' ' + suffix
|
|
|
+ : afterData.data[0][field],
|
|
|
+ change: formatPercentage(
|
|
|
+ percentChange(
|
|
|
+ beforeData.data[0][field] as number,
|
|
|
+ afterData.data[0][field] as number
|
|
|
+ ),
|
|
|
+ 1
|
|
|
+ ),
|
|
|
+ };
|
|
|
+ }
|
|
|
+ return {
|
|
|
+ metric: rowTitle,
|
|
|
+ before: nullValue,
|
|
|
+ after: nullValue,
|
|
|
+ change: '-',
|
|
|
+ };
|
|
|
+}
|
|
|
+
|
|
|
+function getTrendsRowData(
|
|
|
+ aggregateData: TrendsTransaction | undefined,
|
|
|
+ metric: TrendFunctionField
|
|
|
+): TableDataRow | undefined {
|
|
|
+ if (aggregateData) {
|
|
|
+ return {
|
|
|
+ metric: metric.toString().toUpperCase(),
|
|
|
+ before: aggregateData?.aggregate_range_1.toFixed(1) + ' ms',
|
|
|
+ after: aggregateData?.aggregate_range_2.toFixed(1) + ' ms',
|
|
|
+ change:
|
|
|
+ aggregateData?.trend_percentage !== 1
|
|
|
+ ? formatPercentage(aggregateData?.trend_percentage! - 1, 1)
|
|
|
+ : '-',
|
|
|
+ };
|
|
|
+ }
|
|
|
+ return undefined;
|
|
|
+}
|
|
|
+
|
|
|
+function getEventViewWithFields(
|
|
|
+ _organization: Organization,
|
|
|
+ eventView: EventView,
|
|
|
+ start: string,
|
|
|
+ end: string,
|
|
|
+ fields: AggregationKeyWithAlias[],
|
|
|
+ eventType: string,
|
|
|
+ transactionName: string,
|
|
|
+ dataset: DiscoverDatasets
|
|
|
+): EventView {
|
|
|
+ const newEventView = eventView.clone();
|
|
|
+ newEventView.start = start;
|
|
|
+ newEventView.end = end;
|
|
|
+ newEventView.statsPeriod = undefined;
|
|
|
+ newEventView.dataset = dataset;
|
|
|
+ newEventView.query = 'event.type:' + eventType + ' transaction:' + transactionName;
|
|
|
+ newEventView.additionalConditions = new MutableSearch('');
|
|
|
+
|
|
|
+ const chartFields: QueryFieldValue[] = fields.map(field => {
|
|
|
+ return {
|
|
|
+ kind: 'function',
|
|
|
+ function: [field, '', undefined, undefined],
|
|
|
+ };
|
|
|
+ });
|
|
|
+
|
|
|
+ return newEventView.withColumns(chartFields);
|
|
|
+}
|
|
|
+
|
|
|
+function toFormattedNumber(numberString: string, decimal: number) {
|
|
|
+ return parseFloat(numberString).toFixed(decimal);
|
|
|
+}
|
|
|
+
|
|
|
+function percentChange(before: number, after: number) {
|
|
|
+ return (after - before) / before;
|
|
|
+}
|
|
|
+
|
|
|
+function renderHeadCell(column: MetricColumn, _index: number): ReactNode {
|
|
|
+ const align = fieldAlignment(column.key, COLUMN_TYPE[column.key]);
|
|
|
+ return (
|
|
|
+ <SortLink
|
|
|
+ title={column.name}
|
|
|
+ align={align}
|
|
|
+ direction={undefined}
|
|
|
+ canSort={false}
|
|
|
+ generateSortLink={() => undefined}
|
|
|
+ />
|
|
|
+ );
|
|
|
+}
|
|
|
+
|
|
|
+export function renderBodyCell(
|
|
|
+ column: GridColumnOrder<MetricColumnKey>,
|
|
|
+ dataRow: TableDataRow
|
|
|
+) {
|
|
|
+ let data = '';
|
|
|
+ let color = '';
|
|
|
+ if (column.key === 'change') {
|
|
|
+ if (
|
|
|
+ dataRow[column.key] === '0%' ||
|
|
|
+ dataRow[column.key] === '+NaN%' ||
|
|
|
+ dataRow[column.key] === '-'
|
|
|
+ ) {
|
|
|
+ data = '-';
|
|
|
+ } else if (dataRow[column.key].charAt(0) !== '-') {
|
|
|
+ color = theme.red300;
|
|
|
+ data = '+' + dataRow[column.key];
|
|
|
+ } else {
|
|
|
+ color = theme.green300;
|
|
|
+ data = dataRow[column.key];
|
|
|
+ }
|
|
|
+ } else {
|
|
|
+ data = dataRow[column.key];
|
|
|
+ }
|
|
|
+
|
|
|
+ return (
|
|
|
+ <Container data-test-id={'pce-metrics-chart-row-' + column.key}>
|
|
|
+ <ExplorerText
|
|
|
+ data-test-id={'pce-metrics-text-' + column.key}
|
|
|
+ align={column.key !== 'metric' ? 'right' : 'left'}
|
|
|
+ color={color}
|
|
|
+ >
|
|
|
+ {data}
|
|
|
+ </ExplorerText>
|
|
|
+ </Container>
|
|
|
+ );
|
|
|
+}
|
|
|
+
|
|
|
+function getQueryParams(
|
|
|
+ startTime: string,
|
|
|
+ endTime: string,
|
|
|
+ fields: AggregationKeyWithAlias[],
|
|
|
+ query: string,
|
|
|
+ dataset: DiscoverDatasets,
|
|
|
+ organization: Organization,
|
|
|
+ eventView: EventView,
|
|
|
+ transactionName: string,
|
|
|
+ location: Location
|
|
|
+) {
|
|
|
+ const newLocation = {
|
|
|
+ ...location,
|
|
|
+ start: startTime,
|
|
|
+ end: endTime,
|
|
|
+ statsPeriod: undefined,
|
|
|
+ dataset,
|
|
|
+ sort: undefined,
|
|
|
+ query: {
|
|
|
+ query: `event.type: ${query} transaction: ${transactionName}`,
|
|
|
+ statsPeriod: undefined,
|
|
|
+ start: startTime,
|
|
|
+ end: endTime,
|
|
|
+ },
|
|
|
+ };
|
|
|
+
|
|
|
+ const newEventView = getEventViewWithFields(
|
|
|
+ organization,
|
|
|
+ eventView,
|
|
|
+ startTime,
|
|
|
+ endTime,
|
|
|
+ fields,
|
|
|
+ query,
|
|
|
+ transactionName,
|
|
|
+ dataset
|
|
|
+ );
|
|
|
+
|
|
|
+ return {
|
|
|
+ eventView: newEventView,
|
|
|
+ location: newLocation,
|
|
|
+ orgSlug: organization.slug,
|
|
|
+ transactionName,
|
|
|
+ transactionThresholdMetric: TransactionThresholdMetric.TRANSACTION_DURATION,
|
|
|
+ options: {
|
|
|
+ refetchOnWindowFocus: false,
|
|
|
+ },
|
|
|
+ };
|
|
|
+}
|