Skip to content
Open
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
Original file line number Diff line number Diff line change
@@ -0,0 +1,157 @@
import {
MetricDetectorFixture,
SnubaQueryDataSourceFixture,
} from 'sentry-fixture/detectors';
import {EventFixture} from 'sentry-fixture/event';
import {GroupFixture} from 'sentry-fixture/group';
import {OrganizationFixture} from 'sentry-fixture/organization';
import {ProjectFixture} from 'sentry-fixture/project';

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

import {IssueCategory, IssueType} from 'sentry/types/group';
import {Dataset} from 'sentry/views/alerts/rules/metric/types';
import {IssueDetailsContext} from 'sentry/views/issueDetails/context';
import {MetricIssuesSection} from 'sentry/views/issueDetails/metricIssues/metricIssuesSection';
import {getDetectorDetails} from 'sentry/views/issueDetails/sidebar/detectorSection';

describe('MetricIssuesSection', () => {
const organization = OrganizationFixture();
const project = ProjectFixture({organization});
const group = GroupFixture({
project,
issueCategory: IssueCategory.METRIC,
issueType: IssueType.METRIC_ISSUE,
});

const baseIssueDetailsContext = {
sectionData: {},
detectorDetails: {},
isSidebarOpen: true,
navScrollMargin: 0,
eventCount: 0,
dispatch: jest.fn(),
};

const detector = MetricDetectorFixture({projectId: project.id});
const event = EventFixture({
occurrence: {
evidenceData: {detectorId: detector.id},
type: 8001,
},
});

beforeEach(() => {
MockApiClient.clearMockResponses();
MockApiClient.addMockResponse({
url: `/organizations/${organization.slug}/open-periods/`,
body: [],
});
// Endpoints fired by the correlated issues/transactions tables
MockApiClient.addMockResponse({
url: `/organizations/${organization.slug}/issues/`,
body: [],
});
MockApiClient.addMockResponse({
url: `/organizations/${organization.slug}/events/`,
body: {data: [], meta: {}},
});
MockApiClient.addMockResponse({
url: `/organizations/${organization.slug}/users/`,
body: [],
});
MockApiClient.addMockResponse({
url: `/organizations/${organization.slug}/members/`,
body: [],
});
});

function renderSection(
detectorDetails = getDetectorDetails({event, organization, project})
) {
return render(
<IssueDetailsContext value={{...baseIssueDetailsContext, detectorDetails}}>
<MetricIssuesSection
organization={organization}
group={group}
project={project}
/>
</IssueDetailsContext>,
{organization}
);
}

it('fetches the detector and renders correlated issues for an error dataset', async () => {
const mockDetector = MockApiClient.addMockResponse({
url: `/organizations/${organization.slug}/detectors/${detector.id}/`,
body: detector,
});
const mockIssues = MockApiClient.addMockResponse({
url: `/organizations/${organization.slug}/issues/`,
body: [],
});

renderSection();

expect(await screen.findByText('Correlated Issues')).toBeInTheDocument();
expect(mockDetector).toHaveBeenCalled();
// The detector's snuba query flows through to the correlated issues request
expect(mockIssues).toHaveBeenCalledWith(
expect.anything(),
expect.objectContaining({
query: expect.objectContaining({
query: expect.stringContaining('is:unresolved'),
}),
})
);
});

it('renders correlated transactions for a transaction dataset detector', async () => {
const transactionDetector = MetricDetectorFixture({
projectId: project.id,
dataSources: [
SnubaQueryDataSourceFixture({
queryObj: {
id: '1',
status: 1,
subscription: '1',
snubaQuery: {
id: '',
aggregate: 'count()',
dataset: Dataset.TRANSACTIONS,
query: '',
timeWindow: 60,
eventTypes: [],
},
},
}),
],
});
MockApiClient.addMockResponse({
url: `/organizations/${organization.slug}/detectors/${detector.id}/`,
body: transactionDetector,
});

renderSection();

expect(await screen.findByText('Correlated Transactions')).toBeInTheDocument();
});

it('renders nothing and never hits the legacy alert-rules endpoint without a metric detector', () => {

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

idk how necessary this test is 🤔

const alertRulesMock = MockApiClient.addMockResponse({
url: `/organizations/${organization.slug}/alert-rules/123/`,
body: {},
});
const detectorMock = MockApiClient.addMockResponse({
url: `/organizations/${organization.slug}/detectors/${detector.id}/`,
body: detector,
});

renderSection({});

expect(screen.queryByText('Correlated Issues')).not.toBeInTheDocument();
expect(screen.queryByText('Correlated Transactions')).not.toBeInTheDocument();
expect(alertRulesMock).not.toHaveBeenCalled();
expect(detectorMock).not.toHaveBeenCalled();
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -4,18 +4,20 @@ import {t} from 'sentry/locale';
import type {Group} from 'sentry/types/group';
import type {Organization} from 'sentry/types/organization';
import type {Project} from 'sentry/types/project';
import type {MetricDetector} from 'sentry/types/workflowEngine/detectors';
import {useLocation} from 'sentry/utils/useLocation';
import {RelatedIssues} from 'sentry/views/alerts/rules/metric/details/relatedIssues';
import {RelatedTransactions} from 'sentry/views/alerts/rules/metric/details/relatedTransactions';
import {Dataset} from 'sentry/views/alerts/rules/metric/types';
import {extractEventTypeFilterFromRule} from 'sentry/views/alerts/rules/metric/utils/getEventTypeFilter';
import {isCrashFreeAlert} from 'sentry/views/alerts/rules/metric/utils/isCrashFreeAlert';
import {useMetricRule} from 'sentry/views/alerts/rules/metric/utils/useMetricRule';
import {useDetectorQuery} from 'sentry/views/detectors/hooks';
import {useOpenPeriods} from 'sentry/views/detectors/hooks/useOpenPeriods';
import {SectionKey} from 'sentry/views/issueDetails/context';
import {FoldSection} from 'sentry/views/issueDetails/foldSection';
import {
useMetricIssueAlertId,
getMetricRuleFromDetector,
useMetricIssueDetectorId,
useMetricTimePeriod,
} from 'sentry/views/issueDetails/metricIssues/utils';

Expand All @@ -32,28 +34,20 @@ export function MetricIssuesSection({
}: MetricIssuesSectionProps) {
const location = useLocation();

const ruleId = useMetricIssueAlertId({groupId: group.id});
const {data: rule} = useMetricRule(
{
orgSlug: organization.slug,
ruleId: ruleId ?? '',
query: {
expand: 'latestIncident',
},
},
{
staleTime: Infinity,
retry: false,
enabled: !!ruleId,
}
);
const detectorId = useMetricIssueDetectorId();
const {data: detector} = useDetectorQuery<MetricDetector>(detectorId ?? '', {
staleTime: Infinity,
retry: false,
enabled: !!detectorId,
});
const {data: openPeriods} = useOpenPeriods({groupId: group.id});
const timePeriod = useMetricTimePeriod({openPeriod: openPeriods?.[0]});

if (!rule || !timePeriod) {
if (!detector || !timePeriod) {
return null;
}

const rule = getMetricRuleFromDetector(detector, project);
const {dataset, query} = rule;

if ([Dataset.METRICS, Dataset.SESSIONS, Dataset.ERRORS].includes(dataset)) {
Expand Down
57 changes: 57 additions & 0 deletions static/app/views/issueDetails/metricIssues/utils.spec.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
import {
MetricDetectorFixture,
SnubaQueryDataSourceFixture,
} from 'sentry-fixture/detectors';
import {ProjectFixture} from 'sentry-fixture/project';

import {Dataset, EventTypes} from 'sentry/views/alerts/rules/metric/types';
import {getMetricRuleFromDetector} from 'sentry/views/issueDetails/metricIssues/utils';

describe('getMetricRuleFromDetector', () => {
const project = ProjectFixture();

it('maps a metric detector snuba query into a MetricRule', () => {
const detector = MetricDetectorFixture({
config: {detectionType: 'static'},
dataSources: [
SnubaQueryDataSourceFixture({
queryObj: {
id: '1',
status: 1,
subscription: '1',
snubaQuery: {
id: '',
aggregate: 'count_unique(user)',
dataset: Dataset.ERRORS,
query: 'is:unresolved',
// seconds; should be converted to minutes
timeWindow: 3600,
eventTypes: [EventTypes.ERROR],
environment: 'prod',
},
},
}),
],
});

const rule = getMetricRuleFromDetector(detector, project);

expect(rule.aggregate).toBe('count_unique(user)');
expect(rule.dataset).toBe(Dataset.ERRORS);
expect(rule.query).toBe('is:unresolved');
expect(rule.eventTypes).toEqual([EventTypes.ERROR]);
expect(rule.environment).toBe('prod');
expect(rule.projects).toEqual([project.slug]);
expect(rule.detectionType).toBe('static');
// 3600s -> 60m
expect(rule.timeWindow).toBe(60);
});

it('defaults environment to null when the snuba query has none', () => {
const detector = MetricDetectorFixture();

const rule = getMetricRuleFromDetector(detector, project);

expect(rule.environment).toBeNull();
});
});
69 changes: 38 additions & 31 deletions static/app/views/issueDetails/metricIssues/utils.tsx
Original file line number Diff line number Diff line change
@@ -1,48 +1,55 @@
import {Fragment, useMemo} from 'react';
import {useQuery} from '@tanstack/react-query';
import moment from 'moment-timezone';

import {usePageFilterDates} from 'sentry/components/checkInTimeline/hooks/useMonitorDates';
import {DateTime} from 'sentry/components/dateTime';
import {t} from 'sentry/locale';
import type {GroupOpenPeriod} from 'sentry/types/group';
import {useOrganization} from 'sentry/utils/useOrganization';
import {useUser} from 'sentry/utils/useUser';
import type {Project} from 'sentry/types/project';
import type {MetricDetector} from 'sentry/types/workflowEngine/detectors';
import type {TimePeriodType} from 'sentry/views/alerts/rules/metric/details/constants';
import {TimePeriod} from 'sentry/views/alerts/rules/metric/types';
import type {MetricRule} from 'sentry/views/alerts/rules/metric/types';
import {
AlertRuleThresholdType,
TimePeriod,
TimeWindow,
} from 'sentry/views/alerts/rules/metric/types';
import {useIssueDetails} from 'sentry/views/issueDetails/context';
import {groupEventApiOptions} from 'sentry/views/issueDetails/utils';

export function useMetricIssueAlertId({groupId}: {groupId: string}): string | undefined {
/**
* This should be removed once the metric alert rule ID is set on the issue.
* This will fetch an event from the max range if the detector details
* are not available (e.g. time range has changed and page refreshed)
*/
const user = useUser();
const organization = useOrganization();
export function useMetricIssueDetectorId(): string | undefined {
const {detectorDetails} = useIssueDetails();
const {detectorId, detectorType} = detectorDetails;
return detectorType === 'metric_alert' ? detectorId : undefined;
}

const hasMetricDetector = detectorId && detectorType === 'metric_alert';

const {data: event} = useQuery({
...groupEventApiOptions({
orgSlug: organization.slug,
groupId,
eventId: user.options.defaultIssueEvent,
environments: [],
}),
staleTime: Infinity,
enabled: !hasMetricDetector,
retry: false,
});
/**
* Adapts a metric detector into the legacy `MetricRule` shape consumed by the
* correlated issues/transactions views. Only the fields those views read are
* populated with real values; the remaining required fields are filled with
* inert defaults.
*/
export function getMetricRuleFromDetector(
detector: MetricDetector,
project: Project
): MetricRule {
const {aggregate, dataset, query, environment, eventTypes, timeWindow} =
detector.dataSources[0].queryObj.snubaQuery;

// Fall back to the fetched event in case the provider doesn't have the detector details
const fallback =
event?.occurrence?.evidenceData?.alertId ||
event?.contexts?.metric_alert?.alert_rule_id;
return hasMetricDetector ? detectorId : fallback;
return {
aggregate,
dataset,
query,
eventTypes,
environment: environment ?? null,
projects: [project.slug],
detectionType: detector.config.detectionType,
// snubaQuery time windows are in seconds; MetricRule expects minutes
timeWindow: (timeWindow / 60) as TimeWindow,
resolveThreshold: null,
thresholdPeriod: null,
thresholdType: AlertRuleThresholdType.ABOVE,
triggers: [],
};
}

interface UseMetricTimePeriodParams {
Expand Down
Loading