Просмотр исходного кода

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
colin-sentry 10 месяцев назад
Родитель
Сommit
729b942e5b

+ 4 - 0
static/app/routes.tsx

@@ -1495,6 +1495,10 @@ function buildRoutes() {
   const aiAnalyticsRoutes = (
     <Route path="/ai-analytics/" withOrgPath>
       <IndexRoute component={make(() => import('sentry/views/aiAnalytics/landing'))} />
+      <Route
+        path="pipeline-type/:groupId/"
+        component={make(() => import('sentry/views/aiAnalytics/aiAnalyticsDetailsPage'))}
+      />
     </Route>
   );
 

+ 11 - 10
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 (
       <Link
         to={normalizeUrl(
-          `/organizations/${organization.slug}/ai-analytics/pipelines/${row['span.group']}`
+          `/organizations/${organization.slug}/ai-analytics/pipeline-type/${row['span.group']}`
         )}
       >
         {row['span.description']}

+ 28 - 8
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%;

+ 161 - 0
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 (
+    <Layout.Page withPadding>
+      <Alert type="warning">{t("You don't have access to this feature")}</Alert>
+    </Layout.Page>
+  );
+}
+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 (
+    <PageFiltersContainer>
+      <SentryDocumentTitle
+        title={`AI Analytics — ${spanMetrics['span.description'] ?? t('(no name)')}`}
+      >
+        <Layout.Page>
+          <Feature
+            features="ai-analytics"
+            organization={organization}
+            renderDisabled={NoAccessComponent}
+          >
+            <NoProjectMessage organization={organization}>
+              <Layout.Header>
+                <Layout.HeaderContent>
+                  <Layout.Title>
+                    {`${t('AI Analytics')} - ${spanMetrics['span.description'] ?? t('(no name)')}`}
+                    <PageHeadingQuestionTooltip
+                      title={t(
+                        'If this name is too generic, read the docs to learn how to change it.'
+                      )}
+                      docsUrl="https://docs.sentry.io/product/ai-analytics/"
+                    />
+                  </Layout.Title>
+                </Layout.HeaderContent>
+              </Layout.Header>
+              <Layout.Body>
+                <Layout.Main fullWidth>
+                  <ModuleLayout.Layout>
+                    <ModuleLayout.Full>
+                      <SpaceBetweenWrap>
+                        <PageFilterBar condensed>
+                          <ProjectPageFilter />
+                          <EnvironmentPageFilter />
+                          <DatePageFilter />
+                        </PageFilterBar>
+                        <MetricsRibbon>
+                          <MetricReadout
+                            title={t('Total Runs')}
+                            value={spanMetrics['count()']}
+                            unit={'count'}
+                            isLoading={areSpanMetricsLoading}
+                          />
+
+                          <MetricReadout
+                            title={t('Runs Per Minute')}
+                            value={spanMetrics?.[`${SpanFunction.SPM}()`]}
+                            unit={RateUnit.PER_MINUTE}
+                            isLoading={areSpanMetricsLoading}
+                          />
+
+                          <MetricReadout
+                            title={DataTitles.avg}
+                            value={
+                              spanMetrics?.[`avg(${SpanMetricsField.SPAN_DURATION})`]
+                            }
+                            unit={DurationUnit.MILLISECOND}
+                            isLoading={areSpanMetricsLoading}
+                          />
+                        </MetricsRibbon>
+                      </SpaceBetweenWrap>
+                    </ModuleLayout.Full>
+                    <ModuleLayout.Half>
+                      <NumberOfPipelinesChart groupId={groupId} />
+                    </ModuleLayout.Half>
+                    <ModuleLayout.Half>
+                      <PipelineDurationChart groupId={groupId} />
+                    </ModuleLayout.Half>
+                    <ModuleLayout.Full>
+                      <PipelineSpansTable groupId={groupId} />
+                    </ModuleLayout.Full>
+                  </ModuleLayout.Layout>
+                </Layout.Main>
+              </Layout.Body>
+            </NoProjectMessage>
+          </Feature>
+        </Layout.Page>
+      </SentryDocumentTitle>
+    </PageFiltersContainer>
+  );
+}
+
+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)};
+`;

+ 187 - 0
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 (
+    <VisuallyCompleteWithData
+      id="PipelineSpansTable"
+      hasData={data.length > 0}
+      isLoading={isLoading}
+    >
+      <GridEditable
+        isLoading={isLoading}
+        error={error}
+        data={data}
+        columnOrder={COLUMN_ORDER}
+        columnSortBy={[
+          {
+            key: sort.field,
+            order: sort.kind,
+          },
+        ]}
+        grid={{
+          renderHeadCell: column =>
+            renderHeadCell({
+              column,
+              sort,
+              location,
+              sortParameterName: QueryParameterNames.SPANS_SORT,
+            }),
+          renderBodyCell: (column, row) =>
+            renderBodyCell(column, row, meta, location, organization),
+        }}
+        location={location}
+      />
+    </VisuallyCompleteWithData>
+  );
+}
+
+function renderBodyCell(
+  column: Column,
+  row: any,
+  meta: EventsMetaType | undefined,
+  location: Location,
+  organization: Organization
+) {
+  if (column.key === SpanIndexedField.ID) {
+    if (!row[SpanIndexedField.ID]) {
+      return <span>(unknown)</span>;
+    }
+    if (!row[SpanIndexedField.TRACE]) {
+      return <span>{row[SpanIndexedField.ID]}</span>;
+    }
+    return (
+      <Link
+        to={generateLinkToEventInTraceView({
+          organization,
+          eventId: row[SpanIndexedField.TRANSACTION_ID],
+          projectSlug: row[SpanIndexedField.PROJECT],
+          traceSlug: row[SpanIndexedField.TRACE],
+          timestamp: row[SpanIndexedField.TIMESTAMP],
+          location,
+          eventView: EventView.fromLocation(location),
+          spanId: row[SpanIndexedField.ID],
+        })}
+      >
+        {row[SpanIndexedField.ID]}
+      </Link>
+    );
+  }
+
+  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;
+}

+ 1 - 0
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'