Skip to content

Commit

Permalink
On search source error, show 'view details' action that opens request…
Browse files Browse the repository at this point in the history
… in inspector (#170790)

Closes #167904

PR updates `EsError` with `getActions` method that returns "View
details" button. Clicking "View details" opens inspector to request that
failed. PR updates Discover and maps to display EsError action.

PR does not update lens to display "View details". Chatted with
@drewdaemon and the implementation path is more involved. This will be
completed in another PR.

### Test setup
1. install sample web logs

### Test discover with EsError
1. open discover 
2. Add filter
    ```
    {
      "error_query": {
        "indices": [
          {
            "error_type": "exception",
            "message": "local shard failure message 123",
            "name": "kibana_sample_data_logs"
          }
        ]
      }
    }
    ```
3. Verify `View details` action is displayed and clicking action opens
inspector
<img width="300" alt="Screenshot 2023-11-07 at 12 53 31 PM"
src="https://github.com/elastic/kibana/assets/373691/6b43e9c8-daab-4782-876e-ded6958d15cf">

### Test search embeddable with EsError
1. create new dashboard. Add saved search from `kibana_sample_data_logs`
data view
2. Add filter
    ```
    {
      "error_query": {
        "indices": [
          {
            "error_type": "exception",
            "message": "local shard failure message 123",
            "name": "kibana_sample_data_logs"
          }
        ]
      }
    }
    ```
3. Verify `View details` action is displayed and clicking action opens
inspector
<img width="300" alt="Screenshot 2023-11-07 at 12 55 46 PM"
src="https://github.com/elastic/kibana/assets/373691/5ebe37c6-467a-4d72-89e3-21fc53f59d89">

### Test discover with PainlessError
<img width="300" alt="Screenshot 2023-11-07 at 12 52 51 PM"
src="https://github.com/elastic/kibana/assets/373691/6d17498f-657c-46e8-86e8-dde461599267">

### Test Maps error
1. create new map
2. Add `documents` layer
3. Set scaling to "limit to 10000"
4. Add filter
    ```
    {
      "error_query": {
        "indices": [
          {
            "error_type": "exception",
            "message": "local shard failure message 123",
            "name": "kibana_sample_data_logs"
          }
        ]
      }
    }
    ```
5. Verify "View details" button is displayed in maps legend in error
callout
<img width="500" alt="Screenshot 2023-11-08 at 12 07 42 PM"
src="https://github.com/elastic/kibana/assets/373691/2eb2cc41-0919-49a3-9792-fda9707973cb">

---------

Co-authored-by: kibanamachine <42973632+kibanamachine@users.noreply.github.com>
  • Loading branch information
nreese and kibanamachine authored Nov 14, 2023
1 parent 36c0d8d commit f9870c1
Show file tree
Hide file tree
Showing 31 changed files with 213 additions and 121 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import { ReactWrapper } from 'enzyme';
import { act } from 'react-dom/test-utils';
import { mountWithIntl } from '@kbn/test-jest-helpers';
import { NotFoundPrompt } from '@kbn/shared-ux-prompt-not-found';
import { setStubKibanaServices } from '@kbn/embeddable-plugin/public/mocks';

import { DashboardContainerFactory } from '..';
import { DASHBOARD_CONTAINER_TYPE } from '../..';
Expand Down Expand Up @@ -168,6 +169,9 @@ describe('dashboard renderer', () => {
});

test('renders a 404 page when initial dashboard creation returns a savedObjectNotFound error', async () => {
// mock embeddable dependencies so that the embeddable panel renders
setStubKibanaServices();

// ensure that the first attempt at creating a dashboard results in a 404
const mockErrorEmbeddable = {
error: new SavedObjectNotFound('dashboard', 'gat em'),
Expand Down
4 changes: 2 additions & 2 deletions src/plugins/data/common/search/strategies/ese_search/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,11 +6,11 @@
* Side Public License, v 1.
*/

import { ISearchOptions } from '../../types';
import { SearchSourceSearchOptions } from '../../search_source/types';

export const ENHANCED_ES_SEARCH_STRATEGY = 'ese';

export interface IAsyncSearchOptions extends ISearchOptions {
export interface IAsyncSearchOptions extends SearchSourceSearchOptions {
/**
* The number of milliseconds to wait between receiving a response and sending another request
* If not provided, then a default 1 second interval with back-off up to 5 seconds interval is used
Expand Down
4 changes: 2 additions & 2 deletions src/plugins/data/public/search/errors/es_error.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@ describe('EsError', () => {
},
},
} as IEsError;
const esError = new EsError(error);
const esError = new EsError(error, () => {});

expect(typeof esError.attributes).toEqual('object');
expect(esError.attributes).toEqual(error.attributes);
Expand Down Expand Up @@ -50,7 +50,7 @@ describe('EsError', () => {
},
},
} as IEsError;
const esError = new EsError(error);
const esError = new EsError(error, () => {});
expect(esError.message).toEqual(
'EsError: The supplied interval [2q] could not be parsed as a calendar interval.'
);
Expand Down
16 changes: 13 additions & 3 deletions src/plugins/data/public/search/errors/es_error.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@
*/

import React from 'react';
import { EuiCodeBlock, EuiSpacer } from '@elastic/eui';
import { EuiButton, EuiCodeBlock, EuiSpacer } from '@elastic/eui';
import { i18n } from '@kbn/i18n';
import { ApplicationStart } from '@kbn/core/public';
import { KbnError } from '@kbn/kibana-utils-plugin/common';
Expand All @@ -17,7 +17,7 @@ import { getRootCause } from './utils';
export class EsError extends KbnError {
readonly attributes: IEsError['attributes'];

constructor(protected readonly err: IEsError) {
constructor(protected readonly err: IEsError, private readonly openInInspector: () => void) {
super(
`EsError: ${
getRootCause(err?.attributes?.error)?.reason ||
Expand All @@ -27,7 +27,7 @@ export class EsError extends KbnError {
this.attributes = err.attributes;
}

public getErrorMessage(application: ApplicationStart) {
public getErrorMessage() {
if (!this.attributes?.error) {
return null;
}
Expand All @@ -45,4 +45,14 @@ export class EsError extends KbnError {
</>
);
}

public getActions(application: ApplicationStart) {
return [
<EuiButton key="viewRequestDetails" color="primary" onClick={this.openInInspector} size="s">
{i18n.translate('data.esError.viewDetailsButtonLabel', {
defaultMessage: 'View details',
})}
</EuiButton>,
];
}
}
20 changes: 12 additions & 8 deletions src/plugins/data/public/search/errors/painless_error.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -20,14 +20,17 @@ describe('PainlessError', () => {
});

it('Should show reason and code', () => {
const e = new PainlessError({
statusCode: 400,
message: 'search_phase_execution_exception',
attributes: {
error: searchPhaseException.error,
const e = new PainlessError(
{
statusCode: 400,
message: 'search_phase_execution_exception',
attributes: {
error: searchPhaseException.error,
},
},
});
const component = mount(e.getErrorMessage(startMock.application));
() => {}
);
const component = mount(e.getErrorMessage());

const failedShards = searchPhaseException.error.failed_shards![0];

Expand All @@ -41,6 +44,7 @@ describe('PainlessError', () => {
).getDOMNode();
expect(humanReadableError.textContent).toBe(failedShards?.reason.caused_by?.reason);

expect(component.find('EuiButton').length).toBe(1);
const actions = e.getActions(startMock.application);
expect(actions.length).toBe(2);
});
});
49 changes: 28 additions & 21 deletions src/plugins/data/public/search/errors/painless_error.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,8 +8,7 @@

import React from 'react';
import { i18n } from '@kbn/i18n';
import { EuiButton, EuiSpacer, EuiText, EuiCodeBlock } from '@elastic/eui';
import { FormattedMessage } from '@kbn/i18n-react';
import { EuiButtonEmpty, EuiSpacer, EuiText, EuiCodeBlock } from '@elastic/eui';
import { ApplicationStart } from '@kbn/core/public';
import type { DataView } from '@kbn/data-views-plugin/common';
import { IEsError, isEsError } from './types';
Expand All @@ -19,18 +18,12 @@ import { getRootCause } from './utils';
export class PainlessError extends EsError {
painlessStack?: string;
indexPattern?: DataView;
constructor(err: IEsError, indexPattern?: DataView) {
super(err);
constructor(err: IEsError, openInInspector: () => void, indexPattern?: DataView) {
super(err, openInInspector);
this.indexPattern = indexPattern;
}

public getErrorMessage(application: ApplicationStart) {
function onClick(indexPatternId?: string) {
application.navigateToApp('management', {
path: `/kibana/indexPatterns${indexPatternId ? `/patterns/${indexPatternId}` : ''}`,
});
}

public getErrorMessage() {
const rootCause = getRootCause(this.err.attributes?.error);
const scriptFromStackTrace = rootCause?.script_stack
? rootCause?.script_stack?.slice(-2).join('\n')
Expand All @@ -41,7 +34,6 @@ export class PainlessError extends EsError {
// fallback, show ES stacktrace
const painlessStack = rootCause?.script_stack ? rootCause?.script_stack.join('\n') : undefined;

const indexPatternId = this?.indexPattern?.id;
return (
<>
<EuiText size="s" data-test-subj="painlessScript">
Expand All @@ -54,25 +46,40 @@ export class PainlessError extends EsError {
})}
</EuiText>
<EuiSpacer size="s" />
<EuiSpacer size="s" />
{scriptFromStackTrace || painlessStack ? (
<EuiCodeBlock data-test-subj="painlessStackTrace" isCopyable={true} paddingSize="s">
{hasScript ? scriptFromStackTrace : painlessStack}
</EuiCodeBlock>
) : null}
{humanReadableError ? (
<EuiText data-test-subj="painlessHumanReadableError">{humanReadableError}</EuiText>
<EuiText size="s" data-test-subj="painlessHumanReadableError">
{humanReadableError}
</EuiText>
) : null}
<EuiSpacer size="s" />
<EuiSpacer size="s" />
<EuiText textAlign="right">
<EuiButton color="danger" onClick={() => onClick(indexPatternId)} size="s">
<FormattedMessage id="data.painlessError.buttonTxt" defaultMessage="Edit script" />
</EuiButton>
</EuiText>
</>
);
}

getActions(application: ApplicationStart) {
function onClick(indexPatternId?: string) {
application.navigateToApp('management', {
path: `/kibana/indexPatterns${indexPatternId ? `/patterns/${indexPatternId}` : ''}`,
});
}
const actions = super.getActions(application) ?? [];
actions.push(
<EuiButtonEmpty
key="editPainlessScript"
onClick={() => onClick(this?.indexPattern?.id)}
size="s"
>
{i18n.translate('data.painlessError.buttonTxt', {
defaultMessage: 'Edit script',
})}
</EuiButtonEmpty>
);
return actions;
}
}

export function isPainlessError(err: Error | IEsError) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,8 @@ import { BehaviorSubject } from 'rxjs';
import { dataPluginMock } from '../../mocks';
import { UI_SETTINGS } from '../../../common';
import type { IEsError } from '../errors';
import type { SearchServiceStartDependencies } from '../search_service';
import type { Start as InspectorStart } from '@kbn/inspector-plugin/public';

jest.mock('./utils', () => {
const originalModule = jest.requireActual('./utils');
Expand Down Expand Up @@ -117,13 +119,21 @@ describe('SearchInterceptor', () => {
const bfetchMock = bfetchPluginMock.createSetupContract();
bfetchMock.batchedFunction.mockReturnValue(fetchMock);

const inspectorServiceMock = {
open: () => {},
} as unknown as InspectorStart;

bfetchSetup = bfetchPluginMock.createSetupContract();
bfetchSetup.batchedFunction.mockReturnValue(fetchMock);
searchInterceptor = new SearchInterceptor({
bfetch: bfetchSetup,
toasts: mockCoreSetup.notifications.toasts,
startServices: new Promise((resolve) => {
resolve([mockCoreStart, {}, {}]);
resolve([
mockCoreStart,
{ inspector: inspectorServiceMock } as unknown as SearchServiceStartDependencies,
{},
]);
}),
uiSettings: mockCoreSetup.uiSettings,
http: mockCoreSetup.http,
Expand All @@ -149,13 +159,16 @@ describe('SearchInterceptor', () => {

test('Renders a PainlessError', async () => {
searchInterceptor.showError(
new PainlessError({
statusCode: 400,
message: 'search_phase_execution_exception',
attributes: {
error: searchPhaseException.error,
new PainlessError(
{
statusCode: 400,
message: 'search_phase_execution_exception',
attributes: {
error: searchPhaseException.error,
},
},
})
() => {}
)
);
expect(mockCoreSetup.notifications.toasts.addDanger).toBeCalledTimes(1);
expect(mockCoreSetup.notifications.toasts.addError).not.toBeCalled();
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
* Side Public License, v 1.
*/

import { v4 as uuidv4 } from 'uuid';
import { memoize, once } from 'lodash';
import {
BehaviorSubject,
Expand All @@ -29,9 +30,12 @@ import {
takeUntil,
tap,
} from 'rxjs/operators';
import { estypes } from '@elastic/elasticsearch';
import { i18n } from '@kbn/i18n';
import { PublicMethodsOf } from '@kbn/utility-types';
import type { HttpSetup, IHttpFetchError } from '@kbn/core-http-browser';
import { BfetchRequestError } from '@kbn/bfetch-plugin/public';
import { type Start as InspectorStart, RequestAdapter } from '@kbn/inspector-plugin/public';

import {
ApplicationStart,
Expand Down Expand Up @@ -73,13 +77,14 @@ import { SearchResponseCache } from './search_response_cache';
import { createRequestHash, getSearchErrorOverrideDisplay } from './utils';
import { SearchAbortController } from './search_abort_controller';
import { SearchConfigSchema } from '../../../config';
import type { SearchServiceStartDependencies } from '../search_service';

export interface SearchInterceptorDeps {
bfetch: BfetchPublicSetup;
http: HttpSetup;
executionContext: ExecutionContextSetup;
uiSettings: IUiSettingsClient;
startServices: Promise<[CoreStart, any, unknown]>;
startServices: Promise<[CoreStart, object, unknown]>;
toasts: ToastsSetup;
usageCollector?: SearchUsageCollector;
session: ISessionService;
Expand Down Expand Up @@ -114,16 +119,18 @@ export class SearchInterceptor {
{ request: IKibanaSearchRequest; options: ISearchOptionsSerializable },
IKibanaSearchResponse
>;
private inspector!: InspectorStart;

/*
* @internal
*/
constructor(private readonly deps: SearchInterceptorDeps) {
this.deps.http.addLoadingCountSource(this.pendingCount$);

this.deps.startServices.then(([coreStart]) => {
this.deps.startServices.then(([coreStart, depsStart]) => {
this.application = coreStart.application;
this.docLinks = coreStart.docLinks;
this.inspector = (depsStart as SearchServiceStartDependencies).inspector;
});

this.batchedFetch = deps.bfetch.batchedFunction({
Expand Down Expand Up @@ -184,7 +191,8 @@ export class SearchInterceptor {
*/
private handleSearchError(
e: KibanaServerError | AbortError,
options?: ISearchOptions,
requestBody: estypes.SearchRequest,
options?: IAsyncSearchOptions,
isTimeout?: boolean
): Error {
if (isTimeout || e.message === 'Request timed out') {
Expand All @@ -203,7 +211,36 @@ export class SearchInterceptor {
}

if (isEsError(e)) {
return isPainlessError(e) ? new PainlessError(e, options?.indexPattern) : new EsError(e);
const openInInspector = () => {
const requestId = options?.inspector?.id ?? uuidv4();
const requestAdapter = options?.inspector?.adapter ?? new RequestAdapter();
if (!options?.inspector?.adapter) {
const requestResponder = requestAdapter.start(
i18n.translate('data.searchService.anonymousRequestTitle', {
defaultMessage: 'Request',
}),
{
id: requestId,
}
);
requestResponder.json(requestBody);
requestResponder.error({ json: e.attributes });
}
this.inspector.open(
{
requests: requestAdapter,
},
{
options: {
initialRequestId: requestId,
initialTabs: ['clusters', 'response'],
},
}
);
};
return isPainlessError(e)
? new PainlessError(e, openInInspector, options?.indexPattern)
: new EsError(e, openInInspector);
}

return e instanceof Error ? e : new Error(e.message);
Expand Down Expand Up @@ -473,7 +510,12 @@ export class SearchInterceptor {
takeUntil(aborted$),
catchError((e) => {
return throwError(
this.handleSearchError(e, searchOptions, searchAbortController.isTimeout())
this.handleSearchError(
e,
request?.params?.body ?? {},
searchOptions,
searchAbortController.isTimeout()
)
);
}),
tap((response) => {
Expand Down
Loading

0 comments on commit f9870c1

Please sign in to comment.