index.spec.tsx 36 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964965966967968969970971972973974975976977978979980981982983984985986987988989990991992993994995996997998999100010011002100310041005100610071008100910101011101210131014101510161017101810191020102110221023102410251026102710281029103010311032103310341035103610371038103910401041104210431044104510461047104810491050105110521053105410551056105710581059106010611062106310641065106610671068106910701071107210731074107510761077107810791080108110821083108410851086108710881089109010911092109310941095109610971098109911001101110211031104110511061107110811091110111111121113111411151116111711181119112011211122112311241125112611271128112911301131113211331134113511361137113811391140114111421143114411451146114711481149115011511152115311541155115611571158115911601161116211631164116511661167116811691170117111721173117411751176117711781179118011811182118311841185118611871188118911901191119211931194119511961197119811991200120112021203120412051206120712081209121012111212121312141215121612171218121912201221122212231224122512261227122812291230123112321233123412351236123712381239124012411242124312441245124612471248124912501251125212531254125512561257125812591260126112621263126412651266126712681269127012711272
  1. import type {InjectedRouter} from 'react-router';
  2. import {OrganizationFixture} from 'sentry-fixture/organization';
  3. import {ProjectFixture} from 'sentry-fixture/project';
  4. import {TeamFixture} from 'sentry-fixture/team';
  5. import {initializeOrg} from 'sentry-test/initializeOrg';
  6. import {makeTestQueryClient} from 'sentry-test/queryClient';
  7. import {
  8. render,
  9. renderGlobalModal,
  10. screen,
  11. userEvent,
  12. waitFor,
  13. } from 'sentry-test/reactTestingLibrary';
  14. import OrganizationStore from 'sentry/stores/organizationStore';
  15. import ProjectsStore from 'sentry/stores/projectsStore';
  16. import TeamStore from 'sentry/stores/teamStore';
  17. import type {Project} from 'sentry/types/project';
  18. import {browserHistory} from 'sentry/utils/browserHistory';
  19. import {DiscoverDatasets} from 'sentry/utils/discover/types';
  20. import {MetricsCardinalityProvider} from 'sentry/utils/performance/contexts/metricsCardinality';
  21. import {
  22. MEPSetting,
  23. MEPState,
  24. } from 'sentry/utils/performance/contexts/metricsEnhancedSetting';
  25. import {QueryClientProvider} from 'sentry/utils/queryClient';
  26. import TransactionSummary from 'sentry/views/performance/transactionSummary/transactionOverview';
  27. const teams = [
  28. TeamFixture({id: '1', slug: 'team1', name: 'Team 1'}),
  29. TeamFixture({id: '2', slug: 'team2', name: 'Team 2'}),
  30. ];
  31. function initializeData({
  32. features: additionalFeatures = [],
  33. query = {},
  34. project: prj,
  35. projects,
  36. }: {
  37. features?: string[];
  38. project?: Project;
  39. projects?: Project[];
  40. query?: Record<string, any>;
  41. } = {}) {
  42. const features = ['discover-basic', 'performance-view', ...additionalFeatures];
  43. const project = prj ?? ProjectFixture({teams});
  44. const organization = OrganizationFixture({
  45. features,
  46. });
  47. const initialData = initializeOrg({
  48. organization,
  49. projects: projects ? projects : [project],
  50. router: {
  51. location: {
  52. query: {
  53. transaction: '/performance',
  54. project: project.id,
  55. transactionCursor: '1:0:0',
  56. ...query,
  57. },
  58. },
  59. },
  60. });
  61. ProjectsStore.loadInitialData(initialData.projects);
  62. TeamStore.loadInitialData(teams, false, null);
  63. return initialData;
  64. }
  65. function TestComponent({
  66. ...props
  67. }: React.ComponentProps<typeof TransactionSummary> & {
  68. router: InjectedRouter<Record<string, string>, any>;
  69. }) {
  70. if (!props.organization) {
  71. throw new Error('Missing organization');
  72. }
  73. return (
  74. <QueryClientProvider client={makeTestQueryClient()}>
  75. <MetricsCardinalityProvider
  76. organization={props.organization}
  77. location={props.location}
  78. >
  79. <TransactionSummary {...props} />
  80. </MetricsCardinalityProvider>
  81. </QueryClientProvider>
  82. );
  83. }
  84. describe('Performance > TransactionSummary', function () {
  85. let eventStatsMock: jest.Mock;
  86. beforeEach(function () {
  87. // eslint-disable-next-line no-console
  88. jest.spyOn(console, 'error').mockImplementation(jest.fn());
  89. MockApiClient.clearMockResponses();
  90. MockApiClient.addMockResponse({
  91. url: '/organizations/org-slug/projects/',
  92. body: [],
  93. });
  94. MockApiClient.addMockResponse({
  95. url: '/organizations/org-slug/tags/',
  96. body: [],
  97. });
  98. MockApiClient.addMockResponse({
  99. url: '/organizations/org-slug/tags/user.email/values/',
  100. body: [],
  101. });
  102. eventStatsMock = MockApiClient.addMockResponse({
  103. url: '/organizations/org-slug/events-stats/',
  104. body: {data: [[123, []]]},
  105. });
  106. MockApiClient.addMockResponse({
  107. url: '/organizations/org-slug/releases/stats/',
  108. body: [],
  109. });
  110. MockApiClient.addMockResponse({
  111. url: '/organizations/org-slug/issues/?limit=5&project=2&query=is%3Aunresolved%20transaction%3A%2Fperformance&sort=trends&statsPeriod=14d',
  112. body: [],
  113. });
  114. MockApiClient.addMockResponse({
  115. url: '/organizations/org-slug/users/',
  116. body: [],
  117. });
  118. MockApiClient.addMockResponse({
  119. url: '/organizations/org-slug/recent-searches/',
  120. body: [],
  121. });
  122. MockApiClient.addMockResponse({
  123. url: '/organizations/org-slug/recent-searches/',
  124. method: 'POST',
  125. body: [],
  126. });
  127. MockApiClient.addMockResponse({
  128. url: '/organizations/org-slug/sdk-updates/',
  129. body: [],
  130. });
  131. MockApiClient.addMockResponse({
  132. url: '/organizations/org-slug/prompts-activity/',
  133. body: {},
  134. });
  135. MockApiClient.addMockResponse({
  136. url: '/organizations/org-slug/events-facets-performance/',
  137. body: {},
  138. });
  139. // Events Mock totals for the sidebar and other summary data
  140. MockApiClient.addMockResponse({
  141. url: '/organizations/org-slug/events/',
  142. body: {
  143. meta: {
  144. fields: {
  145. 'count()': 'number',
  146. 'apdex()': 'number',
  147. 'count_miserable_user()': 'number',
  148. 'user_misery()': 'number',
  149. 'count_unique_user()': 'number',
  150. 'p95()': 'number',
  151. 'failure_rate()': 'number',
  152. 'tpm()': 'number',
  153. project_threshold_config: 'string',
  154. },
  155. },
  156. data: [
  157. {
  158. 'count()': 2,
  159. 'apdex()': 0.6,
  160. 'count_miserable_user()': 122,
  161. 'user_misery()': 0.114,
  162. 'count_unique_user()': 1,
  163. 'p95()': 750.123,
  164. 'failure_rate()': 1,
  165. 'tpm()': 1,
  166. project_threshold_config: ['duration', 300],
  167. },
  168. ],
  169. },
  170. match: [
  171. (_url, options) => {
  172. return options.query?.field?.includes('p95()');
  173. },
  174. ],
  175. });
  176. // [Metrics Enhanced] Events Mock totals for the sidebar and other summary data
  177. MockApiClient.addMockResponse({
  178. url: '/organizations/org-slug/events/',
  179. body: {
  180. meta: {
  181. fields: {
  182. 'count()': 'number',
  183. 'apdex()': 'number',
  184. 'count_miserable_user()': 'number',
  185. 'user_misery()': 'number',
  186. 'count_unique_user()': 'number',
  187. 'p95()': 'number',
  188. 'failure_rate()': 'number',
  189. 'tpm()': 'number',
  190. project_threshold_config: 'string',
  191. },
  192. isMetricsData: true,
  193. },
  194. data: [
  195. {
  196. 'count()': 200,
  197. 'apdex()': 0.5,
  198. 'count_miserable_user()': 120,
  199. 'user_misery()': 0.1,
  200. 'count_unique_user()': 100,
  201. 'p95()': 731.3132,
  202. 'failure_rate()': 1,
  203. 'tpm()': 100,
  204. project_threshold_config: ['duration', 300],
  205. },
  206. ],
  207. },
  208. match: [
  209. (_url, options) => {
  210. const isMetricsEnhanced =
  211. options.query?.dataset === DiscoverDatasets.METRICS_ENHANCED;
  212. return options.query?.field?.includes('p95()') && isMetricsEnhanced;
  213. },
  214. ],
  215. });
  216. // Events Mock unfiltered totals for percentage calculations
  217. MockApiClient.addMockResponse({
  218. url: '/organizations/org-slug/events/',
  219. body: {
  220. meta: {
  221. fields: {
  222. 'tpm()': 'number',
  223. },
  224. },
  225. data: [
  226. {
  227. 'tpm()': 1,
  228. },
  229. ],
  230. },
  231. match: [
  232. (_url, options) => {
  233. return (
  234. options.query?.field?.includes('tpm()') &&
  235. !options.query?.field?.includes('p95()')
  236. );
  237. },
  238. ],
  239. });
  240. // Events Mock count totals for histogram percentage calculations
  241. MockApiClient.addMockResponse({
  242. url: '/organizations/org-slug/events/',
  243. body: {
  244. meta: {
  245. fields: {
  246. 'count()': 'number',
  247. },
  248. },
  249. data: [
  250. {
  251. 'count()': 2,
  252. },
  253. ],
  254. },
  255. match: [
  256. (_url, options) => {
  257. return (
  258. options.query?.field?.includes('count()') &&
  259. !options.query?.field?.includes('p95()')
  260. );
  261. },
  262. ],
  263. });
  264. // Events Transaction list response
  265. MockApiClient.addMockResponse({
  266. url: '/organizations/org-slug/events/',
  267. headers: {
  268. Link:
  269. '<http://localhost/api/0/organizations/org-slug/events/?cursor=2:0:0>; rel="next"; results="true"; cursor="2:0:0",' +
  270. '<http://localhost/api/0/organizations/org-slug/events/?cursor=1:0:0>; rel="previous"; results="false"; cursor="1:0:0"',
  271. },
  272. body: {
  273. meta: {
  274. fields: {
  275. id: 'string',
  276. 'user.display': 'string',
  277. 'transaction.duration': 'duration',
  278. 'project.id': 'integer',
  279. timestamp: 'date',
  280. },
  281. },
  282. data: [
  283. {
  284. id: 'deadbeef',
  285. 'user.display': 'uhoh@example.com',
  286. 'transaction.duration': 400,
  287. 'project.id': 2,
  288. timestamp: '2020-05-21T15:31:18+00:00',
  289. },
  290. ],
  291. },
  292. match: [
  293. (_url, options) => {
  294. return options.query?.field?.includes('user.display');
  295. },
  296. ],
  297. });
  298. // Events Mock totals for status breakdown
  299. MockApiClient.addMockResponse({
  300. url: '/organizations/org-slug/events/',
  301. body: {
  302. meta: {
  303. fields: {
  304. 'transaction.status': 'string',
  305. 'count()': 'number',
  306. },
  307. },
  308. data: [
  309. {
  310. 'count()': 2,
  311. 'transaction.status': 'ok',
  312. },
  313. ],
  314. },
  315. match: [
  316. (_url, options) => {
  317. return options.query?.field?.includes('transaction.status');
  318. },
  319. ],
  320. });
  321. MockApiClient.addMockResponse({
  322. url: '/organizations/org-slug/events-facets/',
  323. body: [
  324. {
  325. key: 'release',
  326. topValues: [{count: 3, value: 'abcd123', name: 'abcd123'}],
  327. },
  328. {
  329. key: 'environment',
  330. topValues: [
  331. {count: 2, value: 'dev', name: 'dev'},
  332. {count: 1, value: 'prod', name: 'prod'},
  333. ],
  334. },
  335. {
  336. key: 'foo',
  337. topValues: [
  338. {count: 2, value: 'bar', name: 'bar'},
  339. {count: 1, value: 'baz', name: 'baz'},
  340. ],
  341. },
  342. {
  343. key: 'user',
  344. topValues: [
  345. {count: 2, value: 'id:100', name: '100'},
  346. {count: 1, value: 'id:101', name: '101'},
  347. ],
  348. },
  349. ],
  350. });
  351. MockApiClient.addMockResponse({
  352. url: '/organizations/org-slug/project-transaction-threshold-override/',
  353. method: 'GET',
  354. body: {
  355. threshold: '800',
  356. metric: 'lcp',
  357. },
  358. });
  359. MockApiClient.addMockResponse({
  360. url: '/organizations/org-slug/events-vitals/',
  361. body: {
  362. 'measurements.fcp': {
  363. poor: 3,
  364. meh: 100,
  365. good: 47,
  366. total: 150,
  367. p75: 1500,
  368. },
  369. 'measurements.lcp': {
  370. poor: 2,
  371. meh: 38,
  372. good: 40,
  373. total: 80,
  374. p75: 2750,
  375. },
  376. 'measurements.fid': {
  377. poor: 2,
  378. meh: 53,
  379. good: 5,
  380. total: 60,
  381. p75: 1000,
  382. },
  383. 'measurements.cls': {
  384. poor: 3,
  385. meh: 10,
  386. good: 4,
  387. total: 17,
  388. p75: 0.2,
  389. },
  390. },
  391. });
  392. MockApiClient.addMockResponse({
  393. method: 'GET',
  394. url: `/organizations/org-slug/key-transactions-list/`,
  395. body: teams.map(({id}) => ({
  396. team: id,
  397. count: 0,
  398. keyed: [],
  399. })),
  400. });
  401. MockApiClient.addMockResponse({
  402. url: '/organizations/org-slug/events-has-measurements/',
  403. body: {measurements: false},
  404. });
  405. MockApiClient.addMockResponse({
  406. url: '/organizations/org-slug/events-spans-performance/',
  407. body: [
  408. {
  409. op: 'ui.long-task',
  410. group: 'c777169faad84eb4',
  411. description: 'Main UI thread blocked',
  412. frequency: 713,
  413. count: 9040,
  414. avgOccurrences: null,
  415. sumExclusiveTime: 1743893.9822921753,
  416. p50ExclusiveTime: null,
  417. p75ExclusiveTime: 244.9998779296875,
  418. p95ExclusiveTime: null,
  419. p99ExclusiveTime: null,
  420. },
  421. ],
  422. });
  423. MockApiClient.addMockResponse({
  424. url: `/projects/org-slug/project-slug/profiling/functions/`,
  425. body: {functions: []},
  426. });
  427. MockApiClient.addMockResponse({
  428. method: 'GET',
  429. url: `/organizations/org-slug/metrics-compatibility/`,
  430. body: {
  431. compatible_projects: [],
  432. incompatible_projecs: [],
  433. },
  434. });
  435. MockApiClient.addMockResponse({
  436. method: 'GET',
  437. url: `/organizations/org-slug/metrics-compatibility-sums/`,
  438. body: {
  439. sum: {
  440. metrics: 100,
  441. metrics_null: 0,
  442. metrics_unparam: 0,
  443. },
  444. },
  445. });
  446. jest.spyOn(MEPSetting, 'get').mockImplementation(() => MEPState.AUTO);
  447. });
  448. afterEach(function () {
  449. MockApiClient.clearMockResponses();
  450. ProjectsStore.reset();
  451. jest.clearAllMocks();
  452. });
  453. describe('with events', function () {
  454. it('renders basic UI elements', async function () {
  455. const {organization, router} = initializeData();
  456. render(
  457. <TestComponent
  458. organization={organization}
  459. router={router}
  460. location={router.location}
  461. />,
  462. {
  463. router,
  464. organization,
  465. }
  466. );
  467. // It shows the header
  468. await screen.findByText('Transaction Summary');
  469. expect(screen.getByRole('heading', {name: '/performance'})).toBeInTheDocument();
  470. // It shows a chart
  471. expect(
  472. screen.getByRole('button', {name: 'Display Duration Breakdown'})
  473. ).toBeInTheDocument();
  474. // It shows a searchbar
  475. expect(screen.getByLabelText('Search events')).toBeInTheDocument();
  476. // It shows a table
  477. expect(screen.getByTestId('transactions-table')).toBeInTheDocument();
  478. // Ensure open in discover button exists.
  479. expect(screen.getByTestId('transaction-events-open')).toBeInTheDocument();
  480. // Ensure open issues button exists.
  481. expect(screen.getByRole('button', {name: 'Open in Issues'})).toBeInTheDocument();
  482. // Ensure transaction filter button exists
  483. expect(
  484. screen.getByRole('button', {name: 'Filter Slow Transactions (p95)'})
  485. ).toBeInTheDocument();
  486. // Ensure create alert from discover is hidden without metric alert
  487. expect(
  488. screen.queryByRole('button', {name: 'Create Alert'})
  489. ).not.toBeInTheDocument();
  490. // Ensure status breakdown exists
  491. expect(screen.getByText('Status Breakdown')).toBeInTheDocument();
  492. });
  493. it('renders feature flagged UI elements', function () {
  494. const {organization, router} = initializeData({
  495. features: ['incidents'],
  496. });
  497. render(
  498. <TestComponent
  499. organization={organization}
  500. router={router}
  501. location={router.location}
  502. />,
  503. {
  504. router,
  505. organization,
  506. }
  507. );
  508. // Ensure create alert from discover is shown with metric alerts
  509. expect(screen.getByRole('button', {name: 'Create Alert'})).toBeInTheDocument();
  510. });
  511. it('renders Web Vitals widget', async function () {
  512. const {organization, router} = initializeData({
  513. project: ProjectFixture({teams, platform: 'javascript'}),
  514. query: {
  515. query:
  516. 'transaction.duration:<15m transaction.op:pageload event.type:transaction transaction:/organizations/:orgId/issues/',
  517. },
  518. });
  519. render(
  520. <TestComponent
  521. organization={organization}
  522. router={router}
  523. location={router.location}
  524. />,
  525. {
  526. router,
  527. organization,
  528. }
  529. );
  530. // It renders the web vitals widget
  531. await screen.findByRole('heading', {name: 'Web Vitals'});
  532. await waitFor(() => {
  533. expect(screen.getAllByTestId('vital-status')).toHaveLength(3);
  534. });
  535. const vitalStatues = screen.getAllByTestId('vital-status');
  536. expect(vitalStatues[0]).toHaveTextContent('31%');
  537. expect(vitalStatues[1]).toHaveTextContent('65%');
  538. expect(vitalStatues[2]).toHaveTextContent('3%');
  539. });
  540. it('renders sidebar widgets', async function () {
  541. const {organization, router} = initializeData({});
  542. render(
  543. <TestComponent
  544. organization={organization}
  545. router={router}
  546. location={router.location}
  547. />,
  548. {
  549. router,
  550. organization,
  551. }
  552. );
  553. // Renders Apdex widget
  554. await screen.findByRole('heading', {name: 'Apdex'});
  555. expect(await screen.findByTestId('apdex-summary-value')).toHaveTextContent('0.6');
  556. // Renders Failure Rate widget
  557. expect(screen.getByRole('heading', {name: 'Failure Rate'})).toBeInTheDocument();
  558. expect(screen.getByTestId('failure-rate-summary-value')).toHaveTextContent('100%');
  559. });
  560. it('renders project picker modal when no url does not have project id', async function () {
  561. MockApiClient.addMockResponse({
  562. url: '/organizations/org-slug/events/',
  563. body: {
  564. meta: {
  565. fields: {
  566. project: 'string',
  567. 'count()': 'number',
  568. },
  569. },
  570. data: [
  571. {
  572. 'count()': 2,
  573. project: 'proj-slug-1',
  574. },
  575. {
  576. 'count()': 3,
  577. project: 'proj-slug-2',
  578. },
  579. ],
  580. },
  581. match: [
  582. (_url, options) => {
  583. return options.query?.field?.includes('project');
  584. },
  585. ],
  586. });
  587. const projects = [
  588. ProjectFixture({
  589. slug: 'proj-slug-1',
  590. id: '1',
  591. name: 'Project Name 1',
  592. }),
  593. ProjectFixture({
  594. slug: 'proj-slug-2',
  595. id: '2',
  596. name: 'Project Name 2',
  597. }),
  598. ];
  599. OrganizationStore.onUpdate(OrganizationFixture({slug: 'org-slug'}), {
  600. replace: true,
  601. });
  602. const {organization, router} = initializeData({projects});
  603. const spy = jest.spyOn(router, 'replace');
  604. // Ensure project id is not in path
  605. delete router.location.query.project;
  606. render(
  607. <TestComponent
  608. organization={organization}
  609. router={router}
  610. location={router.location}
  611. />,
  612. {router, organization}
  613. );
  614. renderGlobalModal();
  615. const firstProjectOption = await screen.findByText('proj-slug-1');
  616. expect(firstProjectOption).toBeInTheDocument();
  617. expect(screen.getByText('proj-slug-2')).toBeInTheDocument();
  618. expect(screen.getByText('My Projects')).toBeInTheDocument();
  619. await userEvent.click(firstProjectOption);
  620. expect(spy).toHaveBeenCalledWith(
  621. '/organizations/org-slug/performance/summary/?transaction=/performance&statsPeriod=14d&referrer=performance-transaction-summary&transactionCursor=1:0:0&project=1'
  622. );
  623. });
  624. it('fetches transaction threshold', function () {
  625. const {organization, router} = initializeData();
  626. const getTransactionThresholdMock = MockApiClient.addMockResponse({
  627. url: '/organizations/org-slug/project-transaction-threshold-override/',
  628. method: 'GET',
  629. body: {
  630. threshold: '800',
  631. metric: 'lcp',
  632. },
  633. });
  634. const getProjectThresholdMock = MockApiClient.addMockResponse({
  635. url: '/projects/org-slug/project-slug/transaction-threshold/configure/',
  636. method: 'GET',
  637. body: {
  638. threshold: '200',
  639. metric: 'duration',
  640. },
  641. });
  642. render(
  643. <TestComponent
  644. organization={organization}
  645. router={router}
  646. location={router.location}
  647. />,
  648. {
  649. router,
  650. organization,
  651. }
  652. );
  653. expect(getTransactionThresholdMock).toHaveBeenCalledTimes(1);
  654. expect(getProjectThresholdMock).not.toHaveBeenCalled();
  655. });
  656. it('fetches project transaction threshdold', async function () {
  657. const {organization, router} = initializeData();
  658. const getTransactionThresholdMock = MockApiClient.addMockResponse({
  659. url: '/organizations/org-slug/project-transaction-threshold-override/',
  660. method: 'GET',
  661. statusCode: 404,
  662. });
  663. const getProjectThresholdMock = MockApiClient.addMockResponse({
  664. url: '/projects/org-slug/project-slug/transaction-threshold/configure/',
  665. method: 'GET',
  666. body: {
  667. threshold: '200',
  668. metric: 'duration',
  669. },
  670. });
  671. render(
  672. <TestComponent
  673. organization={organization}
  674. router={router}
  675. location={router.location}
  676. />,
  677. {
  678. router,
  679. organization,
  680. }
  681. );
  682. await screen.findByText('Transaction Summary');
  683. expect(getTransactionThresholdMock).toHaveBeenCalledTimes(1);
  684. expect(getProjectThresholdMock).toHaveBeenCalledTimes(1);
  685. });
  686. it('triggers a navigation on search', async function () {
  687. const {organization, router} = initializeData();
  688. render(
  689. <TestComponent
  690. organization={organization}
  691. router={router}
  692. location={router.location}
  693. />,
  694. {
  695. router,
  696. organization,
  697. }
  698. );
  699. // Fill out the search box, and submit it.
  700. await userEvent.type(
  701. screen.getByLabelText('Search events'),
  702. 'user.email:uhoh*{enter}'
  703. );
  704. // Check the navigation.
  705. expect(browserHistory.push).toHaveBeenCalledTimes(1);
  706. expect(browserHistory.push).toHaveBeenCalledWith({
  707. pathname: undefined,
  708. query: {
  709. transaction: '/performance',
  710. project: '2',
  711. statsPeriod: '14d',
  712. query: 'user.email:uhoh*',
  713. transactionCursor: '1:0:0',
  714. },
  715. });
  716. });
  717. it('can mark a transaction as key', async function () {
  718. const {organization, router} = initializeData();
  719. render(
  720. <TestComponent
  721. organization={organization}
  722. router={router}
  723. location={router.location}
  724. />,
  725. {
  726. router,
  727. organization,
  728. }
  729. );
  730. const mockUpdate = MockApiClient.addMockResponse({
  731. url: `/organizations/org-slug/key-transactions/`,
  732. method: 'POST',
  733. body: {},
  734. });
  735. await screen.findByRole('button', {name: 'Star for Team'});
  736. // Click the key transaction button
  737. await userEvent.click(screen.getByRole('button', {name: 'Star for Team'}));
  738. await userEvent.click(screen.getByRole('option', {name: '#team1'}));
  739. // Ensure request was made.
  740. expect(mockUpdate).toHaveBeenCalled();
  741. });
  742. it('triggers a navigation on transaction filter', async function () {
  743. const {organization, router} = initializeData();
  744. render(
  745. <TestComponent
  746. organization={organization}
  747. router={router}
  748. location={router.location}
  749. />,
  750. {
  751. router,
  752. organization,
  753. }
  754. );
  755. await screen.findByText('Transaction Summary');
  756. await waitFor(() => {
  757. expect(screen.queryByTestId('loading-indicator')).not.toBeInTheDocument();
  758. });
  759. // Open the transaction filter dropdown
  760. await userEvent.click(
  761. screen.getByRole('button', {name: 'Filter Slow Transactions (p95)'})
  762. );
  763. await userEvent.click(screen.getAllByText('Slow Transactions (p95)')[1]);
  764. // Check the navigation.
  765. expect(browserHistory.push).toHaveBeenCalledWith({
  766. pathname: undefined,
  767. query: {
  768. transaction: '/performance',
  769. project: '2',
  770. showTransactions: 'slow',
  771. transactionCursor: undefined,
  772. },
  773. });
  774. });
  775. it('renders pagination buttons', async function () {
  776. const {organization, router} = initializeData();
  777. render(
  778. <TestComponent
  779. organization={organization}
  780. router={router}
  781. location={router.location}
  782. />,
  783. {
  784. router,
  785. organization,
  786. }
  787. );
  788. await screen.findByText('Transaction Summary');
  789. expect(await screen.findByLabelText('Previous')).toBeInTheDocument();
  790. // Click the 'next' button
  791. await userEvent.click(screen.getByLabelText('Next'));
  792. // Check the navigation.
  793. expect(browserHistory.push).toHaveBeenCalledWith({
  794. pathname: undefined,
  795. query: {
  796. transaction: '/performance',
  797. project: '2',
  798. transactionCursor: '2:0:0',
  799. },
  800. });
  801. });
  802. it('forwards conditions to related issues', async function () {
  803. const issueGet = MockApiClient.addMockResponse({
  804. url: '/organizations/org-slug/issues/?limit=5&project=2&query=tag%3Avalue%20is%3Aunresolved%20transaction%3A%2Fperformance&sort=trends&statsPeriod=14d',
  805. body: [],
  806. });
  807. const {organization, router} = initializeData({
  808. query: {query: 'tag:value'},
  809. });
  810. render(
  811. <TestComponent
  812. organization={organization}
  813. router={router}
  814. location={router.location}
  815. />,
  816. {
  817. router,
  818. organization,
  819. }
  820. );
  821. await screen.findByText('Transaction Summary');
  822. expect(issueGet).toHaveBeenCalled();
  823. });
  824. it('does not forward event type to related issues', async function () {
  825. const issueGet = MockApiClient.addMockResponse({
  826. url: '/organizations/org-slug/issues/?limit=5&project=2&query=tag%3Avalue%20is%3Aunresolved%20transaction%3A%2Fperformance&sort=trends&statsPeriod=14d',
  827. body: [],
  828. match: [
  829. (_, options) => {
  830. // event.type must NOT be in the query params
  831. return !options.query?.query?.includes('event.type');
  832. },
  833. ],
  834. });
  835. const {organization, router} = initializeData({
  836. query: {query: 'tag:value event.type:transaction'},
  837. });
  838. render(
  839. <TestComponent
  840. organization={organization}
  841. router={router}
  842. location={router.location}
  843. />,
  844. {
  845. router,
  846. organization,
  847. }
  848. );
  849. await screen.findByText('Transaction Summary');
  850. expect(issueGet).toHaveBeenCalled();
  851. });
  852. it('renders the suspect spans table if the feature is enabled', async function () {
  853. MockApiClient.addMockResponse({
  854. url: '/organizations/org-slug/events-spans-performance/',
  855. body: [],
  856. });
  857. const {organization, router} = initializeData();
  858. render(
  859. <TestComponent
  860. organization={organization}
  861. router={router}
  862. location={router.location}
  863. />,
  864. {
  865. router,
  866. organization,
  867. }
  868. );
  869. expect(await screen.findByText('Suspect Spans')).toBeInTheDocument();
  870. });
  871. it('adds search condition on transaction status when clicking on status breakdown', async function () {
  872. const {organization, router} = initializeData();
  873. render(
  874. <TestComponent
  875. organization={organization}
  876. router={router}
  877. location={router.location}
  878. />,
  879. {
  880. router,
  881. organization,
  882. }
  883. );
  884. await screen.findByTestId('status-ok');
  885. await userEvent.click(screen.getByTestId('status-ok'));
  886. expect(browserHistory.push).toHaveBeenCalledTimes(1);
  887. expect(browserHistory.push).toHaveBeenCalledWith(
  888. expect.objectContaining({
  889. query: expect.objectContaining({
  890. query: expect.stringContaining('transaction.status:ok'),
  891. }),
  892. })
  893. );
  894. });
  895. it('appends tag value to existing query when clicked', async function () {
  896. const {organization, router} = initializeData();
  897. render(
  898. <TestComponent
  899. organization={organization}
  900. router={router}
  901. location={router.location}
  902. />,
  903. {
  904. router,
  905. organization,
  906. }
  907. );
  908. await screen.findByText('Tag Summary');
  909. await userEvent.click(
  910. await screen.findByLabelText(
  911. 'environment, dev, 100% of all events. View events with this tag value.'
  912. )
  913. );
  914. await userEvent.click(
  915. await screen.findByLabelText(
  916. 'foo, bar, 100% of all events. View events with this tag value.'
  917. )
  918. );
  919. expect(router.push).toHaveBeenCalledTimes(2);
  920. expect(router.push).toHaveBeenNthCalledWith(1, {
  921. query: {
  922. project: '2',
  923. query: 'tags[environment]:dev',
  924. transaction: '/performance',
  925. transactionCursor: '1:0:0',
  926. },
  927. });
  928. expect(router.push).toHaveBeenNthCalledWith(2, {
  929. query: {
  930. project: '2',
  931. query: 'foo:bar',
  932. transaction: '/performance',
  933. transactionCursor: '1:0:0',
  934. },
  935. });
  936. });
  937. it('does not use MEP dataset for stats query without features', async function () {
  938. const {organization, router} = initializeData({
  939. query: {query: 'transaction.op:pageload'}, // transaction.op is covered by the metrics dataset
  940. features: [''], // No 'dynamic-sampling' feature to indicate it can use metrics dataset or metrics enhanced.
  941. });
  942. render(
  943. <TestComponent
  944. organization={organization}
  945. router={router}
  946. location={router.location}
  947. />,
  948. {
  949. router,
  950. organization,
  951. }
  952. );
  953. await screen.findByText('Transaction Summary');
  954. await screen.findByRole('heading', {name: 'Apdex'});
  955. expect(await screen.findByTestId('apdex-summary-value')).toHaveTextContent('0.6');
  956. expect(eventStatsMock).toHaveBeenNthCalledWith(
  957. 1,
  958. expect.anything(),
  959. expect.objectContaining({
  960. query: expect.objectContaining({
  961. environment: [],
  962. interval: '30m',
  963. partial: '1',
  964. project: [2],
  965. query:
  966. 'transaction.op:pageload event.type:transaction transaction:/performance',
  967. referrer: 'api.performance.transaction-summary.duration-chart',
  968. statsPeriod: '14d',
  969. yAxis: [
  970. 'p50(transaction.duration)',
  971. 'p75(transaction.duration)',
  972. 'p95(transaction.duration)',
  973. 'p99(transaction.duration)',
  974. 'p100(transaction.duration)',
  975. 'avg(transaction.duration)',
  976. ],
  977. }),
  978. })
  979. );
  980. });
  981. it('uses MEP dataset for stats query', async function () {
  982. const {organization, router} = initializeData({
  983. query: {query: 'transaction.op:pageload'}, // transaction.op is covered by the metrics dataset
  984. features: ['dynamic-sampling', 'mep-rollout-flag'],
  985. });
  986. render(
  987. <TestComponent
  988. organization={organization}
  989. router={router}
  990. location={router.location}
  991. />,
  992. {
  993. router,
  994. organization,
  995. }
  996. );
  997. await screen.findByText('Transaction Summary');
  998. // Renders Apdex widget
  999. await screen.findByRole('heading', {name: 'Apdex'});
  1000. expect(await screen.findByTestId('apdex-summary-value')).toHaveTextContent('0.5');
  1001. expect(eventStatsMock).toHaveBeenNthCalledWith(
  1002. 1,
  1003. expect.anything(),
  1004. expect.objectContaining({
  1005. query: expect.objectContaining({
  1006. query:
  1007. 'transaction.op:pageload event.type:transaction transaction:/performance',
  1008. dataset: 'metricsEnhanced',
  1009. }),
  1010. })
  1011. );
  1012. // Renders Failure Rate widget
  1013. expect(screen.getByRole('heading', {name: 'Failure Rate'})).toBeInTheDocument();
  1014. expect(screen.getByTestId('failure-rate-summary-value')).toHaveTextContent('100%');
  1015. expect(
  1016. screen.queryByTestId('search-metrics-fallback-warning')
  1017. ).not.toBeInTheDocument();
  1018. });
  1019. it('does not use MEP dataset for stats query if cardinality fallback fails', async function () {
  1020. MockApiClient.addMockResponse({
  1021. method: 'GET',
  1022. url: `/organizations/org-slug/metrics-compatibility-sums/`,
  1023. body: {
  1024. sum: {
  1025. metrics: 100,
  1026. metrics_null: 100,
  1027. metrics_unparam: 0,
  1028. },
  1029. },
  1030. });
  1031. const {organization, router} = initializeData({
  1032. query: {query: 'transaction.op:pageload'}, // transaction.op is covered by the metrics dataset
  1033. features: ['dynamic-sampling', 'mep-rollout-flag'],
  1034. });
  1035. render(
  1036. <TestComponent
  1037. organization={organization}
  1038. router={router}
  1039. location={router.location}
  1040. />,
  1041. {
  1042. router,
  1043. organization,
  1044. }
  1045. );
  1046. await screen.findByText('Transaction Summary');
  1047. // Renders Apdex widget
  1048. await screen.findByRole('heading', {name: 'Apdex'});
  1049. expect(await screen.findByTestId('apdex-summary-value')).toHaveTextContent('0.6');
  1050. expect(eventStatsMock).toHaveBeenNthCalledWith(
  1051. 1,
  1052. expect.anything(),
  1053. expect.objectContaining({
  1054. query: expect.objectContaining({
  1055. query:
  1056. 'transaction.op:pageload event.type:transaction transaction:/performance',
  1057. }),
  1058. })
  1059. );
  1060. });
  1061. it('uses MEP dataset for stats query and shows fallback warning', async function () {
  1062. MockApiClient.addMockResponse({
  1063. url: '/organizations/org-slug/issues/?limit=5&project=2&query=has%3Anot-compatible%20is%3Aunresolved%20transaction%3A%2Fperformance&sort=trends&statsPeriod=14d',
  1064. body: [],
  1065. });
  1066. MockApiClient.addMockResponse({
  1067. url: '/organizations/org-slug/events/',
  1068. body: {
  1069. meta: {
  1070. fields: {
  1071. 'count()': 'number',
  1072. 'apdex()': 'number',
  1073. 'count_miserable_user()': 'number',
  1074. 'user_misery()': 'number',
  1075. 'count_unique_user()': 'number',
  1076. 'p95()': 'number',
  1077. 'failure_rate()': 'number',
  1078. 'tpm()': 'number',
  1079. project_threshold_config: 'string',
  1080. },
  1081. isMetricsData: false, // The total response is setting the metrics fallback behaviour.
  1082. },
  1083. data: [
  1084. {
  1085. 'count()': 200,
  1086. 'apdex()': 0.5,
  1087. 'count_miserable_user()': 120,
  1088. 'user_misery()': 0.1,
  1089. 'count_unique_user()': 100,
  1090. 'p95()': 731.3132,
  1091. 'failure_rate()': 1,
  1092. 'tpm()': 100,
  1093. project_threshold_config: ['duration', 300],
  1094. },
  1095. ],
  1096. },
  1097. match: [
  1098. (_url, options) => {
  1099. const isMetricsEnhanced =
  1100. options.query?.dataset === DiscoverDatasets.METRICS_ENHANCED;
  1101. return (
  1102. options.query?.field?.includes('p95()') &&
  1103. isMetricsEnhanced &&
  1104. options.query?.query?.includes('not-compatible')
  1105. );
  1106. },
  1107. ],
  1108. });
  1109. const {organization, router} = initializeData({
  1110. query: {query: 'transaction.op:pageload has:not-compatible'}, // Adds incompatible w/ metrics tag
  1111. features: ['dynamic-sampling', 'mep-rollout-flag'],
  1112. });
  1113. render(
  1114. <TestComponent
  1115. organization={organization}
  1116. router={router}
  1117. location={router.location}
  1118. />,
  1119. {
  1120. router,
  1121. organization,
  1122. }
  1123. );
  1124. await screen.findByText('Transaction Summary');
  1125. // Renders Apdex widget
  1126. await screen.findByRole('heading', {name: 'Apdex'});
  1127. expect(await screen.findByTestId('apdex-summary-value')).toHaveTextContent('0.5');
  1128. expect(eventStatsMock).toHaveBeenNthCalledWith(
  1129. 1,
  1130. expect.anything(),
  1131. expect.objectContaining({
  1132. query: expect.objectContaining({
  1133. query:
  1134. 'transaction.op:pageload has:not-compatible event.type:transaction transaction:/performance',
  1135. dataset: 'metricsEnhanced',
  1136. }),
  1137. })
  1138. );
  1139. // Renders Failure Rate widget
  1140. expect(screen.getByRole('heading', {name: 'Failure Rate'})).toBeInTheDocument();
  1141. expect(screen.getByTestId('failure-rate-summary-value')).toHaveTextContent('100%');
  1142. expect(screen.getByTestId('search-metrics-fallback-warning')).toBeInTheDocument();
  1143. });
  1144. });
  1145. });