diff --git a/static/app/components/events/metrics/metricsDrawer.tsx b/static/app/components/events/metrics/metricsDrawer.tsx new file mode 100644 index 00000000000000..c53a193f3e7776 --- /dev/null +++ b/static/app/components/events/metrics/metricsDrawer.tsx @@ -0,0 +1,71 @@ +import {useRef} from 'react'; + +import {ProjectAvatar} from 'sentry/components/core/avatar/projectAvatar'; +import { + CrumbContainer, + EventDrawerBody, + EventDrawerContainer, + EventDrawerHeader, + EventNavigator, + NavigationCrumbs, + ShortId, +} from 'sentry/components/events/eventDrawer'; +import {SearchQueryBuilder} from 'sentry/components/searchQueryBuilder'; +import {t} from 'sentry/locale'; +import type {Event} from 'sentry/types/event'; +import type {Group} from 'sentry/types/group'; +import type {Project} from 'sentry/types/project'; +import {getShortEventId} from 'sentry/utils/events'; +import {MetricsSamplesTable} from 'sentry/views/explore/metrics/metricInfoTabs/metricsSamplesTable'; +import { + useQueryParamsSearch, + useSetQueryParamsQuery, +} from 'sentry/views/explore/queryParams/context'; + +interface MetricsIssueDrawerProps { + event: Event; + group: Group; + project: Project; +} + +export function MetricsDrawer({event, project, group}: MetricsIssueDrawerProps) { + const setMetricsQuery = useSetQueryParamsQuery(); + const metricsSearch = useQueryParamsSearch(); + const containerRef = useRef(null); + + return ( + + + + + {group.shortId} + + ), + }, + {label: getShortEventId(event.id)}, + {label: t('Metrics')}, + ]} + /> + + + new Promise(() => [])} + initialQuery={metricsSearch.formatString()} + searchSource="tracemetrics" + onSearch={query => setMetricsQuery(query)} + /> + + +
+ +
+
+
+ ); +} diff --git a/static/app/components/events/metrics/metricsSection.spec.tsx b/static/app/components/events/metrics/metricsSection.spec.tsx new file mode 100644 index 00000000000000..5d4c76de86b973 --- /dev/null +++ b/static/app/components/events/metrics/metricsSection.spec.tsx @@ -0,0 +1,330 @@ +import {EventFixture} from 'sentry-fixture/event'; +import {GroupFixture} from 'sentry-fixture/group'; +import {OrganizationFixture} from 'sentry-fixture/organization'; +import {ProjectFixture} from 'sentry-fixture/project'; + +import { + render, + screen, + userEvent, + waitFor, + within, +} from 'sentry-test/reactTestingLibrary'; + +import {MetricsSection} from 'sentry/components/events/metrics/metricsSection'; +import PageFiltersStore from 'sentry/stores/pageFiltersStore'; +import ProjectsStore from 'sentry/stores/projectsStore'; +import {TraceMetricKnownFieldKey} from 'sentry/views/explore/metrics/types'; + +const TRACE_ID = '00000000000000000000000000000000'; + +const organization = OrganizationFixture({ + features: ['tracemetrics-enabled'], +}); + +const project = ProjectFixture(); +const group = GroupFixture(); + +const event = EventFixture({ + id: '11111111111111111111111111111111', + dateCreated: '2025-01-01T12:00:00.000Z', + contexts: { + trace: { + trace_id: TRACE_ID, + span_id: '1111111111111111', + op: 'ui.action.click', + type: 'trace', + }, + }, +}); + +describe('MetricsSection', () => { + let metricId: string; + let mockRequest: jest.Mock; + + beforeEach(() => { + metricId = '22222222222222222222222222222222'; + + ProjectsStore.loadInitialData([project]); + + PageFiltersStore.init(); + PageFiltersStore.onInitializeUrlState({ + projects: [parseInt(project.id, 10)], + environments: [], + datetime: { + period: '14d', + start: null, + end: null, + utc: null, + }, + }); + + MockApiClient.addMockResponse({ + url: `/projects/`, + body: [project], + }); + + MockApiClient.addMockResponse({ + url: `/projects/${organization.slug}/${project.slug}/`, + body: project, + }); + + mockRequest = MockApiClient.addMockResponse({ + url: `/organizations/${organization.slug}/events/`, + body: { + data: [ + { + [TraceMetricKnownFieldKey.ID]: metricId, + [TraceMetricKnownFieldKey.PROJECT_ID]: project.id, + [TraceMetricKnownFieldKey.ORGANIZATION_ID]: Number(organization.id), + [TraceMetricKnownFieldKey.TRACE]: TRACE_ID, + [TraceMetricKnownFieldKey.METRIC_NAME]: 'http.server.duration', + [TraceMetricKnownFieldKey.METRIC_TYPE]: 'distribution', + [TraceMetricKnownFieldKey.METRIC_VALUE]: 150.5, + [TraceMetricKnownFieldKey.TIMESTAMP]: '2025-01-01T12:00:00.000Z', + }, + ], + meta: { + fields: { + [TraceMetricKnownFieldKey.METRIC_NAME]: 'string', + [TraceMetricKnownFieldKey.METRIC_TYPE]: 'string', + [TraceMetricKnownFieldKey.METRIC_VALUE]: 'number', + }, + units: {}, + }, + }, + }); + + MockApiClient.addMockResponse({ + url: `/organizations/${organization.slug}/trace-items/attributes/`, + method: 'GET', + body: {}, + }); + + MockApiClient.addMockResponse({ + url: '/organizations/org-slug/recent-searches/', + body: [], + }); + + // Mock telemetry requests for errors, spans, and logs + MockApiClient.addMockResponse({ + url: `/organizations/${organization.slug}/events/`, + match: [MockApiClient.matchQuery({dataset: 'errors'})], + body: {data: [], meta: {}}, + }); + + MockApiClient.addMockResponse({ + url: `/organizations/${organization.slug}/events/`, + match: [MockApiClient.matchQuery({dataset: 'spansIndexed'})], + body: {data: [], meta: {}}, + }); + + MockApiClient.addMockResponse({ + url: `/organizations/${organization.slug}/events/`, + match: [MockApiClient.matchQuery({dataset: 'ourlogs'})], + body: {data: [], meta: {}}, + }); + }); + + afterEach(() => { + jest.restoreAllMocks(); + }); + + it('renders empty when no trace id', () => { + const eventWithoutTrace = EventFixture({ + contexts: {}, + }); + + render(, { + organization, + }); + + expect(screen.queryByText(/Metrics/)).not.toBeInTheDocument(); + }); + + it('does not render when feature flag is disabled', () => { + const orgWithoutFeature = OrganizationFixture({ + features: [], + }); + + render(, { + organization: orgWithoutFeature, + }); + + expect(screen.queryByText(/Metrics/)).not.toBeInTheDocument(); + }); + + it('renders empty when no metrics data', () => { + const mockRequestEmpty = MockApiClient.addMockResponse({ + url: `/organizations/${organization.slug}/events/`, + body: { + data: [], + meta: {}, + }, + }); + + render(, { + organization, + }); + + expect(mockRequestEmpty).toHaveBeenCalledTimes(1); + expect(screen.queryByText(/Metrics/)).not.toBeInTheDocument(); + }); + + it('renders metrics section with data', async () => { + render(, { + organization, + initialRouterConfig: { + location: { + pathname: `/organizations/${organization.slug}/issues/${group.id}/`, + query: { + project: project.id, + }, + }, + }, + }); + + expect(mockRequest).toHaveBeenCalledTimes(1); + + await waitFor(() => { + expect(screen.getByText(/Metrics/)).toBeInTheDocument(); + }); + + expect(screen.getByTestId('metrics')).toBeInTheDocument(); + }); + + it('shows view more button when there are more than 5 metrics', async () => { + const mockRequestWithManyMetrics = MockApiClient.addMockResponse({ + url: `/organizations/${organization.slug}/events/`, + body: { + data: Array.from({length: 10}, (_, i) => ({ + [TraceMetricKnownFieldKey.ID]: `metric-${i}`, + [TraceMetricKnownFieldKey.PROJECT_ID]: project.id, + [TraceMetricKnownFieldKey.ORGANIZATION_ID]: Number(organization.id), + [TraceMetricKnownFieldKey.TRACE]: TRACE_ID, + [TraceMetricKnownFieldKey.METRIC_NAME]: `metric.name.${i}`, + [TraceMetricKnownFieldKey.METRIC_TYPE]: 'counter', + [TraceMetricKnownFieldKey.METRIC_VALUE]: i * 10, + [TraceMetricKnownFieldKey.TIMESTAMP]: '2025-01-01T12:00:00.000Z', + })), + meta: { + fields: { + [TraceMetricKnownFieldKey.METRIC_NAME]: 'string', + [TraceMetricKnownFieldKey.METRIC_TYPE]: 'string', + [TraceMetricKnownFieldKey.METRIC_VALUE]: 'number', + }, + units: {}, + }, + }, + }); + + render(, { + organization, + }); + + expect(mockRequestWithManyMetrics).toHaveBeenCalledTimes(1); + + await waitFor(() => { + expect(screen.getByText(/Metrics/)).toBeInTheDocument(); + }); + + expect(screen.getByRole('button', {name: 'View more'})).toBeInTheDocument(); + }); + + it('opens metrics drawer when view more is clicked', async () => { + const mockRequestWithManyMetrics = MockApiClient.addMockResponse({ + url: `/organizations/${organization.slug}/events/`, + body: { + data: Array.from({length: 10}, (_, i) => ({ + [TraceMetricKnownFieldKey.ID]: `metric-${i}`, + [TraceMetricKnownFieldKey.PROJECT_ID]: project.id, + [TraceMetricKnownFieldKey.ORGANIZATION_ID]: Number(organization.id), + [TraceMetricKnownFieldKey.TRACE]: TRACE_ID, + [TraceMetricKnownFieldKey.METRIC_NAME]: `metric.name.${i}`, + [TraceMetricKnownFieldKey.METRIC_TYPE]: 'counter', + [TraceMetricKnownFieldKey.METRIC_VALUE]: i * 10, + [TraceMetricKnownFieldKey.TIMESTAMP]: '2025-01-01T12:00:00.000Z', + })), + meta: { + fields: { + [TraceMetricKnownFieldKey.METRIC_NAME]: 'string', + [TraceMetricKnownFieldKey.METRIC_TYPE]: 'string', + [TraceMetricKnownFieldKey.METRIC_VALUE]: 'number', + }, + units: {}, + }, + }, + }); + + render(, { + organization, + initialRouterConfig: { + location: { + pathname: `/organizations/${organization.slug}/issues/${group.id}/`, + query: { + project: project.id, + }, + }, + }, + }); + + expect(mockRequestWithManyMetrics).toHaveBeenCalledTimes(1); + + await waitFor(() => { + expect(screen.getByText(/Metrics/)).toBeInTheDocument(); + }); + + expect( + screen.queryByRole('complementary', {name: 'metrics drawer'}) + ).not.toBeInTheDocument(); + + await userEvent.click(screen.getByRole('button', {name: 'View more'})); + + const aside = screen.getByRole('complementary', {name: 'metrics drawer'}); + expect(aside).toBeInTheDocument(); + + // Check that the drawer contains the expected elements + expect(within(aside).getByText('Metrics')).toBeInTheDocument(); + expect( + within(aside).getByPlaceholderText('Search metrics for this trace') + ).toBeInTheDocument(); + }); + + it('does not show view more button when there are 5 or fewer metrics', async () => { + const mockRequestWithFewMetrics = MockApiClient.addMockResponse({ + url: `/organizations/${organization.slug}/events/`, + body: { + data: Array.from({length: 3}, (_, i) => ({ + [TraceMetricKnownFieldKey.ID]: `metric-${i}`, + [TraceMetricKnownFieldKey.PROJECT_ID]: project.id, + [TraceMetricKnownFieldKey.ORGANIZATION_ID]: Number(organization.id), + [TraceMetricKnownFieldKey.TRACE]: TRACE_ID, + [TraceMetricKnownFieldKey.METRIC_NAME]: `metric.name.${i}`, + [TraceMetricKnownFieldKey.METRIC_TYPE]: 'counter', + [TraceMetricKnownFieldKey.METRIC_VALUE]: i * 10, + [TraceMetricKnownFieldKey.TIMESTAMP]: '2025-01-01T12:00:00.000Z', + })), + meta: { + fields: { + [TraceMetricKnownFieldKey.METRIC_NAME]: 'string', + [TraceMetricKnownFieldKey.METRIC_TYPE]: 'string', + [TraceMetricKnownFieldKey.METRIC_VALUE]: 'number', + }, + units: {}, + }, + }, + }); + + render(, { + organization, + }); + + expect(mockRequestWithFewMetrics).toHaveBeenCalledTimes(1); + + await waitFor(() => { + expect(screen.getByText(/Metrics/)).toBeInTheDocument(); + }); + + expect(screen.queryByRole('button', {name: 'View more'})).not.toBeInTheDocument(); + }); +}); diff --git a/static/app/components/events/metrics/metricsSection.tsx b/static/app/components/events/metrics/metricsSection.tsx new file mode 100644 index 00000000000000..8e99a3f9c433f5 --- /dev/null +++ b/static/app/components/events/metrics/metricsSection.tsx @@ -0,0 +1,138 @@ +import {useCallback, useRef} from 'react'; + +import {Flex} from '@sentry/scraps/layout/flex'; + +import {Button} from 'sentry/components/core/button'; +import {MetricsDrawer} from 'sentry/components/events/metrics/metricsDrawer'; +import {useMetricsIssueSection} from 'sentry/components/events/metrics/useMetricsIssueSection'; +import useDrawer from 'sentry/components/globalDrawer'; +import {IconChevron} from 'sentry/icons'; +import {t} from 'sentry/locale'; +import type {Event} from 'sentry/types/event'; +import type {Group} from 'sentry/types/group'; +import type {Project} from 'sentry/types/project'; +import {trackAnalytics} from 'sentry/utils/analytics'; +import useOrganization from 'sentry/utils/useOrganization'; +import {TraceItemAttributeProvider} from 'sentry/views/explore/contexts/traceItemAttributeContext'; +import {MetricsSamplesTable} from 'sentry/views/explore/metrics/metricInfoTabs/metricsSamplesTable'; +import {canUseMetricsUI} from 'sentry/views/explore/metrics/metricsFlags'; +import {TraceItemDataset} from 'sentry/views/explore/types'; +import {SectionKey} from 'sentry/views/issueDetails/streamline/context'; +import {InterimSection} from 'sentry/views/issueDetails/streamline/interimSection'; +import {TraceViewMetricsProviderWrapper} from 'sentry/views/performance/newTraceDetails/traceMetrics'; + +export function MetricsSection({ + event, + project, + group, +}: { + event: Event; + group: Group; + project: Project; +}) { + const traceId = event.contexts?.trace?.trace_id; + + if (!traceId) { + return null; + } + + return ( + + + + ); +} + +function MetricsSectionContent({ + event, + project, + group, + traceId, +}: { + event: Event; + group: Group; + project: Project; + traceId: string; +}) { + const organization = useOrganization(); + const feature = canUseMetricsUI(organization); + const {openDrawer} = useDrawer(); + const viewAllButtonRef = useRef(null); + const {result} = useMetricsIssueSection({traceId}); + const abbreviatedTableData = result.data ? result.data.slice(0, 5) : undefined; + + const onOpenMetricsDrawer = useCallback( + (e: React.MouseEvent) => { + e.stopPropagation(); + trackAnalytics('metrics.issue_details.drawer_opened', { + organization, + }); + openDrawer( + () => ( + + + + + + ), + { + ariaLabel: 'metrics drawer', + drawerKey: 'metrics-issue-drawer', + shouldCloseOnInteractOutside: element => { + const viewAllButton = viewAllButtonRef.current; + return !viewAllButton?.contains(element); + }, + } + ); + }, + [group, event, project, openDrawer, organization, traceId] + ); + + if (!feature) { + return null; + } + + if (!traceId) { + // If there isn't a traceId, we shouldn't show metrics since they are trace specific + return null; + } + + if (!result.data || result.data.length === 0) { + // Don't show the metrics section if there are no metrics + return null; + } + + return ( + + + + {result.data && result.data.length > 5 ? ( +
+ +
+ ) : null} +
+
+ ); +} diff --git a/static/app/components/events/metrics/useMetricsIssueSection.tsx b/static/app/components/events/metrics/useMetricsIssueSection.tsx new file mode 100644 index 00000000000000..303b2c72aaed0b --- /dev/null +++ b/static/app/components/events/metrics/useMetricsIssueSection.tsx @@ -0,0 +1,15 @@ +import {TraceSamplesTableEmbeddedColumns} from 'sentry/views/explore/metrics/constants'; +import {useMetricSamplesTable} from 'sentry/views/explore/metrics/hooks/useMetricSamplesTable'; +import {getMetricTableColumnType} from 'sentry/views/explore/metrics/utils'; + +export function useMetricsIssueSection({traceId}: {traceId: string}) { + const fields = TraceSamplesTableEmbeddedColumns.filter( + c => getMetricTableColumnType(c) !== 'stat' + ); + return useMetricSamplesTable({ + disabled: !traceId, + limit: 10, // Just needs to be >5 to show the view more button + traceMetric: undefined, + fields, + }); +} diff --git a/static/app/utils/analytics/metricsAnalyticsEvent.tsx b/static/app/utils/analytics/metricsAnalyticsEvent.tsx index f96855b6f9651d..2fc1d3245a67f0 100644 --- a/static/app/utils/analytics/metricsAnalyticsEvent.tsx +++ b/static/app/utils/analytics/metricsAnalyticsEvent.tsx @@ -34,6 +34,9 @@ export type MetricsAnalyticsEventParameters = { platform: PlatformKey | 'unknown'; supports_onboarding_checklist: boolean; }; + 'metrics.issue_details.drawer_opened': { + organization: Organization; + }; 'metrics.nav.rendered': { metrics_tab_visible: boolean; organization: Organization; @@ -66,6 +69,7 @@ type MetricsAnalyticsEventKey = keyof MetricsAnalyticsEventParameters; export const metricsAnalyticsEventMap: Record = { 'metrics.explorer.metadata': 'Metric Explorer Pageload Metadata', 'metrics.explorer.panel.metadata': 'Metric Explorer Panel Metadata', + 'metrics.issue_details.drawer_opened': 'Metrics Issue Details Drawer Opened', 'metrics.explorer.setup_button_clicked': 'Metrics Setup Button Clicked', 'metrics.nav.rendered': 'Metrics Nav Rendered', 'metrics.onboarding': 'Metrics Explore Empty State (Onboarding)', diff --git a/static/app/views/explore/exploreStateQueryParamsProvider.tsx b/static/app/views/explore/exploreStateQueryParamsProvider.tsx new file mode 100644 index 00000000000000..dcd887e3bac102 --- /dev/null +++ b/static/app/views/explore/exploreStateQueryParamsProvider.tsx @@ -0,0 +1,95 @@ +import type {ReactNode} from 'react'; +import {useCallback, useMemo, useState} from 'react'; + +import type {Sort} from 'sentry/utils/discover/fields'; +import {useResettableState} from 'sentry/utils/useResettableState'; +import type {AggregateField} from 'sentry/views/explore/queryParams/aggregateField'; +import {QueryParamsContextProvider} from 'sentry/views/explore/queryParams/context'; +import {defaultCursor} from 'sentry/views/explore/queryParams/cursor'; +import {defaultMode} from 'sentry/views/explore/queryParams/mode'; +import {defaultQuery} from 'sentry/views/explore/queryParams/query'; +import { + ReadableQueryParams, + type ReadableQueryParamsOptions, +} from 'sentry/views/explore/queryParams/readableQueryParams'; +import type {WritableQueryParams} from 'sentry/views/explore/queryParams/writableQueryParams'; + +interface ExploreStateQueryParamsProviderProps { + children: ReactNode; + defaultAggregateFields: () => AggregateField[]; + defaultAggregateSortBys: (aggregateFields: AggregateField[]) => Sort[]; + defaultFields: () => string[]; + defaultSortBys: (fields: string[]) => Sort[]; + frozenParams?: Partial; +} + +export function ExploreStateQueryParamsProvider({ + children, + defaultFields, + defaultSortBys, + defaultAggregateFields, + defaultAggregateSortBys, + frozenParams, +}: ExploreStateQueryParamsProviderProps) { + const [mode, _setMode] = useState(defaultMode()); + const [query, setQuery] = useResettableState(defaultQuery); + + const [cursor, _setCursor] = useState(defaultCursor()); + const [fields, _setFields] = useState(defaultFields()); + const [sortBys, _setSortBys] = useState(defaultSortBys(fields)); + + const [aggregateCursor, _setAggregateCursor] = useState(defaultCursor()); + const [aggregateFields, _setAggregateFields] = useState(defaultAggregateFields()); + const [aggregateSortBys, _setAggregateSortBys] = useState( + defaultAggregateSortBys(aggregateFields) + ); + + const _readableQueryParams = useMemo(() => { + return new ReadableQueryParams({ + extrapolate: true, + mode, + query, + + cursor, + fields, + sortBys, + + aggregateCursor, + aggregateFields, + aggregateSortBys, + }); + }, [ + mode, + query, + cursor, + fields, + sortBys, + aggregateCursor, + aggregateFields, + aggregateSortBys, + ]); + + const readableQueryParams = useMemo( + () => + frozenParams ? _readableQueryParams.replace(frozenParams) : _readableQueryParams, + [_readableQueryParams, frozenParams] + ); + + const setWritableQueryParams = useCallback( + (writableQueryParams: WritableQueryParams) => { + setQuery(writableQueryParams.query); + }, + [setQuery] + ); + + return ( + + {children} + + ); +} diff --git a/static/app/views/explore/logs/logsStateQueryParamsProvider.tsx b/static/app/views/explore/logs/logsStateQueryParamsProvider.tsx index 3ad8adb289fa8e..2d39f516015afa 100644 --- a/static/app/views/explore/logs/logsStateQueryParamsProvider.tsx +++ b/static/app/views/explore/logs/logsStateQueryParamsProvider.tsx @@ -1,23 +1,14 @@ import type {ReactNode} from 'react'; -import {useCallback, useMemo, useState} from 'react'; -import {useResettableState} from 'sentry/utils/useResettableState'; import {defaultLogFields} from 'sentry/views/explore/contexts/logs/fields'; import {defaultSortBys} from 'sentry/views/explore/contexts/pageParamsContext/sortBys'; +import {ExploreStateQueryParamsProvider} from 'sentry/views/explore/exploreStateQueryParamsProvider'; import { defaultAggregateSortBys, defaultVisualizes, } from 'sentry/views/explore/logs/logsQueryParams'; -import {QueryParamsContextProvider} from 'sentry/views/explore/queryParams/context'; -import {defaultCursor} from 'sentry/views/explore/queryParams/cursor'; import {defaultGroupBys} from 'sentry/views/explore/queryParams/groupBy'; -import {defaultMode} from 'sentry/views/explore/queryParams/mode'; -import {defaultQuery} from 'sentry/views/explore/queryParams/query'; -import { - ReadableQueryParams, - type ReadableQueryParamsOptions, -} from 'sentry/views/explore/queryParams/readableQueryParams'; -import type {WritableQueryParams} from 'sentry/views/explore/queryParams/writableQueryParams'; +import type {ReadableQueryParamsOptions} from 'sentry/views/explore/queryParams/readableQueryParams'; interface LogsStateQueryParamsProviderProps { children: ReactNode; @@ -28,66 +19,16 @@ export function LogsStateQueryParamsProvider({ children, frozenParams, }: LogsStateQueryParamsProviderProps) { - const [mode, _setMode] = useState(defaultMode()); - const [query, setQuery] = useResettableState(defaultQuery); - - const [cursor, _setCursor] = useState(defaultCursor()); - const [fields, _setFields] = useState(defaultLogFields()); - const [sortBys, _setSortBys] = useState(defaultSortBys(fields)); - - const [aggregateCursor, _setAggregateCursor] = useState(defaultCursor()); - const [aggregateFields, _setAggregateFields] = useState(defaultAggregateFields()); - const [aggregateSortBys, _setAggregateSortBys] = useState( - defaultAggregateSortBys(aggregateFields) - ); - - const _readableQueryParams = useMemo(() => { - return new ReadableQueryParams({ - extrapolate: true, - mode, - query, - - cursor, - fields, - sortBys, - - aggregateCursor, - aggregateFields, - aggregateSortBys, - }); - }, [ - mode, - query, - cursor, - fields, - sortBys, - aggregateCursor, - aggregateFields, - aggregateSortBys, - ]); - - const readableQueryParams = useMemo( - () => - frozenParams ? _readableQueryParams.replace(frozenParams) : _readableQueryParams, - [_readableQueryParams, frozenParams] - ); - - const setWritableQueryParams = useCallback( - (writableQueryParams: WritableQueryParams) => { - setQuery(writableQueryParams.query); - }, - [setQuery] - ); - return ( - {children} - + ); } diff --git a/static/app/views/explore/metrics/metricInfoTabs/metricInfoTabStyles.tsx b/static/app/views/explore/metrics/metricInfoTabs/metricInfoTabStyles.tsx index eed4f4493a07fb..83d518246c76e8 100644 --- a/static/app/views/explore/metrics/metricInfoTabs/metricInfoTabStyles.tsx +++ b/static/app/views/explore/metrics/metricInfoTabs/metricInfoTabStyles.tsx @@ -100,10 +100,10 @@ export const StyledSimpleTableHeader = styled(SimpleTable.Header)` `; export const StickyTableRow = styled(SimpleTable.Row)<{ - isSticky?: boolean; + sticky?: boolean; }>` ${p => - p.isSticky && + p.sticky && ` top: 0px; z-index: 1; diff --git a/static/app/views/explore/metrics/metricInfoTabs/metricsSamplesTable.tsx b/static/app/views/explore/metrics/metricInfoTabs/metricsSamplesTable.tsx index 27b5cbc2c8e719..3d6fdb5cd12d2b 100644 --- a/static/app/views/explore/metrics/metricInfoTabs/metricsSamplesTable.tsx +++ b/static/app/views/explore/metrics/metricInfoTabs/metricsSamplesTable.tsx @@ -19,11 +19,15 @@ import { import {MetricsSamplesTableHeader} from 'sentry/views/explore/metrics/metricInfoTabs/metricsSamplesTableHeader'; import {SampleTableRow} from 'sentry/views/explore/metrics/metricInfoTabs/metricsSamplesTableRow'; import type {TraceMetric} from 'sentry/views/explore/metrics/metricQuery'; -import {TraceMetricKnownFieldKey} from 'sentry/views/explore/metrics/types'; +import { + TraceMetricKnownFieldKey, + type TraceMetricEventsResponseItem, +} from 'sentry/views/explore/metrics/types'; import {getMetricTableColumnType} from 'sentry/views/explore/metrics/utils'; import {GenericWidgetEmptyStateWarning} from 'sentry/views/performance/landing/widgets/components/selectableList'; const RESULT_LIMIT = 50; +const EMBEDDED_RESULT_LIMIT = 100; const TWO_MINUTE_DELAY = 120; const MAX_TELEMETRY_WIDTH = 40; @@ -32,6 +36,7 @@ export const SAMPLES_PANEL_MIN_WIDTH = 350; interface MetricsSamplesTableProps { embedded?: boolean; isMetricOptionsEmpty?: boolean; + overrideTableData?: TraceMetricEventsResponseItem[]; traceMetric?: TraceMetric; } @@ -39,6 +44,7 @@ export function MetricsSamplesTable({ traceMetric, embedded = false, isMetricOptionsEmpty, + overrideTableData, }: MetricsSamplesTableProps) { const columns = embedded ? TraceSamplesTableEmbeddedColumns : TraceSamplesTableColumns; const fields = columns.filter(c => getMetricTableColumnType(c) !== 'stat'); @@ -49,8 +55,10 @@ export function MetricsSamplesTable({ error, isFetching, } = useMetricSamplesTable({ - disabled: embedded ? false : !traceMetric?.name || isMetricOptionsEmpty, - limit: RESULT_LIMIT, + disabled: embedded + ? false + : !traceMetric?.name || isMetricOptionsEmpty || !!overrideTableData, + limit: embedded ? EMBEDDED_RESULT_LIMIT : RESULT_LIMIT, traceMetric, fields, ingestionDelaySeconds: TWO_MINUTE_DELAY, @@ -74,11 +82,11 @@ export function MetricsSamplesTable({ {error ? ( - + ) : data?.length ? ( - data.map((row, i) => ( + (overrideTableData ?? data).map((row, i) => ( )) ) : isFetching ? ( - - + + ) : ( - + )} @@ -107,6 +115,7 @@ const SimpleTableWithHiddenColumns = styled(StyledSimpleTable)<{ numColumns: number; }>` grid-template-columns: repeat(${p => p.numColumns}, min-content) 1fr; + grid-column: 1 / -1; ${p => !p.embedded && diff --git a/static/app/views/explore/metrics/metricInfoTabs/metricsSamplesTableRow.tsx b/static/app/views/explore/metrics/metricInfoTabs/metricsSamplesTableRow.tsx index 5acef3d7151ed9..905b6f68a7d798 100644 --- a/static/app/views/explore/metrics/metricInfoTabs/metricsSamplesTableRow.tsx +++ b/static/app/views/explore/metrics/metricInfoTabs/metricsSamplesTableRow.tsx @@ -302,7 +302,7 @@ export function SampleTableRow({ return ( - + {columns.map((field, i) => { const isValueColumn = field === TraceMetricKnownFieldKey.METRIC_VALUE; const cellContent = renderFieldCell(field); diff --git a/static/app/views/explore/metrics/metricQuery.tsx b/static/app/views/explore/metrics/metricQuery.tsx index 288981ad4a0ee5..3dcb7a3606f119 100644 --- a/static/app/views/explore/metrics/metricQuery.tsx +++ b/static/app/views/explore/metrics/metricQuery.tsx @@ -79,7 +79,7 @@ export function decodeMetricsQueryParams(value: string): BaseMetricQuery | null cursor: '', fields: defaultFields(), - sortBys: defaultSortBys(), + sortBys: defaultSortBys(defaultFields()), aggregateCursor: '', aggregateFields, @@ -115,11 +115,11 @@ export function defaultMetricQuery(): BaseMetricQuery { cursor: '', fields: defaultFields(), - sortBys: defaultSortBys(), + sortBys: defaultSortBys(defaultFields()), aggregateCursor: '', aggregateFields: defaultAggregateFields(), - aggregateSortBys: defaultAggregateSortBys(), + aggregateSortBys: defaultAggregateSortBys(defaultAggregateFields()), }), }; } @@ -128,19 +128,38 @@ export function defaultQuery(): string { return ''; } -function defaultFields(): string[] { +export function defaultFields(): string[] { return ['id', 'timestamp']; } -function defaultSortBys(): Sort[] { - return [{field: 'timestamp', kind: 'desc'}]; +export function defaultSortBys(fields: string[]): Sort[] { + if (fields.includes('timestamp')) { + return [ + { + field: 'timestamp', + kind: 'desc' as const, + }, + ]; + } + + if (fields.length) { + return [ + { + field: fields[0]!, + kind: 'desc' as const, + }, + ]; + } + + return []; } -function defaultAggregateFields(): AggregateField[] { +export function defaultAggregateFields(): AggregateField[] { return [new VisualizeFunction('per_second(value)')]; } -function defaultAggregateSortBys(): Sort[] { +export function defaultAggregateSortBys(_: AggregateField[]): Sort[] { + // TODO: Handle aggregate fields for compound sort-by. return [{field: 'per_second(value)', kind: 'desc'}]; } diff --git a/static/app/views/explore/metrics/metricsQueryParams.tsx b/static/app/views/explore/metrics/metricsQueryParams.tsx index c4ca01e95f7683..5635ebcd317a25 100644 --- a/static/app/views/explore/metrics/metricsQueryParams.tsx +++ b/static/app/views/explore/metrics/metricsQueryParams.tsx @@ -8,6 +8,7 @@ import { MetricsFrozenContextProvider, type MetricsFrozenForTracesProviderProps, } from 'sentry/views/explore/metrics/metricsFrozenContext'; +import {MetricsStateQueryParamsProvider} from 'sentry/views/explore/metrics/metricsStateQueryParamsProvider'; import type {AggregateField} from 'sentry/views/explore/queryParams/aggregateField'; import { QueryParamsContextProvider, @@ -42,6 +43,7 @@ interface MetricsQueryParamsProviderProps { setTraceMetric: (traceMetric: TraceMetric) => void; traceMetric: TraceMetric; freeze?: MetricsFrozenForTracesProviderProps; + isStateBased?: boolean; } export function MetricsQueryParamsProvider({ @@ -52,6 +54,7 @@ export function MetricsQueryParamsProvider({ removeMetric, traceMetric, freeze, + isStateBased, }: MetricsQueryParamsProviderProps) { const setWritableQueryParams = useCallback( (writableQueryParams: WritableQueryParams) => { @@ -76,20 +79,24 @@ export function MetricsQueryParamsProvider({ [setTraceMetric, removeMetric, traceMetric] ); + const QueryContextProvider = isStateBased + ? MetricsStateQueryParamsProvider + : QueryParamsContextProvider; + return ( - {children} - + ); diff --git a/static/app/views/explore/metrics/metricsStateQueryParamsProvider.tsx b/static/app/views/explore/metrics/metricsStateQueryParamsProvider.tsx new file mode 100644 index 00000000000000..f4db154864fcbc --- /dev/null +++ b/static/app/views/explore/metrics/metricsStateQueryParamsProvider.tsx @@ -0,0 +1,32 @@ +import type {ReactNode} from 'react'; + +import {ExploreStateQueryParamsProvider} from 'sentry/views/explore/exploreStateQueryParamsProvider'; +import { + defaultAggregateFields, + defaultAggregateSortBys, + defaultFields, + defaultSortBys, +} from 'sentry/views/explore/metrics/metricQuery'; +import type {ReadableQueryParamsOptions} from 'sentry/views/explore/queryParams/readableQueryParams'; + +interface MetricsStateQueryParamsProviderProps { + children: ReactNode; + frozenParams?: Partial; +} + +export function MetricsStateQueryParamsProvider({ + children, + frozenParams, +}: MetricsStateQueryParamsProviderProps) { + return ( + + {children} + + ); +} diff --git a/static/app/views/explore/metrics/multiMetricsQueryParams.tsx b/static/app/views/explore/metrics/multiMetricsQueryParams.tsx index 960ff5840276f2..5f28bbb41e2e39 100644 --- a/static/app/views/explore/metrics/multiMetricsQueryParams.tsx +++ b/static/app/views/explore/metrics/multiMetricsQueryParams.tsx @@ -197,24 +197,3 @@ 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/issueDetails/groupEventDetails/groupEventDetailsContent.tsx b/static/app/views/issueDetails/groupEventDetails/groupEventDetailsContent.tsx index b850f483358280..e11abc5bf01a00 100644 --- a/static/app/views/issueDetails/groupEventDetails/groupEventDetailsContent.tsx +++ b/static/app/views/issueDetails/groupEventDetails/groupEventDetailsContent.tsx @@ -50,6 +50,7 @@ import {StackTrace} from 'sentry/components/events/interfaces/stackTrace'; import {Template} from 'sentry/components/events/interfaces/template'; import {Threads} from 'sentry/components/events/interfaces/threads'; import {UptimeDataSection} from 'sentry/components/events/interfaces/uptime/uptimeDataSection'; +import {MetricsSection} from 'sentry/components/events/metrics/metricsSection'; import {OurlogsSection} from 'sentry/components/events/ourlogs/ourlogsSection'; import {EventPackageData} from 'sentry/components/events/packageData'; import {EventRRWebIntegration} from 'sentry/components/events/rrwebIntegration'; @@ -373,6 +374,11 @@ export function EventDetailsContent({ + + + + + {hasStreamlinedUI && event.contexts.trace?.trace_id && organization.features.includes('performance-view') && ( diff --git a/static/app/views/issueDetails/streamline/context.tsx b/static/app/views/issueDetails/streamline/context.tsx index 3e2e88b40125dd..be5b7d7bee3689 100644 --- a/static/app/views/issueDetails/streamline/context.tsx +++ b/static/app/views/issueDetails/streamline/context.tsx @@ -53,6 +53,7 @@ export const enum SectionKey { BREADCRUMBS = 'breadcrumbs', LOGS = 'logs', + METRICS = 'metrics', SPAN_ATTRIBUTES = 'span-attributes', /** * Also called images loaded diff --git a/static/app/views/issueDetails/streamline/issueDetailsJumpTo.tsx b/static/app/views/issueDetails/streamline/issueDetailsJumpTo.tsx index 9ab5aed9e66ec6..f0c386968cf8e5 100644 --- a/static/app/views/issueDetails/streamline/issueDetailsJumpTo.tsx +++ b/static/app/views/issueDetails/streamline/issueDetailsJumpTo.tsx @@ -23,6 +23,7 @@ const sectionLabels: Partial> = { [SectionKey.BREADCRUMBS]: t('Breadcrumbs'), [SectionKey.TRACE]: t('Trace'), [SectionKey.LOGS]: t('Logs'), + [SectionKey.METRICS]: t('Metrics'), [SectionKey.TAGS]: t('Tags'), [SectionKey.CONTEXTS]: t('Context'), [SectionKey.USER_FEEDBACK]: t('User Feedback'), @@ -42,6 +43,9 @@ export function IssueDetailsJumpTo() { if (!features.includes('ourlogs-enabled')) { excluded.push(SectionKey.LOGS); } + if (!features.includes('tracemetrics-enabled')) { + excluded.push(SectionKey.METRICS); + } return excluded; }, [organization.features]); diff --git a/static/app/views/performance/newTraceDetails/index.tsx b/static/app/views/performance/newTraceDetails/index.tsx index bed554dd2dbcb7..df4ee6e86c1a3b 100644 --- a/static/app/views/performance/newTraceDetails/index.tsx +++ b/static/app/views/performance/newTraceDetails/index.tsx @@ -13,7 +13,7 @@ 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, + TraceViewMetricsProviderWrapper, TraceViewMetricsSection, } from 'sentry/views/performance/newTraceDetails/traceMetrics'; import { @@ -74,14 +74,14 @@ export default function TraceView() { return ( - + - + ); } diff --git a/static/app/views/performance/newTraceDetails/traceMetrics.tsx b/static/app/views/performance/newTraceDetails/traceMetrics.tsx index 4b70b59e01d414..6f32deb5b18075 100644 --- a/static/app/views/performance/newTraceDetails/traceMetrics.tsx +++ b/static/app/views/performance/newTraceDetails/traceMetrics.tsx @@ -9,14 +9,12 @@ 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 {Mode} from 'sentry/views/explore/queryParams/mode'; +import {ReadableQueryParams} from 'sentry/views/explore/queryParams/readableQueryParams'; import {useTraceQueryParams} from 'sentry/views/performance/newTraceDetails/useTraceQueryParams'; type UseTraceViewMetricsDataProps = { @@ -24,7 +22,7 @@ type UseTraceViewMetricsDataProps = { traceSlug: string; }; -export function TraceViewMetricsDataProvider({ +export function TraceViewMetricsProviderWrapper({ children, traceSlug, }: UseTraceViewMetricsDataProps) { @@ -60,38 +58,30 @@ export function TraceViewMetricsDataProvider({ queryParams.statsPeriod, ]); - return ( - - - {children} - - - ); -} - -function TraceViewMetricsDataProviderInner({ - children, - traceSlug, - tracePeriod, -}: { - children: React.ReactNode; - traceSlug: string; - tracePeriod?: TracePeriod; -}) { - const {queryParams, setQueryParams, metric, setTraceMetric, removeMetric} = - useSingleMetricQueryParams(); - return ( {}} + traceMetric={{name: '', type: ''}} + setTraceMetric={() => {}} + removeMetric={() => {}} freeze={{ traceIds: [traceSlug], tracePeriod, }} + isStateBased > {children}