Skip to content

Commit 6c5430e

Browse files
k-fishZylphrex
andauthored
feat(logs): Add pause functionality to auto-refresh (#96698)
### Summary - 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. --------- Co-authored-by: Tony Xiao <txiao@sentry.io>
1 parent 73e9bd9 commit 6c5430e

13 files changed

+256
-32
lines changed

static/app/views/explore/contexts/logs/logsAutoRefreshContext.tsx

Lines changed: 56 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import {useCallback} from 'react';
1+
import {useCallback, useRef, useState} from 'react';
22
import type {Location} from 'history';
33

44
import {createDefinedContext} from 'sentry/utils/performance/contexts/utils';
@@ -11,6 +11,7 @@ import {useLogsQueryKeyWithInfinite} from 'sentry/views/explore/logs/useLogsQuer
1111
export const LOGS_AUTO_REFRESH_KEY = 'live';
1212
export const LOGS_REFRESH_INTERVAL_KEY = 'refreshEvery';
1313
const LOGS_REFRESH_INTERVAL_DEFAULT = 5000;
14+
const MAX_AUTO_REFRESH_PAUSED_TIME_MS = 60 * 1000; // 60 seconds
1415

1516
export const ABSOLUTE_MAX_AUTO_REFRESH_TIME_MS = 10 * 60 * 1000; // 10 minutes
1617
export const CONSECUTIVE_PAGES_WITH_MORE_DATA = 5;
@@ -21,12 +22,15 @@ export type AutoRefreshState =
2122
| 'timeout' // Hit 10 minute limit
2223
| 'rate_limit' // Too much data during refresh
2324
| 'error' // Fetch error
25+
| 'paused' // Paused for MAX_AUTO_REFRESH_PAUSED_TIME_MS otherwise treated as idle
2426
| 'idle'; // Default (inactive ) state. Should never appear in query params.
2527

2628
interface LogsAutoRefreshContextValue {
2729
autoRefresh: AutoRefreshState;
2830
isTableFrozen: boolean | undefined;
31+
pausedAt: number | undefined;
2932
refreshInterval: number;
33+
setPausedAt: (timestamp: number | undefined) => void;
3034
}
3135

3236
const [_LogsAutoRefreshProvider, useLogsAutoRefresh, LogsAutoRefreshContext] =
@@ -48,14 +52,24 @@ export function LogsAutoRefreshProvider({
4852
_testContext,
4953
}: LogsAutoRefreshProviderProps) {
5054
const location = useLocation();
55+
const [pausedAt, setPausedAt] = useState<number | undefined>(undefined);
56+
const hasInitialized = useRef(false);
5157

52-
const autoRefreshRaw = decodeScalar(location.query[LOGS_AUTO_REFRESH_KEY]);
53-
const autoRefresh: AutoRefreshState = (
54-
autoRefreshRaw &&
55-
['enabled', 'timeout', 'rate_limit', 'error'].includes(autoRefreshRaw)
56-
? autoRefreshRaw
57-
: 'idle'
58-
) as AutoRefreshState;
58+
const allowedStates: AutoRefreshState[] = ['enabled', 'timeout', 'rate_limit', 'error'];
59+
if (hasInitialized.current) {
60+
// Paused is not allowed via linking since it requires internal state (pausedAt) to work.
61+
allowedStates.push('paused');
62+
}
63+
64+
const rawState = decodeScalar(location.query[LOGS_AUTO_REFRESH_KEY]);
65+
const autoRefresh: AutoRefreshState =
66+
rawState && allowedStates.includes(rawState as AutoRefreshState)
67+
? (rawState as AutoRefreshState)
68+
: 'idle';
69+
70+
if (autoRefresh !== 'idle') {
71+
hasInitialized.current = true;
72+
}
5973

6074
const refreshInterval = decodeInteger(
6175
location.query[LOGS_REFRESH_INTERVAL_KEY],
@@ -68,6 +82,8 @@ export function LogsAutoRefreshProvider({
6882
autoRefresh,
6983
refreshInterval,
7084
isTableFrozen,
85+
pausedAt,
86+
setPausedAt,
7187
..._testContext,
7288
}}
7389
>
@@ -86,6 +102,27 @@ export function useLogsAutoRefreshEnabled() {
86102
return isTableFrozen ? false : autoRefresh === 'enabled';
87103
}
88104

105+
export function useAutorefreshWithinPauseWindow() {
106+
const {autoRefresh, pausedAt} = useLogsAutoRefresh();
107+
return withinPauseWindow(autoRefresh, pausedAt);
108+
}
109+
110+
export function useAutorefreshEnabledOrWithinPauseWindow() {
111+
const {autoRefresh, pausedAt} = useLogsAutoRefresh();
112+
return (
113+
autoRefresh === 'enabled' ||
114+
(autoRefresh === 'paused' && withinPauseWindow(autoRefresh, pausedAt))
115+
);
116+
}
117+
118+
function withinPauseWindow(autoRefresh: AutoRefreshState, pausedAt: number | undefined) {
119+
return (
120+
(autoRefresh === 'paused' || autoRefresh === 'enabled') &&
121+
pausedAt &&
122+
Date.now() - pausedAt < MAX_AUTO_REFRESH_PAUSED_TIME_MS
123+
);
124+
}
125+
89126
export function useSetLogsAutoRefresh() {
90127
const location = useLocation();
91128
const navigate = useNavigate();
@@ -94,22 +131,31 @@ export function useSetLogsAutoRefresh() {
94131
autoRefresh: true,
95132
});
96133
const queryClient = useQueryClient();
134+
const {setPausedAt, pausedAt: currentPausedAt} = useLogsAutoRefresh();
97135

98136
return useCallback(
99137
(autoRefresh: AutoRefreshState) => {
100-
if (autoRefresh === 'enabled') {
138+
if (autoRefresh === 'enabled' && !withinPauseWindow(autoRefresh, currentPausedAt)) {
101139
queryClient.removeQueries({queryKey});
102140
}
103141

142+
const newPausedAt = autoRefresh === 'paused' ? Date.now() : undefined;
104143
const target: Location = {...location, query: {...location.query}};
144+
if (autoRefresh === 'paused') {
145+
setPausedAt(newPausedAt);
146+
} else if (autoRefresh !== 'enabled') {
147+
setPausedAt(undefined);
148+
}
149+
105150
if (autoRefresh === 'idle') {
106151
delete target.query[LOGS_AUTO_REFRESH_KEY];
107152
} else {
108153
target.query[LOGS_AUTO_REFRESH_KEY] = autoRefresh;
109154
}
155+
110156
navigate(target);
111157
},
112-
[navigate, location, queryClient, queryKey]
158+
[navigate, location, queryClient, queryKey, setPausedAt, currentPausedAt]
113159
);
114160
}
115161

static/app/views/explore/hooks/useAnalytics.tsx

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import {DiscoverDatasets} from 'sentry/utils/discover/types';
88
import {MutableSearch} from 'sentry/utils/tokenizeSearch';
99
import useOrganization from 'sentry/utils/useOrganization';
1010
import type {TimeSeries} from 'sentry/views/dashboards/widgets/common/types';
11+
import {useLogsAutoRefreshEnabled} from 'sentry/views/explore/contexts/logs/logsAutoRefreshContext';
1112
import {
1213
useLogsFields,
1314
useLogsSearch,
@@ -414,9 +415,11 @@ export function useLogAnalytics({
414415

415416
const tableError = logsTableResult.error?.message ?? '';
416417
const query_status = tableError ? 'error' : 'success';
418+
const autorefreshEnabled = useLogsAutoRefreshEnabled();
417419

418420
useEffect(() => {
419-
if (logsTableResult.isPending || isLoadingSubscriptionDetails) {
421+
if (logsTableResult.isPending || isLoadingSubscriptionDetails || autorefreshEnabled) {
422+
// Auto-refresh causes constant metadata events, so we don't want to track them.
420423
return;
421424
}
422425

@@ -462,6 +465,7 @@ export function useLogAnalytics({
462465
logsTableResult.isPending,
463466
logsTableResult.data?.length,
464467
search,
468+
autorefreshEnabled,
465469
]);
466470
}
467471

static/app/views/explore/hooks/useTraceItemDetails.tsx

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -15,8 +15,6 @@ import {
1515
shouldRetryHandler,
1616
} from 'sentry/views/insights/common/utils/retryHandlers';
1717

18-
export const DEFAULT_TRACE_ITEM_HOVER_TIMEOUT = 200;
19-
2018
interface UseTraceItemDetailsProps {
2119
/**
2220
* Every trace item belongs to a project.
@@ -147,12 +145,17 @@ export function usePrefetchTraceItemDetailsOnHover({
147145
referrer,
148146
hoverPrefetchDisabled,
149147
sharedHoverTimeoutRef,
148+
timeout,
150149
}: UseTraceItemDetailsProps & {
151150
/**
152151
* A ref to a shared timeout so multiple hover events can be handled
153152
* without creating multiple timeouts and firing multiple prefetches.
154153
*/
155154
sharedHoverTimeoutRef: React.MutableRefObject<NodeJS.Timeout | null>;
155+
/**
156+
* Custom timeout for the prefetched item.
157+
*/
158+
timeout: number;
156159
/**
157160
* Whether the hover prefetch should be disabled.
158161
*/
@@ -184,7 +187,7 @@ export function usePrefetchTraceItemDetailsOnHover({
184187
queryFn: fetchDataQuery,
185188
staleTime: Infinity, // Prefetched items are never stale as the row is either entirely stored or not stored at all.
186189
});
187-
}, DEFAULT_TRACE_ITEM_HOVER_TIMEOUT);
190+
}, timeout);
188191
},
189192
onHoverEnd: () => {
190193
if (sharedHoverTimeoutRef.current) {

static/app/views/explore/logs/constants.tsx

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,8 @@ export const MAX_LOG_INGEST_DELAY = 40_000;
1717
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.
1818
export const QUERY_PAGE_LIMIT_WITH_AUTO_REFRESH = 1000;
1919
export const LOG_ATTRIBUTE_LAZY_LOAD_HOVER_TIMEOUT = 150;
20+
export const DEFAULT_TRACE_ITEM_HOVER_TIMEOUT = 150;
21+
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.
2022

2123
/**
2224
* These are required fields are always added to the query when fetching the log table.

static/app/views/explore/logs/content.spec.tsx

Lines changed: 146 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,13 @@
1+
import {LogFixture, LogFixtureMeta} from 'sentry-fixture/log';
2+
13
import {initializeOrg} from 'sentry-test/initializeOrg';
24
import {render, screen, userEvent, waitFor} from 'sentry-test/reactTestingLibrary';
35

46
import PageFiltersStore from 'sentry/stores/pageFiltersStore';
7+
import ProjectsStore from 'sentry/stores/projectsStore';
8+
import {LOGS_AUTO_REFRESH_KEY} from 'sentry/views/explore/contexts/logs/logsAutoRefreshContext';
9+
import type {TraceItemResponseAttribute} from 'sentry/views/explore/hooks/useTraceItemDetails';
10+
import {OurLogKnownFieldKey} from 'sentry/views/explore/logs/types';
511

612
import LogsPage from './content';
713

@@ -14,6 +20,7 @@ describe('LogsPage', function () {
1420
},
1521
});
1622

23+
ProjectsStore.loadInitialData([project]);
1724
PageFiltersStore.init();
1825
PageFiltersStore.onInitializeUrlState(
1926
{
@@ -29,6 +36,7 @@ describe('LogsPage', function () {
2936
beforeEach(function () {
3037
organization.features = BASE_FEATURES;
3138
MockApiClient.clearMockResponses();
39+
3240
eventTableMock = MockApiClient.addMockResponse({
3341
url: `/organizations/${organization.slug}/events/`,
3442
method: 'GET',
@@ -233,4 +241,142 @@ describe('LogsPage', function () {
233241
{timeout: 5000}
234242
);
235243
});
244+
245+
it('pauses auto-refresh when enabled switch is clicked', async function () {
246+
const {organization: newOrganization} = initializeOrg({
247+
organization: {
248+
features: [...BASE_FEATURES, 'ourlogs-infinite-scroll', 'ourlogs-live-refresh'],
249+
},
250+
});
251+
252+
MockApiClient.addMockResponse({
253+
url: `/organizations/${newOrganization.slug}/events/`,
254+
method: 'GET',
255+
body: {
256+
data: [
257+
{
258+
'sentry.item_id': '1',
259+
'project.id': 1,
260+
trace: 'trace1',
261+
severity_number: 9,
262+
severity_text: 'info',
263+
timestamp: '2025-04-10T19:21:12+00:00',
264+
message: 'some log message',
265+
'tags[sentry.timestamp_precise,number]': 100,
266+
},
267+
],
268+
meta: {fields: {}, units: {}},
269+
},
270+
});
271+
272+
const {router} = render(<LogsPage />, {
273+
organization: newOrganization,
274+
initialRouterConfig: {
275+
location: {
276+
pathname: `/organizations/${newOrganization.slug}/explore/logs/`,
277+
query: {
278+
[LOGS_AUTO_REFRESH_KEY]: 'enabled',
279+
},
280+
},
281+
},
282+
});
283+
expect(router.location.query[LOGS_AUTO_REFRESH_KEY]).toBe('enabled');
284+
285+
await waitFor(() => {
286+
expect(screen.getByTestId('logs-table')).toBeInTheDocument();
287+
});
288+
289+
const switchInput = screen.getByRole('checkbox', {name: /auto-refresh/i});
290+
expect(switchInput).toBeChecked();
291+
292+
await userEvent.click(switchInput);
293+
294+
await waitFor(() => {
295+
expect(router.location.query[LOGS_AUTO_REFRESH_KEY]).toBe('paused');
296+
});
297+
expect(switchInput).not.toBeChecked();
298+
});
299+
300+
it('pauses auto-refresh when row is clicked', async function () {
301+
const {organization: newOrganization, project: newProject} = initializeOrg({
302+
organization: {
303+
features: [...BASE_FEATURES, 'ourlogs-infinite-scroll', 'ourlogs-live-refresh'],
304+
},
305+
});
306+
307+
ProjectsStore.loadInitialData([newProject]);
308+
309+
const rowData = LogFixture({
310+
[OurLogKnownFieldKey.ID]: '1',
311+
[OurLogKnownFieldKey.PROJECT_ID]: newProject.id,
312+
[OurLogKnownFieldKey.ORGANIZATION_ID]: Number(newOrganization.id),
313+
[OurLogKnownFieldKey.TRACE_ID]: '7b91699f',
314+
[OurLogKnownFieldKey.MESSAGE]: 'some log message',
315+
[OurLogKnownFieldKey.TIMESTAMP]: '2025-04-10T19:21:12+00:00',
316+
[OurLogKnownFieldKey.TIMESTAMP_PRECISE]: 100,
317+
[OurLogKnownFieldKey.SEVERITY_NUMBER]: 9,
318+
[OurLogKnownFieldKey.SEVERITY]: 'info',
319+
});
320+
321+
const rowDetails = Object.entries(rowData).map(
322+
([k, v]) =>
323+
({
324+
name: k,
325+
value: v,
326+
type: typeof v === 'string' ? 'str' : 'float',
327+
}) as TraceItemResponseAttribute
328+
);
329+
330+
MockApiClient.addMockResponse({
331+
url: `/organizations/${newOrganization.slug}/events/`,
332+
method: 'GET',
333+
body: {
334+
data: [rowData],
335+
meta: {fields: {}, units: {}},
336+
},
337+
});
338+
339+
const rowDetailsMock = MockApiClient.addMockResponse({
340+
url: `/projects/${newOrganization.slug}/${newProject.slug}/trace-items/${rowData[OurLogKnownFieldKey.ID]}/`,
341+
method: 'GET',
342+
body: {
343+
itemId: rowData[OurLogKnownFieldKey.ID],
344+
links: null,
345+
meta: LogFixtureMeta(rowData),
346+
timestamp: rowData[OurLogKnownFieldKey.TIMESTAMP],
347+
attributes: rowDetails,
348+
},
349+
});
350+
351+
const {router} = render(<LogsPage />, {
352+
organization: newOrganization,
353+
initialRouterConfig: {
354+
location: {
355+
pathname: `/organizations/${newOrganization.slug}/explore/logs/`,
356+
query: {
357+
[LOGS_AUTO_REFRESH_KEY]: 'enabled',
358+
},
359+
},
360+
},
361+
});
362+
363+
expect(router.location.query[LOGS_AUTO_REFRESH_KEY]).toBe('enabled');
364+
365+
await waitFor(() => {
366+
expect(screen.getByTestId('logs-table')).toBeInTheDocument();
367+
});
368+
369+
expect(rowDetailsMock).not.toHaveBeenCalled();
370+
371+
const row = screen.getByText('some log message');
372+
await userEvent.click(row);
373+
374+
await waitFor(() => {
375+
expect(rowDetailsMock).toHaveBeenCalled();
376+
});
377+
378+
await waitFor(() => {
379+
expect(router.location.query[LOGS_AUTO_REFRESH_KEY]).toBe('paused');
380+
});
381+
});
236382
});

0 commit comments

Comments
 (0)