import {Fragment} from 'react'; import styled from '@emotion/styled'; import type {Location} from 'history'; import {Button, LinkButton} from 'sentry/components/button'; import {CompactSelect} from 'sentry/components/compactSelect'; import type {DropdownOption} from 'sentry/components/discover/transactionsList'; import {InvestigationRuleCreation} from 'sentry/components/dynamicSampling/investigationRule'; import GridEditable, { COL_WIDTH_UNDEFINED, type GridColumnHeader, } from 'sentry/components/gridEditable'; import Pagination, {type CursorHandler} from 'sentry/components/pagination'; import {IconPlay, IconProfiling} from 'sentry/icons'; import {t} from 'sentry/locale'; import {space} from 'sentry/styles/space'; import type {Organization} from 'sentry/types/organization'; import {parseCursor} from 'sentry/utils/cursor'; import type EventView from 'sentry/utils/discover/eventView'; import type {EventsMetaType} from 'sentry/utils/discover/eventView'; import {getFieldRenderer} from 'sentry/utils/discover/fieldRenderers'; import {decodeScalar} from 'sentry/utils/queryString'; import {MutableSearch} from 'sentry/utils/tokenizeSearch'; import {useLocation} from 'sentry/utils/useLocation'; import {useNavigate} from 'sentry/utils/useNavigate'; import useOrganization from 'sentry/utils/useOrganization'; import useProjects from 'sentry/utils/useProjects'; import {renderHeadCell} from 'sentry/views/insights/common/components/tableCells/renderHeadCell'; import {SpanIdCell} from 'sentry/views/insights/common/components/tableCells/spanIdCell'; import {useEAPSpans} from 'sentry/views/insights/common/queries/useDiscover'; import {type EAPSpanResponse, ModuleName} from 'sentry/views/insights/types'; import {TraceViewSources} from 'sentry/views/performance/newTraceDetails/traceHeader/breadcrumbs'; import {TransactionFilterOptions} from 'sentry/views/performance/transactionSummary/utils'; // TODO: When supported, also add span operation breakdown as a field type Row = Pick< EAPSpanResponse, | 'span_id' | 'user.display' | 'user.id' | 'user.email' | 'user.username' | 'user.ip' | 'span.duration' | 'trace' | 'timestamp' | 'replayId' | 'profile.id' | 'profiler.id' | 'thread.id' | 'precise.start_ts' | 'precise.finish_ts' >; type Column = GridColumnHeader< | 'span_id' | 'user.display' | 'span.duration' | 'trace' | 'timestamp' | 'replayId' | 'profile.id' >; const COLUMN_ORDER: Column[] = [ { key: 'trace', name: t('Trace ID'), width: COL_WIDTH_UNDEFINED, }, { key: 'span_id', name: t('Span ID'), width: COL_WIDTH_UNDEFINED, }, { key: 'user.display', name: t('User'), width: COL_WIDTH_UNDEFINED, }, { key: 'span.duration', name: t('Total Duration'), width: COL_WIDTH_UNDEFINED, }, { key: 'timestamp', name: t('Timestamp'), width: COL_WIDTH_UNDEFINED, }, { key: 'replayId', name: t('Replay'), width: COL_WIDTH_UNDEFINED, }, { key: 'profile.id', name: t('Profile'), width: COL_WIDTH_UNDEFINED, }, ]; const LIMIT = 5; const PAGINATION_CURSOR_SIZE = 'xs'; const CURSOR_NAME = 'serviceEntrySpansCursor'; type Props = { eventView: EventView; handleDropdownChange: (k: string) => void; totalValues: Record | null; transactionName: string; showViewSampledEventsButton?: boolean; supportsInvestigationRule?: boolean; }; export function ServiceEntrySpansTable({ eventView, handleDropdownChange, totalValues, transactionName, supportsInvestigationRule, showViewSampledEventsButton, }: Props) { const location = useLocation(); const organization = useOrganization(); const {projects} = useProjects(); const navigate = useNavigate(); const projectSlug = projects.find(p => p.id === `${eventView.project}`)?.slug; const cursor = decodeScalar(location.query?.[CURSOR_NAME]); const {selected, options} = getOTelTransactionsListSort(location); const p95 = totalValues?.['p95()'] ?? 0; const eventViewQuery = new MutableSearch(eventView.query); if (selected.value === TransactionFilterOptions.SLOW && p95) { eventViewQuery.addFilterValue('span.duration', `<=${p95.toFixed(0)}`); } const { data: tableData, isLoading, pageLinks, meta, error, } = useEAPSpans( { search: eventViewQuery.formatString(), fields: [ 'span_id', 'user.id', 'user.email', 'user.username', 'user.ip', 'span.duration', 'trace', 'timestamp', 'replayId', 'profile.id', 'profiler.id', 'thread.id', 'precise.start_ts', 'precise.finish_ts', ], sorts: [selected.sort], limit: LIMIT, cursor, }, 'api.performance.service-entry-spans-table', true ); const consolidatedData = tableData?.map(row => { const user = row['user.username'] || row['user.email'] || row['user.ip'] || row['user.id']; return { ...row, 'user.display': user, }; }); const handleCursor: CursorHandler = (_cursor, pathname, query) => { navigate({ pathname, query: {...query, [CURSOR_NAME]: _cursor}, }); }; const cursorOffset = parseCursor(cursor)?.offset ?? 0; const totalNumSamples = cursorOffset; const handleViewSampledEvents = () => { if (!projectSlug) { return; } navigate({ pathname: `${location.pathname}events/`, query: { ...location.query, transaction: transactionName, project: `${eventView.project}`, }, }); }; return (
handleDropdownChange(opt.value)} /> {supportsInvestigationRule && ( )} {showViewSampledEventsButton && ( )}
renderHeadCell({ column, }), renderBodyCell: (column, row) => renderBodyCell(column, row, meta, projectSlug, location, organization), }} />
); } function renderBodyCell( column: Column, row: Row, meta: EventsMetaType | undefined, projectSlug: string | undefined, location: Location, organization: Organization ) { if (column.key === 'span_id') { return ( ); } if (column.key === 'profile.id') { return (
} to={{ pathname: `/organizations/${organization.slug}/profiling/profile/${projectSlug}/${row['profile.id']}/flamegraph/`, query: { referrer: 'performance', }, }} aria-label={t('View Profile')} disabled={!row['profile.id']} />
); } if (column.key === 'replayId') { return (
} to={{ pathname: `/organizations/${organization.slug}/replays/${row.replayId}/`, query: { referrer: 'performance', }, }} disabled={!row.replayId} aria-label={t('View Replay')} />
); } if (!meta || !meta?.fields) { return row[column.key]; } const renderer = getFieldRenderer(column.key, meta.fields, false); const rendered = renderer(row, { location, organization, unit: meta.units?.[column.key], }); return rendered; } // A wrapper component that handles the isLoading state. This will allow the component to not disappear when the data is loading. function CustomPagination({ pageLinks, onCursor, isLoading, }: { isLoading: boolean; onCursor: CursorHandler; pageLinks: string | undefined; }) { if (isLoading) { return ( {}} size={PAGINATION_CURSOR_SIZE} /> ); } return ( ); } // TODO: The span ops breakdown filter will not work here due to OTLP changes. // this may need to be adjusted in the future to handle the new breakdown filter that will replace it. function getOTelFilterOptions(): DropdownOption[] { return [ { sort: {kind: 'asc', field: 'span.duration'}, value: TransactionFilterOptions.FASTEST, label: t('Fastest Transactions'), }, { sort: {kind: 'desc', field: 'span.duration'}, value: TransactionFilterOptions.SLOW, label: t('Slow Transactions (p95)'), }, { sort: {kind: 'desc', field: 'span.duration'}, value: TransactionFilterOptions.OUTLIER, label: t('Outlier Transactions (p100)'), }, { sort: {kind: 'desc', field: 'timestamp'}, value: TransactionFilterOptions.RECENT, label: t('Recent Transactions'), }, ]; } function getOTelTransactionsListSort(location: Location): { options: DropdownOption[]; selected: DropdownOption; } { const sortOptions = getOTelFilterOptions(); const urlParam = decodeScalar( location.query.showTransactions, TransactionFilterOptions.SLOW ); const selectedSort = sortOptions.find(opt => opt.value === urlParam) || sortOptions[0]!; return {selected: selectedSort, options: sortOptions}; } const Header = styled('div')` display: grid; grid-template-columns: 1fr auto auto auto; margin-bottom: ${space(1)}; align-items: center; `; const StyledPagination = styled(Pagination)` margin: 0 0 0 ${space(1)}; `; const HeaderButtonWrapper = styled('div')` display: flex; `; const InvestigationRuleWrapper = styled('div')` margin-right: ${space(1)}; `;