PipelinesTable.tsx 8.0 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281
  1. import styled from '@emotion/styled';
  2. import type {Location} from 'history';
  3. import GridEditable, {
  4. COL_WIDTH_UNDEFINED,
  5. type GridColumnHeader,
  6. } from 'sentry/components/gridEditable';
  7. import Link from 'sentry/components/links/link';
  8. import type {CursorHandler} from 'sentry/components/pagination';
  9. import Pagination from 'sentry/components/pagination';
  10. import SearchBar from 'sentry/components/searchBar';
  11. import {Tooltip} from 'sentry/components/tooltip';
  12. import {IconInfo} from 'sentry/icons';
  13. import {t} from 'sentry/locale';
  14. import {space} from 'sentry/styles/space';
  15. import type {Organization} from 'sentry/types/organization';
  16. import {browserHistory} from 'sentry/utils/browserHistory';
  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 useOrganization from 'sentry/utils/useOrganization';
  26. import {normalizeUrl} from 'sentry/utils/withDomainRequired';
  27. import {renderHeadCell} from 'sentry/views/starfish/components/tableCells/renderHeadCell';
  28. import {useSpanMetrics} from 'sentry/views/starfish/queries/useDiscover';
  29. import type {SpanMetricsResponse} from 'sentry/views/starfish/types';
  30. import {QueryParameterNames} from 'sentry/views/starfish/views/queryParameters';
  31. type Row = Pick<
  32. SpanMetricsResponse,
  33. | 'project.id'
  34. | 'span.description'
  35. | 'span.group'
  36. | 'spm()'
  37. | 'avg(span.duration)'
  38. | 'sum(span.duration)'
  39. | 'ai_total_tokens_used()'
  40. | 'ai_total_tokens_used(c:spans/ai.total_cost@usd)'
  41. >;
  42. type Column = GridColumnHeader<
  43. | 'span.description'
  44. | 'spm()'
  45. | 'avg(span.duration)'
  46. | 'ai_total_tokens_used()'
  47. | 'ai_total_tokens_used(c:spans/ai.total_cost@usd)'
  48. >;
  49. const COLUMN_ORDER: Column[] = [
  50. {
  51. key: 'span.description',
  52. name: t('AI Pipeline Name'),
  53. width: COL_WIDTH_UNDEFINED,
  54. },
  55. {
  56. key: 'ai_total_tokens_used()',
  57. name: t('Total tokens used'),
  58. width: 180,
  59. },
  60. {
  61. key: 'ai_total_tokens_used(c:spans/ai.total_cost@usd)',
  62. name: t('Total cost'),
  63. width: 180,
  64. },
  65. {
  66. key: `avg(span.duration)`,
  67. name: t('Pipeline Duration'),
  68. width: COL_WIDTH_UNDEFINED,
  69. },
  70. {
  71. key: 'spm()',
  72. name: `${t('Pipeline runs')} ${RATE_UNIT_TITLE[RateUnit.PER_MINUTE]}`,
  73. width: COL_WIDTH_UNDEFINED,
  74. },
  75. ];
  76. const SORTABLE_FIELDS = ['ai_total_tokens_used()', 'avg(span.duration)', 'spm()'];
  77. type ValidSort = Sort & {
  78. field: 'spm()' | 'avg(span.duration)';
  79. };
  80. export function isAValidSort(sort: Sort): sort is ValidSort {
  81. return (SORTABLE_FIELDS as unknown as string[]).includes(sort.field);
  82. }
  83. export function PipelinesTable() {
  84. const location = useLocation();
  85. const organization = useOrganization();
  86. const cursor = decodeScalar(location.query?.[QueryParameterNames.SPANS_CURSOR]);
  87. const sortField = decodeScalar(location.query?.[QueryParameterNames.SPANS_SORT]);
  88. const spanDescription = decodeScalar(location.query?.['span.description'], '');
  89. let sort = decodeSorts(sortField).filter(isAValidSort)[0];
  90. if (!sort) {
  91. sort = {field: 'spm()', kind: 'desc'};
  92. }
  93. const {data, isLoading, meta, pageLinks, error} = useSpanMetrics(
  94. {
  95. search: MutableSearch.fromQueryObject({
  96. 'span.category': 'ai.pipeline',
  97. 'span.description': spanDescription ? `*${spanDescription}*` : undefined,
  98. }),
  99. fields: [
  100. 'project.id',
  101. 'span.group',
  102. 'span.description',
  103. 'spm()',
  104. 'avg(span.duration)',
  105. 'sum(span.duration)',
  106. ],
  107. sorts: [sort],
  108. limit: 25,
  109. cursor,
  110. },
  111. 'api.ai-pipelines.view'
  112. );
  113. const {data: tokensUsedData, isLoading: tokensUsedLoading} = useSpanMetrics(
  114. {
  115. search: new MutableSearch(
  116. `span.category:ai span.ai.pipeline.group:[${(data as Row[])?.map(x => x['span.group']).join(',')}]`
  117. ),
  118. fields: [
  119. 'span.ai.pipeline.group',
  120. 'ai_total_tokens_used()',
  121. 'ai_total_tokens_used(c:spans/ai.total_cost@usd)',
  122. ],
  123. },
  124. 'api.performance.ai-analytics.token-usage-chart'
  125. );
  126. const rows: Row[] = (data as Row[]).map(baseRow => {
  127. const row: Row = {
  128. ...baseRow,
  129. 'ai_total_tokens_used()': 0,
  130. 'ai_total_tokens_used(c:spans/ai.total_cost@usd)': 0,
  131. };
  132. if (!tokensUsedLoading) {
  133. const tokenUsedDataPoint = tokensUsedData.find(
  134. tokenRow => tokenRow['span.ai.pipeline.group'] === row['span.group']
  135. );
  136. if (tokenUsedDataPoint) {
  137. row['ai_total_tokens_used()'] = tokenUsedDataPoint['ai_total_tokens_used()'];
  138. row['ai_total_tokens_used(c:spans/ai.total_cost@usd)'] =
  139. tokenUsedDataPoint['ai_total_tokens_used(c:spans/ai.total_cost@usd)'];
  140. }
  141. }
  142. return row;
  143. });
  144. const handleCursor: CursorHandler = (newCursor, pathname, query) => {
  145. browserHistory.push({
  146. pathname,
  147. query: {...query, [QueryParameterNames.SPANS_CURSOR]: newCursor},
  148. });
  149. };
  150. const handleSearch = (newQuery: string) => {
  151. browserHistory.push({
  152. ...location,
  153. query: {
  154. ...location.query,
  155. 'span.description': newQuery === '' ? undefined : newQuery,
  156. [QueryParameterNames.SPANS_CURSOR]: undefined,
  157. },
  158. });
  159. };
  160. return (
  161. <VisuallyCompleteWithData
  162. id="PipelinesTable"
  163. hasData={rows.length > 0}
  164. isLoading={isLoading}
  165. >
  166. <Container>
  167. <SearchBar
  168. placeholder={t('Search for pipeline')}
  169. query={spanDescription}
  170. onSearch={handleSearch}
  171. />
  172. <GridEditable
  173. isLoading={isLoading}
  174. error={error}
  175. data={rows}
  176. columnOrder={COLUMN_ORDER}
  177. columnSortBy={[
  178. {
  179. key: sort.field,
  180. order: sort.kind,
  181. },
  182. ]}
  183. grid={{
  184. renderHeadCell: column =>
  185. renderHeadCell({
  186. column,
  187. sort,
  188. location,
  189. sortParameterName: QueryParameterNames.SPANS_SORT,
  190. }),
  191. renderBodyCell: (column, row) =>
  192. renderBodyCell(column, row, meta, location, organization),
  193. }}
  194. location={location}
  195. />
  196. <Pagination pageLinks={pageLinks} onCursor={handleCursor} />
  197. </Container>
  198. </VisuallyCompleteWithData>
  199. );
  200. }
  201. function renderBodyCell(
  202. column: Column,
  203. row: Row,
  204. meta: EventsMetaType | undefined,
  205. location: Location,
  206. organization: Organization
  207. ) {
  208. if (column.key === 'span.description') {
  209. if (!row['span.description']) {
  210. return <span>(unknown)</span>;
  211. }
  212. if (!row['span.group']) {
  213. return <span>{row['span.description']}</span>;
  214. }
  215. return (
  216. <Link
  217. to={normalizeUrl(
  218. `/organizations/${organization.slug}/ai-monitoring/pipeline-type/${row['span.group']}`
  219. )}
  220. >
  221. {row['span.description']}
  222. </Link>
  223. );
  224. }
  225. if (column.key === 'ai_total_tokens_used(c:spans/ai.total_cost@usd)') {
  226. const cost = row['ai_total_tokens_used(c:spans/ai.total_cost@usd)'];
  227. if (cost) {
  228. if (cost < 0.01) {
  229. return <span>US {cost * 100}¢</span>;
  230. }
  231. return <span>US${cost}</span>;
  232. }
  233. return (
  234. <span>
  235. Unknown{' '}
  236. <Tooltip
  237. title="Cost can only be calculated for certain OpenAI and Anthropic models, other providers aren't yet supported."
  238. isHoverable
  239. >
  240. <IconInfo size="xs" />
  241. </Tooltip>
  242. </span>
  243. );
  244. }
  245. if (!meta || !meta?.fields) {
  246. return row[column.key];
  247. }
  248. const renderer = getFieldRenderer(column.key, meta.fields, false);
  249. const rendered = renderer(row, {
  250. location,
  251. organization,
  252. unit: meta.units?.[column.key],
  253. });
  254. return rendered;
  255. }
  256. const Container = styled('div')`
  257. display: flex;
  258. flex-direction: column;
  259. gap: ${space(1)};
  260. `;