Skip to content

feat(logs): Add pause functionality to auto-refresh #96698

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 4 commits into from
Jul 30, 2025
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
66 changes: 56 additions & 10 deletions static/app/views/explore/contexts/logs/logsAutoRefreshContext.tsx
Original file line number Diff line number Diff line change
@@ -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';
Expand All @@ -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;
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;
Expand All @@ -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] =
Expand All @@ -48,14 +52,24 @@ export function LogsAutoRefreshProvider({
_testContext,
}: LogsAutoRefreshProviderProps) {
const location = useLocation();
const [pausedAt, setPausedAt] = useState<number | undefined>(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],
Expand All @@ -68,6 +82,8 @@ export function LogsAutoRefreshProvider({
autoRefresh,
refreshInterval,
isTableFrozen,
pausedAt,
setPausedAt,
..._testContext,
}}
>
Expand All @@ -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 &&
Date.now() - pausedAt < MAX_AUTO_REFRESH_PAUSED_TIME_MS
);
}

export function useSetLogsAutoRefresh() {
const location = useLocation();
const navigate = useNavigate();
Expand All @@ -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]
);
}

Expand Down
6 changes: 5 additions & 1 deletion static/app/views/explore/hooks/useAnalytics.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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;
}

Expand Down Expand Up @@ -462,6 +465,7 @@ export function useLogAnalytics({
logsTableResult.isPending,
logsTableResult.data?.length,
search,
autorefreshEnabled,
]);
}

Expand Down
9 changes: 6 additions & 3 deletions static/app/views/explore/hooks/useTraceItemDetails.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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<NodeJS.Timeout | null>;
/**
* Custom timeout for the prefetched item.
*/
timeout: number;
/**
* Whether the hover prefetch should be disabled.
*/
Expand Down Expand Up @@ -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) {
Expand Down
2 changes: 2 additions & 0 deletions static/app/views/explore/logs/constants.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
146 changes: 146 additions & 0 deletions static/app/views/explore/logs/content.spec.tsx
Original file line number Diff line number Diff line change
@@ -1,7 +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';

Expand All @@ -14,6 +20,7 @@ describe('LogsPage', function () {
},
});

ProjectsStore.loadInitialData([project]);
PageFiltersStore.init();
PageFiltersStore.onInitializeUrlState(
{
Expand All @@ -29,6 +36,7 @@ describe('LogsPage', function () {
beforeEach(function () {
organization.features = BASE_FEATURES;
MockApiClient.clearMockResponses();

eventTableMock = MockApiClient.addMockResponse({
url: `/organizations/${organization.slug}/events/`,
method: 'GET',
Expand Down Expand Up @@ -233,4 +241,142 @@ 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(<LogsPage />, {
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, 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: [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(<LogsPage />, {
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();
});

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');
});
});
});
Loading
Loading