From 8658918a5f759598a5ebc954ae3ee9e0e9d81984 Mon Sep 17 00:00:00 2001 From: Kev Date: Thu, 24 Jul 2025 15:22:51 -0400 Subject: [PATCH 1/4] feat(logs): Add pause functionality to auto-refresh - Add pausedAt timestamp tracking in context using ref - Update useSetLogsAutoRefresh to handle 'pause' state - Change toggle and row expansion to use 'pause' instead of 'idle' - Make useVirtualStreaming jump to pausedAt timestamp when re-enabling - Fixes analytics firing multiple times because of .length attribute on the useEffect - fixes prefetching firing too much if you hover rows and autorefresh is on. --- .../contexts/logs/logsAutoRefreshContext.tsx | 66 +++++++++-- .../app/views/explore/hooks/useAnalytics.tsx | 6 +- .../explore/hooks/useTraceItemDetails.tsx | 9 +- static/app/views/explore/logs/constants.tsx | 2 + .../app/views/explore/logs/content.spec.tsx | 108 ++++++++++++++++++ .../explore/logs/logsAutoRefresh.spec.tsx | 13 ++- .../views/explore/logs/logsAutoRefresh.tsx | 2 +- .../logs/tables/logsInfiniteTable.spec.tsx | 2 +- .../explore/logs/tables/logsInfiniteTable.tsx | 12 +- .../explore/logs/tables/logsTableRow.spec.tsx | 6 +- .../explore/logs/tables/logsTableRow.tsx | 12 +- .../app/views/explore/logs/useLogsQuery.tsx | 3 + .../explore/logs/useVirtualStreaming.tsx | 9 +- 13 files changed, 218 insertions(+), 32 deletions(-) diff --git a/static/app/views/explore/contexts/logs/logsAutoRefreshContext.tsx b/static/app/views/explore/contexts/logs/logsAutoRefreshContext.tsx index ff36e242c71cb6..9776e279857147 100644 --- a/static/app/views/explore/contexts/logs/logsAutoRefreshContext.tsx +++ b/static/app/views/explore/contexts/logs/logsAutoRefreshContext.tsx @@ -1,4 +1,4 @@ -import {useCallback} from 'react'; +import {useCallback, useRef, useState} from 'react'; import type {Location} from 'history'; import {createDefinedContext} from 'sentry/utils/performance/contexts/utils'; @@ -11,6 +11,7 @@ import {useLogsQueryKeyWithInfinite} from 'sentry/views/explore/logs/useLogsQuer export const LOGS_AUTO_REFRESH_KEY = 'live'; export const LOGS_REFRESH_INTERVAL_KEY = 'refreshEvery'; const LOGS_REFRESH_INTERVAL_DEFAULT = 5000; +export const MAX_AUTO_REFRESH_PAUSED_TIME_MS = 60 * 1000; // 60 seconds export const ABSOLUTE_MAX_AUTO_REFRESH_TIME_MS = 10 * 60 * 1000; // 10 minutes export const CONSECUTIVE_PAGES_WITH_MORE_DATA = 5; @@ -21,12 +22,15 @@ export type AutoRefreshState = | 'timeout' // Hit 10 minute limit | 'rate_limit' // Too much data during refresh | 'error' // Fetch error + | 'paused' // Paused for MAX_AUTO_REFRESH_PAUSED_TIME_MS otherwise treated as idle | 'idle'; // Default (inactive ) state. Should never appear in query params. interface LogsAutoRefreshContextValue { autoRefresh: AutoRefreshState; isTableFrozen: boolean | undefined; + pausedAt: number | undefined; refreshInterval: number; + setPausedAt: (timestamp: number | undefined) => void; } const [_LogsAutoRefreshProvider, useLogsAutoRefresh, LogsAutoRefreshContext] = @@ -48,14 +52,24 @@ export function LogsAutoRefreshProvider({ _testContext, }: LogsAutoRefreshProviderProps) { const location = useLocation(); + const [pausedAt, setPausedAt] = useState(undefined); + const hasInitialized = useRef(false); - const autoRefreshRaw = decodeScalar(location.query[LOGS_AUTO_REFRESH_KEY]); - const autoRefresh: AutoRefreshState = ( - autoRefreshRaw && - ['enabled', 'timeout', 'rate_limit', 'error'].includes(autoRefreshRaw) - ? autoRefreshRaw - : 'idle' - ) as AutoRefreshState; + const allowedStates: AutoRefreshState[] = ['enabled', 'timeout', 'rate_limit', 'error']; + if (hasInitialized.current) { + // Paused is not allowed via linking since it requires internal state (pausedAt) to work. + allowedStates.push('paused'); + } + + const rawState = decodeScalar(location.query[LOGS_AUTO_REFRESH_KEY]); + const autoRefresh: AutoRefreshState = + rawState && allowedStates.includes(rawState as AutoRefreshState) + ? (rawState as AutoRefreshState) + : 'idle'; + + if (autoRefresh !== 'idle') { + hasInitialized.current = true; + } const refreshInterval = decodeInteger( location.query[LOGS_REFRESH_INTERVAL_KEY], @@ -68,6 +82,8 @@ export function LogsAutoRefreshProvider({ autoRefresh, refreshInterval, isTableFrozen, + pausedAt, + setPausedAt, ..._testContext, }} > @@ -86,6 +102,27 @@ export function useLogsAutoRefreshEnabled() { return isTableFrozen ? false : autoRefresh === 'enabled'; } +export function useAutorefreshWithinPauseWindow() { + const {autoRefresh, pausedAt} = useLogsAutoRefresh(); + return withinPauseWindow(autoRefresh, pausedAt); +} + +export function useAutorefreshEnabledOrWithinPauseWindow() { + const {autoRefresh, pausedAt} = useLogsAutoRefresh(); + return ( + autoRefresh === 'enabled' || + (autoRefresh === 'paused' && withinPauseWindow(autoRefresh, pausedAt)) + ); +} + +function withinPauseWindow(autoRefresh: AutoRefreshState, pausedAt: number | undefined) { + return ( + (autoRefresh === 'paused' || autoRefresh === 'enabled') && + pausedAt && + MAX_AUTO_REFRESH_PAUSED_TIME_MS - (Date.now() - pausedAt) > 0 + ); +} + export function useSetLogsAutoRefresh() { const location = useLocation(); const navigate = useNavigate(); @@ -94,22 +131,31 @@ export function useSetLogsAutoRefresh() { autoRefresh: true, }); const queryClient = useQueryClient(); + const {setPausedAt, pausedAt: currentPausedAt} = useLogsAutoRefresh(); return useCallback( (autoRefresh: AutoRefreshState) => { - if (autoRefresh === 'enabled') { + if (autoRefresh === 'enabled' && !withinPauseWindow(autoRefresh, currentPausedAt)) { queryClient.removeQueries({queryKey}); } + const newPausedAt = autoRefresh === 'paused' ? Date.now() : undefined; const target: Location = {...location, query: {...location.query}}; + if (autoRefresh === 'paused') { + setPausedAt(newPausedAt); + } else if (autoRefresh !== 'enabled') { + setPausedAt(undefined); + } + if (autoRefresh === 'idle') { delete target.query[LOGS_AUTO_REFRESH_KEY]; } else { target.query[LOGS_AUTO_REFRESH_KEY] = autoRefresh; } + navigate(target); }, - [navigate, location, queryClient, queryKey] + [navigate, location, queryClient, queryKey, setPausedAt, currentPausedAt] ); } diff --git a/static/app/views/explore/hooks/useAnalytics.tsx b/static/app/views/explore/hooks/useAnalytics.tsx index 2ca25e70c47274..477d26d7941248 100644 --- a/static/app/views/explore/hooks/useAnalytics.tsx +++ b/static/app/views/explore/hooks/useAnalytics.tsx @@ -8,6 +8,7 @@ import {DiscoverDatasets} from 'sentry/utils/discover/types'; import {MutableSearch} from 'sentry/utils/tokenizeSearch'; import useOrganization from 'sentry/utils/useOrganization'; import type {TimeSeries} from 'sentry/views/dashboards/widgets/common/types'; +import {useLogsAutoRefreshEnabled} from 'sentry/views/explore/contexts/logs/logsAutoRefreshContext'; import { useLogsFields, useLogsSearch, @@ -414,9 +415,11 @@ export function useLogAnalytics({ const tableError = logsTableResult.error?.message ?? ''; const query_status = tableError ? 'error' : 'success'; + const autorefreshEnabled = useLogsAutoRefreshEnabled(); useEffect(() => { - if (logsTableResult.isPending || isLoadingSubscriptionDetails) { + if (logsTableResult.isPending || isLoadingSubscriptionDetails || autorefreshEnabled) { + // Auto-refresh causes constant metadata events, so we don't want to track them. return; } @@ -462,6 +465,7 @@ export function useLogAnalytics({ logsTableResult.isPending, logsTableResult.data?.length, search, + autorefreshEnabled, ]); } diff --git a/static/app/views/explore/hooks/useTraceItemDetails.tsx b/static/app/views/explore/hooks/useTraceItemDetails.tsx index db0c26b3b6ad8d..396a0e10d916cc 100644 --- a/static/app/views/explore/hooks/useTraceItemDetails.tsx +++ b/static/app/views/explore/hooks/useTraceItemDetails.tsx @@ -15,8 +15,6 @@ import { shouldRetryHandler, } from 'sentry/views/insights/common/utils/retryHandlers'; -export const DEFAULT_TRACE_ITEM_HOVER_TIMEOUT = 200; - interface UseTraceItemDetailsProps { /** * Every trace item belongs to a project. @@ -147,12 +145,17 @@ export function usePrefetchTraceItemDetailsOnHover({ referrer, hoverPrefetchDisabled, sharedHoverTimeoutRef, + timeout, }: UseTraceItemDetailsProps & { /** * A ref to a shared timeout so multiple hover events can be handled * without creating multiple timeouts and firing multiple prefetches. */ sharedHoverTimeoutRef: React.MutableRefObject; + /** + * Custom timeout for the prefetched item. + */ + timeout: number; /** * Whether the hover prefetch should be disabled. */ @@ -184,7 +187,7 @@ export function usePrefetchTraceItemDetailsOnHover({ queryFn: fetchDataQuery, staleTime: Infinity, // Prefetched items are never stale as the row is either entirely stored or not stored at all. }); - }, DEFAULT_TRACE_ITEM_HOVER_TIMEOUT); + }, timeout); }, onHoverEnd: () => { if (sharedHoverTimeoutRef.current) { diff --git a/static/app/views/explore/logs/constants.tsx b/static/app/views/explore/logs/constants.tsx index d8d43859455765..36331495121485 100644 --- a/static/app/views/explore/logs/constants.tsx +++ b/static/app/views/explore/logs/constants.tsx @@ -17,6 +17,8 @@ export const MAX_LOG_INGEST_DELAY = 40_000; export const QUERY_PAGE_LIMIT = 1000; // If this does not equal the limit with auto-refresh, the query keys will diverge and they will have separate caches. We may want to make this change in the future. export const QUERY_PAGE_LIMIT_WITH_AUTO_REFRESH = 1000; export const LOG_ATTRIBUTE_LAZY_LOAD_HOVER_TIMEOUT = 150; +export const DEFAULT_TRACE_ITEM_HOVER_TIMEOUT = 150; +export const DEFAULT_TRACE_ITEM_HOVER_TIMEOUT_WITH_AUTO_REFRESH = 400; // With autorefresh on, a stationary mouse can prefetch multiple rows since virtual time moves rows constantly. /** * These are required fields are always added to the query when fetching the log table. diff --git a/static/app/views/explore/logs/content.spec.tsx b/static/app/views/explore/logs/content.spec.tsx index 9acc9c257e1fbf..0781365292ab5c 100644 --- a/static/app/views/explore/logs/content.spec.tsx +++ b/static/app/views/explore/logs/content.spec.tsx @@ -2,6 +2,7 @@ import {initializeOrg} from 'sentry-test/initializeOrg'; import {render, screen, userEvent, waitFor} from 'sentry-test/reactTestingLibrary'; import PageFiltersStore from 'sentry/stores/pageFiltersStore'; +import {LOGS_AUTO_REFRESH_KEY} from 'sentry/views/explore/contexts/logs/logsAutoRefreshContext'; import LogsPage from './content'; @@ -233,4 +234,111 @@ describe('LogsPage', function () { {timeout: 5000} ); }); + + it('pauses auto-refresh when enabled switch is clicked', async function () { + const {organization: newOrganization} = initializeOrg({ + organization: { + features: [...BASE_FEATURES, 'ourlogs-infinite-scroll', 'ourlogs-live-refresh'], + }, + }); + + MockApiClient.addMockResponse({ + url: `/organizations/${newOrganization.slug}/events/`, + method: 'GET', + body: { + data: [ + { + 'sentry.item_id': '1', + 'project.id': 1, + trace: 'trace1', + severity_number: 9, + severity_text: 'info', + timestamp: '2025-04-10T19:21:12+00:00', + message: 'some log message', + 'tags[sentry.timestamp_precise,number]': 100, + }, + ], + meta: {fields: {}, units: {}}, + }, + }); + + const {router} = render(, { + organization: newOrganization, + initialRouterConfig: { + location: { + pathname: `/organizations/${newOrganization.slug}/explore/logs/`, + query: { + [LOGS_AUTO_REFRESH_KEY]: 'enabled', + }, + }, + }, + }); + expect(router.location.query[LOGS_AUTO_REFRESH_KEY]).toBe('enabled'); + + await waitFor(() => { + expect(screen.getByTestId('logs-table')).toBeInTheDocument(); + }); + + const switchInput = screen.getByRole('checkbox', {name: /auto-refresh/i}); + expect(switchInput).toBeChecked(); + + await userEvent.click(switchInput); + + await waitFor(() => { + expect(router.location.query[LOGS_AUTO_REFRESH_KEY]).toBe('paused'); + }); + expect(switchInput).not.toBeChecked(); + }); + + it('pauses auto-refresh when row is clicked', async function () { + const {organization: newOrganization} = initializeOrg({ + organization: { + features: [...BASE_FEATURES, 'ourlogs-infinite-scroll', 'ourlogs-live-refresh'], + }, + }); + + MockApiClient.addMockResponse({ + url: `/organizations/${newOrganization.slug}/events/`, + method: 'GET', + body: { + data: [ + { + 'sentry.item_id': '1', + 'project.id': 1, + trace: 'trace1', + severity_number: 9, + severity_text: 'info', + timestamp: '2025-04-10T19:21:12+00:00', + message: 'some log message', + 'tags[sentry.timestamp_precise,number]': 100, + }, + ], + meta: {fields: {}, units: {}}, + }, + }); + + const {router} = render(, { + organization: newOrganization, + initialRouterConfig: { + location: { + pathname: `/organizations/${newOrganization.slug}/explore/logs/`, + query: { + [LOGS_AUTO_REFRESH_KEY]: 'enabled', + }, + }, + }, + }); + expect(router.location.query[LOGS_AUTO_REFRESH_KEY]).toBe('enabled'); + + await waitFor(() => { + expect(screen.getByTestId('logs-table')).toBeInTheDocument(); + }); + + const row = screen.getByText('some log message'); + await userEvent.click(row); + + await waitFor(() => { + expect(router.location.query[LOGS_AUTO_REFRESH_KEY]).toBe('paused'); + }); + }); }); diff --git a/static/app/views/explore/logs/logsAutoRefresh.spec.tsx b/static/app/views/explore/logs/logsAutoRefresh.spec.tsx index 45382282b9813e..3733959741ca3c 100644 --- a/static/app/views/explore/logs/logsAutoRefresh.spec.tsx +++ b/static/app/views/explore/logs/logsAutoRefresh.spec.tsx @@ -21,6 +21,8 @@ import {LOGS_SORT_BYS_KEY} from 'sentry/views/explore/contexts/logs/sortBys'; import {AutorefreshToggle} from 'sentry/views/explore/logs/logsAutoRefresh'; import {OurLogKnownFieldKey} from 'sentry/views/explore/logs/types'; +const REFRESH_INTERVAL_MS = 100; + describe('LogsAutoRefresh Integration Tests', () => { const organization = OrganizationFixture({ features: ['ourlogs-enabled', 'ourlogs-live-refresh', 'ourlogs-infinite-scroll'], @@ -34,7 +36,7 @@ describe('LogsAutoRefresh Integration Tests', () => { query: { // Toggle is disabled if sort is not a timestamp [LOGS_SORT_BYS_KEY]: '-timestamp', - [LOGS_REFRESH_INTERVAL_KEY]: '200', // Fast refresh for testing + [LOGS_REFRESH_INTERVAL_KEY]: REFRESH_INTERVAL_MS, // Fast refresh for testing }, }, route: '/organizations/:orgId/explore/logs/', @@ -141,7 +143,7 @@ describe('LogsAutoRefresh Integration Tests', () => { expect(mockApi).toHaveBeenCalled(); }); - it('disables auto-refresh when toggled off and removes from URL', async () => { + it('disables auto-refresh when toggled off and sets paused state', async () => { mockApiCall(); const {router} = renderWithProviders(, { @@ -156,7 +158,7 @@ describe('LogsAutoRefresh Integration Tests', () => { await userEvent.click(toggleSwitch); await waitFor(() => { - expect(router.location.query[LOGS_AUTO_REFRESH_KEY]).toBeUndefined(); + expect(router.location.query[LOGS_AUTO_REFRESH_KEY]).toBe('paused'); }); // The toggle should be unchecked after navigation completes @@ -273,6 +275,7 @@ describe('LogsAutoRefresh Integration Tests', () => { }); it('disables auto-refresh after 5 consecutive requests with more data', async () => { + jest.useFakeTimers(); const mockApi = MockApiClient.addMockResponse({ url: `/organizations/${organization.slug}/events/`, method: 'GET', @@ -294,9 +297,7 @@ describe('LogsAutoRefresh Integration Tests', () => { expect(mockApi).toHaveBeenCalledTimes(5); }); - await waitFor(() => { - expect(router.location.query[LOGS_AUTO_REFRESH_KEY]).toBe('rate_limit'); - }); + expect(router.location.query[LOGS_AUTO_REFRESH_KEY]).toBe('rate_limit'); }); it('continues auto-refresh when there is no more data', async () => { diff --git a/static/app/views/explore/logs/logsAutoRefresh.tsx b/static/app/views/explore/logs/logsAutoRefresh.tsx index 62a05b69ca77b0..60f2b792931c8a 100644 --- a/static/app/views/explore/logs/logsAutoRefresh.tsx +++ b/static/app/views/explore/logs/logsAutoRefresh.tsx @@ -114,7 +114,7 @@ export function AutorefreshToggle({ if (newChecked) { setAutorefresh('enabled'); } else { - setAutorefresh('idle'); + setAutorefresh('paused'); } }} /> diff --git a/static/app/views/explore/logs/tables/logsInfiniteTable.spec.tsx b/static/app/views/explore/logs/tables/logsInfiniteTable.spec.tsx index aef4889ee4af89..1887cf1ad37893 100644 --- a/static/app/views/explore/logs/tables/logsInfiniteTable.spec.tsx +++ b/static/app/views/explore/logs/tables/logsInfiniteTable.spec.tsx @@ -23,7 +23,7 @@ import { LogsPageParamsProvider, } from 'sentry/views/explore/contexts/logs/logsPageParams'; import {LOGS_SORT_BYS_KEY} from 'sentry/views/explore/contexts/logs/sortBys'; -import {DEFAULT_TRACE_ITEM_HOVER_TIMEOUT} from 'sentry/views/explore/hooks/useTraceItemDetails'; +import {DEFAULT_TRACE_ITEM_HOVER_TIMEOUT} from 'sentry/views/explore/logs/constants'; import {LogsInfiniteTable} from 'sentry/views/explore/logs/tables/logsInfiniteTable'; import {OurLogKnownFieldKey} from 'sentry/views/explore/logs/types'; import {OrganizationContext} from 'sentry/views/organizationContext'; diff --git a/static/app/views/explore/logs/tables/logsInfiniteTable.tsx b/static/app/views/explore/logs/tables/logsInfiniteTable.tsx index 155dfcfe29bf7a..a1605a2d6da421 100644 --- a/static/app/views/explore/logs/tables/logsInfiniteTable.tsx +++ b/static/app/views/explore/logs/tables/logsInfiniteTable.tsx @@ -23,7 +23,10 @@ import { TableStatus, useTableStyles, } from 'sentry/views/explore/components/table'; -import {useLogsAutoRefreshEnabled} from 'sentry/views/explore/contexts/logs/logsAutoRefreshContext'; +import { + useAutorefreshEnabledOrWithinPauseWindow, + useLogsAutoRefreshEnabled, +} from 'sentry/views/explore/contexts/logs/logsAutoRefreshContext'; import {useLogsPageData} from 'sentry/views/explore/contexts/logs/logsPageData'; import { useLogsFields, @@ -94,6 +97,10 @@ export function LogsInfiniteTable({ Record >({}); const [isFunctionScrolling, setIsFunctionScrolling] = useState(false); + const isAutorefreshEnabledOrWithinPauseWindow = + useAutorefreshEnabledOrWithinPauseWindow(); + const scrollFetchDisabled = + isFunctionScrolling || !isAutorefreshEnabledOrWithinPauseWindow; const sharedHoverTimeoutRef = useRef(null); const {initialTableStyles, onResizeMouseDown} = useTableStyles(fields, tableRef, { @@ -161,7 +168,7 @@ export function LogsInfiniteTable({ }, [isFunctionScrolling, isScrolling, scrollOffset]); useEffect(() => { - if (isScrolling && !isFunctionScrolling) { + if (isScrolling && !scrollFetchDisabled) { if ( scrollDirection === 'backward' && scrollOffset && @@ -187,6 +194,7 @@ export function LogsInfiniteTable({ lastPageLength, scrollOffset, isFunctionScrolling, + scrollFetchDisabled, ]); const handleExpand = useCallback((logItemId: string) => { diff --git a/static/app/views/explore/logs/tables/logsTableRow.spec.tsx b/static/app/views/explore/logs/tables/logsTableRow.spec.tsx index c6ce62961a0947..9658aab00d5735 100644 --- a/static/app/views/explore/logs/tables/logsTableRow.spec.tsx +++ b/static/app/views/explore/logs/tables/logsTableRow.spec.tsx @@ -14,10 +14,8 @@ import { LogsPageParamsProvider, } from 'sentry/views/explore/contexts/logs/logsPageParams'; import {LOGS_SORT_BYS_KEY} from 'sentry/views/explore/contexts/logs/sortBys'; -import { - DEFAULT_TRACE_ITEM_HOVER_TIMEOUT, - type TraceItemResponseAttribute, -} from 'sentry/views/explore/hooks/useTraceItemDetails'; +import {type TraceItemResponseAttribute} from 'sentry/views/explore/hooks/useTraceItemDetails'; +import {DEFAULT_TRACE_ITEM_HOVER_TIMEOUT} from 'sentry/views/explore/logs/constants'; import {LogRowContent} from 'sentry/views/explore/logs/tables/logsTableRow'; import {OurLogKnownFieldKey} from 'sentry/views/explore/logs/types'; diff --git a/static/app/views/explore/logs/tables/logsTableRow.tsx b/static/app/views/explore/logs/tables/logsTableRow.tsx index e56276ab9b8f6a..5008590ffb1a26 100644 --- a/static/app/views/explore/logs/tables/logsTableRow.tsx +++ b/static/app/views/explore/logs/tables/logsTableRow.tsx @@ -35,7 +35,11 @@ import { useLogsSearch, useSetLogsSearch, } from 'sentry/views/explore/contexts/logs/logsPageParams'; -import {HiddenLogDetailFields} from 'sentry/views/explore/logs/constants'; +import { + DEFAULT_TRACE_ITEM_HOVER_TIMEOUT, + DEFAULT_TRACE_ITEM_HOVER_TIMEOUT_WITH_AUTO_REFRESH, + HiddenLogDetailFields, +} from 'sentry/views/explore/logs/constants'; import type {RendererExtra} from 'sentry/views/explore/logs/fieldRenderers'; import { LogAttributesRendererMap, @@ -163,7 +167,7 @@ export const LogRowContent = memo(function LogRowContent({ setExpanded(e => !e); } if (!isExpanded && autorefreshEnabled) { - setAutorefresh('idle'); + setAutorefresh('paused'); } trackAnalytics('logs.table.row_expanded', { @@ -212,11 +216,15 @@ export const LogRowContent = memo(function LogRowContent({ typeof severityText === 'string' ? severityText : null ); const logColors = getLogColors(level, theme); + const prefetchTimeout = autorefreshEnabled + ? DEFAULT_TRACE_ITEM_HOVER_TIMEOUT_WITH_AUTO_REFRESH + : DEFAULT_TRACE_ITEM_HOVER_TIMEOUT; const hoverProps = usePrefetchLogTableRowOnHover({ logId: String(dataRow[OurLogKnownFieldKey.ID]), projectId: String(dataRow[OurLogKnownFieldKey.PROJECT_ID]), traceId: String(dataRow[OurLogKnownFieldKey.TRACE_ID]), sharedHoverTimeoutRef, + timeout: prefetchTimeout, }); const rendererExtra = { diff --git a/static/app/views/explore/logs/useLogsQuery.tsx b/static/app/views/explore/logs/useLogsQuery.tsx index 2e897d4515a5c5..60b2f70477f2ec 100644 --- a/static/app/views/explore/logs/useLogsQuery.tsx +++ b/static/app/views/explore/logs/useLogsQuery.tsx @@ -79,10 +79,12 @@ export function usePrefetchLogTableRowOnHover({ traceId, hoverPrefetchDisabled, sharedHoverTimeoutRef, + timeout, }: { logId: string | number; projectId: string; sharedHoverTimeoutRef: React.MutableRefObject; + timeout: number; traceId: string; hoverPrefetchDisabled?: boolean; }) { @@ -93,6 +95,7 @@ export function usePrefetchLogTableRowOnHover({ traceItemType: TraceItemDataset.LOGS, hoverPrefetchDisabled, sharedHoverTimeoutRef, + timeout, referrer: 'api.explore.log-item-details', }); } diff --git a/static/app/views/explore/logs/useVirtualStreaming.tsx b/static/app/views/explore/logs/useVirtualStreaming.tsx index 41f23c19317bc2..d8101605b9331c 100644 --- a/static/app/views/explore/logs/useVirtualStreaming.tsx +++ b/static/app/views/explore/logs/useVirtualStreaming.tsx @@ -4,6 +4,7 @@ import type {ApiResult} from 'sentry/api'; import type {InfiniteData} from 'sentry/utils/queryClient'; import usePrevious from 'sentry/utils/usePrevious'; import { + useAutorefreshWithinPauseWindow, useLogsAutoRefreshEnabled, useLogsRefreshInterval, } from 'sentry/views/explore/contexts/logs/logsAutoRefreshContext'; @@ -52,6 +53,7 @@ export function useVirtualStreaming( data: InfiniteData> | undefined ) { const autoRefresh = useLogsAutoRefreshEnabled(); + const isWithinPauseWindow = useAutorefreshWithinPauseWindow(); const refreshInterval = useLogsRefreshInterval(); const rafOn = useRef(false); const [virtualTimestamp, setVirtualTimestamp] = useState(undefined); @@ -71,7 +73,10 @@ export function useVirtualStreaming( // If we've received data, initialize the virtual timestamp to be refreshEvery seconds before the max ingest delay timestamp const initializeVirtualTimestamp = useCallback(() => { - if (!data?.pages?.length || virtualTimestamp !== undefined) { + if ( + !data?.pages?.length || + (!isWithinPauseWindow && virtualTimestamp !== undefined) + ) { return; } @@ -104,7 +109,7 @@ export function useVirtualStreaming( ); setVirtualTimestamp(initialTimestamp); - }, [data, virtualTimestamp, refreshInterval]); + }, [data, virtualTimestamp, refreshInterval, isWithinPauseWindow]); // Initialize when auto refresh is enabled and we have data useEffect(() => { From 5c0d58511a37517d0422899dc0f5b90028f5000b Mon Sep 17 00:00:00 2001 From: Kev Date: Wed, 30 Jul 2025 09:20:20 -0400 Subject: [PATCH 2/4] Fix export --- .../app/views/explore/contexts/logs/logsAutoRefreshContext.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/static/app/views/explore/contexts/logs/logsAutoRefreshContext.tsx b/static/app/views/explore/contexts/logs/logsAutoRefreshContext.tsx index 9776e279857147..f759af71f5a2a5 100644 --- a/static/app/views/explore/contexts/logs/logsAutoRefreshContext.tsx +++ b/static/app/views/explore/contexts/logs/logsAutoRefreshContext.tsx @@ -11,7 +11,7 @@ import {useLogsQueryKeyWithInfinite} from 'sentry/views/explore/logs/useLogsQuer export const LOGS_AUTO_REFRESH_KEY = 'live'; export const LOGS_REFRESH_INTERVAL_KEY = 'refreshEvery'; const LOGS_REFRESH_INTERVAL_DEFAULT = 5000; -export const MAX_AUTO_REFRESH_PAUSED_TIME_MS = 60 * 1000; // 60 seconds +const MAX_AUTO_REFRESH_PAUSED_TIME_MS = 60 * 1000; // 60 seconds export const ABSOLUTE_MAX_AUTO_REFRESH_TIME_MS = 10 * 60 * 1000; // 10 minutes export const CONSECUTIVE_PAGES_WITH_MORE_DATA = 5; From e58e501749e63ddd4f0bf9aecfacbdac8ba5de3a Mon Sep 17 00:00:00 2001 From: Kev Date: Wed, 30 Jul 2025 09:54:41 -0400 Subject: [PATCH 3/4] Fix test --- .../app/views/explore/logs/content.spec.tsx | 64 +++++++++++++++---- 1 file changed, 51 insertions(+), 13 deletions(-) diff --git a/static/app/views/explore/logs/content.spec.tsx b/static/app/views/explore/logs/content.spec.tsx index 0781365292ab5c..3de832c7fb4301 100644 --- a/static/app/views/explore/logs/content.spec.tsx +++ b/static/app/views/explore/logs/content.spec.tsx @@ -1,8 +1,13 @@ +import {LogFixture, LogFixtureMeta} from 'sentry-fixture/log'; + import {initializeOrg} from 'sentry-test/initializeOrg'; import {render, screen, userEvent, waitFor} from 'sentry-test/reactTestingLibrary'; import PageFiltersStore from 'sentry/stores/pageFiltersStore'; +import ProjectsStore from 'sentry/stores/projectsStore'; import {LOGS_AUTO_REFRESH_KEY} from 'sentry/views/explore/contexts/logs/logsAutoRefreshContext'; +import type {TraceItemResponseAttribute} from 'sentry/views/explore/hooks/useTraceItemDetails'; +import {OurLogKnownFieldKey} from 'sentry/views/explore/logs/types'; import LogsPage from './content'; @@ -15,6 +20,7 @@ describe('LogsPage', function () { }, }); + ProjectsStore.loadInitialData([project]); PageFiltersStore.init(); PageFiltersStore.onInitializeUrlState( { @@ -30,6 +36,7 @@ describe('LogsPage', function () { beforeEach(function () { organization.features = BASE_FEATURES; MockApiClient.clearMockResponses(); + eventTableMock = MockApiClient.addMockResponse({ url: `/organizations/${organization.slug}/events/`, method: 'GET', @@ -291,32 +298,56 @@ describe('LogsPage', function () { }); it('pauses auto-refresh when row is clicked', async function () { - const {organization: newOrganization} = initializeOrg({ + const {organization: newOrganization, project: newProject} = initializeOrg({ organization: { features: [...BASE_FEATURES, 'ourlogs-infinite-scroll', 'ourlogs-live-refresh'], }, }); + ProjectsStore.loadInitialData([newProject]); + + const rowData = LogFixture({ + [OurLogKnownFieldKey.ID]: '1', + [OurLogKnownFieldKey.PROJECT_ID]: newProject.id, + [OurLogKnownFieldKey.ORGANIZATION_ID]: Number(newOrganization.id), + [OurLogKnownFieldKey.TRACE_ID]: '7b91699f', + [OurLogKnownFieldKey.MESSAGE]: 'some log message', + [OurLogKnownFieldKey.TIMESTAMP]: '2025-04-10T19:21:12+00:00', + [OurLogKnownFieldKey.TIMESTAMP_PRECISE]: 100, + [OurLogKnownFieldKey.SEVERITY_NUMBER]: 9, + [OurLogKnownFieldKey.SEVERITY]: 'info', + }); + + const rowDetails = Object.entries(rowData).map( + ([k, v]) => + ({ + name: k, + value: v, + type: typeof v === 'string' ? 'str' : 'float', + }) as TraceItemResponseAttribute + ); + MockApiClient.addMockResponse({ url: `/organizations/${newOrganization.slug}/events/`, method: 'GET', body: { - data: [ - { - 'sentry.item_id': '1', - 'project.id': 1, - trace: 'trace1', - severity_number: 9, - severity_text: 'info', - timestamp: '2025-04-10T19:21:12+00:00', - message: 'some log message', - 'tags[sentry.timestamp_precise,number]': 100, - }, - ], + data: [rowData], meta: {fields: {}, units: {}}, }, }); + const rowDetailsMock = MockApiClient.addMockResponse({ + url: `/projects/${newOrganization.slug}/${newProject.slug}/trace-items/${rowData[OurLogKnownFieldKey.ID]}/`, + method: 'GET', + body: { + itemId: rowData[OurLogKnownFieldKey.ID], + links: null, + meta: LogFixtureMeta(rowData), + timestamp: rowData[OurLogKnownFieldKey.TIMESTAMP], + attributes: rowDetails, + }, + }); + const {router} = render(, { organization: newOrganization, initialRouterConfig: { @@ -328,15 +359,22 @@ describe('LogsPage', function () { }, }, }); + expect(router.location.query[LOGS_AUTO_REFRESH_KEY]).toBe('enabled'); await waitFor(() => { expect(screen.getByTestId('logs-table')).toBeInTheDocument(); }); + expect(rowDetailsMock).not.toHaveBeenCalled(); + const row = screen.getByText('some log message'); await userEvent.click(row); + await waitFor(() => { + expect(rowDetailsMock).toHaveBeenCalled(); + }); + await waitFor(() => { expect(router.location.query[LOGS_AUTO_REFRESH_KEY]).toBe('paused'); }); From 24b3f8978341de0fd7070b580f9992d2a6d73d87 Mon Sep 17 00:00:00 2001 From: Kev <6111995+k-fish@users.noreply.github.com> Date: Wed, 30 Jul 2025 10:27:46 -0400 Subject: [PATCH 4/4] Update static/app/views/explore/contexts/logs/logsAutoRefreshContext.tsx Co-authored-by: Tony Xiao --- .../app/views/explore/contexts/logs/logsAutoRefreshContext.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/static/app/views/explore/contexts/logs/logsAutoRefreshContext.tsx b/static/app/views/explore/contexts/logs/logsAutoRefreshContext.tsx index f759af71f5a2a5..86081f2c231ebc 100644 --- a/static/app/views/explore/contexts/logs/logsAutoRefreshContext.tsx +++ b/static/app/views/explore/contexts/logs/logsAutoRefreshContext.tsx @@ -119,7 +119,7 @@ function withinPauseWindow(autoRefresh: AutoRefreshState, pausedAt: number | und return ( (autoRefresh === 'paused' || autoRefresh === 'enabled') && pausedAt && - MAX_AUTO_REFRESH_PAUSED_TIME_MS - (Date.now() - pausedAt) > 0 + Date.now() - pausedAt < MAX_AUTO_REFRESH_PAUSED_TIME_MS ); }