index.spec.tsx 37 KB

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