import styled from '@emotion/styled'; import type {Location} from 'history'; import * as qs from 'query-string'; import GridEditable, { COL_WIDTH_UNDEFINED, type GridColumnHeader, } from 'sentry/components/gridEditable'; import Link from 'sentry/components/links/link'; import type {CursorHandler} from 'sentry/components/pagination'; import Pagination from 'sentry/components/pagination'; import SearchBar from 'sentry/components/searchBar'; import {Tooltip} from 'sentry/components/tooltip'; import {IconInfo} from 'sentry/icons'; import {t} from 'sentry/locale'; import {space} from 'sentry/styles/space'; import type {Organization} from 'sentry/types/organization'; import {browserHistory} from 'sentry/utils/browserHistory'; import type {EventsMetaType} from 'sentry/utils/discover/eventView'; import {getFieldRenderer} from 'sentry/utils/discover/fieldRenderers'; import type {Sort} from 'sentry/utils/discover/fields'; import {RATE_UNIT_TITLE, RateUnit} from 'sentry/utils/discover/fields'; import {VisuallyCompleteWithData} from 'sentry/utils/performanceForSentry'; import {decodeScalar, decodeSorts} from 'sentry/utils/queryString'; import {MutableSearch} from 'sentry/utils/tokenizeSearch'; import {useLocation} from 'sentry/utils/useLocation'; import useOrganization from 'sentry/utils/useOrganization'; import {renderHeadCell} from 'sentry/views/insights/common/components/tableCells/renderHeadCell'; import {useSpanMetrics} from 'sentry/views/insights/common/queries/useDiscover'; import {useModuleURL} from 'sentry/views/insights/common/utils/useModuleURL'; import {QueryParameterNames} from 'sentry/views/insights/common/views/queryParameters'; import type {SpanMetricsResponse} from 'sentry/views/insights/types'; type Row = Pick< SpanMetricsResponse, | 'project.id' | 'span.description' | 'span.group' | 'spm()' | 'avg(span.duration)' | 'sum(span.duration)' | 'sum(ai.total_tokens.used)' | 'sum(ai.total_cost)' >; type Column = GridColumnHeader< | 'span.description' | 'spm()' | 'avg(span.duration)' | 'sum(ai.total_tokens.used)' | 'sum(ai.total_cost)' >; const COLUMN_ORDER: Column[] = [ { key: 'span.description', name: t('AI Pipeline Name'), width: COL_WIDTH_UNDEFINED, }, { key: 'sum(ai.total_tokens.used)', name: t('Total tokens used'), width: 180, }, { key: 'sum(ai.total_cost)', name: t('Total cost'), width: 180, }, { key: `avg(span.duration)`, name: t('Pipeline Duration'), width: COL_WIDTH_UNDEFINED, }, { key: 'spm()', name: `${t('Pipeline runs')} ${RATE_UNIT_TITLE[RateUnit.PER_MINUTE]}`, width: COL_WIDTH_UNDEFINED, }, ]; const SORTABLE_FIELDS = ['sum(ai.total_tokens.used)', 'avg(span.duration)', 'spm()']; type ValidSort = Sort & { field: 'spm()' | 'avg(span.duration)'; }; export function isAValidSort(sort: Sort): sort is ValidSort { return (SORTABLE_FIELDS as unknown as string[]).includes(sort.field); } export function PipelinesTable() { const location = useLocation(); const moduleURL = useModuleURL('ai'); const organization = useOrganization(); const cursor = decodeScalar(location.query?.[QueryParameterNames.SPANS_CURSOR]); const sortField = decodeScalar(location.query?.[QueryParameterNames.SPANS_SORT]); const spanDescription = decodeScalar(location.query?.['span.description'], ''); let sort = decodeSorts(sortField).filter(isAValidSort)[0]; if (!sort) { sort = {field: 'spm()', kind: 'desc'}; } const {data, isPending, meta, pageLinks, error} = useSpanMetrics( { search: MutableSearch.fromQueryObject({ 'span.category': 'ai.pipeline', 'span.description': spanDescription ? `*${spanDescription}*` : undefined, }), fields: [ 'project.id', 'span.group', 'span.description', 'spm()', 'avg(span.duration)', 'sum(span.duration)', ], sorts: [sort], limit: 25, cursor, }, 'api.ai-pipelines.view' ); const {data: tokensUsedData, isPending: tokensUsedLoading} = useSpanMetrics( { search: new MutableSearch( `span.category:ai span.ai.pipeline.group:[${(data as Row[]) ?.map(x => x['span.group']) ?.filter(x => !!x) .join(',')}]` ), fields: ['span.ai.pipeline.group', 'sum(ai.total_tokens.used)'], }, 'api.performance.ai-analytics.token-usage-chart' ); const { data: tokenCostData, isPending: tokenCostLoading, error: tokenCostError, } = useSpanMetrics( { search: new MutableSearch( `span.category:ai span.ai.pipeline.group:[${(data as Row[])?.map(x => x['span.group']).join(',')}]` ), fields: ['span.ai.pipeline.group', 'sum(ai.total_cost)'], }, 'api.performance.ai-analytics.token-usage-chart' ); const rows: Row[] = (data as Row[]).map(baseRow => { const row: Row = { ...baseRow, 'sum(ai.total_tokens.used)': 0, 'sum(ai.total_cost)': 0, }; if (!tokensUsedLoading) { const tokenUsedDataPoint = tokensUsedData.find( tokenRow => tokenRow['span.ai.pipeline.group'] === row['span.group'] ); if (tokenUsedDataPoint) { row['sum(ai.total_tokens.used)'] = tokenUsedDataPoint['sum(ai.total_tokens.used)']; } } if (!tokenCostLoading && !tokenCostError) { const tokenCostDataPoint = tokenCostData.find( tokenRow => tokenRow['span.ai.pipeline.group'] === row['span.group'] ); if (tokenCostDataPoint) { row['sum(ai.total_cost)'] = tokenCostDataPoint['sum(ai.total_cost)']; } } return row; }); const handleCursor: CursorHandler = (newCursor, pathname, query) => { browserHistory.push({ pathname, query: {...query, [QueryParameterNames.SPANS_CURSOR]: newCursor}, }); }; const handleSearch = (newQuery: string) => { browserHistory.push({ ...location, query: { ...location.query, 'span.description': newQuery === '' ? undefined : newQuery, [QueryParameterNames.SPANS_CURSOR]: undefined, }, }); }; return ( 0} isLoading={isPending} > renderHeadCell({ column, sort, location, sortParameterName: QueryParameterNames.SPANS_SORT, }), renderBodyCell: (column, row) => renderBodyCell(moduleURL, column, row, meta, location, organization), }} /> ); } function renderBodyCell( moduleURL: string, column: Column, row: Row, meta: EventsMetaType | undefined, location: Location, organization: Organization ) { if (column.key === 'span.description') { if (!row['span.description']) { return (unknown); } if (!row['span.group']) { return {row['span.description']}; } const queryString = { ...location.query, 'span.description': row['span.description'], }; return ( {row['span.description']} ); } if (column.key === 'sum(ai.total_cost)') { const cost = row[column.key]; if (cost) { return US ${cost.toFixed(3)}; } return ( Unknown{' '} ); } if (!meta || !meta?.fields) { return row[column.key]; } const renderer = getFieldRenderer(column.key, meta.fields, false); const rendered = renderer(row, { location, organization, unit: meta.units?.[column.key], }); return rendered; } const Container = styled('div')` display: flex; flex-direction: column; gap: ${space(1)}; `;