traceTimelineOrRelatedIssue.spec.tsx 16 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472
  1. import {EventFixture} from 'sentry-fixture/event';
  2. import {OrganizationFixture} from 'sentry-fixture/organization';
  3. import {ProjectFixture} from 'sentry-fixture/project';
  4. import {render, screen, userEvent, waitFor} from 'sentry-test/reactTestingLibrary';
  5. import ProjectsStore from 'sentry/stores/projectsStore';
  6. import {trackAnalytics} from 'sentry/utils/analytics';
  7. import useRouteAnalyticsParams from 'sentry/utils/routeAnalytics/useRouteAnalyticsParams';
  8. import {getTitleSubtitleMessage} from './traceTimeline/traceIssue';
  9. import {TraceTimeline} from './traceTimeline/traceTimeline';
  10. import type {
  11. TimelineErrorEvent,
  12. TraceEventResponse,
  13. } from './traceTimeline/useTraceTimelineEvents';
  14. import {TraceTimeLineOrRelatedIssue} from './traceTimelineOrRelatedIssue';
  15. jest.mock('sentry/utils/routeAnalytics/useRouteAnalyticsParams');
  16. jest.mock('sentry/utils/analytics');
  17. describe('TraceTimeline & TraceRelated Issue', () => {
  18. // Paid plans have global-views enabled
  19. // Include project: -1 in all matchQuery calls to ensure we are looking at all projects
  20. const organization = OrganizationFixture({
  21. features: ['global-views'],
  22. });
  23. const firstEventTimestamp = '2024-01-24T09:09:01+00:00';
  24. // This creates the ApiException event
  25. const event = EventFixture({
  26. // This is used to determine the presence of seconds
  27. dateCreated: firstEventTimestamp,
  28. contexts: {
  29. trace: {
  30. // This is used to determine if we should attempt
  31. // to render the trace timeline
  32. trace_id: '123',
  33. },
  34. },
  35. });
  36. const project = ProjectFixture();
  37. const emptyBody: TraceEventResponse = {data: [], meta: {fields: {}, units: {}}};
  38. const issuePlatformBody: TraceEventResponse = {
  39. data: [
  40. {
  41. // In issuePlatform, the message contains the title and the transaction
  42. message: '/api/slow/ Slow DB Query SELECT "sentry_monitorcheckin"."monitor_id"',
  43. timestamp: '2024-01-24T09:09:03+00:00',
  44. 'issue.id': 1000,
  45. project: project.slug,
  46. 'project.name': project.name,
  47. title: 'Slow DB Query',
  48. id: 'abc',
  49. transaction: 'n/a',
  50. culprit: '/api/slow/',
  51. 'event.type': '',
  52. },
  53. ],
  54. meta: {fields: {}, units: {}},
  55. };
  56. const mainError: TimelineErrorEvent = {
  57. culprit: 'n/a',
  58. 'error.value': ['some-other-error-value', 'The last error value'],
  59. timestamp: firstEventTimestamp,
  60. 'issue.id': event['issue.id'],
  61. project: project.slug,
  62. 'project.name': project.name,
  63. title: event.title,
  64. id: event.id,
  65. transaction: 'important.task',
  66. 'event.type': 'error',
  67. 'stack.function': ['important.task', 'task.run'],
  68. };
  69. const secondError: TimelineErrorEvent = {
  70. culprit: 'billiard.pool in foo', // Used for subtitle
  71. 'error.value': ['some-other-error-value', 'The last error value'],
  72. timestamp: '2024-01-24T09:09:04+00:00',
  73. 'issue.id': 9999,
  74. project: project.slug,
  75. 'project.name': project.name,
  76. title: 'someTitle',
  77. id: '12345',
  78. transaction: 'foo',
  79. 'event.type': 'error',
  80. 'stack.function': ['n/a'],
  81. };
  82. const discoverBody: TraceEventResponse = {
  83. data: [mainError],
  84. meta: {fields: {}, units: {}},
  85. };
  86. const twoIssuesBody: TraceEventResponse = {
  87. data: [mainError, secondError],
  88. meta: {fields: {}, units: {}},
  89. };
  90. beforeEach(() => {
  91. ProjectsStore.loadInitialData([project]);
  92. jest.clearAllMocks();
  93. });
  94. it('renders items and highlights the current event', async () => {
  95. MockApiClient.addMockResponse({
  96. url: `/organizations/${organization.slug}/events/`,
  97. body: issuePlatformBody,
  98. match: [MockApiClient.matchQuery({dataset: 'issuePlatform', project: -1})],
  99. });
  100. MockApiClient.addMockResponse({
  101. url: `/organizations/${organization.slug}/events/`,
  102. body: twoIssuesBody,
  103. match: [MockApiClient.matchQuery({dataset: 'discover', project: -1})],
  104. });
  105. render(<TraceTimeLineOrRelatedIssue event={event} />, {organization});
  106. expect(await screen.findByLabelText('Current Event')).toBeInTheDocument();
  107. await userEvent.hover(screen.getByTestId('trace-timeline-tooltip-1'));
  108. expect(await screen.findByText('You are here')).toBeInTheDocument();
  109. expect(useRouteAnalyticsParams).toHaveBeenCalledWith({
  110. trace_timeline_status: 'shown',
  111. });
  112. });
  113. it('displays nothing if the only event is the current event', async () => {
  114. MockApiClient.addMockResponse({
  115. url: `/organizations/${organization.slug}/events/`,
  116. body: emptyBody,
  117. match: [MockApiClient.matchQuery({dataset: 'issuePlatform', project: -1})],
  118. });
  119. MockApiClient.addMockResponse({
  120. url: `/organizations/${organization.slug}/events/`,
  121. body: discoverBody,
  122. match: [MockApiClient.matchQuery({dataset: 'discover', project: -1})],
  123. });
  124. const {container} = render(<TraceTimeline event={event} />, {organization});
  125. await waitFor(() =>
  126. expect(useRouteAnalyticsParams).toHaveBeenCalledWith({
  127. trace_timeline_status: 'empty',
  128. })
  129. );
  130. expect(container).toBeEmptyDOMElement();
  131. });
  132. it('displays nothing if there are no events', async () => {
  133. MockApiClient.addMockResponse({
  134. url: `/organizations/${organization.slug}/events/`,
  135. body: emptyBody,
  136. match: [MockApiClient.matchQuery({dataset: 'issuePlatform', project: -1})],
  137. });
  138. MockApiClient.addMockResponse({
  139. url: `/organizations/${organization.slug}/events/`,
  140. body: emptyBody,
  141. match: [MockApiClient.matchQuery({dataset: 'discover', project: -1})],
  142. });
  143. const {container} = render(<TraceTimeline event={event} />, {organization});
  144. await waitFor(() =>
  145. expect(useRouteAnalyticsParams).toHaveBeenCalledWith({
  146. trace_timeline_status: 'empty',
  147. })
  148. );
  149. expect(container).toBeEmptyDOMElement();
  150. });
  151. it('shows seconds for very short timelines', async () => {
  152. MockApiClient.addMockResponse({
  153. url: `/organizations/${organization.slug}/events/`,
  154. body: issuePlatformBody,
  155. match: [MockApiClient.matchQuery({dataset: 'issuePlatform', project: -1})],
  156. });
  157. MockApiClient.addMockResponse({
  158. url: `/organizations/${organization.slug}/events/`,
  159. body: twoIssuesBody,
  160. match: [MockApiClient.matchQuery({dataset: 'discover', project: -1})],
  161. });
  162. render(<TraceTimeLineOrRelatedIssue event={event} />, {organization});
  163. // Checking for the presence of seconds
  164. expect(await screen.findAllByText(/\d{1,2}:\d{2}:\d{2} (AM|PM)/)).toHaveLength(5);
  165. });
  166. // useTraceTimelineEvents() adds the current event if missing
  167. it('adds the current event if not in the api response', async () => {
  168. MockApiClient.addMockResponse({
  169. url: `/organizations/${organization.slug}/events/`,
  170. body: issuePlatformBody,
  171. match: [MockApiClient.matchQuery({dataset: 'issuePlatform', project: -1})],
  172. });
  173. MockApiClient.addMockResponse({
  174. url: `/organizations/${organization.slug}/events/`,
  175. body: {
  176. // The event for the mainError is missing, thus, it will get added
  177. data: [secondError],
  178. },
  179. match: [MockApiClient.matchQuery({dataset: 'discover', project: -1})],
  180. });
  181. render(<TraceTimeLineOrRelatedIssue event={event} />, {organization});
  182. expect(await screen.findByLabelText('Current Event')).toBeInTheDocument();
  183. });
  184. it('skips the timeline and shows related issues (2 issues)', async () => {
  185. MockApiClient.addMockResponse({
  186. url: `/organizations/${organization.slug}/events/`,
  187. body: issuePlatformBody,
  188. match: [MockApiClient.matchQuery({dataset: 'issuePlatform', project: -1})],
  189. });
  190. MockApiClient.addMockResponse({
  191. url: `/organizations/${organization.slug}/events/`,
  192. body: discoverBody,
  193. match: [MockApiClient.matchQuery({dataset: 'discover', project: -1})],
  194. });
  195. // Used to determine the project badge
  196. MockApiClient.addMockResponse({
  197. url: `/organizations/${organization.slug}/projects/`,
  198. body: [],
  199. });
  200. render(<TraceTimeLineOrRelatedIssue event={event} />, {organization});
  201. // Instead of a timeline, we should see the other related issue
  202. expect(await screen.findByText('Slow DB Query')).toBeInTheDocument(); // The title
  203. expect(await screen.findByText('/api/slow/')).toBeInTheDocument(); // The subtitle/transaction
  204. expect(
  205. await screen.findByText('One other issue appears in the same trace.')
  206. ).toBeInTheDocument();
  207. expect(
  208. await screen.findByText('SELECT "sentry_monitorcheckin"."monitor_id"') // The message
  209. ).toBeInTheDocument();
  210. expect(screen.queryByLabelText('Current Event')).not.toBeInTheDocument();
  211. // Test analytics
  212. await userEvent.click(await screen.findByText('Slow DB Query'));
  213. expect(useRouteAnalyticsParams).toHaveBeenCalledWith({
  214. has_related_trace_issue: true,
  215. });
  216. expect(trackAnalytics).toHaveBeenCalledTimes(1);
  217. expect(trackAnalytics).toHaveBeenCalledWith(
  218. 'issue_details.related_trace_issue.trace_issue_clicked',
  219. {
  220. group_id: issuePlatformBody.data[0]['issue.id'],
  221. organization: organization,
  222. }
  223. );
  224. });
  225. it('skips the timeline and shows NO related issues (only 1 issue)', async () => {
  226. MockApiClient.addMockResponse({
  227. url: `/organizations/${organization.slug}/events/`,
  228. body: emptyBody,
  229. match: [MockApiClient.matchQuery({dataset: 'issuePlatform', project: -1})],
  230. });
  231. MockApiClient.addMockResponse({
  232. url: `/organizations/${organization.slug}/events/`,
  233. // Only 1 issue
  234. body: discoverBody,
  235. match: [MockApiClient.matchQuery({dataset: 'discover', project: -1})],
  236. });
  237. // Used to determine the project badge
  238. MockApiClient.addMockResponse({
  239. url: `/organizations/${organization.slug}/projects/`,
  240. body: [],
  241. });
  242. render(<TraceTimeLineOrRelatedIssue event={event} />, {organization});
  243. // We do not display any related issues because we only have 1 issue
  244. expect(await screen.queryByText('Slow DB Query')).not.toBeInTheDocument();
  245. expect(
  246. await screen.queryByText('AttributeError: Something Failed')
  247. ).not.toBeInTheDocument();
  248. // We do not display the timeline because we only have 1 event
  249. expect(await screen.queryByLabelText('Current Event')).not.toBeInTheDocument();
  250. expect(useRouteAnalyticsParams).toHaveBeenCalledWith({});
  251. });
  252. it('trace timeline works for plans with no global-views feature', async () => {
  253. // This test will call the endpoint without the global-views feature, thus,
  254. // we will only look at the current project (project: event.projectID) instead of passing -1
  255. MockApiClient.addMockResponse({
  256. url: `/organizations/${organization.slug}/events/`,
  257. body: issuePlatformBody,
  258. match: [
  259. MockApiClient.matchQuery({
  260. dataset: 'issuePlatform',
  261. project: event.projectID,
  262. }),
  263. ],
  264. });
  265. MockApiClient.addMockResponse({
  266. url: `/organizations/${organization.slug}/events/`,
  267. body: twoIssuesBody,
  268. match: [
  269. MockApiClient.matchQuery({
  270. dataset: 'discover',
  271. project: event.projectID,
  272. }),
  273. ],
  274. });
  275. render(<TraceTimeLineOrRelatedIssue event={event} />, {
  276. organization: OrganizationFixture({features: []}), // No global-views feature
  277. });
  278. expect(await screen.findByLabelText('Current Event')).toBeInTheDocument();
  279. });
  280. it('trace-related issue works for plans with no global-views feature', async () => {
  281. // This test will call the endpoint without the global-views feature, thus,
  282. // we will only look at the current project (project: event.projectID) instead of passing -1
  283. MockApiClient.addMockResponse({
  284. url: `/organizations/${organization.slug}/events/`,
  285. body: issuePlatformBody,
  286. match: [
  287. MockApiClient.matchQuery({
  288. dataset: 'issuePlatform',
  289. project: event.projectID,
  290. }),
  291. ],
  292. });
  293. MockApiClient.addMockResponse({
  294. url: `/organizations/${organization.slug}/events/`,
  295. body: discoverBody,
  296. match: [
  297. MockApiClient.matchQuery({
  298. dataset: 'discover',
  299. project: event.projectID,
  300. }),
  301. ],
  302. });
  303. // Used to determine the project badge
  304. MockApiClient.addMockResponse({
  305. url: `/organizations/${organization.slug}/projects/`,
  306. body: [],
  307. });
  308. render(<TraceTimeLineOrRelatedIssue event={event} />, {
  309. organization: OrganizationFixture({
  310. features: [],
  311. }),
  312. });
  313. expect(await screen.findByText('Slow DB Query')).toBeInTheDocument();
  314. });
  315. });
  316. function createEvent({
  317. culprit,
  318. title,
  319. error_value,
  320. event_type = 'error',
  321. stack_function = [],
  322. message = 'n/a',
  323. }: {
  324. culprit: string;
  325. title: string;
  326. error_value?: string[];
  327. event_type?: 'default' | 'error' | '';
  328. message?: string;
  329. stack_function?: string[];
  330. }) {
  331. const event = {
  332. culprit: culprit,
  333. timestamp: '2024-01-24T09:09:04+00:00',
  334. 'issue.id': 9999,
  335. project: 'foo',
  336. 'project.name': 'bar',
  337. title: title,
  338. id: '12345',
  339. transaction: 'n/a',
  340. 'event.type': event_type,
  341. };
  342. // Using this intermediary variable helps typescript
  343. let return_event;
  344. if (event['event.type'] === 'error') {
  345. return_event = {
  346. ...event,
  347. 'stack.function': stack_function,
  348. 'error.value': error_value,
  349. };
  350. } else if (event['event.type'] === '') {
  351. return_event = {
  352. ...event,
  353. message: message,
  354. };
  355. } else {
  356. return_event = event;
  357. }
  358. return return_event;
  359. }
  360. describe('getTitleSubtitleMessage()', () => {
  361. it('error event', () => {
  362. expect(
  363. getTitleSubtitleMessage(
  364. createEvent({
  365. culprit: '/api/0/sentry-app-installations/{uuid}/',
  366. title:
  367. 'ClientError: 404 Client Error: for url: https://api.clickup.com/sentry/webhook',
  368. error_value: [
  369. '404 Client Error: for url: https://api.clickup.com/sentry/webhook',
  370. ],
  371. })
  372. )
  373. ).toEqual({
  374. title: 'ClientError', // The colon and remainder of string are removed
  375. subtitle: '/api/0/sentry-app-installations/{uuid}/',
  376. message: '404 Client Error: for url: https://api.clickup.com/sentry/webhook',
  377. });
  378. });
  379. it('error event: It keeps the colon', () => {
  380. expect(
  381. getTitleSubtitleMessage(
  382. createEvent({
  383. culprit: 'billiard.pool in foo',
  384. title: 'WorkerLostError: ',
  385. error_value: ['some-other-error-value', 'The last error value'],
  386. })
  387. )
  388. ).toEqual({
  389. title: 'WorkerLostError:', // The colon is kept
  390. subtitle: 'billiard.pool in foo',
  391. message: 'The last error value',
  392. });
  393. });
  394. it('error event: No error_value', () => {
  395. expect(
  396. getTitleSubtitleMessage(
  397. createEvent({
  398. title: 'foo',
  399. culprit: 'bar',
  400. error_value: [''], // We always get a non-empty array
  401. })
  402. )
  403. ).toEqual({
  404. title: 'foo',
  405. subtitle: 'bar',
  406. message: '',
  407. });
  408. });
  409. it('default event', () => {
  410. expect(
  411. getTitleSubtitleMessage(
  412. createEvent({
  413. culprit: '/api/0/organizations/{organization_id_or_slug}/issues/',
  414. title: 'Query from referrer search.group_index is throttled',
  415. event_type: 'default',
  416. })
  417. )
  418. ).toEqual({
  419. title: 'Query from referrer search.group_index is throttled',
  420. subtitle: '',
  421. message: '/api/0/organizations/{organization_id_or_slug}/issues/',
  422. });
  423. });
  424. it('issue platform event', () => {
  425. expect(
  426. getTitleSubtitleMessage(
  427. createEvent({
  428. message: '/api/slow/ Slow DB Query SELECT "sentry_monitorcheckin"."monitor_id"',
  429. culprit: '/api/slow/',
  430. title: 'Slow DB Query',
  431. event_type: '',
  432. })
  433. )
  434. ).toEqual({
  435. title: 'Slow DB Query',
  436. subtitle: '/api/slow/',
  437. message: 'SELECT "sentry_monitorcheckin"."monitor_id"',
  438. });
  439. });
  440. });