Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
38 changes: 38 additions & 0 deletions static/app/utils/performance/suspectSpans/suspectSpansQuery.tsx
Original file line number Diff line number Diff line change
@@ -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<GenericChildrenProps<SuspectSpansProps>, 'tableData'> & {
suspectSpans: SuspectSpans | null;
};

type Props = RequestProps & {
children: (props: ChildrenProps) => ReactNode;
};

function SuspectSpansQuery(props: Props) {
return (
<GenericDiscoverQuery<SuspectSpans, SuspectSpansProps>
route="events-spans-performance"
limit={4}
{...omit(props, 'children')}
>
{({tableData, ...rest}) => {
return props.children({suspectSpans: tableData, ...rest});
}}
</GenericDiscoverQuery>
);
}

export default withApi(SuspectSpansQuery);
33 changes: 33 additions & 0 deletions static/app/utils/performance/suspectSpans/types.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
export type ExampleSpan = {
id: string;
startTimestamp: number;
finishTimestamp: number;
exclusiveTime: number;
};

export 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[];
Original file line number Diff line number Diff line change
@@ -1,16 +1,122 @@
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<string | undefined>;
transactionName: string;
};

function SpansContent(_props: Props) {
return <p>spans</p>;
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 (
<Layout.Main fullWidth>
<Actions>
<SearchBar
organization={organization}
projectIds={eventView.project}
query={query}
fields={eventView.fields}
onSearch={handleChange('query')}
/>
<DropdownControl buttonProps={{prefix: sort.prefix}} label={sort.label}>
{SPAN_SORT_OPTIONS.map(option => (
<DropdownItem
key={option.field}
eventKey={option.field}
isActive={option.field === sort.field}
onSelect={handleChange('sort')}
>
{option.label}
</DropdownItem>
))}
</DropdownControl>
</Actions>
<SuspectSpansQuery
location={location}
orgSlug={organization.slug}
eventView={eventView}
>
{({suspectSpans, isLoading, error, pageLinks}) => {
if (error) {
setError(error);
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Side note (doesn't really matter in this PR):
Instead of having to pass this in, I've also made the pageError context now along with an alert. You can just put the provider and alert in the topmost level, and use the hook here without needing it in props anymore.

return null;
}

// make sure to clear the clear the error message
setError(undefined);

if (isLoading) {
return <LoadingIndicator />;
}

if (!suspectSpans?.length) {
// TODO: empty state
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yep. Was just coming to look for this but I see it's already a TODO, I'd just actually put a EmptyStateWarning or something basic until we figure out what empty state we actually want.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Will do that in a follow up

return null;
}

return (
<Fragment>
{suspectSpans.map(suspectSpan => (
<SuspectSpanCard
key={`${suspectSpan.op}-${suspectSpan.group}`}
location={location}
organization={organization}
suspectSpan={suspectSpan}
generateTransactionLink={generateTransactionLink(transactionName)}
eventView={eventView}
/>
))}
<Pagination pageLinks={pageLinks} />
</Fragment>
);
}}
</SuspectSpansQuery>
</Layout.Main>
);
}

export default SpansContent;
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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 {
Expand Down
Original file line number Diff line number Diff line change
@@ -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 (
<HeaderItemContainer align={align}>
<SectionHeading>{label}</SectionHeading>
<SectionValue>{value}</SectionValue>
</HeaderItemContainer>
);
}

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 = <EmptyValueContainer>{t('n/a')}</EmptyValueContainer>;
Loading