diff --git a/x-pack/plugins/apm/common/service_groups.test.ts b/x-pack/plugins/apm/common/service_groups.test.ts new file mode 100644 index 0000000000000..856eec4ef2e3f --- /dev/null +++ b/x-pack/plugins/apm/common/service_groups.test.ts @@ -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(''); + }); + }); +}); diff --git a/x-pack/plugins/apm/common/service_groups.ts b/x-pack/plugins/apm/common/service_groups.ts index e3a82e7e56b6c..4b2ba1288ecae 100644 --- a/x-pack/plugins/apm/common/service_groups.ts +++ b/x-pack/plugins/apm/common/service_groups.ts @@ -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, + }; + } +} diff --git a/x-pack/plugins/apm/common/utils/environment_query.ts b/x-pack/plugins/apm/common/utils/environment_query.ts index bc02e4cd2518b..42744778b861b 100644 --- a/x-pack/plugins/apm/common/utils/environment_query.ts +++ b/x-pack/plugins/apm/common/utils/environment_query.ts @@ -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 []; diff --git a/x-pack/plugins/apm/server/lib/helpers/get_kuery_fields.test.ts b/x-pack/plugins/apm/common/utils/get_kuery_fields.test.ts similarity index 100% rename from x-pack/plugins/apm/server/lib/helpers/get_kuery_fields.test.ts rename to x-pack/plugins/apm/common/utils/get_kuery_fields.test.ts diff --git a/x-pack/plugins/apm/server/lib/helpers/get_kuery_fields.ts b/x-pack/plugins/apm/common/utils/get_kuery_fields.ts similarity index 100% rename from x-pack/plugins/apm/server/lib/helpers/get_kuery_fields.ts rename to x-pack/plugins/apm/common/utils/get_kuery_fields.ts diff --git a/x-pack/plugins/apm/public/components/alerting/utils/fields.tsx b/x-pack/plugins/apm/public/components/alerting/utils/fields.tsx index 129c36e14102c..3f028c2ead002 100644 --- a/x-pack/plugins/apm/public/components/alerting/utils/fields.tsx +++ b/x-pack/plugins/apm/public/components/alerting/utils/fields.tsx @@ -38,7 +38,9 @@ export function ServiceField({ })} > (); 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({ } )} + {kueryValidationMessage && ( + + {kueryValidationMessage} + + )} 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 {}; }, diff --git a/x-pack/plugins/apm/server/routes/alerts/rule_types/error_count/register_error_count_rule_type.ts b/x-pack/plugins/apm/server/routes/alerts/rule_types/error_count/register_error_count_rule_type.ts index 58e475ced07fb..d9826aae392c8 100644 --- a/x-pack/plugins/apm/server/routes/alerts/rule_types/error_count/register_error_count_rule_type.ts +++ b/x-pack/plugins/apm/server/routes/alerts/rule_types/error_count/register_error_count_rule_type.ts @@ -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, { diff --git a/x-pack/plugins/apm/server/routes/alerts/rule_types/get_service_group_fields.test.ts b/x-pack/plugins/apm/server/routes/alerts/rule_types/get_service_group_fields.test.ts new file mode 100644 index 0000000000000..0df590d524f91 --- /dev/null +++ b/x-pack/plugins/apm/server/routes/alerts/rule_types/get_service_group_fields.test.ts @@ -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", + } + `); + }); +}); diff --git a/x-pack/plugins/apm/server/routes/alerts/rule_types/get_service_group_fields.ts b/x-pack/plugins/apm/server/routes/alerts/rule_types/get_service_group_fields.ts new file mode 100644 index 0000000000000..2a50b8ba2f31e --- /dev/null +++ b/x-pack/plugins/apm/server/routes/alerts/rule_types/get_service_group_fields.ts @@ -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 { + 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 }; + }, {}); +} diff --git a/x-pack/plugins/apm/server/routes/alerts/average_or_percentile_agg.ts b/x-pack/plugins/apm/server/routes/alerts/rule_types/transaction_duration/average_or_percentile_agg.ts similarity index 70% rename from x-pack/plugins/apm/server/routes/alerts/average_or_percentile_agg.ts rename to x-pack/plugins/apm/server/routes/alerts/rule_types/transaction_duration/average_or_percentile_agg.ts index 0a7b9e29229bb..2e61108b8a9a0 100644 --- a/x-pack/plugins/apm/server/routes/alerts/average_or_percentile_agg.ts +++ b/x-pack/plugins/apm/server/routes/alerts/rule_types/transaction_duration/average_or_percentile_agg.ts @@ -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' } }; +} diff --git a/x-pack/plugins/apm/server/routes/alerts/rule_types/transaction_duration/get_transaction_duration_chart_preview.ts b/x-pack/plugins/apm/server/routes/alerts/rule_types/transaction_duration/get_transaction_duration_chart_preview.ts index 292748f3af16c..781e9739fdba9 100644 --- a/x-pack/plugins/apm/server/routes/alerts/rule_types/transaction_duration/get_transaction_duration_chart_preview.ts +++ b/x-pack/plugins/apm/server/routes/alerts/rule_types/transaction_duration/get_transaction_duration_chart_preview.ts @@ -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'; diff --git a/x-pack/plugins/apm/server/routes/alerts/rule_types/transaction_duration/register_transaction_duration_rule_type.test.ts b/x-pack/plugins/apm/server/routes/alerts/rule_types/transaction_duration/register_transaction_duration_rule_type.test.ts index 4d8b91636fb6c..2b159e7acc0d2 100644 --- a/x-pack/plugins/apm/server/routes/alerts/rule_types/transaction_duration/register_transaction_duration_rule_type.test.ts +++ b/x-pack/plugins/apm/server/routes/alerts/rule_types/transaction_duration/register_transaction_duration_rule_type.test.ts @@ -24,10 +24,10 @@ describe('registerTransactionDurationRuleType', () => { }, }, aggregations: { - environments: { + series: { buckets: [ { - key: 'ENVIRONMENT_NOT_DEFINED', + key: ['opbeans-java', 'ENVIRONMENT_NOT_DEFINED', 'request'], avgLatency: { value: 5500000, }, diff --git a/x-pack/plugins/apm/server/routes/alerts/rule_types/transaction_duration/register_transaction_duration_rule_type.ts b/x-pack/plugins/apm/server/routes/alerts/rule_types/transaction_duration/register_transaction_duration_rule_type.ts index 0ea099c8d4bc2..b4c7a6212b62d 100644 --- a/x-pack/plugins/apm/server/routes/alerts/rule_types/transaction_duration/register_transaction_duration_rule_type.ts +++ b/x-pack/plugins/apm/server/routes/alerts/rule_types/transaction_duration/register_transaction_duration_rule_type.ts @@ -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, diff --git a/x-pack/plugins/apm/server/routes/alerts/rule_types/transaction_error_rate/register_transaction_error_rate_rule_type.ts b/x-pack/plugins/apm/server/routes/alerts/rule_types/transaction_error_rate/register_transaction_error_rate_rule_type.ts index 15a5880345ffd..73f7ccda26401 100644 --- a/x-pack/plugins/apm/server/routes/alerts/rule_types/transaction_error_rate/register_transaction_error_rate_rule_type.ts +++ b/x-pack/plugins/apm/server/routes/alerts/rule_types/transaction_error_rate/register_transaction_error_rate_rule_type.ts @@ -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, { diff --git a/x-pack/plugins/apm/server/routes/service_groups/route.ts b/x-pack/plugins/apm/server/routes/service_groups/route.ts index 4da84e6848696..dde307efa7c4b 100644 --- a/x-pack/plugins/apm/server/routes/service_groups/route.ts +++ b/x-pack/plugins/apm/server/routes/service_groups/route.ts @@ -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, diff --git a/x-pack/plugins/apm/server/routes/service_map/get_service_anomalies.ts b/x-pack/plugins/apm/server/routes/service_map/get_service_anomalies.ts index 3c5211528cc91..e67b2b554df05 100644 --- a/x-pack/plugins/apm/server/routes/service_map/get_service_anomalies.ts +++ b/x-pack/plugins/apm/server/routes/service_map/get_service_anomalies.ts @@ -157,14 +157,14 @@ export async function getServiceAnomalies({ export async function getMLJobs( anomalyDetectors: ReturnType, - 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, - environment: string + environment?: string ) { const mlJobs = await getMLJobs(anomalyDetectors, environment); return mlJobs.map((job) => job.jobId); diff --git a/x-pack/plugins/observability/server/utils/queries.ts b/x-pack/plugins/observability/server/utils/queries.ts index 008b8720de7cd..f6a5a02d8e415 100644 --- a/x-pack/plugins/observability/server/utils/queries.ts +++ b/x-pack/plugins/observability/server/utils/queries.ts @@ -13,11 +13,16 @@ function isUndefinedOrNull(value: any): value is undefined | null { return value === undefined || value === null; } +interface TermQueryOpts { + queryEmptyString: boolean; +} + export function termQuery( 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 []; } diff --git a/x-pack/test/apm_api_integration/tests/service_groups/save_service_group.spec.ts b/x-pack/test/apm_api_integration/tests/service_groups/save_service_group.spec.ts new file mode 100644 index 0000000000000..533d6079c1a6d --- /dev/null +++ b/x-pack/test/apm_api_integration/tests/service_groups/save_service_group.spec.ts @@ -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(() => + 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'); + }); + }); +}