import {Component, Fragment} from 'react';
import type {RouteContextInterface} from 'react-router';
import styled from '@emotion/styled';
import type {Location, LocationDescriptor, LocationDescriptorObject} from 'history';
import groupBy from 'lodash/groupBy';
import {Client} from 'sentry/api';
import {LinkButton} from 'sentry/components/button';
import type {GridColumn} from 'sentry/components/gridEditable';
import GridEditable, {COL_WIDTH_UNDEFINED} from 'sentry/components/gridEditable';
import SortLink from 'sentry/components/gridEditable/sortLink';
import Link from 'sentry/components/links/link';
import Pagination from 'sentry/components/pagination';
import QuestionTooltip from 'sentry/components/questionTooltip';
import {Tooltip} from 'sentry/components/tooltip';
import {IconProfiling} from 'sentry/icons';
import {t, tct} from 'sentry/locale';
import type {IssueAttachment} from 'sentry/types/group';
import type {Organization} from 'sentry/types/organization';
import {trackAnalytics} from 'sentry/utils/analytics';
import {browserHistory} from 'sentry/utils/browserHistory';
import type {TableData, TableDataRow} from 'sentry/utils/discover/discoverQuery';
import DiscoverQuery from 'sentry/utils/discover/discoverQuery';
import type EventView from 'sentry/utils/discover/eventView';
import {isFieldSortable} from 'sentry/utils/discover/eventView';
import {getFieldRenderer} from 'sentry/utils/discover/fieldRenderers';
import {
fieldAlignment,
getAggregateAlias,
isSpanOperationBreakdownField,
SPAN_OP_RELATIVE_BREAKDOWN_FIELD,
} from 'sentry/utils/discover/fields';
import {generateLinkToEventInTraceView} from 'sentry/utils/discover/urls';
import ViewReplayLink from 'sentry/utils/discover/viewReplayLink';
import {isEmptyObject} from 'sentry/utils/object/isEmptyObject';
import parseLinkHeader from 'sentry/utils/parseLinkHeader';
import {VisuallyCompleteWithData} from 'sentry/utils/performanceForSentry';
import CellAction, {Actions, updateQuery} from 'sentry/views/discover/table/cellAction';
import type {TableColumn} from 'sentry/views/discover/table/types';
import {COLUMN_TITLES} from '../../data';
import {TraceViewSources} from '../../newTraceDetails/traceMetadataHeader';
import Tab from '../tabs';
import {
generateProfileLink,
generateReplayLink,
generateTraceLink,
normalizeSearchConditions,
} from '../utils';
import type {TitleProps} from './operationSort';
import OperationSort from './operationSort';
function shouldRenderColumn(containsSpanOpsBreakdown: boolean, col: string): boolean {
if (containsSpanOpsBreakdown && isSpanOperationBreakdownField(col)) {
return false;
}
if (
col === 'profiler.id' ||
col === 'thread.id' ||
col === 'precise.start_ts' ||
col === 'precise.finish_ts'
) {
return false;
}
return true;
}
function OperationTitle({onClick}: TitleProps) {
return (
{t('operation duration')}
);
}
type Props = {
eventView: EventView;
location: Location;
organization: Organization;
routes: RouteContextInterface['routes'];
setError: (msg: string | undefined) => void;
transactionName: string;
columnTitles?: string[];
customColumns?: ('attachments' | 'minidump')[];
excludedTags?: string[];
isEventLoading?: boolean;
isRegressionIssue?: boolean;
issueId?: string;
projectSlug?: string;
referrer?: string;
};
type State = {
attachments: IssueAttachment[];
hasMinidumps: boolean;
lastFetchedCursor: string;
widths: number[];
};
class EventsTable extends Component {
state: State = {
widths: [],
lastFetchedCursor: '',
attachments: [],
hasMinidumps: false,
};
api = new Client();
replayLinkGenerator = generateReplayLink(this.props.routes);
handleCellAction = (column: TableColumn) => {
return (action: Actions, value: React.ReactText) => {
const {eventView, location, organization, excludedTags} = this.props;
trackAnalytics('performance_views.transactionEvents.cellaction', {
organization,
action,
});
const searchConditions = normalizeSearchConditions(eventView.query);
if (excludedTags) {
excludedTags.forEach(tag => {
searchConditions.removeFilter(tag);
});
}
updateQuery(searchConditions, action, column, value);
browserHistory.push({
pathname: location.pathname,
query: {
...location.query,
cursor: undefined,
query: searchConditions.formatString(),
},
});
};
};
renderBodyCell(
tableData: TableData | null,
column: TableColumn,
dataRow: TableDataRow
): React.ReactNode {
const {eventView, organization, location, transactionName, projectSlug} = this.props;
if (!tableData || !tableData.meta) {
return dataRow[column.key];
}
const tableMeta = tableData.meta;
const field = String(column.key);
const fieldRenderer = getFieldRenderer(field, tableMeta);
const rendered = fieldRenderer(dataRow, {
organization,
location,
eventView,
projectSlug,
});
const allowActions = [
Actions.ADD,
Actions.EXCLUDE,
Actions.SHOW_GREATER_THAN,
Actions.SHOW_LESS_THAN,
];
if (['attachments', 'minidump'].includes(field)) {
return rendered;
}
if (field === 'id' || field === 'trace') {
const {issueId, isRegressionIssue} = this.props;
const isIssue: boolean = !!issueId;
let target: LocationDescriptor = {};
const locationWithTab = {...location, query: {...location.query, tab: Tab.EVENTS}};
// TODO: set referrer properly
if (isIssue && !isRegressionIssue && field === 'id') {
target.pathname = `/organizations/${organization.slug}/issues/${issueId}/events/${dataRow.id}/`;
} else {
if (field === 'id') {
target = generateLinkToEventInTraceView({
traceSlug: dataRow.trace?.toString(),
projectSlug: dataRow['project.name']?.toString(),
eventId: dataRow.id,
timestamp: dataRow.timestamp,
location: locationWithTab,
organization,
transactionName: transactionName,
source: TraceViewSources.PERFORMANCE_TRANSACTION_SUMMARY,
});
} else {
target = generateTraceLink(transactionName)(
organization,
dataRow,
locationWithTab
);
}
}
return (
{rendered}
);
}
if (field === 'replayId') {
const target: LocationDescriptor | null = dataRow.replayId
? this.replayLinkGenerator(organization, dataRow, undefined)
: null;
return (
{target ? (
{rendered}
) : (
rendered
)}
);
}
if (field === 'profile.id') {
const target = generateProfileLink()(organization, dataRow, undefined);
const transactionMeetsProfilingRequirements =
typeof dataRow['transaction.duration'] === 'number' &&
dataRow['transaction.duration'] > 20;
return (
);
}
const fieldName = getAggregateAlias(field);
const value = dataRow[fieldName];
if (tableMeta[fieldName] === 'integer' && typeof value === 'number' && value > 999) {
return (
{rendered}
);
}
return (
{rendered}
);
}
renderBodyCellWithData = (tableData: TableData | null) => {
return (
column: TableColumn,
dataRow: TableDataRow
): React.ReactNode => this.renderBodyCell(tableData, column, dataRow);
};
onSortClick(currentSortKind?: string, currentSortField?: string) {
const {organization} = this.props;
trackAnalytics('performance_views.transactionEvents.sort', {
organization,
field: currentSortField,
direction: currentSortKind,
});
}
renderHeadCell(
tableMeta: TableData['meta'],
column: TableColumn,
title: React.ReactNode
): React.ReactNode {
const {eventView, location} = this.props;
const align = fieldAlignment(column.name, column.type, tableMeta);
const field = {field: column.name, width: column.width};
function generateSortLink(): LocationDescriptorObject | undefined {
if (!tableMeta) {
return undefined;
}
const nextEventView = eventView.sortOnField(field, tableMeta);
const queryStringObject = nextEventView.generateQueryStringObject();
return {
...location,
query: {...location.query, sort: queryStringObject.sort},
};
}
const currentSort = eventView.sortForField(field, tableMeta);
// EventId, TraceId, and ReplayId are technically sortable but we don't want to sort them here since sorting by a uuid value doesn't make sense
const canSort =
field.field !== 'id' &&
field.field !== 'trace' &&
field.field !== 'replayId' &&
field.field !== SPAN_OP_RELATIVE_BREAKDOWN_FIELD &&
isFieldSortable(field, tableMeta);
const currentSortKind = currentSort ? currentSort.kind : undefined;
const currentSortField = currentSort ? currentSort.field : undefined;
if (field.field === SPAN_OP_RELATIVE_BREAKDOWN_FIELD) {
title = (
);
}
const sortLink = (
this.onSortClick(currentSortKind, currentSortField)}
/>
);
return sortLink;
}
renderHeadCellWithMeta = (tableMeta: TableData['meta']) => {
const columnTitles = this.props.columnTitles ?? COLUMN_TITLES;
return (column: TableColumn, index: number): React.ReactNode =>
this.renderHeadCell(tableMeta, column, columnTitles[index]);
};
handleResizeColumn = (columnIndex: number, nextColumn: GridColumn) => {
const widths: number[] = [...this.state.widths];
widths[columnIndex] = nextColumn.width
? Number(nextColumn.width)
: COL_WIDTH_UNDEFINED;
this.setState({...this.state, widths});
};
render() {
const {eventView, organization, location, setError, referrer, isEventLoading} =
this.props;
const totalEventsView = eventView.clone();
totalEventsView.sorts = [];
totalEventsView.fields = [{field: 'count()', width: -1}];
const {widths} = this.state;
const containsSpanOpsBreakdown = !!eventView
.getColumns()
.find(
(col: TableColumn) =>
col.name === SPAN_OP_RELATIVE_BREAKDOWN_FIELD
);
const columnOrder = eventView
.getColumns()
.filter((col: TableColumn) =>
shouldRenderColumn(containsSpanOpsBreakdown, col.name)
)
.map((col: TableColumn, i: number) => {
if (typeof widths[i] === 'number') {
return {...col, width: widths[i]};
}
return col;
});
if (
this.props.customColumns?.includes('attachments') &&
this.state.attachments.length
) {
columnOrder.push({
isSortable: false,
key: 'attachments',
name: 'attachments',
type: 'never',
column: {field: 'attachments', kind: 'field', alias: undefined},
});
}
if (this.props.customColumns?.includes('minidump') && this.state.hasMinidumps) {
columnOrder.push({
isSortable: false,
key: 'minidump',
name: 'minidump',
type: 'never',
column: {field: 'minidump', kind: 'field', alias: undefined},
});
}
const joinCustomData = ({data}: TableData) => {
const attachmentsByEvent = groupBy(this.state.attachments, 'event_id');
data.forEach(event => {
event.attachments = (attachmentsByEvent[event.id] || []) as any;
});
};
const fetchAttachments = async ({data}: TableData, cursor: string) => {
const eventIds = data.map(value => value.id);
const fetchOnlyMinidumps = !this.props.customColumns?.includes('attachments');
const queries: string = [
'per_page=50',
...(fetchOnlyMinidumps ? ['types=event.minidump'] : []),
...eventIds.map(eventId => `event_id=${eventId}`),
].join('&');
const res: IssueAttachment[] = await this.api.requestPromise(
`/api/0/issues/${this.props.issueId}/attachments/?${queries}`
);
let hasMinidumps = false;
res.forEach(attachment => {
if (attachment.type === 'event.minidump') {
hasMinidumps = true;
}
});
this.setState({
...this.state,
lastFetchedCursor: cursor,
attachments: res,
hasMinidumps,
});
};
return (
setError(error?.message)}
referrer="api.performance.transaction-summary"
cursor="0:0:0"
>
{({isLoading: isTotalEventsLoading, tableData: table}) => {
const totalEventsCount = table?.data[0]?.['count()'] ?? 0;
return (
setError(error?.message)}
referrer={referrer || 'api.performance.transaction-events'}
>
{({pageLinks, isLoading: isDiscoverQueryLoading, tableData}) => {
tableData ??= {data: []};
const pageEventsCount = tableData?.data?.length ?? 0;
const parsedPageLinks = parseLinkHeader(pageLinks);
const cursor = parsedPageLinks?.next?.cursor;
const shouldFetchAttachments: boolean =
organization.features.includes('event-attachments') &&
!!this.props.issueId &&
!!cursor &&
this.state.lastFetchedCursor !== cursor; // Only fetch on issue details page
const paginationCaption =
totalEventsCount && pageEventsCount
? tct('Showing [pageEventsCount] of [totalEventsCount] events', {
pageEventsCount: pageEventsCount.toLocaleString(),
totalEventsCount: totalEventsCount.toLocaleString(),
})
: undefined;
if (shouldFetchAttachments) {
fetchAttachments(tableData, cursor);
}
joinCustomData(tableData);
return (
);
}}
);
}}
);
}
}
const StyledIconQuestion = styled(QuestionTooltip)`
position: relative;
top: 1px;
left: 4px;
`;
export default EventsTable;