utils.spec.tsx 39 KB

1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586878889909192939495969798991001011021031041051061071081091101111121131141151161171181191201211221231241251261271281291301311321331341351361371381391401411421431441451461471481491501511521531541551561571581591601611621631641651661671681691701711721731741751761771781791801811821831841851861871881891901911921931941951961971981992002012022032042052062072082092102112122132142152162172182192202212222232242252262272282292302312322332342352362372382392402412422432442452462472482492502512522532542552562572582592602612622632642652662672682692702712722732742752762772782792802812822832842852862872882892902912922932942952962972982993003013023033043053063073083093103113123133143153163173183193203213223233243253263273283293303313323333343353363373383393403413423433443453463473483493503513523533543553563573583593603613623633643653663673683693703713723733743753763773783793803813823833843853863873883893903913923933943953963973983994004014024034044054064074084094104114124134144154164174184194204214224234244254264274284294304314324334344354364374384394404414424434444454464474484494504514524534544554564574584594604614624634644654664674684694704714724734744754764774784794804814824834844854864874884894904914924934944954964974984995005015025035045055065075085095105115125135145155165175185195205215225235245255265275285295305315325335345355365375385395405415425435445455465475485495505515525535545555565575585595605615625635645655665675685695705715725735745755765775785795805815825835845855865875885895905915925935945955965975985996006016026036046056066076086096106116126136146156166176186196206216226236246256266276286296306316326336346356366376386396406416426436446456466476486496506516526536546556566576586596606616626636646656666676686696706716726736746756766776786796806816826836846856866876886896906916926936946956966976986997007017027037047057067077087097107117127137147157167177187197207217227237247257267277287297307317327337347357367377387397407417427437447457467477487497507517527537547557567577587597607617627637647657667677687697707717727737747757767777787797807817827837847857867877887897907917927937947957967977987998008018028038048058068078088098108118128138148158168178188198208218228238248258268278288298308318328338348358368378388398408418428438448458468478488498508518528538548558568578588598608618628638648658668678688698708718728738748758768778788798808818828838848858868878888898908918928938948958968978988999009019029039049059069079089099109119129139149159169179189199209219229239249259269279289299309319329339349359369379389399409419429439449459469479489499509519529539549559569579589599609619629639649659669679689699709719729739749759769779789799809819829839849859869879889899909919929939949959969979989991000100110021003100410051006100710081009101010111012101310141015101610171018101910201021102210231024102510261027102810291030103110321033103410351036103710381039104010411042104310441045104610471048104910501051105210531054105510561057105810591060106110621063106410651066106710681069107010711072107310741075107610771078107910801081108210831084108510861087108810891090109110921093109410951096109710981099110011011102110311041105110611071108110911101111111211131114111511161117111811191120112111221123112411251126112711281129113011311132113311341135113611371138113911401141114211431144114511461147114811491150115111521153115411551156115711581159116011611162116311641165116611671168116911701171117211731174117511761177117811791180118111821183118411851186118711881189119011911192119311941195119611971198119912001201120212031204120512061207120812091210121112121213121412151216121712181219122012211222122312241225122612271228122912301231123212331234123512361237123812391240124112421243124412451246124712481249125012511252125312541255125612571258125912601261126212631264126512661267126812691270127112721273127412751276127712781279128012811282128312841285128612871288128912901291
  1. import type {Location} from 'history';
  2. import {EventFixture} from 'sentry-fixture/event';
  3. import {LocationFixture} from 'sentry-fixture/locationFixture';
  4. import {OrganizationFixture} from 'sentry-fixture/organization';
  5. import {RouterFixture} from 'sentry-fixture/routerFixture';
  6. import {initializeOrg} from 'sentry-test/initializeOrg';
  7. import {openAddToDashboardModal} from 'sentry/actionCreators/modal';
  8. import {COL_WIDTH_UNDEFINED} from 'sentry/components/gridEditable';
  9. import type {InjectedRouter} from 'sentry/types/legacyReactRouter';
  10. import type {Organization} from 'sentry/types/organization';
  11. import type {EventViewOptions} from 'sentry/utils/discover/eventView';
  12. import EventView from 'sentry/utils/discover/eventView';
  13. import {DisplayModes} from 'sentry/utils/discover/types';
  14. import {
  15. DashboardWidgetSource,
  16. DisplayType,
  17. WidgetType,
  18. } from 'sentry/views/dashboards/types';
  19. import {
  20. constructAddQueryToDashboardLink,
  21. decodeColumnOrder,
  22. downloadAsCsv,
  23. eventViewToWidgetQuery,
  24. generateFieldOptions,
  25. getExpandedResults,
  26. handleAddQueryToDashboard,
  27. pushEventViewToLocation,
  28. } from 'sentry/views/discover/utils';
  29. jest.mock('sentry/actionCreators/modal');
  30. const baseView: EventViewOptions = {
  31. display: undefined,
  32. start: undefined,
  33. end: undefined,
  34. id: '0',
  35. name: undefined,
  36. fields: [],
  37. createdBy: undefined,
  38. environment: [],
  39. project: [],
  40. query: '',
  41. sorts: [],
  42. statsPeriod: undefined,
  43. team: [],
  44. topEvents: undefined,
  45. };
  46. describe('decodeColumnOrder', function () {
  47. it('can decode 0 elements', function () {
  48. const results = decodeColumnOrder([]);
  49. expect(Array.isArray(results)).toBeTruthy();
  50. expect(results).toHaveLength(0);
  51. });
  52. it('can decode fields', function () {
  53. const results = decodeColumnOrder([{field: 'title', width: 123}]);
  54. expect(Array.isArray(results)).toBeTruthy();
  55. expect(results[0]).toEqual({
  56. key: 'title',
  57. name: 'title',
  58. column: {
  59. kind: 'field',
  60. field: 'title',
  61. },
  62. width: 123,
  63. isSortable: false,
  64. type: 'string',
  65. });
  66. });
  67. it('can decode measurement fields', function () {
  68. const results = decodeColumnOrder([{field: 'measurements.foo', width: 123}]);
  69. expect(Array.isArray(results)).toBeTruthy();
  70. expect(results[0]).toEqual({
  71. key: 'measurements.foo',
  72. name: 'measurements.foo',
  73. column: {
  74. kind: 'field',
  75. field: 'measurements.foo',
  76. },
  77. width: 123,
  78. isSortable: false,
  79. type: 'number',
  80. });
  81. });
  82. it('can decode span op breakdown fields', function () {
  83. const results = decodeColumnOrder([{field: 'spans.foo', width: 123}]);
  84. expect(Array.isArray(results)).toBeTruthy();
  85. expect(results[0]).toEqual({
  86. key: 'spans.foo',
  87. name: 'spans.foo',
  88. column: {
  89. kind: 'field',
  90. field: 'spans.foo',
  91. },
  92. width: 123,
  93. isSortable: false,
  94. type: 'duration',
  95. });
  96. });
  97. it('can decode aggregate functions with no arguments', function () {
  98. let results = decodeColumnOrder([{field: 'count()', width: 123}]);
  99. expect(Array.isArray(results)).toBeTruthy();
  100. expect(results[0]).toEqual({
  101. key: 'count()',
  102. name: 'count()',
  103. column: {
  104. kind: 'function',
  105. function: ['count', '', undefined, undefined],
  106. },
  107. width: 123,
  108. isSortable: true,
  109. type: 'number',
  110. });
  111. results = decodeColumnOrder([{field: 'p75()', width: 123}]);
  112. expect(results[0]!.type).toBe('duration');
  113. results = decodeColumnOrder([{field: 'p99()', width: 123}]);
  114. expect(results[0]!.type).toBe('duration');
  115. });
  116. it('can decode elements with aggregate functions with arguments', function () {
  117. const results = decodeColumnOrder([{field: 'avg(transaction.duration)'}]);
  118. expect(Array.isArray(results)).toBeTruthy();
  119. expect(results[0]).toEqual({
  120. key: 'avg(transaction.duration)',
  121. name: 'avg(transaction.duration)',
  122. column: {
  123. kind: 'function',
  124. function: ['avg', 'transaction.duration', undefined, undefined],
  125. },
  126. width: COL_WIDTH_UNDEFINED,
  127. isSortable: true,
  128. type: 'duration',
  129. });
  130. });
  131. it('can decode elements with aggregate functions with multiple arguments', function () {
  132. const results = decodeColumnOrder([
  133. {field: 'percentile(transaction.duration, 0.65)'},
  134. ]);
  135. expect(Array.isArray(results)).toBeTruthy();
  136. expect(results[0]).toEqual({
  137. key: 'percentile(transaction.duration, 0.65)',
  138. name: 'percentile(transaction.duration, 0.65)',
  139. column: {
  140. kind: 'function',
  141. function: ['percentile', 'transaction.duration', '0.65', undefined],
  142. },
  143. width: COL_WIDTH_UNDEFINED,
  144. isSortable: true,
  145. type: 'duration',
  146. });
  147. });
  148. it('can decode elements with aggregate functions using measurements', function () {
  149. const results = decodeColumnOrder([{field: 'avg(measurements.foo)'}]);
  150. expect(Array.isArray(results)).toBeTruthy();
  151. expect(results[0]).toEqual({
  152. key: 'avg(measurements.foo)',
  153. name: 'avg(measurements.foo)',
  154. column: {
  155. kind: 'function',
  156. function: ['avg', 'measurements.foo', undefined, undefined],
  157. },
  158. width: COL_WIDTH_UNDEFINED,
  159. isSortable: true,
  160. type: 'number',
  161. });
  162. });
  163. it('can decode elements with aggregate functions with multiple arguments using measurements', function () {
  164. const results = decodeColumnOrder([{field: 'percentile(measurements.lcp, 0.65)'}]);
  165. expect(Array.isArray(results)).toBeTruthy();
  166. expect(results[0]).toEqual({
  167. key: 'percentile(measurements.lcp, 0.65)',
  168. name: 'percentile(measurements.lcp, 0.65)',
  169. column: {
  170. kind: 'function',
  171. function: ['percentile', 'measurements.lcp', '0.65', undefined],
  172. },
  173. width: COL_WIDTH_UNDEFINED,
  174. isSortable: true,
  175. type: 'duration',
  176. });
  177. });
  178. it('can decode elements with aggregate functions using span op breakdowns', function () {
  179. const results = decodeColumnOrder([{field: 'avg(spans.foo)'}]);
  180. expect(Array.isArray(results)).toBeTruthy();
  181. expect(results[0]).toEqual({
  182. key: 'avg(spans.foo)',
  183. name: 'avg(spans.foo)',
  184. column: {
  185. kind: 'function',
  186. function: ['avg', 'spans.foo', undefined, undefined],
  187. },
  188. width: COL_WIDTH_UNDEFINED,
  189. isSortable: true,
  190. type: 'duration',
  191. });
  192. });
  193. it('can decode elements with aggregate functions with multiple arguments using span op breakdowns', function () {
  194. const results = decodeColumnOrder([{field: 'percentile(spans.lcp, 0.65)'}]);
  195. expect(Array.isArray(results)).toBeTruthy();
  196. expect(results[0]).toEqual({
  197. key: 'percentile(spans.lcp, 0.65)',
  198. name: 'percentile(spans.lcp, 0.65)',
  199. column: {
  200. kind: 'function',
  201. function: ['percentile', 'spans.lcp', '0.65', undefined],
  202. },
  203. width: COL_WIDTH_UNDEFINED,
  204. isSortable: true,
  205. type: 'duration',
  206. });
  207. });
  208. });
  209. describe('pushEventViewToLocation', function () {
  210. const state: EventViewOptions = {
  211. ...baseView,
  212. id: '1234',
  213. name: 'best query',
  214. fields: [{field: 'count()', width: 420}, {field: 'project.id'}],
  215. sorts: [{field: 'count', kind: 'desc'}],
  216. query: 'event.type:error',
  217. project: [42],
  218. start: '2019-10-01T00:00:00',
  219. end: '2019-10-02T00:00:00',
  220. statsPeriod: '14d',
  221. environment: ['staging'],
  222. };
  223. const location = LocationFixture({
  224. query: {
  225. bestCountry: 'canada',
  226. },
  227. });
  228. it('correct query string object pushed to history', function () {
  229. const navigate = jest.fn();
  230. const eventView = new EventView({...baseView, ...state});
  231. pushEventViewToLocation({
  232. navigate,
  233. location,
  234. nextEventView: eventView,
  235. });
  236. expect(navigate).toHaveBeenCalledWith(
  237. expect.objectContaining({
  238. query: expect.objectContaining({
  239. id: '1234',
  240. name: 'best query',
  241. field: ['count()', 'project.id'],
  242. widths: '420',
  243. sort: '-count',
  244. query: 'event.type:error',
  245. project: '42',
  246. start: '2019-10-01T00:00:00',
  247. end: '2019-10-02T00:00:00',
  248. statsPeriod: '14d',
  249. environment: 'staging',
  250. yAxis: 'count()',
  251. }),
  252. })
  253. );
  254. });
  255. it('extra query params', function () {
  256. const navigate = jest.fn();
  257. const eventView = new EventView({...baseView, ...state});
  258. pushEventViewToLocation({
  259. navigate,
  260. location,
  261. nextEventView: eventView,
  262. extraQuery: {
  263. cursor: 'some cursor',
  264. },
  265. });
  266. expect(navigate).toHaveBeenCalledWith(
  267. expect.objectContaining({
  268. query: expect.objectContaining({
  269. id: '1234',
  270. name: 'best query',
  271. field: ['count()', 'project.id'],
  272. widths: '420',
  273. sort: '-count',
  274. query: 'event.type:error',
  275. project: '42',
  276. start: '2019-10-01T00:00:00',
  277. end: '2019-10-02T00:00:00',
  278. statsPeriod: '14d',
  279. environment: 'staging',
  280. cursor: 'some cursor',
  281. yAxis: 'count()',
  282. }),
  283. })
  284. );
  285. });
  286. });
  287. describe('getExpandedResults()', function () {
  288. const state: EventViewOptions = {
  289. ...baseView,
  290. id: '1234',
  291. name: 'best query',
  292. fields: [
  293. {field: 'count()'},
  294. {field: 'last_seen()'},
  295. {field: 'title'},
  296. {field: 'custom_tag'},
  297. ],
  298. sorts: [{field: 'count', kind: 'desc'}],
  299. query: 'event.type:error',
  300. project: [42],
  301. start: '2019-10-01T00:00:00',
  302. end: '2019-10-02T00:00:00',
  303. statsPeriod: '14d',
  304. environment: ['staging'],
  305. };
  306. it('id should be default column when drilldown results in no columns', () => {
  307. const view = new EventView({
  308. ...baseView,
  309. ...state,
  310. fields: [{field: 'count()'}, {field: 'epm()'}, {field: 'eps()'}],
  311. });
  312. const result = getExpandedResults(view, {}, EventFixture());
  313. expect(result.fields).toEqual([{field: 'id', width: -1}]);
  314. });
  315. it('preserves aggregated fields', () => {
  316. let view = new EventView(state);
  317. let result = getExpandedResults(view, {}, EventFixture());
  318. // id should be omitted as it is an implicit property on unaggregated results.
  319. expect(result.fields).toEqual([
  320. {field: 'timestamp', width: -1},
  321. {field: 'title'},
  322. {field: 'custom_tag'},
  323. ]);
  324. expect(result.query).toBe('event.type:error title:ApiException');
  325. // de-duplicate transformed columns
  326. view = new EventView({
  327. ...baseView,
  328. ...state,
  329. fields: [
  330. {field: 'count()'},
  331. {field: 'last_seen()'},
  332. {field: 'title'},
  333. {field: 'custom_tag'},
  334. {field: 'count(id)'},
  335. ],
  336. });
  337. result = getExpandedResults(view, {}, EventFixture());
  338. // id should be omitted as it is an implicit property on unaggregated results.
  339. expect(result.fields).toEqual([
  340. {field: 'timestamp', width: -1},
  341. {field: 'title'},
  342. {field: 'custom_tag'},
  343. ]);
  344. // transform aliased fields, & de-duplicate any transforms
  345. view = new EventView({
  346. ...baseView,
  347. ...state,
  348. fields: [
  349. {field: 'last_seen()'}, // expect this to be transformed to timestamp
  350. {field: 'title'},
  351. {field: 'avg(transaction.duration)'}, // expect this to be dropped
  352. {field: 'p50()'},
  353. {field: 'p75()'},
  354. {field: 'p95()'},
  355. {field: 'p99()'},
  356. {field: 'p100()'},
  357. {field: 'p9001()'}, // it's over 9000
  358. {field: 'foobar()'}, // unknown function with no parameter
  359. {field: 'custom_tag'},
  360. {field: 'transaction.duration'}, // should be dropped
  361. {field: 'title'}, // should be dropped
  362. {field: 'unique_count(id)'},
  363. {field: 'apdex(300)'}, // should be dropped
  364. {field: 'user_misery(300)'}, // should be dropped
  365. {field: 'failure_count()'}, // expect this to be transformed to transaction.status
  366. ],
  367. });
  368. result = getExpandedResults(view, {}, EventFixture());
  369. expect(result.fields).toEqual([
  370. {field: 'timestamp', width: -1},
  371. {field: 'title'},
  372. {field: 'transaction.duration', width: -1},
  373. {field: 'custom_tag'},
  374. {field: 'transaction.status', width: -1},
  375. ]);
  376. // transforms pXX functions with optional arguments properly
  377. view = new EventView({
  378. ...baseView,
  379. ...state,
  380. fields: [
  381. {field: 'p50(transaction.duration)'},
  382. {field: 'p75(measurements.foo)'},
  383. {field: 'p95(measurements.bar)'},
  384. {field: 'p99(measurements.fcp)'},
  385. {field: 'p100(measurements.lcp)'},
  386. ],
  387. });
  388. result = getExpandedResults(view, {}, EventFixture());
  389. expect(result.fields).toEqual([
  390. {field: 'transaction.duration', width: -1},
  391. {field: 'measurements.foo', width: -1},
  392. {field: 'measurements.bar', width: -1},
  393. {field: 'measurements.fcp', width: -1},
  394. {field: 'measurements.lcp', width: -1},
  395. ]);
  396. });
  397. it('applies provided additional conditions', () => {
  398. const view = new EventView({
  399. ...baseView,
  400. ...state,
  401. fields: [...state.fields, {field: 'measurements.lcp'}, {field: 'measurements.fcp'}],
  402. });
  403. let result = getExpandedResults(view, {extra: 'condition'}, EventFixture());
  404. expect(result.query).toBe('event.type:error extra:condition title:ApiException');
  405. // handles user tag values.
  406. result = getExpandedResults(view, {user: 'id:12735'}, EventFixture());
  407. expect(result.query).toBe('event.type:error user:id:12735 title:ApiException');
  408. result = getExpandedResults(view, {user: 'name:uhoh'}, EventFixture());
  409. expect(result.query).toBe('event.type:error user:name:uhoh title:ApiException');
  410. // quotes value
  411. result = getExpandedResults(view, {extra: 'has space'}, EventFixture());
  412. expect(result.query).toBe('event.type:error extra:"has space" title:ApiException');
  413. // appends to existing conditions
  414. result = getExpandedResults(view, {'event.type': 'csp'}, EventFixture());
  415. expect(result.query).toBe('event.type:csp title:ApiException');
  416. // Includes empty strings
  417. result = getExpandedResults(view, {}, EventFixture({id: '0', custom_tag: ''}));
  418. expect(result.query).toBe('event.type:error title:ApiException custom_tag:""');
  419. // Includes 0
  420. result = getExpandedResults(view, {}, EventFixture({id: '0', custom_tag: 0}));
  421. expect(result.query).toBe('event.type:error title:ApiException custom_tag:0');
  422. // Includes null
  423. result = getExpandedResults(view, {}, EventFixture({id: '0', custom_tag: null}));
  424. expect(result.query).toBe('event.type:error title:ApiException custom_tag:""');
  425. // Handles measurements while ignoring null values
  426. result = getExpandedResults(
  427. view,
  428. {},
  429. // @ts-expect-error The type on this is wrong, the actual type is ReactText which is just string|number
  430. // however we seem to have tests that test for null values as well, hence the expect error
  431. {'measurements.lcp': 2, 'measurements.fcp': null}
  432. );
  433. expect(result.query).toBe('event.type:error measurements.lcp:2');
  434. });
  435. it('removes any aggregates in either search conditions or extra conditions', () => {
  436. const view = new EventView({...state, query: 'event.type:error count(id):<10'});
  437. const result = getExpandedResults(view, {'count(id)': '>2'}, EventFixture());
  438. expect(result.query).toBe('event.type:error title:ApiException');
  439. });
  440. it('applies conditions from dataRow map structure based on fields', () => {
  441. const view = new EventView(state);
  442. const result = getExpandedResults(
  443. view,
  444. {extra: 'condition'},
  445. EventFixture({title: 'Event title'})
  446. );
  447. expect(result.query).toBe('event.type:error extra:condition title:"Event title"');
  448. });
  449. it('applies tag key conditions from event data', () => {
  450. const view = new EventView(state);
  451. const event = EventFixture({
  452. type: 'error',
  453. tags: [
  454. {key: 'nope', value: 'nope'},
  455. {key: 'custom_tag', value: 'tag_value'},
  456. ],
  457. });
  458. const result = getExpandedResults(view, {}, event);
  459. expect(result.query).toBe('event.type:error title:ApiException custom_tag:tag_value');
  460. });
  461. it('generate eventview from an empty eventview', () => {
  462. const view = EventView.fromLocation(LocationFixture());
  463. const result = getExpandedResults(view, {some_tag: 'value'}, EventFixture());
  464. expect(result.fields).toEqual([]);
  465. expect(result.query).toBe('some_tag:value');
  466. });
  467. it('removes equations on aggregates', () => {
  468. const view = new EventView({
  469. ...baseView,
  470. ...state,
  471. fields: [
  472. {field: 'count()'},
  473. {field: 'equation|count() / 2'},
  474. {field: 'equation|(count() - count()) + 5'},
  475. ],
  476. });
  477. const result = getExpandedResults(view, {});
  478. expect(result.fields).toEqual([
  479. {
  480. field: 'id',
  481. width: -1,
  482. },
  483. ]);
  484. });
  485. it('keeps equations without aggregates', () => {
  486. const view = new EventView({
  487. ...baseView,
  488. ...state,
  489. fields: [{field: 'count()'}, {field: 'equation|transaction.duration / 2'}],
  490. });
  491. const result = getExpandedResults(view, {});
  492. expect(result.fields).toEqual([
  493. {
  494. field: 'equation|transaction.duration / 2',
  495. width: -1,
  496. },
  497. ]);
  498. });
  499. it('applies array value conditions from event data', () => {
  500. const view = new EventView({
  501. ...baseView,
  502. ...state,
  503. fields: [...state.fields, {field: 'error.type'}],
  504. });
  505. const event = EventFixture({
  506. type: 'error',
  507. tags: [
  508. {key: 'nope', value: 'nope'},
  509. {key: 'custom_tag', value: 'tag_value'},
  510. ],
  511. 'error.type': ['DeadSystem Exception', 'RuntimeException', 'RuntimeException'],
  512. });
  513. const result = getExpandedResults(view, {}, event);
  514. expect(result.query).toBe(
  515. 'event.type:error title:ApiException custom_tag:tag_value error.type:"DeadSystem Exception" error.type:RuntimeException error.type:RuntimeException'
  516. );
  517. });
  518. it('applies project condition to project property', () => {
  519. const view = new EventView(state);
  520. const result = getExpandedResults(view, {'project.id': '1'});
  521. expect(result.query.includes('event.type:error')).toBeTruthy();
  522. expect(result.project).toEqual([42, 1]);
  523. });
  524. it('applies environment condition to environment property', () => {
  525. const view = new EventView(state);
  526. const result = getExpandedResults(view, {environment: 'dev'});
  527. expect(result.query).toBe('event.type:error');
  528. expect(result.environment).toEqual(['staging', 'dev']);
  529. });
  530. it('applies tags that overlap PageFilters state', () => {
  531. const view = new EventView({
  532. ...baseView,
  533. ...state,
  534. fields: [{field: 'project'}, {field: 'environment'}, {field: 'title'}],
  535. });
  536. const event = EventFixture({
  537. title: 'something bad',
  538. timestamp: '2020-02-13T17:05:46+00:00',
  539. tags: [
  540. {key: 'project', value: '12345'},
  541. {key: 'environment', value: 'earth'},
  542. ],
  543. });
  544. const result = getExpandedResults(view, {}, event);
  545. expect(result.query).toBe(
  546. 'event.type:error tags[project]:12345 tags[environment]:earth title:"something bad"'
  547. );
  548. expect(result.project).toEqual([42]);
  549. expect(result.environment).toEqual(['staging']);
  550. });
  551. it('applies the normalized user tag', function () {
  552. const view = new EventView({
  553. ...baseView,
  554. ...state,
  555. fields: [{field: 'user'}, {field: 'title'}],
  556. });
  557. let event = EventFixture({
  558. title: 'something bad',
  559. // user context should be ignored.
  560. user: {
  561. id: 1234,
  562. username: 'uhoh',
  563. },
  564. tags: [{key: 'user', value: 'id:1234'}],
  565. });
  566. let result = getExpandedResults(view, {}, event);
  567. expect(result.query).toBe('event.type:error user:id:1234 title:"something bad"');
  568. event = EventFixture({
  569. title: 'something bad',
  570. tags: [{key: 'user', value: '1234'}],
  571. });
  572. result = getExpandedResults(view, {}, event);
  573. expect(result.query).toBe('event.type:error user:1234 title:"something bad"');
  574. });
  575. it('applies the user field in a table row', function () {
  576. const view = new EventView({
  577. ...state,
  578. fields: [{field: 'user'}, {field: 'title'}],
  579. });
  580. const event = EventFixture({
  581. title: 'something bad',
  582. user: 'id:1234',
  583. });
  584. const result = getExpandedResults(view, {}, event);
  585. expect(result.query).toBe('event.type:error user:id:1234 title:"something bad"');
  586. });
  587. it('normalizes the timestamp field', () => {
  588. const view = new EventView({
  589. ...state,
  590. fields: [{field: 'timestamp'}],
  591. sorts: [{field: 'timestamp', kind: 'desc'}],
  592. });
  593. const event = EventFixture({
  594. type: 'error',
  595. timestamp: '2020-02-13T17:05:46+00:00',
  596. });
  597. const result = getExpandedResults(view, {}, event);
  598. expect(result.query).toBe('event.type:error timestamp:2020-02-13T17:05:46');
  599. });
  600. it('does not duplicate conditions', () => {
  601. const view = new EventView({
  602. ...baseView,
  603. ...state,
  604. query: 'event.type:error title:bogus',
  605. });
  606. const event = EventFixture({
  607. title: 'bogus',
  608. });
  609. const result = getExpandedResults(view, {trace: 'abc123'}, event);
  610. expect(result.query).toBe('event.type:error trace:abc123 title:bogus');
  611. });
  612. it('applies project as condition if present', () => {
  613. const view = new EventView({
  614. ...baseView,
  615. ...state,
  616. query: '',
  617. fields: [{field: 'project'}],
  618. });
  619. const event = EventFixture({project: 'whoosh'});
  620. const result = getExpandedResults(view, {}, event);
  621. expect(result.query).toBe('project:whoosh');
  622. });
  623. it('applies project name as condition if present', () => {
  624. const view = new EventView({
  625. ...baseView,
  626. ...state,
  627. query: '',
  628. fields: [{field: 'project.name'}],
  629. });
  630. const event = EventFixture({'project.name': 'whoosh'});
  631. const result = getExpandedResults(view, {}, event);
  632. expect(result.query).toBe('project.name:whoosh');
  633. });
  634. it('should not trim values that need to be quoted', () => {
  635. const view = new EventView({
  636. ...baseView,
  637. ...state,
  638. query: '',
  639. fields: [{field: 'title'}],
  640. });
  641. // needs to be quoted because of whitespace in middle
  642. const event = EventFixture({title: 'hello there '});
  643. const result = getExpandedResults(view, {}, event);
  644. expect(result.query).toBe('title:"hello there "');
  645. });
  646. it('should add environment from the data row', () => {
  647. const view = new EventView({
  648. ...baseView,
  649. ...state,
  650. environment: [],
  651. query: '',
  652. fields: [{field: 'environment'}],
  653. });
  654. expect(view.environment).toEqual([]);
  655. const event = EventFixture({environment: 'staging'});
  656. const result = getExpandedResults(view, {}, event);
  657. expect(result.environment).toEqual(['staging']);
  658. });
  659. it('should not add duplicate environment', () => {
  660. const view = new EventView({
  661. ...baseView,
  662. ...state,
  663. query: '',
  664. fields: [{field: 'environment'}],
  665. });
  666. expect(view.environment).toEqual(['staging']);
  667. const event = EventFixture({environment: 'staging'});
  668. const result = getExpandedResults(view, {}, event);
  669. expect(result.environment).toEqual(['staging']);
  670. });
  671. });
  672. describe('downloadAsCsv', function () {
  673. const messageColumn = {key: 'message', name: 'message'};
  674. const environmentColumn = {key: 'environment', name: 'environment'};
  675. const countColumn = {key: 'count', name: 'count'};
  676. const userColumn = {key: 'user', name: 'user'};
  677. const equationColumn = {key: 'equation| count() + count()', name: 'count() + count()'};
  678. it('handles raw data', function () {
  679. const result = {
  680. data: [
  681. {message: 'test 1', environment: 'prod'},
  682. {message: 'test 2', environment: 'test'},
  683. ],
  684. };
  685. expect(
  686. downloadAsCsv(result, [messageColumn, environmentColumn], 'filename.csv')
  687. ).toContain(encodeURIComponent('message,environment\r\ntest 1,prod\r\ntest 2,test'));
  688. });
  689. it('handles aggregations', function () {
  690. const result = {
  691. data: [{count: 3}],
  692. };
  693. expect(downloadAsCsv(result, [countColumn], 'filename.csv')).toContain(
  694. encodeURI('count\r\n3')
  695. );
  696. });
  697. it('quotes unsafe strings', function () {
  698. const result = {
  699. data: [{message: '=HYPERLINK(http://some-bad-website#)'}],
  700. };
  701. expect(downloadAsCsv(result, [messageColumn], 'filename.csv')).toContain(
  702. encodeURIComponent("message\r\n'=HYPERLINK(http://some-bad-website#)")
  703. );
  704. });
  705. it('handles the user column', function () {
  706. const result = {
  707. data: [
  708. {message: 'test 0', user: 'name:baz'},
  709. {message: 'test 1', user: 'id:123'},
  710. {message: 'test 2', user: 'email:test@example.com'},
  711. {message: 'test 3', user: 'ip:127.0.0.1'},
  712. ],
  713. };
  714. expect(downloadAsCsv(result, [messageColumn, userColumn], 'filename.csv')).toContain(
  715. encodeURIComponent(
  716. 'message,user\r\ntest 0,name:baz\r\ntest 1,id:123\r\ntest 2,email:test@example.com\r\ntest 3,ip:127.0.0.1'
  717. )
  718. );
  719. });
  720. it('handles equations', function () {
  721. const result = {
  722. data: [{'equation| count() + count()': 3}],
  723. };
  724. expect(downloadAsCsv(result, [equationColumn], 'filename.csv')).toContain(
  725. encodeURIComponent('count() + count()\r\n3')
  726. );
  727. });
  728. });
  729. describe('eventViewToWidgetQuery', function () {
  730. const state: EventViewOptions = {
  731. ...baseView,
  732. id: '1234',
  733. name: 'best query',
  734. fields: [{field: 'count()', width: 420}, {field: 'project.id'}],
  735. sorts: [{field: 'count', kind: 'desc'}],
  736. query: 'event.type:error',
  737. project: [42],
  738. start: '2019-10-01T00:00:00',
  739. end: '2019-10-02T00:00:00',
  740. statsPeriod: '14d',
  741. environment: ['staging'],
  742. };
  743. it('updates orderby to function format for top N query', function () {
  744. const view = new EventView({...baseView, ...state});
  745. const widgetQuery = eventViewToWidgetQuery({
  746. eventView: view,
  747. displayType: DisplayType.TOP_N,
  748. yAxis: ['count()'],
  749. });
  750. expect(widgetQuery.orderby).toBe('-count()');
  751. });
  752. it('updates orderby to function format for complex function', function () {
  753. const view = new EventView({
  754. ...baseView,
  755. ...state,
  756. fields: [{field: 'count_unique(device.locale)', width: 420}, {field: 'project.id'}],
  757. sorts: [{field: 'count_unique_device_locale', kind: 'desc'}],
  758. });
  759. const widgetQuery = eventViewToWidgetQuery({
  760. eventView: view,
  761. displayType: DisplayType.TABLE,
  762. });
  763. expect(widgetQuery.orderby).toBe('-count_unique(device.locale)');
  764. });
  765. it('updates orderby to field', function () {
  766. const view = new EventView({
  767. ...baseView,
  768. ...state,
  769. sorts: [{field: 'project.id', kind: 'desc'}],
  770. });
  771. const widgetQuery = eventViewToWidgetQuery({
  772. eventView: view,
  773. displayType: DisplayType.TABLE,
  774. });
  775. expect(widgetQuery.orderby).toBe('-project.id');
  776. });
  777. });
  778. describe('generateFieldOptions', function () {
  779. it('generates custom measurement field options', function () {
  780. expect(
  781. generateFieldOptions({
  782. organization: initializeOrg().organization,
  783. customMeasurements: [
  784. {functions: ['p99'], key: 'measurements.custom.measurement'},
  785. ],
  786. })['measurement:measurements.custom.measurement']
  787. ).toEqual({
  788. label: 'measurements.custom.measurement',
  789. value: {
  790. kind: 'custom_measurement',
  791. meta: {
  792. dataType: 'number',
  793. functions: ['p99'],
  794. name: 'measurements.custom.measurement',
  795. },
  796. },
  797. });
  798. });
  799. it('disambiguates tags that are also fields', function () {
  800. expect(
  801. generateFieldOptions({
  802. organization: initializeOrg().organization,
  803. tagKeys: ['environment'],
  804. fieldKeys: ['environment'],
  805. aggregations: {},
  806. })
  807. ).toEqual({
  808. 'field:environment': {
  809. label: 'environment',
  810. value: {
  811. kind: 'field',
  812. meta: {
  813. dataType: 'string',
  814. name: 'environment',
  815. },
  816. },
  817. },
  818. 'tag:environment': {
  819. label: 'environment',
  820. value: {
  821. kind: 'tag',
  822. meta: {
  823. dataType: 'string',
  824. name: 'tags[environment]',
  825. },
  826. },
  827. },
  828. });
  829. });
  830. });
  831. describe('constructAddQueryToDashboardLink', function () {
  832. let organization: Organization;
  833. let location: Location;
  834. describe('new widget builder', function () {
  835. beforeEach(() => {
  836. organization = OrganizationFixture({
  837. features: ['dashboards-widget-builder-redesign'],
  838. });
  839. location = LocationFixture();
  840. });
  841. it('should construct a link with the correct params - total period', function () {
  842. const eventView = new EventView({
  843. ...baseView,
  844. display: DisplayModes.DEFAULT,
  845. name: 'best query',
  846. });
  847. const {query} = constructAddQueryToDashboardLink({
  848. eventView,
  849. organization,
  850. location,
  851. source: DashboardWidgetSource.DISCOVERV2,
  852. yAxis: ['count()', 'count_unique(user)'],
  853. widgetType: WidgetType.TRANSACTIONS,
  854. });
  855. expect(query).toEqual({
  856. start: undefined,
  857. end: undefined,
  858. description: '',
  859. limit: undefined,
  860. query: [''],
  861. sort: [''],
  862. legendAlias: [''],
  863. field: [],
  864. title: 'best query',
  865. dataset: WidgetType.TRANSACTIONS,
  866. displayType: DisplayType.AREA,
  867. yAxis: ['count()', 'count_unique(user)'],
  868. source: DashboardWidgetSource.DISCOVERV2,
  869. });
  870. });
  871. it('should construct a link with the correct params - topN', function () {
  872. // This test assigns a grouping through the fields array in the event view
  873. const eventView = new EventView({
  874. ...baseView,
  875. display: DisplayModes.TOP5,
  876. name: 'best query',
  877. fields: [{field: 'transaction', width: 420}, {field: 'project.id'}],
  878. });
  879. const {query} = constructAddQueryToDashboardLink({
  880. eventView,
  881. organization,
  882. location,
  883. source: DashboardWidgetSource.DISCOVERV2,
  884. yAxis: ['count()'],
  885. widgetType: WidgetType.TRANSACTIONS,
  886. });
  887. expect(query).toEqual({
  888. start: undefined,
  889. end: undefined,
  890. description: '',
  891. query: [''],
  892. sort: [''],
  893. legendAlias: [''],
  894. field: ['transaction', 'project.id'],
  895. title: 'best query',
  896. dataset: WidgetType.TRANSACTIONS,
  897. displayType: DisplayType.AREA,
  898. yAxis: ['count()'],
  899. limit: 5,
  900. source: DashboardWidgetSource.DISCOVERV2,
  901. });
  902. });
  903. it('should construct a link with the correct params - daily top N', function () {
  904. // This test assigns a grouping through the fields array in the event view
  905. const eventView = new EventView({
  906. ...baseView,
  907. display: DisplayModes.DAILYTOP5,
  908. name: 'best query',
  909. fields: [{field: 'transaction', width: 420}, {field: 'project.id'}],
  910. });
  911. const {query} = constructAddQueryToDashboardLink({
  912. eventView,
  913. organization,
  914. location,
  915. source: DashboardWidgetSource.DISCOVERV2,
  916. yAxis: ['count()'],
  917. widgetType: WidgetType.TRANSACTIONS,
  918. });
  919. expect(query).toEqual({
  920. start: undefined,
  921. end: undefined,
  922. description: '',
  923. query: [''],
  924. sort: [''],
  925. legendAlias: [''],
  926. field: ['transaction', 'project.id'],
  927. title: 'best query',
  928. dataset: WidgetType.TRANSACTIONS,
  929. displayType: DisplayType.BAR,
  930. yAxis: ['count()'],
  931. limit: 5,
  932. source: DashboardWidgetSource.DISCOVERV2,
  933. });
  934. });
  935. });
  936. });
  937. describe('handleAddQueryToDashboard', function () {
  938. let organization: Organization;
  939. let location: Location;
  940. let router: InjectedRouter;
  941. let mockedOpenAddToDashboardModal: jest.Mock;
  942. beforeEach(() => {
  943. organization = OrganizationFixture({});
  944. location = LocationFixture();
  945. router = RouterFixture();
  946. mockedOpenAddToDashboardModal = jest.mocked(openAddToDashboardModal);
  947. });
  948. it('constructs the correct widget queries for the modal with single yAxis', function () {
  949. const eventView = new EventView({
  950. ...baseView,
  951. display: DisplayModes.DEFAULT,
  952. name: 'best query',
  953. });
  954. handleAddQueryToDashboard({
  955. eventView,
  956. organization,
  957. location,
  958. router,
  959. widgetType: WidgetType.TRANSACTIONS,
  960. yAxis: ['count()'],
  961. source: DashboardWidgetSource.DISCOVERV2,
  962. });
  963. expect(mockedOpenAddToDashboardModal).toHaveBeenCalledWith(
  964. expect.objectContaining({
  965. widget: {
  966. title: 'best query',
  967. displayType: DisplayType.AREA,
  968. queries: [
  969. {
  970. name: '',
  971. fields: ['count()'],
  972. aggregates: ['count()'],
  973. columns: [],
  974. orderby: '',
  975. conditions: '',
  976. },
  977. ],
  978. interval: undefined,
  979. limit: undefined,
  980. widgetType: WidgetType.TRANSACTIONS,
  981. },
  982. })
  983. );
  984. });
  985. it('constructs the correct widget queries for the modal with single yAxis top N', function () {
  986. const eventView = new EventView({
  987. ...baseView,
  988. display: DisplayModes.TOP5,
  989. name: 'best query',
  990. fields: [{field: 'transaction'}],
  991. });
  992. handleAddQueryToDashboard({
  993. eventView,
  994. organization,
  995. location,
  996. router,
  997. source: DashboardWidgetSource.DISCOVERV2,
  998. widgetType: WidgetType.TRANSACTIONS,
  999. yAxis: ['count()'],
  1000. });
  1001. expect(mockedOpenAddToDashboardModal).toHaveBeenCalledWith(
  1002. expect.objectContaining({
  1003. widget: {
  1004. title: 'best query',
  1005. displayType: DisplayType.AREA,
  1006. queries: [
  1007. {
  1008. name: '',
  1009. aggregates: ['count()'],
  1010. columns: ['transaction'],
  1011. fields: ['transaction', 'count()'],
  1012. orderby: '',
  1013. conditions: '',
  1014. },
  1015. ],
  1016. interval: undefined,
  1017. limit: 5,
  1018. widgetType: WidgetType.TRANSACTIONS,
  1019. },
  1020. })
  1021. );
  1022. });
  1023. it('constructs the correct widget queries for the modal with multiple yAxes', function () {
  1024. const eventView = new EventView({
  1025. ...baseView,
  1026. display: DisplayModes.DEFAULT,
  1027. name: 'best query',
  1028. });
  1029. handleAddQueryToDashboard({
  1030. eventView,
  1031. organization,
  1032. location,
  1033. router,
  1034. source: DashboardWidgetSource.DISCOVERV2,
  1035. widgetType: WidgetType.TRANSACTIONS,
  1036. yAxis: ['count()', 'count_unique(user)'],
  1037. });
  1038. expect(mockedOpenAddToDashboardModal).toHaveBeenCalledWith(
  1039. expect.objectContaining({
  1040. widget: {
  1041. title: 'best query',
  1042. displayType: DisplayType.AREA,
  1043. queries: [
  1044. {
  1045. name: '',
  1046. aggregates: ['count()', 'count_unique(user)'],
  1047. columns: [],
  1048. fields: ['count()', 'count_unique(user)'],
  1049. orderby: '',
  1050. conditions: '',
  1051. },
  1052. ],
  1053. interval: undefined,
  1054. limit: undefined,
  1055. widgetType: WidgetType.TRANSACTIONS,
  1056. },
  1057. })
  1058. );
  1059. });
  1060. describe('new widget builder', function () {
  1061. beforeEach(() => {
  1062. organization = OrganizationFixture({
  1063. features: ['dashboards-widget-builder-redesign'],
  1064. });
  1065. });
  1066. it('constructs the correct widget queries for the modal with single yAxis', function () {
  1067. const eventView = new EventView({
  1068. ...baseView,
  1069. display: DisplayModes.DEFAULT,
  1070. name: 'best query',
  1071. });
  1072. handleAddQueryToDashboard({
  1073. eventView,
  1074. organization,
  1075. location,
  1076. router,
  1077. source: DashboardWidgetSource.DISCOVERV2,
  1078. widgetType: WidgetType.TRANSACTIONS,
  1079. yAxis: ['count()'],
  1080. });
  1081. expect(mockedOpenAddToDashboardModal).toHaveBeenCalledWith(
  1082. expect.objectContaining({
  1083. widget: {
  1084. title: 'best query',
  1085. displayType: DisplayType.AREA,
  1086. queries: [
  1087. {
  1088. name: '',
  1089. aggregates: ['count()'],
  1090. columns: [],
  1091. fields: [],
  1092. orderby: '',
  1093. conditions: '',
  1094. },
  1095. ],
  1096. interval: undefined,
  1097. limit: undefined,
  1098. widgetType: WidgetType.TRANSACTIONS,
  1099. },
  1100. widgetAsQueryParams: expect.objectContaining({
  1101. source: DashboardWidgetSource.DISCOVERV2,
  1102. }),
  1103. })
  1104. );
  1105. });
  1106. it('constructs the correct widget queries for the modal with single yAxis top N', function () {
  1107. const eventView = new EventView({
  1108. ...baseView,
  1109. display: DisplayModes.TOP5,
  1110. name: 'best query',
  1111. fields: [{field: 'transaction'}],
  1112. });
  1113. handleAddQueryToDashboard({
  1114. eventView,
  1115. organization,
  1116. location,
  1117. router,
  1118. source: DashboardWidgetSource.DISCOVERV2,
  1119. widgetType: WidgetType.TRANSACTIONS,
  1120. yAxis: ['count()'],
  1121. });
  1122. expect(mockedOpenAddToDashboardModal).toHaveBeenCalledWith(
  1123. expect.objectContaining({
  1124. widget: {
  1125. title: 'best query',
  1126. displayType: DisplayType.AREA,
  1127. queries: [
  1128. {
  1129. name: '',
  1130. aggregates: ['count()'],
  1131. columns: ['transaction'],
  1132. fields: ['transaction'],
  1133. orderby: '',
  1134. conditions: '',
  1135. },
  1136. ],
  1137. interval: undefined,
  1138. limit: 5,
  1139. widgetType: WidgetType.TRANSACTIONS,
  1140. },
  1141. widgetAsQueryParams: expect.objectContaining({
  1142. source: DashboardWidgetSource.DISCOVERV2,
  1143. }),
  1144. })
  1145. );
  1146. });
  1147. it('constructs the correct widget queries for the modal with multiple yAxes', function () {
  1148. const eventView = new EventView({
  1149. ...baseView,
  1150. display: DisplayModes.DEFAULT,
  1151. name: 'best query',
  1152. });
  1153. handleAddQueryToDashboard({
  1154. eventView,
  1155. organization,
  1156. location,
  1157. router,
  1158. source: DashboardWidgetSource.DISCOVERV2,
  1159. widgetType: WidgetType.TRANSACTIONS,
  1160. yAxis: ['count()', 'count_unique(user)'],
  1161. });
  1162. expect(mockedOpenAddToDashboardModal).toHaveBeenCalledWith(
  1163. expect.objectContaining({
  1164. widget: {
  1165. title: 'best query',
  1166. displayType: DisplayType.AREA,
  1167. queries: [
  1168. {
  1169. name: '',
  1170. aggregates: ['count()', 'count_unique(user)'],
  1171. columns: [],
  1172. fields: [],
  1173. orderby: '',
  1174. conditions: '',
  1175. },
  1176. ],
  1177. interval: undefined,
  1178. limit: undefined,
  1179. widgetType: WidgetType.TRANSACTIONS,
  1180. },
  1181. widgetAsQueryParams: expect.objectContaining({
  1182. source: DashboardWidgetSource.DISCOVERV2,
  1183. }),
  1184. })
  1185. );
  1186. });
  1187. });
  1188. });