index.spec.tsx 38 KB

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