diff --git a/static/app/router/routes.tsx b/static/app/router/routes.tsx index d1324307a5d254..6972e85caee440 100644 --- a/static/app/router/routes.tsx +++ b/static/app/router/routes.tsx @@ -2219,6 +2219,7 @@ function buildRoutes(): RouteObject[] { index: true, component: make(() => import('sentry/views/explore/metrics/content')), }, + traceView, ]; const profilingChildren: SentryRouteObject[] = [ diff --git a/static/app/views/explore/metrics/constants.tsx b/static/app/views/explore/metrics/constants.tsx index 4c6e8a8ea394bd..d3fde609669f0c 100644 --- a/static/app/views/explore/metrics/constants.tsx +++ b/static/app/views/explore/metrics/constants.tsx @@ -72,10 +72,17 @@ export const TraceSamplesTableEmbeddedColumns: Array< > = [ VirtualTableSampleColumnKey.EXPAND_ROW, TraceMetricKnownFieldKey.TIMESTAMP, + VirtualTableSampleColumnKey.PROJECT_BADGE, TraceMetricKnownFieldKey.METRIC_NAME, + TraceMetricKnownFieldKey.METRIC_TYPE, TraceMetricKnownFieldKey.METRIC_VALUE, ]; +export const NoPaddingColumns: VirtualTableSampleColumnKey[] = [ + VirtualTableSampleColumnKey.EXPAND_ROW, + VirtualTableSampleColumnKey.PROJECT_BADGE, +]; + export const OPTIONS_BY_TYPE: Record>> = { counter: [ { diff --git a/static/app/views/explore/metrics/metricInfoTabs/metricInfoTabStyles.tsx b/static/app/views/explore/metrics/metricInfoTabs/metricInfoTabStyles.tsx index ee583c53efbfb1..eed4f4493a07fb 100644 --- a/static/app/views/explore/metrics/metricInfoTabs/metricInfoTabStyles.tsx +++ b/static/app/views/explore/metrics/metricInfoTabs/metricInfoTabStyles.tsx @@ -53,33 +53,31 @@ export const WrappingText = styled('div')` export const ExpandedRowContainer = styled('div')<{embedded?: boolean}>` grid-column: 1 / -1; - border-bottom: 1px solid ${p => p.theme.innerBorder}; - - ${p => - p.embedded && - css` - padding: ${p.theme.space.xs} ${p.theme.space.sm}; - `} `; export const StyledSimpleTableRowCell = styled(SimpleTable.RowCell)<{ embedded?: boolean; noPadding?: boolean; }>` - padding: ${p => (p.noPadding ? 0 : p.theme.space.lg)}; - padding-top: ${p => (p.noPadding ? 0 : p.theme.space.xs)}; - padding-bottom: ${p => (p.noPadding ? 0 : p.theme.space.xs)}; + padding: ${p => (p.noPadding ? 0 : p.embedded ? p.theme.space.xl : p.theme.space.lg)}; + padding-top: ${p => + p.noPadding ? 0 : p.embedded ? p.theme.space.sm : p.theme.space.xs}; + padding-bottom: ${p => + p.noPadding ? 0 : p.embedded ? p.theme.space.sm : p.theme.space.xs}; font-size: ${p => p.theme.fontSize.sm}; `; export const StyledSimpleTableHeaderCell = styled(SimpleTable.HeaderCell)<{ + embedded?: boolean; noPadding?: boolean; }>` font-size: ${p => p.theme.fontSize.sm}; - padding: ${p => (p.noPadding ? 0 : p.theme.space.lg)}; - padding-top: ${p => (p.noPadding ? 0 : p.theme.space.xs)}; - padding-bottom: ${p => (p.noPadding ? 0 : p.theme.space.xs)}; + padding: ${p => (p.noPadding ? 0 : p.embedded ? p.theme.space.xl : p.theme.space.lg)}; + padding-top: ${p => + p.noPadding ? 0 : p.embedded ? p.theme.space.sm : p.theme.space.xs}; + padding-bottom: ${p => + p.noPadding ? 0 : p.embedded ? p.theme.space.sm : p.theme.space.xs}; `; export const StyledSimpleTableBody = styled('div')` @@ -112,6 +110,8 @@ export const StickyTableRow = styled(SimpleTable.Row)<{ background: ${p.theme.background}; position: sticky; box-shadow: 0 2px 4px rgba(0, 0, 0, 0.05); + margin-right: -15px; + padding-right: calc(15px); `} `; @@ -124,6 +124,9 @@ export const DetailsContent = styled(StyledPanel)` export const MetricsDetailsWrapper = styled(DetailsWrapper)` border-top: 0; + border-bottom: 0; + margin-right: -15px; + padding-right: calc(15px + ${p => p.theme.space.md}); `; export const NumericSimpleTableHeaderCell = styled(StyledSimpleTableHeaderCell)` @@ -144,3 +147,17 @@ export const BodyContainer = styled('div')` export const StyledTabPanels = styled(TabPanels)` overflow: auto; `; + +export const TableRowContainer = styled('div')` + display: grid; + grid-template-columns: subgrid; + grid-auto-rows: min-content; + grid-column: 1 / -1; + + :not(:last-child) { + border-bottom: 1px solid ${p => p.theme.border}; + } + + margin-right: -15px; + padding-right: calc(15px); +`; diff --git a/static/app/views/explore/metrics/metricInfoTabs/metricsSamplesTable.tsx b/static/app/views/explore/metrics/metricInfoTabs/metricsSamplesTable.tsx index 6e00fe0fb929e1..cc25b45ede22a6 100644 --- a/static/app/views/explore/metrics/metricInfoTabs/metricsSamplesTable.tsx +++ b/static/app/views/explore/metrics/metricInfoTabs/metricsSamplesTable.tsx @@ -69,7 +69,7 @@ export function MetricsSamplesTable({ return ( {isFetching && } - + {error ? ( diff --git a/static/app/views/explore/metrics/metricInfoTabs/metricsSamplesTableHeader.tsx b/static/app/views/explore/metrics/metricInfoTabs/metricsSamplesTableHeader.tsx index dd82e829aaf754..1c488397ef0920 100644 --- a/static/app/views/explore/metrics/metricInfoTabs/metricsSamplesTableHeader.tsx +++ b/static/app/views/explore/metrics/metricInfoTabs/metricsSamplesTableHeader.tsx @@ -3,6 +3,7 @@ import type {ReactNode} from 'react'; import {Tooltip} from 'sentry/components/core/tooltip'; import {IconFire, IconSpan, IconTerminal} from 'sentry/icons'; import {t} from 'sentry/locale'; +import {NoPaddingColumns} from 'sentry/views/explore/metrics/constants'; import { NumericSimpleTableHeaderCell, StyledSimpleTableHeader, @@ -24,9 +25,13 @@ const ICON_HEADERS = { interface MetricsSamplesTableHeaderProps { columns: SampleTableColumnKey[]; + embedded?: boolean; } -export function MetricsSamplesTableHeader({columns}: MetricsSamplesTableHeaderProps) { +export function MetricsSamplesTableHeader({ + columns, + embedded, +}: MetricsSamplesTableHeaderProps) { const sorts = useQueryParamsSortBys(); return ( @@ -41,6 +46,7 @@ export function MetricsSamplesTableHeader({columns}: MetricsSamplesTableHeaderPr field={field} index={i} sort={sorts.find(s => s.field === field)?.kind} + embedded={embedded} > {columnType === 'stat' ? ICON_HEADERS[field as keyof typeof ICON_HEADERS] @@ -59,15 +65,17 @@ function FieldHeaderCellWrapper({ children, index, sort, + embedded = false, }: { children: ReactNode; field: SampleTableColumnKey; index: number; + embedded?: boolean; sort?: 'asc' | 'desc'; }) { const columnType = getMetricTableColumnType(field); const label = getFieldLabel(field); - const hasPadding = field !== VirtualTableSampleColumnKey.EXPAND_ROW; + const hasPadding = !NoPaddingColumns.includes(field as VirtualTableSampleColumnKey); if (columnType === 'stat') { return ( @@ -75,6 +83,7 @@ function FieldHeaderCellWrapper({ key={`stat-${index}`} divider={false} data-column-name={field} + embedded={embedded} > {children} @@ -92,6 +101,7 @@ function FieldHeaderCellWrapper({ justifyContent: 'flex-end', paddingRight: 'calc(12px + 15px)', // 12px is the padding of the cell, 15px is the width of the scrollbar. }} + embedded={embedded} > {children} @@ -101,7 +111,12 @@ function FieldHeaderCellWrapper({ } return ( - + {children} diff --git a/static/app/views/explore/metrics/metricInfoTabs/metricsSamplesTableRow.tsx b/static/app/views/explore/metrics/metricInfoTabs/metricsSamplesTableRow.tsx index e6bfd259c071cd..5acef3d7151ed9 100644 --- a/static/app/views/explore/metrics/metricInfoTabs/metricsSamplesTableRow.tsx +++ b/static/app/views/explore/metrics/metricInfoTabs/metricsSamplesTableRow.tsx @@ -1,11 +1,13 @@ import {useRef, useState, type ReactNode} from 'react'; import {useTheme} from '@emotion/react'; -import styled from '@emotion/styled'; + +import {Flex} from '@sentry/scraps/layout/flex'; import {Button} from 'sentry/components/core/button'; import {Link} from 'sentry/components/core/link'; import {Tooltip} from 'sentry/components/core/tooltip'; import Count from 'sentry/components/count'; +import ProjectBadge from 'sentry/components/idBadge/projectBadge'; import {IconChevron} from 'sentry/icons'; import {t} from 'sentry/locale'; import type {TableDataRow} from 'sentry/utils/discover/discoverQuery'; @@ -16,10 +18,15 @@ import {FieldValueType} from 'sentry/utils/fields'; import {useLocation} from 'sentry/utils/useLocation'; import useOrganization from 'sentry/utils/useOrganization'; import usePageFilters from 'sentry/utils/usePageFilters'; +import useProjects from 'sentry/utils/useProjects'; import type {TableColumn} from 'sentry/views/discover/table/types'; import {TimestampRenderer} from 'sentry/views/explore/logs/fieldRenderers'; import {getLogColors} from 'sentry/views/explore/logs/styles'; import {SeverityLevel} from 'sentry/views/explore/logs/utils'; +import { + NoPaddingColumns, + type AlwaysPresentTraceMetricFields, +} from 'sentry/views/explore/metrics/constants'; import {useTraceTelemetry} from 'sentry/views/explore/metrics/hooks/useTraceTelemetry'; import {MetricDetails} from 'sentry/views/explore/metrics/metricInfoTabs/metricDetails'; import { @@ -27,6 +34,7 @@ import { NumericSimpleTableRowCell, StickyTableRow, StyledSimpleTableRowCell, + TableRowContainer, WrappingText, } from 'sentry/views/explore/metrics/metricInfoTabs/metricInfoTabStyles'; import {stripMetricParamsFromLocation} from 'sentry/views/explore/metrics/metricQuery'; @@ -69,7 +77,7 @@ function FieldCellWrapper({ embedded?: boolean; }) { const columnType = getMetricTableColumnType(field); - const hasPadding = field !== VirtualTableSampleColumnKey.EXPAND_ROW; + const hasPadding = !NoPaddingColumns.includes(field as VirtualTableSampleColumnKey); if (columnType === 'stat') { return ( (null); + const projects = useProjects(); + const projectId: (typeof AlwaysPresentTraceMetricFields)[1] = + row[TraceMetricKnownFieldKey.PROJECT_ID]; + const project = projects.projects.find(p => p.id === '' + projectId); + const projectSlug = project?.slug ?? ''; const traceId = row[TraceMetricKnownFieldKey.TRACE]; const telemetry = telemetryData?.get?.(traceId); @@ -137,9 +150,9 @@ export function SampleTableRow({ const spanId = row[TraceMetricKnownFieldKey.SPAN_ID]; const oldSpanId = row[TraceMetricKnownFieldKey.OLD_SPAN_ID] as string; const spanIdToUse = oldSpanId || spanId; - const hasSpans = (telemetry?.spansCount ?? 0) > 0; const strippedLocation = stripMetricParamsFromLocation(location); + const hasSpans = (telemetry?.spansCount ?? 0) > 0; const shouldGoToSpans = spanIdToUse && hasSpans; const target = getTraceDetailsUrl({ @@ -150,12 +163,12 @@ export function SampleTableRow({ end: selection.datetime.end, statsPeriod: selection.datetime.period, }, - location: strippedLocation, timestamp, - source: TraceViewSources.TRACES, // TODO: Should be TraceViewSources.TRACE_METRICS later after the trace view changes + location: strippedLocation, + source: TraceViewSources.TRACE_METRICS, spanId: shouldGoToSpans ? spanIdToUse : undefined, - // tab: shouldGoToSpans ? TraceLayoutTabKeys.WATERFALL : TraceLayoutTabKeys.METRICS, // TODO: Add metrics tab to trace view - tab: TraceLayoutTabKeys.WATERFALL, + // tab: shouldGoToSpans ? TraceLayoutTabKeys.WATERFALL : TraceLayoutTabKeys.METRICS, // TODO: Can use this if want to go to the waterfall view if we add metrics to span details. + tab: TraceLayoutTabKeys.METRICS, }); return ( @@ -263,6 +276,14 @@ export function SampleTableRow({ ); }; + const renderProjectCell = () => { + return ( + + + + ); + }; + const renderMap: Record ReactNode> = { [VirtualTableSampleColumnKey.EXPAND_ROW]: renderExpandRowCell, [TraceMetricKnownFieldKey.TRACE]: renderTraceCell, @@ -271,6 +292,7 @@ export function SampleTableRow({ [VirtualTableSampleColumnKey.LOGS]: renderLogsCell, [VirtualTableSampleColumnKey.SPANS]: renderSpansCell, [VirtualTableSampleColumnKey.ERRORS]: renderErrorsCell, + [VirtualTableSampleColumnKey.PROJECT_BADGE]: renderProjectCell, [TraceMetricKnownFieldKey.METRIC_TYPE]: renderMetricTypeCell, }; @@ -315,14 +337,3 @@ export function SampleTableRow({ ); } - -const TableRowContainer = styled('div')` - display: grid; - grid-template-columns: subgrid; - grid-auto-rows: min-content; - grid-column: 1 / -1; - - :not(:last-child) { - border-bottom: 1px solid ${p => p.theme.border}; - } -`; diff --git a/static/app/views/explore/metrics/metricsFlags.tsx b/static/app/views/explore/metrics/metricsFlags.tsx index 4cdaf450a42983..800f43019e352c 100644 --- a/static/app/views/explore/metrics/metricsFlags.tsx +++ b/static/app/views/explore/metrics/metricsFlags.tsx @@ -3,7 +3,7 @@ import type {Organization} from 'sentry/types/organization'; -const canUseMetricsUI = (organization: Organization) => { +export const canUseMetricsUI = (organization: Organization) => { return organization.features.includes('tracemetrics-enabled'); }; diff --git a/static/app/views/explore/metrics/metricsFrozenContext.tsx b/static/app/views/explore/metrics/metricsFrozenContext.tsx index 9685668a7d3e2b..7685e1352e94c2 100644 --- a/static/app/views/explore/metrics/metricsFrozenContext.tsx +++ b/static/app/views/explore/metrics/metricsFrozenContext.tsx @@ -1,8 +1,13 @@ +import type {ReactNode} from 'react'; +import {useMemo} from 'react'; + +import {ALL_ACCESS_PROJECTS} from 'sentry/constants/pageFilters'; import type {DateString} from 'sentry/types/core'; import {createDefinedContext} from 'sentry/utils/performance/contexts/utils'; import {MutableSearch} from 'sentry/utils/tokenizeSearch'; +import {TraceMetricKnownFieldKey} from 'sentry/views/explore/metrics/types'; -interface TracePeriod { +export interface TracePeriod { end?: DateString; period?: string | null; start?: DateString; @@ -16,12 +21,38 @@ interface MetricsFrozenContextValue { tracePeriod?: TracePeriod; } -const [_MetricsFrozenContextProvider, _useMetricsFrozenContext] = +const [_MetricsFrozenContextProvider, _useMetricsFrozenContext, MetricsFrozenContext] = createDefinedContext({ name: 'MetricsFrozenContext', strict: false, }); +export interface MetricsFrozenForTracesProviderProps { + traceIds: string[]; + children?: ReactNode; + tracePeriod?: TracePeriod; +} + +export function MetricsFrozenContextProvider(props: MetricsFrozenForTracesProviderProps) { + const value: MetricsFrozenContextValue = useMemo(() => { + if (props.traceIds.length) { + const search = new MutableSearch(''); + const traceIds = `[${props.traceIds.join(',')}]`; + search.addFilterValue(TraceMetricKnownFieldKey.TRACE, traceIds); + return { + frozen: true, + search, + traceIds: props.traceIds, + projectIds: [ALL_ACCESS_PROJECTS], + tracePeriod: props.tracePeriod, + }; + } + + return {frozen: false}; + }, [props]); + + return {props.children}; +} function useMetricsFrozenContext() { return _useMetricsFrozenContext() ?? {}; } diff --git a/static/app/views/explore/metrics/metricsQueryParams.tsx b/static/app/views/explore/metrics/metricsQueryParams.tsx index c1af47c287d1a7..c4ca01e95f7683 100644 --- a/static/app/views/explore/metrics/metricsQueryParams.tsx +++ b/static/app/views/explore/metrics/metricsQueryParams.tsx @@ -4,6 +4,10 @@ import {useCallback, useMemo} from 'react'; import {defined} from 'sentry/utils'; import {createDefinedContext} from 'sentry/utils/performance/contexts/utils'; import {defaultQuery, type TraceMetric} from 'sentry/views/explore/metrics/metricQuery'; +import { + MetricsFrozenContextProvider, + type MetricsFrozenForTracesProviderProps, +} from 'sentry/views/explore/metrics/metricsFrozenContext'; import type {AggregateField} from 'sentry/views/explore/queryParams/aggregateField'; import { QueryParamsContextProvider, @@ -37,6 +41,7 @@ interface MetricsQueryParamsProviderProps { setQueryParams: (queryParams: ReadableQueryParams) => void; setTraceMetric: (traceMetric: TraceMetric) => void; traceMetric: TraceMetric; + freeze?: MetricsFrozenForTracesProviderProps; } export function MetricsQueryParamsProvider({ @@ -46,6 +51,7 @@ export function MetricsQueryParamsProvider({ setTraceMetric, removeMetric, traceMetric, + freeze, }: MetricsQueryParamsProviderProps) { const setWritableQueryParams = useCallback( (writableQueryParams: WritableQueryParams) => { @@ -72,14 +78,19 @@ export function MetricsQueryParamsProvider({ return ( - - {children} - + + {children} + + ); } diff --git a/static/app/views/explore/metrics/multiMetricsQueryParams.tsx b/static/app/views/explore/metrics/multiMetricsQueryParams.tsx index 7b3e746db652a6..f10c69a3a658b0 100644 --- a/static/app/views/explore/metrics/multiMetricsQueryParams.tsx +++ b/static/app/views/explore/metrics/multiMetricsQueryParams.tsx @@ -39,16 +39,18 @@ const [ interface MultiMetricsQueryParamsProviderProps { children: ReactNode; + allowUpTo?: number; } export function MultiMetricsQueryParamsProvider({ children, + allowUpTo, }: MultiMetricsQueryParamsProviderProps) { const location = useLocation(); const navigate = useNavigate(); const value: MultiMetricsQueryParamsContextValue = useMemo(() => { - const metricQueries = getMultiMetricsQueryParamsFromLocation(location); + const metricQueries = getMultiMetricsQueryParamsFromLocation(location, allowUpTo); function setQueryParamsForIndex(i: number) { return function (newQueryParams: ReadableQueryParams) { @@ -158,7 +160,7 @@ export function MultiMetricsQueryParamsProvider({ }; }), }; - }, [location, navigate]); + }, [location, navigate, allowUpTo]); return ( @@ -167,14 +169,17 @@ export function MultiMetricsQueryParamsProvider({ ); } -function getMultiMetricsQueryParamsFromLocation(location: Location): BaseMetricQuery[] { +function getMultiMetricsQueryParamsFromLocation( + location: Location, + limit?: number +): BaseMetricQuery[] { const rawQueryParams = decodeList(location.query.metric); const metricQueries = rawQueryParams.map(decodeMetricsQueryParams).filter(defined); - if (metricQueries.length) { - return metricQueries; - } - return [defaultMetricQuery()]; + + const queries = metricQueries.length ? metricQueries : [defaultMetricQuery()]; + + return limit ? queries.slice(0, limit) : queries; } export function useMultiMetricsQueryParams() { @@ -199,3 +204,24 @@ export function useAddMetricQuery() { navigate(target); }; } + +export function SingleMetricQueryParamsProvider({children}: {children: ReactNode}) { + return ( + + {children} + + ); +} + +export function useSingleMetricQueryParams() { + const metricQueries = useMultiMetricsQueryParams(); + const metricQuery = metricQueries[0]!; + + return { + queryParams: metricQuery.queryParams, + setQueryParams: metricQuery.setQueryParams, + metric: metricQuery.metric, + setTraceMetric: metricQuery.setTraceMetric, + removeMetric: metricQuery.removeMetric, + }; +} diff --git a/static/app/views/explore/metrics/types.tsx b/static/app/views/explore/metrics/types.tsx index ffd6622b02e221..9e964449bd7af1 100644 --- a/static/app/views/explore/metrics/types.tsx +++ b/static/app/views/explore/metrics/types.tsx @@ -103,6 +103,7 @@ export interface TraceMetricEventsResult { */ export enum VirtualTableSampleColumnKey { EXPAND_ROW = 'expand_row', // Chevron acts as an additional column + PROJECT_BADGE = 'project_badge', LOGS = 'logs', SPANS = 'spans', ERRORS = 'errors', diff --git a/static/app/views/performance/newTraceDetails/index.tsx b/static/app/views/performance/newTraceDetails/index.tsx index d4d4fc24ddecf5..bed554dd2dbcb7 100644 --- a/static/app/views/performance/newTraceDetails/index.tsx +++ b/static/app/views/performance/newTraceDetails/index.tsx @@ -12,6 +12,10 @@ import {useLogsPageDataQueryResult} from 'sentry/views/explore/contexts/logs/log import type {OurLogsResponseItem} from 'sentry/views/explore/logs/types'; import TraceAiSpans from 'sentry/views/performance/newTraceDetails/traceDrawer/tabs/traceAiSpans'; import {TraceProfiles} from 'sentry/views/performance/newTraceDetails/traceDrawer/tabs/traceProfiles'; +import { + TraceViewMetricsDataProvider, + TraceViewMetricsSection, +} from 'sentry/views/performance/newTraceDetails/traceMetrics'; import { TraceViewLogsDataProvider, TraceViewLogsSection, @@ -35,6 +39,7 @@ import { import {TraceStateProvider} from './traceState/traceStateProvider'; import {ErrorsOnlyWarnings} from './traceTypeWarnings/errorsOnlyWarnings'; import {TraceMetaDataHeader} from './traceHeader'; +import {useInitialTraceMetricData} from './useInitialTraceMetricData'; import {useTraceEventView} from './useTraceEventView'; import {useTraceQueryParams} from './useTraceQueryParams'; import useTraceStateAnalytics from './useTraceStateAnalytics'; @@ -69,12 +74,14 @@ export default function TraceView() { return ( - - - + + + + + ); } @@ -101,6 +108,11 @@ function TraceViewImpl({traceSlug}: {traceSlug: string}) { const queryParams = useTraceQueryParams(); const traceEventView = useTraceEventView(traceSlug, queryParams); const logsData = useInitialLogsData(); + const {metricsData} = useInitialTraceMetricData({ + traceId: traceSlug, + queryParams, + enabled: true, + }); const hideTraceWaterfallIfEmpty = (logsData?.length ?? 0) > 0; const meta = useTraceMeta([{traceSlug, timestamp: queryParams.timestamp}]); @@ -125,6 +137,7 @@ function TraceViewImpl({traceSlug}: {traceSlug: string}) { const {tabOptions, currentTab, onTabChange} = useTraceLayoutTabs({ tree, logs: logsData, + metrics: metricsData, }); return ( @@ -142,6 +155,7 @@ function TraceViewImpl({traceSlug}: {traceSlug: string}) { traceSlug={traceSlug} traceEventView={traceEventView} logs={logsData} + metrics={metricsData} /> ) : null} + {currentTab === TraceLayoutTabKeys.METRICS ? ( + + ) : null} {currentTab === TraceLayoutTabKeys.SUMMARY ? ( ) : null} diff --git a/static/app/views/performance/newTraceDetails/traceContextVitals.tsx b/static/app/views/performance/newTraceDetails/traceContextVitals.tsx index 2ebb63e9c30318..1f476ac16e397f 100644 --- a/static/app/views/performance/newTraceDetails/traceContextVitals.tsx +++ b/static/app/views/performance/newTraceDetails/traceContextVitals.tsx @@ -39,7 +39,11 @@ type Props = { }; export function TraceContextVitals({rootEventResults, tree, containerWidth}: Props) { - const {hasVitals} = useTraceContextSections({tree, logs: undefined}); + const {hasVitals} = useTraceContextSections({ + tree, + logs: undefined, + metrics: undefined, + }); const traceNode = tree.root.children[0]; const theme = useTheme(); diff --git a/static/app/views/performance/newTraceDetails/traceHeader/breadcrumbs.tsx b/static/app/views/performance/newTraceDetails/traceHeader/breadcrumbs.tsx index aaf6fd55831664..dca1cd1a6901db 100644 --- a/static/app/views/performance/newTraceDetails/traceHeader/breadcrumbs.tsx +++ b/static/app/views/performance/newTraceDetails/traceHeader/breadcrumbs.tsx @@ -518,6 +518,17 @@ export function getTraceViewBreadcrumbs({ }, leafBreadcrumb, ]; + case TraceViewSources.TRACE_METRICS: + return [ + { + label: t('Metrics'), + to: getBreadCrumbTarget( + normalizeUrl(`/organizations/${organization.slug}/explore/metrics/`), + location.query + ), + }, + leafBreadcrumb, + ]; default: return [ { diff --git a/static/app/views/performance/newTraceDetails/traceHeader/index.tsx b/static/app/views/performance/newTraceDetails/traceHeader/index.tsx index d938d6a3cfc3f8..f1e871f8359fa9 100644 --- a/static/app/views/performance/newTraceDetails/traceHeader/index.tsx +++ b/static/app/views/performance/newTraceDetails/traceHeader/index.tsx @@ -31,6 +31,7 @@ import {Title} from './title'; export interface TraceMetadataHeaderProps { logs: OurLogsResponseItem[] | undefined; metaResults: TraceMetaQueryResults; + metrics: {count: number} | undefined; organization: Organization; rootEventResults: TraceRootEventQueryResults; traceEventView: EventView; @@ -44,9 +45,10 @@ export function TraceMetaDataHeader(props: TraceMetadataHeaderProps) { const {view} = useDomainViewFilters(); const moduleURLBuilder = useModuleURLBuilder(true); const {projects} = useProjects(); - const {hasLogs} = useTraceContextSections({ + const {hasLogs, hasMetrics} = useTraceContextSections({ tree: props.tree, logs: props.logs, + metrics: props.metrics, }); const isLoading = @@ -59,7 +61,7 @@ export function TraceMetaDataHeader(props: TraceMetadataHeaderProps) { props.rootEventResults.status === 'error' || props.tree.type === 'error'; - const noEvents = props.tree.type === 'empty' && !hasLogs; + const noEvents = props.tree.type === 'empty' && !hasLogs && !hasMetrics; if (isLoading || isError || noEvents) { return ; } @@ -99,6 +101,7 @@ export function TraceMetaDataHeader(props: TraceMetadataHeaderProps) { meta={props.metaResults.data} representativeEvent={rep} logs={props.logs} + metrics={props.metrics} /> diff --git a/static/app/views/performance/newTraceDetails/traceHeader/meta.tsx b/static/app/views/performance/newTraceDetails/traceHeader/meta.tsx index 085dda6dbe4706..8ed0cf11996ac6 100644 --- a/static/app/views/performance/newTraceDetails/traceHeader/meta.tsx +++ b/static/app/views/performance/newTraceDetails/traceHeader/meta.tsx @@ -52,6 +52,7 @@ const SectionBody = styled('div')<{alignment?: boolean}>` interface MetaProps { logs: OurLogsResponseItem[] | undefined; meta: TraceMetaQueryResults['data']; + metrics: {count: number} | undefined; organization: Organization; representativeEvent: RepresentativeTraceEvent; tree: TraceTree; diff --git a/static/app/views/performance/newTraceDetails/traceMetrics.tsx b/static/app/views/performance/newTraceDetails/traceMetrics.tsx new file mode 100644 index 00000000000000..4b70b59e01d414 --- /dev/null +++ b/static/app/views/performance/newTraceDetails/traceMetrics.tsx @@ -0,0 +1,137 @@ +import type React from 'react'; +import {Fragment, useMemo} from 'react'; +import styled from '@emotion/styled'; + +import Panel from 'sentry/components/panels/panel'; +import {SearchQueryBuilder} from 'sentry/components/searchQueryBuilder'; +import {t} from 'sentry/locale'; +import {space} from 'sentry/styles/space'; +import {MetricsSamplesTable} from 'sentry/views/explore/metrics/metricInfoTabs/metricsSamplesTable'; +import type {TracePeriod} from 'sentry/views/explore/metrics/metricsFrozenContext'; +import {MetricsQueryParamsProvider} from 'sentry/views/explore/metrics/metricsQueryParams'; +import { + SingleMetricQueryParamsProvider, + useSingleMetricQueryParams, +} from 'sentry/views/explore/metrics/multiMetricsQueryParams'; +import { + useQueryParamsSearch, + useSetQueryParamsQuery, +} from 'sentry/views/explore/queryParams/context'; +import {useTraceQueryParams} from 'sentry/views/performance/newTraceDetails/useTraceQueryParams'; + +type UseTraceViewMetricsDataProps = { + children: React.ReactNode; + traceSlug: string; +}; + +export function TraceViewMetricsDataProvider({ + children, + traceSlug, +}: UseTraceViewMetricsDataProps) { + const queryParams = useTraceQueryParams(); + + const tracePeriod: TracePeriod | undefined = useMemo(() => { + // If timestamp is available, create a +-3 hour window around it + if (queryParams.timestamp) { + const timestampMs = queryParams.timestamp * 1000; + const threeHoursMs = 3 * 60 * 60 * 1000; + const start = new Date(timestampMs - threeHoursMs).toISOString(); + const end = new Date(timestampMs + threeHoursMs).toISOString(); + return { + start, + end, + period: null, + }; + } + + // Fallback to existing period logic if no timestamp + if (queryParams.start || queryParams.end || queryParams.statsPeriod) { + return { + start: queryParams.start, + end: queryParams.end, + period: queryParams.statsPeriod, + }; + } + return undefined; + }, [ + queryParams.timestamp, + queryParams.start, + queryParams.end, + queryParams.statsPeriod, + ]); + + return ( + + + {children} + + + ); +} + +function TraceViewMetricsDataProviderInner({ + children, + traceSlug, + tracePeriod, +}: { + children: React.ReactNode; + traceSlug: string; + tracePeriod?: TracePeriod; +}) { + const {queryParams, setQueryParams, metric, setTraceMetric, removeMetric} = + useSingleMetricQueryParams(); + + return ( + + {children} + + ); +} + +export function TraceViewMetricsSection() { + return ( + + + + ); +} + +function MetricsSectionContent() { + const setMetricsQuery = useSetQueryParamsQuery(); + const metricsSearch = useQueryParamsSearch(); + + return ( + + new Promise(() => [])} + initialQuery={metricsSearch.formatString()} + searchSource="tracemetrics" + onSearch={query => setMetricsQuery(query)} + /> + + + + + ); +} + +const TableContainer = styled('div')` + margin-top: ${space(2)}; +`; + +const StyledPanel = styled(Panel)` + padding: ${space(2)}; + margin: 0; +`; diff --git a/static/app/views/performance/newTraceDetails/useInitialTraceMetricData.tsx b/static/app/views/performance/newTraceDetails/useInitialTraceMetricData.tsx new file mode 100644 index 00000000000000..95613819cf6a06 --- /dev/null +++ b/static/app/views/performance/newTraceDetails/useInitialTraceMetricData.tsx @@ -0,0 +1,110 @@ +import {useMemo} from 'react'; + +import type {PageFilters} from 'sentry/types/core'; +import {DiscoverDatasets} from 'sentry/utils/discover/types'; +import {useApiQuery, type ApiQueryKey} from 'sentry/utils/queryClient'; +import {MutableSearch} from 'sentry/utils/tokenizeSearch'; +import useOrganization from 'sentry/utils/useOrganization'; +import usePageFilters from 'sentry/utils/usePageFilters'; +import {canUseMetricsUI} from 'sentry/views/explore/metrics/metricsFlags'; +import {TraceMetricKnownFieldKey} from 'sentry/views/explore/metrics/types'; + +import type {TraceViewQueryParams} from './useTraceQueryParams'; + +interface UseInitialTraceMetricDataProps { + queryParams: TraceViewQueryParams; + traceId: string; + enabled?: boolean; +} + +interface TraceMetricCountResult { + data: Array<{ + 'count()': number; + }>; +} + +function traceMetricCountQueryKey({ + orgSlug, + traceId, + queryParams, + projectIds, +}: { + orgSlug: string; + projectIds: PageFilters['projects']; + queryParams: TraceViewQueryParams; + traceId: string; +}): ApiQueryKey { + const searchValue = new MutableSearch(''); + searchValue.addFilterValue(TraceMetricKnownFieldKey.TRACE, traceId); + + const query: Record = { + dataset: DiscoverDatasets.TRACEMETRICS, + field: ['count()'], + query: searchValue.formatString(), + referrer: 'api.trace-details.initial-metric-data', + }; + + if (projectIds?.length) { + query.project = projectIds.map(String); + } + + // Use the period from trace query params + if (queryParams.statsPeriod) { + query.statsPeriod = queryParams.statsPeriod; + } else if (queryParams.start && queryParams.end) { + query.start = queryParams.start; + query.end = queryParams.end; + } else { + // Default fallback + query.statsPeriod = '24h'; + } + + return [`/organizations/${orgSlug}/events/`, {query}]; +} + +/** + * Hook to fetch initial trace metric count data for a specific trace ID. + * Returns a memoized object with the count to avoid unnecessary re-renders. + * + * Used in trace view for approximate metrics count (may vary on statsperiod). + * Should not be used for fetching metrics data, use useMetricAggregatesTable instead. + */ +export function useInitialTraceMetricData({ + traceId, + queryParams, + enabled = true, +}: UseInitialTraceMetricDataProps) { + const organization = useOrganization(); + const {selection} = usePageFilters(); + + const hasMetricsFeature = canUseMetricsUI(organization); + + const queryKey = useMemo( + () => + traceMetricCountQueryKey({ + orgSlug: organization.slug, + traceId, + queryParams, + projectIds: selection.projects, + }), + [organization.slug, traceId, queryParams, selection.projects] + ); + + const result = useApiQuery(queryKey, { + staleTime: 5 * 60 * 1000, // Cache for 5 minutes + refetchOnWindowFocus: false, + refetchOnMount: false, + retry: false, + enabled: enabled && Boolean(traceId) && hasMetricsFeature, + }); + + const metricsData = useMemo(() => { + const count = result.data?.data?.[0]?.['count()'] ?? 0; + return {count}; + }, [result.data]); + + return { + ...result, + metricsData, + }; +} diff --git a/static/app/views/performance/newTraceDetails/useTraceContextSections.tsx b/static/app/views/performance/newTraceDetails/useTraceContextSections.tsx index e9844e995f81c3..f285a8ceec18d6 100644 --- a/static/app/views/performance/newTraceDetails/useTraceContextSections.tsx +++ b/static/app/views/performance/newTraceDetails/useTraceContextSections.tsx @@ -9,8 +9,10 @@ import {TraceTree} from 'sentry/views/performance/newTraceDetails/traceModels/tr export function useTraceContextSections({ tree, logs, + metrics, }: { logs: OurLogsResponseItem[] | undefined; + metrics: {count: number} | undefined; tree: TraceTree; }) { const organization = useOrganization(); @@ -18,6 +20,7 @@ export function useTraceContextSections({ const hasProfiles: boolean = tree.type === 'trace' && tree.profiled_events.size > 0; const hasLogs = !!(logs && logs?.length > 0); + const hasMetrics = !!(metrics && metrics.count > 0); const hasOnlyLogs: boolean = tree.type === 'empty' && hasLogs; const allowedVitals = Object.keys(VITAL_DETAILS); @@ -36,7 +39,8 @@ export function useTraceContextSections({ hasVitals, hasSummary, hasAiSpans, + hasMetrics, }), - [hasProfiles, hasOnlyLogs, hasLogs, hasVitals, hasSummary, hasAiSpans] + [hasProfiles, hasOnlyLogs, hasLogs, hasVitals, hasSummary, hasAiSpans, hasMetrics] ); } diff --git a/static/app/views/performance/newTraceDetails/useTraceLayoutTabs.tsx b/static/app/views/performance/newTraceDetails/useTraceLayoutTabs.tsx index 6e2dd3809ea686..28aa9d62eb7a26 100644 --- a/static/app/views/performance/newTraceDetails/useTraceLayoutTabs.tsx +++ b/static/app/views/performance/newTraceDetails/useTraceLayoutTabs.tsx @@ -11,6 +11,7 @@ export enum TraceLayoutTabKeys { WATERFALL = 'waterfall', PROFILES = 'profiles', LOGS = 'logs', + METRICS = 'metrics', SUMMARY = 'summary', AI_SPANS = 'ai-spans', } @@ -36,6 +37,10 @@ const TAB_DEFINITIONS: Record = { label: t('Profiles'), }, [TraceLayoutTabKeys.LOGS]: {slug: TraceLayoutTabKeys.LOGS, label: t('Logs')}, + [TraceLayoutTabKeys.METRICS]: { + slug: TraceLayoutTabKeys.METRICS, + label: t('Metrics'), + }, [TraceLayoutTabKeys.SUMMARY]: {slug: TraceLayoutTabKeys.SUMMARY, label: t('Summary')}, [TraceLayoutTabKeys.AI_SPANS]: { slug: TraceLayoutTabKeys.AI_SPANS, @@ -62,6 +67,10 @@ function getTabOptions({ tabOptions.push(TAB_DEFINITIONS[TraceLayoutTabKeys.LOGS]); } + if (sections.hasMetrics) { + tabOptions.push(TAB_DEFINITIONS[TraceLayoutTabKeys.METRICS]); + } + if (sections.hasSummary) { tabOptions.push(TAB_DEFINITIONS[TraceLayoutTabKeys.SUMMARY]); } @@ -75,17 +84,20 @@ function getTabOptions({ interface UseTraceLayoutTabsProps { logs: OurLogsResponseItem[] | undefined; + metrics: {count: number} | undefined; tree: TraceTree; } export function useTraceLayoutTabs({ tree, logs, + metrics, }: UseTraceLayoutTabsProps): TraceLayoutTabsConfig { const navigate = useNavigate(); const sections = useTraceContextSections({ tree, logs, + metrics, }); const tabOptions = getTabOptions({sections: {...sections}});