Skip to content
Open
Show file tree
Hide file tree
Changes from 4 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
Original file line number Diff line number Diff line change
@@ -0,0 +1,92 @@
import type {ComponentProps} from 'react';
import {OrganizationFixture} from 'sentry-fixture/organization';

import {render, screen, waitFor} from 'sentry-test/reactTestingLibrary';

import {Dataset, EventTypes} from 'sentry/views/alerts/rules/metric/types';
import {AttributeComparisonSection} from 'sentry/views/issueDetails/streamline/sidebar/attributeComparisonSection';

describe('AttributeComparisonSection', () => {
const organization = OrganizationFixture();
const openPeriodStart = '2024-01-01T00:00:00Z';
const openPeriodEnd = '2024-01-01T00:10:00Z';

const defaultProps: ComponentProps<typeof AttributeComparisonSection> = {

Check failure on line 14 in static/app/views/issueDetails/streamline/sidebar/attributeComparisonSection.spec.tsx

View workflow job for this annotation

GitHub Actions / @typescript/native-preview

Property 'isOpenPeriodLoading' is missing in type '{ openPeriodStart: string; openPeriodEnd: string; projectId: number; snubaQuery: { aggregate: string; dataset: Dataset.ERRORS; eventTypes: EventTypes.ERROR[]; id: string; query: string; timeWindow: number; }; }' but required in type 'AttributeComparisonSectionProps'.

Check failure on line 14 in static/app/views/issueDetails/streamline/sidebar/attributeComparisonSection.spec.tsx

View workflow job for this annotation

GitHub Actions / typescript

Property 'isOpenPeriodLoading' is missing in type '{ openPeriodStart: string; openPeriodEnd: string; projectId: number; snubaQuery: { aggregate: string; dataset: Dataset.ERRORS; eventTypes: EventTypes.ERROR[]; id: string; query: string; timeWindow: number; }; }' but required in type 'AttributeComparisonSectionProps'.
openPeriodStart,
openPeriodEnd,
projectId: 1,
snubaQuery: {
aggregate: 'count()',
dataset: Dataset.ERRORS,
eventTypes: [EventTypes.ERROR],
id: '1',
query: 'is:unresolved event.type:error',
timeWindow: 60,
},
};

beforeEach(() => {
MockApiClient.clearMockResponses();
});

it('renders a View All link and requests ranked attributes', async () => {
const rankedAttributesRequest = MockApiClient.addMockResponse({
url: `/organizations/${organization.slug}/trace-items/attributes/ranked/`,
method: 'GET',
body: {
cohort1Total: 0,
cohort2Total: 0,
rankedAttributes: [],
},
});

render(<AttributeComparisonSection {...defaultProps} />, {organization});

await waitFor(() => {
expect(rankedAttributesRequest).toHaveBeenCalled();
});

const [requestUrl, requestOptions] = rankedAttributesRequest.mock.calls[0]!;
expect(requestUrl).toBe(
`/organizations/${organization.slug}/trace-items/attributes/ranked/`
);
expect(requestOptions).toEqual(
expect.objectContaining({
query: expect.objectContaining({
project: [1],
start: '2023-12-25T00:00:00.000',
end: '2024-01-01T00:10:00.000',
dataset: 'spans',
function: defaultProps.snubaQuery.aggregate,
above: 1,
sampling: 'NORMAL',
aggregateExtrapolation: '1',
query_1: expect.stringContaining('timestamp:>=2024-01-01T00:00:00'),
query_2: 'is:unresolved event.type:error',
}),
})
);

const viewAllLink = screen.getByRole('button', {name: 'View All'});
const href = viewAllLink.getAttribute('href');
expect(href).toBeTruthy();

const parsedUrl = new URL(href!, 'http://localhost');
expect(parsedUrl.pathname).toBe(
`/organizations/${organization.slug}/explore/traces/`
);
expect(parsedUrl.searchParams.get('table')).toBe('attribute_breakdowns');
expect(parsedUrl.searchParams.get('mode')).toBe('samples');
expect(parsedUrl.searchParams.get('project')).toBe('1');
expect(parsedUrl.searchParams.get('query')).toBe(defaultProps.snubaQuery.query);
expect(parsedUrl.searchParams.get('start')).toBe('2023-12-25T00:00:00.000');
expect(parsedUrl.searchParams.get('end')).toBe('2024-01-01T00:10:00.000');

const chartSelection = JSON.parse(parsedUrl.searchParams.get('chartSelection')!);
expect(chartSelection).toEqual({
chartIndex: 0,
range: [new Date(openPeriodStart).getTime(), new Date(openPeriodEnd).getTime()],
panelId: 'grid--\u0000series\u00000\u00000',
});
});
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,201 @@
import {Fragment, useMemo, useState} from 'react';
import {useTheme} from '@emotion/react';
import styled from '@emotion/styled';

import {LinkButton} from '@sentry/scraps/button';
import {Flex, Grid, Stack} from '@sentry/scraps/layout';
import {Text} from '@sentry/scraps/text';

import Placeholder from 'sentry/components/placeholder';
import {t} from 'sentry/locale';
import type {SnubaQuery} from 'sentry/types/workflowEngine/detectors';
import useOrganization from 'sentry/utils/useOrganization';
import type {ChartSelectionQueryParam} from 'sentry/views/explore/components/attributeBreakdowns/chartSelectionContext';
import {Chart} from 'sentry/views/explore/components/attributeBreakdowns/cohortComparisonChart';
import {COHORT_2_COLOR} from 'sentry/views/explore/components/attributeBreakdowns/constants';
import {AttributeBreakdownsComponent} from 'sentry/views/explore/components/attributeBreakdowns/styles';
import useAttributeBreakdownComparison from 'sentry/views/explore/hooks/useAttributeBreakdownComparison';
import {useFilteredRankedAttributes} from 'sentry/views/explore/hooks/useFilteredRankedAttributes';
import {Mode} from 'sentry/views/explore/queryParams/mode';
import {getExploreUrl} from 'sentry/views/explore/utils';
import {ChartType} from 'sentry/views/insights/common/components/chart';
import {InterimSection} from 'sentry/views/issueDetails/streamline/interimSection';

const BASELINE_DAYS = 7;
const CHARTS_PER_PAGE = 9; // 3 rows × 3 columns max

type AttributeComparisonSectionProps = {
isOpenPeriodLoading: boolean;
openPeriodEnd: string;
openPeriodStart: string;
projectId: string | number;
snubaQuery: SnubaQuery;
};

export function AttributeComparisonSection({
snubaQuery,
openPeriodStart,
openPeriodEnd,
projectId,
isOpenPeriodLoading,
}: AttributeComparisonSectionProps) {
const organization = useOrganization();
const theme = useTheme();

const openPeriodStartMs = new Date(openPeriodStart).getTime();
const openPeriodEndMs = new Date(openPeriodEnd).getTime();
const baselineStart = useMemo(
() => new Date(openPeriodStartMs - BASELINE_DAYS * 24 * 60 * 60 * 1000),
[openPeriodStartMs]
Copy link
Member

Choose a reason for hiding this comment

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

the date ref here will update on every render

);

const {data, isLoading, error} = useAttributeBreakdownComparison({
query: snubaQuery.query,
aggregateFunction: isOpenPeriodLoading ? '' : snubaQuery.aggregate,
range: [openPeriodStartMs, openPeriodEndMs],
pageFilters: {
datetime: {
start: baselineStart,
end: openPeriodEnd,
period: null,
utc: null,
},
environments: snubaQuery.environment ? [snubaQuery.environment] : [],
projects: [Number(projectId)],
},
});

const [searchQuery, setSearchQuery] = useState('');
const {
filteredRankedAttributes,
paginatedAttributes,
hasPrevious,
hasNext,
nextPage,
previousPage,
} = useFilteredRankedAttributes({
rankedAttributes: data?.rankedAttributes,
searchQuery,
pageSize: CHARTS_PER_PAGE,
});

const exploreUrl = useMemo(() => {
const chartSelection: ChartSelectionQueryParam = {
chartIndex: 0,
range: [openPeriodStartMs, openPeriodEndMs],
// XXX: This is a necessary query parameter to ensure that the chart selection hydrated correctly
// This value is generated by echarts and there isn't a way to import it, unfortunately
panelId: 'grid--\u0000series\u00000\u00000',
};

return getExploreUrl({
organization,
selection: {
datetime: {
start: baselineStart,
end: openPeriodEnd,
period: null,
utc: null,
},
environments: snubaQuery.environment ? [snubaQuery.environment] : [],
projects: [Number(projectId)],
},
visualize: [
{
chartType: ChartType.LINE,
yAxes: [snubaQuery.aggregate],
},
],
query: snubaQuery.query,
mode: Mode.SAMPLES,
table: 'attribute_breakdowns',
chartSelection,
});
}, [
organization,
openPeriodStartMs,
openPeriodEndMs,
openPeriodEnd,
baselineStart,
snubaQuery,
projectId,
]);

return (
<InterimSection
title={t('Attribute Comparison')}
type="attribute_comparison"
actions={
<LinkButton size="xs" to={exploreUrl}>
{t('View All')}
</LinkButton>
}
>
<Flex direction="column" gap="md">
<AttributeBreakdownsComponent.ControlsContainer>
<AttributeBreakdownsComponent.StyledBaseSearchBar
placeholder={t('Search attributes')}
onChange={setSearchQuery}
query={searchQuery}
size="sm"
/>
</AttributeBreakdownsComponent.ControlsContainer>
<Stack gap="xs">
<LegendHint backgroundColor={theme.chart.getColorPalette(0)?.[0]}>
{t('Open period data')}
</LegendHint>
<LegendHint backgroundColor={COHORT_2_COLOR}>
{t('Baseline is 7 days before the open period')}
</LegendHint>
</Stack>
{isLoading ? (
<Placeholder height="200px" />
) : error ? (
<AttributeBreakdownsComponent.ErrorState error={error} />
) : filteredRankedAttributes.length > 0 ? (
<Fragment>
<Grid columns="repeat(auto-fill, minmax(min(300px, 100%), 1fr))" gap="md">
{paginatedAttributes.map(attribute => (
<Chart
key={attribute.attributeName}
attribute={attribute}
theme={theme}
cohort1Total={data?.cohort1Total ?? 0}
cohort2Total={data?.cohort2Total ?? 0}
query={snubaQuery.query}
/>
))}
</Grid>
{filteredRankedAttributes.length > CHARTS_PER_PAGE && (
<AttributeBreakdownsComponent.Pagination
isNextDisabled={!hasNext}
isPrevDisabled={!hasPrevious}
onNextClick={nextPage}
onPrevClick={previousPage}
/>
)}
</Fragment>
) : (
<AttributeBreakdownsComponent.EmptySearchState />
)}
</Flex>
</InterimSection>
);
}

const LegendHint = styled(Text)<{backgroundColor?: string}>`
display: flex;
align-items: center;
color: ${p => p.theme.tokens.content.secondary};
font-size: ${p => p.theme.font.size.sm};

&::before {
content: '';
width: 8px;
height: 8px;
border-radius: 50%;
background-color: ${p => p.backgroundColor || p.theme.colors.gray500};
margin-right: ${p => p.theme.space.sm};
flex-shrink: 0;
}
`;
Copy link
Contributor

Choose a reason for hiding this comment

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

Duplicated styled component already exists in feature area

Low Severity

LegendHint is an exact duplicate of the SelectionHint styled component in cohortComparisonContent.tsx from the same attributeBreakdowns feature area. This file already imports several shared items from that directory (Chart, COHORT_2_COLOR, AttributeBreakdownsComponent), so this component could be exported from the shared styles.tsx module rather than redefined.

Fix in Cursor Fix in Web

Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,7 @@ import {getMetricDetectorSuffix} from 'sentry/views/detectors/utils/metricDetect
import {makeDiscoverPathname} from 'sentry/views/discover/pathnames';
import {InterimSection} from 'sentry/views/issueDetails/streamline/interimSection';

import {AttributeComparisonSection} from './attributeComparisonSection';
import {OpenPeriodTimelineSection} from './openPeriodTimelineSection';

interface MetricDetectorEvidenceData {
Expand Down Expand Up @@ -468,6 +469,17 @@ function TriggeredConditionDetails({
/>
</InterimSection>
<OpenPeriodTimelineSection eventId={eventId} groupId={groupId} />
{detectorDataset === DetectorDataset.SPANS && openPeriod && (
<Feature features="organizations:performance-spans-suspect-attributes">
<AttributeComparisonSection
snubaQuery={snubaQuery}
openPeriodStart={startDate}
openPeriodEnd={endDate}
projectId={projectId}
isOpenPeriodLoading={isOpenPeriodLoading}
/>
</Feature>
)}
{isErrorsDataset &&
(isOpenPeriodLoading ? (
<InterimSection title={t('Contributing Issues')} type="contributing_issues">
Expand Down
Loading