import {EventFixture} from 'sentry-fixture/event';
import {OrganizationFixture} from 'sentry-fixture/organization';
import {ProjectFixture} from 'sentry-fixture/project';
import {render, screen, userEvent, waitFor} from 'sentry-test/reactTestingLibrary';
import ProjectsStore from 'sentry/stores/projectsStore';
import {trackAnalytics} from 'sentry/utils/analytics';
import useRouteAnalyticsParams from 'sentry/utils/routeAnalytics/useRouteAnalyticsParams';
import {getTitleSubtitleMessage} from './traceTimeline/traceIssue';
import {TraceTimeline} from './traceTimeline/traceTimeline';
import type {
TimelineErrorEvent,
TraceEventResponse,
} from './traceTimeline/useTraceTimelineEvents';
import {TraceTimeLineOrRelatedIssue} from './traceTimelineOrRelatedIssue';
jest.mock('sentry/utils/routeAnalytics/useRouteAnalyticsParams');
jest.mock('sentry/utils/analytics');
describe('TraceTimeline & TraceRelated Issue', () => {
// Paid plans have global-views enabled
// Include project: -1 in all matchQuery calls to ensure we are looking at all projects
const organization = OrganizationFixture({
features: ['global-views'],
});
const firstEventTimestamp = '2024-01-24T09:09:01+00:00';
// This creates the ApiException event
const event = EventFixture({
// This is used to determine the presence of seconds
dateCreated: firstEventTimestamp,
contexts: {
trace: {
// This is used to determine if we should attempt
// to render the trace timeline
trace_id: '123',
},
},
});
const project = ProjectFixture();
const emptyBody: TraceEventResponse = {data: [], meta: {fields: {}, units: {}}};
const issuePlatformBody: TraceEventResponse = {
data: [
{
// In issuePlatform, the message contains the title and the transaction
message: '/api/slow/ Slow DB Query SELECT "sentry_monitorcheckin"."monitor_id"',
timestamp: '2024-01-24T09:09:03+00:00',
'issue.id': 1000,
project: project.slug,
'project.name': project.name,
title: 'Slow DB Query',
id: 'abc',
transaction: 'n/a',
culprit: '/api/slow/',
'event.type': '',
},
],
meta: {fields: {}, units: {}},
};
const mainError: TimelineErrorEvent = {
culprit: 'n/a',
'error.value': ['some-other-error-value', 'The last error value'],
timestamp: firstEventTimestamp,
'issue.id': event['issue.id'],
project: project.slug,
'project.name': project.name,
title: event.title,
id: event.id,
transaction: 'important.task',
'event.type': 'error',
'stack.function': ['important.task', 'task.run'],
};
const secondError: TimelineErrorEvent = {
culprit: 'billiard.pool in foo', // Used for subtitle
'error.value': ['some-other-error-value', 'The last error value'],
timestamp: '2024-01-24T09:09:04+00:00',
'issue.id': 9999,
project: project.slug,
'project.name': project.name,
title: 'someTitle',
id: '12345',
transaction: 'foo',
'event.type': 'error',
'stack.function': ['n/a'],
};
const discoverBody: TraceEventResponse = {
data: [mainError],
meta: {fields: {}, units: {}},
};
const twoIssuesBody: TraceEventResponse = {
data: [mainError, secondError],
meta: {fields: {}, units: {}},
};
beforeEach(() => {
ProjectsStore.loadInitialData([project]);
jest.clearAllMocks();
});
it('renders items and highlights the current event', async () => {
MockApiClient.addMockResponse({
url: `/organizations/${organization.slug}/events/`,
body: issuePlatformBody,
match: [MockApiClient.matchQuery({dataset: 'issuePlatform', project: -1})],
});
MockApiClient.addMockResponse({
url: `/organizations/${organization.slug}/events/`,
body: twoIssuesBody,
match: [MockApiClient.matchQuery({dataset: 'discover', project: -1})],
});
render(, {organization});
expect(await screen.findByLabelText('Current Event')).toBeInTheDocument();
await userEvent.hover(screen.getByTestId('trace-timeline-tooltip-1'));
expect(await screen.findByText('You are here')).toBeInTheDocument();
expect(useRouteAnalyticsParams).toHaveBeenCalledWith({
trace_timeline_status: 'shown',
});
});
it('displays nothing if the only event is the current event', async () => {
MockApiClient.addMockResponse({
url: `/organizations/${organization.slug}/events/`,
body: emptyBody,
match: [MockApiClient.matchQuery({dataset: 'issuePlatform', project: -1})],
});
MockApiClient.addMockResponse({
url: `/organizations/${organization.slug}/events/`,
body: discoverBody,
match: [MockApiClient.matchQuery({dataset: 'discover', project: -1})],
});
const {container} = render(, {organization});
await waitFor(() =>
expect(useRouteAnalyticsParams).toHaveBeenCalledWith({
trace_timeline_status: 'empty',
})
);
expect(container).toBeEmptyDOMElement();
});
it('displays nothing if there are no events', async () => {
MockApiClient.addMockResponse({
url: `/organizations/${organization.slug}/events/`,
body: emptyBody,
match: [MockApiClient.matchQuery({dataset: 'issuePlatform', project: -1})],
});
MockApiClient.addMockResponse({
url: `/organizations/${organization.slug}/events/`,
body: emptyBody,
match: [MockApiClient.matchQuery({dataset: 'discover', project: -1})],
});
const {container} = render(, {organization});
await waitFor(() =>
expect(useRouteAnalyticsParams).toHaveBeenCalledWith({
trace_timeline_status: 'empty',
})
);
expect(container).toBeEmptyDOMElement();
});
it('shows seconds for very short timelines', async () => {
MockApiClient.addMockResponse({
url: `/organizations/${organization.slug}/events/`,
body: issuePlatformBody,
match: [MockApiClient.matchQuery({dataset: 'issuePlatform', project: -1})],
});
MockApiClient.addMockResponse({
url: `/organizations/${organization.slug}/events/`,
body: twoIssuesBody,
match: [MockApiClient.matchQuery({dataset: 'discover', project: -1})],
});
render(, {organization});
// Checking for the presence of seconds
expect(await screen.findAllByText(/\d{1,2}:\d{2}:\d{2} (AM|PM)/)).toHaveLength(5);
});
// useTraceTimelineEvents() adds the current event if missing
it('adds the current event if not in the api response', async () => {
MockApiClient.addMockResponse({
url: `/organizations/${organization.slug}/events/`,
body: issuePlatformBody,
match: [MockApiClient.matchQuery({dataset: 'issuePlatform', project: -1})],
});
MockApiClient.addMockResponse({
url: `/organizations/${organization.slug}/events/`,
body: {
// The event for the mainError is missing, thus, it will get added
data: [secondError],
},
match: [MockApiClient.matchQuery({dataset: 'discover', project: -1})],
});
render(, {organization});
expect(await screen.findByLabelText('Current Event')).toBeInTheDocument();
});
it('skips the timeline and shows related issues (2 issues)', async () => {
MockApiClient.addMockResponse({
url: `/organizations/${organization.slug}/events/`,
body: issuePlatformBody,
match: [MockApiClient.matchQuery({dataset: 'issuePlatform', project: -1})],
});
MockApiClient.addMockResponse({
url: `/organizations/${organization.slug}/events/`,
body: discoverBody,
match: [MockApiClient.matchQuery({dataset: 'discover', project: -1})],
});
// Used to determine the project badge
MockApiClient.addMockResponse({
url: `/organizations/${organization.slug}/projects/`,
body: [],
});
render(, {organization});
// Instead of a timeline, we should see the other related issue
expect(await screen.findByText('Slow DB Query')).toBeInTheDocument(); // The title
expect(await screen.findByText('/api/slow/')).toBeInTheDocument(); // The subtitle/transaction
expect(
await screen.findByText('One other issue appears in the same trace.')
).toBeInTheDocument();
expect(
await screen.findByText('SELECT "sentry_monitorcheckin"."monitor_id"') // The message
).toBeInTheDocument();
expect(screen.queryByLabelText('Current Event')).not.toBeInTheDocument();
// Test analytics
await userEvent.click(await screen.findByText('Slow DB Query'));
expect(useRouteAnalyticsParams).toHaveBeenCalledWith({
has_related_trace_issue: true,
});
expect(trackAnalytics).toHaveBeenCalledTimes(1);
expect(trackAnalytics).toHaveBeenCalledWith(
'issue_details.related_trace_issue.trace_issue_clicked',
{
group_id: issuePlatformBody.data[0]['issue.id'],
organization: organization,
}
);
});
it('skips the timeline and shows NO related issues (only 1 issue)', async () => {
MockApiClient.addMockResponse({
url: `/organizations/${organization.slug}/events/`,
body: emptyBody,
match: [MockApiClient.matchQuery({dataset: 'issuePlatform', project: -1})],
});
MockApiClient.addMockResponse({
url: `/organizations/${organization.slug}/events/`,
// Only 1 issue
body: discoverBody,
match: [MockApiClient.matchQuery({dataset: 'discover', project: -1})],
});
// Used to determine the project badge
MockApiClient.addMockResponse({
url: `/organizations/${organization.slug}/projects/`,
body: [],
});
render(, {organization});
// We do not display any related issues because we only have 1 issue
expect(await screen.queryByText('Slow DB Query')).not.toBeInTheDocument();
expect(
await screen.queryByText('AttributeError: Something Failed')
).not.toBeInTheDocument();
// We do not display the timeline because we only have 1 event
expect(await screen.queryByLabelText('Current Event')).not.toBeInTheDocument();
expect(useRouteAnalyticsParams).toHaveBeenCalledWith({});
});
it('trace timeline works for plans with no global-views feature', async () => {
// This test will call the endpoint without the global-views feature, thus,
// we will only look at the current project (project: event.projectID) instead of passing -1
MockApiClient.addMockResponse({
url: `/organizations/${organization.slug}/events/`,
body: issuePlatformBody,
match: [
MockApiClient.matchQuery({
dataset: 'issuePlatform',
project: event.projectID,
}),
],
});
MockApiClient.addMockResponse({
url: `/organizations/${organization.slug}/events/`,
body: twoIssuesBody,
match: [
MockApiClient.matchQuery({
dataset: 'discover',
project: event.projectID,
}),
],
});
render(, {
organization: OrganizationFixture({features: []}), // No global-views feature
});
expect(await screen.findByLabelText('Current Event')).toBeInTheDocument();
});
it('trace-related issue works for plans with no global-views feature', async () => {
// This test will call the endpoint without the global-views feature, thus,
// we will only look at the current project (project: event.projectID) instead of passing -1
MockApiClient.addMockResponse({
url: `/organizations/${organization.slug}/events/`,
body: issuePlatformBody,
match: [
MockApiClient.matchQuery({
dataset: 'issuePlatform',
project: event.projectID,
}),
],
});
MockApiClient.addMockResponse({
url: `/organizations/${organization.slug}/events/`,
body: discoverBody,
match: [
MockApiClient.matchQuery({
dataset: 'discover',
project: event.projectID,
}),
],
});
// Used to determine the project badge
MockApiClient.addMockResponse({
url: `/organizations/${organization.slug}/projects/`,
body: [],
});
render(, {
organization: OrganizationFixture({
features: [],
}),
});
expect(await screen.findByText('Slow DB Query')).toBeInTheDocument();
});
});
function createEvent({
culprit,
title,
error_value,
event_type = 'error',
stack_function = [],
message = 'n/a',
}: {
culprit: string;
title: string;
error_value?: string[];
event_type?: 'default' | 'error' | '';
message?: string;
stack_function?: string[];
}) {
const event = {
culprit: culprit,
timestamp: '2024-01-24T09:09:04+00:00',
'issue.id': 9999,
project: 'foo',
'project.name': 'bar',
title: title,
id: '12345',
transaction: 'n/a',
'event.type': event_type,
};
// Using this intermediary variable helps typescript
let return_event;
if (event['event.type'] === 'error') {
return_event = {
...event,
'stack.function': stack_function,
'error.value': error_value,
};
} else if (event['event.type'] === '') {
return_event = {
...event,
message: message,
};
} else {
return_event = event;
}
return return_event;
}
describe('getTitleSubtitleMessage()', () => {
it('error event', () => {
expect(
getTitleSubtitleMessage(
createEvent({
culprit: '/api/0/sentry-app-installations/{uuid}/',
title:
'ClientError: 404 Client Error: for url: https://api.clickup.com/sentry/webhook',
error_value: [
'404 Client Error: for url: https://api.clickup.com/sentry/webhook',
],
})
)
).toEqual({
title: 'ClientError', // The colon and remainder of string are removed
subtitle: '/api/0/sentry-app-installations/{uuid}/',
message: '404 Client Error: for url: https://api.clickup.com/sentry/webhook',
});
});
it('error event: It keeps the colon', () => {
expect(
getTitleSubtitleMessage(
createEvent({
culprit: 'billiard.pool in foo',
title: 'WorkerLostError: ',
error_value: ['some-other-error-value', 'The last error value'],
})
)
).toEqual({
title: 'WorkerLostError:', // The colon is kept
subtitle: 'billiard.pool in foo',
message: 'The last error value',
});
});
it('error event: No error_value', () => {
expect(
getTitleSubtitleMessage(
createEvent({
title: 'foo',
culprit: 'bar',
error_value: [''], // We always get a non-empty array
})
)
).toEqual({
title: 'foo',
subtitle: 'bar',
message: '',
});
});
it('default event', () => {
expect(
getTitleSubtitleMessage(
createEvent({
culprit: '/api/0/organizations/{organization_id_or_slug}/issues/',
title: 'Query from referrer search.group_index is throttled',
event_type: 'default',
})
)
).toEqual({
title: 'Query from referrer search.group_index is throttled',
subtitle: '',
message: '/api/0/organizations/{organization_id_or_slug}/issues/',
});
});
it('issue platform event', () => {
expect(
getTitleSubtitleMessage(
createEvent({
message: '/api/slow/ Slow DB Query SELECT "sentry_monitorcheckin"."monitor_id"',
culprit: '/api/slow/',
title: 'Slow DB Query',
event_type: '',
})
)
).toEqual({
title: 'Slow DB Query',
subtitle: '/api/slow/',
message: 'SELECT "sentry_monitorcheckin"."monitor_id"',
});
});
});