index.spec.tsx 37 KB

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