pipelinesTable.tsx 13 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458
  1. import styled from '@emotion/styled';
  2. import type {Location} from 'history';
  3. import * as qs from 'query-string';
  4. import GridEditable, {
  5. COL_WIDTH_UNDEFINED,
  6. type GridColumnHeader,
  7. } from 'sentry/components/gridEditable';
  8. import Link from 'sentry/components/links/link';
  9. import type {CursorHandler} from 'sentry/components/pagination';
  10. import Pagination from 'sentry/components/pagination';
  11. import SearchBar from 'sentry/components/searchBar';
  12. import {Tooltip} from 'sentry/components/tooltip';
  13. import {IconInfo} from 'sentry/icons';
  14. import {t} from 'sentry/locale';
  15. import {space} from 'sentry/styles/space';
  16. import type {Organization} from 'sentry/types/organization';
  17. import {browserHistory} from 'sentry/utils/browserHistory';
  18. import type {EventsMetaType} from 'sentry/utils/discover/eventView';
  19. import {getFieldRenderer} from 'sentry/utils/discover/fieldRenderers';
  20. import type {Sort} from 'sentry/utils/discover/fields';
  21. import {RATE_UNIT_TITLE, RateUnit} from 'sentry/utils/discover/fields';
  22. import {VisuallyCompleteWithData} from 'sentry/utils/performanceForSentry';
  23. import {decodeScalar, decodeSorts} from 'sentry/utils/queryString';
  24. import {MutableSearch} from 'sentry/utils/tokenizeSearch';
  25. import {useLocation} from 'sentry/utils/useLocation';
  26. import useOrganization from 'sentry/utils/useOrganization';
  27. import {renderHeadCell} from 'sentry/views/insights/common/components/tableCells/renderHeadCell';
  28. import {
  29. useEAPSpans,
  30. useSpanMetrics,
  31. } from 'sentry/views/insights/common/queries/useDiscover';
  32. import {useModuleURL} from 'sentry/views/insights/common/utils/useModuleURL';
  33. import {QueryParameterNames} from 'sentry/views/insights/common/views/queryParameters';
  34. import type {SpanMetricsResponse} from 'sentry/views/insights/types';
  35. import {SpanIndexedField} from 'sentry/views/insights/types';
  36. type Row = Pick<
  37. SpanMetricsResponse,
  38. | 'project.id'
  39. | 'span.description'
  40. | 'span.group'
  41. | 'spm()'
  42. | 'avg(span.duration)'
  43. | 'sum(span.duration)'
  44. | 'sum(ai.total_tokens.used)'
  45. | 'sum(ai.total_cost)'
  46. >;
  47. type Column = GridColumnHeader<
  48. | 'span.description'
  49. | 'spm()'
  50. | 'avg(span.duration)'
  51. | 'sum(ai.total_tokens.used)'
  52. | 'sum(ai.total_cost)'
  53. >;
  54. const COLUMN_ORDER: Column[] = [
  55. {
  56. key: 'span.description',
  57. name: t('AI Pipeline Name'),
  58. width: COL_WIDTH_UNDEFINED,
  59. },
  60. {
  61. key: 'sum(ai.total_tokens.used)',
  62. name: t('Total tokens used'),
  63. width: 180,
  64. },
  65. {
  66. key: 'sum(ai.total_cost)',
  67. name: t('Total cost'),
  68. width: 180,
  69. },
  70. {
  71. key: `avg(span.duration)`,
  72. name: t('Pipeline Duration'),
  73. width: COL_WIDTH_UNDEFINED,
  74. },
  75. {
  76. key: 'spm()',
  77. name: `${t('Pipeline runs')} ${RATE_UNIT_TITLE[RateUnit.PER_MINUTE]}`,
  78. width: COL_WIDTH_UNDEFINED,
  79. },
  80. ];
  81. const SORTABLE_FIELDS = ['sum(ai.total_tokens.used)', 'avg(span.duration)', 'spm()'];
  82. type ValidSort = Sort & {
  83. field: 'spm()' | 'avg(span.duration)';
  84. };
  85. export function isAValidSort(sort: Sort): sort is ValidSort {
  86. return (SORTABLE_FIELDS as unknown as string[]).includes(sort.field);
  87. }
  88. export function EAPPipelinesTable() {
  89. const location = useLocation();
  90. const moduleURL = useModuleURL('ai');
  91. const organization = useOrganization();
  92. const cursor = decodeScalar(location.query?.[QueryParameterNames.SPANS_CURSOR]);
  93. const sortField = decodeScalar(location.query?.[QueryParameterNames.SPANS_SORT]);
  94. const spanDescription = decodeScalar(location.query?.['span.description'], '');
  95. let sort = decodeSorts(sortField).filter(isAValidSort)[0];
  96. if (!sort) {
  97. sort = {field: 'spm()', kind: 'desc'};
  98. }
  99. const {data, isPending, meta, pageLinks, error} = useEAPSpans(
  100. {
  101. search: MutableSearch.fromQueryObject({
  102. 'span.category': 'ai.pipeline',
  103. [SpanIndexedField.SPAN_DESCRIPTION]: spanDescription
  104. ? `*${spanDescription}*`
  105. : undefined,
  106. }),
  107. fields: [
  108. SpanIndexedField.SPAN_GROUP,
  109. SpanIndexedField.SPAN_DESCRIPTION,
  110. 'spm()',
  111. 'avg(span.duration)',
  112. 'sum(span.duration)',
  113. ],
  114. sorts: [sort],
  115. limit: 25,
  116. cursor,
  117. },
  118. 'api.ai-pipelines-eap.table'
  119. );
  120. const {data: tokensUsedData, isPending: tokensUsedLoading} = useEAPSpans(
  121. {
  122. search: new MutableSearch(
  123. `span.category:ai span.ai.pipeline.group:[${(data as Row[])
  124. ?.map(x => x['span.group'])
  125. ?.filter(x => !!x)
  126. .join(',')}]`
  127. ),
  128. fields: ['span.ai.pipeline.group', 'sum(ai.total_tokens.used)'],
  129. },
  130. 'api.ai-pipelines-eap.table'
  131. );
  132. const {
  133. data: tokenCostData,
  134. isPending: tokenCostLoading,
  135. error: tokenCostError,
  136. } = useEAPSpans(
  137. {
  138. search: new MutableSearch(
  139. `span.category:ai span.ai.pipeline.group:[${(data as Row[])?.map(x => x['span.group']).join(',')}]`
  140. ),
  141. fields: ['span.ai.pipeline.group', 'sum(ai.total_cost)'],
  142. },
  143. 'api.ai-pipelines-eap.table'
  144. );
  145. const rows: Row[] = (data as Row[]).map(baseRow => {
  146. const row: Row = {
  147. ...baseRow,
  148. 'sum(ai.total_tokens.used)': 0,
  149. 'sum(ai.total_cost)': 0,
  150. };
  151. if (!tokensUsedLoading) {
  152. const tokenUsedDataPoint = tokensUsedData.find(
  153. tokenRow => tokenRow['span.ai.pipeline.group'] === row['span.group']
  154. );
  155. if (tokenUsedDataPoint) {
  156. row['sum(ai.total_tokens.used)'] =
  157. tokenUsedDataPoint['sum(ai.total_tokens.used)'];
  158. }
  159. }
  160. if (!tokenCostLoading && !tokenCostError) {
  161. const tokenCostDataPoint = tokenCostData.find(
  162. tokenRow => tokenRow['span.ai.pipeline.group'] === row['span.group']
  163. );
  164. if (tokenCostDataPoint) {
  165. row['sum(ai.total_cost)'] = tokenCostDataPoint['sum(ai.total_cost)'];
  166. }
  167. }
  168. return row;
  169. });
  170. const handleCursor: CursorHandler = (newCursor, pathname, query) => {
  171. browserHistory.push({
  172. pathname,
  173. query: {...query, [QueryParameterNames.SPANS_CURSOR]: newCursor},
  174. });
  175. };
  176. const handleSearch = (newQuery: string) => {
  177. browserHistory.push({
  178. ...location,
  179. query: {
  180. ...location.query,
  181. 'span.description': newQuery === '' ? undefined : newQuery,
  182. [QueryParameterNames.SPANS_CURSOR]: undefined,
  183. },
  184. });
  185. };
  186. return (
  187. <VisuallyCompleteWithData
  188. id="PipelinesTable"
  189. hasData={rows.length > 0}
  190. isLoading={isPending}
  191. >
  192. <Container>
  193. <SearchBar
  194. placeholder={t('Search for pipeline')}
  195. query={spanDescription}
  196. onSearch={handleSearch}
  197. />
  198. <GridEditable
  199. isLoading={isPending}
  200. error={error}
  201. data={rows}
  202. columnOrder={COLUMN_ORDER}
  203. columnSortBy={[
  204. {
  205. key: sort.field,
  206. order: sort.kind,
  207. },
  208. ]}
  209. grid={{
  210. renderHeadCell: column =>
  211. renderHeadCell({
  212. column,
  213. sort,
  214. location,
  215. sortParameterName: QueryParameterNames.SPANS_SORT,
  216. }),
  217. renderBodyCell: (column, row) =>
  218. renderBodyCell(moduleURL, column, row, meta, location, organization),
  219. }}
  220. />
  221. <Pagination pageLinks={pageLinks} onCursor={handleCursor} />
  222. </Container>
  223. </VisuallyCompleteWithData>
  224. );
  225. }
  226. export function PipelinesTable() {
  227. const location = useLocation();
  228. const moduleURL = useModuleURL('ai');
  229. const organization = useOrganization();
  230. const cursor = decodeScalar(location.query?.[QueryParameterNames.SPANS_CURSOR]);
  231. const sortField = decodeScalar(location.query?.[QueryParameterNames.SPANS_SORT]);
  232. const spanDescription = decodeScalar(location.query?.['span.description'], '');
  233. let sort = decodeSorts(sortField).filter(isAValidSort)[0];
  234. if (!sort) {
  235. sort = {field: 'spm()', kind: 'desc'};
  236. }
  237. const {data, isPending, meta, pageLinks, error} = useSpanMetrics(
  238. {
  239. search: MutableSearch.fromQueryObject({
  240. 'span.category': 'ai.pipeline',
  241. 'span.description': spanDescription ? `*${spanDescription}*` : undefined,
  242. }),
  243. fields: [
  244. 'span.group',
  245. 'span.description',
  246. 'spm()',
  247. 'avg(span.duration)',
  248. 'sum(span.duration)',
  249. ],
  250. sorts: [sort],
  251. limit: 25,
  252. cursor,
  253. },
  254. 'api.ai-pipelines.view'
  255. );
  256. const {data: tokensUsedData, isPending: tokensUsedLoading} = useSpanMetrics(
  257. {
  258. search: new MutableSearch(
  259. `span.category:ai span.ai.pipeline.group:[${(data as Row[])
  260. ?.map(x => x['span.group'])
  261. ?.filter(x => !!x)
  262. .join(',')}]`
  263. ),
  264. fields: ['span.ai.pipeline.group', 'sum(ai.total_tokens.used)'],
  265. },
  266. 'api.performance.ai-analytics.token-usage-chart'
  267. );
  268. const {
  269. data: tokenCostData,
  270. isPending: tokenCostLoading,
  271. error: tokenCostError,
  272. } = useSpanMetrics(
  273. {
  274. search: new MutableSearch(
  275. `span.category:ai span.ai.pipeline.group:[${(data as Row[])?.map(x => x['span.group']).join(',')}]`
  276. ),
  277. fields: ['span.ai.pipeline.group', 'sum(ai.total_cost)'],
  278. },
  279. 'api.performance.ai-analytics.token-usage-chart'
  280. );
  281. const rows: Row[] = (data as Row[]).map(baseRow => {
  282. const row: Row = {
  283. ...baseRow,
  284. 'sum(ai.total_tokens.used)': 0,
  285. 'sum(ai.total_cost)': 0,
  286. };
  287. if (!tokensUsedLoading) {
  288. const tokenUsedDataPoint = tokensUsedData.find(
  289. tokenRow => tokenRow['span.ai.pipeline.group'] === row['span.group']
  290. );
  291. if (tokenUsedDataPoint) {
  292. row['sum(ai.total_tokens.used)'] =
  293. tokenUsedDataPoint['sum(ai.total_tokens.used)'];
  294. }
  295. }
  296. if (!tokenCostLoading && !tokenCostError) {
  297. const tokenCostDataPoint = tokenCostData.find(
  298. tokenRow => tokenRow['span.ai.pipeline.group'] === row['span.group']
  299. );
  300. if (tokenCostDataPoint) {
  301. row['sum(ai.total_cost)'] = tokenCostDataPoint['sum(ai.total_cost)'];
  302. }
  303. }
  304. return row;
  305. });
  306. const handleCursor: CursorHandler = (newCursor, pathname, query) => {
  307. browserHistory.push({
  308. pathname,
  309. query: {...query, [QueryParameterNames.SPANS_CURSOR]: newCursor},
  310. });
  311. };
  312. const handleSearch = (newQuery: string) => {
  313. browserHistory.push({
  314. ...location,
  315. query: {
  316. ...location.query,
  317. 'span.description': newQuery === '' ? undefined : newQuery,
  318. [QueryParameterNames.SPANS_CURSOR]: undefined,
  319. },
  320. });
  321. };
  322. return (
  323. <VisuallyCompleteWithData
  324. id="PipelinesTable"
  325. hasData={rows.length > 0}
  326. isLoading={isPending}
  327. >
  328. <Container>
  329. <SearchBar
  330. placeholder={t('Search for pipeline')}
  331. query={spanDescription}
  332. onSearch={handleSearch}
  333. />
  334. <GridEditable
  335. isLoading={isPending}
  336. error={error}
  337. data={rows}
  338. columnOrder={COLUMN_ORDER}
  339. columnSortBy={[
  340. {
  341. key: sort.field,
  342. order: sort.kind,
  343. },
  344. ]}
  345. grid={{
  346. renderHeadCell: column =>
  347. renderHeadCell({
  348. column,
  349. sort,
  350. location,
  351. sortParameterName: QueryParameterNames.SPANS_SORT,
  352. }),
  353. renderBodyCell: (column, row) =>
  354. renderBodyCell(moduleURL, column, row, meta, location, organization),
  355. }}
  356. />
  357. <Pagination pageLinks={pageLinks} onCursor={handleCursor} />
  358. </Container>
  359. </VisuallyCompleteWithData>
  360. );
  361. }
  362. function renderBodyCell(
  363. moduleURL: string,
  364. column: Column,
  365. row: Row,
  366. meta: EventsMetaType | undefined,
  367. location: Location,
  368. organization: Organization
  369. ) {
  370. if (column.key === 'span.description') {
  371. if (!row['span.description']) {
  372. return <span>(unknown)</span>;
  373. }
  374. if (!row['span.group']) {
  375. return <span>{row['span.description']}</span>;
  376. }
  377. const queryString = {
  378. ...location.query,
  379. 'span.description': row['span.description'],
  380. };
  381. return (
  382. <Link
  383. to={`${moduleURL}/pipeline-type/${row['span.group']}?${qs.stringify(queryString)}`}
  384. >
  385. {row['span.description']}
  386. </Link>
  387. );
  388. }
  389. if (column.key === 'sum(ai.total_cost)') {
  390. const cost = row[column.key];
  391. if (cost) {
  392. return <span>US ${cost.toFixed(3)}</span>;
  393. }
  394. return (
  395. <span>
  396. Unknown{' '}
  397. <Tooltip
  398. title={t(
  399. "Cost is calculated for some of the most popular models, but some providers aren't yet supported."
  400. )}
  401. isHoverable
  402. >
  403. <IconInfo size="xs" />
  404. </Tooltip>
  405. </span>
  406. );
  407. }
  408. if (!meta || !meta?.fields) {
  409. return row[column.key];
  410. }
  411. const renderer = getFieldRenderer(column.key, meta.fields, false);
  412. const rendered = renderer(row, {
  413. location,
  414. organization,
  415. unit: meta.units?.[column.key],
  416. });
  417. return rendered;
  418. }
  419. const Container = styled('div')`
  420. display: flex;
  421. flex-direction: column;
  422. gap: ${space(1)};
  423. `;