From f88434252f97b33fc3e6e9ee8e353a9f87b90c4b Mon Sep 17 00:00:00 2001 From: Arthur Knaus Date: Thu, 6 Nov 2025 17:58:02 +0100 Subject: [PATCH 1/3] feat(generation-page): Add column selector --- .../explore/tables/columnEditorModal.tsx | 8 +- .../views/components/generationsTable.tsx | 89 +++++++++++------- .../insights/aiGenerations/views/overview.tsx | 94 ++++++++++++++----- .../aiGenerations/views/utils/constants.tsx | 5 + .../views/utils/useFieldsQueryParam.tsx | 23 +++++ 5 files changed, 159 insertions(+), 60 deletions(-) create mode 100644 static/app/views/insights/aiGenerations/views/utils/useFieldsQueryParam.tsx diff --git a/static/app/views/explore/tables/columnEditorModal.tsx b/static/app/views/explore/tables/columnEditorModal.tsx index f9ccf16426ecac..b18285332d7ae8 100644 --- a/static/app/views/explore/tables/columnEditorModal.tsx +++ b/static/app/views/explore/tables/columnEditorModal.tsx @@ -32,6 +32,7 @@ interface ColumnEditorModalProps extends ModalRenderProps { handleReset?: () => void; hiddenKeys?: string[]; isDocsButtonHidden?: boolean; + requiredTags?: string[]; } export function ColumnEditorModal({ @@ -41,6 +42,7 @@ export function ColumnEditorModal({ closeModal, columns, onColumnsChange, + requiredTags, numberTags, stringTags, hiddenKeys, @@ -146,6 +148,7 @@ export function ColumnEditorModal({ 1} + required={requiredTags?.includes(column.column)} column={column} options={tags} onColumnChange={c => updateColumnAtIndex(i, c)} @@ -201,11 +204,13 @@ interface ColumnEditorRowProps { onColumnChange: (column: string) => void; onColumnDelete: () => void; options: Array>; + required?: boolean; } function ColumnEditorRow({ canDelete, column, + required, options, onColumnChange, onColumnDelete, @@ -266,6 +271,7 @@ function ColumnEditorRow({ options={options} value={column.column ?? ''} onChange={handleColumnChange} + disabled={required} searchable triggerProps={{ children: label, @@ -278,7 +284,7 @@ function ColumnEditorRow({ } onClick={onColumnDelete} diff --git a/static/app/views/insights/aiGenerations/views/components/generationsTable.tsx b/static/app/views/insights/aiGenerations/views/components/generationsTable.tsx index ad92a9386831c8..3482e095f03232 100644 --- a/static/app/views/insights/aiGenerations/views/components/generationsTable.tsx +++ b/static/app/views/insights/aiGenerations/views/components/generationsTable.tsx @@ -1,4 +1,4 @@ -import {useCallback} from 'react'; +import {useCallback, useMemo} from 'react'; import {Button} from '@sentry/scraps/button'; import {Container, Grid} from '@sentry/scraps/layout'; @@ -22,27 +22,27 @@ import { import {ModelName} from 'sentry/views/insights/agents/components/modelName'; import {useCombinedQuery} from 'sentry/views/insights/agents/hooks/useCombinedQuery'; import {useTableCursor} from 'sentry/views/insights/agents/hooks/useTableCursor'; -import {AI_GENERATIONS_PAGE_FILTER} from 'sentry/views/insights/aiGenerations/views/utils/constants'; +import { + AI_GENERATIONS_PAGE_FILTER, + INPUT_OUTPUT_FIELD, + type GenerationFields, +} from 'sentry/views/insights/aiGenerations/views/utils/constants'; import {Referrer} from 'sentry/views/insights/aiGenerations/views/utils/referrer'; +import {useFieldsQueryParam} from 'sentry/views/insights/aiGenerations/views/utils/useFieldsQueryParam'; import {TextAlignRight} from 'sentry/views/insights/common/components/textAlign'; import {useSpans} from 'sentry/views/insights/common/queries/useDiscover'; import {PlatformInsightsTable} from 'sentry/views/insights/pages/platform/shared/table'; import {SpanFields} from 'sentry/views/insights/types'; -const INITIAL_COLUMN_ORDER = [ - {key: SpanFields.SPAN_ID, name: t('Span ID'), width: 100}, - { - key: SpanFields.GEN_AI_REQUEST_MESSAGES, - name: t('Input / Output'), - width: COL_WIDTH_UNDEFINED, - }, - { - key: SpanFields.GEN_AI_REQUEST_MODEL, - name: t('Model'), - width: 200, - }, - {key: SpanFields.TIMESTAMP, name: t('Timestamp')}, -] as const; +const columnWidths: Partial> = { + [SpanFields.SPAN_ID]: 100, + [INPUT_OUTPUT_FIELD]: COL_WIDTH_UNDEFINED, + [SpanFields.GEN_AI_REQUEST_MODEL]: 200, +}; + +const prettyFieldNames: Partial> = { + [SpanFields.GEN_AI_REQUEST_MODEL]: t('Model'), +}; const DEFAULT_SORT: Sort = {field: SpanFields.TIMESTAMP, kind: 'desc'}; @@ -63,26 +63,36 @@ function getLastInputMessage(messages?: string) { } } +const REQUIRED_FIELDS = [ + SpanFields.SPAN_STATUS, + SpanFields.TIMESTAMP, + SpanFields.TRACE, + SpanFields.SPAN_ID, + SpanFields.GEN_AI_REQUEST_MESSAGES, + SpanFields.GEN_AI_RESPONSE_TEXT, + SpanFields.GEN_AI_RESPONSE_OBJECT, +]; + export function GenerationsTable() { const {openTraceViewDrawer} = useTraceViewDrawer({}); const query = useCombinedQuery(AI_GENERATIONS_PAGE_FILTER); const {cursor} = useTableCursor(); const {tableSort} = useTableSort(DEFAULT_SORT); + const [fields] = useFieldsQueryParam(); + + const fieldsToQuery = useMemo(() => { + return [ + ...fields.filter( + (field): field is SpanFields => + field !== INPUT_OUTPUT_FIELD && !REQUIRED_FIELDS.includes(field) + ), + ]; + }, [fields]); const {data, isLoading, error, pageLinks, isPlaceholderData} = useSpans( { search: query, - fields: [ - SpanFields.TRACE, - SpanFields.SPAN_ID, - SpanFields.SPAN_STATUS, - SpanFields.SPAN_DESCRIPTION, - SpanFields.GEN_AI_REQUEST_MESSAGES, - SpanFields.GEN_AI_RESPONSE_TEXT, - SpanFields.GEN_AI_RESPONSE_OBJECT, - SpanFields.GEN_AI_REQUEST_MODEL, - SpanFields.TIMESTAMP, - ], + fields: [...REQUIRED_FIELDS, ...fieldsToQuery] as any, cursor, sorts: [tableSort], keepPreviousData: true, @@ -94,27 +104,27 @@ export function GenerationsTable() { type TableData = (typeof data)[number]; const renderBodyCell = useCallback( - (column: GridColumnOrder, dataRow: TableData) => { - if (column.key === SpanFields.SPAN_ID) { + (column: GridColumnOrder, dataRow: TableData) => { + if (column.key === SpanFields.ID) { return (
); } - if (column.key === SpanFields.GEN_AI_REQUEST_MESSAGES) { + if (column.key === INPUT_OUTPUT_FIELD) { const noValueFallback = ; const statusValue = dataRow[SpanFields.SPAN_STATUS]; const isError = statusValue && statusValue !== 'ok' && statusValue !== 'unknown'; @@ -178,7 +188,7 @@ export function GenerationsTable() { if (column.key === SpanFields.TIMESTAMP) { return ( - + ); } @@ -204,13 +214,20 @@ export function GenerationsTable() { ); return ( -
+ => ({ + key: field, + name: prettyFieldNames[field] ?? field, + width: columnWidths[field] ?? COL_WIDTH_UNDEFINED, + }) + )} pageLinks={pageLinks} grid={{ renderBodyCell, @@ -218,6 +235,6 @@ export function GenerationsTable() { }} isPlaceholderData={isPlaceholderData} /> -
+ ); } diff --git a/static/app/views/insights/aiGenerations/views/overview.tsx b/static/app/views/insights/aiGenerations/views/overview.tsx index 91d8188ae363ff..e4116aba4b21cf 100644 --- a/static/app/views/insights/aiGenerations/views/overview.tsx +++ b/static/app/views/insights/aiGenerations/views/overview.tsx @@ -1,4 +1,4 @@ -import {useMemo, useState} from 'react'; +import {useCallback, useMemo, useState} from 'react'; import {css} from '@emotion/react'; import styled from '@emotion/styled'; import {parseAsString, useQueryState} from 'nuqs'; @@ -6,6 +6,7 @@ import {parseAsString, useQueryState} from 'nuqs'; import {Button} from '@sentry/scraps/button'; import {Container, Flex, Stack} from '@sentry/scraps/layout'; +import {openModal} from 'sentry/actionCreators/modal'; import {DatePageFilter} from 'sentry/components/organizations/datePageFilter'; import PageFilterBar from 'sentry/components/organizations/pageFilterBar'; import { @@ -13,7 +14,7 @@ import { useEAPSpanSearchQueryBuilderProps, } from 'sentry/components/performance/spanSearchQueryBuilder'; import {SearchQueryBuilderProvider} from 'sentry/components/searchQueryBuilder/context'; -import {IconChevron} from 'sentry/icons'; +import {IconChevron, IconEdit} from 'sentry/icons'; import {t} from 'sentry/locale'; import {getSelectedProjectList} from 'sentry/utils/project/useSelectedProjectsHaveField'; import {chonkStyled} from 'sentry/utils/theme/theme.chonk'; @@ -21,9 +22,11 @@ import {withChonk} from 'sentry/utils/theme/withChonk'; import useOrganization from 'sentry/utils/useOrganization'; import usePageFilters from 'sentry/utils/usePageFilters'; import useProjects from 'sentry/utils/useProjects'; +import {TableActionButton} from 'sentry/views/explore/components/tableActionButton'; import {useTraceItemTags} from 'sentry/views/explore/contexts/spanTagsContext'; import {TraceItemAttributeProvider} from 'sentry/views/explore/contexts/traceItemAttributeContext'; import {SpansQueryParamsProvider} from 'sentry/views/explore/spans/spansQueryParamsProvider'; +import {ColumnEditorModal} from 'sentry/views/explore/tables/columnEditorModal'; import {TraceItemDataset} from 'sentry/views/explore/types'; import {limitMaxPickableDays} from 'sentry/views/explore/utils'; import {useTableCursor} from 'sentry/views/insights/agents/hooks/useTableCursor'; @@ -31,11 +34,13 @@ import {Onboarding} from 'sentry/views/insights/agents/views/onboarding'; import {GenerationsChart} from 'sentry/views/insights/aiGenerations/views/components/generationsChart'; import {GenerationsTable} from 'sentry/views/insights/aiGenerations/views/components/generationsTable'; import {GenerationsToolbar} from 'sentry/views/insights/aiGenerations/views/components/generationsToolbar'; +import {INPUT_OUTPUT_FIELD} from 'sentry/views/insights/aiGenerations/views/utils/constants'; +import {useFieldsQueryParam} from 'sentry/views/insights/aiGenerations/views/utils/useFieldsQueryParam'; import {InsightsEnvironmentSelector} from 'sentry/views/insights/common/components/enviornmentSelector'; import {ModuleFeature} from 'sentry/views/insights/common/components/moduleFeature'; import {ModulePageProviders} from 'sentry/views/insights/common/components/modulePageProviders'; import {InsightsProjectSelector} from 'sentry/views/insights/common/components/projectSelector'; -import {ModuleName} from 'sentry/views/insights/types'; +import {ModuleName, SpanFields} from 'sentry/views/insights/types'; function useShowOnboarding() { const {projects} = useProjects(); @@ -53,12 +58,15 @@ function AIGenerationsPage() { const [sidebarOpen, setSidebarOpen] = useState(true); const showOnboarding = useShowOnboarding(); const datePageFilterProps = limitMaxPickableDays(organization); + const [searchQuery, setSearchQuery] = useQueryState( 'query', parseAsString.withOptions({history: 'replace'}) ); const {unsetCursor} = useTableCursor(); + const [fields, setFields] = useFieldsQueryParam(); + const {tags: numberTags, secondaryAliases: numberSecondaryAliases} = useTraceItemTags('number'); const {tags: stringTags, secondaryAliases: stringSecondaryAliases} = @@ -102,6 +110,24 @@ function AIGenerationsPage() { eapSpanSearchQueryBuilderProps ); + const openColumnEditor = useCallback(() => { + openModal( + modalProps => ( + setFields(null)} + isDocsButtonHidden + /> + ), + {closeEvents: 'escape-key'} + ); + }, [fields, setFields, stringTags, numberTags]); + return ( @@ -151,26 +177,48 @@ function AIGenerationsPage() { borderTop="muted" background="secondary" > - - setSidebarOpen(!sidebarOpen)} - aria-label={sidebarOpen ? t('Collapse sidebar') : t('Expand sidebar')} - size="xs" - icon={ - - } - > - {sidebarOpen ? null : t('Advanced')} - - - - - + + + setSidebarOpen(!sidebarOpen)} + aria-label={sidebarOpen ? t('Collapse sidebar') : t('Expand sidebar')} + size="xs" + icon={ + + } + > + {sidebarOpen ? null : t('Advanced')} + + + + + } + size="sm" + aria-label={t('Edit Table')} + /> + } + desktop={ + + } + /> + + diff --git a/static/app/views/insights/aiGenerations/views/utils/constants.tsx b/static/app/views/insights/aiGenerations/views/utils/constants.tsx index 6006fd65af8c17..5b3d3120a5d460 100644 --- a/static/app/views/insights/aiGenerations/views/utils/constants.tsx +++ b/static/app/views/insights/aiGenerations/views/utils/constants.tsx @@ -1,5 +1,10 @@ import {getAIGenerationsFilter} from 'sentry/views/insights/agents/utils/query'; +import type {SpanFields} from 'sentry/views/insights/types'; export const GENERATIONS_COUNT_FIELD = 'span.duration'; // Only show generation spans that result in a response to the user's message export const AI_GENERATIONS_PAGE_FILTER = `${getAIGenerationsFilter()} AND !span.op:gen_ai.embeddings AND (has:gen_ai.response.text OR has:gen_ai.response.object)`; + +export const INPUT_OUTPUT_FIELD = 'Input / Output' as const; + +export type GenerationFields = typeof INPUT_OUTPUT_FIELD | SpanFields; diff --git a/static/app/views/insights/aiGenerations/views/utils/useFieldsQueryParam.tsx b/static/app/views/insights/aiGenerations/views/utils/useFieldsQueryParam.tsx new file mode 100644 index 00000000000000..cac5e975142afe --- /dev/null +++ b/static/app/views/insights/aiGenerations/views/utils/useFieldsQueryParam.tsx @@ -0,0 +1,23 @@ +import {parseAsArrayOf, parseAsStringEnum, useQueryState} from 'nuqs'; + +import { + INPUT_OUTPUT_FIELD, + type GenerationFields, +} from 'sentry/views/insights/aiGenerations/views/utils/constants'; +import {SpanFields} from 'sentry/views/insights/types'; + +const defaultFields: GenerationFields[] = [ + SpanFields.ID, + INPUT_OUTPUT_FIELD, + SpanFields.GEN_AI_REQUEST_MODEL, + SpanFields.TIMESTAMP, +]; + +export function useFieldsQueryParam() { + return useQueryState( + 'fields', + parseAsArrayOf( + parseAsStringEnum([...Object.values(SpanFields), INPUT_OUTPUT_FIELD]) + ).withDefault(defaultFields) + ); +} From a46499948716124d4e89b1431bf6332d0d306b0b Mon Sep 17 00:00:00 2001 From: Arthur Knaus Date: Thu, 6 Nov 2025 18:27:07 +0100 Subject: [PATCH 2/3] add generic field renderer --- .../views/components/generationsTable.tsx | 22 +++++++++++++++---- 1 file changed, 18 insertions(+), 4 deletions(-) diff --git a/static/app/views/insights/aiGenerations/views/components/generationsTable.tsx b/static/app/views/insights/aiGenerations/views/components/generationsTable.tsx index 3482e095f03232..d0cf1be03f7489 100644 --- a/static/app/views/insights/aiGenerations/views/components/generationsTable.tsx +++ b/static/app/views/insights/aiGenerations/views/components/generationsTable.tsx @@ -1,4 +1,5 @@ import {useCallback, useMemo} from 'react'; +import {useTheme} from '@emotion/react'; import {Button} from '@sentry/scraps/button'; import {Container, Grid} from '@sentry/scraps/layout'; @@ -12,8 +13,11 @@ import { import TimeSince from 'sentry/components/timeSince'; import {t} from 'sentry/locale'; import {getTimeStampFromTableDateField} from 'sentry/utils/dates'; +import {getFieldRenderer} from 'sentry/utils/discover/fieldRenderers'; import type {Sort} from 'sentry/utils/discover/fields'; import {getShortEventId} from 'sentry/utils/events'; +import {useLocation} from 'sentry/utils/useLocation'; +import useOrganization from 'sentry/utils/useOrganization'; import {useTraceViewDrawer} from 'sentry/views/insights/agents/components/drawer'; import { HeadSortCell, @@ -65,6 +69,7 @@ function getLastInputMessage(messages?: string) { const REQUIRED_FIELDS = [ SpanFields.SPAN_STATUS, + SpanFields.PROJECT, SpanFields.TIMESTAMP, SpanFields.TRACE, SpanFields.SPAN_ID, @@ -79,6 +84,9 @@ export function GenerationsTable() { const {cursor} = useTableCursor(); const {tableSort} = useTableSort(DEFAULT_SORT); const [fields] = useFieldsQueryParam(); + const location = useLocation(); + const organization = useOrganization(); + const theme = useTheme(); const fieldsToQuery = useMemo(() => { return [ @@ -89,7 +97,7 @@ export function GenerationsTable() { ]; }, [fields]); - const {data, isLoading, error, pageLinks, isPlaceholderData} = useSpans( + const {data, meta, isLoading, error, pageLinks, isPlaceholderData} = useSpans( { search: query, fields: [...REQUIRED_FIELDS, ...fieldsToQuery] as any, @@ -188,13 +196,19 @@ export function GenerationsTable() { if (column.key === SpanFields.TIMESTAMP) { return ( - + ); } - return
{dataRow[column.key]}
; + const fieldRenderer = getFieldRenderer(column.key, meta!); + return fieldRenderer(dataRow, { + location, + organization, + theme, + projectSlug: dataRow.project, + }); }, - [openTraceViewDrawer] + [openTraceViewDrawer, meta, location, organization, theme] ); const renderHeadCell = useCallback( From dbb7bc4788176bd9fd8be49c9c94bf3ae7362ec2 Mon Sep 17 00:00:00 2001 From: Arthur Knaus Date: Mon, 10 Nov 2025 08:59:08 +0100 Subject: [PATCH 3/3] align attributes usage --- .../views/components/generationsTable.tsx | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/static/app/views/insights/aiGenerations/views/components/generationsTable.tsx b/static/app/views/insights/aiGenerations/views/components/generationsTable.tsx index d0cf1be03f7489..a2f34b7ad31958 100644 --- a/static/app/views/insights/aiGenerations/views/components/generationsTable.tsx +++ b/static/app/views/insights/aiGenerations/views/components/generationsTable.tsx @@ -39,7 +39,7 @@ import {PlatformInsightsTable} from 'sentry/views/insights/pages/platform/shared import {SpanFields} from 'sentry/views/insights/types'; const columnWidths: Partial> = { - [SpanFields.SPAN_ID]: 100, + [SpanFields.ID]: 100, [INPUT_OUTPUT_FIELD]: COL_WIDTH_UNDEFINED, [SpanFields.GEN_AI_REQUEST_MODEL]: 200, }; @@ -72,7 +72,7 @@ const REQUIRED_FIELDS = [ SpanFields.PROJECT, SpanFields.TIMESTAMP, SpanFields.TRACE, - SpanFields.SPAN_ID, + SpanFields.ID, SpanFields.GEN_AI_REQUEST_MESSAGES, SpanFields.GEN_AI_RESPONSE_TEXT, SpanFields.GEN_AI_RESPONSE_OBJECT, @@ -121,12 +121,12 @@ export function GenerationsTable() { onClick={() => { openTraceViewDrawer( dataRow.trace!, - dataRow.span_id, + dataRow.id, getTimeStampFromTableDateField(dataRow.timestamp) ); }} > - {getShortEventId(dataRow.span_id!)} + {getShortEventId(dataRow.id!)} ); @@ -218,7 +218,7 @@ export function GenerationsTable() { align={column.key === SpanFields.TIMESTAMP ? 'right' : 'left'} currentSort={tableSort} sortKey={column.key} - forceCellGrow={column.key === SpanFields.GEN_AI_REQUEST_MESSAGES} + forceCellGrow={column.key === INPUT_OUTPUT_FIELD} > {column.name}