[APM] Support specific fields when creating service groups (#142201) (#143881)

* [APM] Support specific fields when creating service groups (#142201)

* add support to anomaly rule type to store supported service group fields in alert

* address PR feedback and fixes checks

* [CI] Auto-commit changed files from 'node scripts/precommit_hook.js --ref HEAD~1..HEAD --fix'

* [CI] Auto-commit changed files from 'node scripts/eslint --no-cache --fix'

* add API tests for field validation

* fixes linting

* [CI] Auto-commit changed files from 'node scripts/precommit_hook.js --ref HEAD~1..HEAD --fix'

* fixes multi_terms sort order paths, for each rule type query

* adds unit tests and moves some source files

* fixed back import path

* PR feedback

* improvements to kuery validation

* fixes selecting 'All' in service.name, transaction.type fields when creating/editing APM Rules (#143861)

Co-authored-by: kibanamachine <42973632+kibanamachine@users.noreply.github.com>
This commit is contained in:
Oliver Gupte 2022-10-29 01:40:47 -04:00 committed by GitHub
parent e82e0a150f
commit 796751e2d3
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
22 changed files with 700 additions and 83 deletions

View file

@ -0,0 +1,67 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import {
isSupportedField,
validateServiceGroupKuery,
SERVICE_GROUP_SUPPORTED_FIELDS,
} from './service_groups';
import {
TRANSACTION_TYPE,
TRANSACTION_DURATION,
SERVICE_FRAMEWORK_VERSION,
} from './elasticsearch_fieldnames';
describe('service_groups common utils', () => {
describe('isSupportedField', () => {
it('should allow supported fields', () => {
SERVICE_GROUP_SUPPORTED_FIELDS.map((field) => {
expect(isSupportedField(field)).toBe(true);
});
});
it('should reject unsupported fields', () => {
const unsupportedFields = [
TRANSACTION_TYPE,
TRANSACTION_DURATION,
SERVICE_FRAMEWORK_VERSION,
];
unsupportedFields.map((field) => {
expect(isSupportedField(field)).toBe(false);
});
});
});
describe('validateServiceGroupKuery', () => {
it('should validate supported KQL filter for a service group', () => {
const result = validateServiceGroupKuery(
`service.name: testbeans* or agent.name: "nodejs"`
);
expect(result).toHaveProperty('isValidFields', true);
expect(result).toHaveProperty('isValidSyntax', true);
expect(result).not.toHaveProperty('message');
});
it('should return validation error when unsupported fields are used', () => {
const result = validateServiceGroupKuery(
`service.name: testbeans* or agent.name: "nodejs" or transaction.type: request`
);
expect(result).toHaveProperty('isValidFields', false);
expect(result).toHaveProperty('isValidSyntax', true);
expect(result).toHaveProperty(
'message',
'Query filter for service group does not support fields [transaction.type]'
);
});
it('should return parsing error when KQL is incomplete', () => {
const result = validateServiceGroupKuery(
`service.name: testbeans* or agent.name: "nod`
);
expect(result).toHaveProperty('isValidFields', false);
expect(result).toHaveProperty('isValidSyntax', false);
expect(result).toHaveProperty('message');
expect(result).not.toBe('');
});
});
});

View file

@ -5,6 +5,18 @@
* 2.0.
*/
import { fromKueryExpression } from '@kbn/es-query';
import { i18n } from '@kbn/i18n';
import { getKueryFields } from './utils/get_kuery_fields';
import {
AGENT_NAME,
SERVICE_NAME,
SERVICE_ENVIRONMENT,
SERVICE_LANGUAGE_NAME,
} from './elasticsearch_fieldnames';
const LABELS = 'labels'; // implies labels.* wildcard
export const APM_SERVICE_GROUP_SAVED_OBJECT_TYPE = 'apm-service-group';
export const SERVICE_GROUP_COLOR_DEFAULT = '#D1DAE7';
export const MAX_NUMBER_OF_SERVICE_GROUPS = 500;
@ -20,3 +32,51 @@ export interface SavedServiceGroup extends ServiceGroup {
id: string;
updatedAt: number;
}
export const SERVICE_GROUP_SUPPORTED_FIELDS = [
AGENT_NAME,
SERVICE_NAME,
SERVICE_ENVIRONMENT,
SERVICE_LANGUAGE_NAME,
LABELS,
];
export function isSupportedField(fieldName: string) {
return (
fieldName.startsWith(LABELS) ||
SERVICE_GROUP_SUPPORTED_FIELDS.includes(fieldName)
);
}
export function validateServiceGroupKuery(kuery: string): {
isValidFields: boolean;
isValidSyntax: boolean;
message?: string;
} {
try {
const kueryFields = getKueryFields([fromKueryExpression(kuery)]);
const unsupportedKueryFields = kueryFields.filter(
(fieldName) => !isSupportedField(fieldName)
);
if (unsupportedKueryFields.length === 0) {
return { isValidFields: true, isValidSyntax: true };
}
return {
isValidFields: false,
isValidSyntax: true,
message: i18n.translate('xpack.apm.serviceGroups.invalidFields.message', {
defaultMessage:
'Query filter for service group does not support fields [{unsupportedFieldNames}]',
values: {
unsupportedFieldNames: unsupportedKueryFields.join(', '),
},
}),
};
} catch (error) {
return {
isValidFields: false,
isValidSyntax: false,
message: error.message,
};
}
}

View file

@ -17,7 +17,7 @@ import {
import { SERVICE_NODE_NAME_MISSING } from '../service_nodes';
export function environmentQuery(
environment: string
environment: string | undefined
): QueryDslQueryContainer[] {
if (!environment || environment === ENVIRONMENT_ALL.value) {
return [];

View file

@ -38,7 +38,9 @@ export function ServiceField({
})}
>
<SuggestionsSelect
customOptions={allowAll ? [ENVIRONMENT_ALL] : undefined}
customOptions={
allowAll ? [{ label: allOptionText, value: '' }] : undefined
}
customOptionText={i18n.translate(
'xpack.apm.serviceNamesSelectCustomOptionText',
{
@ -106,7 +108,7 @@ export function TransactionTypeField({
return (
<PopoverExpression value={currentValue || allOptionText} title={label}>
<SuggestionsSelect
customOptions={[ENVIRONMENT_ALL]}
customOptions={[{ label: allOptionText, value: '' }]}
customOptionText={i18n.translate(
'xpack.apm.transactionTypesSelectCustomOptionText',
{

View file

@ -27,6 +27,10 @@ import { KueryBar } from '../../../shared/kuery_bar';
import { ServiceListPreview } from './service_list_preview';
import type { StagedServiceGroup } from './save_modal';
import { getDateRange } from '../../../../context/url_params_context/helpers';
import {
validateServiceGroupKuery,
isSupportedField,
} from '../../../../../common/service_groups';
const CentralizedContainer = styled.div`
display: flex;
@ -39,13 +43,6 @@ const MAX_CONTAINER_HEIGHT = 600;
const MODAL_HEADER_HEIGHT = 180;
const MODAL_FOOTER_HEIGHT = 80;
const suggestedFieldsWhitelist = [
'agent.name',
'service.name',
'service.language.name',
'service.environment',
];
const Container = styled.div`
width: 600px;
height: ${MAX_CONTAINER_HEIGHT}px;
@ -70,6 +67,9 @@ export function SelectServices({
}: Props) {
const [kuery, setKuery] = useState(serviceGroup?.kuery || '');
const [stagedKuery, setStagedKuery] = useState(serviceGroup?.kuery || '');
const [kueryValidationMessage, setKueryValidationMessage] = useState<
string | undefined
>();
useEffect(() => {
if (isEdit) {
@ -78,6 +78,14 @@ export function SelectServices({
}
}, [isEdit, serviceGroup.kuery]);
useEffect(() => {
if (!stagedKuery) {
return;
}
const { message } = validateServiceGroupKuery(stagedKuery);
setKueryValidationMessage(message);
}, [stagedKuery]);
const { start, end } = useMemo(
() =>
getDateRange({
@ -122,6 +130,11 @@ export function SelectServices({
}
)}
</EuiText>
{kueryValidationMessage && (
<EuiText color="danger" size="s">
{kueryValidationMessage}
</EuiText>
)}
<EuiFlexGroup gutterSize="s">
<EuiFlexItem>
<KueryBar
@ -144,10 +157,7 @@ export function SelectServices({
},
} = querySuggestion;
return (
fieldName.startsWith('label') ||
suggestedFieldsWhitelist.includes(fieldName)
);
return isSupportedField(fieldName);
}
return true;
}}

View file

@ -17,7 +17,7 @@ import {
APM_SERVICE_GROUP_SAVED_OBJECT_TYPE,
MAX_NUMBER_OF_SERVICE_GROUPS,
} from '../../../../common/service_groups';
import { getKueryFields } from '../../helpers/get_kuery_fields';
import { getKueryFields } from '../../../../common/utils/get_kuery_fields';
import {
AGENT_NAME,
AGENT_VERSION,

View file

@ -0,0 +1,91 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import { firstValueFrom } from 'rxjs';
import {
IScopedClusterClient,
SavedObjectsClientContract,
} from '@kbn/core/server';
import {
SERVICE_ENVIRONMENT,
SERVICE_NAME,
TRANSACTION_TYPE,
TRANSACTION_DURATION,
} from '../../../../../common/elasticsearch_fieldnames';
import { alertingEsClient } from '../../alerting_es_client';
import {
getServiceGroupFields,
getServiceGroupFieldsAgg,
} from '../get_service_group_fields';
import { getApmIndices } from '../../../settings/apm_indices/get_apm_indices';
import { RegisterRuleDependencies } from '../../register_apm_rule_types';
export async function getServiceGroupFieldsForAnomaly({
config$,
scopedClusterClient,
savedObjectsClient,
serviceName,
environment,
transactionType,
timestamp,
bucketSpan,
}: {
config$: RegisterRuleDependencies['config$'];
scopedClusterClient: IScopedClusterClient;
savedObjectsClient: SavedObjectsClientContract;
serviceName: string;
environment: string;
transactionType: string;
timestamp: number;
bucketSpan: number;
}) {
const config = await firstValueFrom(config$);
const indices = await getApmIndices({
config,
savedObjectsClient,
});
const { transaction: index } = indices;
const params = {
index,
body: {
size: 0,
track_total_hits: false,
query: {
bool: {
filter: [
{ term: { [SERVICE_NAME]: serviceName } },
{ term: { [TRANSACTION_TYPE]: transactionType } },
{ term: { [SERVICE_ENVIRONMENT]: environment } },
{
range: {
'@timestamp': {
gte: timestamp,
lte: timestamp + bucketSpan * 1000,
format: 'epoch_millis',
},
},
},
],
},
},
aggs: {
...getServiceGroupFieldsAgg({
sort: [{ [TRANSACTION_DURATION]: { order: 'desc' as const } }],
}),
},
},
};
const response = await alertingEsClient({
scopedClusterClient,
params,
});
if (!response.aggregations) {
return {};
}
return getServiceGroupFields(response.aggregations);
}

View file

@ -46,6 +46,7 @@ import { getAlertUrlTransaction } from '../../../../../common/utils/formatters';
import { getMLJobs } from '../../../service_map/get_service_anomalies';
import { apmActionVariables } from '../../action_variables';
import { RegisterRuleDependencies } from '../../register_apm_rule_types';
import { getServiceGroupFieldsForAnomaly } from './get_service_group_fields_for_anomaly';
const paramsSchema = schema.object({
serviceName: schema.maybe(schema.string()),
@ -66,6 +67,7 @@ const ruleTypeConfig = RULE_TYPES_CONFIG[ApmRuleType.Anomaly];
export function registerAnomalyRuleType({
logger,
ruleDataClient,
config$,
alerting,
ml,
basePath,
@ -102,6 +104,7 @@ export function registerAnomalyRuleType({
if (!ml) {
return {};
}
const ruleParams = params;
const request = {} as KibanaRequest;
const { mlAnomalySearch } = ml.mlSystemProvider(
@ -161,8 +164,14 @@ export function registerAnomalyRuleType({
},
},
},
...termQuery('partition_field_value', ruleParams.serviceName),
...termQuery('by_field_value', ruleParams.transactionType),
...termQuery(
'partition_field_value',
ruleParams.serviceName,
{ queryEmptyString: false }
),
...termQuery('by_field_value', ruleParams.transactionType, {
queryEmptyString: false,
}),
...termQuery(
'detector_index',
getApmMlDetectorIndex(ApmMlDetectorType.txLatency)
@ -178,7 +187,8 @@ export function registerAnomalyRuleType({
{ field: 'by_field_value' },
{ field: 'job_id' },
],
size: 10000,
size: 1000,
order: { 'latest_score.record_score': 'desc' as const },
},
aggs: {
latest_score: {
@ -188,6 +198,8 @@ export function registerAnomalyRuleType({
{ field: 'partition_field_value' },
{ field: 'by_field_value' },
{ field: 'job_id' },
{ field: 'timestamp' },
{ field: 'bucket_span' },
] as const),
sort: {
timestamp: 'desc' as const,
@ -222,14 +234,35 @@ export function registerAnomalyRuleType({
transactionType: latest.by_field_value as string,
environment: job.environment,
score: latest.record_score as number,
timestamp: Date.parse(latest.timestamp as string),
bucketSpan: latest.bucket_span as number,
};
})
.filter((anomaly) =>
anomaly ? anomaly.score >= threshold : false
) ?? [];
compact(anomalies).forEach((anomaly) => {
const { serviceName, environment, transactionType, score } = anomaly;
for (const anomaly of compact(anomalies)) {
const {
serviceName,
environment,
transactionType,
score,
timestamp,
bucketSpan,
} = anomaly;
const eventSourceFields = await getServiceGroupFieldsForAnomaly({
config$,
scopedClusterClient: services.scopedClusterClient,
savedObjectsClient: services.savedObjectsClient,
serviceName,
environment,
transactionType,
timestamp,
bucketSpan,
});
const severityLevel = getSeverity(score);
const reasonMessage = formatAnomalyReason({
measured: score,
@ -270,6 +303,7 @@ export function registerAnomalyRuleType({
[ALERT_EVALUATION_VALUE]: score,
[ALERT_EVALUATION_THRESHOLD]: threshold,
[ALERT_REASON]: reasonMessage,
...eventSourceFields,
},
})
.scheduleActions(ruleTypeConfig.defaultActionGroupId, {
@ -281,7 +315,7 @@ export function registerAnomalyRuleType({
reason: reasonMessage,
viewInAppUrl,
});
});
}
return {};
},

View file

@ -37,6 +37,10 @@ import { getApmIndices } from '../../../settings/apm_indices/get_apm_indices';
import { apmActionVariables } from '../../action_variables';
import { alertingEsClient } from '../../alerting_es_client';
import { RegisterRuleDependencies } from '../../register_apm_rule_types';
import {
getServiceGroupFieldsAgg,
getServiceGroupFields,
} from '../get_service_group_fields';
const paramsSchema = schema.object({
windowSize: schema.number(),
@ -107,7 +111,9 @@ export function registerErrorCountRuleType({
},
},
{ term: { [PROCESSOR_EVENT]: ProcessorEvent.error } },
...termQuery(SERVICE_NAME, ruleParams.serviceName),
...termQuery(SERVICE_NAME, ruleParams.serviceName, {
queryEmptyString: false,
}),
...environmentQuery(ruleParams.environment),
],
},
@ -122,8 +128,10 @@ export function registerErrorCountRuleType({
missing: ENVIRONMENT_NOT_DEFINED.value,
},
],
size: 10000,
size: 1000,
order: { _count: 'desc' as const },
},
aggs: getServiceGroupFieldsAgg(),
},
},
},
@ -137,13 +145,19 @@ export function registerErrorCountRuleType({
const errorCountResults =
response.aggregations?.error_counts.buckets.map((bucket) => {
const [serviceName, environment] = bucket.key;
return { serviceName, environment, errorCount: bucket.doc_count };
return {
serviceName,
environment,
errorCount: bucket.doc_count,
sourceFields: getServiceGroupFields(bucket),
};
}) ?? [];
errorCountResults
.filter((result) => result.errorCount >= ruleParams.threshold)
.forEach((result) => {
const { serviceName, environment, errorCount } = result;
const { serviceName, environment, errorCount, sourceFields } =
result;
const alertReason = formatErrorCountReason({
serviceName,
threshold: ruleParams.threshold,
@ -176,6 +190,7 @@ export function registerErrorCountRuleType({
[ALERT_EVALUATION_VALUE]: errorCount,
[ALERT_EVALUATION_THRESHOLD]: ruleParams.threshold,
[ALERT_REASON]: alertReason,
...sourceFields,
},
})
.scheduleActions(ruleTypeConfig.defaultActionGroupId, {

View file

@ -0,0 +1,121 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import {
getServiceGroupFields,
getServiceGroupFieldsAgg,
flattenSourceDoc,
} from './get_service_group_fields';
const mockSourceObj = {
service: {
name: 'testbeans',
environment: 'testing',
language: {
name: 'typescript',
},
},
labels: {
team: 'test',
},
agent: {
name: 'nodejs',
},
};
const mockBucket = {
source_fields: {
hits: {
hits: [{ _source: mockSourceObj }],
},
},
};
describe('getSourceFields', () => {
it('should return a flattened record of fields and values for a given bucket', () => {
const result = getServiceGroupFields(mockBucket);
expect(result).toMatchInlineSnapshot(`
Object {
"agent.name": "nodejs",
"labels.team": "test",
"service.environment": "testing",
"service.language.name": "typescript",
"service.name": "testbeans",
}
`);
});
});
describe('getSourceFieldsAgg', () => {
it('should create a agg for specific source fields', () => {
const agg = getServiceGroupFieldsAgg();
expect(agg).toMatchInlineSnapshot(`
Object {
"source_fields": Object {
"top_hits": Object {
"_source": Object {
"includes": Array [
"agent.name",
"service.name",
"service.environment",
"service.language.name",
"labels",
],
},
"size": 1,
},
},
}
`);
});
it('should accept options for top_hits options', () => {
const agg = getServiceGroupFieldsAgg({
sort: [{ 'transaction.duration.us': { order: 'desc' } }],
});
expect(agg).toMatchInlineSnapshot(`
Object {
"source_fields": Object {
"top_hits": Object {
"_source": Object {
"includes": Array [
"agent.name",
"service.name",
"service.environment",
"service.language.name",
"labels",
],
},
"size": 1,
"sort": Array [
Object {
"transaction.duration.us": Object {
"order": "desc",
},
},
],
},
},
}
`);
});
});
describe('flattenSourceDoc', () => {
it('should flatten a given nested object with dot delim paths as keys', () => {
const result = flattenSourceDoc(mockSourceObj);
expect(result).toMatchInlineSnapshot(`
Object {
"agent.name": "nodejs",
"labels.team": "test",
"service.environment": "testing",
"service.language.name": "typescript",
"service.name": "testbeans",
}
`);
});
});

View file

@ -0,0 +1,59 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import { AggregationsTopHitsAggregation } from '@elastic/elasticsearch/lib/api/types';
import { SERVICE_GROUP_SUPPORTED_FIELDS } from '../../../../common/service_groups';
export interface SourceDoc {
[key: string]: string | SourceDoc;
}
export function getServiceGroupFieldsAgg(
topHitsOpts: AggregationsTopHitsAggregation = {}
) {
return {
source_fields: {
top_hits: {
size: 1,
_source: {
includes: SERVICE_GROUP_SUPPORTED_FIELDS,
},
...topHitsOpts,
},
},
};
}
interface AggResultBucket {
source_fields: {
hits: {
hits: Array<{ _source: any }>;
};
};
}
export function getServiceGroupFields(bucket?: AggResultBucket) {
if (!bucket) {
return {};
}
const sourceDoc: SourceDoc =
bucket?.source_fields?.hits.hits[0]?._source ?? {};
return flattenSourceDoc(sourceDoc);
}
export function flattenSourceDoc(
val: SourceDoc | string,
path: string[] = []
): Record<string, string> {
if (typeof val !== 'object') {
return { [path.join('.')]: val };
}
return Object.keys(val).reduce((acc, key) => {
const fieldMap = flattenSourceDoc(val[key], [...path, key]);
return { ...acc, ...fieldMap };
}, {});
}

View file

@ -4,8 +4,8 @@
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import { AggregationType } from '../../../common/rules/apm_rule_types';
import { getDurationFieldForTransactions } from '../../lib/helpers/transactions';
import { AggregationType } from '../../../../../common/rules/apm_rule_types';
import { getDurationFieldForTransactions } from '../../../../lib/helpers/transactions';
type TransactionDurationField = ReturnType<
typeof getDurationFieldForTransactions
@ -45,3 +45,13 @@ export function averageOrPercentileAgg({
},
};
}
export function getMultiTermsSortOrder(aggregationType: AggregationType): {
order: { [path: string]: 'desc' };
} {
if (aggregationType === AggregationType.Avg) {
return { order: { avgLatency: 'desc' } };
}
const percentsKey = aggregationType === AggregationType.P95 ? 95 : 99;
return { order: { [`pctLatency.${percentsKey}`]: 'desc' } };
}

View file

@ -25,7 +25,7 @@ import {
ENVIRONMENT_NOT_DEFINED,
getEnvironmentLabel,
} from '../../../../../common/environment_filter_values';
import { averageOrPercentileAgg } from '../../average_or_percentile_agg';
import { averageOrPercentileAgg } from './average_or_percentile_agg';
import { APMConfig } from '../../../..';
import { APMEventClient } from '../../../../lib/helpers/create_es_client/create_apm_event_client';

View file

@ -24,10 +24,10 @@ describe('registerTransactionDurationRuleType', () => {
},
},
aggregations: {
environments: {
series: {
buckets: [
{
key: 'ENVIRONMENT_NOT_DEFINED',
key: ['opbeans-java', 'ENVIRONMENT_NOT_DEFINED', 'request'],
avgLatency: {
value: 5500000,
},

View file

@ -14,6 +14,7 @@ import {
} from '@kbn/rule-data-utils';
import { firstValueFrom } from 'rxjs';
import { asDuration } from '@kbn/observability-plugin/common/utils/formatters';
import { termQuery } from '@kbn/observability-plugin/server';
import { createLifecycleRuleTypeFactory } from '@kbn/rule-registry-plugin/server';
import { ProcessorEvent } from '@kbn/observability-plugin/common';
import { getAlertUrlTransaction } from '../../../../../common/utils/formatters';
@ -46,7 +47,14 @@ import { getApmIndices } from '../../../settings/apm_indices/get_apm_indices';
import { apmActionVariables } from '../../action_variables';
import { alertingEsClient } from '../../alerting_es_client';
import { RegisterRuleDependencies } from '../../register_apm_rule_types';
import { averageOrPercentileAgg } from '../../average_or_percentile_agg';
import {
averageOrPercentileAgg,
getMultiTermsSortOrder,
} from './average_or_percentile_agg';
import {
getServiceGroupFields,
getServiceGroupFieldsAgg,
} from '../get_service_group_fields';
const paramsSchema = schema.object({
serviceName: schema.string(),
@ -140,26 +148,37 @@ export function registerTransactionDurationRuleType({
...getDocumentTypeFilterForTransactions(
searchAggregatedTransactions
),
{ term: { [SERVICE_NAME]: ruleParams.serviceName } },
{
term: {
[TRANSACTION_TYPE]: ruleParams.transactionType,
},
},
...termQuery(SERVICE_NAME, ruleParams.serviceName, {
queryEmptyString: false,
}),
...termQuery(TRANSACTION_TYPE, ruleParams.transactionType, {
queryEmptyString: false,
}),
...environmentQuery(ruleParams.environment),
] as QueryDslQueryContainer[],
},
},
aggs: {
environments: {
terms: {
field: SERVICE_ENVIRONMENT,
missing: ENVIRONMENT_NOT_DEFINED.value,
series: {
multi_terms: {
terms: [
{ field: SERVICE_NAME },
{
field: SERVICE_ENVIRONMENT,
missing: ENVIRONMENT_NOT_DEFINED.value,
},
{ field: TRANSACTION_TYPE },
],
size: 1000,
...getMultiTermsSortOrder(ruleParams.aggregationType),
},
aggs: {
...averageOrPercentileAgg({
aggregationType: ruleParams.aggregationType,
transactionDurationField: field,
}),
...getServiceGroupFieldsAgg(),
},
aggs: averageOrPercentileAgg({
aggregationType: ruleParams.aggregationType,
transactionDurationField: field,
}),
},
},
},
@ -177,32 +196,40 @@ export function registerTransactionDurationRuleType({
// Converts threshold to microseconds because this is the unit used on transactionDuration
const thresholdMicroseconds = ruleParams.threshold * 1000;
const triggeredEnvironmentDurations =
response.aggregations.environments.buckets
.map((bucket) => {
const { key: environment } = bucket;
const transactionDuration =
'avgLatency' in bucket // only true if ruleParams.aggregationType === 'avg'
? bucket.avgLatency.value
: bucket.pctLatency.values[0].value;
return { transactionDuration, environment };
})
.filter(
({ transactionDuration }) =>
transactionDuration !== null &&
transactionDuration > thresholdMicroseconds
) as Array<{ transactionDuration: number; environment: string }>;
const triggeredBuckets = [];
for (const bucket of response.aggregations.series.buckets) {
const [serviceName, environment, transactionType] = bucket.key;
const transactionDuration =
'avgLatency' in bucket // only true if ruleParams.aggregationType === 'avg'
? bucket.avgLatency.value
: bucket.pctLatency.values[0].value;
if (
transactionDuration !== null &&
transactionDuration > thresholdMicroseconds
) {
triggeredBuckets.push({
serviceName,
environment,
transactionType,
transactionDuration,
sourceFields: getServiceGroupFields(bucket),
});
}
}
for (const {
serviceName,
environment,
transactionType,
transactionDuration,
} of triggeredEnvironmentDurations) {
sourceFields,
} of triggeredBuckets) {
const durationFormatter = getDurationFormatter(transactionDuration);
const transactionDurationFormatted =
durationFormatter(transactionDuration).formatted;
const reasonMessage = formatTransactionDurationReason({
measured: transactionDuration,
serviceName: ruleParams.serviceName,
serviceName,
threshold: thresholdMicroseconds,
asDuration,
aggregationType: String(ruleParams.aggregationType),
@ -211,9 +238,9 @@ export function registerTransactionDurationRuleType({
});
const relativeViewInAppUrl = getAlertUrlTransaction(
ruleParams.serviceName,
serviceName,
getEnvironmentEsField(environment)?.[SERVICE_ENVIRONMENT],
ruleParams.transactionType
transactionType
);
const viewInAppUrl = basePath.publicBaseUrl
@ -228,18 +255,19 @@ export function registerTransactionDurationRuleType({
environment
)}`,
fields: {
[SERVICE_NAME]: ruleParams.serviceName,
[SERVICE_NAME]: serviceName,
...getEnvironmentEsField(environment),
[TRANSACTION_TYPE]: ruleParams.transactionType,
[TRANSACTION_TYPE]: transactionType,
[PROCESSOR_EVENT]: ProcessorEvent.transaction,
[ALERT_EVALUATION_VALUE]: transactionDuration,
[ALERT_EVALUATION_THRESHOLD]: thresholdMicroseconds,
[ALERT_REASON]: reasonMessage,
...sourceFields,
},
})
.scheduleActions(ruleTypeConfig.defaultActionGroupId, {
transactionType: ruleParams.transactionType,
serviceName: ruleParams.serviceName,
transactionType,
serviceName,
environment: getEnvironmentLabel(environment),
threshold: thresholdMicroseconds,
triggerValue: transactionDurationFormatted,

View file

@ -44,6 +44,10 @@ import { alertingEsClient } from '../../alerting_es_client';
import { RegisterRuleDependencies } from '../../register_apm_rule_types';
import { SearchAggregatedTransactionSetting } from '../../../../../common/aggregated_transactions';
import { getDocumentTypeFilterForTransactions } from '../../../../lib/helpers/transactions';
import {
getServiceGroupFields,
getServiceGroupFieldsAgg,
} from '../get_service_group_fields';
const paramsSchema = schema.object({
windowSize: schema.number(),
@ -136,8 +140,12 @@ export function registerTransactionErrorRateRuleType({
],
},
},
...termQuery(SERVICE_NAME, ruleParams.serviceName),
...termQuery(TRANSACTION_TYPE, ruleParams.transactionType),
...termQuery(SERVICE_NAME, ruleParams.serviceName, {
queryEmptyString: false,
}),
...termQuery(TRANSACTION_TYPE, ruleParams.transactionType, {
queryEmptyString: false,
}),
...environmentQuery(ruleParams.environment),
],
},
@ -153,13 +161,15 @@ export function registerTransactionErrorRateRuleType({
},
{ field: TRANSACTION_TYPE },
],
size: 10000,
size: 1000,
order: { _count: 'desc' as const },
},
aggs: {
outcomes: {
terms: {
field: EVENT_OUTCOME,
},
aggs: getServiceGroupFieldsAgg(),
},
},
},
@ -180,10 +190,10 @@ export function registerTransactionErrorRateRuleType({
for (const bucket of response.aggregations.series.buckets) {
const [serviceName, environment, transactionType] = bucket.key;
const failed =
bucket.outcomes.buckets.find(
(outcomeBucket) => outcomeBucket.key === EventOutcome.failure
)?.doc_count ?? 0;
const failedOutcomeBucket = bucket.outcomes.buckets.find(
(outcomeBucket) => outcomeBucket.key === EventOutcome.failure
);
const failed = failedOutcomeBucket?.doc_count ?? 0;
const succesful =
bucket.outcomes.buckets.find(
(outcomeBucket) => outcomeBucket.key === EventOutcome.success
@ -196,13 +206,19 @@ export function registerTransactionErrorRateRuleType({
environment,
transactionType,
errorRate,
sourceFields: getServiceGroupFields(failedOutcomeBucket),
});
}
}
results.forEach((result) => {
const { serviceName, environment, transactionType, errorRate } =
result;
const {
serviceName,
environment,
transactionType,
errorRate,
sourceFields,
} = result;
const reasonMessage = formatTransactionErrorRateReason({
threshold: ruleParams.threshold,
measured: errorRate,
@ -241,6 +257,7 @@ export function registerTransactionErrorRateRuleType({
[ALERT_EVALUATION_VALUE]: errorRate,
[ALERT_EVALUATION_THRESHOLD]: ruleParams.threshold,
[ALERT_REASON]: reasonMessage,
...sourceFields,
},
})
.scheduleActions(ruleTypeConfig.defaultActionGroupId, {

View file

@ -6,6 +6,7 @@
*/
import * as t from 'io-ts';
import Boom from '@hapi/boom';
import { apmServiceGroupMaxNumberOfServices } from '@kbn/observability-plugin/common';
import { createApmServerRoute } from '../apm_routes/create_apm_server_route';
import { kueryRt, rangeRt } from '../default_api_types';
@ -14,7 +15,10 @@ import { getServiceGroup } from './get_service_group';
import { saveServiceGroup } from './save_service_group';
import { deleteServiceGroup } from './delete_service_group';
import { lookupServices } from './lookup_services';
import { SavedServiceGroup } from '../../../common/service_groups';
import {
validateServiceGroupKuery,
SavedServiceGroup,
} from '../../../common/service_groups';
import { getServicesCounts } from './get_services_counts';
import { getApmEventClient } from '../../lib/helpers/get_apm_event_client';
@ -120,6 +124,12 @@ const serviceGroupSaveRoute = createApmServerRoute({
const {
savedObjects: { client: savedObjectsClient },
} = await context.core;
const { isValidFields, isValidSyntax, message } = validateServiceGroupKuery(
params.body.kuery
);
if (!(isValidFields && isValidSyntax)) {
throw Boom.badRequest(message);
}
await saveServiceGroup({
savedObjectsClient,

View file

@ -157,14 +157,14 @@ export async function getServiceAnomalies({
export async function getMLJobs(
anomalyDetectors: ReturnType<MlPluginSetup['anomalyDetectorsProvider']>,
environment: string
environment?: string
) {
const jobs = await getMlJobsWithAPMGroup(anomalyDetectors);
// to filter out legacy jobs we are filtering by the existence of `apm_ml_version` in `custom_settings`
// and checking that it is compatable.
const mlJobs = jobs.filter((job) => job.version >= 2);
if (environment !== ENVIRONMENT_ALL.value) {
if (environment && environment !== ENVIRONMENT_ALL.value) {
const matchingMLJob = mlJobs.find((job) => job.environment === environment);
if (!matchingMLJob) {
return [];
@ -176,7 +176,7 @@ export async function getMLJobs(
export async function getMLJobIds(
anomalyDetectors: ReturnType<MlPluginSetup['anomalyDetectorsProvider']>,
environment: string
environment?: string
) {
const mlJobs = await getMLJobs(anomalyDetectors, environment);
return mlJobs.map((job) => job.jobId);

View file

@ -13,11 +13,16 @@ function isUndefinedOrNull(value: any): value is undefined | null {
return value === undefined || value === null;
}
interface TermQueryOpts {
queryEmptyString: boolean;
}
export function termQuery<T extends string>(
field: T,
value: string | boolean | number | undefined | null
value: string | boolean | number | undefined | null,
opts: TermQueryOpts = { queryEmptyString: true }
): QueryDslQueryContainer[] {
if (isUndefinedOrNull(value)) {
if (isUndefinedOrNull(value) || (!opts.queryEmptyString && value === '')) {
return [];
}

View file

@ -0,0 +1,88 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import expect from '@kbn/expect';
import { ApmApiError } from '../../common/apm_api_supertest';
import { FtrProviderContext } from '../../common/ftr_provider_context';
import { expectToReject } from '../../common/utils/expect_to_reject';
export default function ApiTest({ getService }: FtrProviderContext) {
const registry = getService('registry');
const apmApiClient = getService('apmApiClient');
const supertest = getService('supertest');
async function callApi({
serviceGroupId,
groupName,
kuery,
description,
color,
}: {
serviceGroupId?: string;
groupName: string;
kuery: string;
description?: string;
color?: string;
}) {
const response = await apmApiClient.writeUser({
endpoint: 'POST /internal/apm/service-group',
params: {
query: {
serviceGroupId,
},
body: {
groupName,
kuery,
description,
color,
},
},
});
return response;
}
type SavedObjectsFindResults = Array<{
id: string;
type: string;
}>;
async function deleteServiceGroups() {
const response = await supertest
.get('/api/saved_objects/_find?type=apm-service-group')
.set('kbn-xsrf', 'true');
const savedObjects: SavedObjectsFindResults = response.body.saved_objects;
const bulkDeleteBody = savedObjects.map(({ id, type }) => ({ id, type }));
return supertest
.post(`/api/saved_objects/_bulk_delete?force=true`)
.set('kbn-xsrf', 'foo')
.send(bulkDeleteBody);
}
registry.when('Service group create', { config: 'basic', archives: [] }, () => {
afterEach(deleteServiceGroups);
it('creates a new service group', async () => {
const response = await callApi({
groupName: 'synthbeans',
kuery: 'service.name: synth*',
});
expect(response.status).to.be(200);
expect(Object.keys(response.body).length).to.be(0);
});
it('handles invalid fields with error response', async () => {
const err = await expectToReject<ApmApiError>(() =>
callApi({
groupName: 'synthbeans',
kuery: 'service.name: synth* or transaction.type: request',
})
);
expect(err.res.status).to.be(400);
expect(err.res.body.message).to.contain('transaction.type');
});
});
}