From 6d9606fd0502bde6dc4f31a8f943b94b6901381b Mon Sep 17 00:00:00 2001 From: colin-sentry <161344340+colin-sentry@users.noreply.github.com> Date: Fri, 19 Apr 2024 11:34:18 -0400 Subject: [PATCH] feat(ai): AI Analytics details pages (#69308) Adds a page for viewing each pipeline in AI analytics, as well as a table with clickthroughs to the trace view --- static/app/routes.tsx | 4 + .../app/views/aiAnalytics/PipelinesTable.tsx | 21 +- .../views/aiAnalytics/aiAnalyticsCharts.tsx | 36 +++- .../aiAnalytics/aiAnalyticsDetailsPage.tsx | 161 +++++++++++++++ .../views/aiAnalytics/pipelineSpansTable.tsx | 187 ++++++++++++++++++ static/app/views/starfish/types.tsx | 1 + 6 files changed, 392 insertions(+), 18 deletions(-) create mode 100644 static/app/views/aiAnalytics/aiAnalyticsDetailsPage.tsx create mode 100644 static/app/views/aiAnalytics/pipelineSpansTable.tsx diff --git a/static/app/routes.tsx b/static/app/routes.tsx index 91a296041a6bff..689f86233e1884 100644 --- a/static/app/routes.tsx +++ b/static/app/routes.tsx @@ -1495,6 +1495,10 @@ function buildRoutes() { const aiAnalyticsRoutes = ( import('sentry/views/aiAnalytics/landing'))} /> + import('sentry/views/aiAnalytics/aiAnalyticsDetailsPage'))} + /> ); diff --git a/static/app/views/aiAnalytics/PipelinesTable.tsx b/static/app/views/aiAnalytics/PipelinesTable.tsx index 5960132d5e5181..be3504ccdfca5d 100644 --- a/static/app/views/aiAnalytics/PipelinesTable.tsx +++ b/static/app/views/aiAnalytics/PipelinesTable.tsx @@ -30,13 +30,13 @@ type Row = Pick< | 'span.description' | 'span.group' | 'spm()' - | 'avg(span.self_time)' - | 'sum(span.self_time)' + | 'avg(span.duration)' + | 'sum(span.duration)' | 'time_spent_percentage()' >; type Column = GridColumnHeader< - 'span.description' | 'spm()' | 'avg(span.self_time)' | 'time_spent_percentage()' + 'span.description' | 'spm()' | 'avg(span.duration)' | 'time_spent_percentage()' >; const COLUMN_ORDER: Column[] = [ @@ -51,7 +51,7 @@ const COLUMN_ORDER: Column[] = [ width: COL_WIDTH_UNDEFINED, }, { - key: `avg(span.self_time)`, + key: `avg(span.duration)`, name: DataTitles.avg, width: COL_WIDTH_UNDEFINED, }, @@ -62,10 +62,10 @@ const COLUMN_ORDER: Column[] = [ }, ]; -const SORTABLE_FIELDS = ['avg(span.self_time)', 'spm()', 'time_spent_percentage()']; +const SORTABLE_FIELDS = ['avg(span.duration)', 'spm()', 'time_spent_percentage()']; type ValidSort = Sort & { - field: 'spm()' | 'avg(span.self_time)' | 'time_spent_percentage()'; + field: 'spm()' | 'avg(span.duration)' | 'time_spent_percentage()'; }; export function isAValidSort(sort: Sort): sort is ValidSort { @@ -83,19 +83,20 @@ export function PipelinesTable() { sort = {field: 'time_spent_percentage()', kind: 'desc'}; } const {data, isLoading, meta, pageLinks, error} = useSpanMetrics({ - search: new MutableSearch('span.op:ai.pipeline.langchain'), + search: new MutableSearch('span.category:ai.pipeline'), fields: [ 'project.id', 'span.group', 'span.description', 'spm()', - 'avg(span.self_time)', - 'sum(span.self_time)', + 'avg(span.duration)', + 'sum(span.duration)', 'time_spent_percentage()', ], sorts: [sort], limit: 25, cursor, + referrer: 'api.ai-pipelines.view', }); const handleCursor: CursorHandler = (newCursor, pathname, query) => { @@ -157,7 +158,7 @@ function renderBodyCell( return ( {row['span.description']} diff --git a/static/app/views/aiAnalytics/aiAnalyticsCharts.tsx b/static/app/views/aiAnalytics/aiAnalyticsCharts.tsx index 97de1c6f48d4fb..b4635ab8eea7d0 100644 --- a/static/app/views/aiAnalytics/aiAnalyticsCharts.tsx +++ b/static/app/views/aiAnalytics/aiAnalyticsCharts.tsx @@ -2,6 +2,7 @@ import styled from '@emotion/styled'; import {t} from 'sentry/locale'; import {space} from 'sentry/styles/space'; +import type {MetricsQueryApiResponseLastMeta} from 'sentry/types'; import {MetricDisplayType} from 'sentry/utils/metrics/types'; import {useMetricsQuery} from 'sentry/utils/metrics/useMetricsQuery'; import usePageFilters from 'sentry/utils/usePageFilters'; @@ -20,6 +21,7 @@ export function TotalTokensUsedChart() { name: 'total', mri: `c:spans/ai.total_tokens.used@none`, op: 'sum', + // TODO this double counts the (e.g.) langchain and openai token usage }, ], selection, @@ -55,8 +57,15 @@ export function TotalTokensUsedChart() { ); } -export function NumberOfPipelinesChart() { +interface NumberOfPipelinesChartProps { + groupId?: string; +} +export function NumberOfPipelinesChart({groupId}: NumberOfPipelinesChartProps) { const {selection, isReady: isGlobalSelectionReady} = usePageFilters(); + let query = 'span.category:"ai.pipeline"'; + if (groupId) { + query = `${query} span.group:"${groupId}"`; + } const { data: timeseriesData, isLoading, @@ -68,7 +77,7 @@ export function NumberOfPipelinesChart() { name: 'number', mri: `d:spans/exclusive_time@millisecond`, op: 'count', - query: 'span.op:"ai.pipeline.langchain"', // TODO: for now this is the only AI "pipeline" supported + query, }, ], selection, @@ -104,8 +113,15 @@ export function NumberOfPipelinesChart() { ); } -export function PipelineDurationChart() { +interface PipelineDurationChartProps { + groupId?: string; +} +export function PipelineDurationChart({groupId}: PipelineDurationChartProps) { const {selection, isReady: isGlobalSelectionReady} = usePageFilters(); + let query = 'span.category:"ai.pipeline"'; + if (groupId) { + query = `${query} span.group:"${groupId}"`; + } const { data: timeseriesData, isLoading, @@ -114,10 +130,10 @@ export function PipelineDurationChart() { } = useMetricsQuery( [ { - name: 'number', - mri: `d:spans/exclusive_time@millisecond`, + name: 'a', + mri: `d:spans/duration@millisecond`, op: 'avg', - query: 'span.op:"ai.pipeline.langchain"', // TODO: for now this is the only AI "pipeline" supported + query, }, ], selection, @@ -125,6 +141,11 @@ export function PipelineDurationChart() { intervalLadder: 'dashboard', } ); + const lastMeta = timeseriesData?.meta?.findLast(_ => true); + if (lastMeta && lastMeta.length >= 2) { + // TODO hack: there is a bug somewhere that is dropping the unit + (lastMeta[1] as MetricsQueryApiResponseLastMeta).unit = 'millisecond'; + } if (!isGlobalSelectionReady) { return null; @@ -143,7 +164,7 @@ export function PipelineDurationChart() { metricQueries={[ { name: 'mql', - formula: '$number', + formula: '$a', }, ]} displayType={MetricDisplayType.AREA} @@ -159,7 +180,6 @@ const PanelTitle = styled('h5')` `; const TokenChartContainer = styled('div')` - overflow: hidden; border: 1px solid ${p => p.theme.border}; border-radius: ${p => p.theme.borderRadius}; height: 100%; diff --git a/static/app/views/aiAnalytics/aiAnalyticsDetailsPage.tsx b/static/app/views/aiAnalytics/aiAnalyticsDetailsPage.tsx new file mode 100644 index 00000000000000..56eed47bcfade0 --- /dev/null +++ b/static/app/views/aiAnalytics/aiAnalyticsDetailsPage.tsx @@ -0,0 +1,161 @@ +import styled from '@emotion/styled'; + +import Feature from 'sentry/components/acl/feature'; +import {Alert} from 'sentry/components/alert'; +import * as Layout from 'sentry/components/layouts/thirds'; +import NoProjectMessage from 'sentry/components/noProjectMessage'; +import {DatePageFilter} from 'sentry/components/organizations/datePageFilter'; +import {EnvironmentPageFilter} from 'sentry/components/organizations/environmentPageFilter'; +import PageFilterBar from 'sentry/components/organizations/pageFilterBar'; +import PageFiltersContainer from 'sentry/components/organizations/pageFilters/container'; +import {ProjectPageFilter} from 'sentry/components/organizations/projectPageFilter'; +import {PageHeadingQuestionTooltip} from 'sentry/components/pageHeadingQuestionTooltip'; +import SentryDocumentTitle from 'sentry/components/sentryDocumentTitle'; +import {t} from 'sentry/locale'; +import {space} from 'sentry/styles/space'; +import {DurationUnit, RateUnit} from 'sentry/utils/discover/fields'; +import {MutableSearch} from 'sentry/utils/tokenizeSearch'; +import useOrganization from 'sentry/utils/useOrganization'; +import { + NumberOfPipelinesChart, + PipelineDurationChart, +} from 'sentry/views/aiAnalytics/aiAnalyticsCharts'; +import {PipelineSpansTable} from 'sentry/views/aiAnalytics/pipelineSpansTable'; +import {MetricReadout} from 'sentry/views/performance/metricReadout'; +import * as ModuleLayout from 'sentry/views/performance/moduleLayout'; +import {useSpanMetrics} from 'sentry/views/starfish/queries/useSpanMetrics'; +import { + SpanFunction, + SpanMetricsField, + type SpanMetricsQueryFilters, +} from 'sentry/views/starfish/types'; +import {DataTitles} from 'sentry/views/starfish/views/spans/types'; + +function NoAccessComponent() { + return ( + + {t("You don't have access to this feature")} + + ); +} +interface Props { + params: { + groupId: string; + }; +} + +export default function AiAnalyticsPage({params}: Props) { + const organization = useOrganization(); + const {groupId} = params; + + const filters: SpanMetricsQueryFilters = { + 'span.group': groupId, + 'span.category': 'ai.pipeline', + }; + + const {data, isLoading: areSpanMetricsLoading} = useSpanMetrics({ + search: MutableSearch.fromQueryObject(filters), + fields: [ + SpanMetricsField.SPAN_OP, + SpanMetricsField.SPAN_DESCRIPTION, + 'count()', + `${SpanFunction.SPM}()`, + `avg(${SpanMetricsField.SPAN_DURATION})`, + ], + enabled: Boolean(groupId), + referrer: 'api.ai-pipelines.view', + }); + const spanMetrics = data[0] ?? {}; + + return ( + + + + + + + + + {`${t('AI Analytics')} - ${spanMetrics['span.description'] ?? t('(no name)')}`} + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + ); +} + +const SpaceBetweenWrap = styled('div')` + display: flex; + justify-content: space-between; + flex-wrap: wrap; +`; + +const MetricsRibbon = styled('div')` + display: flex; + flex-wrap: wrap; + gap: ${space(4)}; +`; diff --git a/static/app/views/aiAnalytics/pipelineSpansTable.tsx b/static/app/views/aiAnalytics/pipelineSpansTable.tsx new file mode 100644 index 00000000000000..168aa439d2e56a --- /dev/null +++ b/static/app/views/aiAnalytics/pipelineSpansTable.tsx @@ -0,0 +1,187 @@ +import type {Location} from 'history'; + +import GridEditable, { + COL_WIDTH_UNDEFINED, + type GridColumnHeader, +} from 'sentry/components/gridEditable'; +import Link from 'sentry/components/links/link'; +import {t} from 'sentry/locale'; +import type {Organization} from 'sentry/types'; +import EventView, {type EventsMetaType} from 'sentry/utils/discover/eventView'; +import {getFieldRenderer} from 'sentry/utils/discover/fieldRenderers'; +import type {Sort} from 'sentry/utils/discover/fields'; +import {generateLinkToEventInTraceView} from 'sentry/utils/discover/urls'; +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/starfish/components/tableCells/renderHeadCell'; +import {useIndexedSpans} from 'sentry/views/starfish/queries/useIndexedSpans'; +import {SpanIndexedField} from 'sentry/views/starfish/types'; +import {QueryParameterNames} from 'sentry/views/starfish/views/queryParameters'; + +type Column = GridColumnHeader< + | SpanIndexedField.ID + | SpanIndexedField.SPAN_DURATION + | SpanIndexedField.TIMESTAMP + | SpanIndexedField.USER +>; + +const COLUMN_ORDER: Column[] = [ + { + key: SpanIndexedField.ID, + name: t('ID'), + width: COL_WIDTH_UNDEFINED, + }, + { + key: SpanIndexedField.SPAN_DURATION, + name: t('Total duration'), + width: 150, + }, + { + key: SpanIndexedField.USER, + name: t('User'), + width: COL_WIDTH_UNDEFINED, + }, + { + key: SpanIndexedField.TIMESTAMP, + name: t('Timestamp'), + width: COL_WIDTH_UNDEFINED, + }, +]; + +const SORTABLE_FIELDS = [ + SpanIndexedField.ID, + SpanIndexedField.SPAN_DURATION, + SpanIndexedField.TIMESTAMP, +]; + +type ValidSort = Sort & { + field: + | SpanIndexedField.ID + | SpanIndexedField.SPAN_DURATION + | SpanIndexedField.TIMESTAMP; +}; + +export function isAValidSort(sort: Sort): sort is ValidSort { + return (SORTABLE_FIELDS as unknown as string[]).includes(sort.field); +} + +interface Props { + groupId: string; +} +export function PipelineSpansTable({groupId}: Props) { + const location = useLocation(); + const organization = useOrganization(); + + const sortField = decodeScalar(location.query?.[QueryParameterNames.SPANS_SORT]); + + let sort = decodeSorts(sortField).filter(isAValidSort)[0]; + if (!sort) { + sort = {field: SpanIndexedField.TIMESTAMP, kind: 'desc'}; + } + + const { + data: rawData, + meta: rawMeta, + error, + isLoading, + } = useIndexedSpans({ + limit: 30, + sorts: [sort], + fields: [ + SpanIndexedField.ID, + SpanIndexedField.TRACE, + SpanIndexedField.SPAN_DURATION, + SpanIndexedField.TRANSACTION_ID, + SpanIndexedField.USER, + SpanIndexedField.TIMESTAMP, + SpanIndexedField.PROJECT, + ], + referrer: 'api.ai-pipelines.view', + search: new MutableSearch(`span.category:ai.pipeline span.group:"${groupId}"`), + }); + const data = rawData || []; + const meta = rawMeta as EventsMetaType; + + return ( + 0} + isLoading={isLoading} + > + + renderHeadCell({ + column, + sort, + location, + sortParameterName: QueryParameterNames.SPANS_SORT, + }), + renderBodyCell: (column, row) => + renderBodyCell(column, row, meta, location, organization), + }} + location={location} + /> + + ); +} + +function renderBodyCell( + column: Column, + row: any, + meta: EventsMetaType | undefined, + location: Location, + organization: Organization +) { + if (column.key === SpanIndexedField.ID) { + if (!row[SpanIndexedField.ID]) { + return (unknown); + } + if (!row[SpanIndexedField.TRACE]) { + return {row[SpanIndexedField.ID]}; + } + return ( + + {row[SpanIndexedField.ID]} + + ); + } + + 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; +} diff --git a/static/app/views/starfish/types.tsx b/static/app/views/starfish/types.tsx index c1cd69eb71e355..2d68ad25957e31 100644 --- a/static/app/views/starfish/types.tsx +++ b/static/app/views/starfish/types.tsx @@ -53,6 +53,7 @@ export type SpanStringFields = | 'span.module' | 'span.action' | 'span.group' + | 'span.category' | 'transaction' | 'transaction.method' | 'release'