diff --git a/static/app/views/explore/hooks/useTraceItemDetails.spec.tsx b/static/app/views/explore/hooks/useTraceItemDetails.spec.tsx index c7166d822bc9..d37c46cdf833 100644 --- a/static/app/views/explore/hooks/useTraceItemDetails.spec.tsx +++ b/static/app/views/explore/hooks/useTraceItemDetails.spec.tsx @@ -256,6 +256,48 @@ describe('useTraceItemDetails', () => { await waitFor(() => expect(traceItemDetailsMock).toHaveBeenCalledTimes(1)); }); + it('reports pending only while the prefetched details request is in flight', async () => { + initializePageFilters({ + period: '14d', + start: null, + end: null, + utc: false, + }); + MockApiClient.addMockResponse({ + method: 'GET', + url: `/projects/${organization.slug}/${project.slug}/trace-items/item-id/`, + asyncDelay: 100, + body: { + itemId: 'item-id', + links: null, + meta: {}, + timestamp: '2025-04-03T15:50:10.000Z', + attributes: [], + }, + }); + + const {result} = renderHookWithProviders(usePrefetchTraceItemDetailsOnHover, { + organization, + initialProps: { + projectId: project.id, + traceItemId: 'item-id', + traceId: '1234567890abcdef1234567890abcdef', + traceItemType: TraceItemDataset.LOGS, + referrer: 'api.explore.log-item-details', + timestamp: 123, + sharedHoverTimeoutRef: {current: null}, + timeout: 0, + }, + }); + + await waitFor(() => expect(ProjectsStore.getState().projects).toHaveLength(1)); + expect(result.current.isTraceItemDetailsPending).toBe(false); + + act(() => result.current.prefetch()); + await waitFor(() => expect(result.current.isTraceItemDetailsPending).toBe(true)); + await waitFor(() => expect(result.current.isTraceItemDetailsPending).toBe(false)); + }); + it('does not fetch details when the hovered element unmounts before the hover timeout elapses', async () => { jest.useFakeTimers(); initializePageFilters({ diff --git a/static/app/views/explore/hooks/useTraceItemDetails.tsx b/static/app/views/explore/hooks/useTraceItemDetails.tsx index 6ac14bf38315..02f6be6dd85c 100644 --- a/static/app/views/explore/hooks/useTraceItemDetails.tsx +++ b/static/app/views/explore/hooks/useTraceItemDetails.tsx @@ -1,7 +1,7 @@ import {useCallback, useEffect, useRef, useState} from 'react'; import {useHover} from '@react-aria/interactions'; import {captureException} from '@sentry/react'; -import {skipToken, useQuery, useQueryClient} from '@tanstack/react-query'; +import {skipToken, useQuery} from '@tanstack/react-query'; import {normalizeDateTimeParams} from 'sentry/components/pageFilters/parse'; import {usePageFilters} from 'sentry/components/pageFilters/usePageFilters'; @@ -207,50 +207,32 @@ function useTraceItemDetailsPrefetch({ const organization = useOrganization(); const {selection} = usePageFilters(); const project = useProjectFromId({project_id: projectId}); - const projectRef = useRef(project); - projectRef.current = project; - const queryClient = useQueryClient(); - const [traceItemMeta, setTraceItemMeta] = useState(); - const [traceItemAttributes, setTraceItemAttributes] = useState< - TraceItemResponseAttribute[] | undefined - >(); + const [shouldFetch, setShouldFetch] = useState(false); - const prefetch = useCallback(() => { - const currentProject = projectRef.current; - if (!currentProject?.slug) { - return; - } - const timeQueryParams = defined(timestamp) - ? {timestamp: normalizeTimestampToSeconds(timestamp)} - : normalizeDateTimeParams(selection.datetime); - const options = traceItemDetailsApiOptions({ + const {data, isFetching} = useQuery({ + ...traceItemDetailsApiOptions({ organizationSlug: organization.slug, - projectSlug: currentProject.slug, + projectSlug: project?.slug ?? '', traceItemId, traceItemType, referrer, traceId, - ...timeQueryParams, - }); - queryClient.fetchQuery(options).then( - response => { - setTraceItemMeta(response?.json?.meta); - setTraceItemAttributes(response?.json?.attributes); - }, - () => {} - ); - }, [ - organization.slug, - queryClient, - referrer, - selection.datetime, - timestamp, - traceId, - traceItemId, - traceItemType, - ]); + ...(timestamp + ? {timestamp: normalizeTimestampToSeconds(timestamp)} + : normalizeDateTimeParams(selection.datetime)), + }), + enabled: shouldFetch && !!project?.slug, + }); + + const prefetch = useCallback(() => setShouldFetch(true), []); - return {prefetch, project, traceItemMeta, traceItemAttributes}; + return { + prefetch, + project, + traceItemMeta: data?.meta, + traceItemAttributes: data?.attributes, + isPending: isFetching, + }; } export function usePrefetchTraceItemDetailsOnHover({ @@ -278,7 +260,7 @@ export function usePrefetchTraceItemDetailsOnHover({ */ hoverPrefetchDisabled?: boolean; }) { - const {prefetch, project, traceItemMeta, traceItemAttributes} = + const {prefetch, project, traceItemMeta, traceItemAttributes, isPending} = useTraceItemDetailsPrefetch({ traceItemId, projectId, @@ -328,6 +310,7 @@ export function usePrefetchTraceItemDetailsOnHover({ isProjectReady: Boolean(project?.slug), traceItemMeta, traceItemAttributes, + isTraceItemDetailsPending: isPending, }; } diff --git a/static/app/views/explore/logs/constants.tsx b/static/app/views/explore/logs/constants.tsx index 90399c1d79cb..9013af255d7e 100644 --- a/static/app/views/explore/logs/constants.tsx +++ b/static/app/views/explore/logs/constants.tsx @@ -72,7 +72,7 @@ export const HiddenLogDetailFields: OurLogFieldKey[] = [ // deprecated/otel fields that clutter the UI 'sentry.timestamp_nanos', - 'sentry.observed_timestamp_nanos', + OurLogKnownFieldKey.OBSERVED_TIMESTAMP_NANOS, 'tags[sentry.trace_flags,number]', ]; diff --git a/static/app/views/explore/logs/fieldRenderers.tsx b/static/app/views/explore/logs/fieldRenderers.tsx index 3cb0ccbaa6bd..c45d78e39583 100644 --- a/static/app/views/explore/logs/fieldRenderers.tsx +++ b/static/app/views/explore/logs/fieldRenderers.tsx @@ -83,6 +83,7 @@ export interface RendererExtra extends RenderFunctionBaggage { logColors: ReturnType; align?: 'left' | 'center' | 'right'; canAppendTemplateToBody?: boolean; + isTraceItemDetailsPending?: boolean; logEnd?: string; logStart?: string; meta?: EventsMetaType; @@ -166,6 +167,7 @@ function TimestampRenderer(props: LogFieldRendererProps) { @@ -213,6 +215,7 @@ function RelativeTimestampRenderer(props: LogFieldRendererProps) { diff --git a/static/app/views/explore/logs/logsTimeTooltip.spec.tsx b/static/app/views/explore/logs/logsTimeTooltip.spec.tsx index 8959c721025f..6ba5a656fac0 100644 --- a/static/app/views/explore/logs/logsTimeTooltip.spec.tsx +++ b/static/app/views/explore/logs/logsTimeTooltip.spec.tsx @@ -91,10 +91,53 @@ describe('TimestampTooltipBody', () => { ); - expect(screen.getByText('Occurred')).toBeInTheDocument(); + expect(screen.queryByText('Received')).not.toBeInTheDocument(); expect(screen.queryAllByRole('time')).toHaveLength(2); }); + it('renders received time when the observed timestamp uses its internal name', () => { + const user = UserFixture(); + user.options.timezone = 'America/New_York'; + ConfigStore.set('user', user); + + const attributes = { + [OurLogKnownFieldKey.TIMESTAMP_PRECISE]: '1705333530456789012', + [OurLogKnownFieldKey.OBSERVED_TIMESTAMP_NANOS]: '1705333540456789012', + }; + + render( + + + + ); + + expect(screen.getByText('Received')).toBeInTheDocument(); + expect(screen.queryAllByRole('time')).toHaveLength(3); + }); + + it('renders a loading received time when the trace item details are still pending', () => { + const user = UserFixture(); + user.options.timezone = 'America/New_York'; + ConfigStore.set('user', user); + + const attributes = { + [OurLogKnownFieldKey.TIMESTAMP_PRECISE]: '1705333530456789012', + }; + + render( + + + + ); + + expect(screen.getByText('Received')).toBeInTheDocument(); + expect(screen.getByTestId('loading-indicator')).toBeInTheDocument(); + }); + it('renders in 24h format when user preference is set', () => { const user = UserFixture(); user.options.timezone = 'America/New_York'; diff --git a/static/app/views/explore/logs/logsTimeTooltip.tsx b/static/app/views/explore/logs/logsTimeTooltip.tsx index 6d17ef8dc107..b3114af7b713 100644 --- a/static/app/views/explore/logs/logsTimeTooltip.tsx +++ b/static/app/views/explore/logs/logsTimeTooltip.tsx @@ -18,6 +18,7 @@ type Props = { attributes: Record; children: React.ReactNode; timestamp: string | number; + isTraceItemDetailsPending?: boolean; relativeTimeToReplay?: number; shouldRender?: boolean; }; @@ -25,10 +26,12 @@ type Props = { function TimestampTooltipBody({ timestamp, attributes, + isTraceItemDetailsPending, relativeTime, }: { attributes: Record; timestamp: string | number; + isTraceItemDetailsPending?: boolean; relativeTime?: number; }) { const currentTimezone = useTimezone(); @@ -39,7 +42,9 @@ function TimestampTooltipBody({ : null; const timestampToUse = preciseTimestampMs ? new Date(preciseTimestampMs) : timestamp; - const observedTimeNanos = attributes[OurLogKnownFieldKey.OBSERVED_TIMESTAMP_PRECISE]; + const observedTimeNanos = + attributes[OurLogKnownFieldKey.OBSERVED_TIMESTAMP_PRECISE] ?? + attributes[OurLogKnownFieldKey.OBSERVED_TIMESTAMP_NANOS]; const observedTime = observedTimeNanos ? new Date(Math.floor(Number(observedTimeNanos) / 1_000_000)) : null; @@ -94,21 +99,23 @@ function TimestampTooltipBody({ )} - - -
{t('Received')}
-
- {observedTime ? ( - - - - - - ) : ( - - )} -
-
+ {(observedTime || isTraceItemDetailsPending) && ( + + +
{t('Received')}
+
+ {observedTime ? ( + + + + + + ) : ( + + )} +
+
+ )} ); } @@ -119,6 +126,7 @@ export function LogsTimestampTooltip({ timestamp, attributes, children, + isTraceItemDetailsPending, shouldRender = true, relativeTimeToReplay: relativeTime, }: Props) { @@ -137,6 +145,7 @@ export function LogsTimestampTooltip({ diff --git a/static/app/views/explore/logs/tables/logsTableRow.spec.tsx b/static/app/views/explore/logs/tables/logsTableRow.spec.tsx index ba86c76065c1..3456897c1848 100644 --- a/static/app/views/explore/logs/tables/logsTableRow.spec.tsx +++ b/static/app/views/explore/logs/tables/logsTableRow.spec.tsx @@ -411,6 +411,64 @@ describe('logsTableRow', () => { ); }); + it('renders a received time in the details timestamp tooltip when the observed timestamp uses its internal name', async () => { + const { + [OurLogKnownFieldKey.OBSERVED_TIMESTAMP_PRECISE]: observedTimestamp, + ...rowDataWithInternalObservedTimestamp + } = LogFixture({ + [OurLogKnownFieldKey.ID]: '4', + [OurLogKnownFieldKey.PROJECT_ID]: project.id, + [OurLogKnownFieldKey.ORGANIZATION_ID]: Number(organization.id), + }); + + MockApiClient.addMockResponse({ + url: `/projects/${organization.slug}/${project.slug}/trace-items/4/`, + method: 'GET', + body: { + itemId: '4', + links: null, + meta: {}, + timestamp: rowDataWithInternalObservedTimestamp[OurLogKnownFieldKey.TIMESTAMP], + attributes: [ + ...Object.entries(rowDataWithInternalObservedTimestamp).map( + ([k, v]) => + ({ + name: k, + value: v, + type: typeof v === 'string' ? 'str' : 'float', + }) as TraceItemResponseAttribute + ), + { + name: OurLogKnownFieldKey.OBSERVED_TIMESTAMP_NANOS, + value: String(observedTimestamp), + type: 'str', + }, + ], + }, + }); + + render( + , + {organization, initialRouterConfig, additionalWrapper: ProviderWrapper} + ); + + await userEvent.click(await screen.findByTestId('log-table-row')); + + const attributesTree = await screen.findByTestId('fields-tree'); + const [treeTimestamp] = within(attributesTree).getAllByText( + 'Apr 10, 2025 7:21:10.049 PM' + ); + await userEvent.hover(treeTimestamp!); + + expect(await screen.findByText('Received')).toBeInTheDocument(); + expect(screen.getByText('Apr 10, 2025 7:21:10 PM UTC')).toBeInTheDocument(); + }); + it('adds a similar spans action to the log message dropdown', async () => { const rowDataWithQuotedMessage = { ...rowData, diff --git a/static/app/views/explore/logs/tables/logsTableRow.tsx b/static/app/views/explore/logs/tables/logsTableRow.tsx index 9692753c9f71..e448e3914e98 100644 --- a/static/app/views/explore/logs/tables/logsTableRow.tsx +++ b/static/app/views/explore/logs/tables/logsTableRow.tsx @@ -357,17 +357,23 @@ export const LogRowContent = memo(function LogRowContentImpl({ const logTimestampSeconds = isRegularLogResponseItem(dataRow) ? getLogRowTimestampMillis(dataRow) / 1000 : null; - const {hoverProps, prefetch, isProjectReady, traceItemMeta, traceItemAttributes} = - usePrefetchTraceItemDetailsOnHover({ - traceItemId: rowId, - projectId: String(dataRow[OurLogKnownFieldKey.PROJECT_ID]), - traceId: String(dataRow[OurLogKnownFieldKey.TRACE_ID]), - traceItemType: TraceItemDataset.LOGS, - referrer: 'api.explore.log-item-details', - timestamp: logTimestampSeconds, - sharedHoverTimeoutRef, - timeout: prefetchTimeout, - }); + const { + hoverProps, + prefetch, + isProjectReady, + isTraceItemDetailsPending, + traceItemMeta, + traceItemAttributes, + } = usePrefetchTraceItemDetailsOnHover({ + traceItemId: rowId, + projectId: String(dataRow[OurLogKnownFieldKey.PROJECT_ID]), + traceId: String(dataRow[OurLogKnownFieldKey.TRACE_ID]), + traceItemType: TraceItemDataset.LOGS, + referrer: 'api.explore.log-item-details', + timestamp: logTimestampSeconds, + sharedHoverTimeoutRef, + timeout: prefetchTimeout, + }); usePrefetchTraceItemDetailsOnMount({ prefetch, enabled: isHighlighted, @@ -376,13 +382,14 @@ export const LogRowContent = memo(function LogRowContentImpl({ const [caseInsensitivity] = useCaseInsensitivity(); const observedTimestamp = traceItemAttributes?.find( - a => a.name === 'sentry.observed_timestamp_nanos' + a => a.name === OurLogKnownFieldKey.OBSERVED_TIMESTAMP_NANOS ); const rendererExtra: RendererExtra = { highlightTerms, caseSensitiveHighlighting: !caseInsensitivity, datetime: selection.datetime, + isTraceItemDetailsPending, logColors, useFullSeverityText: false, location, diff --git a/static/app/views/explore/logs/types.tsx b/static/app/views/explore/logs/types.tsx index 0088f2fe0dd3..f929e0723a46 100644 --- a/static/app/views/explore/logs/types.tsx +++ b/static/app/views/explore/logs/types.tsx @@ -55,6 +55,9 @@ export enum OurLogKnownFieldKey { // From the EAP dataset directly not using a column alias, should be hidden. ITEM_TYPE = 'sentry.item_type', + // Trace item details fall back to this when OBSERVED_TIMESTAMP_PRECISE can't be aliased. + OBSERVED_TIMESTAMP_NANOS = 'sentry.observed_timestamp_nanos', + // Deprecated fields TIMESTAMP_NANOS = 'sentry.timestamp_nanos',