index.spec.tsx 37 KB

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