pipelinesTable.tsx 13 KB

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