Alert creation and freeform selection (#111883)

Allow selecting any service name, transaction type (where appropriate), and environment when creating and editing rules, both in APM and Stack Management.

- Create /internal/apm/suggestions endpoint that uses `terms_enum`
- Use combo box for environment, service name, and transaction type with suggestions endpoint on all alerts
- Remove "Go to APM" callouts on new alert creation
- Wrap calls to `createCallApmApi` in alert triggers with `useEffect`
- Use `getEnvironmentLabel` for value in environment field expression
- Make all `AlertParams` fields optional (except in latency threshold alert)
- Add e2e tests for creating an alert
- Remove `NewAlertEmptyPrompt` component and `isNewApmRuleFromStackManagement` helper
- Replace `maxServiceEnvironments` and `maxServiceSelections` config options with `maxSuggestions` advanced setting.


![CleanShot 2021-09-28 at 10 35 58](https://user-images.githubusercontent.com/9912/135119948-e247615a-d235-4feb-b197-b803f165ad1e.gif)

Fixes #106786
This commit is contained in:
Nathan L Smith 2021-09-29 11:18:44 -05:00 committed by GitHub
parent c7e06ab7d3
commit f4a95f9b97
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
48 changed files with 1062 additions and 394 deletions

View file

@ -408,12 +408,12 @@ export const stackManagementSchema: MakeSchemaFrom<UsageStats> = {
type: 'text',
_meta: { description: 'Non-default value of setting.' },
},
'apm:enableSignificantTerms': {
'observability:enableInspectEsQueries': {
type: 'boolean',
_meta: { description: 'Non-default value of setting.' },
},
'observability:enableInspectEsQueries': {
type: 'boolean',
'observability:maxSuggestions': {
type: 'integer',
_meta: { description: 'Non-default value of setting.' },
},
'banners:placement': {

View file

@ -34,8 +34,8 @@ export interface UsageStats {
'discover:showMultiFields': boolean;
'discover:maxDocFieldsDisplayed': number;
'securitySolution:rulesTableRefresh': string;
'apm:enableSignificantTerms': boolean;
'observability:enableInspectEsQueries': boolean;
'observability:maxSuggestions': number;
'visualize:enableLabs': boolean;
'visualization:heatmap:maxBuckets': number;
'visualization:colorMapping': string;

View file

@ -7629,14 +7629,14 @@
"description": "Non-default value of setting."
}
},
"apm:enableSignificantTerms": {
"observability:enableInspectEsQueries": {
"type": "boolean",
"_meta": {
"description": "Non-default value of setting."
}
},
"observability:enableInspectEsQueries": {
"type": "boolean",
"observability:maxSuggestions": {
"type": "integer",
"_meta": {
"description": "Non-default value of setting."
}

View file

@ -1,14 +0,0 @@
## Feature flags
To set up a flagged feature, add the name of the feature key (`apm:myFeature`) to [commmon/ui_settings_keys.ts](./common/ui_settings_keys.ts) and the feature parameters to [server/ui_settings.ts](./server/ui_settings.ts).
Test for the feature like:
```js
import { myFeatureEnabled } from '../ui_settings_keys';
if (core.uiSettings.get(myFeatureEnabled)) {
doStuff();
}
```
Settings can be managed in Kibana under Stack Management > Advanced Settings > Observability.

View file

@ -0,0 +1,108 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
describe('Rules', () => {
describe('Error count', () => {
const ruleName = 'Error count threshold';
const comboBoxInputSelector =
'.euiPopover__panel-isOpen [data-test-subj=comboBoxSearchInput]';
const confirmModalButtonSelector =
'.euiModal button[data-test-subj=confirmModalConfirmButton]';
const deleteButtonSelector =
'[data-test-subj=deleteActionHoverButton]:first';
const editButtonSelector = '[data-test-subj=editActionHoverButton]:first';
describe('when created from APM', () => {
describe('when created from Service Inventory', () => {
before(() => {
cy.loginAsPowerUser();
});
it('creates and updates a rule', () => {
// Create a rule in APM
cy.visit('/app/apm/services');
cy.contains('Alerts and rules').click();
cy.contains('Error count').click();
cy.contains('Create threshold rule').click();
// Change the environment to "testing"
cy.contains('Environment All').click();
cy.get(comboBoxInputSelector).type('testing{enter}');
// Save, with no actions
cy.contains('button:not(:disabled)', 'Save').click();
cy.get(confirmModalButtonSelector).click();
cy.contains(`Created rule "${ruleName}`);
// Go to Stack Management
cy.contains('Alerts and rules').click();
cy.contains('Manage rules').click();
// Edit the rule, changing the environment to "All"
cy.get(editButtonSelector).click();
cy.contains('Environment testing').click();
cy.get(comboBoxInputSelector).type('All{enter}');
cy.contains('button:not(:disabled)', 'Save').click();
cy.contains(`Updated '${ruleName}'`);
// Wait for the table to be ready for next edit click
cy.get('.euiBasicTable').not('.euiBasicTable-loading');
// Ensure the rule now shows "All" for the environment
cy.get(editButtonSelector).click();
cy.contains('Environment All');
cy.contains('button', 'Cancel').click();
// Delete the rule
cy.get(deleteButtonSelector).click();
cy.get(confirmModalButtonSelector).click();
// Ensure the table is empty
cy.contains('Create your first rule');
});
});
});
describe('when created from Stack management', () => {
before(() => {
cy.loginAsPowerUser();
});
it('creates a rule', () => {
// Go to stack management
cy.visit('/app/management/insightsAndAlerting/triggersActions/rules');
// Create a rule
cy.contains('button', 'Create rule').click();
cy.get('[name=name]').type(ruleName);
cy.contains('.euiFlyout button', ruleName).click();
// Change the environment to "testing"
cy.contains('Environment All').click();
cy.get(comboBoxInputSelector).type('testing{enter}');
// Save, with no actions
cy.contains('button:not(:disabled)', 'Save').click();
cy.get(confirmModalButtonSelector).click();
cy.contains(`Created rule "${ruleName}`);
// Wait for the table to be ready for next delete click
cy.get('.euiBasicTable').not('.euiBasicTable-loading');
// Delete the rule
cy.get(deleteButtonSelector).click();
cy.get(confirmModalButtonSelector).click();
// Ensure the table is empty
cy.contains('Create your first rule');
});
});
});
});

View file

@ -0,0 +1,152 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import { Meta, Story } from '@storybook/react';
import React, { useState } from 'react';
import { AlertParams, ErrorCountAlertTrigger } from '.';
import { CoreStart } from '../../../../../../../src/core/public';
import { createKibanaReactContext } from '../../../../../../../src/plugins/kibana_react/public';
import { ENVIRONMENT_ALL } from '../../../../common/environment_filter_values';
import { AlertMetadata } from '../helper';
const KibanaReactContext = createKibanaReactContext({
notifications: { toasts: { add: () => {} } },
} as unknown as Partial<CoreStart>);
interface Args {
alertParams: AlertParams;
metadata?: AlertMetadata;
}
const stories: Meta<{}> = {
title: 'alerting/ErrorCountAlertTrigger',
component: ErrorCountAlertTrigger,
decorators: [
(StoryComponent) => {
return (
<KibanaReactContext.Provider>
<div style={{ width: 400 }}>
<StoryComponent />
</div>
</KibanaReactContext.Provider>
);
},
],
};
export default stories;
export const CreatingInApmFromInventory: Story<Args> = ({
alertParams,
metadata,
}) => {
const [params, setParams] = useState<AlertParams>(alertParams);
function setAlertParams(property: string, value: any) {
setParams({ ...params, [property]: value });
}
return (
<ErrorCountAlertTrigger
alertParams={params}
metadata={metadata}
setAlertParams={setAlertParams}
setAlertProperty={() => {}}
/>
);
};
CreatingInApmFromInventory.args = {
alertParams: {},
metadata: {
end: '2021-09-10T14:14:04.789Z',
environment: ENVIRONMENT_ALL.value,
serviceName: undefined,
start: '2021-09-10T13:59:00.000Z',
},
};
export const CreatingInApmFromService: Story<Args> = ({
alertParams,
metadata,
}) => {
const [params, setParams] = useState<AlertParams>(alertParams);
function setAlertParams(property: string, value: any) {
setParams({ ...params, [property]: value });
}
return (
<ErrorCountAlertTrigger
alertParams={params}
metadata={metadata}
setAlertParams={setAlertParams}
setAlertProperty={() => {}}
/>
);
};
CreatingInApmFromService.args = {
alertParams: {},
metadata: {
end: '2021-09-10T14:14:04.789Z',
environment: 'testEnvironment',
serviceName: 'testServiceName',
start: '2021-09-10T13:59:00.000Z',
},
};
export const EditingInStackManagement: Story<Args> = ({
alertParams,
metadata,
}) => {
const [params, setParams] = useState<AlertParams>(alertParams);
function setAlertParams(property: string, value: any) {
setParams({ ...params, [property]: value });
}
return (
<ErrorCountAlertTrigger
alertParams={params}
metadata={metadata}
setAlertParams={setAlertParams}
setAlertProperty={() => {}}
/>
);
};
EditingInStackManagement.args = {
alertParams: {
environment: 'testEnvironment',
serviceName: 'testServiceName',
threshold: 25,
windowSize: 1,
windowUnit: 'm',
},
metadata: undefined,
};
export const CreatingInStackManagement: Story<Args> = ({
alertParams,
metadata,
}) => {
const [params, setParams] = useState<AlertParams>(alertParams);
function setAlertParams(property: string, value: any) {
setParams({ ...params, [property]: value });
}
return (
<ErrorCountAlertTrigger
alertParams={params}
metadata={metadata}
setAlertParams={setAlertParams}
setAlertProperty={() => {}}
/>
);
};
CreatingInStackManagement.args = {
alertParams: {},
metadata: undefined,
};

View file

@ -0,0 +1,19 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import { render } from '@testing-library/react';
import React from 'react';
import * as stories from './error_count_alert_trigger.stories';
import { composeStories } from '@storybook/testing-react';
const { CreatingInApmFromService } = composeStories(stories);
describe('ErrorCountAlertTrigger', () => {
it('renders', () => {
expect(() => render(<CreatingInApmFromService />)).not.toThrowError();
});
});

View file

@ -1,51 +0,0 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import React, { useState } from 'react';
import { AlertParams, ErrorCountAlertTrigger } from '.';
import { CoreStart } from '../../../../../../../src/core/public';
import { createKibanaReactContext } from '../../../../../../../src/plugins/kibana_react/public';
const KibanaReactContext = createKibanaReactContext({
notifications: { toasts: { add: () => {} } },
} as unknown as Partial<CoreStart>);
export default {
title: 'alerting/ErrorCountAlertTrigger',
component: ErrorCountAlertTrigger,
decorators: [
(Story: React.ComponentClass) => (
<KibanaReactContext.Provider>
<div style={{ width: 400 }}>
<Story />
</div>
</KibanaReactContext.Provider>
),
],
};
export function Example() {
const [params, setParams] = useState<AlertParams>({
serviceName: 'testServiceName',
environment: 'testEnvironment',
threshold: 2,
windowSize: 5,
windowUnit: 'm',
});
function setAlertParams(property: string, value: any) {
setParams({ ...params, [property]: value });
}
return (
<ErrorCountAlertTrigger
alertParams={params}
setAlertParams={setAlertParams}
setAlertProperty={() => {}}
/>
);
}

View file

@ -7,29 +7,25 @@
import { i18n } from '@kbn/i18n';
import { defaults, omit } from 'lodash';
import React from 'react';
import React, { useEffect } from 'react';
import { CoreStart } from '../../../../../../../src/core/public';
import { useKibana } from '../../../../../../../src/plugins/kibana_react/public';
import { ForLastExpression } from '../../../../../triggers_actions_ui/public';
import { ENVIRONMENT_ALL } from '../../../../common/environment_filter_values';
import { asInteger } from '../../../../common/utils/formatters';
import { useEnvironmentsFetcher } from '../../../hooks/use_environments_fetcher';
import { useFetcher } from '../../../hooks/use_fetcher';
import { createCallApmApi } from '../../../services/rest/createCallApmApi';
import { ChartPreview } from '../chart_preview';
import { EnvironmentField, IsAboveField, ServiceField } from '../fields';
import {
AlertMetadata,
getIntervalAndTimeRange,
isNewApmRuleFromStackManagement,
TimeUnit,
} from '../helper';
import { NewAlertEmptyPrompt } from '../new_alert_empty_prompt';
import { AlertMetadata, getIntervalAndTimeRange, TimeUnit } from '../helper';
import { ServiceAlertTrigger } from '../service_alert_trigger';
export interface AlertParams {
windowSize: number;
windowUnit: string;
threshold: number;
serviceName: string;
environment: string;
windowSize?: number;
windowUnit?: TimeUnit;
threshold?: number;
serviceName?: string;
environment?: string;
}
interface Props {
@ -40,13 +36,12 @@ interface Props {
}
export function ErrorCountAlertTrigger(props: Props) {
const { services } = useKibana();
const { alertParams, metadata, setAlertParams, setAlertProperty } = props;
const { environmentOptions } = useEnvironmentsFetcher({
serviceName: metadata?.serviceName,
start: metadata?.start,
end: metadata?.end,
});
useEffect(() => {
createCallApmApi(services as CoreStart);
}, [services]);
const params = defaults(
{ ...omit(metadata, ['start', 'end']), ...alertParams },
@ -87,16 +82,14 @@ export function ErrorCountAlertTrigger(props: Props) {
]
);
if (isNewApmRuleFromStackManagement(alertParams, metadata)) {
return <NewAlertEmptyPrompt />;
}
const fields = [
<ServiceField value={params.serviceName} />,
<ServiceField
currentValue={params.serviceName}
onChange={(value) => setAlertParams('serviceName', value)}
/>,
<EnvironmentField
currentValue={params.environment}
options={environmentOptions}
onChange={(e) => setAlertParams('environment', e.target.value)}
onChange={(value) => setAlertParams('environment', value)}
/>,
<IsAboveField
value={params.threshold}

View file

@ -7,56 +7,35 @@
import React from 'react';
import { ServiceField, TransactionTypeField } from './fields';
import { act, fireEvent, render } from '@testing-library/react';
import { render } from '@testing-library/react';
import { expectTextsInDocument } from '../../utils/testHelpers';
describe('alerting fields', () => {
describe('Service Field', () => {
it('renders with value', () => {
const component = render(<ServiceField value="foo" />);
const component = render(
<ServiceField currentValue="foo" onChange={() => {}} />
);
expectTextsInDocument(component, ['foo']);
});
it('renders with All when value is not defined', () => {
const component = render(<ServiceField />);
const component = render(<ServiceField onChange={() => {}} />);
expectTextsInDocument(component, ['All']);
});
});
describe('Transaction Type Field', () => {
it('renders select field when multiple options available', () => {
const options = [
{ text: 'Foo', value: 'foo' },
{ text: 'Bar', value: 'bar' },
];
const { getByText, getByTestId } = render(
<TransactionTypeField currentValue="Foo" options={options} />
);
act(() => {
fireEvent.click(getByText('Foo'));
});
const selectBar = getByTestId('transactionTypeField');
expect(selectBar instanceof HTMLSelectElement).toBeTruthy();
const selectOptions = (selectBar as HTMLSelectElement).options;
expect(selectOptions.length).toEqual(2);
expect(
Object.values(selectOptions).map((option) => option.value)
).toEqual(['foo', 'bar']);
});
it('renders read-only field when single option available', () => {
const options = [{ text: 'Bar', value: 'bar' }];
describe('TransactionTypeField', () => {
it('renders', () => {
const component = render(
<TransactionTypeField currentValue="Bar" options={options} />
<TransactionTypeField currentValue="Bar" onChange={() => {}} />
);
expectTextsInDocument(component, ['Bar']);
});
it('renders read-only All option when no option available', () => {
const component = render(<TransactionTypeField currentValue="" />);
expectTextsInDocument(component, ['All']);
});
it('renders current value when available', () => {
const component = render(<TransactionTypeField currentValue="foo" />);
const component = render(
<TransactionTypeField currentValue="foo" onChange={() => {}} />
);
expectTextsInDocument(component, ['foo']);
});
});

View file

@ -5,59 +5,96 @@
* 2.0.
*/
import { EuiSelect, EuiExpression, EuiFieldNumber } from '@elastic/eui';
import React from 'react';
import { EuiComboBoxOptionOption, EuiFieldNumber } from '@elastic/eui';
import { i18n } from '@kbn/i18n';
import { EuiSelectOption } from '@elastic/eui';
import React from 'react';
import {
SERVICE_ENVIRONMENT,
SERVICE_NAME,
TRANSACTION_TYPE,
} from '../../../common/elasticsearch_fieldnames';
import {
ENVIRONMENT_ALL,
getEnvironmentLabel,
} from '../../../common/environment_filter_values';
import { SuggestionsSelect } from '../shared/suggestions_select';
import { PopoverExpression } from './service_alert_trigger/popover_expression';
const ALL_OPTION = i18n.translate('xpack.apm.alerting.fields.all_option', {
const allOptionText = i18n.translate('xpack.apm.alerting.fields.allOption', {
defaultMessage: 'All',
});
const allOption: EuiComboBoxOptionOption<string> = {
label: allOptionText,
value: allOptionText,
};
const environmentAllOption: EuiComboBoxOptionOption<string> = {
label: ENVIRONMENT_ALL.text,
value: ENVIRONMENT_ALL.value,
};
export function ServiceField({ value }: { value?: string }) {
export function ServiceField({
allowAll = true,
currentValue,
onChange,
}: {
allowAll?: boolean;
currentValue?: string;
onChange: (value?: string) => void;
}) {
return (
<EuiExpression
description={i18n.translate('xpack.apm.alerting.fields.service', {
<PopoverExpression
value={currentValue || allOption.value}
title={i18n.translate('xpack.apm.alerting.fields.service', {
defaultMessage: 'Service',
})}
value={value || ALL_OPTION}
/>
>
<SuggestionsSelect
allOption={allowAll ? allOption : undefined}
customOptionText={i18n.translate(
'xpack.apm.serviceNamesSelectCustomOptionText',
{
defaultMessage: 'Add \\{searchValue\\} as a new service name',
}
)}
defaultValue={currentValue}
field={SERVICE_NAME}
onChange={onChange}
placeholder={i18n.translate('xpack.apm.serviceNamesSelectPlaceholder', {
defaultMessage: 'Select service name',
})}
/>
</PopoverExpression>
);
}
export function EnvironmentField({
currentValue,
options,
onChange,
}: {
currentValue: string;
options: EuiSelectOption[];
onChange: (event: React.ChangeEvent<HTMLSelectElement>) => void;
onChange: (value?: string) => void;
}) {
const title = i18n.translate('xpack.apm.alerting.fields.environment', {
defaultMessage: 'Environment',
});
if (options.length === 1) {
return (
<EuiExpression
description={title}
value={currentValue || ENVIRONMENT_ALL.text}
/>
);
}
return (
<PopoverExpression value={getEnvironmentLabel(currentValue)} title={title}>
<EuiSelect
defaultValue={currentValue}
options={options}
<PopoverExpression
value={getEnvironmentLabel(currentValue)}
title={i18n.translate('xpack.apm.alerting.fields.environment', {
defaultMessage: 'Environment',
})}
>
<SuggestionsSelect
allOption={environmentAllOption}
customOptionText={i18n.translate(
'xpack.apm.environmentsSelectCustomOptionText',
{
defaultMessage: 'Add \\{searchValue\\} as a new environment',
}
)}
defaultValue={getEnvironmentLabel(currentValue)}
field={SERVICE_ENVIRONMENT}
onChange={onChange}
compressed
placeholder={i18n.translate('xpack.apm.environmentsSelectPlaceholder', {
defaultMessage: 'Select environment',
})}
/>
</PopoverExpression>
);
@ -65,31 +102,33 @@ export function EnvironmentField({
export function TransactionTypeField({
currentValue,
options,
onChange,
}: {
currentValue?: string;
options?: EuiSelectOption[];
onChange?: (event: React.ChangeEvent<HTMLSelectElement>) => void;
onChange: (value?: string) => void;
}) {
const label = i18n.translate('xpack.apm.alerting.fields.type', {
defaultMessage: 'Type',
});
if (!options || options.length <= 1) {
return (
<EuiExpression description={label} value={currentValue || ALL_OPTION} />
);
}
return (
<PopoverExpression value={currentValue} title={label}>
<EuiSelect
data-test-subj="transactionTypeField"
<PopoverExpression value={currentValue || allOption.value} title={label}>
<SuggestionsSelect
allOption={allOption}
customOptionText={i18n.translate(
'xpack.apm.transactionTypesSelectCustomOptionText',
{
defaultMessage: 'Add \\{searchValue\\} as a new transaction type',
}
)}
defaultValue={currentValue}
options={options}
field={TRANSACTION_TYPE}
onChange={onChange}
compressed
placeholder={i18n.translate(
'xpack.apm.transactionTypesSelectPlaceholder',
{
defaultMessage: 'Select transaction type',
}
)}
/>
</PopoverExpression>
);

View file

@ -36,14 +36,3 @@ export function getIntervalAndTimeRange({
end: new Date(end).toISOString(),
};
}
export function isNewApmRuleFromStackManagement(
alertParams: any,
metadata?: AlertMetadata
) {
return (
alertParams !== undefined &&
Object.keys(alertParams).length === 0 &&
metadata === undefined
);
}

View file

@ -1,47 +0,0 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
/* eslint-disable @elastic/eui/href-or-on-click */
import { EuiButton, EuiEmptyPrompt } from '@elastic/eui';
import { i18n } from '@kbn/i18n';
import React, { MouseEvent } from 'react';
import { useKibana } from '../../../../../../src/plugins/kibana_react/public';
export function NewAlertEmptyPrompt() {
const { services } = useKibana();
const apmUrl = services.http?.basePath.prepend('/app/apm');
const navigateToUrl = services.application?.navigateToUrl;
const handleClick = (event: MouseEvent<HTMLAnchorElement>) => {
event.preventDefault();
if (apmUrl && navigateToUrl) {
navigateToUrl(apmUrl);
}
};
return (
<EuiEmptyPrompt
iconType="alert"
body={i18n.translate('xpack.apm.NewAlertEmptyPrompt.bodyDescription', {
defaultMessage:
'APM rules cannot be created in Stack Management. Go to APM and use the "Alerts and rules" menu.',
})}
actions={[
<EuiButton
color="primary"
fill={true}
href={apmUrl}
onClick={handleClick}
>
{i18n.translate('xpack.apm.NewAlertEmptyPrompt.goToApmLinkText', {
defaultMessage: 'Go to APM',
})}
</EuiButton>,
]}
/>
);
}

View file

@ -8,14 +8,12 @@
import { EuiSelect } from '@elastic/eui';
import { i18n } from '@kbn/i18n';
import { defaults, map, omit } from 'lodash';
import React from 'react';
import React, { useEffect } from 'react';
import { CoreStart } from '../../../../../../../src/core/public';
import { useKibana } from '../../../../../../../src/plugins/kibana_react/public';
import { ForLastExpression } from '../../../../../triggers_actions_ui/public';
import { ENVIRONMENT_ALL } from '../../../../common/environment_filter_values';
import { getDurationFormatter } from '../../../../common/utils/formatters';
import { useServiceTransactionTypesFetcher } from '../../../context/apm_service/use_service_transaction_types_fetcher';
import { useEnvironmentsFetcher } from '../../../hooks/use_environments_fetcher';
import { useFetcher } from '../../../hooks/use_fetcher';
import { createCallApmApi } from '../../../services/rest/createCallApmApi';
import {
@ -29,13 +27,7 @@ import {
ServiceField,
TransactionTypeField,
} from '../fields';
import {
AlertMetadata,
getIntervalAndTimeRange,
isNewApmRuleFromStackManagement,
TimeUnit,
} from '../helper';
import { NewAlertEmptyPrompt } from '../new_alert_empty_prompt';
import { AlertMetadata, getIntervalAndTimeRange, TimeUnit } from '../helper';
import { ServiceAlertTrigger } from '../service_alert_trigger';
import { PopoverExpression } from '../service_alert_trigger/popover_expression';
@ -81,13 +73,9 @@ export function TransactionDurationAlertTrigger(props: Props) {
const { services } = useKibana();
const { alertParams, metadata, setAlertParams, setAlertProperty } = props;
createCallApmApi(services as CoreStart);
const transactionTypes = useServiceTransactionTypesFetcher({
serviceName: metadata?.serviceName,
start: metadata?.start,
end: metadata?.end,
});
useEffect(() => {
createCallApmApi(services as CoreStart);
}, [services]);
const params = defaults(
{
@ -100,16 +88,9 @@ export function TransactionDurationAlertTrigger(props: Props) {
windowSize: 5,
windowUnit: 'm',
environment: ENVIRONMENT_ALL.value,
transactionType: transactionTypes[0],
}
);
const { environmentOptions } = useEnvironmentsFetcher({
serviceName: params.serviceName,
start: metadata?.start,
end: metadata?.end,
});
const { data } = useFetcher(
(callApmApi) => {
const { interval, start, end } = getIntervalAndTimeRange({
@ -160,25 +141,19 @@ export function TransactionDurationAlertTrigger(props: Props) {
/>
);
if (isNewApmRuleFromStackManagement(alertParams, metadata)) {
return <NewAlertEmptyPrompt />;
}
if (!params.serviceName) {
return null;
}
const fields = [
<ServiceField value={params.serviceName} />,
<ServiceField
allowAll={false}
currentValue={params.serviceName}
onChange={(value) => setAlertParams('serviceName', value)}
/>,
<TransactionTypeField
currentValue={params.transactionType}
options={transactionTypes.map((key) => ({ text: key, value: key }))}
onChange={(e) => setAlertParams('transactionType', e.target.value)}
onChange={(value) => setAlertParams('transactionType', value)}
/>,
<EnvironmentField
currentValue={params.environment}
options={environmentOptions}
onChange={(e) => setAlertParams('environment', e.target.value)}
onChange={(value) => setAlertParams('environment', value)}
/>,
<PopoverExpression
value={params.aggregationType}

View file

@ -7,18 +7,18 @@
import { i18n } from '@kbn/i18n';
import { defaults, omit } from 'lodash';
import React from 'react';
import React, { useEffect } from 'react';
import { CoreStart } from '../../../../../../../src/core/public';
import { useKibana } from '../../../../../../../src/plugins/kibana_react/public';
import { ENVIRONMENT_ALL } from '../../../../common/environment_filter_values';
import { ANOMALY_SEVERITY } from '../../../../common/ml_constants';
import { useServiceTransactionTypesFetcher } from '../../../context/apm_service/use_service_transaction_types_fetcher';
import { useEnvironmentsFetcher } from '../../../hooks/use_environments_fetcher';
import { createCallApmApi } from '../../../services/rest/createCallApmApi';
import {
EnvironmentField,
ServiceField,
TransactionTypeField,
} from '../fields';
import { AlertMetadata, isNewApmRuleFromStackManagement } from '../helper';
import { NewAlertEmptyPrompt } from '../new_alert_empty_prompt';
import { AlertMetadata } from '../helper';
import { ServiceAlertTrigger } from '../service_alert_trigger';
import { PopoverExpression } from '../service_alert_trigger/popover_expression';
import {
@ -27,16 +27,16 @@ import {
} from './select_anomaly_severity';
interface AlertParams {
anomalySeverityType:
anomalySeverityType?:
| ANOMALY_SEVERITY.CRITICAL
| ANOMALY_SEVERITY.MAJOR
| ANOMALY_SEVERITY.MINOR
| ANOMALY_SEVERITY.WARNING;
environment: string;
environment?: string;
serviceName?: string;
transactionType?: string;
windowSize: number;
windowUnit: string;
windowSize?: number;
windowUnit?: string;
}
interface Props {
@ -47,13 +47,12 @@ interface Props {
}
export function TransactionDurationAnomalyAlertTrigger(props: Props) {
const { services } = useKibana();
const { alertParams, metadata, setAlertParams, setAlertProperty } = props;
const transactionTypes = useServiceTransactionTypesFetcher({
serviceName: metadata?.serviceName,
start: metadata?.start,
end: metadata?.end,
});
useEffect(() => {
createCallApmApi(services as CoreStart);
}, [services]);
const params = defaults(
{
@ -68,27 +67,18 @@ export function TransactionDurationAnomalyAlertTrigger(props: Props) {
}
);
const { environmentOptions } = useEnvironmentsFetcher({
serviceName: params.serviceName,
start: metadata?.start,
end: metadata?.end,
});
if (isNewApmRuleFromStackManagement(alertParams, metadata)) {
return <NewAlertEmptyPrompt />;
}
const fields = [
<ServiceField value={params.serviceName} />,
<ServiceField
currentValue={params.serviceName}
onChange={(value) => setAlertParams('serviceName', value)}
/>,
<TransactionTypeField
currentValue={params.transactionType}
options={transactionTypes.map((key) => ({ text: key, value: key }))}
onChange={(e) => setAlertParams('transactionType', e.target.value)}
onChange={(value) => setAlertParams('transactionType', value)}
/>,
<EnvironmentField
currentValue={params.environment}
options={environmentOptions}
onChange={(e) => setAlertParams('environment', e.target.value)}
onChange={(value) => setAlertParams('environment', value)}
/>,
<PopoverExpression
value={<AnomalySeverity type={params.anomalySeverityType} />}

View file

@ -6,14 +6,12 @@
*/
import { defaults, omit } from 'lodash';
import React from 'react';
import React, { useEffect } from 'react';
import { CoreStart } from '../../../../../../../src/core/public';
import { useKibana } from '../../../../../../../src/plugins/kibana_react/public';
import { ForLastExpression } from '../../../../../triggers_actions_ui/public';
import { ENVIRONMENT_ALL } from '../../../../common/environment_filter_values';
import { asPercent } from '../../../../common/utils/formatters';
import { useServiceTransactionTypesFetcher } from '../../../context/apm_service/use_service_transaction_types_fetcher';
import { useEnvironmentsFetcher } from '../../../hooks/use_environments_fetcher';
import { useFetcher } from '../../../hooks/use_fetcher';
import { createCallApmApi } from '../../../services/rest/createCallApmApi';
import { ChartPreview } from '../chart_preview';
@ -23,22 +21,16 @@ import {
ServiceField,
TransactionTypeField,
} from '../fields';
import {
AlertMetadata,
getIntervalAndTimeRange,
isNewApmRuleFromStackManagement,
TimeUnit,
} from '../helper';
import { NewAlertEmptyPrompt } from '../new_alert_empty_prompt';
import { AlertMetadata, getIntervalAndTimeRange, TimeUnit } from '../helper';
import { ServiceAlertTrigger } from '../service_alert_trigger';
interface AlertParams {
windowSize: number;
windowUnit: string;
threshold: number;
serviceName: string;
transactionType: string;
environment: string;
windowSize?: number;
windowUnit?: string;
threshold?: number;
serviceName?: string;
transactionType?: string;
environment?: string;
}
interface Props {
@ -52,12 +44,9 @@ export function TransactionErrorRateAlertTrigger(props: Props) {
const { services } = useKibana();
const { alertParams, metadata, setAlertParams, setAlertProperty } = props;
createCallApmApi(services as CoreStart);
const transactionTypes = useServiceTransactionTypesFetcher({
serviceName: metadata?.serviceName,
start: metadata?.start,
end: metadata?.end,
});
useEffect(() => {
createCallApmApi(services as CoreStart);
}, [services]);
const params = defaults(
{ ...omit(metadata, ['start', 'end']), ...alertParams },
@ -69,12 +58,6 @@ export function TransactionErrorRateAlertTrigger(props: Props) {
}
);
const { environmentOptions } = useEnvironmentsFetcher({
serviceName: params.serviceName,
start: metadata?.start,
end: metadata?.end,
});
const thresholdAsPercent = (params.threshold ?? 0) / 100;
const { data } = useFetcher(
@ -108,21 +91,18 @@ export function TransactionErrorRateAlertTrigger(props: Props) {
]
);
if (isNewApmRuleFromStackManagement(alertParams, metadata)) {
return <NewAlertEmptyPrompt />;
}
const fields = [
<ServiceField value={params.serviceName} />,
<ServiceField
currentValue={params.serviceName}
onChange={(value) => setAlertParams('serviceName', value)}
/>,
<TransactionTypeField
currentValue={params.transactionType}
options={transactionTypes.map((key) => ({ text: key, value: key }))}
onChange={(e) => setAlertParams('transactionType', e.target.value)}
onChange={(value) => setAlertParams('transactionType', value)}
/>,
<EnvironmentField
currentValue={params.environment}
options={environmentOptions}
onChange={(e) => setAlertParams('environment', e.target.value)}
onChange={(value) => setAlertParams('environment', value)}
/>,
<IsAboveField
value={params.threshold}

View file

@ -0,0 +1,108 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import { EuiComboBox, EuiComboBoxOptionOption } from '@elastic/eui';
import { debounce } from 'lodash';
import React, { useCallback, useState } from 'react';
import { FETCH_STATUS, useFetcher } from '../../../hooks/use_fetcher';
interface SuggestionsSelectProps {
allOption?: EuiComboBoxOptionOption<string>;
customOptionText: string;
defaultValue?: string;
field: string;
onChange: (value?: string) => void;
placeholder: string;
}
export function SuggestionsSelect({
allOption,
customOptionText,
defaultValue,
field,
onChange,
placeholder,
}: SuggestionsSelectProps) {
const allowAll = !!allOption;
let defaultOption: EuiComboBoxOptionOption<string> | undefined;
if (allowAll && !defaultValue) {
defaultOption = allOption;
}
if (defaultValue) {
defaultOption = { label: defaultValue, value: defaultValue };
}
const [selectedOptions, setSelectedOptions] = useState(
defaultOption ? [defaultOption] : []
);
const [searchValue, setSearchValue] = useState('');
const { data, status } = useFetcher(
(callApmApi) => {
return callApmApi({
endpoint: 'GET /internal/apm/suggestions',
params: {
query: { field, string: searchValue },
},
});
},
[field, searchValue],
{ preservePreviousData: false }
);
const handleChange = useCallback(
(changedOptions: Array<EuiComboBoxOptionOption<string>>) => {
setSelectedOptions(changedOptions);
if (changedOptions.length === 1) {
onChange(
changedOptions[0].value
? changedOptions[0].value.trim()
: changedOptions[0].value
);
}
},
[onChange]
);
const handleCreateOption = useCallback(
(value: string) => {
handleChange([{ label: value, value }]);
},
[handleChange]
);
const terms = data?.terms ?? [];
const options: Array<EuiComboBoxOptionOption<string>> = [
...(allOption &&
(searchValue === '' ||
searchValue.toLowerCase() === allOption.label.toLowerCase())
? [allOption]
: []),
...terms.map((name) => {
return { label: name, value: name };
}),
];
return (
<EuiComboBox
async={true}
compressed={true}
customOptionText={customOptionText}
isLoading={status === FETCH_STATUS.LOADING}
onChange={handleChange}
onCreateOption={handleCreateOption}
onSearchChange={debounce(setSearchValue, 500)}
options={options}
placeholder={placeholder}
selectedOptions={selectedOptions}
singleSelection={{ asPlainText: true }}
style={{ minWidth: '256px' }}
/>
);
}

View file

@ -0,0 +1,77 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import { EuiComboBoxOptionOption } from '@elastic/eui';
import { Meta, Story } from '@storybook/react';
import React from 'react';
import { CoreStart } from '../../../../../../../src/core/public';
import { createKibanaReactContext } from '../../../../../../../src/plugins/kibana_react/public';
import { createCallApmApi } from '../../../services/rest/createCallApmApi';
import { SuggestionsSelect } from './';
interface Args {
allOption: EuiComboBoxOptionOption<string>;
customOptionText: string;
field: string;
placeholder: string;
terms: string[];
}
const stories: Meta<Args> = {
title: 'shared/SuggestionsSelect',
component: SuggestionsSelect,
decorators: [
(StoryComponent, { args }) => {
const { terms } = args;
const coreMock = {
http: {
get: () => {
return { terms };
},
},
notifications: { toasts: { add: () => {} } },
uiSettings: { get: () => true },
} as unknown as CoreStart;
const KibanaReactContext = createKibanaReactContext(coreMock);
createCallApmApi(coreMock);
return (
<KibanaReactContext.Provider>
<StoryComponent />
</KibanaReactContext.Provider>
);
},
],
};
export default stories;
export const Example: Story<Args> = ({
allOption,
customOptionText,
field,
placeholder,
}) => {
return (
<SuggestionsSelect
allOption={allOption}
customOptionText={customOptionText}
field={field}
onChange={() => {}}
placeholder={placeholder}
/>
);
};
Example.args = {
allOption: { label: 'All the things', value: 'ALL_THE_THINGS' },
terms: ['thing1', 'thing2'],
customOptionText: 'Add {searchValue} as a new thing',
field: 'test.field',
placeholder: 'Select thing',
};

View file

@ -0,0 +1,19 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import { composeStories } from '@storybook/testing-react';
import { render } from '@testing-library/react';
import React from 'react';
import * as stories from './suggestions_select.stories';
const { Example } = composeStories(stories);
describe('SuggestionsSelect', () => {
it('renders', () => {
expect(() => render(<Example />)).not.toThrowError();
});
});

View file

@ -7,12 +7,13 @@
import { schema, TypeOf } from '@kbn/config-schema';
import {
PluginInitializerContext,
PluginConfigDescriptor,
PluginInitializerContext,
} from 'src/core/server';
import { APMOSSConfig } from 'src/plugins/apm_oss/server';
import { APMPlugin } from './plugin';
import { maxSuggestions } from '../../observability/common';
import { SearchAggregatedTransactionSetting } from '../common/aggregated_transactions';
import { APMPlugin } from './plugin';
const configSchema = schema.object({
enabled: schema.boolean({ defaultValue: true }),
@ -40,8 +41,6 @@ const configSchema = schema.object({
),
telemetryCollectionEnabled: schema.boolean({ defaultValue: true }),
metricsInterval: schema.number({ defaultValue: 30 }),
maxServiceEnvironments: schema.number({ defaultValue: 100 }),
maxServiceSelection: schema.number({ defaultValue: 50 }),
profilingEnabled: schema.boolean({ defaultValue: false }),
agent: schema.object({
migrations: schema.object({
@ -52,7 +51,17 @@ const configSchema = schema.object({
// plugin config
export const config: PluginConfigDescriptor<APMXPackConfig> = {
deprecations: ({ deprecate }) => [deprecate('enabled', '8.0.0')],
deprecations: ({ deprecate, renameFromRoot }) => [
deprecate('enabled', '8.0.0'),
renameFromRoot(
'xpack.apm.maxServiceEnvironments',
`uiSettings.overrides[${maxSuggestions}]`
),
renameFromRoot(
'xpack.apm.maxServiceSelections',
`uiSettings.overrides[${maxSuggestions}]`
),
],
exposeToBrowser: {
serviceMapEnabled: true,
ui: true,
@ -91,8 +100,6 @@ export function mergeConfigs(
'xpack.apm.serviceMapMaxTracesPerRequest':
apmConfig.serviceMapMaxTracesPerRequest,
'xpack.apm.ui.enabled': apmConfig.ui.enabled,
'xpack.apm.maxServiceEnvironments': apmConfig.maxServiceEnvironments,
'xpack.apm.maxServiceSelection': apmConfig.maxServiceSelection,
'xpack.apm.ui.maxTraceItems': apmConfig.ui.maxTraceItems,
'xpack.apm.ui.transactionGroupBucketSize':
apmConfig.ui.transactionGroupBucketSize,

View file

@ -15,7 +15,7 @@ Object {
"terms": Object {
"field": "service.environment",
"missing": undefined,
"size": 100,
"size": 50,
},
},
},
@ -50,7 +50,7 @@ Object {
"terms": Object {
"field": "service.environment",
"missing": "ENVIRONMENT_NOT_DEFINED",
"size": 100,
"size": 50,
},
},
},

View file

@ -15,7 +15,7 @@ Object {
"terms": Object {
"field": "service.environment",
"missing": "ENVIRONMENT_NOT_DEFINED",
"size": 100,
"size": 50,
},
},
},
@ -59,7 +59,7 @@ Object {
"terms": Object {
"field": "service.environment",
"missing": "ENVIRONMENT_NOT_DEFINED",
"size": 100,
"size": 50,
},
},
},

View file

@ -21,9 +21,10 @@ describe('getAllEnvironments', () => {
it('fetches all environments', async () => {
mock = await inspectSearchParams((setup) =>
getAllEnvironments({
serviceName: 'test',
searchAggregatedTransactions: false,
serviceName: 'test',
setup,
size: 50,
})
);
@ -33,10 +34,11 @@ describe('getAllEnvironments', () => {
it('fetches all environments with includeMissing', async () => {
mock = await inspectSearchParams((setup) =>
getAllEnvironments({
includeMissing: true,
searchAggregatedTransactions: false,
serviceName: 'test',
setup,
searchAggregatedTransactions: false,
includeMissing: true,
size: 50,
})
);

View file

@ -19,22 +19,23 @@ import { getProcessorEventForAggregatedTransactions } from '../helpers/aggregate
* It's used in places where we get the list of all possible environments.
*/
export async function getAllEnvironments({
includeMissing = false,
searchAggregatedTransactions,
serviceName,
setup,
searchAggregatedTransactions,
includeMissing = false,
size,
}: {
includeMissing?: boolean;
searchAggregatedTransactions: boolean;
serviceName?: string;
setup: Setup;
searchAggregatedTransactions: boolean;
includeMissing?: boolean;
size: number;
}) {
const operationName = serviceName
? 'get_all_environments_for_service'
: 'get_all_environments_for_all_services';
const { apmEventClient, config } = setup;
const maxServiceEnvironments = config['xpack.apm.maxServiceEnvironments'];
const { apmEventClient } = setup;
// omit filter for service.name if "All" option is selected
const serviceNameFilter = serviceName
@ -65,7 +66,7 @@ export async function getAllEnvironments({
environments: {
terms: {
field: SERVICE_ENVIRONMENT,
size: maxServiceEnvironments,
size,
...(!serviceName ? { min_doc_count: 0 } : {}),
missing: includeMissing ? ENVIRONMENT_NOT_DEFINED.value : undefined,
},

View file

@ -24,6 +24,7 @@ describe('getEnvironments', () => {
setup,
serviceName: 'foo',
searchAggregatedTransactions: false,
size: 50,
start: 0,
end: 50000,
})
@ -37,6 +38,7 @@ describe('getEnvironments', () => {
getEnvironments({
setup,
searchAggregatedTransactions: false,
size: 50,
start: 0,
end: 50000,
})

View file

@ -20,15 +20,17 @@ import { Setup } from '../helpers/setup_request';
* filtered by range.
*/
export async function getEnvironments({
setup,
serviceName,
searchAggregatedTransactions,
serviceName,
setup,
size,
start,
end,
}: {
setup: Setup;
serviceName?: string;
searchAggregatedTransactions: boolean;
size: number;
start: number;
end: number;
}) {
@ -36,7 +38,7 @@ export async function getEnvironments({
? 'get_environments_for_service'
: 'get_environments';
const { apmEventClient, config } = setup;
const { apmEventClient } = setup;
const filter = rangeQuery(start, end);
@ -46,8 +48,6 @@ export async function getEnvironments({
});
}
const maxServiceEnvironments = config['xpack.apm.maxServiceEnvironments'];
const params = {
apm: {
events: [
@ -70,7 +70,7 @@ export async function getEnvironments({
terms: {
field: SERVICE_ENVIRONMENT,
missing: ENVIRONMENT_NOT_DEFINED.value,
size: maxServiceEnvironments,
size,
},
},
},

View file

@ -5,6 +5,10 @@
* 2.0.
*/
import type {
TermsEnumRequest,
TermsEnumResponse,
} from '@elastic/elasticsearch/api/types';
import { ValuesType } from 'utility-types';
import { withApmSpan } from '../../../../utils/with_apm_span';
import { Profile } from '../../../../../typings/es_schemas/ui/profile';
@ -39,6 +43,10 @@ export type APMEventESSearchRequest = Omit<ESSearchRequest, 'index'> & {
};
};
export type APMEventESTermsEnumRequest = Omit<TermsEnumRequest, 'index'> & {
apm: { events: ProcessorEvent[] };
};
// These keys shoul all be `ProcessorEvent.x`, but until TypeScript 4.2 we're inlining them here.
// See https://github.com/microsoft/TypeScript/issues/37888
type TypeOfProcessorEvent<T extends ProcessorEvent> = {
@ -124,5 +132,44 @@ export function createApmEventClient({
requestParams: searchParams,
});
},
async termsEnum(
operationName: string,
params: APMEventESTermsEnumRequest
): Promise<TermsEnumResponse> {
const requestType = 'terms_enum';
const { index } = unpackProcessorEvents(params, indices);
return callAsyncWithDebug({
cb: () => {
const { apm, ...rest } = params;
const termsEnumPromise = withApmSpan(operationName, () =>
cancelEsRequestOnAbort(
esClient.termsEnum({
index: Array.isArray(index) ? index.join(',') : index,
...rest,
}),
request
)
);
return unwrapEsResponse(termsEnumPromise);
},
getDebugMessage: () => ({
body: getDebugBody({
params,
requestType,
operationName,
}),
title: getDebugTitle(request),
}),
isCalledWithInternalUser: false,
debug,
request,
requestType,
operationName,
requestParams: params,
});
},
};
}

View file

@ -12,7 +12,7 @@ import {
ESSearchRequest,
ESFilter,
} from '../../../../../../../../src/core/types/elasticsearch';
import { APMEventESSearchRequest } from '.';
import { APMEventESSearchRequest, APMEventESTermsEnumRequest } from '.';
import {
ApmIndicesConfig,
ApmIndicesName,
@ -28,7 +28,7 @@ const processorEventIndexMap: Record<ProcessorEvent, ApmIndicesName> = {
};
export function unpackProcessorEvents(
request: APMEventESSearchRequest,
request: APMEventESSearchRequest | APMEventESTermsEnumRequest,
indices: ApmIndicesConfig
) {
const { apm, ...params } = request;

View file

@ -99,7 +99,7 @@ Object {
"terms": Object {
"field": "service.environment",
"missing": undefined,
"size": 100,
"size": 50,
},
},
},
@ -127,7 +127,7 @@ Object {
"terms": Object {
"field": "service.environment",
"missing": "ALL_OPTION_VALUE",
"size": 100,
"size": 50,
},
},
},

View file

@ -15,12 +15,13 @@ import { ALL_OPTION_VALUE } from '../../../../../common/agent_configuration/all_
export async function getExistingEnvironmentsForService({
serviceName,
setup,
size,
}: {
serviceName: string | undefined;
setup: Setup;
size: number;
}) {
const { internalClient, indices, config } = setup;
const maxServiceEnvironments = config['xpack.apm.maxServiceEnvironments'];
const { internalClient, indices } = setup;
const bool = serviceName
? { filter: [{ term: { [SERVICE_NAME]: serviceName } }] }
@ -36,7 +37,7 @@ export async function getExistingEnvironmentsForService({
terms: {
field: SERVICE_ENVIRONMENT,
missing: ALL_OPTION_VALUE,
size: maxServiceEnvironments,
size,
},
},
},

View file

@ -20,15 +20,22 @@ export async function getEnvironments({
serviceName,
setup,
searchAggregatedTransactions,
size,
}: {
serviceName: string | undefined;
setup: Setup;
searchAggregatedTransactions: boolean;
size: number;
}) {
return withApmSpan('get_environments_for_agent_configuration', async () => {
const [allEnvironments, existingEnvironments] = await Promise.all([
getAllEnvironments({ serviceName, setup, searchAggregatedTransactions }),
getExistingEnvironmentsForService({ serviceName, setup }),
getAllEnvironments({
searchAggregatedTransactions,
serviceName,
setup,
size,
}),
getExistingEnvironmentsForService({ serviceName, setup, size }),
]);
return [ALL_OPTION_VALUE, ...allEnvironments].map((environment) => {

View file

@ -15,15 +15,17 @@ import { getProcessorEventForAggregatedTransactions } from '../../helpers/aggreg
export type AgentConfigurationServicesAPIResponse = PromiseReturnType<
typeof getServiceNames
>;
export async function getServiceNames({
setup,
searchAggregatedTransactions,
size,
}: {
setup: Setup;
searchAggregatedTransactions: boolean;
size: number;
}) {
const { apmEventClient, config } = setup;
const maxServiceSelection = config['xpack.apm.maxServiceSelection'];
const { apmEventClient } = setup;
const params = {
apm: {
@ -42,8 +44,8 @@ export async function getServiceNames({
services: {
terms: {
field: SERVICE_NAME,
size: maxServiceSelection,
min_doc_count: 0,
size,
},
},
},

View file

@ -27,9 +27,10 @@ describe('agent configuration queries', () => {
it('fetches all environments', async () => {
mock = await inspectSearchParams((setup) =>
getAllEnvironments({
searchAggregatedTransactions: false,
serviceName: 'foo',
setup,
searchAggregatedTransactions: false,
size: 50,
})
);
@ -43,6 +44,7 @@ describe('agent configuration queries', () => {
getExistingEnvironmentsForService({
serviceName: 'foo',
setup,
size: 50,
})
);
@ -56,6 +58,7 @@ describe('agent configuration queries', () => {
getServiceNames({
setup,
searchAggregatedTransactions: false,
size: 50,
})
);

View file

@ -0,0 +1,46 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import { ProcessorEvent } from '../../../common/processor_event';
import { getProcessorEventForAggregatedTransactions } from '../helpers/aggregated_transactions';
import { Setup } from '../helpers/setup_request';
export async function getSuggestions({
field,
searchAggregatedTransactions,
setup,
size,
string,
}: {
field: string;
searchAggregatedTransactions: boolean;
setup: Setup;
size: number;
string: string;
}) {
const { apmEventClient } = setup;
const response = await apmEventClient.termsEnum('get_suggestions', {
apm: {
events: [
getProcessorEventForAggregatedTransactions(
searchAggregatedTransactions
),
ProcessorEvent.error,
ProcessorEvent.metric,
],
},
body: {
case_insensitive: true,
field,
size,
string,
},
});
return { terms: response.terms };
}

View file

@ -6,6 +6,7 @@
*/
import * as t from 'io-ts';
import { maxSuggestions } from '../../../observability/common';
import { getSearchAggregatedTransactions } from '../lib/helpers/aggregated_transactions';
import { setupRequest } from '../lib/helpers/setup_request';
import { getEnvironments } from '../lib/environments/get_environments';
@ -26,7 +27,7 @@ const environmentsRoute = createApmServerRoute({
options: { tags: ['access:apm'] },
handler: async (resources) => {
const setup = await setupRequest(resources);
const { params } = resources;
const { context, params } = resources;
const { serviceName, start, end } = params.query;
const searchAggregatedTransactions = await getSearchAggregatedTransactions({
apmEventClient: setup.apmEventClient,
@ -35,11 +36,14 @@ const environmentsRoute = createApmServerRoute({
end,
kuery: '',
});
const size = await context.core.uiSettings.client.get<number>(
maxSuggestions
);
const environments = await getEnvironments({
setup,
serviceName,
searchAggregatedTransactions,
size,
start,
end,
});

View file

@ -33,6 +33,7 @@ import { traceRouteRepository } from './traces';
import { transactionRouteRepository } from './transactions';
import { APMRouteHandlerResources } from './typings';
import { historicalDataRouteRepository } from './historical_data';
import { suggestionsRouteRepository } from './suggestions';
const getTypedGlobalApmServerRouteRepository = () => {
const repository = createApmServerRouteRepository()
@ -45,6 +46,7 @@ const getTypedGlobalApmServerRouteRepository = () => {
.merge(serviceMapRouteRepository)
.merge(serviceNodeRouteRepository)
.merge(serviceRouteRepository)
.merge(suggestionsRouteRepository)
.merge(traceRouteRepository)
.merge(transactionRouteRepository)
.merge(alertsChartPreviewRouteRepository)

View file

@ -8,6 +8,7 @@
import * as t from 'io-ts';
import Boom from '@hapi/boom';
import { toBooleanRt } from '@kbn/io-ts-utils';
import { maxSuggestions } from '../../../../observability/common';
import { setupRequest } from '../../lib/helpers/setup_request';
import { getServiceNames } from '../../lib/settings/agent_configuration/get_service_names';
import { createOrUpdateConfiguration } from '../../lib/settings/agent_configuration/create_or_update_configuration';
@ -251,9 +252,13 @@ const listAgentConfigurationServicesRoute = createApmServerRoute({
start,
end,
});
const size = await resources.context.core.uiSettings.client.get<number>(
maxSuggestions
);
const serviceNames = await getServiceNames({
setup,
searchAggregatedTransactions,
setup,
size,
});
return { serviceNames };
@ -269,7 +274,7 @@ const listAgentConfigurationEnvironmentsRoute = createApmServerRoute({
options: { tags: ['access:apm'] },
handler: async (resources) => {
const setup = await setupRequest(resources);
const { params } = resources;
const { context, params } = resources;
const { serviceName, start, end } = params.query;
const searchAggregatedTransactions = await getSearchAggregatedTransactions({
@ -279,11 +284,14 @@ const listAgentConfigurationEnvironmentsRoute = createApmServerRoute({
start,
end,
});
const size = await context.core.uiSettings.client.get<number>(
maxSuggestions
);
const environments = await getEnvironments({
serviceName,
setup,
searchAggregatedTransactions,
size,
});
return { environments };

View file

@ -7,6 +7,7 @@
import * as t from 'io-ts';
import Boom from '@hapi/boom';
import { maxSuggestions } from '../../../../observability/common';
import { isActivePlatinumLicense } from '../../../common/license_check';
import { ML_ERRORS } from '../../../common/anomaly_detection';
import { createApmServerRoute } from '../create_apm_server_route';
@ -92,11 +93,14 @@ const anomalyDetectionEnvironmentsRoute = createApmServerRoute({
config: setup.config,
kuery: '',
});
const size = await resources.context.core.uiSettings.client.get<number>(
maxSuggestions
);
const environments = await getAllEnvironments({
setup,
searchAggregatedTransactions,
includeMissing: true,
searchAggregatedTransactions,
setup,
size,
});
return { environments };

View file

@ -0,0 +1,47 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import * as t from 'io-ts';
import { maxSuggestions } from '../../../observability/common';
import { getSuggestions } from '../lib/suggestions/get_suggestions';
import { getSearchAggregatedTransactions } from '../lib/helpers/aggregated_transactions';
import { setupRequest } from '../lib/helpers/setup_request';
import { createApmServerRoute } from './create_apm_server_route';
import { createApmServerRouteRepository } from './create_apm_server_route_repository';
const suggestionsRoute = createApmServerRoute({
endpoint: 'GET /internal/apm/suggestions',
params: t.partial({
query: t.type({ field: t.string, string: t.string }),
}),
options: { tags: ['access:apm'] },
handler: async (resources) => {
const setup = await setupRequest(resources);
const { context, params } = resources;
const { field, string } = params.query;
const searchAggregatedTransactions = await getSearchAggregatedTransactions({
apmEventClient: setup.apmEventClient,
config: setup.config,
kuery: '',
});
const size = await context.core.uiSettings.client.get<number>(
maxSuggestions
);
const suggestions = await getSuggestions({
field,
searchAggregatedTransactions,
setup,
size,
string,
});
return suggestions;
},
});
export const suggestionsRouteRepository =
createApmServerRouteRepository().add(suggestionsRoute);

View file

@ -79,12 +79,6 @@ export async function inspectSearchParams(
case 'xpack.apm.metricsInterval':
return 30;
case 'xpack.apm.maxServiceEnvironments':
return 100;
case 'xpack.apm.maxServiceSelection':
return 50;
}
},
}

View file

@ -6,6 +6,7 @@
*/
export type { AsDuration, AsPercent } from './utils/formatters';
export { enableInspectEsQueries, maxSuggestions } from './ui_settings_keys';
export const casesFeatureId = 'observabilityCases';

View file

@ -6,3 +6,4 @@
*/
export const enableInspectEsQueries = 'observability:enableInspectEsQueries';
export const maxSuggestions = 'observability:maxSuggestions';

View file

@ -0,0 +1,16 @@
## Feature flags and advanced settings
To set up a flagged feature or other advanced setting, add the name of the feature key (`observability:myFeature`) to [common/ui_settings_keys.ts](../common/ui_settings_keys.ts) and the feature parameters to [server/ui_settings.ts](../server/ui_settings.ts).
Test for the feature like:
```js
import { myFeatureEnabled } from '../ui_settings_keys';
if (core.uiSettings.get(myFeatureEnabled)) {
doStuff();
}
```
In order for telemetry to be collected, the keys and types need to be added in [src/plugins/kibana_usage_collection/server/collectors/management/schema.ts](../../../../src/plugins/kibana_usage_collection/server/collectors/management/schema.ts) and [src/plugins/kibana_usage_collection/server/collectors/management/types.ts](../../../../src/plugins/kibana_usage_collection/server/collectors/management/types.ts).
Settings can be managed in Kibana under Stack Management > Advanced Settings > Observability.

View file

@ -9,16 +9,16 @@ import { schema } from '@kbn/config-schema';
import { i18n } from '@kbn/i18n';
import { UiSettingsParams } from '../../../../src/core/types';
import { observabilityFeatureId } from '../common';
import { enableInspectEsQueries } from '../common/ui_settings_keys';
import { enableInspectEsQueries, maxSuggestions } from '../common/ui_settings_keys';
/**
* uiSettings definitions for Observability.
*/
export const uiSettings: Record<string, UiSettingsParams<boolean>> = {
export const uiSettings: Record<string, UiSettingsParams<boolean | number>> = {
[enableInspectEsQueries]: {
category: [observabilityFeatureId],
name: i18n.translate('xpack.observability.enableInspectEsQueriesExperimentName', {
defaultMessage: 'inspect ES queries',
defaultMessage: 'Inspect ES queries',
}),
value: false,
description: i18n.translate('xpack.observability.enableInspectEsQueriesExperimentDescription', {
@ -26,4 +26,15 @@ export const uiSettings: Record<string, UiSettingsParams<boolean>> = {
}),
schema: schema.boolean(),
},
[maxSuggestions]: {
category: [observabilityFeatureId],
name: i18n.translate('xpack.observability.maxSuggestionsUiSettingName', {
defaultMessage: 'Maximum suggestions',
}),
value: 100,
description: i18n.translate('xpack.observability.maxSuggestionsUiSettingDescription', {
defaultMessage: 'Maximum number of suggestions fetched in autocomplete selection boxes.',
}),
schema: schema.number(),
},
};

View file

@ -6193,7 +6193,6 @@
"xpack.apm.alertAnnotationCriticalTitle": "重大アラート",
"xpack.apm.alertAnnotationNoSeverityTitle": "アラート",
"xpack.apm.alertAnnotationWarningTitle": "警告アラート",
"xpack.apm.alerting.fields.all_option": "すべて",
"xpack.apm.alerting.fields.environment": "環境",
"xpack.apm.alerting.fields.service": "サービス",
"xpack.apm.alerting.fields.type": "型",

View file

@ -6243,7 +6243,6 @@
"xpack.apm.alertAnnotationCriticalTitle": "紧急告警",
"xpack.apm.alertAnnotationNoSeverityTitle": "告警",
"xpack.apm.alertAnnotationWarningTitle": "警告告警",
"xpack.apm.alerting.fields.all_option": "全部",
"xpack.apm.alerting.fields.environment": "环境",
"xpack.apm.alerting.fields.service": "服务",
"xpack.apm.alerting.fields.type": "类型",

View file

@ -137,6 +137,11 @@ export default function apmApiIntegrationTests(providerContext: FtrProviderConte
loadTestFile(require.resolve('./settings/custom_link'));
});
// suggestions
describe('suggestions', function () {
loadTestFile(require.resolve('./suggestions/suggestions'));
});
// traces
describe('traces/top_traces', function () {
loadTestFile(require.resolve('./traces/top_traces'));

View file

@ -0,0 +1,143 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import {
SERVICE_ENVIRONMENT,
SERVICE_NAME,
TRANSACTION_TYPE,
} from '../../../../plugins/apm/common/elasticsearch_fieldnames';
import { FtrProviderContext } from '../../common/ftr_provider_context';
import { registry } from '../../common/registry';
export default function suggestionsTests({ getService }: FtrProviderContext) {
const apmApiClient = getService('apmApiClient');
const archiveName = 'apm_8.0.0';
registry.when(
'suggestions when data is loaded',
{ config: 'basic', archives: [archiveName] },
() => {
describe('with environment', () => {
describe('with an empty string parameter', () => {
it('returns all environments', async () => {
const { body } = await apmApiClient.readUser({
endpoint: 'GET /internal/apm/suggestions',
params: { query: { field: SERVICE_ENVIRONMENT, string: '' } },
});
expectSnapshot(body).toMatchInline(`
Object {
"terms": Array [
"production",
"testing",
],
}
`);
});
});
describe('with a string parameter', () => {
it('returns items matching the string parameter', async () => {
const { body } = await apmApiClient.readUser({
endpoint: 'GET /internal/apm/suggestions',
params: { query: { field: SERVICE_ENVIRONMENT, string: 'pr' } },
});
expectSnapshot(body).toMatchInline(`
Object {
"terms": Array [
"production",
],
}
`);
});
});
});
describe('with service name', () => {
describe('with an empty string parameter', () => {
it('returns all services', async () => {
const { body } = await apmApiClient.readUser({
endpoint: 'GET /internal/apm/suggestions',
params: { query: { field: SERVICE_NAME, string: '' } },
});
expectSnapshot(body).toMatchInline(`
Object {
"terms": Array [
"auditbeat",
"opbeans-dotnet",
"opbeans-go",
"opbeans-java",
"opbeans-node",
"opbeans-python",
"opbeans-ruby",
"opbeans-rum",
],
}
`);
});
});
describe('with a string parameter', () => {
it('returns items matching the string parameter', async () => {
const { body } = await apmApiClient.readUser({
endpoint: 'GET /internal/apm/suggestions',
params: { query: { field: SERVICE_NAME, string: 'aud' } },
});
expectSnapshot(body).toMatchInline(`
Object {
"terms": Array [
"auditbeat",
],
}
`);
});
});
});
describe('with transaction type', () => {
describe('with an empty string parameter', () => {
it('returns all transaction types', async () => {
const { body } = await apmApiClient.readUser({
endpoint: 'GET /internal/apm/suggestions',
params: { query: { field: TRANSACTION_TYPE, string: '' } },
});
expectSnapshot(body).toMatchInline(`
Object {
"terms": Array [
"Worker",
"celery",
"page-load",
"request",
],
}
`);
});
});
describe('with a string parameter', () => {
it('returns items matching the string parameter', async () => {
const { body } = await apmApiClient.readUser({
endpoint: 'GET /internal/apm/suggestions',
params: { query: { field: TRANSACTION_TYPE, string: 'w' } },
});
expectSnapshot(body).toMatchInline(`
Object {
"terms": Array [
"Worker",
],
}
`);
});
});
});
}
);
}