From 7e2d5fae08f177caf3d52b3c55dce2636da8f8e6 Mon Sep 17 00:00:00 2001 From: Tony Xiao Date: Wed, 20 Oct 2021 17:26:14 -0400 Subject: [PATCH 1/5] feat(suspect-spans): Add suspect spans tab page This replaces the place holder for the spans tab. --- .../suspectSpans/suspectSpansQuery.tsx | 38 +++ .../utils/performance/suspectSpans/types.tsx | 33 +++ .../transactionSpans/content.tsx | 107 +++++++- .../transactionSpans/index.tsx | 10 +- .../transactionSpans/styles.tsx | 88 +++++++ .../transactionSpans/suspectSpanCard.tsx | 242 ++++++++++++++++++ .../transactionSpans/types.tsx | 33 +++ .../transactionSpans/utils.tsx | 61 ++++- .../performance/transactionSummary/utils.tsx | 11 +- static/app/views/performance/utils.tsx | 10 +- 10 files changed, 623 insertions(+), 10 deletions(-) create mode 100644 static/app/utils/performance/suspectSpans/suspectSpansQuery.tsx create mode 100644 static/app/utils/performance/suspectSpans/types.tsx create mode 100644 static/app/views/performance/transactionSummary/transactionSpans/styles.tsx create mode 100644 static/app/views/performance/transactionSummary/transactionSpans/suspectSpanCard.tsx create mode 100644 static/app/views/performance/transactionSummary/transactionSpans/types.tsx diff --git a/static/app/utils/performance/suspectSpans/suspectSpansQuery.tsx b/static/app/utils/performance/suspectSpans/suspectSpansQuery.tsx new file mode 100644 index 00000000000000..d0e0155baabca3 --- /dev/null +++ b/static/app/utils/performance/suspectSpans/suspectSpansQuery.tsx @@ -0,0 +1,38 @@ +import {ReactNode} from 'react'; +import omit from 'lodash/omit'; + +import GenericDiscoverQuery, { + DiscoverQueryProps, + GenericChildrenProps, +} from 'app/utils/discover/genericDiscoverQuery'; +import withApi from 'app/utils/withApi'; + +import {SuspectSpans} from './types'; + +type SuspectSpansProps = {}; + +type RequestProps = DiscoverQueryProps & SuspectSpansProps; + +type ChildrenProps = Omit, 'tableData'> & { + suspectSpans: SuspectSpans | null; +}; + +type Props = RequestProps & { + children: (props: ChildrenProps) => ReactNode; +}; + +function SuspectSpansQuery(props: Props) { + return ( + + route="events-spans-performance" + limit={4} + {...omit(props, 'children')} + > + {({tableData, ...rest}) => { + return props.children({suspectSpans: tableData, ...rest}); + }} + + ); +} + +export default withApi(SuspectSpansQuery); diff --git a/static/app/utils/performance/suspectSpans/types.tsx b/static/app/utils/performance/suspectSpans/types.tsx new file mode 100644 index 00000000000000..b71cecd7b98d51 --- /dev/null +++ b/static/app/utils/performance/suspectSpans/types.tsx @@ -0,0 +1,33 @@ +type ExampleSpan = { + id: string; + startTimestamp: number; + finishTimestamp: number; + exclusiveTime: number; +}; + +type ExampleTransaction = { + id: string; + description: string | null; + startTimestamp: number; + finishTimestamp: number; + nonOverlappingExclusiveTime: number; + spans: ExampleSpan[]; +}; + +export type SuspectSpan = { + projectId: number; + project: string; + transaction: string; + op: string; + group: string; + frequency: number; + count: number; + sumExclusiveTime: number; + p50ExclusiveTime: number; + p75ExclusiveTime: number; + p95ExclusiveTime: number; + p99ExclusiveTime: number; + examples: ExampleTransaction[]; +}; + +export type SuspectSpans = SuspectSpan[]; diff --git a/static/app/views/performance/transactionSummary/transactionSpans/content.tsx b/static/app/views/performance/transactionSummary/transactionSpans/content.tsx index 7f970dd8ca3c8e..b53ac77a8f72ad 100644 --- a/static/app/views/performance/transactionSummary/transactionSpans/content.tsx +++ b/static/app/views/performance/transactionSummary/transactionSpans/content.tsx @@ -1,16 +1,119 @@ +import {Fragment} from 'react'; +import {browserHistory} from 'react-router'; import {Location} from 'history'; +import omit from 'lodash/omit'; +import DropdownControl, {DropdownItem} from 'app/components/dropdownControl'; +import SearchBar from 'app/components/events/searchBar'; +import * as Layout from 'app/components/layouts/thirds'; +import LoadingIndicator from 'app/components/loadingIndicator'; +import {getParams} from 'app/components/organizations/globalSelectionHeader/getParams'; +import Pagination from 'app/components/pagination'; import {Organization} from 'app/types'; import EventView from 'app/utils/discover/eventView'; +import SuspectSpansQuery from 'app/utils/performance/suspectSpans/suspectSpansQuery'; +import {decodeScalar} from 'app/utils/queryString'; + +import {SetStateAction} from '../types'; +import {generateTransactionLink} from '../utils'; + +import {Actions} from './styles'; +import SuspectSpanCard from './suspectSpanCard'; +import {getSuspectSpanSortFromEventView, SPAN_SORT_OPTIONS} from './utils'; type Props = { location: Location; organization: Organization; eventView: EventView; + setError: SetStateAction; + transactionName: string; }; -function SpansContent(_props: Props) { - return

spans

; +function SpansContent(props: Props) { + const {location, organization, eventView, setError, transactionName} = props; + const query = decodeScalar(location.query.query, ''); + + function handleChange(key: string) { + return function (value: string) { + const queryParams = getParams({ + ...(location.query || {}), + [key]: value, + }); + + // do not propagate pagination when making a new search + const searchQueryParams = omit(queryParams, 'cursor'); + + browserHistory.push({ + ...location, + query: searchQueryParams, + }); + }; + } + + const sort = getSuspectSpanSortFromEventView(eventView); + + return ( + + + + + {SPAN_SORT_OPTIONS.map(option => ( + + {option.label} + + ))} + + + + {({suspectSpans, isLoading, error, pageLinks}) => { + if (isLoading) { + return ; + } + + if (error) { + setError(error); + return null; + } + + if (!suspectSpans?.length) { + // TODO: empty state + return null; + } + + return ( + + {suspectSpans.map(suspectSpan => ( + + ))} + + + ); + }} + + + ); } export default SpansContent; diff --git a/static/app/views/performance/transactionSummary/transactionSpans/index.tsx b/static/app/views/performance/transactionSummary/transactionSpans/index.tsx index f1eb0ff499e043..6e9d57524b2701 100644 --- a/static/app/views/performance/transactionSummary/transactionSpans/index.tsx +++ b/static/app/views/performance/transactionSummary/transactionSpans/index.tsx @@ -12,6 +12,8 @@ import PageLayout from '../pageLayout'; import Tab from '../tabs'; import SpansContent from './content'; +import {SpanSortOthers, SpanSortPercentiles} from './types'; +import {getSuspectSpanSortFromLocation} from './utils'; type Props = { location: Location; @@ -38,22 +40,24 @@ function TransactionSpans(props: Props) { function generateEventView(location: Location, transactionName: string): EventView { const query = decodeScalar(location.query.query, ''); const conditions = new MutableSearch(query); - // TODO: what should this event type be? conditions .setFilterValues('event.type', ['transaction']) .setFilterValues('transaction', [transactionName]); - return EventView.fromNewQueryWithLocation( + const eventView = EventView.fromNewQueryWithLocation( { id: undefined, version: 2, name: transactionName, - fields: ['count()'], + fields: [...Object.values(SpanSortOthers), ...Object.values(SpanSortPercentiles)], query: conditions.formatString(), projects: [], }, location ); + + const sort = getSuspectSpanSortFromLocation(location); + return eventView.withSorts([{field: sort.field, kind: 'desc'}]); } function getDocumentTitle(transactionName: string): string { diff --git a/static/app/views/performance/transactionSummary/transactionSpans/styles.tsx b/static/app/views/performance/transactionSummary/transactionSpans/styles.tsx new file mode 100644 index 00000000000000..2bc727c792210f --- /dev/null +++ b/static/app/views/performance/transactionSummary/transactionSpans/styles.tsx @@ -0,0 +1,88 @@ +import {ReactNode} from 'react'; +import styled from '@emotion/styled'; + +import {SectionHeading as _SectionHeading} from 'app/components/charts/styles'; +import {Panel} from 'app/components/panels'; +import {t} from 'app/locale'; +import overflowEllipsis from 'app/styles/overflowEllipsis'; +import space from 'app/styles/space'; + +export const Actions = styled('div')` + display: grid; + grid-gap: ${space(2)}; + grid-template-columns: 1fr min-content; + align-items: center; +`; + +export const UpperPanel = styled(Panel)` + padding: ${space(1.5)} ${space(3)}; + margin-top: ${space(3)}; + margin-bottom: 0; + border-bottom: 0; + border-bottom-left-radius: 0; + border-bottom-right-radius: 0; + + display: grid; + + grid-template-columns: 1fr; + grid-gap: ${space(1.5)}; + + @media (min-width: ${p => p.theme.breakpoints[1]}) { + grid-template-columns: auto repeat(3, max-content); + grid-gap: 48px; + } +`; + +export const LowerPanel = styled('div')` + > div { + border-top-left-radius: 0; + border-top-right-radius: 0; + } +`; + +type HeaderItemProps = { + label: string; + value: ReactNode; + align: 'left' | 'right'; +}; + +export function HeaderItem(props: HeaderItemProps) { + const {label, value, align} = props; + + return ( + + {label} + {value} + + ); +} + +export const HeaderItemContainer = styled('div')<{align: 'left' | 'right'}>` + ${overflowEllipsis}; + + @media (min-width: ${p => p.theme.breakpoints[1]}) { + text-align: ${p => p.align}; + } +`; + +const SectionHeading = styled(_SectionHeading)` + margin: 0; +`; + +const SectionValue = styled('h1')` + font-size: ${p => p.theme.headerFontSize}; + font-weight: normal; + line-height: 1.2; + color: ${p => p.theme.textColor}; + margin-bottom: 0; +`; + +export const SpanLabelContainer = styled('div')` + ${overflowEllipsis}; +`; + +const EmptyValueContainer = styled('span')` + color: ${p => p.theme.gray300}; +`; + +export const emptyValue = {t('n/a')}; diff --git a/static/app/views/performance/transactionSummary/transactionSpans/suspectSpanCard.tsx b/static/app/views/performance/transactionSummary/transactionSpans/suspectSpanCard.tsx new file mode 100644 index 00000000000000..f3ec472f2129a5 --- /dev/null +++ b/static/app/views/performance/transactionSummary/transactionSpans/suspectSpanCard.tsx @@ -0,0 +1,242 @@ +import {Fragment, ReactNode} from 'react'; +import {Location, LocationDescriptor, Query} from 'history'; + +import GridEditable, {COL_WIDTH_UNDEFINED} from 'app/components/gridEditable'; +import SortLink from 'app/components/gridEditable/sortLink'; +import Link from 'app/components/links/link'; +import Tooltip from 'app/components/tooltip'; +import {t} from 'app/locale'; +import {Organization} from 'app/types'; +import {defined} from 'app/utils'; +import {TableDataRow} from 'app/utils/discover/discoverQuery'; +import EventView from 'app/utils/discover/eventView'; +import {getFieldRenderer} from 'app/utils/discover/fieldRenderers'; +import {ColumnType, fieldAlignment} from 'app/utils/discover/fields'; +import {SuspectSpan} from 'app/utils/performance/suspectSpans/types'; + +import {PerformanceDuration} from '../../utils'; + +import { + emptyValue, + HeaderItem, + LowerPanel, + SpanLabelContainer, + UpperPanel, +} from './styles'; +import { + SpanSortOption, + SpanSortPercentiles, + SuspectSpanDataRow, + SuspectSpanTableColumn, + SuspectSpanTableColumnKeys, +} from './types'; +import {getSuspectSpanSortFromEventView} from './utils'; + +const SPANS_TABLE_COLUMN_ORDER: SuspectSpanTableColumn[] = [ + { + key: 'id', + name: 'Event ID', + width: COL_WIDTH_UNDEFINED, + }, + { + key: 'timestamp', + name: 'Timestamp', + width: COL_WIDTH_UNDEFINED, + }, + { + key: 'spanDuration', + name: 'Span Duration', + width: COL_WIDTH_UNDEFINED, + }, + { + key: 'repeated', + name: 'Repeated', + width: COL_WIDTH_UNDEFINED, + }, + { + key: 'cumulativeDuration', + name: 'Cumulative Duration', + width: COL_WIDTH_UNDEFINED, + }, +]; + +const SPANS_TABLE_COLUMN_TYPE: Partial> = { + id: 'string', + timestamp: 'date', + spanDuration: 'duration', + repeated: 'integer', + cumulativeDuration: 'duration', +}; + +type Props = { + location: Location; + organization: Organization; + suspectSpan: SuspectSpan; + generateTransactionLink: ( + organization: Organization, + tableData: TableDataRow, + query: Query, + hash?: string + ) => LocationDescriptor; + eventView: EventView; +}; + +export default function SuspectSpanEntry(props: Props) { + const {location, organization, suspectSpan, generateTransactionLink, eventView} = props; + + const examples = suspectSpan.examples.map(example => ({ + id: example.id, + project: suspectSpan.project, + timestamp: example.finishTimestamp, + spanDuration: example.nonOverlappingExclusiveTime, + repeated: example.spans.length, + cumulativeDuration: example.spans.reduce( + (duration, span) => duration + span.exclusiveTime, + 0 + ), + spans: example.spans, + })); + + return ( + + + } + align="left" + /> + + + + } + align="right" + /> + + + + + + ); +} + +const PERCENTILE_LABELS: Record = { + [SpanSortPercentiles.P50_EXCLUSIVE_TIME]: t('p50 Duration'), + [SpanSortPercentiles.P75_EXCLUSIVE_TIME]: t('p75 Duration'), + [SpanSortPercentiles.P95_EXCLUSIVE_TIME]: t('p95 Duration'), + [SpanSortPercentiles.P99_EXCLUSIVE_TIME]: t('p99 Duration'), +}; + +type PercentileDurationProps = { + sort: SpanSortOption; + suspectSpan: SuspectSpan; +}; + +function PercentileDuration(props: PercentileDurationProps) { + const {sort, suspectSpan} = props; + + return ( + + } + align="right" + /> + ); +} + +function renderHeadCell(column: SuspectSpanTableColumn, _index: number): ReactNode { + const align = fieldAlignment(column.key, SPANS_TABLE_COLUMN_TYPE[column.key]); + return ( + undefined} + /> + ); +} + +function renderBodyCellWithMeta( + location: Location, + organization: Organization, + generateTransactionLink: ( + organization: Organization, + tableData: TableDataRow, + query: Query, + hash?: string + ) => LocationDescriptor +) { + return (column: SuspectSpanTableColumn, dataRow: SuspectSpanDataRow): ReactNode => { + const fieldRenderer = getFieldRenderer(column.key, SPANS_TABLE_COLUMN_TYPE); + + let rendered = fieldRenderer(dataRow, {location, organization}); + + if (column.key === 'id') { + const worstSpan = dataRow.spans.length + ? dataRow.spans.reduce((worst, span) => + worst.exclusiveTime >= span.exclusiveTime ? worst : span + ) + : null; + const hash = worstSpan ? `#span-${worstSpan.id}` : undefined; + const target = generateTransactionLink(organization, dataRow, location.query, hash); + + rendered = {rendered}; + } + + return rendered; + }; +} + +type SpanLabelProps = { + span: SuspectSpan; +}; + +function SpanLabel(props: SpanLabelProps) { + const {span} = props; + + const example = span.examples.find(ex => defined(ex.description)); + + return ( + + + {span.op} - {example?.description ?? emptyValue} + + + ); +} diff --git a/static/app/views/performance/transactionSummary/transactionSpans/types.tsx b/static/app/views/performance/transactionSummary/transactionSpans/types.tsx new file mode 100644 index 00000000000000..4e67c70c89068f --- /dev/null +++ b/static/app/views/performance/transactionSummary/transactionSpans/types.tsx @@ -0,0 +1,33 @@ +import {GridColumnOrder} from 'app/components/gridEditable'; + +export enum SpanSortPercentiles { + P50_EXCLUSIVE_TIME = 'p50ExclusiveTime', + P75_EXCLUSIVE_TIME = 'p75ExclusiveTime', + P95_EXCLUSIVE_TIME = 'p95ExclusiveTime', + P99_EXCLUSIVE_TIME = 'p99ExclusiveTime', +} + +export enum SpanSortOthers { + COUNT = 'count', + SUM_EXCLUSIVE_TIME = 'sumExclusiveTime', +} + +export type SpanSort = SpanSortPercentiles | SpanSortOthers; + +export type SpanSortOption = { + prefix: string; + label: string; + field: SpanSort; +}; + +export type SuspectSpanTableColumnKeys = + | 'id' + | 'timestamp' + | 'spanDuration' + | 'repeated' + | 'cumulativeDuration' + | 'spans'; + +export type SuspectSpanTableColumn = GridColumnOrder; + +export type SuspectSpanDataRow = Record; diff --git a/static/app/views/performance/transactionSummary/transactionSpans/utils.tsx b/static/app/views/performance/transactionSummary/transactionSpans/utils.tsx index 28401a8cd213c9..e19ef4c04a6af8 100644 --- a/static/app/views/performance/transactionSummary/transactionSpans/utils.tsx +++ b/static/app/views/performance/transactionSummary/transactionSpans/utils.tsx @@ -1,4 +1,10 @@ -import {Query} from 'history'; +import {Location, Query} from 'history'; + +import {t} from 'app/locale'; +import EventView from 'app/utils/discover/eventView'; +import {decodeScalar} from 'app/utils/queryString'; + +import {SpanSortOption, SpanSortOthers, SpanSortPercentiles} from './types'; export function generateSpansRoute({orgSlug}: {orgSlug: String}): string { return `/organizations/${orgSlug}/performance/summary/spans/`; @@ -32,3 +38,56 @@ export function spansRouteWithQuery({ }, }; } + +export const SPAN_SORT_OPTIONS: SpanSortOption[] = [ + { + prefix: t('Percentile'), + label: t('p50'), + field: SpanSortPercentiles.P50_EXCLUSIVE_TIME, + }, + { + prefix: t('Percentile'), + label: t('p75'), + field: SpanSortPercentiles.P75_EXCLUSIVE_TIME, + }, + { + prefix: t('Percentile'), + label: t('p95'), + field: SpanSortPercentiles.P95_EXCLUSIVE_TIME, + }, + { + prefix: t('Percentile'), + label: t('p99'), + field: SpanSortPercentiles.P99_EXCLUSIVE_TIME, + }, + { + prefix: t('Total'), + label: t('Cumulative Duration'), + field: SpanSortOthers.SUM_EXCLUSIVE_TIME, + }, + { + prefix: t('Total'), + label: t('Occurrences'), + field: SpanSortOthers.COUNT, + }, +]; + +const DEFAULT_SORT = SpanSortPercentiles.P75_EXCLUSIVE_TIME; + +function getSuspectSpanSort(sort: string): SpanSortOption { + const selected = SPAN_SORT_OPTIONS.find(option => option.field === sort); + if (selected) { + return selected; + } + return SPAN_SORT_OPTIONS.find(option => option.field === DEFAULT_SORT)!; +} + +export function getSuspectSpanSortFromLocation(location: Location): SpanSortOption { + const sort = decodeScalar(location?.query?.sort) ?? DEFAULT_SORT; + return getSuspectSpanSort(sort); +} + +export function getSuspectSpanSortFromEventView(eventView: EventView): SpanSortOption { + const sort = eventView.sorts.length ? eventView.sorts[0].field : DEFAULT_SORT; + return getSuspectSpanSort(sort); +} diff --git a/static/app/views/performance/transactionSummary/utils.tsx b/static/app/views/performance/transactionSummary/utils.tsx index 1afa77056e84b1..59c9bfc8a8dfca 100644 --- a/static/app/views/performance/transactionSummary/utils.tsx +++ b/static/app/views/performance/transactionSummary/utils.tsx @@ -85,10 +85,17 @@ export function generateTransactionLink(transactionName: string) { return ( organization: Organization, tableRow: TableDataRow, - query: Query + query: Query, + hash?: string ): LocationDescriptor => { const eventSlug = generateEventSlug(tableRow); - return getTransactionDetailsUrl(organization, eventSlug, transactionName, query); + return getTransactionDetailsUrl( + organization, + eventSlug, + transactionName, + query, + hash + ); }; } diff --git a/static/app/views/performance/utils.tsx b/static/app/views/performance/utils.tsx index bcf374d95a3b87..e2431d5b7db800 100644 --- a/static/app/views/performance/utils.tsx +++ b/static/app/views/performance/utils.tsx @@ -120,15 +120,21 @@ export function getTransactionDetailsUrl( organization: OrganizationSummary, eventSlug: string, transaction: string, - query: Query + query: Query, + hash?: string ): LocationDescriptor { - return { + const target = { pathname: `/organizations/${organization.slug}/performance/${eventSlug}/`, query: { ...query, transaction, }, + hash, }; + if (!defined(hash)) { + delete target.hash; + } + return target; } export function getTransactionComparisonUrl({ From efc01b48ce0a8329401fffe7ade1091779c4270b Mon Sep 17 00:00:00 2001 From: Tony Xiao Date: Thu, 21 Oct 2021 15:12:55 -0400 Subject: [PATCH 2/5] starting on RTL tests --- .../utils/performance/suspectSpans/types.tsx | 4 +- .../transactionSpans/content.tsx | 11 ++- .../transactionSpans/suspectSpanCard.tsx | 4 +- .../suspectSpans/suspectSpansQuery.spec.tsx | 43 +++++++++ .../transactionSpans/index.spec.tsx | 95 +++++++++++++++++++ .../performance/transactionSpans/utils.tsx | 62 ++++++++++++ 6 files changed, 211 insertions(+), 8 deletions(-) create mode 100644 tests/js/spec/utils/performance/suspectSpans/suspectSpansQuery.spec.tsx create mode 100644 tests/js/spec/views/performance/transactionSpans/index.spec.tsx create mode 100644 tests/js/spec/views/performance/transactionSpans/utils.tsx diff --git a/static/app/utils/performance/suspectSpans/types.tsx b/static/app/utils/performance/suspectSpans/types.tsx index b71cecd7b98d51..719ef31c5ebf76 100644 --- a/static/app/utils/performance/suspectSpans/types.tsx +++ b/static/app/utils/performance/suspectSpans/types.tsx @@ -1,11 +1,11 @@ -type ExampleSpan = { +export type ExampleSpan = { id: string; startTimestamp: number; finishTimestamp: number; exclusiveTime: number; }; -type ExampleTransaction = { +export type ExampleTransaction = { id: string; description: string | null; startTimestamp: number; diff --git a/static/app/views/performance/transactionSummary/transactionSpans/content.tsx b/static/app/views/performance/transactionSummary/transactionSpans/content.tsx index b53ac77a8f72ad..89a12e697b7b79 100644 --- a/static/app/views/performance/transactionSummary/transactionSpans/content.tsx +++ b/static/app/views/performance/transactionSummary/transactionSpans/content.tsx @@ -81,15 +81,18 @@ function SpansContent(props: Props) { eventView={eventView} > {({suspectSpans, isLoading, error, pageLinks}) => { - if (isLoading) { - return ; - } - if (error) { setError(error); return null; } + // make sure to clear the clear the error message + setError(undefined); + + if (isLoading) { + return ; + } + if (!suspectSpans?.length) { // TODO: empty state return null; diff --git a/static/app/views/performance/transactionSummary/transactionSpans/suspectSpanCard.tsx b/static/app/views/performance/transactionSummary/transactionSpans/suspectSpanCard.tsx index f3ec472f2129a5..6e40ec58519c12 100644 --- a/static/app/views/performance/transactionSummary/transactionSpans/suspectSpanCard.tsx +++ b/static/app/views/performance/transactionSummary/transactionSpans/suspectSpanCard.tsx @@ -99,7 +99,7 @@ export default function SuspectSpanEntry(props: Props) { return ( - + } @@ -125,7 +125,7 @@ export default function SuspectSpanEntry(props: Props) { align="right" /> - + + {() => null} + + ); + + expect(getMock).toHaveBeenCalledTimes(1); + }); +}); diff --git a/tests/js/spec/views/performance/transactionSpans/index.spec.tsx b/tests/js/spec/views/performance/transactionSpans/index.spec.tsx new file mode 100644 index 00000000000000..d40710d63bfe17 --- /dev/null +++ b/tests/js/spec/views/performance/transactionSpans/index.spec.tsx @@ -0,0 +1,95 @@ +import {initializeOrg} from 'sentry-test/initializeOrg'; +import {act, mountWithTheme} from 'sentry-test/reactTestingLibrary'; + +import ProjectsStore from 'app/stores/projectsStore'; +import TransactionSpans from 'app/views/performance/transactionSummary/transactionSpans'; + +import {makeSuspectSpan} from './utils'; + +function initializeData({query} = {query: {}}) { + const features = ['performance-view', 'performance-suspect-spans-view']; + // @ts-expect-error + const organization = TestStubs.Organization({ + features, + // @ts-expect-error + projects: [TestStubs.Project()], + }); + // @ts-expect-error + const initialData = initializeOrg({ + organization, + router: { + location: { + query: { + transaction: 'Test Transaction', + project: '1', + ...query, + }, + }, + }, + }); + act(() => ProjectsStore.loadInitialData(initialData.organization.projects)); + return initialData; +} + +describe('Performance > Transaction Spans', function () { + beforeEach(function () { + // @ts-expect-error + MockApiClient.addMockResponse({ + url: '/organizations/test-org/events-spans-performance/', + body: [ + makeSuspectSpan({ + op: 'op1', + group: 'aaaaaaaa', + examples: [ + { + id: 'abababab', + description: 'span-1', + spans: [{id: 'ababab11'}, {id: 'ababab22'}], + }, + { + id: 'acacacac', + description: 'span-2', + spans: [{id: 'acacac11'}, {id: 'acacac22'}], + }, + ], + }), + makeSuspectSpan({ + op: 'op2', + group: 'bbbbbbbb', + examples: [ + { + id: 'bcbcbcbc', + description: 'span-3', + spans: [{id: 'bcbcbc11'}, {id: 'bcbcbc11'}], + }, + { + id: 'bdbdbdbd', + description: 'span-4', + spans: [{id: 'bdbdbd11'}, {id: 'bdbdbd22'}], + }, + ], + }), + ], + }); + }); + + afterEach(function () { + // @ts-expect-error + MockApiClient.clearMockResponses(); + ProjectsStore.reset(); + }); + + it('renders basic UI elements', async function () { + const initialData = initializeData(); + mountWithTheme( + , + initialData.routerContext + ); + + // const cardUpper = await screen.findByTestId('suspect-card-upper'); + // console.log(cardUpper); + }); +}); diff --git a/tests/js/spec/views/performance/transactionSpans/utils.tsx b/tests/js/spec/views/performance/transactionSpans/utils.tsx new file mode 100644 index 00000000000000..38c97b87547223 --- /dev/null +++ b/tests/js/spec/views/performance/transactionSpans/utils.tsx @@ -0,0 +1,62 @@ +import { + ExampleSpan, + ExampleTransaction, + SuspectSpan, +} from 'app/utils/performance/suspectSpans/types'; + +type SpanOpt = { + id: string; +}; + +type ExampleOpt = { + id: string; + description: string; + spans: SpanOpt[]; +}; + +type SuspectOpt = { + op: string; + group: string; + examples: ExampleOpt[]; +}; + +function makeSpan(opt: SpanOpt): ExampleSpan { + const {id} = opt; + return { + id, + startTimestamp: 10100, + finishTimestamp: 10200, + exclusiveTime: 100, + }; +} + +function makeExample(opt: ExampleOpt): ExampleTransaction { + const {id, description, spans} = opt; + return { + id, + description, + startTimestamp: 10000, + finishTimestamp: 12000, + nonOverlappingExclusiveTime: 2000, + spans: spans.map(makeSpan), + }; +} + +export function makeSuspectSpan(opt: SuspectOpt): SuspectSpan { + const {op, group, examples} = opt; + return { + projectId: 1, + project: 'bar', + transaction: 'transaction-1', + op, + group, + frequency: 1, + count: 1, + sumExclusiveTime: 1, + p50ExclusiveTime: 1, + p75ExclusiveTime: 1, + p95ExclusiveTime: 1, + p99ExclusiveTime: 1, + examples: examples.map(makeExample), + }; +} From cb90b1ee66c3b5e4587891752a996d67383acb76 Mon Sep 17 00:00:00 2001 From: Tony Xiao Date: Thu, 21 Oct 2021 16:50:12 -0400 Subject: [PATCH 3/5] make test work --- .../transactionSpans/suspectSpanCard.tsx | 9 +- .../transactionSpans/index.spec.tsx | 116 ++++++++++++------ 2 files changed, 80 insertions(+), 45 deletions(-) diff --git a/static/app/views/performance/transactionSummary/transactionSpans/suspectSpanCard.tsx b/static/app/views/performance/transactionSummary/transactionSpans/suspectSpanCard.tsx index 6e40ec58519c12..c25f6239c57289 100644 --- a/static/app/views/performance/transactionSummary/transactionSpans/suspectSpanCard.tsx +++ b/static/app/views/performance/transactionSummary/transactionSpans/suspectSpanCard.tsx @@ -1,4 +1,4 @@ -import {Fragment, ReactNode} from 'react'; +import {ReactNode} from 'react'; import {Location, LocationDescriptor, Query} from 'history'; import GridEditable, {COL_WIDTH_UNDEFINED} from 'app/components/gridEditable'; @@ -98,8 +98,8 @@ export default function SuspectSpanEntry(props: Props) { })); return ( - - +
+ } @@ -141,7 +141,7 @@ export default function SuspectSpanEntry(props: Props) { location={location} /> - +
); } @@ -204,7 +204,6 @@ function renderBodyCellWithMeta( ) { return (column: SuspectSpanTableColumn, dataRow: SuspectSpanDataRow): ReactNode => { const fieldRenderer = getFieldRenderer(column.key, SPANS_TABLE_COLUMN_TYPE); - let rendered = fieldRenderer(dataRow, {location, organization}); if (column.key === 'id') { diff --git a/tests/js/spec/views/performance/transactionSpans/index.spec.tsx b/tests/js/spec/views/performance/transactionSpans/index.spec.tsx index d40710d63bfe17..dd2786120783de 100644 --- a/tests/js/spec/views/performance/transactionSpans/index.spec.tsx +++ b/tests/js/spec/views/performance/transactionSpans/index.spec.tsx @@ -1,7 +1,8 @@ import {initializeOrg} from 'sentry-test/initializeOrg'; -import {act, mountWithTheme} from 'sentry-test/reactTestingLibrary'; +import {act, mountWithTheme, screen, within} from 'sentry-test/reactTestingLibrary'; import ProjectsStore from 'app/stores/projectsStore'; +import {getShortEventId} from 'app/utils/events'; import TransactionSpans from 'app/views/performance/transactionSummary/transactionSpans'; import {makeSuspectSpan} from './utils'; @@ -27,49 +28,71 @@ function initializeData({query} = {query: {}}) { }, }, }); - act(() => ProjectsStore.loadInitialData(initialData.organization.projects)); + act(() => void ProjectsStore.loadInitialData(initialData.organization.projects)); return initialData; } +const spans = [ + { + op: 'op1', + group: 'aaaaaaaaaaaaaaaa', + examples: [ + { + id: 'abababababababab', + description: 'span-1', + spans: [{id: 'ababab11'}, {id: 'ababab22'}], + }, + { + id: 'acacacacacacacac', + description: 'span-2', + spans: [{id: 'acacac11'}, {id: 'acacac22'}], + }, + ], + }, + { + op: 'op2', + group: 'bbbbbbbbbbbbbbbb', + examples: [ + { + id: 'bcbcbcbcbcbcbcbc', + description: 'span-3', + spans: [{id: 'bcbcbc11'}, {id: 'bcbcbc11'}], + }, + { + id: 'bdbdbdbdbdbdbdbd', + description: 'span-4', + spans: [{id: 'bdbdbd11'}, {id: 'bdbdbd22'}], + }, + ], + }, +]; + describe('Performance > Transaction Spans', function () { beforeEach(function () { // @ts-expect-error MockApiClient.addMockResponse({ - url: '/organizations/test-org/events-spans-performance/', - body: [ - makeSuspectSpan({ - op: 'op1', - group: 'aaaaaaaa', - examples: [ - { - id: 'abababab', - description: 'span-1', - spans: [{id: 'ababab11'}, {id: 'ababab22'}], - }, - { - id: 'acacacac', - description: 'span-2', - spans: [{id: 'acacac11'}, {id: 'acacac22'}], - }, - ], - }), - makeSuspectSpan({ - op: 'op2', - group: 'bbbbbbbb', - examples: [ - { - id: 'bcbcbcbc', - description: 'span-3', - spans: [{id: 'bcbcbc11'}, {id: 'bcbcbc11'}], - }, - { - id: 'bdbdbdbd', - description: 'span-4', - spans: [{id: 'bdbdbd11'}, {id: 'bdbdbd22'}], - }, - ], - }), - ], + url: '/organizations/org-slug/projects/', + body: [], + }); + // @ts-expect-error + MockApiClient.addMockResponse({ + url: '/prompts-activity/', + body: {}, + }); + // @ts-expect-error + MockApiClient.addMockResponse({ + url: '/organizations/org-slug/sdk-updates/', + body: [], + }); + // @ts-expect-error + MockApiClient.addMockResponse({ + url: '/organizations/org-slug/events-has-measurements/', + body: {measurements: false}, + }); + // @ts-expect-error + MockApiClient.addMockResponse({ + url: '/organizations/org-slug/events-spans-performance/', + body: spans.map(makeSuspectSpan), }); }); @@ -86,10 +109,23 @@ describe('Performance > Transaction Spans', function () { organization={initialData.organization} location={initialData.router.location} />, - initialData.routerContext + {context: initialData.routerContext} ); - // const cardUpper = await screen.findByTestId('suspect-card-upper'); - // console.log(cardUpper); + const cards = await screen.findAllByTestId('suspect-card'); + expect(cards).toHaveLength(2); + for (let i = 0; i < cards.length; i++) { + const card = cards[i]; + + // these headers should be present by default + expect(await within(card).findByText('Span Operation')).toBeTruthy(); + expect(await within(card).findByText('p75 Duration')).toBeTruthy(); + expect(await within(card).findByText('Frequency')).toBeTruthy(); + expect(await within(card).findByText('Total Cumulative Duration')).toBeTruthy(); + + for (const example of spans[i].examples) { + expect(await within(card).findByText(getShortEventId(example.id))).toBeTruthy(); + } + } }); }); From 71944e8ee97e4997cfebe6d3accd7d602812ee39 Mon Sep 17 00:00:00 2001 From: Tony Xiao Date: Thu, 21 Oct 2021 17:18:21 -0400 Subject: [PATCH 4/5] small fixups --- .../transactionSpans/suspectSpanCard.tsx | 3 +- .../transactionSpans/index.spec.tsx | 65 ++++++++++++++++++- .../performance/transactionSpans/utils.tsx | 62 ------------------ 3 files changed, 64 insertions(+), 66 deletions(-) delete mode 100644 tests/js/spec/views/performance/transactionSpans/utils.tsx diff --git a/static/app/views/performance/transactionSummary/transactionSpans/suspectSpanCard.tsx b/static/app/views/performance/transactionSummary/transactionSpans/suspectSpanCard.tsx index c25f6239c57289..7c155eb467da97 100644 --- a/static/app/views/performance/transactionSummary/transactionSpans/suspectSpanCard.tsx +++ b/static/app/views/performance/transactionSummary/transactionSpans/suspectSpanCard.tsx @@ -87,7 +87,8 @@ export default function SuspectSpanEntry(props: Props) { const examples = suspectSpan.examples.map(example => ({ id: example.id, project: suspectSpan.project, - timestamp: example.finishTimestamp, + // finish timestamp is in seconds but want milliseconds + timestamp: example.finishTimestamp * 1000, spanDuration: example.nonOverlappingExclusiveTime, repeated: example.spans.length, cumulativeDuration: example.spans.reduce( diff --git a/tests/js/spec/views/performance/transactionSpans/index.spec.tsx b/tests/js/spec/views/performance/transactionSpans/index.spec.tsx index dd2786120783de..0d7c2b30c60abc 100644 --- a/tests/js/spec/views/performance/transactionSpans/index.spec.tsx +++ b/tests/js/spec/views/performance/transactionSpans/index.spec.tsx @@ -3,10 +3,13 @@ import {act, mountWithTheme, screen, within} from 'sentry-test/reactTestingLibra import ProjectsStore from 'app/stores/projectsStore'; import {getShortEventId} from 'app/utils/events'; +import { + ExampleSpan, + ExampleTransaction, + SuspectSpan, +} from 'app/utils/performance/suspectSpans/types'; import TransactionSpans from 'app/views/performance/transactionSummary/transactionSpans'; -import {makeSuspectSpan} from './utils'; - function initializeData({query} = {query: {}}) { const features = ['performance-view', 'performance-suspect-spans-view']; // @ts-expect-error @@ -15,7 +18,6 @@ function initializeData({query} = {query: {}}) { // @ts-expect-error projects: [TestStubs.Project()], }); - // @ts-expect-error const initialData = initializeOrg({ organization, router: { @@ -32,6 +34,63 @@ function initializeData({query} = {query: {}}) { return initialData; } +type SpanOpt = { + id: string; +}; + +type ExampleOpt = { + id: string; + description: string; + spans: SpanOpt[]; +}; + +type SuspectOpt = { + op: string; + group: string; + examples: ExampleOpt[]; +}; + +function makeSpan(opt: SpanOpt): ExampleSpan { + const {id} = opt; + return { + id, + startTimestamp: 10100, + finishTimestamp: 10200, + exclusiveTime: 100, + }; +} + +function makeExample(opt: ExampleOpt): ExampleTransaction { + const {id, description, spans} = opt; + return { + id, + description, + startTimestamp: 10000, + finishTimestamp: 12000, + nonOverlappingExclusiveTime: 2000, + spans: spans.map(makeSpan), + }; +} + +export function makeSuspectSpan(opt: SuspectOpt): SuspectSpan { + const {op, group, examples} = opt; + return { + projectId: 1, + project: 'bar', + transaction: 'transaction-1', + op, + group, + frequency: 1, + count: 1, + sumExclusiveTime: 1, + p50ExclusiveTime: 1, + p75ExclusiveTime: 1, + p95ExclusiveTime: 1, + p99ExclusiveTime: 1, + examples: examples.map(makeExample), + }; +} + const spans = [ { op: 'op1', diff --git a/tests/js/spec/views/performance/transactionSpans/utils.tsx b/tests/js/spec/views/performance/transactionSpans/utils.tsx deleted file mode 100644 index 38c97b87547223..00000000000000 --- a/tests/js/spec/views/performance/transactionSpans/utils.tsx +++ /dev/null @@ -1,62 +0,0 @@ -import { - ExampleSpan, - ExampleTransaction, - SuspectSpan, -} from 'app/utils/performance/suspectSpans/types'; - -type SpanOpt = { - id: string; -}; - -type ExampleOpt = { - id: string; - description: string; - spans: SpanOpt[]; -}; - -type SuspectOpt = { - op: string; - group: string; - examples: ExampleOpt[]; -}; - -function makeSpan(opt: SpanOpt): ExampleSpan { - const {id} = opt; - return { - id, - startTimestamp: 10100, - finishTimestamp: 10200, - exclusiveTime: 100, - }; -} - -function makeExample(opt: ExampleOpt): ExampleTransaction { - const {id, description, spans} = opt; - return { - id, - description, - startTimestamp: 10000, - finishTimestamp: 12000, - nonOverlappingExclusiveTime: 2000, - spans: spans.map(makeSpan), - }; -} - -export function makeSuspectSpan(opt: SuspectOpt): SuspectSpan { - const {op, group, examples} = opt; - return { - projectId: 1, - project: 'bar', - transaction: 'transaction-1', - op, - group, - frequency: 1, - count: 1, - sumExclusiveTime: 1, - p50ExclusiveTime: 1, - p75ExclusiveTime: 1, - p95ExclusiveTime: 1, - p99ExclusiveTime: 1, - examples: examples.map(makeExample), - }; -} From b18c1b751332bb0effaa99165827daea55da1ee0 Mon Sep 17 00:00:00 2001 From: Tony Xiao Date: Fri, 22 Oct 2021 11:40:07 -0400 Subject: [PATCH 5/5] use toBeInTheDocument --- .../performance/transactionSpans/index.spec.tsx | 14 +++++++++----- 1 file changed, 9 insertions(+), 5 deletions(-) diff --git a/tests/js/spec/views/performance/transactionSpans/index.spec.tsx b/tests/js/spec/views/performance/transactionSpans/index.spec.tsx index 0d7c2b30c60abc..f554ed64b206f8 100644 --- a/tests/js/spec/views/performance/transactionSpans/index.spec.tsx +++ b/tests/js/spec/views/performance/transactionSpans/index.spec.tsx @@ -177,13 +177,17 @@ describe('Performance > Transaction Spans', function () { const card = cards[i]; // these headers should be present by default - expect(await within(card).findByText('Span Operation')).toBeTruthy(); - expect(await within(card).findByText('p75 Duration')).toBeTruthy(); - expect(await within(card).findByText('Frequency')).toBeTruthy(); - expect(await within(card).findByText('Total Cumulative Duration')).toBeTruthy(); + expect(await within(card).findByText('Span Operation')).toBeInTheDocument(); + expect(await within(card).findByText('p75 Duration')).toBeInTheDocument(); + expect(await within(card).findByText('Frequency')).toBeInTheDocument(); + expect( + await within(card).findByText('Total Cumulative Duration') + ).toBeInTheDocument(); for (const example of spans[i].examples) { - expect(await within(card).findByText(getShortEventId(example.id))).toBeTruthy(); + expect( + await within(card).findByText(getShortEventId(example.id)) + ).toBeInTheDocument(); } } });