[7.x] [APM] Use new platform for toast notifications (#47276) | [APM] Agent configuration phase 2 (#46995) (#47806)

* [APM] Use new platform for toast notifications (#47276)

* [APM] Use new platform for toast notifications

* fix more tests

* remove comment

* [APM] Agent configuration phase 2 (#46995)

* [APM] Agent Config Management Phase 2

* Add status indicator

* Extract TimestampTooltip component

* Remove unused StickyTransactionProperties component

* Fix snapshot and minor cleanup

* Minor cleanup

* Display settings conditionally by agent name

* Fix client

* Format timestamp

* Minor design feedback

* Clear cache when clicking refresh

* Fix test

* Revert t() short hand

* Fix translations

* Add support for “all” option

* Fix API tests

* Move delete button to footer

* Fix snapshots

* Add API tests

* Fix toasts

* Address feedback and ensure order when searching for configs

* Fix snapshots

* Remove timeout
This commit is contained in:
Søren Louv-Jansen 2019-10-10 13:51:15 +02:00 committed by GitHub
parent 9b8979424a
commit 0d77169b64
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
68 changed files with 2037 additions and 1637 deletions

View file

@ -0,0 +1,25 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
import { i18n } from '@kbn/i18n';
export const ALL_OPTION_VALUE = 'ALL_OPTION_VALUE';
// human-readable label for the option. The "All" option should be translated.
// Everything else should be returned verbatim
export function getOptionLabel(value: string | undefined) {
if (value === undefined || value === ALL_OPTION_VALUE) {
return i18n.translate('xpack.apm.settings.agentConf.allOptionLabel', {
defaultMessage: 'All'
});
}
return value;
}
export function omitAllOption(value: string) {
return value === ALL_OPTION_VALUE ? undefined : value;
}

View file

@ -0,0 +1,28 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
import { transactionMaxSpansRt } from './index';
import { isRight } from 'fp-ts/lib/Either';
describe('transactionMaxSpans', () => {
it('does not accept empty values', () => {
expect(isRight(transactionMaxSpansRt.decode(undefined))).toBe(false);
expect(isRight(transactionMaxSpansRt.decode(null))).toBe(false);
expect(isRight(transactionMaxSpansRt.decode(''))).toBe(false);
});
it('accepts both strings and numbers as values', () => {
expect(isRight(transactionMaxSpansRt.decode('55'))).toBe(true);
expect(isRight(transactionMaxSpansRt.decode(55))).toBe(true);
});
it('checks if the number falls within 0, 32000', () => {
expect(isRight(transactionMaxSpansRt.decode(0))).toBe(true);
expect(isRight(transactionMaxSpansRt.decode(32000))).toBe(true);
expect(isRight(transactionMaxSpansRt.decode(-55))).toBe(false);
expect(isRight(transactionMaxSpansRt.decode(NaN))).toBe(false);
});
});

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;
* you may not use this file except in compliance with the Elastic License.
*/
import * as t from 'io-ts';
export const transactionMaxSpansRt = new t.Type<number, number, unknown>(
'transactionMaxSpans',
t.number.is,
(input, context) => {
const value = parseInt(input as string, 10);
return value >= 0 && value <= 32000
? t.success(value)
: t.failure(input, context);
},
t.identity
);

View file

@ -7,6 +7,12 @@ import { transactionSampleRateRt } from './index';
import { isRight } from 'fp-ts/lib/Either';
describe('transactionSampleRateRt', () => {
it('does not accept empty values', () => {
expect(isRight(transactionSampleRateRt.decode(undefined))).toBe(false);
expect(isRight(transactionSampleRateRt.decode(null))).toBe(false);
expect(isRight(transactionSampleRateRt.decode(''))).toBe(false);
});
it('accepts both strings and numbers as values', () => {
expect(isRight(transactionSampleRateRt.decode('0.5'))).toBe(true);
expect(isRight(transactionSampleRateRt.decode(0.5))).toBe(true);

View file

@ -10,8 +10,8 @@ export const transactionSampleRateRt = new t.Type<number, number, unknown>(
'TransactionSampleRate',
t.number.is,
(input, context) => {
const value = Number(input);
return value >= 0 && value <= 1 && Number(value.toFixed(3)) === value
const value = parseFloat(input as string);
return value >= 0 && value <= 1 && parseFloat(value.toFixed(3)) === value
? t.success(value)
: t.failure(input, context);
},

View file

@ -36,7 +36,7 @@ import {
logStacktraceTab
} from './ErrorTabs';
import { Summary } from '../../../shared/Summary';
import { TimestampSummaryItem } from '../../../shared/Summary/TimestampSummaryItem';
import { TimestampTooltip } from '../../../shared/TimestampTooltip';
import { HttpInfoSummaryItem } from '../../../shared/Summary/HttpInfoSummaryItem';
import { TransactionDetailLink } from '../../../shared/Links/apm/TransactionDetailLink';
@ -113,7 +113,7 @@ export function DetailView({ errorGroup, urlParams, location }: Props) {
<Summary
items={[
<TimestampSummaryItem time={error.timestamp.us / 1000} />,
<TimestampTooltip time={error.timestamp.us / 1000} />,
errorUrl && method ? (
<HttpInfoSummaryItem
url={errorUrl}

View file

@ -13,7 +13,7 @@ import { TransactionDetails } from '../../TransactionDetails';
import { Home } from '../../Home';
import { BreadcrumbRoute } from '../ProvideBreadcrumbs';
import { RouteName } from './route_names';
import { Settings } from '../../Settings';
import { AgentConfigurations } from '../../Settings/AgentConfigurations';
import { toQuery } from '../../../shared/Links/url_helpers';
import { ServiceNodeMetrics } from '../../ServiceNodeMetrics';
import { resolveUrlParams } from '../../../../context/UrlParamsContext/resolveUrlParams';
@ -66,7 +66,7 @@ export const routes: BreadcrumbRoute[] = [
{
exact: true,
path: '/settings',
component: Settings,
component: AgentConfigurations,
breadcrumb: i18n.translate('xpack.apm.breadcrumb.listSettingsTitle', {
defaultMessage: 'Settings'
}),

View file

@ -6,11 +6,11 @@
import { i18n } from '@kbn/i18n';
import React, { Component } from 'react';
import { toastNotifications } from 'ui/notify';
import { startMLJob } from '../../../../../services/rest/ml';
import { IUrlParams } from '../../../../../context/UrlParamsContext/types';
import { MLJobLink } from '../../../../shared/Links/MachineLearningLinks/MLJobLink';
import { MachineLearningFlyoutView } from './view';
import { KibanaCoreContext } from '../../../../../../../observability/public/context/kibana_core';
interface Props {
isOpen: boolean;
@ -23,6 +23,8 @@ interface State {
}
export class MachineLearningFlyout extends Component<Props, State> {
static contextType = KibanaCoreContext;
public state: State = {
isCreatingJob: false
};
@ -53,6 +55,7 @@ export class MachineLearningFlyout extends Component<Props, State> {
};
public addErrorToast = () => {
const core = this.context;
const { urlParams } = this.props;
const { serviceName } = urlParams;
@ -60,7 +63,7 @@ export class MachineLearningFlyout extends Component<Props, State> {
return;
}
toastNotifications.addWarning({
core.notifications.toasts.addWarning({
title: i18n.translate(
'xpack.apm.serviceDetails.enableAnomalyDetectionPanel.jobCreationFailedNotificationTitle',
{
@ -86,6 +89,7 @@ export class MachineLearningFlyout extends Component<Props, State> {
}: {
transactionType: string;
}) => {
const core = this.context;
const { urlParams } = this.props;
const { serviceName } = urlParams;
@ -93,7 +97,7 @@ export class MachineLearningFlyout extends Component<Props, State> {
return;
}
toastNotifications.addSuccess({
core.notifications.toasts.addSuccess({
title: i18n.translate(
'xpack.apm.serviceDetails.enableAnomalyDetectionPanel.jobCreatedNotificationTitle',
{
@ -113,17 +117,19 @@ export class MachineLearningFlyout extends Component<Props, State> {
}
}
)}{' '}
<MLJobLink
serviceName={serviceName}
transactionType={transactionType}
>
{i18n.translate(
'xpack.apm.serviceDetails.enableAnomalyDetectionPanel.jobCreatedNotificationText.viewJobLinkText',
{
defaultMessage: 'View job'
}
)}
</MLJobLink>
<KibanaCoreContext.Provider value={core}>
<MLJobLink
serviceName={serviceName}
transactionType={transactionType}
>
{i18n.translate(
'xpack.apm.serviceDetails.enableAnomalyDetectionPanel.jobCreatedNotificationText.viewJobLinkText',
{
defaultMessage: 'View job'
}
)}
</MLJobLink>
</KibanaCoreContext.Provider>
</p>
)
});

View file

@ -30,7 +30,6 @@ import { memoize, padLeft, range } from 'lodash';
import moment from 'moment-timezone';
import React, { Component } from 'react';
import styled from 'styled-components';
import { toastNotifications } from 'ui/notify';
import { LegacyCoreStart } from 'src/core/public';
import { KibanaCoreContext } from '../../../../../../observability/public';
import { IUrlParams } from '../../../../context/UrlParamsContext/types';
@ -218,7 +217,9 @@ export class WatcherFlyout extends Component<
};
public addErrorToast = () => {
toastNotifications.addWarning({
const core = this.context;
core.notifications.toasts.addWarning({
title: i18n.translate(
'xpack.apm.serviceDetails.enableErrorReportsPanel.watchCreationFailedNotificationTitle',
{
@ -240,7 +241,9 @@ export class WatcherFlyout extends Component<
};
public addSuccessToast = (id: string) => {
toastNotifications.addSuccess({
const core = this.context;
core.notifications.toasts.addSuccess({
title: i18n.translate(
'xpack.apm.serviceDetails.enableErrorReportsPanel.watchCreatedNotificationTitle',
{
@ -259,16 +262,18 @@ export class WatcherFlyout extends Component<
}
}
)}{' '}
<KibanaLink
path={`/management/elasticsearch/watcher/watches/watch/${id}`}
>
{i18n.translate(
'xpack.apm.serviceDetails.enableErrorReportsPanel.watchCreatedNotificationText.viewWatchLinkText',
{
defaultMessage: 'View watch'
}
)}
</KibanaLink>
<KibanaCoreContext.Provider value={core}>
<KibanaLink
path={`/management/elasticsearch/watcher/watches/watch/${id}`}
>
{i18n.translate(
'xpack.apm.serviceDetails.enableErrorReportsPanel.watchCreatedNotificationText.viewWatchLinkText',
{
defaultMessage: 'View watch'
}
)}
</KibanaLink>
</KibanaCoreContext.Provider>
</p>
)
});

View file

@ -7,7 +7,6 @@
import React from 'react';
import { render, wait, waitForElement } from 'react-testing-library';
import 'react-testing-library/cleanup-after-each';
import { toastNotifications } from 'ui/notify';
import * as callApmApi from '../../../../services/rest/callApmApi';
import { ServiceOverview } from '..';
import * as urlParamsHooks from '../../../../hooks/useUrlParams';
@ -22,16 +21,17 @@ function renderServiceOverview() {
return render(<ServiceOverview />);
}
const coreMock = ({
http: {
basePath: {
prepend: (path: string) => `/basepath${path}`
}
},
notifications: { toasts: { addWarning: () => {} } }
} as unknown) as LegacyCoreStart;
describe('Service Overview -> View', () => {
beforeEach(() => {
const coreMock = ({
http: {
basePath: {
prepend: (path: string) => `/basepath${path}`
}
}
} as unknown) as LegacyCoreStart;
// mock urlParams
spyOn(urlParamsHooks, 'useUrlParams').and.returnValue({
urlParams: {
@ -141,45 +141,56 @@ describe('Service Overview -> View', () => {
expect(container.querySelectorAll('.euiTableRow')).toMatchSnapshot();
});
it('should render upgrade migration notification when legacy data is found, ', async () => {
// create spies
const toastSpy = jest.spyOn(toastNotifications, 'addWarning');
const dataFetchingSpy = jest
.spyOn(callApmApi, 'callApmApi')
.mockResolvedValue({
hasLegacyData: true,
hasHistoricalData: true,
items: []
});
describe('when legacy data is found', () => {
it('renders an upgrade migration notification', async () => {
// create spies
const addWarning = jest.spyOn(
coreMock.notifications.toasts,
'addWarning'
);
renderServiceOverview();
const dataFetchingSpy = jest
.spyOn(callApmApi, 'callApmApi')
.mockResolvedValue({
hasLegacyData: true,
hasHistoricalData: true,
items: []
});
// wait for requests to be made
await wait(() => expect(dataFetchingSpy).toHaveBeenCalledTimes(1));
renderServiceOverview();
expect(toastSpy).toHaveBeenLastCalledWith(
expect.objectContaining({
title: 'Legacy data was detected within the selected time range'
})
);
// wait for requests to be made
await wait(() => expect(dataFetchingSpy).toHaveBeenCalledTimes(1));
expect(addWarning).toHaveBeenLastCalledWith(
expect.objectContaining({
title: 'Legacy data was detected within the selected time range'
})
);
});
});
it('should not render upgrade migration notification when legacy data is not found, ', async () => {
// create spies
const toastSpy = jest.spyOn(toastNotifications, 'addWarning');
const dataFetchingSpy = jest
.spyOn(callApmApi, 'callApmApi')
.mockResolvedValue({
hasLegacyData: false,
hasHistoricalData: true,
items: []
});
describe('when legacy data is not found', () => {
it('does not render an upgrade migration notification', async () => {
// create spies
const addWarning = jest.spyOn(
coreMock.notifications.toasts,
'addWarning'
);
const dataFetchingSpy = jest
.spyOn(callApmApi, 'callApmApi')
.mockResolvedValue({
hasLegacyData: false,
hasHistoricalData: true,
items: []
});
renderServiceOverview();
renderServiceOverview();
// wait for requests to be made
await wait(() => expect(dataFetchingSpy).toHaveBeenCalledTimes(1));
// wait for requests to be made
await wait(() => expect(dataFetchingSpy).toHaveBeenCalledTimes(1));
expect(toastSpy).not.toHaveBeenCalled();
expect(addWarning).not.toHaveBeenCalled();
});
});
});

View file

@ -8,7 +8,6 @@ import { EuiPanel, EuiFlexGroup, EuiFlexItem } from '@elastic/eui';
import { EuiLink } from '@elastic/eui';
import { i18n } from '@kbn/i18n';
import React, { useEffect, useMemo } from 'react';
import { toastNotifications } from 'ui/notify';
import url from 'url';
import { useFetcher } from '../../../hooks/useFetcher';
import { NoServicesMessage } from './NoServicesMessage';
@ -48,7 +47,8 @@ export function ServiceOverview() {
useEffect(() => {
if (data.hasLegacyData && !hasDisplayedToast) {
hasDisplayedToast = true;
toastNotifications.addWarning({
core.notifications.toasts.addWarning({
title: i18n.translate('xpack.apm.serviceOverview.toastTitle', {
defaultMessage:
'Legacy data was detected within the selected time range'
@ -77,7 +77,7 @@ export function ServiceOverview() {
)
});
}
}, [data.hasLegacyData, core.http.basePath]);
}, [data.hasLegacyData, core.http.basePath, core.notifications.toasts]);
useTrackPageview({ app: 'apm', path: 'services_overview' });
useTrackPageview({ app: 'apm', path: 'services_overview', delay: 15000 });

View file

@ -1,386 +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;
* you may not use this file except in compliance with the Elastic License.
*/
import {
EuiFlyout,
EuiFlyoutBody,
EuiFlyoutHeader,
EuiPortal,
EuiTitle,
EuiHorizontalRule,
EuiFlexGroup,
EuiFlexItem,
EuiFlyoutFooter,
EuiButton,
EuiButtonEmpty,
EuiCallOut
} from '@elastic/eui';
import React, { useState } from 'react';
import { toastNotifications } from 'ui/notify';
import { i18n } from '@kbn/i18n';
import { FormattedMessage } from '@kbn/i18n/react';
import { isRight } from 'fp-ts/lib/Either';
import { transactionSampleRateRt } from '../../../../../common/runtime_types/transaction_sample_rate_rt';
import { AddSettingFlyoutBody } from './AddSettingFlyoutBody';
import { useFetcher } from '../../../../hooks/useFetcher';
import { ENVIRONMENT_NOT_DEFINED } from '../../../../../common/environment_filter_values';
import { callApmApi } from '../../../../services/rest/callApmApi';
import { trackEvent } from '../../../../../../infra/public/hooks/use_track_metric';
import { Config } from '..';
interface Props {
onClose: () => void;
onSubmit: () => void;
isOpen: boolean;
selectedConfig: Config | null;
}
export function AddSettingsFlyout({
onClose,
isOpen,
onSubmit,
selectedConfig
}: Props) {
const [environment, setEnvironment] = useState<string | undefined>(
selectedConfig
? selectedConfig.service.environment || ENVIRONMENT_NOT_DEFINED
: undefined
);
const [serviceName, setServiceName] = useState<string | undefined>(
selectedConfig ? selectedConfig.service.name : undefined
);
const [sampleRate, setSampleRate] = useState<string>(
selectedConfig
? selectedConfig.settings.transaction_sample_rate.toString()
: ''
);
const { data: serviceNames = [], status: serviceNamesStatus } = useFetcher(
() =>
callApmApi({
pathname: '/api/apm/settings/agent-configuration/services'
}),
[],
{ preservePreviousData: false }
);
const { data: environments = [], status: environmentStatus } = useFetcher(
() => {
if (serviceName) {
return callApmApi({
pathname:
'/api/apm/settings/agent-configuration/services/{serviceName}/environments',
params: {
path: { serviceName }
}
});
}
},
[serviceName],
{ preservePreviousData: false }
);
const isSampleRateValid = isRight(transactionSampleRateRt.decode(sampleRate));
const isSelectedEnvironmentValid = environments.some(
env =>
env.name === environment && (Boolean(selectedConfig) || env.available)
);
if (!isOpen) {
return null;
}
return (
<EuiPortal>
<EuiFlyout size="s" onClose={onClose} ownFocus={true}>
<EuiFlyoutHeader hasBorder>
<EuiTitle>
{selectedConfig ? (
<h2>
{i18n.translate(
'xpack.apm.settings.agentConf.flyOut.editConfigTitle',
{
defaultMessage: 'Edit configuration'
}
)}
</h2>
) : (
<h2>
{i18n.translate(
'xpack.apm.settings.agentConf.flyOut.createConfigTitle',
{
defaultMessage: 'Create configuration'
}
)}
</h2>
)}
</EuiTitle>
</EuiFlyoutHeader>
<EuiFlyoutBody>
<EuiCallOut
title={i18n.translate(
'xpack.apm.settings.agentConf.flyOut.betaCallOutTitle',
{
defaultMessage: 'APM Agent Configuration (BETA)'
}
)}
iconType="iInCircle"
color="warning"
>
{i18n.translate(
'xpack.apm.settings.agentConf.flyOut.betaCallOutText',
{
defaultMessage:
'Please note only sample rate configuration is supported in this first version. We will extend support for agent configuration in future releases. Please be aware of bugs.'
}
)}
</EuiCallOut>
<EuiHorizontalRule margin="m" />
<AddSettingFlyoutBody
selectedConfig={selectedConfig}
onDelete={async () => {
if (selectedConfig) {
await deleteConfig(selectedConfig);
}
onSubmit();
}}
environment={environment}
setEnvironment={setEnvironment}
serviceName={serviceName}
setServiceName={setServiceName}
sampleRate={sampleRate}
setSampleRate={setSampleRate}
serviceNames={serviceNames}
serviceNamesStatus={serviceNamesStatus}
environments={environments}
environmentStatus={environmentStatus}
isSampleRateValid={isSampleRateValid}
isSelectedEnvironmentValid={isSelectedEnvironmentValid}
/>
</EuiFlyoutBody>
<EuiFlyoutFooter>
<EuiFlexGroup justifyContent="flexEnd">
<EuiFlexItem grow={false}>
<EuiButtonEmpty onClick={onClose}>
{i18n.translate(
'xpack.apm.settings.agentConf.flyOut.cancelButtonLabel',
{
defaultMessage: 'Cancel'
}
)}
</EuiButtonEmpty>
</EuiFlexItem>
<EuiFlexItem grow={false}>
<EuiButton
fill
isDisabled={
!(
(selectedConfig && isSampleRateValid) ||
(!selectedConfig &&
serviceName &&
environment &&
isSelectedEnvironmentValid &&
isSampleRateValid)
)
}
onClick={async (event: React.MouseEvent<HTMLButtonElement>) => {
event.preventDefault();
await saveConfig({
environment,
serviceName,
sampleRate: parseFloat(sampleRate),
configurationId: selectedConfig
? selectedConfig.id
: undefined
});
onSubmit();
}}
>
{i18n.translate(
'xpack.apm.settings.agentConf.flyOut.saveConfigurationButtonLabel',
{
defaultMessage: 'Save configuration'
}
)}
</EuiButton>
</EuiFlexItem>
</EuiFlexGroup>
</EuiFlyoutFooter>
</EuiFlyout>
</EuiPortal>
);
}
async function deleteConfig(selectedConfig: Config) {
try {
await callApmApi({
pathname: '/api/apm/settings/agent-configuration/{configurationId}',
method: 'DELETE',
params: {
path: { configurationId: selectedConfig.id }
}
});
toastNotifications.addSuccess({
title: i18n.translate(
'xpack.apm.settings.agentConf.deleteConfigSucceededTitle',
{
defaultMessage: 'Configuration was deleted'
}
),
text: (
<FormattedMessage
id="xpack.apm.settings.agentConf.deleteConfigSucceededText"
defaultMessage="You have successfully deleted a configuration for {serviceName}. It will take some time to propagate to the agents."
values={{
serviceName: `"${selectedConfig.service.name}"`
}}
/>
)
});
} catch (error) {
toastNotifications.addDanger({
title: i18n.translate(
'xpack.apm.settings.agentConf.deleteConfigFailedTitle',
{
defaultMessage: 'Configuration could not be deleted'
}
),
text: (
<FormattedMessage
id="xpack.apm.settings.agentConf.deleteConfigFailedText"
defaultMessage="Something went wrong when deleting a configuration for {serviceName}. Error: {errorMessage}"
values={{
serviceName: `"${selectedConfig.service.name}"`,
errorMessage: `"${error.message}"`
}}
/>
)
});
}
}
async function saveConfig({
sampleRate,
serviceName,
environment,
configurationId
}: {
sampleRate: number;
serviceName: string | undefined;
environment: string | undefined;
configurationId?: string;
}) {
trackEvent({ app: 'apm', name: 'save_agent_configuration' });
try {
if (isNaN(sampleRate) || !serviceName) {
throw new Error('Missing arguments');
}
const configuration = {
settings: {
transaction_sample_rate: sampleRate
},
service: {
name: serviceName,
environment:
environment === ENVIRONMENT_NOT_DEFINED ? undefined : environment
}
};
if (configurationId) {
await callApmApi({
pathname: '/api/apm/settings/agent-configuration/{configurationId}',
method: 'PUT',
params: {
path: { configurationId },
body: configuration
}
});
toastNotifications.addSuccess({
title: i18n.translate(
'xpack.apm.settings.agentConf.editConfigSucceededTitle',
{
defaultMessage: 'Configuration edited'
}
),
text: (
<FormattedMessage
id="xpack.apm.settings.agentConf.editConfigSucceededText"
defaultMessage="You have successfully edited the configuration for {serviceName}. It will take some time to propagate to the agents."
values={{
serviceName: `"${serviceName}"`
}}
/>
)
});
} else {
await callApmApi({
pathname: '/api/apm/settings/agent-configuration/new',
method: 'POST',
params: {
body: configuration
}
});
toastNotifications.addSuccess({
title: i18n.translate(
'xpack.apm.settings.agentConf.createConfigSucceededTitle',
{
defaultMessage: 'Configuration created!'
}
),
text: (
<FormattedMessage
id="xpack.apm.settings.agentConf.createConfigSucceededText"
defaultMessage="You have successfully created a configuration for {serviceName}. It will take some time to propagate to the agents."
values={{
serviceName: `"${serviceName}"`
}}
/>
)
});
}
} catch (error) {
if (configurationId) {
toastNotifications.addDanger({
title: i18n.translate(
'xpack.apm.settings.agentConf.editConfigFailedTitle',
{
defaultMessage: 'Configuration could not be edited'
}
),
text: (
<FormattedMessage
id="xpack.apm.settings.agentConf.editConfigFailedText"
defaultMessage="Something went wrong when editing the configuration for {serviceName}. Error: {errorMessage}"
values={{
serviceName: `"${serviceName}"`,
errorMessage: `"${error.message}"`
}}
/>
)
});
} else {
toastNotifications.addDanger({
title: i18n.translate(
'xpack.apm.settings.agentConf.createConfigFailedTitle',
{
defaultMessage: 'Configuration could not be created'
}
),
text: (
<FormattedMessage
id="xpack.apm.settings.agentConf.createConfigFailedText"
defaultMessage="Something went wrong when creating a configuration for {serviceName}. Error: {errorMessage}"
values={{
serviceName: `"${serviceName}"`,
errorMessage: `"${error.message}"`
}}
/>
)
});
}
}
}

View file

@ -1,277 +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;
* you may not use this file except in compliance with the Elastic License.
*/
import React from 'react';
import {
EuiForm,
EuiFormRow,
EuiButton,
EuiFieldText,
EuiTitle,
EuiSpacer,
EuiHorizontalRule,
EuiText
} from '@elastic/eui';
import { i18n } from '@kbn/i18n';
import { isEmpty } from 'lodash';
import { FETCH_STATUS } from '../../../../hooks/useFetcher';
import { ENVIRONMENT_NOT_DEFINED } from '../../../../../common/environment_filter_values';
import { SelectWithPlaceholder } from '../../../shared/SelectWithPlaceholder';
import { Config } from '..';
const selectPlaceholderLabel = `- ${i18n.translate(
'xpack.apm.settings.agentConf.flyOut.selectPlaceholder',
{
defaultMessage: 'Select'
}
)} -`;
export function AddSettingFlyoutBody({
selectedConfig,
onDelete,
environment,
setEnvironment,
serviceName,
setServiceName,
sampleRate,
setSampleRate,
serviceNames,
serviceNamesStatus,
environments,
environmentStatus,
isSampleRateValid,
isSelectedEnvironmentValid
}: {
selectedConfig: Config | null;
onDelete: () => void;
environment?: string;
setEnvironment: (env: string | undefined) => void;
serviceName?: string;
setServiceName: (env: string | undefined) => void;
sampleRate: string;
setSampleRate: (env: string) => void;
serviceNames: string[];
serviceNamesStatus?: FETCH_STATUS;
environments: Array<{
name: string;
available: boolean;
}>;
environmentStatus?: FETCH_STATUS;
isSampleRateValid: boolean;
isSelectedEnvironmentValid: boolean;
}) {
const environmentOptions = environments.map(({ name, available }) => ({
disabled: !available,
text:
name === ENVIRONMENT_NOT_DEFINED
? i18n.translate(
'xpack.apm.settings.agentConf.flyOut.serviceEnvironmentNotSetOptionLabel',
{
defaultMessage: 'Not set'
}
)
: name,
value: name
}));
return (
<EuiForm>
<form>
<EuiTitle size="xs">
<h3>
{i18n.translate(
'xpack.apm.settings.agentConf.flyOut.serviceSectionTitle',
{
defaultMessage: 'Service'
}
)}
</h3>
</EuiTitle>
<EuiSpacer size="m" />
<EuiFormRow
label={i18n.translate(
'xpack.apm.settings.agentConf.flyOut.serviceNameSelectLabel',
{
defaultMessage: 'Name'
}
)}
helpText={i18n.translate(
'xpack.apm.settings.agentConf.flyOut.serviceNameSelectHelpText',
{
defaultMessage: 'Choose the service you want to configure.'
}
)}
>
<SelectWithPlaceholder
placeholder={selectPlaceholderLabel}
isLoading={serviceNamesStatus === 'loading'}
options={serviceNames.map(text => ({ text }))}
value={serviceName}
disabled={Boolean(selectedConfig)}
onChange={e => {
e.preventDefault();
setServiceName(e.target.value);
setEnvironment(undefined);
}}
/>
</EuiFormRow>
<EuiFormRow
label={i18n.translate(
'xpack.apm.settings.agentConf.flyOut.serviceEnvironmentSelectLabel',
{
defaultMessage: 'Environment'
}
)}
helpText={i18n.translate(
'xpack.apm.settings.agentConf.flyOut.serviceEnvironmentSelectHelpText',
{
defaultMessage:
'Only a single environment per configuration is supported.'
}
)}
error={i18n.translate(
'xpack.apm.settings.agentConf.flyOut.serviceEnvironmentSelectErrorText',
{
defaultMessage:
'Must select a valid environment to save a configuration.'
}
)}
isInvalid={
!(
selectedConfig ||
(!selectedConfig &&
environment &&
isSelectedEnvironmentValid &&
environmentStatus === 'success') ||
isEmpty(sampleRate)
)
}
>
<SelectWithPlaceholder
placeholder={selectPlaceholderLabel}
isLoading={environmentStatus === 'loading'}
options={environmentOptions}
value={environment}
disabled={!serviceName || Boolean(selectedConfig)}
onChange={e => {
e.preventDefault();
setEnvironment(e.target.value);
}}
/>
</EuiFormRow>
<EuiSpacer />
<EuiTitle size="xs">
<h3>
{i18n.translate(
'xpack.apm.settings.agentConf.flyOut.configurationSectionTitle',
{
defaultMessage: 'Configuration'
}
)}
</h3>
</EuiTitle>
<EuiSpacer size="m" />
<EuiFormRow
label={i18n.translate(
'xpack.apm.settings.agentConf.flyOut.sampleRateConfigurationInputLabel',
{
defaultMessage: 'Transaction sample rate'
}
)}
helpText={i18n.translate(
'xpack.apm.settings.agentConf.flyOut.sampleRateConfigurationInputHelpText',
{
defaultMessage:
'Choose a rate between 0.000 and 1.0. Default configuration is 1.0 (100% of traces).'
}
)}
error={i18n.translate(
'xpack.apm.settings.agentConf.flyOut.sampleRateConfigurationInputErrorText',
{
defaultMessage: 'Sample rate must be between 0.000 and 1'
}
)}
isInvalid={
!(
(Boolean(selectedConfig) &&
(isEmpty(sampleRate) || isSampleRateValid)) ||
(!selectedConfig &&
(!(serviceName || environment) ||
(isEmpty(sampleRate) || isSampleRateValid)))
)
}
>
<EuiFieldText
placeholder={i18n.translate(
'xpack.apm.settings.agentConf.flyOut.sampleRateConfigurationInputPlaceholderText',
{
defaultMessage: 'Set sample rate'
}
)}
value={sampleRate}
onChange={e => {
e.preventDefault();
setSampleRate(e.target.value);
}}
disabled={!(serviceName && environment) && !selectedConfig}
/>
</EuiFormRow>
{selectedConfig ? (
<>
<EuiHorizontalRule margin="m" />
<EuiTitle size="xs">
<h3>
<EuiText color="danger">
{i18n.translate(
'xpack.apm.settings.agentConf.flyOut.deleteConfigurationSectionTitle',
{
defaultMessage: 'Delete configuration'
}
)}
</EuiText>
</h3>
</EuiTitle>
<EuiSpacer size="s" />
<EuiText>
<p>
{i18n.translate(
'xpack.apm.settings.agentConf.flyOut.deleteConfigurationSectionText',
{
defaultMessage:
'If you wish to delete this configuration, please be aware that the agents will continue to use the existing configuration until they sync with the APM Server.'
}
)}
</p>
</EuiText>
<EuiSpacer size="s" />
<EuiButton fill={false} color="danger" onClick={onDelete}>
{i18n.translate(
'xpack.apm.settings.agentConf.flyOut.deleteConfigurationButtonLabel',
{
defaultMessage: 'Delete'
}
)}
</EuiButton>
<EuiSpacer size="m" />
</>
) : null}
</form>
</EuiForm>
);
}

View file

@ -0,0 +1,92 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
import React, { useState } from 'react';
import { EuiButtonEmpty } from '@elastic/eui';
import { NotificationsStart } from 'kibana/public';
import { i18n } from '@kbn/i18n';
import { Config } from '../index';
import { callApmApi } from '../../../../../services/rest/callApmApi';
import { getOptionLabel } from '../../../../../../common/agent_configuration_constants';
import { useKibanaCore } from '../../../../../../../observability/public';
interface Props {
onDeleted: () => void;
selectedConfig: Config;
}
export function DeleteButton({ onDeleted, selectedConfig }: Props) {
const [isDeleting, setIsDeleting] = useState(false);
const {
notifications: { toasts }
} = useKibanaCore();
return (
<EuiButtonEmpty
color="danger"
isLoading={isDeleting}
iconSide="right"
onClick={async () => {
setIsDeleting(true);
await deleteConfig(selectedConfig, toasts);
setIsDeleting(false);
onDeleted();
}}
>
{i18n.translate(
'xpack.apm.settings.agentConf.flyout.deleteSection.buttonLabel',
{ defaultMessage: 'Delete' }
)}
</EuiButtonEmpty>
);
}
async function deleteConfig(
selectedConfig: Config,
toasts: NotificationsStart['toasts']
) {
try {
await callApmApi({
pathname: '/api/apm/settings/agent-configuration/{configurationId}',
method: 'DELETE',
params: {
path: { configurationId: selectedConfig.id }
}
});
toasts.addSuccess({
title: i18n.translate(
'xpack.apm.settings.agentConf.flyout.deleteSection.deleteConfigSucceededTitle',
{ defaultMessage: 'Configuration was deleted' }
),
text: i18n.translate(
'xpack.apm.settings.agentConf.flyout.deleteSection.deleteConfigSucceededText',
{
defaultMessage:
'You have successfully deleted a configuration for "{serviceName}". It will take some time to propagate to the agents.',
values: { serviceName: getOptionLabel(selectedConfig.service.name) }
}
)
});
} catch (error) {
toasts.addDanger({
title: i18n.translate(
'xpack.apm.settings.agentConf.flyout.deleteSection.deleteConfigFailedTitle',
{ defaultMessage: 'Configuration could not be deleted' }
),
text: i18n.translate(
'xpack.apm.settings.agentConf.flyout.deleteSection.deleteConfigFailedText',
{
defaultMessage:
'Something went wrong when deleting a configuration for "{serviceName}". Error: "{errorMessage}"',
values: {
serviceName: getOptionLabel(selectedConfig.service.name),
errorMessage: error.message
}
}
)
});
}
}

View file

@ -0,0 +1,160 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
import { EuiTitle, EuiSpacer, EuiFormRow, EuiText } from '@elastic/eui';
import React from 'react';
import { i18n } from '@kbn/i18n';
import { SelectWithPlaceholder } from '../../../../shared/SelectWithPlaceholder';
import { useFetcher } from '../../../../../hooks/useFetcher';
import { callApmApi } from '../../../../../services/rest/callApmApi';
import {
getOptionLabel,
omitAllOption
} from '../../../../../../common/agent_configuration_constants';
const SELECT_PLACEHOLDER_LABEL = `- ${i18n.translate(
'xpack.apm.settings.agentConf.flyOut.serviceSection.selectPlaceholder',
{ defaultMessage: 'Select' }
)} -`;
interface Props {
isReadOnly: boolean;
serviceName: string;
setServiceName: (env: string) => void;
environment: string;
setEnvironment: (env: string) => void;
}
export function ServiceSection({
isReadOnly,
serviceName,
setServiceName,
environment,
setEnvironment
}: Props) {
const { data: serviceNames = [], status: serviceNamesStatus } = useFetcher(
() => {
if (!isReadOnly) {
return callApmApi({
pathname: '/api/apm/settings/agent-configuration/services',
forceCache: true
});
}
},
[isReadOnly],
{ preservePreviousData: false }
);
const { data: environments = [], status: environmentStatus } = useFetcher(
() => {
if (!isReadOnly && serviceName) {
return callApmApi({
pathname: '/api/apm/settings/agent-configuration/environments',
params: { query: { serviceName: omitAllOption(serviceName) } }
});
}
},
[isReadOnly, serviceName],
{ preservePreviousData: false }
);
const ALREADY_CONFIGURED_TRANSLATED = i18n.translate(
'xpack.apm.settings.agentConf.flyOut.serviceSection.alreadyConfiguredOption',
{ defaultMessage: 'already configured' }
);
const serviceNameOptions = serviceNames.map(name => ({
text: getOptionLabel(name),
value: name
}));
const environmentOptions = environments.map(
({ name, alreadyConfigured }) => ({
disabled: alreadyConfigured,
text: `${getOptionLabel(name)} ${
alreadyConfigured ? `(${ALREADY_CONFIGURED_TRANSLATED})` : ''
}`,
value: name
})
);
return (
<>
<EuiTitle size="xs">
<h3>
{i18n.translate(
'xpack.apm.settings.agentConf.flyOut.serviceSection.title',
{ defaultMessage: 'Service' }
)}
</h3>
</EuiTitle>
<EuiSpacer size="m" />
<EuiFormRow
label={i18n.translate(
'xpack.apm.settings.agentConf.flyOut.serviceSection.serviceNameSelectLabel',
{ defaultMessage: 'Name' }
)}
helpText={
!isReadOnly &&
i18n.translate(
'xpack.apm.settings.agentConf.flyOut.serviceSection.serviceNameSelectHelpText',
{ defaultMessage: 'Choose the service you want to configure.' }
)
}
>
{isReadOnly ? (
<EuiText>{getOptionLabel(serviceName)}</EuiText>
) : (
<SelectWithPlaceholder
placeholder={SELECT_PLACEHOLDER_LABEL}
isLoading={serviceNamesStatus === 'loading'}
options={serviceNameOptions}
value={serviceName}
disabled={serviceNamesStatus === 'loading'}
onChange={e => {
e.preventDefault();
setServiceName(e.target.value);
setEnvironment('');
}}
/>
)}
</EuiFormRow>
<EuiFormRow
label={i18n.translate(
'xpack.apm.settings.agentConf.flyOut.serviceSection.serviceEnvironmentSelectLabel',
{ defaultMessage: 'Environment' }
)}
helpText={
!isReadOnly &&
i18n.translate(
'xpack.apm.settings.agentConf.flyOut.serviceSection.serviceEnvironmentSelectHelpText',
{
defaultMessage:
'Only a single environment per configuration is supported.'
}
)
}
>
{isReadOnly ? (
<EuiText>{getOptionLabel(environment)}</EuiText>
) : (
<SelectWithPlaceholder
placeholder={SELECT_PLACEHOLDER_LABEL}
isLoading={environmentStatus === 'loading'}
options={environmentOptions}
value={environment}
disabled={!serviceName || environmentStatus === 'loading'}
onChange={e => {
e.preventDefault();
setEnvironment(e.target.value);
}}
/>
)}
</EuiFormRow>
</>
);
}

View file

@ -0,0 +1,174 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
import React from 'react';
import {
EuiFormRow,
EuiFieldText,
EuiTitle,
EuiSpacer,
EuiFieldNumber
} from '@elastic/eui';
import { i18n } from '@kbn/i18n';
import { isEmpty } from 'lodash';
import { SelectWithPlaceholder } from '../../../../shared/SelectWithPlaceholder';
interface Props {
isRumService: boolean;
// sampleRate
sampleRate: string;
setSampleRate: (value: string) => void;
isSampleRateValid?: boolean;
// captureBody
captureBody: string;
setCaptureBody: (value: string) => void;
// transactionMaxSpans
transactionMaxSpans: string;
setTransactionMaxSpans: (value: string) => void;
isTransactionMaxSpansValid?: boolean;
}
export function SettingsSection({
isRumService,
// sampleRate
sampleRate,
setSampleRate,
isSampleRateValid,
// captureBody
captureBody,
setCaptureBody,
// transactionMaxSpans
transactionMaxSpans,
setTransactionMaxSpans,
isTransactionMaxSpansValid
}: Props) {
return (
<>
<EuiTitle size="xs">
<h3>
{i18n.translate(
'xpack.apm.settings.agentConf.flyOut.settingsSection.title',
{ defaultMessage: 'Options' }
)}
</h3>
</EuiTitle>
<EuiSpacer size="m" />
<EuiFormRow
label={i18n.translate(
'xpack.apm.settings.agentConf.flyOut.settingsSection.sampleRateConfigurationInputLabel',
{ defaultMessage: 'Transaction sample rate' }
)}
helpText={i18n.translate(
'xpack.apm.settings.agentConf.flyOut.settingsSection.sampleRateConfigurationInputHelpText',
{
defaultMessage:
'Choose a rate between 0.000 and 1.0. Default is 1.0 (100% of traces).'
}
)}
error={i18n.translate(
'xpack.apm.settings.agentConf.flyOut.settingsSection.sampleRateConfigurationInputErrorText',
{ defaultMessage: 'Sample rate must be between 0.000 and 1' }
)}
isInvalid={!isEmpty(sampleRate) && !isSampleRateValid}
>
<EuiFieldText
placeholder={i18n.translate(
'xpack.apm.settings.agentConf.flyOut.settingsSection.sampleRateConfigurationInputPlaceholderText',
{ defaultMessage: 'Set sample rate' }
)}
value={sampleRate}
onChange={e => {
e.preventDefault();
setSampleRate(e.target.value);
}}
/>
</EuiFormRow>
<EuiSpacer size="m" />
{!isRumService && (
<EuiFormRow
label={i18n.translate(
'xpack.apm.settings.agentConf.flyOut.settingsSection.captureBodyInputLabel',
{ defaultMessage: 'Capture body' }
)}
helpText={i18n.translate(
'xpack.apm.settings.agentConf.flyOut.settingsSection.captureBodyInputHelpText',
{
defaultMessage:
'For transactions that are HTTP requests, the agent can optionally capture the request body (e.g. POST variables). Default is "off".'
}
)}
>
<SelectWithPlaceholder
placeholder={i18n.translate(
'xpack.apm.settings.agentConf.flyOut.settingsSection.captureBodyInputPlaceholderText',
{ defaultMessage: 'Select option' }
)}
options={[
{ text: 'off' },
{ text: 'errors' },
{ text: 'transactions' },
{ text: 'all' }
]}
value={captureBody}
onChange={e => {
e.preventDefault();
setCaptureBody(e.target.value);
}}
/>
</EuiFormRow>
)}
{!isRumService && (
<EuiFormRow
label={i18n.translate(
'xpack.apm.settings.agentConf.flyOut.settingsSection.transactionMaxSpansConfigInputLabel',
{ defaultMessage: 'Transaction max spans' }
)}
helpText={i18n.translate(
'xpack.apm.settings.agentConf.flyOut.settingsSection.transactionMaxSpansConfigInputHelpText',
{
defaultMessage:
'Limits the amount of spans that are recorded per transaction. Default is 500.'
}
)}
error={i18n.translate(
'xpack.apm.settings.agentConf.flyOut.settingsSection.transactionMaxSpansConfigInputErrorText',
{ defaultMessage: 'Must be between 0 and 32000' }
)}
isInvalid={
!isEmpty(transactionMaxSpans) && !isTransactionMaxSpansValid
}
>
<EuiFieldNumber
placeholder={i18n.translate(
'xpack.apm.settings.agentConf.flyOut.settingsSection.transactionMaxSpansConfigInputPlaceholderText',
{ defaultMessage: 'Set transaction max spans' }
)}
value={
transactionMaxSpans === '' ? '' : Number(transactionMaxSpans)
}
min={0}
max={32000}
onChange={e => {
e.preventDefault();
setTransactionMaxSpans(e.target.value);
}}
/>
</EuiFormRow>
)}
</>
);
}

View file

@ -0,0 +1,256 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
import {
EuiButton,
EuiButtonEmpty,
EuiFlexGroup,
EuiFlexItem,
EuiFlyout,
EuiFlyoutBody,
EuiFlyoutFooter,
EuiFlyoutHeader,
EuiForm,
EuiPortal,
EuiTitle,
EuiText,
EuiSpacer
} from '@elastic/eui';
import { idx } from '@kbn/elastic-idx';
import React, { useState } from 'react';
import { i18n } from '@kbn/i18n';
import { isRight } from 'fp-ts/lib/Either';
import { transactionSampleRateRt } from '../../../../../../common/runtime_types/transaction_sample_rate_rt';
import { callApmApi } from '../../../../../services/rest/callApmApi';
import { Config } from '../index';
import { SettingsSection } from './SettingsSection';
import { ServiceSection } from './ServiceSection';
import { DeleteButton } from './DeleteButton';
import { transactionMaxSpansRt } from '../../../../../../common/runtime_types/transaction_max_spans_rt';
import { useFetcher } from '../../../../../hooks/useFetcher';
import { isRumAgentName } from '../../../../../../common/agent_name';
import { ALL_OPTION_VALUE } from '../../../../../../common/agent_configuration_constants';
import { saveConfig } from './saveConfig';
import { useKibanaCore } from '../../../../../../../observability/public';
const defaultSettings = {
TRANSACTION_SAMPLE_RATE: '1.0',
CAPTURE_BODY: 'off',
TRANSACTION_MAX_SPANS: '500'
};
interface Props {
onClose: () => void;
onSaved: () => void;
onDeleted: () => void;
selectedConfig: Config | null;
}
export function AddEditFlyout({
onClose,
onSaved,
onDeleted,
selectedConfig
}: Props) {
const {
notifications: { toasts }
} = useKibanaCore();
const [isSaving, setIsSaving] = useState(false);
// config conditions (service)
const [serviceName, setServiceName] = useState<string>(
selectedConfig ? selectedConfig.service.name || ALL_OPTION_VALUE : ''
);
const [environment, setEnvironment] = useState<string>(
selectedConfig ? selectedConfig.service.environment || ALL_OPTION_VALUE : ''
);
const { data: { agentName } = { agentName: undefined } } = useFetcher(
() => {
if (serviceName === ALL_OPTION_VALUE) {
return { agentName: undefined };
}
if (serviceName) {
return callApmApi({
pathname: '/api/apm/settings/agent-configuration/agent_name',
params: { query: { serviceName } }
});
}
},
[serviceName],
{ preservePreviousData: false }
);
// config settings
const [sampleRate, setSampleRate] = useState<string>(
(
idx(selectedConfig, _ => _.settings.transaction_sample_rate) ||
defaultSettings.TRANSACTION_SAMPLE_RATE
).toString()
);
const [captureBody, setCaptureBody] = useState<string>(
idx(selectedConfig, _ => _.settings.capture_body) ||
defaultSettings.CAPTURE_BODY
);
const [transactionMaxSpans, setTransactionMaxSpans] = useState<string>(
(
idx(selectedConfig, _ => _.settings.transaction_max_spans) ||
defaultSettings.TRANSACTION_MAX_SPANS
).toString()
);
const isRumService = isRumAgentName(agentName);
const isSampleRateValid = isRight(transactionSampleRateRt.decode(sampleRate));
const isTransactionMaxSpansValid = isRight(
transactionMaxSpansRt.decode(transactionMaxSpans)
);
const isFormValid =
!!serviceName &&
!!environment &&
isSampleRateValid &&
// captureBody and isTransactionMaxSpansValid are required except if service is RUM
(isRumService || (!!captureBody && isTransactionMaxSpansValid)) &&
// agent name is required, except if serviceName is "all"
(serviceName === ALL_OPTION_VALUE || agentName !== undefined);
const handleSubmitEvent = async (
event:
| React.FormEvent<HTMLFormElement>
| React.MouseEvent<HTMLButtonElement>
) => {
event.preventDefault();
setIsSaving(true);
await saveConfig({
serviceName,
environment,
sampleRate,
captureBody,
transactionMaxSpans,
configurationId: selectedConfig ? selectedConfig.id : undefined,
agentName,
toasts
});
setIsSaving(false);
onSaved();
};
return (
<EuiPortal>
<EuiFlyout size="s" onClose={onClose} ownFocus={true}>
<EuiFlyoutHeader hasBorder>
<EuiTitle>
<h2>
{selectedConfig
? i18n.translate(
'xpack.apm.settings.agentConf.editConfigTitle',
{ defaultMessage: 'Edit configuration' }
)
: i18n.translate(
'xpack.apm.settings.agentConf.createConfigTitle',
{ defaultMessage: 'Create configuration' }
)}
</h2>
</EuiTitle>
</EuiFlyoutHeader>
<EuiFlyoutBody>
<EuiText size="s">
This allows you to fine-tune your agent configuration directly in
Kibana. Best of all, changes are automatically propagated to your
APM agents so theres no need to redeploy.
</EuiText>
<EuiSpacer size="m" />
<EuiForm>
{/* eslint-disable-next-line jsx-a11y/no-noninteractive-element-interactions */}
<form
onKeyPress={e => {
const didClickEnter = e.which === 13;
if (didClickEnter) {
handleSubmitEvent(e);
}
}}
>
<ServiceSection
isReadOnly={Boolean(selectedConfig)}
//
// environment
environment={environment}
setEnvironment={setEnvironment}
//
// serviceName
serviceName={serviceName}
setServiceName={setServiceName}
/>
<EuiSpacer />
<SettingsSection
isRumService={isRumService}
//
// sampleRate
sampleRate={sampleRate}
setSampleRate={setSampleRate}
isSampleRateValid={isSampleRateValid}
//
// captureBody
captureBody={captureBody}
setCaptureBody={setCaptureBody}
//
// transactionMaxSpans
transactionMaxSpans={transactionMaxSpans}
setTransactionMaxSpans={setTransactionMaxSpans}
isTransactionMaxSpansValid={isTransactionMaxSpansValid}
/>
</form>
</EuiForm>
</EuiFlyoutBody>
<EuiFlyoutFooter>
<EuiFlexGroup justifyContent="spaceBetween">
<EuiFlexItem grow={false}>
{selectedConfig ? (
<DeleteButton
selectedConfig={selectedConfig}
onDeleted={onDeleted}
/>
) : null}
</EuiFlexItem>
<EuiFlexItem grow={false}>
<EuiFlexGroup justifyContent="flexEnd">
<EuiFlexItem grow={false}>
<EuiButtonEmpty onClick={onClose}>
{i18n.translate(
'xpack.apm.settings.agentConf.cancelButtonLabel',
{ defaultMessage: 'Cancel' }
)}
</EuiButtonEmpty>
</EuiFlexItem>
<EuiFlexItem grow={false}>
<EuiButton
type="submit"
fill
isLoading={isSaving}
iconSide="right"
isDisabled={!isFormValid}
onClick={handleSubmitEvent}
>
{i18n.translate(
'xpack.apm.settings.agentConf.saveConfigurationButtonLabel',
{ defaultMessage: 'Save' }
)}
</EuiButton>
</EuiFlexItem>
</EuiFlexGroup>
</EuiFlexItem>
</EuiFlexGroup>
</EuiFlyoutFooter>
</EuiFlyout>
</EuiPortal>
);
}

View file

@ -0,0 +1,115 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
import { i18n } from '@kbn/i18n';
import { NotificationsStart } from 'kibana/public';
import { trackEvent } from '../../../../../../../infra/public/hooks/use_track_metric';
import { isRumAgentName } from '../../../../../../common/agent_name';
import {
getOptionLabel,
omitAllOption
} from '../../../../../../common/agent_configuration_constants';
import { callApmApi } from '../../../../../services/rest/callApmApi';
interface Settings {
transaction_sample_rate: number;
capture_body?: string;
transaction_max_spans?: number;
}
export async function saveConfig({
serviceName,
environment,
sampleRate,
captureBody,
transactionMaxSpans,
configurationId,
agentName,
toasts
}: {
serviceName: string;
environment: string;
sampleRate: string;
captureBody: string;
transactionMaxSpans: string;
configurationId?: string;
agentName?: string;
toasts: NotificationsStart['toasts'];
}) {
trackEvent({ app: 'apm', name: 'save_agent_configuration' });
try {
const settings: Settings = {
transaction_sample_rate: Number(sampleRate)
};
if (!isRumAgentName(agentName)) {
settings.capture_body = captureBody;
settings.transaction_max_spans = Number(transactionMaxSpans);
}
const configuration = {
agent_name: agentName,
service: {
name: omitAllOption(serviceName),
environment: omitAllOption(environment)
},
settings
};
if (configurationId) {
await callApmApi({
pathname: '/api/apm/settings/agent-configuration/{configurationId}',
method: 'PUT',
params: {
path: { configurationId },
body: configuration
}
});
} else {
await callApmApi({
pathname: '/api/apm/settings/agent-configuration/new',
method: 'POST',
params: {
body: configuration
}
});
}
toasts.addSuccess({
title: i18n.translate(
'xpack.apm.settings.agentConf.saveConfig.succeeded.title',
{ defaultMessage: 'Configuration saved' }
),
text: i18n.translate(
'xpack.apm.settings.agentConf.saveConfig.succeeded.text',
{
defaultMessage:
'The configuration for "{serviceName}" was saved. It will take some time to propagate to the agents.',
values: { serviceName: getOptionLabel(serviceName) }
}
)
});
} catch (error) {
toasts.addDanger({
title: i18n.translate(
'xpack.apm.settings.agentConf.saveConfig.failed.title',
{ defaultMessage: 'Configuration could not be saved' }
),
text: i18n.translate(
'xpack.apm.settings.agentConf.saveConfig.failed.text',
{
defaultMessage:
'Something went wrong when saving the configuration for "{serviceName}". Error: "{errorMessage}"',
values: {
serviceName: getOptionLabel(serviceName),
errorMessage: error.message
}
}
)
});
}
}

View file

@ -5,17 +5,26 @@
*/
import React from 'react';
import moment from 'moment';
import { i18n } from '@kbn/i18n';
import { EuiEmptyPrompt, EuiButton, EuiButtonEmpty } from '@elastic/eui';
import {
EuiEmptyPrompt,
EuiButton,
EuiButtonEmpty,
EuiHealth,
EuiToolTip
} from '@elastic/eui';
import { isEmpty } from 'lodash';
import { FETCH_STATUS } from '../../../hooks/useFetcher';
import { ITableColumn, ManagedTable } from '../../shared/ManagedTable';
import { LoadingStatePrompt } from '../../shared/LoadingStatePrompt';
import { AgentConfigurationListAPIResponse } from '../../../../server/lib/settings/agent_configuration/list_configurations';
import theme from '@elastic/eui/dist/eui_theme_light.json';
import { FETCH_STATUS } from '../../../../hooks/useFetcher';
import { ITableColumn, ManagedTable } from '../../../shared/ManagedTable';
import { LoadingStatePrompt } from '../../../shared/LoadingStatePrompt';
import { AgentConfigurationListAPIResponse } from '../../../../../server/lib/settings/agent_configuration/list_configurations';
import { Config } from '.';
import { TimestampTooltip } from '../../../shared/TimestampTooltip';
import { px, units } from '../../../../style/variables';
import { getOptionLabel } from '../../../../../common/agent_configuration_constants';
export function SettingsList({
export function AgentConfigurationList({
status,
data,
setIsFlyoutOpen,
@ -23,21 +32,44 @@ export function SettingsList({
}: {
status: FETCH_STATUS;
data: AgentConfigurationListAPIResponse;
setIsFlyoutOpen: React.Dispatch<React.SetStateAction<boolean>>;
setSelectedConfig: React.Dispatch<React.SetStateAction<Config | null>>;
setIsFlyoutOpen: (val: boolean) => void;
setSelectedConfig: (val: Config | null) => void;
}) {
const columns: Array<ITableColumn<Config>> = [
{
field: 'applied_by_agent',
align: 'center',
width: px(units.double),
name: '',
sortable: true,
render: (isApplied: boolean) => (
<EuiToolTip
content={
isApplied
? i18n.translate(
'xpack.apm.settings.agentConf.configTable.appliedTooltipMessage',
{ defaultMessage: 'Applied by at least one agent' }
)
: i18n.translate(
'xpack.apm.settings.agentConf.configTable.notAppliedTooltipMessage',
{ defaultMessage: 'Not yet applied by any agents' }
)
}
>
<EuiHealth color={isApplied ? 'success' : theme.euiColorLightShade} />
</EuiToolTip>
)
},
{
field: 'service.name',
name: i18n.translate(
'xpack.apm.settings.agentConf.configTable.serviceNameColumnLabel',
{
defaultMessage: 'Service name'
}
{ defaultMessage: 'Service name' }
),
sortable: true,
render: (_, config: Config) => (
<EuiButtonEmpty
flush="left"
size="s"
color="primary"
onClick={() => {
@ -45,7 +77,7 @@ export function SettingsList({
setIsFlyoutOpen(true);
}}
>
{config.service.name}
{getOptionLabel(config.service.name)}
</EuiButtonEmpty>
)
},
@ -53,50 +85,64 @@ export function SettingsList({
field: 'service.environment',
name: i18n.translate(
'xpack.apm.settings.agentConf.configTable.environmentColumnLabel',
{
defaultMessage: 'Service environment'
}
{ defaultMessage: 'Service environment' }
),
sortable: true,
render: (value: string) => value
render: (value: string) => getOptionLabel(value)
},
{
field: 'settings.transaction_sample_rate',
name: i18n.translate(
'xpack.apm.settings.agentConf.configTable.sampleRateColumnLabel',
{
defaultMessage: 'Sample rate'
}
{ defaultMessage: 'Sample rate' }
),
dataType: 'number',
sortable: true,
render: (value: number) => value
},
{
field: 'settings.capture_body',
name: i18n.translate(
'xpack.apm.settings.agentConf.configTable.captureBodyColumnLabel',
{ defaultMessage: 'Capture body' }
),
sortable: true,
render: (value: string) => value
},
{
field: 'settings.transaction_max_spans',
name: i18n.translate(
'xpack.apm.settings.agentConf.configTable.transactionMaxSpansColumnLabel',
{ defaultMessage: 'Transaction max spans' }
),
dataType: 'number',
sortable: true,
render: (value: number) => value
},
{
align: 'right',
field: '@timestamp',
name: i18n.translate(
'xpack.apm.settings.agentConf.configTable.lastUpdatedColumnLabel',
{
defaultMessage: 'Last updated'
}
{ defaultMessage: 'Last updated' }
),
sortable: true,
render: (value: number) => (value ? moment(value).fromNow() : null)
render: (value: number) => (
<TimestampTooltip time={value} precision="minutes" />
)
},
{
width: px(units.double),
name: '',
actions: [
{
name: i18n.translate(
'xpack.apm.settings.agentConf.configTable.editButtonLabel',
{
defaultMessage: 'Edit'
}
{ defaultMessage: 'Edit' }
),
description: i18n.translate(
'xpack.apm.settings.agentConf.configTable.editButtonDescription',
{
defaultMessage: 'Edit this config'
}
{ defaultMessage: 'Edit this config' }
),
icon: 'pencil',
color: 'primary',
@ -137,10 +183,8 @@ export function SettingsList({
actions={
<EuiButton color="primary" fill onClick={() => setIsFlyoutOpen(true)}>
{i18n.translate(
'xpack.apm.settings.agentConf.createConfigButtonLabel',
{
defaultMessage: 'Create configuration'
}
'xpack.apm.settings.agentConf.configTable.createConfigButtonLabel',
{ defaultMessage: 'Create configuration' }
)}
</EuiButton>
}
@ -154,7 +198,7 @@ export function SettingsList({
<>
<p>
{i18n.translate(
'xpack.apm.settings.agentConf.configTable.failurePromptText',
'xpack.apm.settings.agentConf.configTable.configTable.failurePromptText',
{
defaultMessage:
'The list of agent configurations could not be fetched. Your user may not have the sufficient permissions.'
@ -165,26 +209,23 @@ export function SettingsList({
}
/>
);
const hasConfigurations = !isEmpty(data);
if (status === 'failure') {
return failurePrompt;
}
if (status === 'success') {
if (hasConfigurations) {
return (
<ManagedTable
noItemsMessage={<LoadingStatePrompt />}
columns={columns}
items={data}
initialSortField="service.name"
initialSortDirection="asc"
initialPageSize={50}
/>
);
} else {
return emptyStatePrompt;
}
if (status === 'success' && isEmpty(data)) {
return emptyStatePrompt;
}
return null;
return (
<ManagedTable
noItemsMessage={<LoadingStatePrompt />}
columns={columns}
items={data}
initialSortField="service.name"
initialSortDirection="asc"
initialPageSize={50}
/>
);
}

View file

@ -0,0 +1,140 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
import React, { useState } from 'react';
import { i18n } from '@kbn/i18n';
import {
EuiTitle,
EuiFlexGroup,
EuiFlexItem,
EuiButtonEmpty,
EuiPanel,
EuiSpacer,
EuiButton
} from '@elastic/eui';
import { isEmpty } from 'lodash';
import { useFetcher } from '../../../../hooks/useFetcher';
import { AgentConfigurationListAPIResponse } from '../../../../../server/lib/settings/agent_configuration/list_configurations';
import { callApmApi } from '../../../../services/rest/callApmApi';
import { HomeLink } from '../../../shared/Links/apm/HomeLink';
import { AgentConfigurationList } from './AgentConfigurationList';
import { useTrackPageview } from '../../../../../../infra/public';
import { AddEditFlyout } from './AddEditFlyout';
export type Config = AgentConfigurationListAPIResponse[0];
export function AgentConfigurations() {
const { data = [], status, refetch } = useFetcher(
() => callApmApi({ pathname: `/api/apm/settings/agent-configuration` }),
[],
{ preservePreviousData: false }
);
const [selectedConfig, setSelectedConfig] = useState<Config | null>(null);
const [isFlyoutOpen, setIsFlyoutOpen] = useState(false);
useTrackPageview({ app: 'apm', path: 'agent_configuration' });
useTrackPageview({ app: 'apm', path: 'agent_configuration', delay: 15000 });
const hasConfigurations = !isEmpty(data);
const onClose = () => {
setSelectedConfig(null);
setIsFlyoutOpen(false);
};
return (
<>
{isFlyoutOpen && (
<AddEditFlyout
selectedConfig={selectedConfig}
onClose={onClose}
onSaved={() => {
onClose();
refetch();
}}
onDeleted={() => {
onClose();
refetch();
}}
/>
)}
<EuiFlexGroup alignItems="center">
<EuiFlexItem grow={false}>
<EuiTitle size="l">
<h1>
{i18n.translate('xpack.apm.settings.agentConf.pageTitle', {
defaultMessage: 'Settings'
})}
</h1>
</EuiTitle>
</EuiFlexItem>
<EuiFlexItem grow={false}>
<HomeLink>
<EuiButtonEmpty size="s" color="primary" iconType="arrowLeft">
{i18n.translate(
'xpack.apm.settings.agentConf.returnToOverviewLinkLabel',
{ defaultMessage: 'Return to overview' }
)}
</EuiButtonEmpty>
</HomeLink>
</EuiFlexItem>
</EuiFlexGroup>
<EuiSpacer size="l" />
<EuiPanel>
<EuiFlexGroup alignItems="center">
<EuiFlexItem grow={false}>
<EuiTitle>
<h2>
{i18n.translate(
'xpack.apm.settings.agentConf.configurationsPanelTitle',
{ defaultMessage: 'Agent remote configuration' }
)}
</h2>
</EuiTitle>
</EuiFlexItem>
{hasConfigurations ? (
<CreateConfigurationButton onClick={() => setIsFlyoutOpen(true)} />
) : null}
</EuiFlexGroup>
<EuiSpacer size="m" />
<AgentConfigurationList
status={status}
data={data}
setIsFlyoutOpen={setIsFlyoutOpen}
setSelectedConfig={setSelectedConfig}
/>
</EuiPanel>
</>
);
}
function CreateConfigurationButton({ onClick }: { onClick: () => void }) {
return (
<EuiFlexItem>
<EuiFlexGroup alignItems="center" justifyContent="flexEnd">
<EuiFlexItem grow={false}>
<EuiButton
color="primary"
fill
iconType="plusInCircle"
onClick={onClick}
>
{i18n.translate(
'xpack.apm.settings.agentConf.createConfigButtonLabel',
{ defaultMessage: 'Create configuration' }
)}
</EuiButton>
</EuiFlexItem>
</EuiFlexGroup>
</EuiFlexItem>
);
}

View file

@ -1,187 +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;
* you may not use this file except in compliance with the Elastic License.
*/
import React, { useState } from 'react';
import { i18n } from '@kbn/i18n';
import { FormattedMessage } from '@kbn/i18n/react';
import {
EuiTitle,
EuiFlexGroup,
EuiFlexItem,
EuiButtonEmpty,
EuiPanel,
EuiBetaBadge,
EuiSpacer,
EuiCallOut,
EuiButton,
EuiLink
} from '@elastic/eui';
import { isEmpty } from 'lodash';
import { useFetcher } from '../../../hooks/useFetcher';
import { AgentConfigurationListAPIResponse } from '../../../../server/lib/settings/agent_configuration/list_configurations';
import { AddSettingsFlyout } from './AddSettings/AddSettingFlyout';
import { callApmApi } from '../../../services/rest/callApmApi';
import { HomeLink } from '../../shared/Links/apm/HomeLink';
import { SettingsList } from './SettingsList';
import { useTrackPageview } from '../../../../../infra/public';
export type Config = AgentConfigurationListAPIResponse[0];
export function Settings() {
const { data = [], status, refresh } = useFetcher(
() =>
callApmApi({
pathname: `/api/apm/settings/agent-configuration`
}),
[]
);
const [selectedConfig, setSelectedConfig] = useState<Config | null>(null);
const [isFlyoutOpen, setIsFlyoutOpen] = useState(false);
useTrackPageview({ app: 'apm', path: 'agent_configuration' });
useTrackPageview({ app: 'apm', path: 'agent_configuration', delay: 15000 });
const RETURN_TO_OVERVIEW_LINK_LABEL = i18n.translate(
'xpack.apm.settings.agentConf.returnToOverviewLinkLabel',
{
defaultMessage: 'Return to overview'
}
);
const hasConfigurations = !isEmpty(data);
return (
<>
<AddSettingsFlyout
isOpen={isFlyoutOpen}
selectedConfig={selectedConfig}
onClose={() => {
setSelectedConfig(null);
setIsFlyoutOpen(false);
}}
onSubmit={() => {
setSelectedConfig(null);
setIsFlyoutOpen(false);
refresh();
}}
/>
<EuiFlexGroup alignItems="center">
<EuiFlexItem grow={false}>
<EuiTitle size="l">
<h1>
{i18n.translate('xpack.apm.settings.agentConf.pageTitle', {
defaultMessage: 'Settings'
})}
</h1>
</EuiTitle>
</EuiFlexItem>
<EuiFlexItem grow={false}>
<HomeLink>
<EuiButtonEmpty size="s" color="primary" iconType="arrowLeft">
{RETURN_TO_OVERVIEW_LINK_LABEL}
</EuiButtonEmpty>
</HomeLink>
</EuiFlexItem>
</EuiFlexGroup>
<EuiSpacer size="l" />
<EuiPanel>
<EuiFlexGroup alignItems="center">
<EuiFlexItem grow={false}>
<EuiTitle>
<h2>
{i18n.translate(
'xpack.apm.settings.agentConf.configurationsPanelTitle',
{
defaultMessage: 'Configurations'
}
)}
</h2>
</EuiTitle>
</EuiFlexItem>
<EuiFlexItem grow={false}>
<EuiBetaBadge
label={i18n.translate(
'xpack.apm.settings.agentConf.betaBadgeLabel',
{
defaultMessage: 'Beta'
}
)}
tooltipContent={i18n.translate(
'xpack.apm.settings.agentConf.betaBadgeText',
{
defaultMessage:
'This feature is still in development. If you have feedback, please reach out in our Discuss forum.'
}
)}
/>
</EuiFlexItem>
{hasConfigurations ? (
<EuiFlexItem>
<EuiFlexGroup alignItems="center" justifyContent="flexEnd">
<EuiFlexItem grow={false}>
<EuiButton
color="primary"
fill
iconType="plusInCircle"
onClick={() => setIsFlyoutOpen(true)}
>
{i18n.translate(
'xpack.apm.settings.agentConf.createConfigButtonLabel',
{
defaultMessage: 'Create configuration'
}
)}
</EuiButton>
</EuiFlexItem>
</EuiFlexGroup>
</EuiFlexItem>
) : null}
</EuiFlexGroup>
<EuiSpacer size="m" />
<EuiCallOut
title={i18n.translate(
'xpack.apm.settings.agentConf.betaCallOutTitle',
{
defaultMessage: 'APM Agent Configuration (BETA)'
}
)}
iconType="iInCircle"
>
<p>
<FormattedMessage
id="xpack.apm.settings.agentConf.betaCallOutText"
defaultMessage="We're excited to bring you a first look at APM Agent configuration. {agentConfigDocsLink}"
values={{
agentConfigDocsLink: (
<EuiLink href="https://www.elastic.co/guide/en/kibana/current/agent-configuration.html">
{i18n.translate(
'xpack.apm.settings.agentConf.agentConfigDocsLinkLabel',
{ defaultMessage: 'Learn more in our docs.' }
)}
</EuiLink>
)
}}
/>
</p>
</EuiCallOut>
<EuiSpacer size="m" />
<SettingsList
status={status}
data={data}
setIsFlyoutOpen={setIsFlyoutOpen}
setSelectedConfig={setSelectedConfig}
/>
</EuiPanel>
</>
);
}

View file

@ -1,191 +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;
* you may not use this file except in compliance with the Elastic License.
*/
import { i18n } from '@kbn/i18n';
import React from 'react';
import { idx } from '@kbn/elastic-idx';
import styled from 'styled-components';
import {
TRANSACTION_DURATION,
TRANSACTION_RESULT,
URL_FULL,
USER_ID,
TRANSACTION_PAGE_URL
} from '../../../../../common/elasticsearch_fieldnames';
import { NOT_AVAILABLE_LABEL } from '../../../../../common/i18n';
import { Transaction } from '../../../../../typings/es_schemas/ui/Transaction';
import { asTime } from '../../../../utils/formatters';
import {
IStickyProperty,
StickyProperties
} from '../../../shared/StickyProperties';
import { ErrorCountBadge } from './ErrorCountBadge';
import { isRumAgentName } from '../../../../../common/agent_name';
import { fontSize } from '../../../../style/variables';
import { PercentOfParent } from './PercentOfParent';
interface Props {
transaction: Transaction;
totalDuration?: number;
errorCount: number;
}
const ErrorTitle = styled.span`
font-size: ${fontSize};
`;
export function StickyTransactionProperties({
transaction,
totalDuration,
errorCount
}: Props) {
const timestamp = transaction['@timestamp'];
const isRumAgent = isRumAgentName(transaction.agent.name);
const { urlFieldName, urlValue } = isRumAgent
? {
urlFieldName: TRANSACTION_PAGE_URL,
urlValue: idx(transaction, _ => _.transaction.page.url)
}
: {
urlFieldName: URL_FULL,
urlValue: idx(transaction, _ => _.url.full)
};
const duration = transaction.transaction.duration.us;
const noErrorsText = i18n.translate(
'xpack.apm.transactionDetails.errorsNone',
{
defaultMessage: 'None'
}
);
const stickyProperties: IStickyProperty[] = [
{
label: i18n.translate('xpack.apm.transactionDetails.timestampLabel', {
defaultMessage: 'Timestamp'
}),
fieldName: '@timestamp',
val: timestamp,
truncated: true,
width: '50%'
},
{
fieldName: urlFieldName,
label: 'URL',
val: urlValue || NOT_AVAILABLE_LABEL,
truncated: true,
width: '50%'
},
{
label: i18n.translate('xpack.apm.transactionDetails.durationLabel', {
defaultMessage: 'Duration'
}),
fieldName: TRANSACTION_DURATION,
val: asTime(duration),
width: '25%'
},
{
label: i18n.translate(
'xpack.apm.transactionDetails.percentOfTraceLabel',
{
defaultMessage: '% of trace'
}
),
val: (
<PercentOfParent
duration={duration}
totalDuration={totalDuration}
parentType="trace"
/>
),
width: '25%'
},
{
label: i18n.translate('xpack.apm.transactionDetails.resultLabel', {
defaultMessage: 'Result'
}),
fieldName: TRANSACTION_RESULT,
val: idx(transaction, _ => _.transaction.result) || NOT_AVAILABLE_LABEL,
width: '14%'
},
{
label: i18n.translate(
'xpack.apm.transactionDetails.errorsOverviewLabel',
{
defaultMessage: 'Errors'
}
),
val: errorCount ? (
<>
<ErrorCountBadge>{errorCount}</ErrorCountBadge>
<ErrorTitle>
&nbsp;
{i18n.translate('xpack.apm.transactionDetails.errorsOverviewLink', {
values: { errorCount },
defaultMessage:
'{errorCount, plural, one {Related error} other {Related errors}}'
})}
</ErrorTitle>
</>
) : (
noErrorsText
),
width: '18%'
},
{
label: i18n.translate('xpack.apm.transactionDetails.userIdLabel', {
defaultMessage: 'User ID'
}),
fieldName: USER_ID,
val: idx(transaction, _ => _.user.id) || NOT_AVAILABLE_LABEL,
truncated: true,
width: '18%'
}
];
const { user_agent: userAgent } = transaction;
if (userAgent) {
const { os, device } = userAgent;
const width = '25%';
stickyProperties.push({
label: i18n.translate('xpack.apm.transactionDetails.userAgentLabel', {
defaultMessage: 'User agent'
}),
val: [userAgent.name, userAgent.version].filter(Boolean).join(' '),
truncated: true,
width
});
if (os) {
stickyProperties.push({
label: i18n.translate('xpack.apm.transactionDetails.userAgentOsLabel', {
defaultMessage: 'User agent OS'
}),
val: os.full || os.name,
truncated: true,
width
});
}
if (device) {
stickyProperties.push({
label: i18n.translate(
'xpack.apm.transactionDetails.userAgentDeviceLabel',
{
defaultMessage: 'User agent device'
}
),
val: device.name,
width
});
}
}
return <StickyProperties stickyProperties={stickyProperties} />;
}

View file

@ -24,7 +24,7 @@ import styled from 'styled-components';
import { idx } from '@kbn/elastic-idx';
import { px, units } from '../../../../../../../style/variables';
import { Summary } from '../../../../../../shared/Summary';
import { TimestampSummaryItem } from '../../../../../../shared/Summary/TimestampSummaryItem';
import { TimestampTooltip } from '../../../../../../shared/TimestampTooltip';
import { DurationSummaryItem } from '../../../../../../shared/Summary/DurationSummaryItem';
import { Span } from '../../../../../../../../typings/es_schemas/ui/Span';
import { Transaction } from '../../../../../../../../typings/es_schemas/ui/Transaction';
@ -143,7 +143,7 @@ export function SpanFlyout({
<EuiSpacer size="m" />
<Summary
items={[
<TimestampSummaryItem time={span.timestamp.us / 1000} />,
<TimestampTooltip time={span.timestamp.us / 1000} />,
<DurationSummaryItem
duration={span.span.duration.us}
totalDuration={totalDuration}

View file

@ -22,12 +22,18 @@ import * as useServiceTransactionTypesHook from '../../../../hooks/useServiceTra
import { fromQuery } from '../../../shared/Links/url_helpers';
import { Router } from 'react-router-dom';
import { UrlParamsProvider } from '../../../../context/UrlParamsContext';
import { KibanaCoreContext } from '../../../../../../observability/public';
import { LegacyCoreStart } from 'kibana/public';
jest.spyOn(history, 'push');
jest.spyOn(history, 'replace');
jest.mock('ui/kfetch');
const coreMock = ({
notifications: { toasts: { addWarning: () => {} } }
} as unknown) as LegacyCoreStart;
// Suppress warnings about "act" until async/await syntax is supported: https://github.com/facebook/react/issues/14769
/* eslint-disable no-console */
const originalError = console.error;
@ -59,11 +65,13 @@ function setup({
.mockReturnValue(serviceTransactionTypes);
return render(
<Router history={history}>
<UrlParamsProvider>
<TransactionOverview />
</UrlParamsProvider>
</Router>
<KibanaCoreContext.Provider value={coreMock}>
<Router history={history}>
<UrlParamsProvider>
<TransactionOverview />
</UrlParamsProvider>
</Router>
</KibanaCoreContext.Provider>
);
}

View file

@ -18,6 +18,8 @@ import { mount } from 'enzyme';
import { EuiSuperDatePicker } from '@elastic/eui';
import { MemoryRouter } from 'react-router-dom';
jest.mock('ui/kfetch');
const mockHistoryPush = jest.spyOn(history, 'push');
const mockRefreshTimeRange = jest.fn();
const MockUrlParamsProvider: React.FC<{

View file

@ -11,6 +11,7 @@ import { fromQuery, toQuery } from '../Links/url_helpers';
import { history } from '../../../utils/history';
import { useLocation } from '../../../hooks/useLocation';
import { useUrlParams } from '../../../hooks/useUrlParams';
import { clearCache } from '../../../services/rest/callApi';
export function DatePicker() {
const location = useLocation();
@ -113,6 +114,7 @@ export function DatePicker() {
refreshInterval={refreshInterval}
onTimeChange={onTimeChange}
onRefresh={({ start, end }) => {
clearCache();
refreshTimeRange({ rangeFrom: start, rangeTo: end });
}}
onRefreshChange={onRefreshChange}

View file

@ -15,11 +15,6 @@ describe('StickyProperties', () => {
it('should render entire component', () => {
const stickyProperties = [
{
label: 'Timestamp',
fieldName: '@timestamp',
val: 1536405447640
},
{
fieldName: URL_FULL,
label: 'URL',
@ -51,22 +46,6 @@ describe('StickyProperties', () => {
});
describe('values', () => {
it('should render timestamp when fieldName is `@timestamp`', () => {
const stickyProperties = [
{
label: 'My Timestamp',
fieldName: '@timestamp',
val: 1536405447640
}
];
const wrapper = shallow(
<StickyProperties stickyProperties={stickyProperties} />
).find('TimestampValue');
expect(wrapper).toMatchSnapshot();
});
it('should render numbers', () => {
const stickyProperties = [
{

View file

@ -20,35 +20,6 @@ exports[`StickyProperties should render entire component 1`] = `
"padding": "1em 1em 1em 0",
}
}
>
<PropertyLabel>
<EuiToolTip
content={
<styled.span>
@timestamp
</styled.span>
}
delay="regular"
position="top"
>
<span>
Timestamp
</span>
</EuiToolTip>
</PropertyLabel>
<TimestampValue
timestamp={1536405447640}
/>
</EuiFlexItem>
<EuiFlexItem
grow={false}
key="1"
style={
Object {
"minWidth": 0,
"padding": "1em 1em 1em 0",
}
}
>
<PropertyLabel>
<EuiToolTip
@ -77,7 +48,7 @@ exports[`StickyProperties should render entire component 1`] = `
</EuiFlexItem>
<EuiFlexItem
grow={false}
key="2"
key="1"
style={
Object {
"minWidth": 0,
@ -106,7 +77,7 @@ exports[`StickyProperties should render entire component 1`] = `
</EuiFlexItem>
<EuiFlexItem
grow={false}
key="3"
key="2"
style={
Object {
"minWidth": 0,
@ -135,7 +106,7 @@ exports[`StickyProperties should render entire component 1`] = `
</EuiFlexItem>
<EuiFlexItem
grow={false}
key="4"
key="3"
style={
Object {
"minWidth": 0,
@ -164,9 +135,3 @@ exports[`StickyProperties should render entire component 1`] = `
</EuiFlexItem>
</EuiFlexGroup>
`;
exports[`StickyProperties values should render timestamp when fieldName is \`@timestamp\` 1`] = `
<TimestampValue
timestamp={1536405447640}
/>
`;

View file

@ -7,10 +7,8 @@
import { EuiFlexGroup, EuiFlexItem } from '@elastic/eui';
import { EuiToolTip } from '@elastic/eui';
import theme from '@elastic/eui/dist/eui_theme_light.json';
import moment from 'moment';
import React from 'react';
import styled from 'styled-components';
import { NOT_AVAILABLE_LABEL } from '../../../../common/i18n';
import {
fontFamilyCode,
fontSizes,
@ -42,10 +40,6 @@ const PropertyLabel = styled.div`
`;
PropertyLabel.displayName = 'PropertyLabel';
const PropertyValueDimmed = styled.span`
color: ${theme.euiColorMediumShade};
`;
const propertyValueLineHeight = 1.2;
const PropertyValue = styled.div`
display: inline-block;
@ -59,20 +53,6 @@ const PropertyValueTruncated = styled.span`
${truncate('100%')};
`;
function TimestampValue({ timestamp }: { timestamp: Date }) {
const time = moment(timestamp);
const timeAgo = timestamp ? time.fromNow() : NOT_AVAILABLE_LABEL;
const timestampFull = timestamp
? time.format('MMMM Do YYYY, HH:mm:ss.SSS')
: NOT_AVAILABLE_LABEL;
return (
<PropertyValue>
{timeAgo} <PropertyValueDimmed>({timestampFull})</PropertyValueDimmed>
</PropertyValue>
);
}
function getPropertyLabel({ fieldName, label }: Partial<IStickyProperty>) {
if (fieldName) {
return (
@ -92,10 +72,6 @@ function getPropertyValue({
fieldName,
truncated = false
}: Partial<IStickyProperty>) {
if (fieldName === '@timestamp') {
return <TimestampValue timestamp={val as Date} />;
}
if (truncated) {
return (
<EuiToolTip content={String(val)}>

View file

@ -1,26 +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;
* you may not use this file except in compliance with the Elastic License.
*/
import React from 'react';
import { EuiToolTip } from '@elastic/eui';
import moment from 'moment-timezone';
interface Props {
time: number;
}
const TimestampSummaryItem = (props: Props) => {
const time = moment.tz(props.time, moment.tz.guess());
const relativeTimeLabel = time.fromNow();
const absoluteTimeLabel = time.format('MMM Do YYYY HH:mm:ss.SSS zz');
return (
<EuiToolTip content={absoluteTimeLabel}>
<>{relativeTimeLabel}</>
</EuiToolTip>
);
};
export { TimestampSummaryItem };

View file

@ -7,7 +7,7 @@ import React from 'react';
import { idx } from '@kbn/elastic-idx';
import { Transaction } from '../../../../typings/es_schemas/ui/Transaction';
import { Summary } from './';
import { TimestampSummaryItem } from './TimestampSummaryItem';
import { TimestampTooltip } from '../TimestampTooltip';
import { DurationSummaryItem } from './DurationSummaryItem';
import { ErrorCountSummaryItem } from './ErrorCountSummaryItem';
import { isRumAgentName } from '../../../../common/agent_name';
@ -47,7 +47,7 @@ const TransactionSummary = ({
errorCount
}: Props) => {
const items = [
<TimestampSummaryItem time={transaction.timestamp.us / 1000} />,
<TimestampTooltip time={transaction.timestamp.us / 1000} />,
<DurationSummaryItem
duration={transaction.transaction.duration.us}
totalDuration={totalDuration}

View file

@ -0,0 +1,41 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
import React from 'react';
import { EuiToolTip } from '@elastic/eui';
import moment from 'moment-timezone';
interface Props {
/**
* timestamp in milliseconds
*/
time: number;
precision?: 'days' | 'minutes' | 'milliseconds';
}
function getPreciseTime(precision: Props['precision']) {
switch (precision) {
case 'days':
return '';
case 'minutes':
return ', HH:mm';
default:
return ', HH:mm.SSS';
}
}
export function TimestampTooltip({ time, precision = 'milliseconds' }: Props) {
const momentTime = moment.tz(time, moment.tz.guess());
const relativeTimeLabel = momentTime.fromNow();
const absoluteTimeLabel = momentTime.format(
`MMM Do YYYY${getPreciseTime(precision)} (ZZ zz)`
);
return (
<EuiToolTip content={absoluteTimeLabel}>
<>{relativeTimeLabel}</>
</EuiToolTip>
);
}

View file

@ -8,6 +8,8 @@ import React from 'react';
import { render } from 'react-testing-library';
import { delay, tick } from '../utils/testHelpers';
import { useFetcher } from './useFetcher';
import { KibanaCoreContext } from '../../../observability/public/context/kibana_core';
import { LegacyCoreStart } from 'kibana/public';
// Suppress warnings about "act" until async/await syntax is supported: https://github.com/facebook/react/issues/14769
/* eslint-disable no-console */
@ -19,6 +21,19 @@ afterAll(() => {
console.error = originalError;
});
// Wrap the hook with a provider so it can useKibanaCore
const wrapper = ({ children }: { children?: React.ReactNode }) => (
<KibanaCoreContext.Provider
value={
({
notifications: { toasts: { addWarning: () => {} } }
} as unknown) as LegacyCoreStart
}
>
{children}
</KibanaCoreContext.Provider>
);
async function asyncFn(name: string, ms: number) {
await delay(ms);
return `Hello from ${name}`;
@ -54,7 +69,8 @@ describe('when simulating race condition', () => {
}
const { rerender } = render(
<MyComponent name="John" ms={500} renderFn={renderSpy} />
<MyComponent name="John" ms={500} renderFn={renderSpy} />,
{ wrapper }
);
rerender(<MyComponent name="Peter" ms={100} renderFn={renderSpy} />);

View file

@ -7,6 +7,9 @@
import { cleanup, renderHook } from 'react-hooks-testing-library';
import { delay } from '../utils/testHelpers';
import { useFetcher } from './useFetcher';
import { KibanaCoreContext } from '../../../observability/public/context/kibana_core';
import { LegacyCoreStart } from 'kibana/public';
import React from 'react';
afterEach(cleanup);
@ -20,6 +23,19 @@ afterAll(() => {
console.error = originalError;
});
// Wrap the hook with a provider so it can useKibanaCore
const wrapper = ({ children }: { children?: React.ReactNode }) => (
<KibanaCoreContext.Provider
value={
({
notifications: { toasts: { addWarning: () => {} } }
} as unknown) as LegacyCoreStart
}
>
{children}
</KibanaCoreContext.Provider>
);
describe('useFetcher', () => {
describe('when resolving after 500ms', () => {
let hook: ReturnType<typeof renderHook>;
@ -29,14 +45,14 @@ describe('useFetcher', () => {
await delay(500);
return 'response from hook';
}
hook = renderHook(() => useFetcher(() => fn(), []));
hook = renderHook(() => useFetcher(() => fn(), []), { wrapper });
});
it('should have loading spinner initally', async () => {
expect(hook.result.current).toEqual({
data: undefined,
error: undefined,
refresh: expect.any(Function),
refetch: expect.any(Function),
status: 'loading'
});
});
@ -47,7 +63,7 @@ describe('useFetcher', () => {
expect(hook.result.current).toEqual({
data: undefined,
error: undefined,
refresh: expect.any(Function),
refetch: expect.any(Function),
status: 'loading'
});
});
@ -59,7 +75,7 @@ describe('useFetcher', () => {
expect(hook.result.current).toEqual({
data: 'response from hook',
error: undefined,
refresh: expect.any(Function),
refetch: expect.any(Function),
status: 'success'
});
});
@ -73,14 +89,14 @@ describe('useFetcher', () => {
await delay(500);
throw new Error('Something went wrong');
}
hook = renderHook(() => useFetcher(() => fn(), []));
hook = renderHook(() => useFetcher(() => fn(), []), { wrapper });
});
it('should have loading spinner initally', async () => {
expect(hook.result.current).toEqual({
data: undefined,
error: undefined,
refresh: expect.any(Function),
refetch: expect.any(Function),
status: 'loading'
});
});
@ -91,7 +107,7 @@ describe('useFetcher', () => {
expect(hook.result.current).toEqual({
data: undefined,
error: undefined,
refresh: expect.any(Function),
refetch: expect.any(Function),
status: 'loading'
});
});
@ -103,7 +119,7 @@ describe('useFetcher', () => {
expect(hook.result.current).toEqual({
data: undefined,
error: expect.any(Error),
refresh: expect.any(Function),
refetch: expect.any(Function),
status: 'failure'
});
});
@ -112,19 +128,21 @@ describe('useFetcher', () => {
describe('when a hook already has data', () => {
it('should show "first response" while loading "second response"', async () => {
jest.useFakeTimers();
const hook = renderHook(
({ callback, args }) => useFetcher(callback, args),
{
initialProps: {
callback: async () => 'first response',
args: ['a']
}
},
wrapper
}
);
expect(hook.result.current).toEqual({
data: undefined,
error: undefined,
refresh: expect.any(Function),
refetch: expect.any(Function),
status: 'loading'
});
@ -134,7 +152,7 @@ describe('useFetcher', () => {
expect(hook.result.current).toEqual({
data: 'first response',
error: undefined,
refresh: expect.any(Function),
refetch: expect.any(Function),
status: 'success'
});
@ -153,7 +171,7 @@ describe('useFetcher', () => {
expect(hook.result.current).toEqual({
data: 'first response',
error: undefined,
refresh: expect.any(Function),
refetch: expect.any(Function),
status: 'loading'
});
@ -164,7 +182,7 @@ describe('useFetcher', () => {
expect(hook.result.current).toEqual({
data: 'second response',
error: undefined,
refresh: expect.any(Function),
refetch: expect.any(Function),
status: 'success'
});
});
@ -176,7 +194,8 @@ describe('useFetcher', () => {
initialProps: {
callback: async () => 'data response',
args: ['a']
}
},
wrapper
}
);
await hook.waitForNextUpdate();

View file

@ -5,12 +5,12 @@
*/
import React, { useContext, useEffect, useState, useMemo } from 'react';
import { toastNotifications } from 'ui/notify';
import { idx } from '@kbn/elastic-idx';
import { i18n } from '@kbn/i18n';
import { LoadingIndicatorContext } from '../context/LoadingIndicatorContext';
import { useComponentId } from './useComponentId';
import { KFetchError } from '../../../../../../src/legacy/ui/public/kfetch/kfetch_error';
import { useKibanaCore } from '../../../observability/public';
export enum FETCH_STATUS {
LOADING = 'loading',
@ -31,7 +31,7 @@ export function useFetcher<TState>(
options?: {
preservePreviousData?: boolean;
}
): Result<TState> & { refresh: () => void };
): Result<TState> & { refetch: () => void };
// To avoid infinite rescursion when infering the type of `TState` `initialState` must be given if `prevResult` is consumed
export function useFetcher<TState>(
@ -41,7 +41,7 @@ export function useFetcher<TState>(
preservePreviousData?: boolean;
initialState: TState;
}
): Result<TState> & { refresh: () => void };
): Result<TState> & { refetch: () => void };
export function useFetcher(
fn: Function,
@ -51,6 +51,11 @@ export function useFetcher(
initialState?: unknown;
} = {}
) {
const {
notifications: {
toasts: { addWarning }
}
} = useKibanaCore();
const { preservePreviousData = true } = options;
const id = useComponentId();
const { dispatchStatus } = useContext(LoadingIndicatorContext);
@ -93,7 +98,7 @@ export function useFetcher(
} catch (e) {
const err = e as KFetchError;
if (!didCancel) {
toastNotifications.addWarning({
addWarning({
title: i18n.translate('xpack.apm.fetcher.error.title', {
defaultMessage: `Error while fetching resource`
}),
@ -141,14 +146,13 @@ export function useFetcher(
/* eslint-enable react-hooks/exhaustive-deps */
]);
return useMemo(
() => ({
return useMemo(() => {
return {
...result,
refresh: () => {
refetch: () => {
// this will invalidate the deps to `useEffect` and will result in a new request
setCounter(count => count + 1);
}
}),
[result]
);
};
}, [result]);
}

View file

@ -6,7 +6,7 @@
import * as kfetchModule from 'ui/kfetch';
import { mockNow } from '../../utils/testHelpers';
import { _clearCache, callApi } from '../rest/callApi';
import { clearCache, callApi } from '../rest/callApi';
import { SessionStorageMock } from './SessionStorageMock';
jest.mock('ui/kfetch');
@ -24,7 +24,7 @@ describe('callApi', () => {
afterEach(() => {
kfetchSpy.mockClear();
_clearCache();
clearCache();
});
describe('apm_debug', () => {

View file

@ -31,12 +31,12 @@ function fetchOptionsWithDebug(fetchOptions: KFetchOptions) {
const cache = new LRU<string, any>({ max: 100, maxAge: 1000 * 60 * 60 });
export function _clearCache() {
export function clearCache() {
cache.reset();
}
export async function callApi<T = void>(
fetchOptions: KFetchOptions,
fetchOptions: KFetchOptions & { forceCache?: boolean },
options?: KFetchKibanaOptions
): Promise<T> {
const cacheKey = getCacheKey(fetchOptions);
@ -57,7 +57,11 @@ export async function callApi<T = void>(
// only cache items that has a time range with `start` and `end` params,
// and where `end` is not a timestamp in the future
function isCachable(fetchOptions: KFetchOptions) {
function isCachable(fetchOptions: KFetchOptions & { forceCache?: boolean }) {
if (fetchOptions.forceCache) {
return true;
}
if (
!(fetchOptions.query && fetchOptions.query.start && fetchOptions.query.end)
) {

View file

@ -110,7 +110,7 @@ Object {
"myIndex",
"myIndex",
],
"terminate_after": 1,
"terminateAfter": 1,
}
`;

View file

@ -9,18 +9,14 @@ import {
SERVICE_AGENT_NAME,
SERVICE_NAME
} from '../../../common/elasticsearch_fieldnames';
import { PromiseReturnType } from '../../../typings/common';
import { rangeFilter } from '../helpers/range_filter';
import { Setup } from '../helpers/setup_request';
export type ServiceAgentNameAPIResponse = PromiseReturnType<
typeof getServiceAgentName
>;
export async function getServiceAgentName(serviceName: string, setup: Setup) {
const { start, end, client, config } = setup;
const params = {
terminate_after: 1,
terminateAfter: 1,
index: [
config.get<string>('apm_oss.errorIndices'),
config.get<string>('apm_oss.transactionIndices'),

View file

@ -9,13 +9,9 @@ import {
SERVICE_NAME,
TRANSACTION_TYPE
} from '../../../common/elasticsearch_fieldnames';
import { PromiseReturnType } from '../../../typings/common';
import { rangeFilter } from '../helpers/range_filter';
import { Setup } from '../helpers/setup_request';
export type ServiceTransactionTypesAPIResponse = PromiseReturnType<
typeof getServiceTransactionTypes
>;
export async function getServiceTransactionTypes(
serviceName: string,
setup: Setup

View file

@ -7,7 +7,6 @@ Object {
"environments": Object {
"terms": Object {
"field": "service.environment",
"missing": "ENVIRONMENT_NOT_DEFINED",
"size": 100,
},
},
@ -53,21 +52,47 @@ Object {
"body": Object {
"query": Object {
"bool": Object {
"filter": Array [
"minimum_should_match": 2,
"should": Array [
Object {
"term": Object {
"service.name": "foo",
"service.name": Object {
"value": "foo",
},
},
},
Object {
"term": Object {
"service.environment": "bar",
"service.environment": Object {
"value": "bar",
},
},
},
Object {
"bool": Object {
"must_not": Array [
Object {
"exists": Object {
"field": "service.name",
},
},
],
},
},
Object {
"bool": Object {
"must_not": Array [
Object {
"exists": Object {
"field": "service.environment",
},
},
],
},
},
],
},
},
"size": 1,
},
"index": "myIndex",
}
@ -78,25 +103,40 @@ Object {
"body": Object {
"query": Object {
"bool": Object {
"filter": Array [
"minimum_should_match": 2,
"should": Array [
Object {
"term": Object {
"service.name": "foo",
"service.name": Object {
"value": "foo",
},
},
},
Object {
"bool": Object {
"must_not": Object {
"exists": Object {
"field": "service.environment",
"must_not": Array [
Object {
"exists": Object {
"field": "service.name",
},
},
},
],
},
},
Object {
"bool": Object {
"must_not": Array [
Object {
"exists": Object {
"field": "service.environment",
},
},
],
},
},
],
},
},
"size": 1,
},
"index": "myIndex",
}
@ -109,7 +149,7 @@ Object {
"services": Object {
"terms": Object {
"field": "service.name",
"size": 100,
"size": 50,
},
},
},
@ -145,8 +185,8 @@ Object {
"environments": Object {
"terms": Object {
"field": "service.environment",
"missing": "ENVIRONMENT_NOT_DEFINED",
"size": 100,
"missing": "ALL_OPTION_VALUE",
"size": 50,
},
},
},

View file

@ -4,16 +4,18 @@
* you may not use this file except in compliance with the Elastic License.
*/
export interface AgentConfigurationIntake {
settings: {
transaction_sample_rate: number;
};
export interface AgentConfiguration {
'@timestamp': number;
applied_by_agent?: boolean;
etag?: string;
agent_name?: string;
service: {
name: string;
name?: string;
environment?: string;
};
}
export interface AgentConfiguration extends AgentConfigurationIntake {
'@timestamp': number;
settings: {
transaction_sample_rate?: number;
capture_body?: string;
transaction_max_spans?: number;
};
}

View file

@ -5,7 +5,7 @@
*/
import { InternalCoreSetup } from 'src/core/server';
import Boom from 'boom';
import { CallCluster } from '../../../../../../../../src/legacy/core_plugins/elasticsearch';
export async function createApmAgentConfigurationIndex(
core: InternalCoreSetup
@ -19,57 +19,84 @@ export async function createApmAgentConfigurationIndex(
'admin'
);
const indexExists = await callWithInternalUser('indices.exists', { index });
const result = indexExists
? await updateExistingIndex(index, callWithInternalUser)
: await createNewIndex(index, callWithInternalUser);
if (!indexExists) {
const result = await callWithInternalUser('indices.create', {
index,
body: {
settings: {
'index.auto_expand_replicas': '0-1'
},
mappings: {
properties: {
'@timestamp': {
type: 'date'
},
settings: {
properties: {
transaction_sample_rate: {
type: 'scaled_float',
scaling_factor: 1000,
ignore_malformed: true,
coerce: false
}
}
},
service: {
properties: {
name: {
type: 'keyword',
ignore_above: 1024
},
environment: {
type: 'keyword',
ignore_above: 1024
}
}
}
}
}
}
});
if (!result.acknowledged) {
const err = new Error(
`Unable to create APM Agent Configuration index '${index}'`
);
// eslint-disable-next-line
console.error(err.stack);
throw Boom.boomify(err, { statusCode: 500 });
}
if (!result.acknowledged) {
const resultError =
result && result.error && JSON.stringify(result.error);
throw new Error(
`Unable to create APM Agent Configuration index '${index}': ${resultError}`
);
}
} catch (e) {
// eslint-disable-next-line no-console
console.error('Could not create APM Agent configuration:', e.message);
}
}
function createNewIndex(index: string, callWithInternalUser: CallCluster) {
return callWithInternalUser('indices.create', {
index,
body: {
settings: { 'index.auto_expand_replicas': '0-1' },
mappings: { properties: mappingProperties }
}
});
}
// Necessary for migration reasons
// Added in 7.5: `capture_body`, `transaction_max_spans`, `applied_by_agent`, `agent_name` and `etag`
function updateExistingIndex(index: string, callWithInternalUser: CallCluster) {
return callWithInternalUser('indices.putMapping', {
index,
body: { properties: mappingProperties }
});
}
const mappingProperties = {
'@timestamp': {
type: 'date'
},
service: {
properties: {
name: {
type: 'keyword',
ignore_above: 1024
},
environment: {
type: 'keyword',
ignore_above: 1024
}
}
},
settings: {
properties: {
transaction_sample_rate: {
type: 'scaled_float',
scaling_factor: 1000,
ignore_malformed: true,
coerce: false
},
capture_body: {
type: 'keyword',
ignore_above: 1024
},
transaction_max_spans: {
type: 'short'
}
}
},
applied_by_agent: {
type: 'boolean'
},
agent_name: {
type: 'keyword',
ignore_above: 1024
},
etag: {
type: 'keyword',
ignore_above: 1024
}
};

View file

@ -0,0 +1,49 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
import hash from 'object-hash';
import { IndexDocumentParams } from 'elasticsearch';
import { Setup } from '../../helpers/setup_request';
import { AgentConfiguration } from './configuration_types';
export async function createOrUpdateConfiguration({
configurationId,
configuration,
setup
}: {
configurationId?: string;
configuration: Omit<
AgentConfiguration,
'@timestamp' | 'applied_by_agent' | 'etag'
>;
setup: Setup;
}) {
const { client, config } = setup;
const params: IndexDocumentParams<AgentConfiguration> = {
type: '_doc',
refresh: true,
index: config.get<string>('apm_oss.apmAgentConfigurationIndex'),
body: {
agent_name: configuration.agent_name,
service: {
name: configuration.service.name,
environment: configuration.service.environment
},
settings: configuration.settings,
'@timestamp': Date.now(),
applied_by_agent: false,
etag: hash(configuration)
}
};
// by specifying an id elasticsearch will delete the previous doc and insert the updated doc
if (configurationId) {
params.id = configurationId;
}
return client.index(params);
}

View file

@ -0,0 +1,54 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
import { idx } from '@kbn/elastic-idx';
import { Setup } from '../../helpers/setup_request';
import {
PROCESSOR_EVENT,
SERVICE_NAME
} from '../../../../common/elasticsearch_fieldnames';
import { SERVICE_AGENT_NAME } from '../../../../common/elasticsearch_fieldnames';
export async function getAgentNameByService({
serviceName,
setup
}: {
serviceName: string;
setup: Setup;
}) {
const { client, config } = setup;
const params = {
terminateAfter: 1,
index: [
config.get<string>('apm_oss.metricsIndices'),
config.get<string>('apm_oss.errorIndices'),
config.get<string>('apm_oss.transactionIndices')
],
body: {
size: 0,
query: {
bool: {
filter: [
{
terms: { [PROCESSOR_EVENT]: ['transaction', 'error', 'metric'] }
},
{ term: { [SERVICE_NAME]: serviceName } }
]
}
},
aggs: {
agent_names: {
terms: { field: SERVICE_AGENT_NAME, size: 1 }
}
}
}
};
const { aggregations } = await client.search(params);
const agentName = idx(aggregations, _ => _.agent_names.buckets[0].key);
return { agentName };
}

View file

@ -11,17 +11,22 @@ import {
SERVICE_NAME,
SERVICE_ENVIRONMENT
} from '../../../../../common/elasticsearch_fieldnames';
import { ENVIRONMENT_NOT_DEFINED } from '../../../../../common/environment_filter_values';
import { ALL_OPTION_VALUE } from '../../../../../common/agent_configuration_constants';
export async function getAllEnvironments({
serviceName,
setup
}: {
serviceName: string;
serviceName: string | undefined;
setup: Setup;
}) {
const { client, config } = setup;
// omit filter for service.name if "All" option is selected
const serviceNameFilter = serviceName
? [{ term: { [SERVICE_NAME]: serviceName } }]
: [];
const params = {
index: [
config.get<string>('apm_oss.metricsIndices'),
@ -36,7 +41,7 @@ export async function getAllEnvironments({
{
terms: { [PROCESSOR_EVENT]: ['transaction', 'error', 'metric'] }
},
{ term: { [SERVICE_NAME]: serviceName } }
...serviceNameFilter
]
}
},
@ -44,7 +49,6 @@ export async function getAllEnvironments({
environments: {
terms: {
field: SERVICE_ENVIRONMENT,
missing: ENVIRONMENT_NOT_DEFINED,
size: 100
}
}
@ -54,5 +58,6 @@ export async function getAllEnvironments({
const resp = await client.search(params);
const buckets = idx(resp.aggregations, _ => _.environments.buckets) || [];
return buckets.map(bucket => bucket.key);
const environments = buckets.map(bucket => bucket.key);
return [ALL_OPTION_VALUE, ...environments];
}

View file

@ -10,32 +10,32 @@ import {
SERVICE_NAME,
SERVICE_ENVIRONMENT
} from '../../../../../common/elasticsearch_fieldnames';
import { ENVIRONMENT_NOT_DEFINED } from '../../../../../common/environment_filter_values';
import { ALL_OPTION_VALUE } from '../../../../../common/agent_configuration_constants';
export async function getUnavailableEnvironments({
export async function getExistingEnvironmentsForService({
serviceName,
setup
}: {
serviceName: string;
serviceName: string | undefined;
setup: Setup;
}) {
const { client, config } = setup;
const bool = serviceName
? { filter: [{ term: { [SERVICE_NAME]: serviceName } }] }
: { must_not: [{ exists: { field: SERVICE_NAME } }] };
const params = {
index: config.get<string>('apm_oss.apmAgentConfigurationIndex'),
body: {
size: 0,
query: {
bool: {
filter: [{ term: { [SERVICE_NAME]: serviceName } }]
}
},
query: { bool },
aggs: {
environments: {
terms: {
field: SERVICE_ENVIRONMENT,
missing: ENVIRONMENT_NOT_DEFINED,
size: 100
missing: ALL_OPTION_VALUE,
size: 50
}
}
}

View file

@ -7,7 +7,7 @@
import { getAllEnvironments } from './get_all_environments';
import { Setup } from '../../../helpers/setup_request';
import { PromiseReturnType } from '../../../../../typings/common';
import { getUnavailableEnvironments } from './get_unavailable_environments';
import { getExistingEnvironmentsForService } from './get_existing_environments_for_service';
export type AgentConfigurationEnvironmentsAPIResponse = PromiseReturnType<
typeof getEnvironments
@ -17,18 +17,18 @@ export async function getEnvironments({
serviceName,
setup
}: {
serviceName: string;
serviceName: string | undefined;
setup: Setup;
}) {
const [allEnvironments, unavailableEnvironments] = await Promise.all([
const [allEnvironments, existingEnvironments] = await Promise.all([
getAllEnvironments({ serviceName, setup }),
getUnavailableEnvironments({ serviceName, setup })
getExistingEnvironmentsForService({ serviceName, setup })
]);
return allEnvironments.map(environment => {
return {
name: environment,
available: !unavailableEnvironments.includes(environment)
alreadyConfigured: existingEnvironments.includes(environment)
};
});
}

View file

@ -11,6 +11,7 @@ import {
PROCESSOR_EVENT,
SERVICE_NAME
} from '../../../../common/elasticsearch_fieldnames';
import { ALL_OPTION_VALUE } from '../../../../common/agent_configuration_constants';
export type AgentConfigurationServicesAPIResponse = PromiseReturnType<
typeof getServiceNames
@ -37,7 +38,7 @@ export async function getServiceNames({ setup }: { setup: Setup }) {
services: {
terms: {
field: SERVICE_NAME,
size: 100
size: 50
}
}
}
@ -46,5 +47,6 @@ export async function getServiceNames({ setup }: { setup: Setup }) {
const resp = await client.search(params);
const buckets = idx(resp.aggregations, _ => _.services.buckets) || [];
return buckets.map(bucket => bucket.key).sort();
const serviceNames = buckets.map(bucket => bucket.key).sort();
return [ALL_OPTION_VALUE, ...serviceNames];
}

View file

@ -5,30 +5,28 @@
*/
import { Setup } from '../../helpers/setup_request';
import { PromiseReturnType } from '../../../../typings/common';
import { AgentConfigurationIntake } from './configuration_types';
import { AgentConfiguration } from './configuration_types';
export type CreateAgentConfigurationAPIResponse = PromiseReturnType<
typeof createConfiguration
>;
export async function createConfiguration({
configuration,
export async function markAppliedByAgent({
id,
body,
setup
}: {
configuration: AgentConfigurationIntake;
id: string;
body: AgentConfiguration;
setup: Setup;
}) {
const { client, config } = setup;
const params = {
type: '_doc',
refresh: true,
index: config.get<string>('apm_oss.apmAgentConfigurationIndex'),
id, // by specifying the `id` elasticsearch will do an "upsert"
body: {
'@timestamp': Date.now(),
...configuration
...body,
applied_by_agent: true
}
};
return client.index(params);
return client.index<AgentConfiguration>(params);
}

View file

@ -5,7 +5,7 @@
*/
import { getAllEnvironments } from './get_environments/get_all_environments';
import { getUnavailableEnvironments } from './get_environments/get_unavailable_environments';
import { getExistingEnvironmentsForService } from './get_environments/get_existing_environments_for_service';
import { getServiceNames } from './get_service_names';
import { listConfigurations } from './list_configurations';
import { searchConfigurations } from './search';
@ -34,7 +34,7 @@ describe('agent configuration queries', () => {
it('fetches unavailable environments', async () => {
mock = await inspectSearchParams(setup =>
getUnavailableEnvironments({
getExistingEnvironmentsForService({
serviceName: 'foo',
setup
})

View file

@ -0,0 +1,74 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
export const searchMocks = {
took: 1,
timed_out: false,
_shards: {
total: 1,
successful: 1,
skipped: 0,
failed: 0
},
hits: {
total: {
value: 3,
relation: 'eq'
},
max_score: 0.9808292,
hits: [
{
_index: '.apm-agent-configuration',
_type: '_doc',
_id: '-aQHsm0BxZLczArvNQYW',
_score: 0.9808292,
_source: {
service: {
environment: 'production'
},
settings: {
transaction_sample_rate: 0.3
},
'@timestamp': 1570649879829,
applied_by_agent: false,
etag: 'c511f4c1df457371c4446c9c4925662e18726f51'
}
},
{
_index: '.apm-agent-configuration',
_type: '_doc',
_id: '-KQHsm0BxZLczArvNAb0',
_score: 0.18232156,
_source: {
service: {
name: 'my_service'
},
settings: {
transaction_sample_rate: 0.2
},
'@timestamp': 1570649879795,
applied_by_agent: false,
etag: 'a13cd8fee5a2fcc2ae773a60a4deaf7f76b90a65'
}
},
{
_index: '.apm-agent-configuration',
_type: '_doc',
_id: '96QHsm0BxZLczArvNAbD',
_score: 0.0,
_source: {
service: {},
settings: {
transaction_sample_rate: 0.1
},
'@timestamp': 1570649879743,
applied_by_agent: false,
etag: 'c7f4ba16f00a9c9bf3c49024c5b6d4632ff05ff5'
}
}
]
}
};

View file

@ -0,0 +1,39 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
import { searchConfigurations } from './search';
import { searchMocks } from './search.mocks';
import { Setup } from '../../helpers/setup_request';
describe('search configurations', () => {
it('should return configuration by matching on service.name', async () => {
const res = await searchConfigurations({
serviceName: 'my_service',
environment: 'production',
setup: ({
config: { get: () => '' },
client: { search: async () => searchMocks }
} as unknown) as Setup
});
expect(res!._source.service).toEqual({ name: 'my_service' });
expect(res!._source.settings).toEqual({ transaction_sample_rate: 0.2 });
});
it('should return configuration by matching on "production" env', async () => {
const res = await searchConfigurations({
serviceName: 'non_existing_service',
environment: 'production',
setup: ({
config: { get: () => '' },
client: { search: async () => searchMocks }
} as unknown) as Setup
});
expect(res!._source.service).toEqual({ environment: 'production' });
expect(res!._source.settings).toEqual({ transaction_sample_rate: 0.3 });
});
});

View file

@ -4,8 +4,7 @@
* you may not use this file except in compliance with the Elastic License.
*/
import { ESFilter } from 'elasticsearch';
import { PromiseReturnType } from '../../../../typings/common';
import { ESSearchHit } from 'elasticsearch';
import {
SERVICE_NAME,
SERVICE_ENVIRONMENT
@ -13,9 +12,6 @@ import {
import { Setup } from '../../helpers/setup_request';
import { AgentConfiguration } from './configuration_types';
export type SearchAgentConfigurationsAPIResponse = PromiseReturnType<
typeof searchConfigurations
>;
export async function searchConfigurations({
serviceName,
environment,
@ -27,26 +23,61 @@ export async function searchConfigurations({
}) {
const { client, config } = setup;
const filters: ESFilter[] = [{ term: { [SERVICE_NAME]: serviceName } }];
// sorting order
// 1. exact match: service.name AND service.environment (eg. opbeans-node / production)
// 2. Partial match: service.name and no service.environment (eg. opbeans-node / All)
// 3. Partial match: service.environment and no service.name (eg. All / production)
// 4. Catch all: no service.name and no service.environment (eg. All / All)
if (environment) {
filters.push({ term: { [SERVICE_ENVIRONMENT]: environment } });
} else {
filters.push({
bool: { must_not: { exists: { field: SERVICE_ENVIRONMENT } } }
});
}
const environmentFilter = environment
? [{ term: { [SERVICE_ENVIRONMENT]: { value: environment } } }]
: [];
const params = {
index: config.get<string>('apm_oss.apmAgentConfigurationIndex'),
body: {
size: 1,
query: {
bool: { filter: filters }
bool: {
minimum_should_match: 2,
should: [
{ term: { [SERVICE_NAME]: { value: serviceName } } },
...environmentFilter,
{ bool: { must_not: [{ exists: { field: SERVICE_NAME } }] } },
{ bool: { must_not: [{ exists: { field: SERVICE_ENVIRONMENT } }] } }
]
}
}
}
};
const resp = await client.search<AgentConfiguration>(params);
return resp.hits.hits[0];
const { hits } = resp.hits;
const exactMatch = hits.find(
hit =>
hit._source.service.name === serviceName &&
hit._source.service.environment === environment
);
if (exactMatch) {
return exactMatch;
}
const matchWithServiceName = hits.find(
hit => hit._source.service.name === serviceName
);
if (matchWithServiceName) {
return matchWithServiceName;
}
const matchWithEnvironment = hits.find(
hit => hit._source.service.environment === environment
);
if (matchWithEnvironment) {
return matchWithEnvironment;
}
return resp.hits.hits[0] as ESSearchHit<AgentConfiguration> | undefined;
}

View file

@ -1,37 +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;
* you may not use this file except in compliance with the Elastic License.
*/
import { Setup } from '../../helpers/setup_request';
import { PromiseReturnType } from '../../../../typings/common';
import { AgentConfigurationIntake } from './configuration_types';
export type UpdateAgentConfigurationAPIResponse = PromiseReturnType<
typeof updateConfiguration
>;
export async function updateConfiguration({
configurationId,
configuration,
setup
}: {
configurationId: string;
configuration: AgentConfigurationIntake;
setup: Setup;
}) {
const { client, config } = setup;
const params = {
type: '_doc',
id: configurationId,
refresh: true,
index: config.get<string>('apm_oss.apmAgentConfigurationIndex'),
body: {
'@timestamp': Date.now(),
...configuration
}
};
return client.index(params);
}

View file

@ -11,14 +11,9 @@ import {
TRANSACTION_DURATION,
TRANSACTION_TYPE
} from '../../../../common/elasticsearch_fieldnames';
import { PromiseReturnType } from '../../../../typings/common';
import { Setup } from '../../helpers/setup_request';
import { rangeFilter } from '../../helpers/range_filter';
export type TransactionAvgDurationByCountryAPIResponse = PromiseReturnType<
typeof getTransactionAvgDurationByCountry
>;
export async function getTransactionAvgDurationByCountry({
setup,
serviceName

View file

@ -16,16 +16,11 @@ import {
TRANSACTION_BREAKDOWN_COUNT,
PROCESSOR_EVENT
} from '../../../../common/elasticsearch_fieldnames';
import { PromiseReturnType } from '../../../../typings/common';
import { Setup } from '../../helpers/setup_request';
import { rangeFilter } from '../../helpers/range_filter';
import { getMetricsDateHistogramParams } from '../../helpers/metrics';
import { MAX_KPIS, COLORS } from './constants';
export type TransactionBreakdownAPIResponse = PromiseReturnType<
typeof getTransactionBreakdown
>;
export async function getTransactionBreakdown({
setup,
serviceName,

View file

@ -10,12 +10,10 @@ import {
TRACE_ID,
TRANSACTION_ID
} from '../../../../common/elasticsearch_fieldnames';
import { PromiseReturnType } from '../../../../typings/common';
import { Transaction } from '../../../../typings/es_schemas/ui/Transaction';
import { rangeFilter } from '../../helpers/range_filter';
import { Setup } from '../../helpers/setup_request';
export type TransactionAPIResponse = PromiseReturnType<typeof getTransaction>;
export async function getTransaction(
transactionId: string,
traceId: string,

View file

@ -11,14 +11,10 @@ import {
SERVICE_ENVIRONMENT,
SERVICE_NAME
} from '../../../common/elasticsearch_fieldnames';
import { PromiseReturnType } from '../../../typings/common';
import { rangeFilter } from '../helpers/range_filter';
import { Setup } from '../helpers/setup_request';
import { ENVIRONMENT_NOT_DEFINED } from '../../../common/environment_filter_values';
export type EnvironmentUIFilterAPIResponse = PromiseReturnType<
typeof getEnvironments
>;
export async function getEnvironments(setup: Setup, serviceName?: string) {
const { start, end, client, config } = setup;

View file

@ -22,7 +22,8 @@ import {
deleteAgentConfigurationRoute,
listAgentConfigurationEnvironmentsRoute,
listAgentConfigurationServicesRoute,
updateAgentConfigurationRoute
updateAgentConfigurationRoute,
agentConfigurationAgentNameRoute
} from './settings';
import { metricsChartsRoute } from './metrics';
import { serviceNodesRoute } from './service_nodes';
@ -48,14 +49,22 @@ import { createApi } from './create_api';
const createApmApi = () => {
const api = createApi()
// index pattern
.add(indexPatternRoute)
// Errors
.add(errorDistributionRoute)
.add(errorGroupsRoute)
.add(errorsRoute)
// Services
.add(serviceAgentNameRoute)
.add(serviceTransactionTypesRoute)
.add(servicesRoute)
.add(serviceNodeMetadataRoute)
// Agent configuration
.add(agentConfigurationAgentNameRoute)
.add(agentConfigurationRoute)
.add(agentConfigurationSearchRoute)
.add(createAgentConfigurationRoute)
@ -63,15 +72,23 @@ const createApmApi = () => {
.add(listAgentConfigurationEnvironmentsRoute)
.add(listAgentConfigurationServicesRoute)
.add(updateAgentConfigurationRoute)
// Metrics
.add(metricsChartsRoute)
.add(serviceNodesRoute)
// Traces
.add(tracesRoute)
.add(tracesByIdRoute)
// Transaction groups
.add(transactionGroupsBreakdownRoute)
.add(transactionGroupsChartsRoute)
.add(transactionGroupsDistributionRoute)
.add(transactionGroupsRoute)
.add(transactionGroupsAvgDurationByCountry)
// UI filters
.add(errorGroupsLocalFiltersRoute)
.add(metricsLocalFiltersRoute)
.add(servicesLocalFiltersRoute)

View file

@ -7,14 +7,16 @@
import * as t from 'io-ts';
import { setupRequest } from '../lib/helpers/setup_request';
import { getServiceNames } from '../lib/settings/agent_configuration/get_service_names';
import { createConfiguration } from '../lib/settings/agent_configuration/create_configuration';
import { updateConfiguration } from '../lib/settings/agent_configuration/update_configuration';
import { createOrUpdateConfiguration } from '../lib/settings/agent_configuration/create_or_update_configuration';
import { searchConfigurations } from '../lib/settings/agent_configuration/search';
import { listConfigurations } from '../lib/settings/agent_configuration/list_configurations';
import { getEnvironments } from '../lib/settings/agent_configuration/get_environments';
import { deleteConfiguration } from '../lib/settings/agent_configuration/delete_configuration';
import { createRoute } from './create_route';
import { transactionSampleRateRt } from '../../common/runtime_types/transaction_sample_rate_rt';
import { transactionMaxSpansRt } from '../../common/runtime_types/transaction_max_spans_rt';
import { getAgentNameByService } from '../lib/settings/agent_configuration/get_agent_name_by_service';
import { markAppliedByAgent } from '../lib/settings/agent_configuration/mark_applied_by_agent';
// get list of configurations
export const agentConfigurationRoute = createRoute(core => ({
@ -56,36 +58,46 @@ export const listAgentConfigurationServicesRoute = createRoute(() => ({
}
}));
const agentPayloadRt = t.type({
settings: t.type({
transaction_sample_rate: transactionSampleRateRt
const agentPayloadRt = t.intersection([
t.partial({ agent_name: t.string }),
t.type({
service: t.intersection([
t.partial({ name: t.string }),
t.partial({ environment: t.string })
])
}),
service: t.intersection([
t.type({
name: t.string
}),
t.partial({
environments: t.array(t.string)
})
])
});
t.type({
settings: t.intersection([
t.partial({ transaction_sample_rate: transactionSampleRateRt }),
t.partial({ capture_body: t.string }),
t.partial({ transaction_max_spans: transactionMaxSpansRt })
])
})
]);
// get environments for service
export const listAgentConfigurationEnvironmentsRoute = createRoute(() => ({
path:
'/api/apm/settings/agent-configuration/services/{serviceName}/environments',
path: '/api/apm/settings/agent-configuration/environments',
params: {
path: t.type({
serviceName: t.string
})
query: t.partial({ serviceName: t.string })
},
handler: async (req, { path }) => {
handler: async (req, { query }) => {
const setup = await setupRequest(req);
const { serviceName } = path;
return await getEnvironments({
serviceName,
setup
});
const { serviceName } = query;
return await getEnvironments({ serviceName, setup });
}
}));
// get agentName for service
export const agentConfigurationAgentNameRoute = createRoute(() => ({
path: '/api/apm/settings/agent-configuration/agent_name',
params: {
query: t.type({ serviceName: t.string })
},
handler: async (req, { query }) => {
const setup = await setupRequest(req);
const { serviceName } = query;
return await getAgentNameByService({ serviceName, setup });
}
}));
@ -97,16 +109,13 @@ export const createAgentConfigurationRoute = createRoute(() => ({
},
handler: async (req, { body }) => {
const setup = await setupRequest(req);
return await createConfiguration({
configuration: body,
setup
});
return await createOrUpdateConfiguration({ configuration: body, setup });
}
}));
export const updateAgentConfigurationRoute = createRoute(() => ({
method: 'PUT',
path: `/api/apm/settings/agent-configuration/{configurationId}`,
path: '/api/apm/settings/agent-configuration/{configurationId}',
params: {
path: t.type({
configurationId: t.string
@ -116,7 +125,7 @@ export const updateAgentConfigurationRoute = createRoute(() => ({
handler: async (req, { path, body }) => {
const setup = await setupRequest(req);
const { configurationId } = path;
return await updateConfiguration({
return await createOrUpdateConfiguration({
configurationId,
configuration: body,
setup
@ -124,7 +133,7 @@ export const updateAgentConfigurationRoute = createRoute(() => ({
}
}));
// Lookup single configuration
// Lookup single configuration (used by APM Server)
export const agentConfigurationSearchRoute = createRoute(core => ({
method: 'POST',
path: '/api/apm/settings/agent-configuration/search',
@ -133,7 +142,8 @@ export const agentConfigurationSearchRoute = createRoute(core => ({
service: t.intersection([
t.type({ name: t.string }),
t.partial({ environment: t.string })
])
]),
etag: t.string
})
},
handler: async (req, { body }, h) => {
@ -148,6 +158,11 @@ export const agentConfigurationSearchRoute = createRoute(core => ({
return h.response().code(404);
}
// update `applied_by_agent` field if etags match
if (body.etag === config._source.etag && !config._source.applied_by_agent) {
markAppliedByAgent({ id: config._id, body: config._source, setup });
}
return config;
}
}));

View file

@ -115,6 +115,7 @@ export type Client<TRouteState> = <
: undefined
>(
options: Omit<KFetchOptions, 'query' | 'body' | 'pathname' | 'method'> & {
forceCache?: boolean;
pathname: TPath;
} & (TMethod extends 'GET' ? { method?: TMethod } : { method: TMethod }) &
// Makes sure params can only be set when types were defined

View file

@ -6,15 +6,11 @@
import { StringMap, IndexAsString } from './common';
export interface BoolQuery {
must_not: Array<Record<string, any>>;
should: Array<Record<string, any>>;
filter: Array<Record<string, any>>;
}
declare module 'elasticsearch' {
// extending SearchResponse to be able to have typed aggregations
type ESSearchHit<T> = SearchResponse<T>['hits']['hits'][0];
type AggregationType =
| 'date_histogram'
| 'histogram'

View file

@ -3425,14 +3425,9 @@
"xpack.apm.transactionActionMenu.showTraceLogsLinkLabel": "トレースログを表示",
"xpack.apm.transactionActionMenu.viewInUptime": "監視ステータスを表示",
"xpack.apm.transactionActionMenu.viewSampleDocumentLinkLabel": "サンプルドキュメントを表示",
"xpack.apm.transactionDetails.durationLabel": "期間",
"xpack.apm.transactionDetails.errorsNone": "なし",
"xpack.apm.transactionDetails.errorsOverviewLabel": "エラー",
"xpack.apm.transactionDetails.errorsOverviewLink": "{errorCount, plural, one {関連エラー} other {関連エラー}}",
"xpack.apm.transactionDetails.errorsOverviewLinkTooltip": "{errorCount, plural, one {1 件の関連エラーを表示} other {# 件の関連エラーを表示}}",
"xpack.apm.transactionDetails.notFoundLabel": "トランザクションが見つかりませんでした。",
"xpack.apm.transactionDetails.noTraceParentButtonTooltip": "トレースの親が見つかりませんでした",
"xpack.apm.transactionDetails.percentOfTraceLabel": "トレースの %",
"xpack.apm.transactionDetails.resultLabel": "結果",
"xpack.apm.transactionDetails.serviceLabel": "サービス",
"xpack.apm.transactionDetails.servicesTitle": "サービス",
@ -3442,7 +3437,6 @@
"xpack.apm.transactionDetails.spanFlyout.spanType.navigationTimingLabel": "ナビゲーションタイミング",
"xpack.apm.transactionDetails.spanFlyout.stackTraceTabLabel": "スタックトレース",
"xpack.apm.transactionDetails.spanFlyout.viewSpanInDiscoverButtonLabel": "ディスカバリでスパンを表示",
"xpack.apm.transactionDetails.timestampLabel": "タイムスタンプ",
"xpack.apm.transactionDetails.transactionLabel": "トランザクション",
"xpack.apm.transactionDetails.transactionsDurationDistributionChart.noSampleTooltip": "このバケットに利用可能なサンプルがありません",
"xpack.apm.transactionDetails.transactionsDurationDistributionChart.requestTypeUnitLongLabel": "{transCount, plural, =0 {# request} 1 {# 件のリクエスト} other {# 件のリクエスト}}",
@ -3454,7 +3448,6 @@
"xpack.apm.transactionDetails.transFlyout.callout.agentDroppedSpansMessage": "このトランザクションを報告した APM エージェントが、構成に基づき {dropped} 個以上のスパンをドロップしました。",
"xpack.apm.transactionDetails.transFlyout.callout.learnMoreAboutDroppedSpansLinkText": "ドロップされたスパンの詳細。",
"xpack.apm.transactionDetails.transFlyout.transactionDetailsTitle": "トランザクションの詳細",
"xpack.apm.transactionDetails.userIdLabel": "ユーザー ID",
"xpack.apm.transactionDetails.viewFullTraceButtonLabel": "完全なトレースを表示",
"xpack.apm.transactionDetails.viewingFullTraceButtonTooltip": "現在完全なトレースが表示されています",
"xpack.apm.transactions.chart.95thPercentileLabel": "95 パーセンタイル",
@ -3495,10 +3488,6 @@
"xpack.apm.metrics.pageLoadCharts.avgPageLoadByCountryLabel": "国ごとの平均ページ読み込み時間の分布",
"xpack.apm.metrics.pageLoadCharts.RegionMapChart.ToolTip.avgPageLoadDuration": "平均ページ読み込み時間:",
"xpack.apm.metrics.pageLoadCharts.RegionMapChart.ToolTip.countPageLoads": "{docCount} ページの読み込み",
"xpack.apm.settings.agentConf.agentConfigDocsLinkLabel": "詳細については、当社のドキュメントをご覧ください。",
"xpack.apm.settings.agentConf.betaBadgeLabel": "ベータ",
"xpack.apm.settings.agentConf.betaBadgeText": "この機能は開発中です。フィードバックがある場合は、ディスカッションフォーラムをご利用ください。",
"xpack.apm.settings.agentConf.betaCallOutTitle": "APM エージェント構成 (ベータ)",
"xpack.apm.settings.agentConf.configTable.editButtonDescription": "この構成を編集します",
"xpack.apm.settings.agentConf.configTable.editButtonLabel": "編集",
"xpack.apm.settings.agentConf.configTable.emptyPromptText": "変更しましょう。直接 Kibana からエージェント構成を微調整できます。再展開する必要はありません。まず、最初の構成を作成します。",
@ -3509,40 +3498,6 @@
"xpack.apm.settings.agentConf.configTable.serviceNameColumnLabel": "サービス名",
"xpack.apm.settings.agentConf.configurationsPanelTitle": "構成",
"xpack.apm.settings.agentConf.createConfigButtonLabel": "構成の作成",
"xpack.apm.settings.agentConf.createConfigFailedText": "{serviceName}の構成を作成するときに問題が発生しました。エラー: {errorMessage}",
"xpack.apm.settings.agentConf.createConfigFailedTitle": "構成を作成できませんでした",
"xpack.apm.settings.agentConf.createConfigSucceededText": "{serviceName}の構成を正常に作成しました。エージェントに反映するには、少し時間がかかります。",
"xpack.apm.settings.agentConf.createConfigSucceededTitle": "構成が作成されました。",
"xpack.apm.settings.agentConf.deleteConfigFailedText": "{serviceName}の構成を削除するときに問題が発生しました。エラー: {errorMessage}",
"xpack.apm.settings.agentConf.deleteConfigFailedTitle": "構成を削除できませんでした",
"xpack.apm.settings.agentConf.deleteConfigSucceededText": "{serviceName}の構成を正常に削除しました。エージェントに反映するには、少し時間がかかります。",
"xpack.apm.settings.agentConf.deleteConfigSucceededTitle": "構成が削除されました",
"xpack.apm.settings.agentConf.editConfigFailedText": "{serviceName}の構成を編集するときに問題が発生しました。エラー: {errorMessage}",
"xpack.apm.settings.agentConf.editConfigFailedTitle": "構成を編集できませんでした",
"xpack.apm.settings.agentConf.editConfigSucceededText": "{serviceName}の構成を正常に編集しました。エージェントに反映するには、少し時間がかかります。",
"xpack.apm.settings.agentConf.editConfigSucceededTitle": "構成が編集されました",
"xpack.apm.settings.agentConf.flyOut.betaCallOutText": "この最初のバージョンでは、サンプルレート構成のみがサポートされます。今後のリリースで、エージェントのサポートを拡張します。不具合があることを認識してください。",
"xpack.apm.settings.agentConf.flyOut.betaCallOutTitle": "APM エージェント構成 (ベータ)",
"xpack.apm.settings.agentConf.flyOut.cancelButtonLabel": "キャンセル",
"xpack.apm.settings.agentConf.flyOut.configurationSectionTitle": "構成",
"xpack.apm.settings.agentConf.flyOut.createConfigTitle": "構成の作成",
"xpack.apm.settings.agentConf.flyOut.deleteConfigurationButtonLabel": "削除",
"xpack.apm.settings.agentConf.flyOut.deleteConfigurationSectionText": "この構成を削除する場合、APM サーバーと同期するまで、エージェントは、既存の構成を使用し続けます。",
"xpack.apm.settings.agentConf.flyOut.deleteConfigurationSectionTitle": "構成の削除",
"xpack.apm.settings.agentConf.flyOut.editConfigTitle": "構成の編集",
"xpack.apm.settings.agentConf.flyOut.sampleRateConfigurationInputErrorText": "サンプルレートは 0.000 1 の範囲でなければなりません",
"xpack.apm.settings.agentConf.flyOut.sampleRateConfigurationInputHelpText": "0.000 1.0 の範囲のレートを選択してください。既定の構成は 1.0 (100% のトレース) です。",
"xpack.apm.settings.agentConf.flyOut.sampleRateConfigurationInputLabel": "トランザクションサンプルレート",
"xpack.apm.settings.agentConf.flyOut.sampleRateConfigurationInputPlaceholderText": "サンプルレートの設定",
"xpack.apm.settings.agentConf.flyOut.saveConfigurationButtonLabel": "構成の保存",
"xpack.apm.settings.agentConf.flyOut.selectPlaceholder": "選択してください",
"xpack.apm.settings.agentConf.flyOut.serviceEnvironmentNotSetOptionLabel": "未設定",
"xpack.apm.settings.agentConf.flyOut.serviceEnvironmentSelectErrorText": "構成を保存するには、有効な環境を選択する必要があります。",
"xpack.apm.settings.agentConf.flyOut.serviceEnvironmentSelectHelpText": "構成ごとに 1 つの環境のみがサポートされます。",
"xpack.apm.settings.agentConf.flyOut.serviceEnvironmentSelectLabel": "環境",
"xpack.apm.settings.agentConf.flyOut.serviceNameSelectHelpText": "構成するサービスを選択してください。",
"xpack.apm.settings.agentConf.flyOut.serviceNameSelectLabel": "名前",
"xpack.apm.settings.agentConf.flyOut.serviceSectionTitle": "サービス",
"xpack.apm.settings.agentConf.pageTitle": "設定",
"xpack.apm.settings.agentConf.returnToOverviewLinkLabel": "概要に戻る",
"xpack.apm.transactionDetails.traceNotFound": "選択されたトレースが見つかりません",

View file

@ -3426,14 +3426,9 @@
"xpack.apm.transactionActionMenu.showTraceLogsLinkLabel": "显示跟踪日志",
"xpack.apm.transactionActionMenu.viewInUptime": "查看监测状态",
"xpack.apm.transactionActionMenu.viewSampleDocumentLinkLabel": "查看样例文档",
"xpack.apm.transactionDetails.durationLabel": "持续时间",
"xpack.apm.transactionDetails.errorsNone": "无",
"xpack.apm.transactionDetails.errorsOverviewLabel": "错误",
"xpack.apm.transactionDetails.errorsOverviewLink": "{errorCount, plural, one {相关错误} other {相关错误}}",
"xpack.apm.transactionDetails.errorsOverviewLinkTooltip": "{errorCount, plural, one {查看 1 个相关错误} other {查看 # 个相关错误}}",
"xpack.apm.transactionDetails.notFoundLabel": "未找到任何事务。",
"xpack.apm.transactionDetails.noTraceParentButtonTooltip": "找不到上级追溯",
"xpack.apm.transactionDetails.percentOfTraceLabel": "追溯的 %",
"xpack.apm.transactionDetails.resultLabel": "结果",
"xpack.apm.transactionDetails.serviceLabel": "服务",
"xpack.apm.transactionDetails.servicesTitle": "服务",
@ -3443,7 +3438,6 @@
"xpack.apm.transactionDetails.spanFlyout.spanType.navigationTimingLabel": "导航定时",
"xpack.apm.transactionDetails.spanFlyout.stackTraceTabLabel": "堆栈追溯",
"xpack.apm.transactionDetails.spanFlyout.viewSpanInDiscoverButtonLabel": "在 Discover 中查看跨度",
"xpack.apm.transactionDetails.timestampLabel": "时间戳",
"xpack.apm.transactionDetails.transactionLabel": "事务",
"xpack.apm.transactionDetails.transactionsDurationDistributionChart.noSampleTooltip": "此存储桶没有可用样例",
"xpack.apm.transactionDetails.transactionsDurationDistributionChart.requestTypeUnitLongLabel": "{transCount, plural, =0 {# 个请求} one {# 个请求} other {# 个请求}}",
@ -3455,7 +3449,6 @@
"xpack.apm.transactionDetails.transFlyout.callout.agentDroppedSpansMessage": "报告此事务的 APM 代理基于其配置丢弃了 {dropped} 个跨度。",
"xpack.apm.transactionDetails.transFlyout.callout.learnMoreAboutDroppedSpansLinkText": "详细了解丢弃的跨度。",
"xpack.apm.transactionDetails.transFlyout.transactionDetailsTitle": "事务详情",
"xpack.apm.transactionDetails.userIdLabel": "用户 ID",
"xpack.apm.transactionDetails.viewFullTraceButtonLabel": "查看完整追溯信息",
"xpack.apm.transactionDetails.viewingFullTraceButtonTooltip": "当前正在查看完整追溯信息",
"xpack.apm.transactions.chart.95thPercentileLabel": "第 95 个百分位",
@ -3496,11 +3489,6 @@
"xpack.apm.metrics.pageLoadCharts.avgPageLoadByCountryLabel": "页面加载平均时长分布(按国家/地区)",
"xpack.apm.metrics.pageLoadCharts.RegionMapChart.ToolTip.avgPageLoadDuration": "页面加载平均时长:",
"xpack.apm.metrics.pageLoadCharts.RegionMapChart.ToolTip.countPageLoads": "{docCount} 个页面加载",
"xpack.apm.settings.agentConf.agentConfigDocsLinkLabel": "在我们的文档中详细了解。",
"xpack.apm.settings.agentConf.betaBadgeLabel": "公测版",
"xpack.apm.settings.agentConf.betaBadgeText": "此功能仍在开发之中。如果您有反馈,请在我们的“讨论”论坛中提供。",
"xpack.apm.settings.agentConf.betaCallOutText": "我们很高兴让您第一时间了解 APM 代理配置。{agentConfigDocsLink}",
"xpack.apm.settings.agentConf.betaCallOutTitle": "APM 代理配置(公测版)",
"xpack.apm.settings.agentConf.configTable.editButtonDescription": "编辑此配置",
"xpack.apm.settings.agentConf.configTable.editButtonLabel": "编辑",
"xpack.apm.settings.agentConf.configTable.emptyPromptText": "让我们改动一下!可以直接从 Kibana 微调代理配置,无需重新部署。首先创建您的第一个配置。",
@ -3511,40 +3499,6 @@
"xpack.apm.settings.agentConf.configTable.serviceNameColumnLabel": "服务名称",
"xpack.apm.settings.agentConf.configurationsPanelTitle": "配置",
"xpack.apm.settings.agentConf.createConfigButtonLabel": "创建配置",
"xpack.apm.settings.agentConf.createConfigFailedText": "为 {serviceName} 创建配置时出现问题。错误:{errorMessage}",
"xpack.apm.settings.agentConf.createConfigFailedTitle": "配置无法创建",
"xpack.apm.settings.agentConf.createConfigSucceededText": "您已成功为 {serviceName} 创建配置。将花费一些时间才能传播到代理。",
"xpack.apm.settings.agentConf.createConfigSucceededTitle": "配置已创建!",
"xpack.apm.settings.agentConf.deleteConfigFailedText": "为 {serviceName} 删除配置时出现问题。错误:{errorMessage}",
"xpack.apm.settings.agentConf.deleteConfigFailedTitle": "配置无法删除",
"xpack.apm.settings.agentConf.deleteConfigSucceededText": "您已成功为 {serviceName} 删除配置。将花费一些时间才能传播到代理。",
"xpack.apm.settings.agentConf.deleteConfigSucceededTitle": "配置已删除",
"xpack.apm.settings.agentConf.editConfigFailedText": "编辑 {serviceName} 的配置时出现问题。错误:{errorMessage}",
"xpack.apm.settings.agentConf.editConfigFailedTitle": "配置无法编辑",
"xpack.apm.settings.agentConf.editConfigSucceededText": "您已成功编辑 {serviceName} 的配置。将花费一些时间才能传播到代理。",
"xpack.apm.settings.agentConf.editConfigSucceededTitle": "配置已编辑",
"xpack.apm.settings.agentConf.flyOut.betaCallOutText": "请注意,在此第一版中仅支持采样速率配置。我们将在未来的版本中提供代理配置的支持。请注意故障。",
"xpack.apm.settings.agentConf.flyOut.betaCallOutTitle": "APM 代理配置(公测版)",
"xpack.apm.settings.agentConf.flyOut.cancelButtonLabel": "取消",
"xpack.apm.settings.agentConf.flyOut.configurationSectionTitle": "配置",
"xpack.apm.settings.agentConf.flyOut.createConfigTitle": "创建配置",
"xpack.apm.settings.agentConf.flyOut.deleteConfigurationButtonLabel": "删除",
"xpack.apm.settings.agentConf.flyOut.deleteConfigurationSectionText": "如果您希望删除此配置,请注意,代理将继续使用现有配置,直至它们与 APM Server 同步。",
"xpack.apm.settings.agentConf.flyOut.deleteConfigurationSectionTitle": "删除配置",
"xpack.apm.settings.agentConf.flyOut.editConfigTitle": "编辑配置",
"xpack.apm.settings.agentConf.flyOut.sampleRateConfigurationInputErrorText": "采样速率必须介于 0.000 和 1 之间",
"xpack.apm.settings.agentConf.flyOut.sampleRateConfigurationInputHelpText": "选择 0.000 和 1.0 之间的速率。默认配置为 1.0(跟踪的 100%)。",
"xpack.apm.settings.agentConf.flyOut.sampleRateConfigurationInputLabel": "事务采样速率",
"xpack.apm.settings.agentConf.flyOut.sampleRateConfigurationInputPlaceholderText": "设置采样速率",
"xpack.apm.settings.agentConf.flyOut.saveConfigurationButtonLabel": "保存配置",
"xpack.apm.settings.agentConf.flyOut.selectPlaceholder": "选择",
"xpack.apm.settings.agentConf.flyOut.serviceEnvironmentNotSetOptionLabel": "未设置",
"xpack.apm.settings.agentConf.flyOut.serviceEnvironmentSelectErrorText": "必须选择有效的环境,才能保存配置。",
"xpack.apm.settings.agentConf.flyOut.serviceEnvironmentSelectHelpText": "每个配置仅支持单个环境。",
"xpack.apm.settings.agentConf.flyOut.serviceEnvironmentSelectLabel": "环境",
"xpack.apm.settings.agentConf.flyOut.serviceNameSelectHelpText": "选择要配置的服务。",
"xpack.apm.settings.agentConf.flyOut.serviceNameSelectLabel": "名称",
"xpack.apm.settings.agentConf.flyOut.serviceSectionTitle": "服务",
"xpack.apm.settings.agentConf.pageTitle": "设置",
"xpack.apm.settings.agentConf.returnToOverviewLinkLabel": "返回至概览",
"xpack.apm.transactionDetails.traceNotFound": "找不到所选跟踪",

View file

@ -0,0 +1,174 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
import expect from '@kbn/expect';
import { FtrProviderContext } from '../../ftr_provider_context';
export default function agentConfigurationTests({ getService }: FtrProviderContext) {
const supertest = getService('supertest');
const log = getService('log');
function searchConfigurations(configuration: any) {
return supertest
.post(`/api/apm/settings/agent-configuration/search`)
.send(configuration)
.set('kbn-xsrf', 'foo');
}
let createdConfigIds: any[] = [];
async function createConfiguration(configuration: any) {
const res = await supertest
.post(`/api/apm/settings/agent-configuration/new`)
.send(configuration)
.set('kbn-xsrf', 'foo');
createdConfigIds.push(res.body._id);
return res;
}
function deleteCreatedConfigurations() {
const promises = Promise.all(createdConfigIds.map(deleteConfiguration));
createdConfigIds = [];
return promises;
}
function deleteConfiguration(configurationId: string) {
return supertest
.delete(`/api/apm/settings/agent-configuration/${configurationId}`)
.set('kbn-xsrf', 'foo');
}
describe('agent configuration', () => {
describe('when creating four configurations', () => {
before(async () => {
log.debug('creating agent configuration');
// all / all
await createConfiguration({
service: {},
settings: { transaction_sample_rate: 0.1 },
});
// my_service / all
await createConfiguration({
service: { name: 'my_service' },
settings: { transaction_sample_rate: 0.2 },
});
// all / production
await createConfiguration({
service: { environment: 'production' },
settings: { transaction_sample_rate: 0.3 },
});
// all / production
await createConfiguration({
service: { environment: 'development' },
settings: { transaction_sample_rate: 0.4 },
});
// my_service / production
await createConfiguration({
service: { name: 'my_service', environment: 'development' },
settings: { transaction_sample_rate: 0.5 },
});
});
after(async () => {
log.debug('deleting agent configurations');
await deleteCreatedConfigurations();
});
const agentsRequests = [
{
service: { name: 'non_existing_service', environment: 'non_existing_env' },
expectedSettings: { transaction_sample_rate: 0.1 },
},
{
service: { name: 'my_service', environment: 'production' },
expectedSettings: { transaction_sample_rate: 0.2 },
},
{
service: { name: 'non_existing_service', environment: 'production' },
expectedSettings: { transaction_sample_rate: 0.3 },
},
{
service: { name: 'non_existing_service', environment: 'development' },
expectedSettings: { transaction_sample_rate: 0.4 },
},
{
service: { name: 'my_service', environment: 'development' },
expectedSettings: { transaction_sample_rate: 0.5 },
},
];
for (const agentRequest of agentsRequests) {
it(`${agentRequest.service.name} / ${agentRequest.service.environment}`, async () => {
const { statusCode, body } = await searchConfigurations({
service: agentRequest.service,
etag: 'abc',
});
expect(statusCode).to.equal(200);
expect(body._source.settings).to.eql(agentRequest.expectedSettings);
});
}
});
describe('when an agent retrieves a configuration', () => {
before(async () => {
log.debug('creating agent configuration');
await createConfiguration({
service: { name: 'myservice', environment: 'development' },
settings: { transaction_sample_rate: 0.9 },
});
});
after(async () => {
log.debug('deleting agent configurations');
await deleteCreatedConfigurations();
});
it(`should have 'applied_by_agent=false' on first request`, async () => {
const { body } = await searchConfigurations({
service: { name: 'myservice', environment: 'development' },
etag: '7312bdcc34999629a3d39df24ed9b2a7553c0c39',
});
expect(body._source.applied_by_agent).to.be(false);
});
it(`should have 'applied_by_agent=true' on second request`, async () => {
async function getAppliedByAgent() {
const { body } = await searchConfigurations({
service: { name: 'myservice', environment: 'development' },
etag: '7312bdcc34999629a3d39df24ed9b2a7553c0c39',
});
return body._source.applied_by_agent;
}
// wait until `applied_by_agent` has been updated in elasticsearch
expect(await waitFor(getAppliedByAgent)).to.be(true);
});
});
});
}
async function waitFor(cb: () => Promise<boolean>, retries = 50): Promise<boolean> {
if (retries === 0) {
throw new Error(`Maximum number of retries reached`);
}
const res = await cb();
if (!res) {
await new Promise(resolve => setTimeout(resolve, 100));
return waitFor(cb, retries - 1);
}
return res;
}

View file

@ -133,7 +133,7 @@ export default function featureControlsTests({ getService }: FtrProviderContext)
req: {
method: 'post',
url: `/api/apm/settings/agent-configuration/search`,
body: { service: { name: 'test-service' } },
body: { service: { name: 'test-service' }, etag: 'abc' },
},
expectForbidden: expect404,
expectResponse: expect200,

View file

@ -9,5 +9,6 @@ import { FtrProviderContext } from '../../ftr_provider_context';
export default function apmApiIntegrationTests({ loadTestFile }: FtrProviderContext) {
describe('APM', () => {
loadTestFile(require.resolve('./feature_controls'));
loadTestFile(require.resolve('./agent_configuration'));
});
}