Skip to content

Commit eaeb4a4

Browse files
committed
feat(ourlogs): Add auto-refresh to logs
This adds an auto-refresh toggle behind the 'ourlogs-live-refresh' flag. Auto-refresh can be enabled with filters so long as the sort is by time and descending. It works on 5 second polling, with a virtual time using RAF to emulate streaming. Filters are applied so data is queried in a ~30s second window behind our ingest delay. Auto-refresh will automatically disable itself under the following conditions: - You've hit a rate limit consistently over 3 polls (>1k logs/s) - You've hit an api error for any reason - You've hit the absolute timeout cap (10 minutes), you can re-enable it if you're actively using it - You change sort / filter etc.
1 parent 28c4cfc commit eaeb4a4

File tree

11 files changed

+1395
-171
lines changed

11 files changed

+1395
-171
lines changed

static/app/components/searchQueryBuilder/context.tsx

Lines changed: 24 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -104,15 +104,29 @@ export function SearchQueryBuilderProvider({
104104
disabled,
105105
});
106106

107+
const stableFieldDefinitionGetter = useMemo(
108+
() => fieldDefinitionGetter,
109+
[fieldDefinitionGetter]
110+
);
111+
112+
const stableFilterKeys = useMemo(() => filterKeys, [filterKeys]);
113+
114+
const stableGetSuggestedFilterKey = useCallback(
115+
(key: string) => {
116+
return getSuggestedFilterKey ? getSuggestedFilterKey(key) : key;
117+
},
118+
[getSuggestedFilterKey]
119+
);
120+
107121
const parseQuery = useCallback(
108122
(query: string) =>
109-
parseQueryBuilderValue(query, fieldDefinitionGetter, {
123+
parseQueryBuilderValue(query, stableFieldDefinitionGetter, {
110124
getFilterTokenWarning,
111125
disallowFreeText,
112126
disallowLogicalOperators,
113127
disallowUnsupportedFilters,
114128
disallowWildcard,
115-
filterKeys,
129+
filterKeys: stableFilterKeys,
116130
invalidMessages,
117131
filterKeyAliases,
118132
}),
@@ -121,8 +135,8 @@ export function SearchQueryBuilderProvider({
121135
disallowLogicalOperators,
122136
disallowUnsupportedFilters,
123137
disallowWildcard,
124-
fieldDefinitionGetter,
125-
filterKeys,
138+
stableFieldDefinitionGetter,
139+
stableFilterKeys,
126140
getFilterTokenWarning,
127141
invalidMessages,
128142
filterKeyAliases,
@@ -150,10 +164,10 @@ export function SearchQueryBuilderProvider({
150164
parsedQuery,
151165
filterKeySections: filterKeySections ?? [],
152166
filterKeyMenuWidth,
153-
filterKeys,
154-
getSuggestedFilterKey: getSuggestedFilterKey ?? ((key: string) => key),
167+
filterKeys: stableFilterKeys,
168+
getSuggestedFilterKey: stableGetSuggestedFilterKey,
155169
getTagValues,
156-
getFieldDefinition: fieldDefinitionGetter,
170+
getFieldDefinition: stableFieldDefinitionGetter,
157171
dispatch,
158172
wrapperRef,
159173
actionBarRef,
@@ -177,10 +191,10 @@ export function SearchQueryBuilderProvider({
177191
parsedQuery,
178192
filterKeySections,
179193
filterKeyMenuWidth,
180-
filterKeys,
181-
getSuggestedFilterKey,
194+
stableFilterKeys,
195+
stableGetSuggestedFilterKey,
182196
getTagValues,
183-
fieldDefinitionGetter,
197+
stableFieldDefinitionGetter,
184198
dispatch,
185199
handleSearch,
186200
placeholder,

static/app/utils/timeSeries/markDelayedData.tsx

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22
* Given a timeseries and a delay in seconds, goes through the timeseries data, and marks each point as either delayed (data bucket ended before the delay threshold) or not
33
*/
44

5+
import {defined} from 'sentry/utils';
56
import type {TimeSeries} from 'sentry/views/dashboards/widgets/common/types';
67

78
export function markDelayedData(timeSeries: TimeSeries, delay: number): TimeSeries {
@@ -22,6 +23,10 @@ export function markDelayedData(timeSeries: TimeSeries, delay: number): TimeSeri
2223
const bucketEndTimestamp = new Date(datum.timestamp).getTime() + bucketSize;
2324
const delayed = bucketEndTimestamp >= ingestionDelayTimestamp;
2425

26+
if (defined(datum.incomplete)) {
27+
return datum;
28+
}
29+
2530
if (!delayed) {
2631
return datum;
2732
}

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

Lines changed: 14 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -237,7 +237,15 @@ function setLogsPageParams(location: Location, pageParams: LogPageParamsUpdate)
237237
updateNullableLocation(target, LOGS_AGGREGATE_PARAM_KEY, pageParams.aggregateParam);
238238
if (!pageParams.isTableFrozen) {
239239
updateLocationWithLogSortBys(target, pageParams.sortBys);
240-
updateLocationWithAggregateSortBys(target, pageParams.aggregateSortBys);
240+
if (
241+
pageParams.sortBys ||
242+
pageParams.search ||
243+
pageParams.aggregateFn ||
244+
pageParams.aggregateParam ||
245+
pageParams.groupBy
246+
) {
247+
updateLocationWithAggregateSortBys(target, pageParams.aggregateSortBys);
248+
}
241249
if (pageParams.sortBys || pageParams.aggregateSortBys || pageParams.search) {
242250
// make sure to clear the cursor every time the query is updated
243251
delete target.query[LOGS_CURSOR_KEY];
@@ -511,14 +519,17 @@ export function useLogsAutoRefresh() {
511519

512520
export function useSetLogsAutoRefresh() {
513521
const setPageParams = useSetLogsPageParams();
514-
const {queryKey} = useLogsQueryKeyWithInfinite({referrer: 'api.explore.logs-table'});
522+
const {queryKey} = useLogsQueryKeyWithInfinite({
523+
referrer: 'api.explore.logs-table',
524+
autoRefresh: false,
525+
});
515526
const queryClient = useQueryClient();
516527
return useCallback(
517528
(autoRefresh: boolean) => {
518-
setPageParams({autoRefresh});
519529
if (autoRefresh) {
520530
queryClient.removeQueries({queryKey});
521531
}
532+
setPageParams({autoRefresh});
522533
},
523534
[setPageParams, queryClient, queryKey]
524535
);

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

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,10 @@ export const LogAttributesHumanLabel: Partial<Record<OurLogFieldKey, string>> =
1313
[OurLogKnownFieldKey.TRACE_ID]: t('Trace'),
1414
};
1515

16-
export const LOG_INGEST_DELAY = 10_000;
16+
export const MAX_LOG_INGEST_DELAY = 40_000;
17+
export const MAX_LOG_INGEST_QUERY_DELAY = 15_000;
18+
export const QUERY_PAGE_LIMIT = 5000; // 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.
19+
export const QUERY_PAGE_LIMIT_WITH_AUTO_REFRESH = 5000;
1720

1821
/**
1922
* These are required fields are always added to the query when fetching the log table.
@@ -66,6 +69,6 @@ export const LOGS_INSTRUCTIONS_URL =
6669

6770
export const LOGS_FILTER_KEY_SECTIONS: FilterKeySection[] = [LOGS_FILTERS];
6871

69-
export const VIRTUAL_STREAMED_INTERVAL_MS = 333;
72+
export const VIRTUAL_STREAMED_INTERVAL_MS = 250;
7073

7174
export const LOGS_GRID_SCROLL_MIN_ITEM_THRESHOLD = 100; // Items from bottom of table to trigger table fetch.

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

Lines changed: 102 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -139,4 +139,106 @@ describe('LogsAutoRefresh', () => {
139139
expect(mockApi).toHaveBeenCalledTimes(5);
140140
});
141141
});
142+
143+
it('disables auto-refresh after 3 consecutive requests with more data', async () => {
144+
const mockApi = MockApiClient.addMockResponse({
145+
url: `/organizations/${organization.slug}/events/`,
146+
method: 'GET',
147+
body: {data: mockLogsData},
148+
headers: {
149+
Link: '<http://localhost/api/0/organizations/org-slug/events/?cursor=0:5000:0>; rel="next"; results="true"',
150+
},
151+
});
152+
153+
jest.spyOn(logsPageParams, 'useLogsAutoRefresh').mockReturnValue(true);
154+
jest.spyOn(logsPageParams, 'useLogsRefreshInterval').mockReturnValue(1); // Faster interval for testing
155+
156+
renderWithProviders(<AutorefreshToggle />);
157+
158+
const toggleSwitch = screen.getByRole('checkbox', {name: 'Auto-refresh'});
159+
expect(toggleSwitch).toBeChecked();
160+
161+
await waitFor(() => {
162+
expect(mockApi).toHaveBeenCalledTimes(3);
163+
});
164+
165+
// After 3 consecutive requests with more data, auto-refresh should be disabled
166+
await waitFor(() => {
167+
expect(setAutoRefresh).toHaveBeenCalledWith(false);
168+
});
169+
});
170+
171+
it('continues auto-refresh when there is no more data', async () => {
172+
const mockApi = MockApiClient.addMockResponse({
173+
url: `/organizations/${organization.slug}/events/`,
174+
method: 'GET',
175+
body: {data: mockLogsData},
176+
headers: {
177+
Link: '', // No Link header means no more data
178+
},
179+
});
180+
181+
jest.spyOn(logsPageParams, 'useLogsAutoRefresh').mockReturnValue(true);
182+
jest.spyOn(logsPageParams, 'useLogsRefreshInterval').mockReturnValue(1); // Faster interval for testing
183+
184+
renderWithProviders(<AutorefreshToggle />);
185+
186+
const toggleSwitch = screen.getByRole('checkbox', {name: 'Auto-refresh'});
187+
expect(toggleSwitch).toBeChecked();
188+
189+
await waitFor(() => {
190+
expect(mockApi).toHaveBeenCalledTimes(5);
191+
});
192+
193+
// Auto-refresh should NOT be disabled
194+
expect(setAutoRefresh).not.toHaveBeenCalledWith(false);
195+
});
196+
197+
it('disables auto-refresh when API request fails', async () => {
198+
const initialMock = MockApiClient.addMockResponse({
199+
url: `/organizations/${organization.slug}/events/`,
200+
method: 'GET',
201+
body: {data: mockLogsData},
202+
});
203+
204+
jest.spyOn(logsPageParams, 'useLogsAutoRefresh').mockReturnValue(true);
205+
jest.spyOn(logsPageParams, 'useLogsRefreshInterval').mockReturnValue(1); // Faster interval for testing
206+
207+
renderWithProviders(<AutorefreshToggle />);
208+
209+
const toggleSwitch = screen.getByRole('checkbox', {name: 'Auto-refresh'});
210+
expect(toggleSwitch).toBeChecked();
211+
212+
await waitFor(() => {
213+
expect(initialMock).toHaveBeenCalled();
214+
});
215+
216+
expect(setAutoRefresh).not.toHaveBeenCalled();
217+
218+
initialMock.mockClear();
219+
const errorMock = MockApiClient.addMockResponse({
220+
url: `/organizations/${organization.slug}/events/`,
221+
method: 'GET',
222+
statusCode: 500,
223+
body: {
224+
detail: 'Internal Server Error',
225+
},
226+
});
227+
228+
await waitFor(() => {
229+
expect(errorMock).toHaveBeenCalled();
230+
});
231+
232+
await waitFor(() => {
233+
expect(setAutoRefresh).toHaveBeenCalledWith(false);
234+
});
235+
236+
await userEvent.hover(toggleSwitch);
237+
238+
expect(
239+
await screen.findByText(
240+
'Auto-refresh was disabled due to an error fetching logs. If the issue persists, please contact support.'
241+
)
242+
).toBeInTheDocument();
243+
});
142244
});

0 commit comments

Comments
 (0)