[8.14] [Logs UI] Fix log entry fly-out when response is slow (#187303) (#187449)

# Backport

This will backport the following commits from `main` to `8.14`:
- [[Logs UI] Fix log entry fly-out when response is slow
(#187303)](https://github.com/elastic/kibana/pull/187303)

<!--- Backport version: 8.9.8 -->

### Questions ?
Please refer to the [Backport tool
documentation](https://github.com/sqren/backport)

<!--BACKPORT [{"author":{"name":"Felix
Stürmer","email":"weltenwort@users.noreply.github.com"},"sourceCommit":{"committedDate":"2024-07-02T22:37:49Z","message":"[Logs
UI] Fix log entry fly-out when response is slow (#187303)\n\nThis
disables a change in polling behavior of the ESE search
strategy,\r\nwhich was introduced with
https://github.com/elastic/kibana/pull/178921.\r\nThe response
processing and progress reporting depends on
it.","sha":"08017ae2dce04e08ec5c1d42896fafef224bf429","branchLabelMapping":{"^v8.15.0$":"main","^v(\\d+).(\\d+).\\d+$":"$1.$2"}},"sourcePullRequest":{"labels":["bug","release_note:fix","Feature:Logs
UI","backport:prev-minor","ci:project-deploy-observability","Team:obs-ux-logs","v8.15.0"],"number":187303,"url":"https://github.com/elastic/kibana/pull/187303","mergeCommit":{"message":"[Logs
UI] Fix log entry fly-out when response is slow (#187303)\n\nThis
disables a change in polling behavior of the ESE search
strategy,\r\nwhich was introduced with
https://github.com/elastic/kibana/pull/178921.\r\nThe response
processing and progress reporting depends on
it.","sha":"08017ae2dce04e08ec5c1d42896fafef224bf429"}},"sourceBranch":"main","suggestedTargetBranches":[],"targetPullRequestStates":[{"branch":"main","label":"v8.15.0","labelRegex":"^v8.15.0$","isSourceBranch":true,"state":"MERGED","url":"https://github.com/elastic/kibana/pull/187303","number":187303,"mergeCommit":{"message":"[Logs
UI] Fix log entry fly-out when response is slow (#187303)\n\nThis
disables a change in polling behavior of the ESE search
strategy,\r\nwhich was introduced with
https://github.com/elastic/kibana/pull/178921.\r\nThe response
processing and progress reporting depends on
it.","sha":"08017ae2dce04e08ec5c1d42896fafef224bf429"}}]}] BACKPORT-->

---------

Co-authored-by: Lukas Olson <olson.lukas@gmail.com>
Co-authored-by: Elastic Machine <elasticmachine@users.noreply.github.com>
This commit is contained in:
Felix Stürmer 2024-07-09 16:15:31 +02:00 committed by GitHub
parent d3a4d221ef
commit 2e36156580
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
7 changed files with 184 additions and 120 deletions

View file

@ -153,6 +153,13 @@ export interface ISearchOptions {
*/
isRestore?: boolean;
/**
* By default, when polling, we don't retrieve the results of the search request (until it is complete). (For async
* search, this is the difference between calling _async_search/{id} and _async_search/status/{id}.) setting this to
* `true` will request the search results, regardless of whether or not the search is complete.
*/
retrieveResults?: boolean;
/**
* Represents a meta-information about a Kibana entity intitating a saerch request.
*/
@ -182,5 +189,6 @@ export type ISearchOptionsSerializable = Pick<
| 'isStored'
| 'isSearchStored'
| 'isRestore'
| 'retrieveResults'
| 'executionContext'
>;

View file

@ -257,6 +257,8 @@ export class SearchInterceptor {
if (combined.sessionId !== undefined) serializableOptions.sessionId = combined.sessionId;
if (combined.isRestore !== undefined) serializableOptions.isRestore = combined.isRestore;
if (combined.retrieveResults !== undefined)
serializableOptions.retrieveResults = combined.retrieveResults;
if (combined.legacyHitsTotal !== undefined)
serializableOptions.legacyHitsTotal = combined.legacyHitsTotal;
if (combined.strategy !== undefined) serializableOptions.strategy = combined.strategy;

View file

@ -36,6 +36,7 @@ export function registerSearchRoute(router: DataPluginRouter): void {
sessionId: schema.maybe(schema.string()),
isStored: schema.maybe(schema.boolean()),
isRestore: schema.maybe(schema.boolean()),
retrieveResults: schema.maybe(schema.boolean()),
},
{ unknowns: 'allow' }
),
@ -48,6 +49,7 @@ export function registerSearchRoute(router: DataPluginRouter): void {
sessionId,
isStored,
isRestore,
retrieveResults,
...searchRequest
} = request.body;
const { strategy, id } = request.params;
@ -65,6 +67,7 @@ export function registerSearchRoute(router: DataPluginRouter): void {
sessionId,
isStored,
isRestore,
retrieveResults,
}
)
.pipe(first())

View file

@ -73,9 +73,11 @@ export const enhancedEsSearchStrategyProvider = (
options: IAsyncSearchOptions,
{ esClient }: SearchStrategyDependencies
) {
// First, request the status of the async search, and return the status if incomplete
const status = await asyncSearchStatus({ id, ...request }, options, { esClient });
if (isRunningResponse(status)) return status;
if (!options.retrieveResults) {
// First, request the status of the async search, and return the status if incomplete
const status = await asyncSearchStatus({ id, ...request }, options, { esClient });
if (isRunningResponse(status)) return status;
}
// Then, if the search is complete, request & return the final results
const client = useInternalUser ? esClient.asInternalUser : esClient.asCurrentUser;

View file

@ -118,7 +118,16 @@ export const logEntriesSearchStrategyProvider = ({
const searchResponse$ = concat(recoveredRequest$, initialRequest$).pipe(
take(1),
concatMap((esRequest) => esSearchStrategy.search(esRequest, options, dependencies))
concatMap((esRequest) =>
esSearchStrategy.search(
esRequest,
{
...options,
retrieveResults: true, // the subsequent processing requires the actual search results
},
dependencies
)
)
);
return combineLatest([searchResponse$, resolvedLogView$, messageFormattingRules$]).pipe(

View file

@ -5,21 +5,21 @@
* 2.0.
*/
import { errors } from '@elastic/elasticsearch';
import { lastValueFrom, of, throwError } from 'rxjs';
import { errors, TransportResult } from '@elastic/elasticsearch';
import { AsyncSearchSubmitResponse } from '@elastic/elasticsearch/lib/api/typesWithBodyKey';
import {
elasticsearchServiceMock,
httpServerMock,
savedObjectsClientMock,
uiSettingsServiceMock,
} from '@kbn/core/server/mocks';
import {
IEsSearchRequest,
IEsSearchResponse,
ISearchStrategy,
SearchStrategyDependencies,
} from '@kbn/data-plugin/server';
import { getMockSearchConfig } from '@kbn/data-plugin/config.mock';
import { ISearchStrategy } from '@kbn/data-plugin/server';
import { enhancedEsSearchStrategyProvider } from '@kbn/data-plugin/server/search';
import { createSearchSessionsClientMock } from '@kbn/data-plugin/server/search/mocks';
import { KbnSearchError } from '@kbn/data-plugin/server/search/report_search_error';
import { loggerMock } from '@kbn/logging-mocks';
import { EMPTY, lastValueFrom } from 'rxjs';
import { createResolvedLogViewMock } from '../../../common/log_views/resolved_log_view.mock';
import { createLogViewsClientMock } from '../log_views/log_views_client.mock';
import { createLogViewsServiceStartMock } from '../log_views/log_views_service.mock';
@ -30,23 +30,34 @@ import {
describe('LogEntry search strategy', () => {
it('handles initial search requests', async () => {
const esSearchStrategyMock = createEsSearchStrategyMock({
id: 'ASYNC_REQUEST_ID',
isRunning: true,
rawResponse: {
took: 0,
_shards: { total: 1, failed: 0, skipped: 0, successful: 0 },
timed_out: false,
hits: { total: 0, max_score: 0, hits: [] },
const esSearchStrategy = createEsSearchStrategy();
const mockDependencies = createSearchStrategyDependenciesMock();
const esClient = mockDependencies.esClient.asCurrentUser;
esClient.asyncSearch.submit.mockResolvedValueOnce({
body: {
id: 'ASYNC_REQUEST_ID',
response: {
took: 0,
_shards: { total: 1, failed: 0, skipped: 0, successful: 0 },
timed_out: false,
hits: { total: 0, max_score: 0, hits: [] },
},
is_partial: false,
is_running: false,
expiration_time_in_millis: 0,
start_time_in_millis: 0,
},
});
statusCode: 200,
headers: {},
warnings: [],
meta: {} as any,
} as TransportResult<AsyncSearchSubmitResponse> as any); // type inference for the mock fails
const dataMock = createDataPluginMock(esSearchStrategyMock);
const dataMock = createDataPluginMock(esSearchStrategy);
const logViewsClientMock = createLogViewsClientMock();
logViewsClientMock.getResolvedLogView.mockResolvedValue(createResolvedLogViewMock());
const logViewsMock = createLogViewsServiceStartMock();
logViewsMock.getScopedClient.mockReturnValue(logViewsClientMock);
const mockDependencies = createSearchStrategyDependenciesMock();
const logEntrySearchStrategy = logEntrySearchStrategyProvider({
data: dataMock,
@ -66,72 +77,88 @@ describe('LogEntry search strategy', () => {
)
);
// ensure log view was resolved
expect(logViewsMock.getScopedClient).toHaveBeenCalled();
expect(logViewsClientMock.getResolvedLogView).toHaveBeenCalled();
expect(esSearchStrategyMock.search).toHaveBeenCalledWith(
{
params: expect.objectContaining({
index: 'log-indices-*',
body: expect.objectContaining({
track_total_hits: false,
terminate_after: 1,
query: {
ids: {
values: ['LOG_ENTRY_ID'],
// ensure search request was made
expect(esClient.asyncSearch.submit).toHaveBeenCalledWith(
expect.objectContaining({
index: 'log-indices-*',
body: expect.objectContaining({
track_total_hits: false,
terminate_after: 1,
query: {
ids: {
values: ['LOG_ENTRY_ID'],
},
},
runtime_mappings: {
runtime_field: {
type: 'keyword',
script: {
source: 'emit("runtime value")',
},
},
runtime_mappings: {
runtime_field: {
type: 'keyword',
script: {
source: 'emit("runtime value")',
},
},
},
}),
},
}),
},
expect.anything(),
}),
expect.anything()
);
// ensure response content is as expected
expect(response.id).toEqual(expect.any(String));
expect(response.isRunning).toBe(true);
expect(response.isRunning).toBe(false);
});
it('handles subsequent polling requests', async () => {
const date = new Date(1605116827143).toISOString();
const esSearchStrategyMock = createEsSearchStrategyMock({
id: 'ASYNC_REQUEST_ID',
isRunning: false,
rawResponse: {
took: 1,
_shards: { total: 1, failed: 0, skipped: 0, successful: 1 },
timed_out: false,
hits: {
total: 0,
max_score: 0,
hits: [
{
_id: 'HIT_ID',
_index: 'HIT_INDEX',
_score: 0,
_source: null,
fields: {
'@timestamp': [date],
message: ['HIT_MESSAGE'],
const esSearchStrategy = createEsSearchStrategy();
const mockDependencies = createSearchStrategyDependenciesMock();
const esClient = mockDependencies.esClient.asCurrentUser;
// set up response to polling request
esClient.asyncSearch.get.mockResolvedValueOnce({
body: {
id: 'ASYNC_REQUEST_ID',
response: {
took: 0,
_shards: { total: 1, failed: 0, skipped: 0, successful: 0 },
timed_out: false,
hits: {
total: 1,
max_score: 0,
hits: [
{
_id: 'HIT_ID',
_index: 'HIT_INDEX',
_score: 0,
_source: null,
fields: {
'@timestamp': [date],
message: ['HIT_MESSAGE'],
},
sort: [date as any, 1 as any], // incorrectly typed as string upstream
},
sort: [date as any, 1 as any], // incorrectly typed as string upstream
},
],
],
},
},
is_partial: false,
is_running: false,
expiration_time_in_millis: 0,
start_time_in_millis: 0,
},
});
const dataMock = createDataPluginMock(esSearchStrategyMock);
statusCode: 200,
headers: {},
warnings: [],
meta: {} as any,
} as TransportResult<AsyncSearchSubmitResponse> as any);
const dataMock = createDataPluginMock(esSearchStrategy);
const logViewsClientMock = createLogViewsClientMock();
logViewsClientMock.getResolvedLogView.mockResolvedValue(createResolvedLogViewMock());
const logViewsMock = createLogViewsServiceStartMock();
logViewsMock.getScopedClient.mockReturnValue(logViewsClientMock);
const mockDependencies = createSearchStrategyDependenciesMock();
const logEntrySearchStrategy = logEntrySearchStrategyProvider({
data: dataMock,
@ -155,9 +182,18 @@ describe('LogEntry search strategy', () => {
)
);
// ensure search was polled using the get API
expect(esClient.asyncSearch.get).toHaveBeenCalledWith(
expect.objectContaining({ id: 'ASYNC_REQUEST_ID' }),
expect.anything()
);
expect(esClient.asyncSearch.status).not.toHaveBeenCalled();
// ensure log view was not resolved again
expect(logViewsMock.getScopedClient).not.toHaveBeenCalled();
expect(logViewsClientMock.getResolvedLogView).not.toHaveBeenCalled();
expect(esSearchStrategyMock.search).toHaveBeenCalled();
// ensure response content is as expected
expect(response.id).toEqual(requestId);
expect(response.isRunning).toBe(false);
expect(response.rawResponse.data).toEqual({
@ -175,22 +211,30 @@ describe('LogEntry search strategy', () => {
});
it('forwards errors from the underlying search strategy', async () => {
const esSearchStrategyMock = createEsSearchStrategyMock({
id: 'ASYNC_REQUEST_ID',
isRunning: false,
rawResponse: {
took: 1,
_shards: { total: 1, failed: 0, skipped: 0, successful: 1 },
timed_out: false,
hits: { total: 0, max_score: 0, hits: [] },
},
});
const dataMock = createDataPluginMock(esSearchStrategyMock);
const esSearchStrategy = createEsSearchStrategy();
const mockDependencies = createSearchStrategyDependenciesMock();
const esClient = mockDependencies.esClient.asCurrentUser;
// set up failing response
esClient.asyncSearch.get.mockRejectedValueOnce(
new errors.ResponseError({
body: {
error: {
type: 'mock_error',
},
},
headers: {},
meta: {} as any,
statusCode: 404,
warnings: [],
})
);
const dataMock = createDataPluginMock(esSearchStrategy);
const logViewsClientMock = createLogViewsClientMock();
logViewsClientMock.getResolvedLogView.mockResolvedValue(createResolvedLogViewMock());
const logViewsMock = createLogViewsServiceStartMock();
logViewsMock.getScopedClient.mockReturnValue(logViewsClientMock);
const mockDependencies = createSearchStrategyDependenciesMock();
const logEntrySearchStrategy = logEntrySearchStrategyProvider({
data: dataMock,
@ -209,26 +253,24 @@ describe('LogEntry search strategy', () => {
mockDependencies
);
await expect(response.toPromise()).rejects.toThrowError(errors.ResponseError);
await expect(lastValueFrom(response)).rejects.toThrowError(KbnSearchError);
});
it('forwards cancellation to the underlying search strategy', async () => {
const esSearchStrategyMock = createEsSearchStrategyMock({
id: 'ASYNC_REQUEST_ID',
isRunning: false,
rawResponse: {
took: 1,
_shards: { total: 1, failed: 0, skipped: 0, successful: 1 },
timed_out: false,
hits: { total: 0, max_score: 0, hits: [] },
},
const esSearchStrategy = createEsSearchStrategy();
const mockDependencies = createSearchStrategyDependenciesMock();
const esClient = mockDependencies.esClient.asCurrentUser;
// set up response to cancellation request
esClient.asyncSearch.delete.mockResolvedValueOnce({
acknowledged: true,
});
const dataMock = createDataPluginMock(esSearchStrategyMock);
const dataMock = createDataPluginMock(esSearchStrategy);
const logViewsClientMock = createLogViewsClientMock();
logViewsClientMock.getResolvedLogView.mockResolvedValue(createResolvedLogViewMock());
const logViewsMock = createLogViewsServiceStartMock();
logViewsMock.getScopedClient.mockReturnValue(logViewsClientMock);
const mockDependencies = createSearchStrategyDependenciesMock();
const logEntrySearchStrategy = logEntrySearchStrategyProvider({
data: dataMock,
@ -240,34 +282,23 @@ describe('LogEntry search strategy', () => {
await logEntrySearchStrategy.cancel?.(requestId, {}, mockDependencies);
expect(esSearchStrategyMock.cancel).toHaveBeenCalled();
// ensure cancellation request is forwarded
expect(esClient.asyncSearch.delete).toHaveBeenCalledWith(
expect.objectContaining({
id: 'ASYNC_REQUEST_ID',
})
);
});
});
const createEsSearchStrategyMock = (esSearchResponse: IEsSearchResponse) => ({
search: jest.fn((esSearchRequest: IEsSearchRequest) => {
if (typeof esSearchRequest.id === 'string') {
if (esSearchRequest.id === esSearchResponse.id) {
return of(esSearchResponse);
} else {
return throwError(
new errors.ResponseError({
body: {},
headers: {},
meta: {} as any,
statusCode: 404,
warnings: [],
})
);
}
} else {
return of(esSearchResponse);
}
}),
cancel: jest.fn().mockResolvedValue(undefined),
});
const createEsSearchStrategy = () => {
const legacyConfig$ = EMPTY;
const searchConfig = getMockSearchConfig({});
const logger = loggerMock.create();
return enhancedEsSearchStrategyProvider(legacyConfig$, searchConfig, logger);
};
const createSearchStrategyDependenciesMock = (): SearchStrategyDependencies => ({
const createSearchStrategyDependenciesMock = () => ({
uiSettingsClient: uiSettingsServiceMock.createClient(),
esClient: elasticsearchServiceMock.createScopedClusterClient(),
savedObjectsClient: savedObjectsClientMock.create(),

View file

@ -82,7 +82,16 @@ export const logEntrySearchStrategyProvider = ({
return concat(recoveredRequest$, initialRequest$).pipe(
take(1),
concatMap((esRequest) => esSearchStrategy.search(esRequest, options, dependencies)),
concatMap((esRequest) =>
esSearchStrategy.search(
esRequest,
{
...options,
retrieveResults: true, // without it response will not contain progress information
},
dependencies
)
),
map((esResponse) => ({
...esResponse,
rawResponse: decodeOrThrow(getLogEntryResponseRT)(esResponse.rawResponse),