PipelinesTable.tsx 6.8 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235
  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 {t} from 'sentry/locale';
  12. import {space} from 'sentry/styles/space';
  13. import type {Organization} from 'sentry/types/organization';
  14. import {browserHistory} from 'sentry/utils/browserHistory';
  15. import type {EventsMetaType} from 'sentry/utils/discover/eventView';
  16. import {getFieldRenderer} from 'sentry/utils/discover/fieldRenderers';
  17. import type {Sort} from 'sentry/utils/discover/fields';
  18. import {RATE_UNIT_TITLE, RateUnit} from 'sentry/utils/discover/fields';
  19. import {VisuallyCompleteWithData} from 'sentry/utils/performanceForSentry';
  20. import {decodeScalar, decodeSorts} from 'sentry/utils/queryString';
  21. import {MutableSearch} from 'sentry/utils/tokenizeSearch';
  22. import {useLocation} from 'sentry/utils/useLocation';
  23. import useOrganization from 'sentry/utils/useOrganization';
  24. import {normalizeUrl} from 'sentry/utils/withDomainRequired';
  25. import {renderHeadCell} from 'sentry/views/starfish/components/tableCells/renderHeadCell';
  26. import {useSpanMetrics} from 'sentry/views/starfish/queries/useSpanMetrics';
  27. import type {SpanMetricsResponse} from 'sentry/views/starfish/types';
  28. import {QueryParameterNames} from 'sentry/views/starfish/views/queryParameters';
  29. type Row = Pick<
  30. SpanMetricsResponse,
  31. | 'project.id'
  32. | 'span.description'
  33. | 'span.group'
  34. | 'spm()'
  35. | 'avg(span.duration)'
  36. | 'sum(span.duration)'
  37. | 'ai_total_tokens_used()'
  38. >;
  39. type Column = GridColumnHeader<
  40. 'span.description' | 'spm()' | 'avg(span.duration)' | 'ai_total_tokens_used()'
  41. >;
  42. const COLUMN_ORDER: Column[] = [
  43. {
  44. key: 'span.description',
  45. name: t('AI Pipeline Name'),
  46. width: COL_WIDTH_UNDEFINED,
  47. },
  48. {
  49. key: 'ai_total_tokens_used()',
  50. name: t('Total tokens used'),
  51. width: 180,
  52. },
  53. {
  54. key: `avg(span.duration)`,
  55. name: t('Pipeline Duration'),
  56. width: COL_WIDTH_UNDEFINED,
  57. },
  58. {
  59. key: 'spm()',
  60. name: `${t('Pipeline runs')} ${RATE_UNIT_TITLE[RateUnit.PER_MINUTE]}`,
  61. width: COL_WIDTH_UNDEFINED,
  62. },
  63. ];
  64. const SORTABLE_FIELDS = ['ai_total_tokens_used()', 'avg(span.duration)', 'spm()'];
  65. type ValidSort = Sort & {
  66. field: 'spm()' | 'avg(span.duration)';
  67. };
  68. export function isAValidSort(sort: Sort): sort is ValidSort {
  69. return (SORTABLE_FIELDS as unknown as string[]).includes(sort.field);
  70. }
  71. export function PipelinesTable() {
  72. const location = useLocation();
  73. const organization = useOrganization();
  74. const cursor = decodeScalar(location.query?.[QueryParameterNames.SPANS_CURSOR]);
  75. const sortField = decodeScalar(location.query?.[QueryParameterNames.SPANS_SORT]);
  76. const spanDescription = decodeScalar(location.query?.['span.description'], '');
  77. let sort = decodeSorts(sortField).filter(isAValidSort)[0];
  78. if (!sort) {
  79. sort = {field: 'spm()', kind: 'desc'};
  80. }
  81. const {data, isLoading, meta, pageLinks, error} = useSpanMetrics({
  82. search: MutableSearch.fromQueryObject({
  83. 'span.category': 'ai.pipeline',
  84. 'span.description': spanDescription ? `*${spanDescription}*` : undefined,
  85. }),
  86. fields: [
  87. 'project.id',
  88. 'span.group',
  89. 'span.description',
  90. 'spm()',
  91. 'avg(span.duration)',
  92. 'sum(span.duration)',
  93. 'ai_total_tokens_used()', // this is zero initially and overwritten below.
  94. ],
  95. sorts: [sort],
  96. limit: 25,
  97. cursor,
  98. referrer: 'api.ai-pipelines.view',
  99. });
  100. const {
  101. data: tokensUsedData,
  102. isLoading: tokensUsedLoading,
  103. error: tokensUsedError,
  104. } = useSpanMetrics({
  105. search: MutableSearch.fromQueryObject({
  106. 'span.ai.pipeline.group': (data as Row[])?.map(x => x['span.group']).join(','),
  107. 'span.category': 'ai',
  108. }),
  109. fields: ['span.ai.pipeline.group', 'ai_total_tokens_used()'],
  110. });
  111. if (!tokensUsedLoading) {
  112. for (const tokenUsedRow of tokensUsedData) {
  113. const groupId = tokenUsedRow['span.ai.pipeline.group'];
  114. const tokensUsed = tokenUsedRow['ai_total_tokens_used()'];
  115. data
  116. .filter(x => x['span.group'] === groupId)
  117. .forEach(x => (x['ai_total_tokens_used()'] = tokensUsed));
  118. }
  119. }
  120. const handleCursor: CursorHandler = (newCursor, pathname, query) => {
  121. browserHistory.push({
  122. pathname,
  123. query: {...query, [QueryParameterNames.SPANS_CURSOR]: newCursor},
  124. });
  125. };
  126. const handleSearch = (newQuery: string) => {
  127. browserHistory.push({
  128. ...location,
  129. query: {
  130. ...location.query,
  131. 'span.description': newQuery === '' ? undefined : newQuery,
  132. [QueryParameterNames.SPANS_CURSOR]: undefined,
  133. },
  134. });
  135. };
  136. return (
  137. <VisuallyCompleteWithData
  138. id="PipelinesTable"
  139. hasData={data.length > 0}
  140. isLoading={isLoading}
  141. >
  142. <Container>
  143. <SearchBar
  144. placeholder={t('Search for pipeline')}
  145. query={spanDescription}
  146. onSearch={handleSearch}
  147. />
  148. <GridEditable
  149. isLoading={isLoading}
  150. error={error ?? tokensUsedError}
  151. data={data}
  152. columnOrder={COLUMN_ORDER}
  153. columnSortBy={[
  154. {
  155. key: sort.field,
  156. order: sort.kind,
  157. },
  158. ]}
  159. grid={{
  160. renderHeadCell: column =>
  161. renderHeadCell({
  162. column,
  163. sort,
  164. location,
  165. sortParameterName: QueryParameterNames.SPANS_SORT,
  166. }),
  167. renderBodyCell: (column, row) =>
  168. renderBodyCell(column, row, meta, location, organization),
  169. }}
  170. location={location}
  171. />
  172. <Pagination pageLinks={pageLinks} onCursor={handleCursor} />
  173. </Container>
  174. </VisuallyCompleteWithData>
  175. );
  176. }
  177. function renderBodyCell(
  178. column: Column,
  179. row: Row,
  180. meta: EventsMetaType | undefined,
  181. location: Location,
  182. organization: Organization
  183. ) {
  184. if (column.key === 'span.description') {
  185. if (!row['span.description']) {
  186. return <span>(unknown)</span>;
  187. }
  188. if (!row['span.group']) {
  189. return <span>{row['span.description']}</span>;
  190. }
  191. return (
  192. <Link
  193. to={normalizeUrl(
  194. `/organizations/${organization.slug}/ai-monitoring/pipeline-type/${row['span.group']}`
  195. )}
  196. >
  197. {row['span.description']}
  198. </Link>
  199. );
  200. }
  201. if (!meta || !meta?.fields) {
  202. return row[column.key];
  203. }
  204. const renderer = getFieldRenderer(column.key, meta.fields, false);
  205. const rendered = renderer(row, {
  206. location,
  207. organization,
  208. unit: meta.units?.[column.key],
  209. });
  210. return rendered;
  211. }
  212. const Container = styled('div')`
  213. display: flex;
  214. flex-direction: column;
  215. gap: ${space(1)};
  216. `;