Skip to content

Commit 144ff52

Browse files
authored
feat(ai): Add preliminary AI analytics page content (#69010)
<img width="908" alt="Screenshot 2024-04-16 at 2 36 14 PM" src="https://github.com/getsentry/sentry/assets/161344340/5c1c9d45-774d-42bf-9f15-9de1d5f4b8e7">
1 parent 92e8a0c commit 144ff52

File tree

7 files changed

+423
-230
lines changed

7 files changed

+423
-230
lines changed

static/app/routes.tsx

Lines changed: 1 addition & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1491,11 +1491,7 @@ function buildRoutes() {
14911491
);
14921492

14931493
const aiAnalyticsRoutes = (
1494-
<Route
1495-
path="/ai-analytics/"
1496-
component={make(() => import('sentry/views/aiAnalytics'))}
1497-
withOrgPath
1498-
>
1494+
<Route path="/ai-analytics/" withOrgPath>
14991495
<IndexRoute component={make(() => import('sentry/views/aiAnalytics/landing'))} />
15001496
</Route>
15011497
);
Lines changed: 181 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,181 @@
1+
import {browserHistory} from 'react-router';
2+
import type {Location} from 'history';
3+
4+
import type {GridColumnHeader} from 'sentry/components/gridEditable';
5+
import GridEditable, {COL_WIDTH_UNDEFINED} from 'sentry/components/gridEditable';
6+
import Link from 'sentry/components/links/link';
7+
import type {CursorHandler} from 'sentry/components/pagination';
8+
import Pagination from 'sentry/components/pagination';
9+
import {t} from 'sentry/locale';
10+
import type {Organization} from 'sentry/types';
11+
import type {EventsMetaType} from 'sentry/utils/discover/eventView';
12+
import {getFieldRenderer} from 'sentry/utils/discover/fieldRenderers';
13+
import type {Sort} from 'sentry/utils/discover/fields';
14+
import {RATE_UNIT_TITLE, RateUnit} from 'sentry/utils/discover/fields';
15+
import {VisuallyCompleteWithData} from 'sentry/utils/performanceForSentry';
16+
import {decodeScalar, decodeSorts} from 'sentry/utils/queryString';
17+
import {MutableSearch} from 'sentry/utils/tokenizeSearch';
18+
import {useLocation} from 'sentry/utils/useLocation';
19+
import useOrganization from 'sentry/utils/useOrganization';
20+
import {normalizeUrl} from 'sentry/utils/withDomainRequired';
21+
import {renderHeadCell} from 'sentry/views/starfish/components/tableCells/renderHeadCell';
22+
import {useSpanMetrics} from 'sentry/views/starfish/queries/useSpanMetrics';
23+
import type {MetricsResponse} from 'sentry/views/starfish/types';
24+
import {QueryParameterNames} from 'sentry/views/starfish/views/queryParameters';
25+
import {DataTitles} from 'sentry/views/starfish/views/spans/types';
26+
27+
type Row = Pick<
28+
MetricsResponse,
29+
| 'project.id'
30+
| 'span.description'
31+
| 'span.group'
32+
| 'spm()'
33+
| 'avg(span.self_time)'
34+
| 'sum(span.self_time)'
35+
| 'time_spent_percentage()'
36+
>;
37+
38+
type Column = GridColumnHeader<
39+
'span.description' | 'spm()' | 'avg(span.self_time)' | 'time_spent_percentage()'
40+
>;
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: 'spm()',
50+
name: `${t('Times')} ${RATE_UNIT_TITLE[RateUnit.PER_MINUTE]}`,
51+
width: COL_WIDTH_UNDEFINED,
52+
},
53+
{
54+
key: `avg(span.self_time)`,
55+
name: DataTitles.avg,
56+
width: COL_WIDTH_UNDEFINED,
57+
},
58+
{
59+
key: 'time_spent_percentage()',
60+
name: DataTitles.timeSpent,
61+
width: COL_WIDTH_UNDEFINED,
62+
},
63+
];
64+
65+
const SORTABLE_FIELDS = ['avg(span.self_time)', 'spm()', 'time_spent_percentage()'];
66+
67+
type ValidSort = Sort & {
68+
field: 'spm()' | 'avg(span.self_time)' | 'time_spent_percentage()';
69+
};
70+
71+
export function isAValidSort(sort: Sort): sort is ValidSort {
72+
return (SORTABLE_FIELDS as unknown as string[]).includes(sort.field);
73+
}
74+
75+
export function PipelinesTable() {
76+
const location = useLocation();
77+
const organization = useOrganization();
78+
const cursor = decodeScalar(location.query?.[QueryParameterNames.SPANS_CURSOR]);
79+
const sortField = decodeScalar(location.query?.[QueryParameterNames.SPANS_SORT]);
80+
81+
let sort = decodeSorts(sortField).filter(isAValidSort)[0];
82+
if (!sort) {
83+
sort = {field: 'time_spent_percentage()', kind: 'desc'};
84+
}
85+
const {data, isLoading, meta, pageLinks, error} = useSpanMetrics({
86+
search: new MutableSearch('span.op:ai.pipeline.langchain'),
87+
fields: [
88+
'project.id',
89+
'span.group',
90+
'span.description',
91+
'spm()',
92+
'avg(span.self_time)',
93+
'sum(span.self_time)',
94+
'time_spent_percentage()',
95+
],
96+
sorts: [sort],
97+
limit: 25,
98+
cursor,
99+
});
100+
101+
const handleCursor: CursorHandler = (newCursor, pathname, query) => {
102+
browserHistory.push({
103+
pathname,
104+
query: {...query, [QueryParameterNames.SPANS_CURSOR]: newCursor},
105+
});
106+
};
107+
108+
return (
109+
<VisuallyCompleteWithData
110+
id="PipelinesTable"
111+
hasData={data.length > 0}
112+
isLoading={isLoading}
113+
>
114+
<GridEditable
115+
isLoading={isLoading}
116+
error={error}
117+
data={data}
118+
columnOrder={COLUMN_ORDER}
119+
columnSortBy={[
120+
{
121+
key: sort.field,
122+
order: sort.kind,
123+
},
124+
]}
125+
grid={{
126+
renderHeadCell: column =>
127+
renderHeadCell({
128+
column,
129+
sort,
130+
location,
131+
sortParameterName: QueryParameterNames.SPANS_SORT,
132+
}),
133+
renderBodyCell: (column, row) =>
134+
renderBodyCell(column, row, meta, location, organization),
135+
}}
136+
location={location}
137+
/>
138+
<Pagination pageLinks={pageLinks} onCursor={handleCursor} />
139+
</VisuallyCompleteWithData>
140+
);
141+
}
142+
143+
function renderBodyCell(
144+
column: Column,
145+
row: Row,
146+
meta: EventsMetaType | undefined,
147+
location: Location,
148+
organization: Organization
149+
) {
150+
if (column.key === 'span.description') {
151+
if (!row['span.description']) {
152+
return <span>(unknown)</span>;
153+
}
154+
if (!row['span.group']) {
155+
return <span>{row['span.description']}</span>;
156+
}
157+
return (
158+
<Link
159+
to={normalizeUrl(
160+
`/organizations/${organization.slug}/ai-analytics/pipelines/${row['span.group']}`
161+
)}
162+
>
163+
{row['span.description']}
164+
</Link>
165+
);
166+
}
167+
168+
if (!meta || !meta?.fields) {
169+
return row[column.key];
170+
}
171+
172+
const renderer = getFieldRenderer(column.key, meta.fields, false);
173+
174+
const rendered = renderer(row, {
175+
location,
176+
organization,
177+
unit: meta.units?.[column.key],
178+
});
179+
180+
return rendered;
181+
}
Lines changed: 168 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,168 @@
1+
import styled from '@emotion/styled';
2+
3+
import {t} from 'sentry/locale';
4+
import {space} from 'sentry/styles/space';
5+
import {MetricDisplayType} from 'sentry/utils/metrics/types';
6+
import {useMetricsQuery} from 'sentry/utils/metrics/useMetricsQuery';
7+
import usePageFilters from 'sentry/utils/usePageFilters';
8+
import {MetricChartContainer} from 'sentry/views/dashboards/metrics/chart';
9+
10+
export function TotalTokensUsedChart() {
11+
const {selection, isReady: isGlobalSelectionReady} = usePageFilters();
12+
const {
13+
data: timeseriesData,
14+
isLoading,
15+
isError,
16+
error,
17+
} = useMetricsQuery(
18+
[
19+
{
20+
name: 'total',
21+
mri: `c:spans/ai.total_tokens.used@none`,
22+
op: 'sum',
23+
},
24+
],
25+
selection,
26+
{
27+
intervalLadder: 'dashboard',
28+
}
29+
);
30+
31+
if (!isGlobalSelectionReady) {
32+
return null;
33+
}
34+
35+
if (isError) {
36+
return <div>{'' + error}</div>;
37+
}
38+
39+
return (
40+
<TokenChartContainer>
41+
<PanelTitle>{t('Total tokens used')}</PanelTitle>
42+
<MetricChartContainer
43+
timeseriesData={timeseriesData}
44+
isLoading={isLoading}
45+
metricQueries={[
46+
{
47+
name: 'mql',
48+
formula: '$total',
49+
},
50+
]}
51+
displayType={MetricDisplayType.AREA}
52+
chartHeight={200}
53+
/>
54+
</TokenChartContainer>
55+
);
56+
}
57+
58+
export function NumberOfPipelinesChart() {
59+
const {selection, isReady: isGlobalSelectionReady} = usePageFilters();
60+
const {
61+
data: timeseriesData,
62+
isLoading,
63+
isError,
64+
error,
65+
} = useMetricsQuery(
66+
[
67+
{
68+
name: 'number',
69+
mri: `d:spans/exclusive_time@millisecond`,
70+
op: 'count',
71+
query: 'span.op:"ai.pipeline.langchain"', // TODO: for now this is the only AI "pipeline" supported
72+
},
73+
],
74+
selection,
75+
{
76+
intervalLadder: 'dashboard',
77+
}
78+
);
79+
80+
if (!isGlobalSelectionReady) {
81+
return null;
82+
}
83+
84+
if (isError) {
85+
return <div>{'' + error}</div>;
86+
}
87+
88+
return (
89+
<TokenChartContainer>
90+
<PanelTitle>{t('Number of AI pipelines')}</PanelTitle>
91+
<MetricChartContainer
92+
timeseriesData={timeseriesData}
93+
isLoading={isLoading}
94+
metricQueries={[
95+
{
96+
name: 'mql',
97+
formula: '$number',
98+
},
99+
]}
100+
displayType={MetricDisplayType.AREA}
101+
chartHeight={200}
102+
/>
103+
</TokenChartContainer>
104+
);
105+
}
106+
107+
export function PipelineDurationChart() {
108+
const {selection, isReady: isGlobalSelectionReady} = usePageFilters();
109+
const {
110+
data: timeseriesData,
111+
isLoading,
112+
isError,
113+
error,
114+
} = useMetricsQuery(
115+
[
116+
{
117+
name: 'number',
118+
mri: `d:spans/exclusive_time@millisecond`,
119+
op: 'avg',
120+
query: 'span.op:"ai.pipeline.langchain"', // TODO: for now this is the only AI "pipeline" supported
121+
},
122+
],
123+
selection,
124+
{
125+
intervalLadder: 'dashboard',
126+
}
127+
);
128+
129+
if (!isGlobalSelectionReady) {
130+
return null;
131+
}
132+
133+
if (isError) {
134+
return <div>{'' + error}</div>;
135+
}
136+
137+
return (
138+
<TokenChartContainer>
139+
<PanelTitle>{t('AI pipeline duration')}</PanelTitle>
140+
<MetricChartContainer
141+
timeseriesData={timeseriesData}
142+
isLoading={isLoading}
143+
metricQueries={[
144+
{
145+
name: 'mql',
146+
formula: '$number',
147+
},
148+
]}
149+
displayType={MetricDisplayType.AREA}
150+
chartHeight={200}
151+
/>
152+
</TokenChartContainer>
153+
);
154+
}
155+
156+
const PanelTitle = styled('h5')`
157+
padding: ${space(3)} ${space(3)} 0;
158+
margin: 0;
159+
`;
160+
161+
const TokenChartContainer = styled('div')`
162+
overflow: hidden;
163+
border: 1px solid ${p => p.theme.border};
164+
border-radius: ${p => p.theme.borderRadius};
165+
height: 100%;
166+
display: flex;
167+
flex-direction: column;
168+
`;

0 commit comments

Comments
 (0)