diff --git a/packages/kbn-io-ts-utils/src/index.ts b/packages/kbn-io-ts-utils/src/index.ts index 2032127b1eb91..418a5a41a2bec 100644 --- a/packages/kbn-io-ts-utils/src/index.ts +++ b/packages/kbn-io-ts-utils/src/index.ts @@ -9,3 +9,6 @@ export { jsonRt } from './json_rt'; export { mergeRt } from './merge_rt'; export { strictKeysRt } from './strict_keys_rt'; +export { isoToEpochRt } from './iso_to_epoch_rt'; +export { toNumberRt } from './to_number_rt'; +export { toBooleanRt } from './to_boolean_rt'; diff --git a/x-pack/plugins/apm/common/runtime_types/iso_to_epoch_rt/index.test.ts b/packages/kbn-io-ts-utils/src/iso_to_epoch_rt/index.test.ts similarity index 83% rename from x-pack/plugins/apm/common/runtime_types/iso_to_epoch_rt/index.test.ts rename to packages/kbn-io-ts-utils/src/iso_to_epoch_rt/index.test.ts index 573bfdc83e429..d6d6afbf98755 100644 --- a/x-pack/plugins/apm/common/runtime_types/iso_to_epoch_rt/index.test.ts +++ b/packages/kbn-io-ts-utils/src/iso_to_epoch_rt/index.test.ts @@ -1,8 +1,9 @@ /* * 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. + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. */ import { isoToEpochRt } from './index'; diff --git a/x-pack/plugins/apm/common/runtime_types/iso_to_epoch_rt/index.ts b/packages/kbn-io-ts-utils/src/iso_to_epoch_rt/index.ts similarity index 69% rename from x-pack/plugins/apm/common/runtime_types/iso_to_epoch_rt/index.ts rename to packages/kbn-io-ts-utils/src/iso_to_epoch_rt/index.ts index 970e39bc4f86f..fe2ece7b0730c 100644 --- a/x-pack/plugins/apm/common/runtime_types/iso_to_epoch_rt/index.ts +++ b/packages/kbn-io-ts-utils/src/iso_to_epoch_rt/index.ts @@ -1,8 +1,9 @@ /* * 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. + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. */ import * as t from 'io-ts'; @@ -17,9 +18,7 @@ export const isoToEpochRt = new t.Type( (input, context) => either.chain(t.string.validate(input, context), (str) => { const epochDate = new Date(str).getTime(); - return isNaN(epochDate) - ? t.failure(input, context) - : t.success(epochDate); + return isNaN(epochDate) ? t.failure(input, context) : t.success(epochDate); }), (output) => new Date(output).toISOString() ); diff --git a/x-pack/plugins/apm/common/runtime_types/to_boolean_rt/index.ts b/packages/kbn-io-ts-utils/src/to_boolean_rt/index.ts similarity index 72% rename from x-pack/plugins/apm/common/runtime_types/to_boolean_rt/index.ts rename to packages/kbn-io-ts-utils/src/to_boolean_rt/index.ts index 1e6828ed4ead3..eb8277adb281e 100644 --- a/x-pack/plugins/apm/common/runtime_types/to_boolean_rt/index.ts +++ b/packages/kbn-io-ts-utils/src/to_boolean_rt/index.ts @@ -1,8 +1,9 @@ /* * 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. + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. */ import * as t from 'io-ts'; diff --git a/x-pack/plugins/apm/common/runtime_types/to_number_rt/index.ts b/packages/kbn-io-ts-utils/src/to_number_rt/index.ts similarity index 70% rename from x-pack/plugins/apm/common/runtime_types/to_number_rt/index.ts rename to packages/kbn-io-ts-utils/src/to_number_rt/index.ts index a4632680cb6e1..ae6bb4911c907 100644 --- a/x-pack/plugins/apm/common/runtime_types/to_number_rt/index.ts +++ b/packages/kbn-io-ts-utils/src/to_number_rt/index.ts @@ -1,8 +1,9 @@ /* * 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. + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. */ import * as t from 'io-ts'; diff --git a/packages/kbn-optimizer/limits.yml b/packages/kbn-optimizer/limits.yml index e114e3e930016..f42ca7451601b 100644 --- a/packages/kbn-optimizer/limits.yml +++ b/packages/kbn-optimizer/limits.yml @@ -61,6 +61,7 @@ pageLoadAssetSize: remoteClusters: 51327 reporting: 183418 rollup: 97204 + ruleRegistry: 100000 savedObjects: 108518 savedObjectsManagement: 101836 savedObjectsTagging: 59482 diff --git a/x-pack/plugins/apm/common/rules.ts b/x-pack/plugins/apm/common/rules.ts new file mode 100644 index 0000000000000..a3b60a785f5c7 --- /dev/null +++ b/x-pack/plugins/apm/common/rules.ts @@ -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 + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +const plainApmRuleRegistrySettings = { + name: 'apm', + fieldMap: { + 'service.environment': { + type: 'keyword', + }, + 'transaction.type': { + type: 'keyword', + }, + 'processor.event': { + type: 'keyword', + }, + }, +} as const; + +type APMRuleRegistrySettings = typeof plainApmRuleRegistrySettings; + +export const apmRuleRegistrySettings: APMRuleRegistrySettings = plainApmRuleRegistrySettings; diff --git a/x-pack/plugins/apm/public/components/alerting/register_apm_alerts.ts b/x-pack/plugins/apm/public/components/alerting/register_apm_alerts.ts index 4f33f11b5f3e7..8834cbc70e0b1 100644 --- a/x-pack/plugins/apm/public/components/alerting/register_apm_alerts.ts +++ b/x-pack/plugins/apm/public/components/alerting/register_apm_alerts.ts @@ -7,18 +7,39 @@ import { i18n } from '@kbn/i18n'; import { lazy } from 'react'; +import { format } from 'url'; +import { ENVIRONMENT_ALL } from '../../../common/environment_filter_values'; +import { asDuration, asPercent } from '../../../common/utils/formatters'; import { AlertType } from '../../../common/alert_types'; -import { ApmPluginStartDeps } from '../../plugin'; +import { ApmRuleRegistry } from '../../plugin'; -export function registerApmAlerts( - alertTypeRegistry: ApmPluginStartDeps['triggersActionsUi']['alertTypeRegistry'] -) { - alertTypeRegistry.register({ +export function registerApmAlerts(apmRuleRegistry: ApmRuleRegistry) { + apmRuleRegistry.registerType({ id: AlertType.ErrorCount, description: i18n.translate('xpack.apm.alertTypes.errorCount.description', { defaultMessage: 'Alert when the number of errors in a service exceeds a defined threshold.', }), + format: ({ alert }) => { + return { + reason: i18n.translate('xpack.apm.alertTypes.errorCount.reason', { + defaultMessage: `Error count is greater than {threshold} (current value is {measured}) for {serviceName}`, + values: { + threshold: alert['kibana.observability.evaluation.threshold'], + measured: alert['kibana.observability.evaluation.value'], + serviceName: alert['service.name']!, + }, + }), + link: format({ + pathname: `/app/apm/services/${alert['service.name']!}`, + query: { + ...(alert['service.environment'] + ? { environment: alert['service.environment'] } + : { environment: ENVIRONMENT_ALL.value }), + }, + }), + }; + }, iconClass: 'bell', documentationUrl(docLinks) { return `${docLinks.ELASTIC_WEBSITE_URL}guide/en/kibana/${docLinks.DOC_LINK_VERSION}/apm-alerts.html`; @@ -41,7 +62,7 @@ export function registerApmAlerts( ), }); - alertTypeRegistry.register({ + apmRuleRegistry.registerType({ id: AlertType.TransactionDuration, description: i18n.translate( 'xpack.apm.alertTypes.transactionDuration.description', @@ -50,6 +71,32 @@ export function registerApmAlerts( 'Alert when the latency of a specific transaction type in a service exceeds a defined threshold.', } ), + format: ({ alert }) => ({ + reason: i18n.translate( + 'xpack.apm.alertTypes.transactionDuration.reason', + { + defaultMessage: `Latency is above {threshold} (current value is {measured}) for {serviceName}`, + values: { + threshold: asDuration( + alert['kibana.observability.evaluation.threshold'] + ), + measured: asDuration( + alert['kibana.observability.evaluation.value'] + ), + serviceName: alert['service.name']!, + }, + } + ), + link: format({ + pathname: `/app/apm/services/${alert['service.name']!}`, + query: { + transactionType: alert['transaction.type']!, + ...(alert['service.environment'] + ? { environment: alert['service.environment'] } + : { environment: ENVIRONMENT_ALL.value }), + }, + }), + }), iconClass: 'bell', documentationUrl(docLinks) { return `${docLinks.ELASTIC_WEBSITE_URL}guide/en/kibana/${docLinks.DOC_LINK_VERSION}/apm-alerts.html`; @@ -75,7 +122,7 @@ export function registerApmAlerts( ), }); - alertTypeRegistry.register({ + apmRuleRegistry.registerType({ id: AlertType.TransactionErrorRate, description: i18n.translate( 'xpack.apm.alertTypes.transactionErrorRate.description', @@ -84,6 +131,34 @@ export function registerApmAlerts( 'Alert when the rate of transaction errors in a service exceeds a defined threshold.', } ), + format: ({ alert }) => ({ + reason: i18n.translate( + 'xpack.apm.alertTypes.transactionErrorRate.reason', + { + defaultMessage: `Transaction error rate is greater than {threshold} (current value is {measured}) for {serviceName}`, + values: { + threshold: asPercent( + alert['kibana.observability.evaluation.threshold'], + 100 + ), + measured: asPercent( + alert['kibana.observability.evaluation.value'], + 100 + ), + serviceName: alert['service.name']!, + }, + } + ), + link: format({ + pathname: `/app/apm/services/${alert['service.name']!}`, + query: { + transactionType: alert['transaction.type']!, + ...(alert['service.environment'] + ? { environment: alert['service.environment'] } + : { environment: ENVIRONMENT_ALL.value }), + }, + }), + }), iconClass: 'bell', documentationUrl(docLinks) { return `${docLinks.ELASTIC_WEBSITE_URL}guide/en/kibana/${docLinks.DOC_LINK_VERSION}/apm-alerts.html`; @@ -109,7 +184,7 @@ export function registerApmAlerts( ), }); - alertTypeRegistry.register({ + apmRuleRegistry.registerType({ id: AlertType.TransactionDurationAnomaly, description: i18n.translate( 'xpack.apm.alertTypes.transactionDurationAnomaly.description', @@ -117,6 +192,28 @@ export function registerApmAlerts( defaultMessage: 'Alert when the latency of a service is abnormal.', } ), + format: ({ alert }) => ({ + reason: i18n.translate( + 'xpack.apm.alertTypes.transactionDurationAnomaly.reason', + { + defaultMessage: `{severityLevel} anomaly detected for {serviceName} (score was {measured})`, + values: { + serviceName: alert['service.name'], + severityLevel: alert['kibana.rac.alert.severity.level'], + measured: alert['kibana.observability.evaluation.value'], + }, + } + ), + link: format({ + pathname: `/app/apm/services/${alert['service.name']!}`, + query: { + transactionType: alert['transaction.type']!, + ...(alert['service.environment'] + ? { environment: alert['service.environment'] } + : { environment: ENVIRONMENT_ALL.value }), + }, + }), + }), iconClass: 'bell', documentationUrl(docLinks) { return `${docLinks.ELASTIC_WEBSITE_URL}guide/en/kibana/${docLinks.DOC_LINK_VERSION}/apm-alerts.html`; @@ -137,7 +234,7 @@ export function registerApmAlerts( - Type: \\{\\{context.transactionType\\}\\} - Environment: \\{\\{context.environment\\}\\} - Severity threshold: \\{\\{context.threshold\\}\\} -- Severity value: \\{\\{context.thresholdValue\\}\\} +- Severity value: \\{\\{context.triggerValue\\}\\} `, } ), diff --git a/x-pack/plugins/apm/public/plugin.ts b/x-pack/plugins/apm/public/plugin.ts index 382053f133950..391c54c1e2497 100644 --- a/x-pack/plugins/apm/public/plugin.ts +++ b/x-pack/plugins/apm/public/plugin.ts @@ -8,6 +8,7 @@ import { ConfigSchema } from '.'; import { FetchDataParams, + FormatterRuleRegistry, HasDataParams, ObservabilityPublicSetup, } from '../../observability/public'; @@ -40,8 +41,11 @@ import { EmbeddableStart } from '../../../../src/plugins/embeddable/public'; import { registerApmAlerts } from './components/alerting/register_apm_alerts'; import { MlPluginSetup, MlPluginStart } from '../../ml/public'; import { MapsStartApi } from '../../maps/public'; +import { apmRuleRegistrySettings } from '../common/rules'; + +export type ApmPluginSetup = ReturnType; +export type ApmRuleRegistry = ApmPluginSetup['ruleRegistry']; -export type ApmPluginSetup = void; export type ApmPluginStart = void; export interface ApmPluginSetupDeps { @@ -52,7 +56,7 @@ export interface ApmPluginSetupDeps { home?: HomePublicPluginSetup; licensing: LicensingPluginSetup; triggersActionsUi: TriggersAndActionsUIPublicPluginSetup; - observability?: ObservabilityPublicSetup; + observability: ObservabilityPublicSetup; } export interface ApmPluginStartDeps { @@ -156,6 +160,13 @@ export class ApmPlugin implements Plugin { }, }); + const apmRuleRegistry = plugins.observability.ruleRegistry.create({ + ...apmRuleRegistrySettings, + ctor: FormatterRuleRegistry, + }); + + registerApmAlerts(apmRuleRegistry); + core.application.register({ id: 'ux', title: 'User Experience', @@ -196,9 +207,12 @@ export class ApmPlugin implements Plugin { ); }, }); + + return { + ruleRegistry: apmRuleRegistry, + }; } public start(core: CoreStart, plugins: ApmPluginStartDeps) { toggleAppLinkInNav(core, this.initializerContext.config.get()); - registerApmAlerts(plugins.triggersActionsUi.alertTypeRegistry); } } diff --git a/x-pack/plugins/apm/scripts/optimize-tsconfig/optimize.js b/x-pack/plugins/apm/scripts/optimize-tsconfig/optimize.js index 58fb096ca3a51..e0151c473d6e2 100644 --- a/x-pack/plugins/apm/scripts/optimize-tsconfig/optimize.js +++ b/x-pack/plugins/apm/scripts/optimize-tsconfig/optimize.js @@ -85,8 +85,14 @@ async function setIgnoreChanges() { } } -async function deleteApmTsConfig() { - await unlink(path.resolve(kibanaRoot, 'x-pack/plugins/apm', 'tsconfig.json')); +async function deleteTsConfigs() { + const toDelete = ['apm', 'observability', 'rule_registry']; + + for (const app of toDelete) { + await unlink( + path.resolve(kibanaRoot, 'x-pack/plugins', app, 'tsconfig.json') + ); + } } async function optimizeTsConfig() { @@ -98,7 +104,7 @@ async function optimizeTsConfig() { await addApmFilesToTestTsConfig(); - await deleteApmTsConfig(); + await deleteTsConfigs(); await setIgnoreChanges(); // eslint-disable-next-line no-console diff --git a/x-pack/plugins/apm/scripts/optimize-tsconfig/paths.js b/x-pack/plugins/apm/scripts/optimize-tsconfig/paths.js index bde129f434934..3a21a89e30917 100644 --- a/x-pack/plugins/apm/scripts/optimize-tsconfig/paths.js +++ b/x-pack/plugins/apm/scripts/optimize-tsconfig/paths.js @@ -15,6 +15,8 @@ const filesToIgnore = [ path.resolve(kibanaRoot, 'tsconfig.json'), path.resolve(kibanaRoot, 'tsconfig.base.json'), path.resolve(kibanaRoot, 'x-pack/plugins/apm', 'tsconfig.json'), + path.resolve(kibanaRoot, 'x-pack/plugins/observability', 'tsconfig.json'), + path.resolve(kibanaRoot, 'x-pack/plugins/rule_registry', 'tsconfig.json'), path.resolve(kibanaRoot, 'x-pack/test', 'tsconfig.json'), ]; diff --git a/x-pack/plugins/apm/server/lib/alerts/alerting_es_client.ts b/x-pack/plugins/apm/server/lib/alerts/alerting_es_client.ts index e3d5e5481caa5..7d5b7d594bdf9 100644 --- a/x-pack/plugins/apm/server/lib/alerts/alerting_es_client.ts +++ b/x-pack/plugins/apm/server/lib/alerts/alerting_es_client.ts @@ -11,14 +11,17 @@ import { } from '../../../../../../typings/elasticsearch'; import { AlertServices } from '../../../../alerting/server'; -export async function alertingEsClient( +export async function alertingEsClient({ + scopedClusterClient, + params, +}: { scopedClusterClient: AlertServices< never, never, never - >['scopedClusterClient'], - params: TParams -): Promise> { + >['scopedClusterClient']; + params: TParams; +}): Promise> { const response = await scopedClusterClient.asCurrentUser.search({ ...params, ignore_unavailable: true, diff --git a/x-pack/plugins/apm/server/lib/alerts/register_error_count_alert_type.ts b/x-pack/plugins/apm/server/lib/alerts/register_error_count_alert_type.ts index 8240e0c369d1f..15ec5d0ef0bd0 100644 --- a/x-pack/plugins/apm/server/lib/alerts/register_error_count_alert_type.ts +++ b/x-pack/plugins/apm/server/lib/alerts/register_error_count_alert_type.ts @@ -114,10 +114,10 @@ export function registerErrorCountAlertType({ }, }; - const response = await alertingEsClient( - services.scopedClusterClient, - searchParams - ); + const response = await alertingEsClient({ + scopedClusterClient: services.scopedClusterClient, + params: searchParams, + }); const errorCountResults = response.aggregations?.error_counts.buckets.map((bucket) => { @@ -145,7 +145,10 @@ export function registerErrorCountAlertType({ ...(environment ? { [SERVICE_ENVIRONMENT]: environment } : {}), - [PROCESSOR_EVENT]: 'error', + [PROCESSOR_EVENT]: ProcessorEvent.error, + 'kibana.observability.evaluation.value': errorCount, + 'kibana.observability.evaluation.threshold': + alertParams.threshold, }, }) .scheduleActions(alertTypeConfig.defaultActionGroupId, { diff --git a/x-pack/plugins/apm/server/lib/alerts/register_transaction_duration_alert_type.ts b/x-pack/plugins/apm/server/lib/alerts/register_transaction_duration_alert_type.ts index 6ca1c4370d6ae..4918a6cc892b7 100644 --- a/x-pack/plugins/apm/server/lib/alerts/register_transaction_duration_alert_type.ts +++ b/x-pack/plugins/apm/server/lib/alerts/register_transaction_duration_alert_type.ts @@ -77,8 +77,8 @@ export function registerTransactionDurationAlertType({ const searchParams = { index: indices['apm_oss.transactionIndices'], - size: 0, body: { + size: 0, query: { bool: { filter: [ @@ -112,10 +112,10 @@ export function registerTransactionDurationAlertType({ }, }; - const response = await alertingEsClient( - services.scopedClusterClient, - searchParams - ); + const response = await alertingEsClient({ + scopedClusterClient: services.scopedClusterClient, + params: searchParams, + }); if (!response.aggregations) { return {}; @@ -149,6 +149,10 @@ export function registerTransactionDurationAlertType({ ? { [SERVICE_ENVIRONMENT]: environmentParsed.esFieldValue } : {}), [TRANSACTION_TYPE]: alertParams.transactionType, + [PROCESSOR_EVENT]: ProcessorEvent.transaction, + 'kibana.observability.evaluation.value': transactionDuration, + 'kibana.observability.evaluation.threshold': + alertParams.threshold * 1000, }, }) .scheduleActions(alertTypeConfig.defaultActionGroupId, { diff --git a/x-pack/plugins/apm/server/lib/alerts/register_transaction_duration_anomaly_alert_type.ts b/x-pack/plugins/apm/server/lib/alerts/register_transaction_duration_anomaly_alert_type.ts index 15f4a8ea07801..66eb7125b0370 100644 --- a/x-pack/plugins/apm/server/lib/alerts/register_transaction_duration_anomaly_alert_type.ts +++ b/x-pack/plugins/apm/server/lib/alerts/register_transaction_duration_anomaly_alert_type.ts @@ -9,8 +9,10 @@ import { schema } from '@kbn/config-schema'; import { compact } from 'lodash'; import { ESSearchResponse } from 'typings/elasticsearch'; import { QueryContainer } from '@elastic/elasticsearch/api/types'; +import { ProcessorEvent } from '../../../common/processor_event'; import { getSeverity } from '../../../common/anomaly_detection'; import { + PROCESSOR_EVENT, SERVICE_ENVIRONMENT, SERVICE_NAME, TRANSACTION_TYPE, @@ -49,7 +51,6 @@ const alertTypeConfig = export function registerTransactionDurationAnomalyAlertType({ registry, ml, - logger, }: RegisterRuleDependencies) { registry.registerType( createAPMLifecycleRuleType({ @@ -166,7 +167,7 @@ export function registerTransactionDurationAnomalyAlertType({ { field: 'job_id' }, ] as const), sort: { - '@timestamp': 'desc' as const, + timestamp: 'desc' as const, }, }, }, @@ -189,7 +190,7 @@ export function registerTransactionDurationAnomalyAlertType({ const job = mlJobs.find((j) => j.job_id === latest.job_id); if (!job) { - logger.warn( + services.logger.warn( `Could not find matching job for job id ${latest.job_id}` ); return undefined; @@ -211,6 +212,8 @@ export function registerTransactionDurationAnomalyAlertType({ const parsedEnvironment = parseEnvironmentUrlParam(environment); + const severityLevel = getSeverity(score); + services .alertWithLifecycle({ id: [ @@ -227,6 +230,11 @@ export function registerTransactionDurationAnomalyAlertType({ ? { [SERVICE_ENVIRONMENT]: environment } : {}), [TRANSACTION_TYPE]: transactionType, + [PROCESSOR_EVENT]: ProcessorEvent.transaction, + 'kibana.rac.alert.severity.level': severityLevel, + 'kibana.rac.alert.severity.value': score, + 'kibana.observability.evaluation.value': score, + 'kibana.observability.evaluation.threshold': threshold, }, }) .scheduleActions(alertTypeConfig.defaultActionGroupId, { @@ -234,7 +242,7 @@ export function registerTransactionDurationAnomalyAlertType({ transactionType, environment, threshold: selectedOption?.label, - triggerValue: getSeverity(score), + triggerValue: severityLevel, }); }); diff --git a/x-pack/plugins/apm/server/lib/alerts/register_transaction_error_rate_alert_type.ts b/x-pack/plugins/apm/server/lib/alerts/register_transaction_error_rate_alert_type.ts index 0865bed41142e..bead17e308f06 100644 --- a/x-pack/plugins/apm/server/lib/alerts/register_transaction_error_rate_alert_type.ts +++ b/x-pack/plugins/apm/server/lib/alerts/register_transaction_error_rate_alert_type.ts @@ -129,10 +129,10 @@ export function registerTransactionErrorRateAlertType({ }, }; - const response = await alertingEsClient( - services.scopedClusterClient, - searchParams - ); + const response = await alertingEsClient({ + scopedClusterClient: services.scopedClusterClient, + params: searchParams, + }); if (!response.aggregations) { return {}; @@ -182,6 +182,10 @@ export function registerTransactionErrorRateAlertType({ [SERVICE_NAME]: serviceName, ...(environment ? { [SERVICE_ENVIRONMENT]: environment } : {}), [TRANSACTION_TYPE]: transactionType, + [PROCESSOR_EVENT]: ProcessorEvent.transaction, + 'kibana.observability.evaluation.value': errorRate, + 'kibana.observability.evaluation.threshold': + alertParams.threshold, }, }) .scheduleActions(alertTypeConfig.defaultActionGroupId, { diff --git a/x-pack/plugins/apm/server/plugin.ts b/x-pack/plugins/apm/server/plugin.ts index cb94b18a1ecf9..714b887a4008b 100644 --- a/x-pack/plugins/apm/server/plugin.ts +++ b/x-pack/plugins/apm/server/plugin.ts @@ -42,6 +42,7 @@ import { } from './types'; import { registerRoutes } from './routes/register_routes'; import { getGlobalApmServerRouteRepository } from './routes/get_global_apm_server_route_repository'; +import { apmRuleRegistrySettings } from '../common/rules'; export type APMRuleRegistry = ReturnType['ruleRegistry']; @@ -150,20 +151,9 @@ export class APMPlugin config: await mergedConfig$.pipe(take(1)).toPromise(), }); - const apmRuleRegistry = plugins.observability.ruleRegistry.create({ - name: 'apm', - fieldMap: { - 'service.environment': { - type: 'keyword', - }, - 'transaction.type': { - type: 'keyword', - }, - 'processor.event': { - type: 'keyword', - }, - }, - }); + const apmRuleRegistry = plugins.observability.ruleRegistry.create( + apmRuleRegistrySettings + ); registerApmAlerts({ registry: apmRuleRegistry, diff --git a/x-pack/plugins/apm/server/routes/default_api_types.ts b/x-pack/plugins/apm/server/routes/default_api_types.ts index 10c50a384c2d7..3181a3dbce7ac 100644 --- a/x-pack/plugins/apm/server/routes/default_api_types.ts +++ b/x-pack/plugins/apm/server/routes/default_api_types.ts @@ -6,7 +6,7 @@ */ import * as t from 'io-ts'; -import { isoToEpochRt } from '../../common/runtime_types/iso_to_epoch_rt'; +import { isoToEpochRt } from '@kbn/io-ts-utils'; export const rangeRt = t.type({ start: isoToEpochRt, diff --git a/x-pack/plugins/apm/server/routes/services.ts b/x-pack/plugins/apm/server/routes/services.ts index 800a5bdcc5d5f..3ac76d4a5b4c2 100644 --- a/x-pack/plugins/apm/server/routes/services.ts +++ b/x-pack/plugins/apm/server/routes/services.ts @@ -6,13 +6,11 @@ */ import Boom from '@hapi/boom'; -import { jsonRt } from '@kbn/io-ts-utils'; +import { jsonRt, isoToEpochRt, toNumberRt } from '@kbn/io-ts-utils'; import * as t from 'io-ts'; import { uniq } from 'lodash'; import { latencyAggregationTypeRt } from '../../common/latency_aggregation_types'; import { ProfilingValueType } from '../../common/profiling'; -import { isoToEpochRt } from '../../common/runtime_types/iso_to_epoch_rt'; -import { toNumberRt } from '../../common/runtime_types/to_number_rt'; import { getSearchAggregatedTransactions } from '../lib/helpers/aggregated_transactions'; import { setupRequest } from '../lib/helpers/setup_request'; import { getServiceAnnotations } from '../lib/services/annotations'; diff --git a/x-pack/plugins/apm/server/routes/settings/agent_configuration.ts b/x-pack/plugins/apm/server/routes/settings/agent_configuration.ts index 111e0a18c8608..ef1ade645cc44 100644 --- a/x-pack/plugins/apm/server/routes/settings/agent_configuration.ts +++ b/x-pack/plugins/apm/server/routes/settings/agent_configuration.ts @@ -7,7 +7,7 @@ import * as t from 'io-ts'; import Boom from '@hapi/boom'; -import { toBooleanRt } from '../../../common/runtime_types/to_boolean_rt'; +import { toBooleanRt } from '@kbn/io-ts-utils'; import { setupRequest } from '../../lib/helpers/setup_request'; import { getServiceNames } from '../../lib/settings/agent_configuration/get_service_names'; import { createOrUpdateConfiguration } from '../../lib/settings/agent_configuration/create_or_update_configuration'; diff --git a/x-pack/plugins/apm/server/routes/transactions.ts b/x-pack/plugins/apm/server/routes/transactions.ts index ebca374db86d7..b323801430dba 100644 --- a/x-pack/plugins/apm/server/routes/transactions.ts +++ b/x-pack/plugins/apm/server/routes/transactions.ts @@ -7,11 +7,11 @@ import { jsonRt } from '@kbn/io-ts-utils'; import * as t from 'io-ts'; +import { toNumberRt } from '@kbn/io-ts-utils'; import { LatencyAggregationType, latencyAggregationTypeRt, } from '../../common/latency_aggregation_types'; -import { toNumberRt } from '../../common/runtime_types/to_number_rt'; import { getSearchAggregatedTransactions } from '../lib/helpers/aggregated_transactions'; import { setupRequest } from '../lib/helpers/setup_request'; import { getServiceTransactionGroups } from '../lib/services/get_service_transaction_groups'; diff --git a/x-pack/plugins/apm/server/utils/queries.ts b/x-pack/plugins/apm/server/utils/queries.ts index 3cbcb0a5b684f..7255e7ed75a63 100644 --- a/x-pack/plugins/apm/server/utils/queries.ts +++ b/x-pack/plugins/apm/server/utils/queries.ts @@ -5,13 +5,13 @@ * 2.0. */ -import { esKuery } from '../../../../../src/plugins/data/server'; import { ESFilter } from '../../../../../typings/elasticsearch'; import { SERVICE_ENVIRONMENT } from '../../common/elasticsearch_fieldnames'; import { ENVIRONMENT_ALL, ENVIRONMENT_NOT_DEFINED, } from '../../common/environment_filter_values'; +export { kqlQuery, rangeQuery } from '../../../observability/server'; type QueryContainer = ESFilter; @@ -26,30 +26,3 @@ export function environmentQuery(environment?: string): QueryContainer[] { return [{ term: { [SERVICE_ENVIRONMENT]: environment } }]; } - -export function rangeQuery( - start: number, - end: number, - field = '@timestamp' -): QueryContainer[] { - return [ - { - range: { - [field]: { - gte: start, - lte: end, - format: 'epoch_millis', - }, - }, - }, - ]; -} - -export function kqlQuery(kql?: string) { - if (!kql) { - return []; - } - - const ast = esKuery.fromKueryExpression(kql); - return [esKuery.toElasticsearchQuery(ast) as ESFilter]; -} diff --git a/x-pack/plugins/observability/common/i18n.ts b/x-pack/plugins/observability/common/i18n.ts new file mode 100644 index 0000000000000..73c27e811b2d6 --- /dev/null +++ b/x-pack/plugins/observability/common/i18n.ts @@ -0,0 +1,12 @@ +/* + * 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 { i18n } from '@kbn/i18n'; + +export const NOT_AVAILABLE_LABEL = i18n.translate('xpack.observability.notAvailable', { + defaultMessage: 'N/A', +}); diff --git a/x-pack/plugins/observability/common/observability_rule_registry.ts b/x-pack/plugins/observability/common/observability_rule_registry.ts new file mode 100644 index 0000000000000..9254401fc19c4 --- /dev/null +++ b/x-pack/plugins/observability/common/observability_rule_registry.ts @@ -0,0 +1,22 @@ +/* + * 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 { ecsFieldMap, pickWithPatterns } from '../../rule_registry/common'; + +export const observabilityRuleRegistrySettings = { + name: 'observability', + fieldMap: { + ...pickWithPatterns(ecsFieldMap, 'host.name', 'service.name'), + 'kibana.observability.evaluation.value': { + type: 'scaled_float' as const, + scaling_factor: 1000, + }, + 'kibana.observability.evaluation.threshold': { + type: 'scaled_float' as const, + scaling_factor: 1000, + }, + }, +}; diff --git a/x-pack/plugins/observability/common/typings.ts b/x-pack/plugins/observability/common/typings.ts new file mode 100644 index 0000000000000..2a7f9edffc4af --- /dev/null +++ b/x-pack/plugins/observability/common/typings.ts @@ -0,0 +1,8 @@ +/* + * 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. + */ + +export type Maybe = T | null | undefined; diff --git a/x-pack/plugins/observability/common/utils/array_union_to_callable.ts b/x-pack/plugins/observability/common/utils/array_union_to_callable.ts new file mode 100644 index 0000000000000..f376f7cd4ef21 --- /dev/null +++ b/x-pack/plugins/observability/common/utils/array_union_to_callable.ts @@ -0,0 +1,14 @@ +/* + * 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 { ValuesType } from 'utility-types'; + +// work around a TypeScript limitation described in https://stackoverflow.com/posts/49511416 + +export const arrayUnionToCallable = (array: T): Array> => { + return array; +}; diff --git a/x-pack/plugins/observability/common/utils/as_mutable_array.ts b/x-pack/plugins/observability/common/utils/as_mutable_array.ts new file mode 100644 index 0000000000000..ce1d7e607ec4c --- /dev/null +++ b/x-pack/plugins/observability/common/utils/as_mutable_array.ts @@ -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 + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +// Sometimes we use `as const` to have a more specific type, +// because TypeScript by default will widen the value type of an +// array literal. Consider the following example: +// +// const filter = [ +// { term: { 'agent.name': 'nodejs' } }, +// { range: { '@timestamp': { gte: 'now-15m ' }} +// ]; + +// The result value type will be: + +// const filter: ({ +// term: { +// 'agent.name'?: string +// }; +// range?: undefined +// } | { +// term?: undefined; +// range: { +// '@timestamp': { +// gte: string +// } +// } +// })[]; + +// This can sometimes leads to issues. In those cases, we can +// use `as const`. However, the Readonly type is not compatible +// with Array. This function returns a mutable version of a type. + +export function asMutableArray>( + arr: T +): T extends Readonly<[...infer U]> ? U : unknown[] { + return arr as any; +} diff --git a/x-pack/plugins/observability/common/utils/formatters/datetime.test.ts b/x-pack/plugins/observability/common/utils/formatters/datetime.test.ts new file mode 100644 index 0000000000000..aaf0b1e574221 --- /dev/null +++ b/x-pack/plugins/observability/common/utils/formatters/datetime.test.ts @@ -0,0 +1,194 @@ +/* + * 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 moment from 'moment-timezone'; +import { asRelativeDateTimeRange, asAbsoluteDateTime, getDateDifference } from './datetime'; + +describe('date time formatters', () => { + beforeAll(() => { + moment.tz.setDefault('Europe/Amsterdam'); + }); + afterAll(() => moment.tz.setDefault('')); + describe('asRelativeDateTimeRange', () => { + const formatDateToTimezone = (dateTimeString: string) => moment(dateTimeString).valueOf(); + describe('YYYY - YYYY', () => { + it('range: 10 years', () => { + const start = formatDateToTimezone('2000-01-01 10:01:01'); + const end = formatDateToTimezone('2010-01-01 10:01:01'); + const dateRange = asRelativeDateTimeRange(start, end); + expect(dateRange).toEqual('2000 - 2010'); + }); + it('range: 5 years', () => { + const start = formatDateToTimezone('2010-01-01 10:01:01'); + const end = formatDateToTimezone('2015-01-01 10:01:01'); + const dateRange = asRelativeDateTimeRange(start, end); + expect(dateRange).toEqual('2010 - 2015'); + }); + }); + describe('MMM YYYY - MMM YYYY', () => { + it('range: 4 years ', () => { + const start = formatDateToTimezone('2010-01-01 10:01:01'); + const end = formatDateToTimezone('2014-04-01 10:01:01'); + const dateRange = asRelativeDateTimeRange(start, end); + expect(dateRange).toEqual('Jan 2010 - Apr 2014'); + }); + it('range: 6 months ', () => { + const start = formatDateToTimezone('2019-01-01 10:01:01'); + const end = formatDateToTimezone('2019-07-01 10:01:01'); + const dateRange = asRelativeDateTimeRange(start, end); + expect(dateRange).toEqual('Jan 2019 - Jul 2019'); + }); + }); + describe('MMM D, YYYY - MMM D, YYYY', () => { + it('range: 2 days', () => { + const start = formatDateToTimezone('2019-10-01 10:01:01'); + const end = formatDateToTimezone('2019-10-05 10:01:01'); + const dateRange = asRelativeDateTimeRange(start, end); + expect(dateRange).toEqual('Oct 1, 2019 - Oct 5, 2019'); + }); + it('range: 1 day', () => { + const start = formatDateToTimezone('2019-10-01 10:01:01'); + const end = formatDateToTimezone('2019-10-03 10:01:01'); + const dateRange = asRelativeDateTimeRange(start, end); + expect(dateRange).toEqual('Oct 1, 2019 - Oct 3, 2019'); + }); + }); + describe('MMM D, YYYY, HH:mm - HH:mm (UTC)', () => { + it('range: 9 hours', () => { + const start = formatDateToTimezone('2019-10-29 10:01:01'); + const end = formatDateToTimezone('2019-10-29 19:01:01'); + const dateRange = asRelativeDateTimeRange(start, end); + expect(dateRange).toEqual('Oct 29, 2019, 10:01 - 19:01 (UTC+1)'); + }); + it('range: 5 hours', () => { + const start = formatDateToTimezone('2019-10-29 10:01:01'); + const end = formatDateToTimezone('2019-10-29 15:01:01'); + const dateRange = asRelativeDateTimeRange(start, end); + expect(dateRange).toEqual('Oct 29, 2019, 10:01 - 15:01 (UTC+1)'); + }); + it('range: 14 minutes', () => { + const start = formatDateToTimezone('2019-10-29 10:01:01'); + const end = formatDateToTimezone('2019-10-29 10:15:01'); + const dateRange = asRelativeDateTimeRange(start, end); + expect(dateRange).toEqual('Oct 29, 2019, 10:01 - 10:15 (UTC+1)'); + }); + it('range: 5 minutes', () => { + const start = formatDateToTimezone('2019-10-29 10:01:01'); + const end = formatDateToTimezone('2019-10-29 10:06:01'); + const dateRange = asRelativeDateTimeRange(start, end); + expect(dateRange).toEqual('Oct 29, 2019, 10:01 - 10:06 (UTC+1)'); + }); + it('range: 1 minute', () => { + const start = formatDateToTimezone('2019-10-29 10:01:01'); + const end = formatDateToTimezone('2019-10-29 10:02:01'); + const dateRange = asRelativeDateTimeRange(start, end); + expect(dateRange).toEqual('Oct 29, 2019, 10:01 - 10:02 (UTC+1)'); + }); + }); + describe('MMM D, YYYY, HH:mm:ss - HH:mm:ss (UTC)', () => { + it('range: 50 seconds', () => { + const start = formatDateToTimezone('2019-10-29 10:01:01'); + const end = formatDateToTimezone('2019-10-29 10:01:50'); + const dateRange = asRelativeDateTimeRange(start, end); + expect(dateRange).toEqual('Oct 29, 2019, 10:01:01 - 10:01:50 (UTC+1)'); + }); + it('range: 10 seconds', () => { + const start = formatDateToTimezone('2019-10-29 10:01:01'); + const end = formatDateToTimezone('2019-10-29 10:01:11'); + const dateRange = asRelativeDateTimeRange(start, end); + expect(dateRange).toEqual('Oct 29, 2019, 10:01:01 - 10:01:11 (UTC+1)'); + }); + }); + describe('MMM D, YYYY, HH:mm:ss.SSS - HH:mm:ss.SSS (UTC)', () => { + it('range: 9 seconds', () => { + const start = formatDateToTimezone('2019-10-29 10:01:01.001'); + const end = formatDateToTimezone('2019-10-29 10:01:10.002'); + const dateRange = asRelativeDateTimeRange(start, end); + expect(dateRange).toEqual('Oct 29, 2019, 10:01:01.001 - 10:01:10.002 (UTC+1)'); + }); + it('range: 1 second', () => { + const start = formatDateToTimezone('2019-10-29 10:01:01.001'); + const end = formatDateToTimezone('2019-10-29 10:01:02.002'); + const dateRange = asRelativeDateTimeRange(start, end); + expect(dateRange).toEqual('Oct 29, 2019, 10:01:01.001 - 10:01:02.002 (UTC+1)'); + }); + }); + }); + + describe('asAbsoluteDateTime', () => { + afterAll(() => moment.tz.setDefault('')); + + it('should add a leading plus for timezones with positive UTC offset', () => { + moment.tz.setDefault('Europe/Copenhagen'); + expect(asAbsoluteDateTime(1559390400000, 'minutes')).toBe('Jun 1, 2019, 14:00 (UTC+2)'); + }); + + it('should add a leading minus for timezones with negative UTC offset', () => { + moment.tz.setDefault('America/Los_Angeles'); + expect(asAbsoluteDateTime(1559390400000, 'minutes')).toBe('Jun 1, 2019, 05:00 (UTC-7)'); + }); + + it('should use default UTC offset formatting when offset contains minutes', () => { + moment.tz.setDefault('Canada/Newfoundland'); + expect(asAbsoluteDateTime(1559390400000, 'minutes')).toBe('Jun 1, 2019, 09:30 (UTC-02:30)'); + }); + + it('should respect DST', () => { + moment.tz.setDefault('Europe/Copenhagen'); + const timeWithDST = 1559390400000; // Jun 1, 2019 + const timeWithoutDST = 1575201600000; // Dec 1, 2019 + + expect(asAbsoluteDateTime(timeWithDST)).toBe('Jun 1, 2019, 14:00:00.000 (UTC+2)'); + + expect(asAbsoluteDateTime(timeWithoutDST)).toBe('Dec 1, 2019, 13:00:00.000 (UTC+1)'); + }); + }); + describe('getDateDifference', () => { + it('milliseconds', () => { + const start = moment('2019-10-29 08:00:00.001'); + const end = moment('2019-10-29 08:00:00.005'); + expect(getDateDifference({ start, end, unitOfTime: 'milliseconds' })).toEqual(4); + }); + it('seconds', () => { + const start = moment('2019-10-29 08:00:00'); + const end = moment('2019-10-29 08:00:10'); + expect(getDateDifference({ start, end, unitOfTime: 'seconds' })).toEqual(10); + }); + it('minutes', () => { + const start = moment('2019-10-29 08:00:00'); + const end = moment('2019-10-29 08:15:00'); + expect(getDateDifference({ start, end, unitOfTime: 'minutes' })).toEqual(15); + }); + it('hours', () => { + const start = moment('2019-10-29 08:00:00'); + const end = moment('2019-10-29 10:00:00'); + expect(getDateDifference({ start, end, unitOfTime: 'hours' })).toEqual(2); + }); + it('days', () => { + const start = moment('2019-10-29 08:00:00'); + const end = moment('2019-10-30 10:00:00'); + expect(getDateDifference({ start, end, unitOfTime: 'days' })).toEqual(1); + }); + it('months', () => { + const start = moment('2019-10-29 08:00:00'); + const end = moment('2019-12-29 08:00:00'); + expect(getDateDifference({ start, end, unitOfTime: 'months' })).toEqual(2); + }); + it('years', () => { + const start = moment('2019-10-29 08:00:00'); + const end = moment('2020-10-29 08:00:00'); + expect(getDateDifference({ start, end, unitOfTime: 'years' })).toEqual(1); + }); + it('precise days', () => { + const start = moment('2019-10-29 08:00:00'); + const end = moment('2019-10-30 10:00:00'); + expect(getDateDifference({ start, end, unitOfTime: 'days', precise: true })).toEqual( + 1.0833333333333333 + ); + }); + }); +}); diff --git a/x-pack/plugins/observability/common/utils/formatters/datetime.ts b/x-pack/plugins/observability/common/utils/formatters/datetime.ts new file mode 100644 index 0000000000000..ebb332797ad2e --- /dev/null +++ b/x-pack/plugins/observability/common/utils/formatters/datetime.ts @@ -0,0 +1,148 @@ +/* + * 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 moment from 'moment-timezone'; + +/** + * Returns the timezone set on momentTime. + * (UTC+offset) when offset if bigger than 0. + * (UTC-offset) when offset if lower than 0. + * @param momentTime Moment + */ +function formatTimezone(momentTime: moment.Moment) { + const DEFAULT_TIMEZONE_FORMAT = 'Z'; + + const utcOffsetHours = momentTime.utcOffset() / 60; + + const customTimezoneFormat = utcOffsetHours > 0 ? `+${utcOffsetHours}` : utcOffsetHours; + + const utcOffsetFormatted = Number.isInteger(utcOffsetHours) + ? customTimezoneFormat + : DEFAULT_TIMEZONE_FORMAT; + + return momentTime.format(`(UTC${utcOffsetFormatted})`); +} + +export type TimeUnit = 'hours' | 'minutes' | 'seconds' | 'milliseconds'; +function getTimeFormat(timeUnit: TimeUnit) { + switch (timeUnit) { + case 'hours': + return 'HH'; + case 'minutes': + return 'HH:mm'; + case 'seconds': + return 'HH:mm:ss'; + case 'milliseconds': + return 'HH:mm:ss.SSS'; + default: + return ''; + } +} + +type DateUnit = 'days' | 'months' | 'years'; +function getDateFormat(dateUnit: DateUnit) { + switch (dateUnit) { + case 'years': + return 'YYYY'; + case 'months': + return 'MMM YYYY'; + case 'days': + return 'MMM D, YYYY'; + default: + return ''; + } +} + +export const getDateDifference = ({ + start, + end, + unitOfTime, + precise, +}: { + start: moment.Moment; + end: moment.Moment; + unitOfTime: DateUnit | TimeUnit; + precise?: boolean; +}) => end.diff(start, unitOfTime, precise); + +function getFormatsAccordingToDateDifference(start: moment.Moment, end: moment.Moment) { + if (getDateDifference({ start, end, unitOfTime: 'years' }) >= 5) { + return { dateFormat: getDateFormat('years') }; + } + + if (getDateDifference({ start, end, unitOfTime: 'months' }) >= 5) { + return { dateFormat: getDateFormat('months') }; + } + + const dateFormatWithDays = getDateFormat('days'); + if (getDateDifference({ start, end, unitOfTime: 'days' }) > 1) { + return { dateFormat: dateFormatWithDays }; + } + + if (getDateDifference({ start, end, unitOfTime: 'minutes' }) >= 1) { + return { + dateFormat: dateFormatWithDays, + timeFormat: getTimeFormat('minutes'), + }; + } + + if (getDateDifference({ start, end, unitOfTime: 'seconds' }) >= 10) { + return { + dateFormat: dateFormatWithDays, + timeFormat: getTimeFormat('seconds'), + }; + } + + return { + dateFormat: dateFormatWithDays, + timeFormat: getTimeFormat('milliseconds'), + }; +} + +export function asAbsoluteDateTime(time: number, timeUnit: TimeUnit = 'milliseconds') { + const momentTime = moment(time); + const formattedTz = formatTimezone(momentTime); + + return momentTime.format(`${getDateFormat('days')}, ${getTimeFormat(timeUnit)} ${formattedTz}`); +} + +/** + * + * Returns the dates formatted according to the difference between the two dates: + * + * | Difference | Format | + * | -------------- |:----------------------------------------------:| + * | >= 5 years | YYYY - YYYY | + * | >= 5 months | MMM YYYY - MMM YYYY | + * | > 1 day | MMM D, YYYY - MMM D, YYYY | + * | >= 1 minute | MMM D, YYYY, HH:mm - HH:mm (UTC) | + * | >= 10 seconds | MMM D, YYYY, HH:mm:ss - HH:mm:ss (UTC) | + * | default | MMM D, YYYY, HH:mm:ss.SSS - HH:mm:ss.SSS (UTC) | + * + * @param start timestamp + * @param end timestamp + */ +export function asRelativeDateTimeRange(start: number, end: number) { + const momentStartTime = moment(start); + const momentEndTime = moment(end); + + const { dateFormat, timeFormat } = getFormatsAccordingToDateDifference( + momentStartTime, + momentEndTime + ); + + if (timeFormat) { + const startFormatted = momentStartTime.format(`${dateFormat}, ${timeFormat}`); + const endFormatted = momentEndTime.format(timeFormat); + const formattedTz = formatTimezone(momentStartTime); + return `${startFormatted} - ${endFormatted} ${formattedTz}`; + } + + const startFormatted = momentStartTime.format(dateFormat); + const endFormatted = momentEndTime.format(dateFormat); + return `${startFormatted} - ${endFormatted}`; +} diff --git a/x-pack/plugins/observability/common/utils/formatters/duration.test.ts b/x-pack/plugins/observability/common/utils/formatters/duration.test.ts new file mode 100644 index 0000000000000..83bcae34a2cf7 --- /dev/null +++ b/x-pack/plugins/observability/common/utils/formatters/duration.test.ts @@ -0,0 +1,51 @@ +/* + * 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 { asDuration, toMicroseconds, asMillisecondDuration } from './duration'; + +describe('duration formatters', () => { + describe('asDuration', () => { + it('formats correctly with defaults', () => { + expect(asDuration(null)).toEqual('N/A'); + expect(asDuration(undefined)).toEqual('N/A'); + expect(asDuration(0)).toEqual('0 μs'); + expect(asDuration(1)).toEqual('1 μs'); + expect(asDuration(toMicroseconds(1, 'milliseconds'))).toEqual('1,000 μs'); + expect(asDuration(toMicroseconds(1000, 'milliseconds'))).toEqual('1,000 ms'); + expect(asDuration(toMicroseconds(10000, 'milliseconds'))).toEqual('10,000 ms'); + expect(asDuration(toMicroseconds(20, 'seconds'))).toEqual('20 s'); + expect(asDuration(toMicroseconds(10, 'minutes'))).toEqual('600 s'); + expect(asDuration(toMicroseconds(11, 'minutes'))).toEqual('11 min'); + expect(asDuration(toMicroseconds(1, 'hours'))).toEqual('60 min'); + expect(asDuration(toMicroseconds(1.5, 'hours'))).toEqual('90 min'); + expect(asDuration(toMicroseconds(10, 'hours'))).toEqual('600 min'); + expect(asDuration(toMicroseconds(11, 'hours'))).toEqual('11 h'); + }); + + it('falls back to default value', () => { + expect(asDuration(undefined, { defaultValue: 'nope' })).toEqual('nope'); + }); + }); + + describe('toMicroseconds', () => { + it('transformes to microseconds', () => { + expect(toMicroseconds(1, 'hours')).toEqual(3600000000); + expect(toMicroseconds(10, 'minutes')).toEqual(600000000); + expect(toMicroseconds(10, 'seconds')).toEqual(10000000); + expect(toMicroseconds(10, 'milliseconds')).toEqual(10000); + }); + }); + + describe('asMilliseconds', () => { + it('converts to formatted decimal milliseconds', () => { + expect(asMillisecondDuration(0)).toEqual('0 ms'); + }); + it('formats correctly with undefined values', () => { + expect(asMillisecondDuration(undefined)).toEqual('N/A'); + }); + }); +}); diff --git a/x-pack/plugins/observability/common/utils/formatters/duration.ts b/x-pack/plugins/observability/common/utils/formatters/duration.ts new file mode 100644 index 0000000000000..6bbeb44ef06af --- /dev/null +++ b/x-pack/plugins/observability/common/utils/formatters/duration.ts @@ -0,0 +1,214 @@ +/* + * 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 { i18n } from '@kbn/i18n'; +import moment from 'moment'; +import { memoize } from 'lodash'; +import { NOT_AVAILABLE_LABEL } from '../../i18n'; +import { asDecimalOrInteger, asInteger, asDecimal } from './formatters'; +import { TimeUnit } from './datetime'; +import { Maybe } from '../../typings'; +import { isFiniteNumber } from '../is_finite_number'; + +interface FormatterOptions { + defaultValue?: string; + extended?: boolean; +} + +type DurationTimeUnit = TimeUnit | 'microseconds'; + +interface ConvertedDuration { + value: string; + unit?: string; + formatted: string; +} + +export type TimeFormatter = (value: Maybe, options?: FormatterOptions) => ConvertedDuration; + +type TimeFormatterBuilder = (max: number) => TimeFormatter; + +function getUnitLabelAndConvertedValue(unitKey: DurationTimeUnit, value: number) { + switch (unitKey) { + case 'hours': { + return { + unitLabel: i18n.translate('xpack.observability.formatters.hoursTimeUnitLabel', { + defaultMessage: 'h', + }), + unitLabelExtended: i18n.translate( + 'xpack.observability.formatters.hoursTimeUnitLabelExtended', + { + defaultMessage: 'hours', + } + ), + convertedValue: asDecimalOrInteger(moment.duration(value / 1000).asHours()), + }; + } + case 'minutes': { + return { + unitLabel: i18n.translate('xpack.observability.formatters.minutesTimeUnitLabel', { + defaultMessage: 'min', + }), + unitLabelExtended: i18n.translate( + 'xpack.observability.formatters.minutesTimeUnitLabelExtended', + { + defaultMessage: 'minutes', + } + ), + convertedValue: asDecimalOrInteger(moment.duration(value / 1000).asMinutes()), + }; + } + case 'seconds': { + return { + unitLabel: i18n.translate('xpack.observability.formatters.secondsTimeUnitLabel', { + defaultMessage: 's', + }), + unitLabelExtended: i18n.translate( + 'xpack.observability.formatters.secondsTimeUnitLabelExtended', + { + defaultMessage: 'seconds', + } + ), + convertedValue: asDecimalOrInteger(moment.duration(value / 1000).asSeconds()), + }; + } + case 'milliseconds': { + return { + unitLabel: i18n.translate('xpack.observability.formatters.millisTimeUnitLabel', { + defaultMessage: 'ms', + }), + unitLabelExtended: i18n.translate( + 'xpack.observability.formatters.millisTimeUnitLabelExtended', + { + defaultMessage: 'milliseconds', + } + ), + convertedValue: asDecimalOrInteger(moment.duration(value / 1000).asMilliseconds()), + }; + } + case 'microseconds': { + return { + unitLabel: i18n.translate('xpack.observability.formatters.microsTimeUnitLabel', { + defaultMessage: 'μs', + }), + unitLabelExtended: i18n.translate( + 'xpack.observability.formatters.microsTimeUnitLabelExtended', + { + defaultMessage: 'microseconds', + } + ), + convertedValue: asInteger(value), + }; + } + } +} + +/** + * Converts a microseconds value into the unit defined. + */ +function convertTo({ + unit, + microseconds, + defaultValue = NOT_AVAILABLE_LABEL, + extended, +}: { + unit: DurationTimeUnit; + microseconds: Maybe; + defaultValue?: string; + extended?: boolean; +}): ConvertedDuration { + if (!isFiniteNumber(microseconds)) { + return { value: defaultValue, formatted: defaultValue }; + } + + const { convertedValue, unitLabel, unitLabelExtended } = getUnitLabelAndConvertedValue( + unit, + microseconds + ); + + const label = extended ? unitLabelExtended : unitLabel; + + return { + value: convertedValue, + unit: unitLabel, + formatted: `${convertedValue} ${label}`, + }; +} + +export const toMicroseconds = (value: number, timeUnit: TimeUnit) => + moment.duration(value, timeUnit).asMilliseconds() * 1000; + +function getDurationUnitKey(max: number): DurationTimeUnit { + if (max > toMicroseconds(10, 'hours')) { + return 'hours'; + } + if (max > toMicroseconds(10, 'minutes')) { + return 'minutes'; + } + if (max > toMicroseconds(10, 'seconds')) { + return 'seconds'; + } + if (max > toMicroseconds(1, 'milliseconds')) { + return 'milliseconds'; + } + return 'microseconds'; +} + +export const getDurationFormatter: TimeFormatterBuilder = memoize((max: number) => { + const unit = getDurationUnitKey(max); + return (value, { defaultValue, extended }: FormatterOptions = {}) => { + return convertTo({ unit, microseconds: value, defaultValue, extended }); + }; +}); + +export function asTransactionRate(value: Maybe) { + if (!isFiniteNumber(value)) { + return NOT_AVAILABLE_LABEL; + } + + let displayedValue: string; + + if (value === 0) { + displayedValue = '0'; + } else if (value <= 0.1) { + displayedValue = '< 0.1'; + } else { + displayedValue = asDecimal(value); + } + + return i18n.translate('xpack.observability.transactionRateLabel', { + defaultMessage: `{value} tpm`, + values: { + value: displayedValue, + }, + }); +} + +/** + * Converts value and returns it formatted - 00 unit + */ +export function asDuration( + value: Maybe, + { defaultValue = NOT_AVAILABLE_LABEL, extended }: FormatterOptions = {} +) { + if (!isFiniteNumber(value)) { + return defaultValue; + } + + const formatter = getDurationFormatter(value); + return formatter(value, { defaultValue, extended }).formatted; +} +/** + * Convert a microsecond value to decimal milliseconds. Normally we use + * `asDuration`, but this is used in places like tables where we always want + * the same units. + */ +export function asMillisecondDuration(value: Maybe) { + return convertTo({ + unit: 'milliseconds', + microseconds: value, + }).formatted; +} diff --git a/x-pack/plugins/observability/common/utils/formatters/formatters.test.ts b/x-pack/plugins/observability/common/utils/formatters/formatters.test.ts new file mode 100644 index 0000000000000..230912045077d --- /dev/null +++ b/x-pack/plugins/observability/common/utils/formatters/formatters.test.ts @@ -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 + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { asPercent, asDecimalOrInteger } from './formatters'; + +describe('formatters', () => { + describe('asPercent', () => { + it('formats as integer when number is above 10', () => { + expect(asPercent(3725, 10000, 'n/a')).toEqual('37%'); + }); + + it('adds a decimal when value is below 10', () => { + expect(asPercent(0.092, 1)).toEqual('9.2%'); + }); + + it('formats when numerator is 0', () => { + expect(asPercent(0, 1, 'n/a')).toEqual('0%'); + }); + + it('returns fallback when denominator is undefined', () => { + expect(asPercent(3725, undefined, 'n/a')).toEqual('n/a'); + }); + + it('returns fallback when denominator is 0 ', () => { + expect(asPercent(3725, 0, 'n/a')).toEqual('n/a'); + }); + + it('returns fallback when numerator or denominator is NaN', () => { + expect(asPercent(3725, NaN, 'n/a')).toEqual('n/a'); + expect(asPercent(NaN, 10000, 'n/a')).toEqual('n/a'); + }); + }); + + describe('asDecimalOrInteger', () => { + it('formats as integer when number equals to 0 ', () => { + expect(asDecimalOrInteger(0)).toEqual('0'); + }); + it('formats as integer when number is above or equals 10 ', () => { + expect(asDecimalOrInteger(10.123)).toEqual('10'); + expect(asDecimalOrInteger(15.123)).toEqual('15'); + }); + it('formats as decimal when number is below 10 ', () => { + expect(asDecimalOrInteger(0.25435632645)).toEqual('0.3'); + expect(asDecimalOrInteger(1)).toEqual('1.0'); + expect(asDecimalOrInteger(3.374329704990765)).toEqual('3.4'); + expect(asDecimalOrInteger(5)).toEqual('5.0'); + expect(asDecimalOrInteger(9)).toEqual('9.0'); + }); + }); +}); diff --git a/x-pack/plugins/observability/common/utils/formatters/formatters.ts b/x-pack/plugins/observability/common/utils/formatters/formatters.ts new file mode 100644 index 0000000000000..3c307f64fa0a9 --- /dev/null +++ b/x-pack/plugins/observability/common/utils/formatters/formatters.ts @@ -0,0 +1,56 @@ +/* + * 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 numeral from '@elastic/numeral'; +import { Maybe } from '../../typings'; +import { NOT_AVAILABLE_LABEL } from '../../i18n'; +import { isFiniteNumber } from '../is_finite_number'; + +export function asDecimal(value?: number | null) { + if (!isFiniteNumber(value)) { + return NOT_AVAILABLE_LABEL; + } + + return numeral(value).format('0,0.0'); +} + +export function asInteger(value?: number | null) { + if (!isFiniteNumber(value)) { + return NOT_AVAILABLE_LABEL; + } + + return numeral(value).format('0,0'); +} + +export function asPercent( + numerator: Maybe, + denominator: number | undefined, + fallbackResult = NOT_AVAILABLE_LABEL +) { + if (!denominator || !isFiniteNumber(numerator)) { + return fallbackResult; + } + + const decimal = numerator / denominator; + + // 33.2 => 33% + // 3.32 => 3.3% + // 0 => 0% + if (Math.abs(decimal) >= 0.1 || decimal === 0) { + return numeral(decimal).format('0%'); + } + + return numeral(decimal).format('0.0%'); +} + +export function asDecimalOrInteger(value: number) { + // exact 0 or above 10 should not have decimal + if (value === 0 || value >= 10) { + return asInteger(value); + } + return asDecimal(value); +} diff --git a/x-pack/plugins/observability/common/utils/formatters/index.ts b/x-pack/plugins/observability/common/utils/formatters/index.ts new file mode 100644 index 0000000000000..1a431867308b6 --- /dev/null +++ b/x-pack/plugins/observability/common/utils/formatters/index.ts @@ -0,0 +1,11 @@ +/* + * 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. + */ + +export * from './formatters'; +export * from './datetime'; +export * from './duration'; +export * from './size'; diff --git a/x-pack/plugins/observability/common/utils/formatters/size.test.ts b/x-pack/plugins/observability/common/utils/formatters/size.test.ts new file mode 100644 index 0000000000000..a71617151c0db --- /dev/null +++ b/x-pack/plugins/observability/common/utils/formatters/size.test.ts @@ -0,0 +1,83 @@ +/* + * 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 { getFixedByteFormatter, asDynamicBytes } from './size'; + +describe('size formatters', () => { + describe('byte formatting', () => { + const bytes = 10; + const kb = 1000 + 1; + const mb = 1e6 + 1; + const gb = 1e9 + 1; + const tb = 1e12 + 1; + + test('dynamic', () => { + expect(asDynamicBytes(bytes)).toEqual('10.0 B'); + expect(asDynamicBytes(kb)).toEqual('1.0 KB'); + expect(asDynamicBytes(mb)).toEqual('1.0 MB'); + expect(asDynamicBytes(gb)).toEqual('1.0 GB'); + expect(asDynamicBytes(tb)).toEqual('1.0 TB'); + expect(asDynamicBytes(null)).toEqual(''); + expect(asDynamicBytes(NaN)).toEqual(''); + }); + + describe('fixed', () => { + test('in bytes', () => { + const formatInBytes = getFixedByteFormatter(bytes); + expect(formatInBytes(bytes)).toEqual('10.0 B'); + expect(formatInBytes(kb)).toEqual('1,001.0 B'); + expect(formatInBytes(mb)).toEqual('1,000,001.0 B'); + expect(formatInBytes(gb)).toEqual('1,000,000,001.0 B'); + expect(formatInBytes(tb)).toEqual('1,000,000,000,001.0 B'); + expect(formatInBytes(null)).toEqual(''); + expect(formatInBytes(NaN)).toEqual(''); + }); + + test('in kb', () => { + const formatInKB = getFixedByteFormatter(kb); + expect(formatInKB(bytes)).toEqual('0.0 KB'); + expect(formatInKB(kb)).toEqual('1.0 KB'); + expect(formatInKB(mb)).toEqual('1,000.0 KB'); + expect(formatInKB(gb)).toEqual('1,000,000.0 KB'); + expect(formatInKB(tb)).toEqual('1,000,000,000.0 KB'); + }); + + test('in mb', () => { + const formatInMB = getFixedByteFormatter(mb); + expect(formatInMB(bytes)).toEqual('0.0 MB'); + expect(formatInMB(kb)).toEqual('0.0 MB'); + expect(formatInMB(mb)).toEqual('1.0 MB'); + expect(formatInMB(gb)).toEqual('1,000.0 MB'); + expect(formatInMB(tb)).toEqual('1,000,000.0 MB'); + expect(formatInMB(null)).toEqual(''); + expect(formatInMB(NaN)).toEqual(''); + }); + + test('in gb', () => { + const formatInGB = getFixedByteFormatter(gb); + expect(formatInGB(bytes)).toEqual('1e-8 GB'); + expect(formatInGB(kb)).toEqual('0.0 GB'); + expect(formatInGB(mb)).toEqual('0.0 GB'); + expect(formatInGB(gb)).toEqual('1.0 GB'); + expect(formatInGB(tb)).toEqual('1,000.0 GB'); + expect(formatInGB(null)).toEqual(''); + expect(formatInGB(NaN)).toEqual(''); + }); + + test('in tb', () => { + const formatInTB = getFixedByteFormatter(tb); + expect(formatInTB(bytes)).toEqual('1e-11 TB'); + expect(formatInTB(kb)).toEqual('1.001e-9 TB'); + expect(formatInTB(mb)).toEqual('0.0 TB'); + expect(formatInTB(gb)).toEqual('0.0 TB'); + expect(formatInTB(tb)).toEqual('1.0 TB'); + expect(formatInTB(null)).toEqual(''); + expect(formatInTB(NaN)).toEqual(''); + }); + }); + }); +}); diff --git a/x-pack/plugins/observability/common/utils/formatters/size.ts b/x-pack/plugins/observability/common/utils/formatters/size.ts new file mode 100644 index 0000000000000..ec0b753f1523d --- /dev/null +++ b/x-pack/plugins/observability/common/utils/formatters/size.ts @@ -0,0 +1,69 @@ +/* + * 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 { memoize } from 'lodash'; +import { asDecimal } from './formatters'; +import { Maybe } from '../../typings'; + +function asKilobytes(value: number) { + return `${asDecimal(value / 1000)} KB`; +} + +function asMegabytes(value: number) { + return `${asDecimal(value / 1e6)} MB`; +} + +function asGigabytes(value: number) { + return `${asDecimal(value / 1e9)} GB`; +} + +function asTerabytes(value: number) { + return `${asDecimal(value / 1e12)} TB`; +} + +function asBytes(value: number) { + return `${asDecimal(value)} B`; +} + +const bailIfNumberInvalid = (cb: (val: number) => string) => { + return (val: Maybe) => { + if (val === null || val === undefined || isNaN(val)) { + return ''; + } + return cb(val); + }; +}; + +export const getFixedByteFormatter = memoize((max: number) => { + const formatter = unmemoizedFixedByteFormatter(max); + + return bailIfNumberInvalid(formatter); +}); + +export const asDynamicBytes = bailIfNumberInvalid((value: number) => { + return unmemoizedFixedByteFormatter(value)(value); +}); + +const unmemoizedFixedByteFormatter = (max: number) => { + if (max > 1e12) { + return asTerabytes; + } + + if (max > 1e9) { + return asGigabytes; + } + + if (max > 1e6) { + return asMegabytes; + } + + if (max > 1000) { + return asKilobytes; + } + + return asBytes; +}; diff --git a/x-pack/plugins/observability/common/utils/is_finite_number.ts b/x-pack/plugins/observability/common/utils/is_finite_number.ts new file mode 100644 index 0000000000000..e5c9af80c7d69 --- /dev/null +++ b/x-pack/plugins/observability/common/utils/is_finite_number.ts @@ -0,0 +1,13 @@ +/* + * 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 { isFinite } from 'lodash'; + +// _.isNumber() returns true for NaN, _.isFinite() does not refine +export function isFiniteNumber(value: any): value is number { + return isFinite(value); +} diff --git a/x-pack/plugins/observability/common/utils/join_by_key/index.test.ts b/x-pack/plugins/observability/common/utils/join_by_key/index.test.ts new file mode 100644 index 0000000000000..93be59df9c0ca --- /dev/null +++ b/x-pack/plugins/observability/common/utils/join_by_key/index.test.ts @@ -0,0 +1,169 @@ +/* + * 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 { joinByKey } from './'; + +describe('joinByKey', () => { + it('joins by a string key', () => { + const joined = joinByKey( + [ + { + serviceName: 'opbeans-node', + avg: 10, + }, + { + serviceName: 'opbeans-node', + count: 12, + }, + { + serviceName: 'opbeans-java', + avg: 11, + }, + { + serviceName: 'opbeans-java', + p95: 18, + }, + ], + 'serviceName' + ); + + expect(joined.length).toBe(2); + + expect(joined).toEqual([ + { + serviceName: 'opbeans-node', + avg: 10, + count: 12, + }, + { + serviceName: 'opbeans-java', + avg: 11, + p95: 18, + }, + ]); + }); + + it('joins by a record key', () => { + const joined = joinByKey( + [ + { + key: { + serviceName: 'opbeans-node', + transactionName: '/api/opbeans-node', + }, + avg: 10, + }, + { + key: { + serviceName: 'opbeans-node', + transactionName: '/api/opbeans-node', + }, + count: 12, + }, + { + key: { + serviceName: 'opbeans-java', + transactionName: '/api/opbeans-java', + }, + avg: 11, + }, + { + key: { + serviceName: 'opbeans-java', + transactionName: '/api/opbeans-java', + }, + p95: 18, + }, + ], + 'key' + ); + + expect(joined.length).toBe(2); + + expect(joined).toEqual([ + { + key: { + serviceName: 'opbeans-node', + transactionName: '/api/opbeans-node', + }, + avg: 10, + count: 12, + }, + { + key: { + serviceName: 'opbeans-java', + transactionName: '/api/opbeans-java', + }, + avg: 11, + p95: 18, + }, + ]); + }); + + it('uses the custom merge fn to replace items', () => { + const joined = joinByKey( + [ + { + serviceName: 'opbeans-java', + values: ['a'], + }, + { + serviceName: 'opbeans-node', + values: ['a'], + }, + { + serviceName: 'opbeans-node', + values: ['b'], + }, + { + serviceName: 'opbeans-node', + values: ['c'], + }, + ], + 'serviceName', + (a, b) => ({ + ...a, + ...b, + values: a.values.concat(b.values), + }) + ); + + expect(joined.find((item) => item.serviceName === 'opbeans-node')?.values).toEqual([ + 'a', + 'b', + 'c', + ]); + }); + + it('deeply merges objects', () => { + const joined = joinByKey( + [ + { + serviceName: 'opbeans-node', + properties: { + foo: '', + }, + }, + { + serviceName: 'opbeans-node', + properties: { + bar: '', + }, + }, + ], + 'serviceName' + ); + + expect(joined[0]).toEqual({ + serviceName: 'opbeans-node', + properties: { + foo: '', + bar: '', + }, + }); + }); +}); diff --git a/x-pack/plugins/observability/common/utils/join_by_key/index.ts b/x-pack/plugins/observability/common/utils/join_by_key/index.ts new file mode 100644 index 0000000000000..91c10e5c550d8 --- /dev/null +++ b/x-pack/plugins/observability/common/utils/join_by_key/index.ts @@ -0,0 +1,68 @@ +/* + * 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 { UnionToIntersection, ValuesType } from 'utility-types'; +import { isEqual, pull, merge, castArray } from 'lodash'; + +/** + * Joins a list of records by a given key. Key can be any type of value, from + * strings to plain objects, as long as it is present in all records. `isEqual` + * is used for comparing keys. + * + * UnionToIntersection is needed to get all keys of union types, see below for + * example. + * + const agentNames = [{ serviceName: '', agentName: '' }]; + const transactionRates = [{ serviceName: '', transactionsPerMinute: 1 }]; + const flattened = joinByKey( + [...agentNames, ...transactionRates], + 'serviceName' + ); +*/ + +type JoinedReturnType, U extends UnionToIntersection> = Array< + Partial & + { + [k in keyof T]: T[k]; + } +>; + +type ArrayOrSingle = T | T[]; + +export function joinByKey< + T extends Record, + U extends UnionToIntersection, + V extends ArrayOrSingle +>(items: T[], key: V): JoinedReturnType; + +export function joinByKey< + T extends Record, + U extends UnionToIntersection, + V extends ArrayOrSingle, + W extends JoinedReturnType, + X extends (a: T, b: T) => ValuesType +>(items: T[], key: V, mergeFn: X): W; + +export function joinByKey( + items: Array>, + key: string | string[], + mergeFn: Function = (a: Record, b: Record) => merge({}, a, b) +) { + const keys = castArray(key); + return items.reduce>>((prev, current) => { + let item = prev.find((prevItem) => keys.every((k) => isEqual(prevItem[k], current[k]))); + + if (!item) { + item = { ...current }; + prev.push(item); + } else { + pull(prev, item).push(mergeFn(item, current)); + } + + return prev; + }, []); +} diff --git a/x-pack/plugins/observability/common/utils/maybe.ts b/x-pack/plugins/observability/common/utils/maybe.ts new file mode 100644 index 0000000000000..f73dbe09d6ad4 --- /dev/null +++ b/x-pack/plugins/observability/common/utils/maybe.ts @@ -0,0 +1,10 @@ +/* + * 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. + */ + +export function maybe(value: T): T | null | undefined { + return value; +} diff --git a/x-pack/plugins/observability/common/utils/pick_keys.ts b/x-pack/plugins/observability/common/utils/pick_keys.ts new file mode 100644 index 0000000000000..fe45e9a0e42c8 --- /dev/null +++ b/x-pack/plugins/observability/common/utils/pick_keys.ts @@ -0,0 +1,12 @@ +/* + * 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 { pick } from 'lodash'; + +export function pickKeys(obj: T, ...keys: K[]) { + return pick(obj, keys) as Pick; +} diff --git a/x-pack/plugins/observability/kibana.json b/x-pack/plugins/observability/kibana.json index 74efc1f4985a3..0ee978c75d6c0 100644 --- a/x-pack/plugins/observability/kibana.json +++ b/x-pack/plugins/observability/kibana.json @@ -2,10 +2,26 @@ "id": "observability", "version": "8.0.0", "kibanaVersion": "kibana", - "configPath": ["xpack", "observability"], - "optionalPlugins": ["licensing", "home", "usageCollection","lens", "ruleRegistry"], - "requiredPlugins": ["data"], + "configPath": [ + "xpack", + "observability" + ], + "optionalPlugins": [ + "licensing", + "home", + "usageCollection", + "lens" + ], + "requiredPlugins": [ + "data", + "alerting", + "ruleRegistry" + ], "ui": true, "server": true, - "requiredBundles": ["data", "kibanaReact", "kibanaUtils"] + "requiredBundles": [ + "data", + "kibanaReact", + "kibanaUtils" + ] } diff --git a/x-pack/plugins/observability/public/application/application.test.tsx b/x-pack/plugins/observability/public/application/application.test.tsx index d06b3822c2571..34ee22e89e66b 100644 --- a/x-pack/plugins/observability/public/application/application.test.tsx +++ b/x-pack/plugins/observability/public/application/application.test.tsx @@ -10,6 +10,7 @@ import React from 'react'; import { Observable } from 'rxjs'; import { AppMountParameters, CoreStart } from 'src/core/public'; import { ObservabilityPublicPluginsStart } from '../plugin'; +import { createObservabilityRuleRegistryMock } from '../rules/observability_rule_registry_mock'; import { renderApp } from './'; describe('renderApp', () => { @@ -51,7 +52,12 @@ describe('renderApp', () => { } as unknown) as AppMountParameters; expect(() => { - const unmount = renderApp(core, plugins, params); + const unmount = renderApp({ + core, + plugins, + appMountParameters: params, + observabilityRuleRegistry: createObservabilityRuleRegistryMock(), + }); unmount(); }).not.toThrowError(); }); diff --git a/x-pack/plugins/observability/public/application/index.tsx b/x-pack/plugins/observability/public/application/index.tsx index c8a8d877380e3..aa7d1d037d7b7 100644 --- a/x-pack/plugins/observability/public/application/index.tsx +++ b/x-pack/plugins/observability/public/application/index.tsx @@ -18,7 +18,7 @@ import { import { PluginContext } from '../context/plugin_context'; import { usePluginContext } from '../hooks/use_plugin_context'; import { useRouteParams } from '../hooks/use_route_params'; -import { ObservabilityPublicPluginsStart } from '../plugin'; +import { ObservabilityPublicPluginsStart, ObservabilityRuleRegistry } from '../plugin'; import { HasDataContextProvider } from '../context/has_data_context'; import { Breadcrumbs, routes } from '../routes'; import { Storage } from '../../../../../src/plugins/kibana_utils/public'; @@ -66,11 +66,17 @@ function App() { ); } -export const renderApp = ( - core: CoreStart, - plugins: ObservabilityPublicPluginsStart, - appMountParameters: AppMountParameters -) => { +export const renderApp = ({ + core, + plugins, + appMountParameters, + observabilityRuleRegistry, +}: { + core: CoreStart; + plugins: ObservabilityPublicPluginsStart; + observabilityRuleRegistry: ObservabilityRuleRegistry; + appMountParameters: AppMountParameters; +}) => { const { element, history } = appMountParameters; const i18nCore = core.i18n; const isDarkMode = core.uiSettings.get('theme:darkMode'); @@ -84,7 +90,9 @@ export const renderApp = ( ReactDOM.render( - + diff --git a/x-pack/plugins/observability/public/components/app/section/apm/index.test.tsx b/x-pack/plugins/observability/public/components/app/section/apm/index.test.tsx index d29481a39eb72..8ff68a0466054 100644 --- a/x-pack/plugins/observability/public/components/app/section/apm/index.test.tsx +++ b/x-pack/plugins/observability/public/components/app/section/apm/index.test.tsx @@ -14,7 +14,7 @@ import * as hasDataHook from '../../../../hooks/use_has_data'; import * as pluginContext from '../../../../hooks/use_plugin_context'; import { HasDataContextValue } from '../../../../context/has_data_context'; import { AppMountParameters, CoreStart } from 'kibana/public'; -import { ObservabilityPublicPluginsStart } from '../../../../plugin'; +import { ObservabilityPublicPluginsStart, ObservabilityRuleRegistry } from '../../../../plugin'; jest.mock('react-router-dom', () => ({ useLocation: () => ({ @@ -40,6 +40,10 @@ describe('APMSection', () => { http: { basePath: { prepend: jest.fn() } }, } as unknown) as CoreStart, appMountParameters: {} as AppMountParameters, + observabilityRuleRegistry: ({ + registerType: jest.fn(), + getTypeByRuleId: jest.fn(), + } as unknown) as ObservabilityRuleRegistry, plugins: ({ data: { query: { diff --git a/x-pack/plugins/observability/public/components/app/section/ux/index.test.tsx b/x-pack/plugins/observability/public/components/app/section/ux/index.test.tsx index d76e6d1b3e551..290990a5c05a5 100644 --- a/x-pack/plugins/observability/public/components/app/section/ux/index.test.tsx +++ b/x-pack/plugins/observability/public/components/app/section/ux/index.test.tsx @@ -7,6 +7,7 @@ import { AppMountParameters, CoreStart } from 'kibana/public'; import React from 'react'; +import { createObservabilityRuleRegistryMock } from '../../../../rules/observability_rule_registry_mock'; import { HasDataContextValue } from '../../../../context/has_data_context'; import * as fetcherHook from '../../../../hooks/use_fetcher'; import * as hasDataHook from '../../../../hooks/use_has_data'; @@ -53,6 +54,7 @@ describe('UXSection', () => { }, }, } as unknown) as ObservabilityPublicPluginsStart, + observabilityRuleRegistry: createObservabilityRuleRegistryMock(), })); }); it('renders with core web vitals', () => { diff --git a/x-pack/plugins/observability/public/components/shared/timestamp_tooltip/index.test.tsx b/x-pack/plugins/observability/public/components/shared/timestamp_tooltip/index.test.tsx new file mode 100644 index 0000000000000..14d0a64be5241 --- /dev/null +++ b/x-pack/plugins/observability/public/components/shared/timestamp_tooltip/index.test.tsx @@ -0,0 +1,65 @@ +/* + * 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 { shallow } from 'enzyme'; +import React from 'react'; +import moment from 'moment-timezone'; +import { TimestampTooltip } from './index'; + +function mockNow(date: string | number | Date) { + const fakeNow = new Date(date).getTime(); + return jest.spyOn(Date, 'now').mockReturnValue(fakeNow); +} + +describe('TimestampTooltip', () => { + const timestamp = 1570720000123; // Oct 10, 2019, 08:06:40.123 (UTC-7) + + beforeAll(() => { + // mock Date.now + mockNow(1570737000000); + + moment.tz.setDefault('America/Los_Angeles'); + }); + + afterAll(() => moment.tz.setDefault('')); + + it('should render component with relative time in body and absolute time in tooltip', () => { + expect(shallow()).toMatchInlineSnapshot(` + + 5 hours ago + + `); + }); + + it('should format with precision in milliseconds by default', () => { + expect( + shallow() + .find('EuiToolTip') + .prop('content') + ).toBe('Oct 10, 2019, 08:06:40.123 (UTC-7)'); + }); + + it('should format with precision in seconds', () => { + expect( + shallow() + .find('EuiToolTip') + .prop('content') + ).toBe('Oct 10, 2019, 08:06:40 (UTC-7)'); + }); + + it('should format with precision in minutes', () => { + expect( + shallow() + .find('EuiToolTip') + .prop('content') + ).toBe('Oct 10, 2019, 08:06 (UTC-7)'); + }); +}); diff --git a/x-pack/plugins/observability/public/components/shared/timestamp_tooltip/index.tsx b/x-pack/plugins/observability/public/components/shared/timestamp_tooltip/index.tsx new file mode 100644 index 0000000000000..784507fbfbcd8 --- /dev/null +++ b/x-pack/plugins/observability/public/components/shared/timestamp_tooltip/index.tsx @@ -0,0 +1,31 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React from 'react'; +import { EuiToolTip } from '@elastic/eui'; +import moment from 'moment-timezone'; +import { asAbsoluteDateTime, TimeUnit } from '../../../../common/utils/formatters/datetime'; + +interface Props { + /** + * timestamp in milliseconds + */ + time: number; + timeUnit?: TimeUnit; +} + +export function TimestampTooltip({ time, timeUnit = 'milliseconds' }: Props) { + const momentTime = moment(time); + const relativeTimeLabel = momentTime.fromNow(); + const absoluteTimeLabel = asAbsoluteDateTime(time, timeUnit); + + return ( + + <>{relativeTimeLabel} + + ); +} diff --git a/x-pack/plugins/observability/public/context/plugin_context.tsx b/x-pack/plugins/observability/public/context/plugin_context.tsx index 771968861a6bb..7a6daca6e7923 100644 --- a/x-pack/plugins/observability/public/context/plugin_context.tsx +++ b/x-pack/plugins/observability/public/context/plugin_context.tsx @@ -7,12 +7,13 @@ import { createContext } from 'react'; import { AppMountParameters, CoreStart } from 'kibana/public'; -import { ObservabilityPublicPluginsStart } from '../plugin'; +import { ObservabilityPublicPluginsStart, ObservabilityRuleRegistry } from '../plugin'; export interface PluginContextValue { appMountParameters: AppMountParameters; core: CoreStart; plugins: ObservabilityPublicPluginsStart; + observabilityRuleRegistry: ObservabilityRuleRegistry; } export const PluginContext = createContext({} as PluginContextValue); diff --git a/x-pack/plugins/observability/public/hooks/use_fetcher.tsx b/x-pack/plugins/observability/public/hooks/use_fetcher.tsx index 8e30f270bc58c..ab8263b086fcd 100644 --- a/x-pack/plugins/observability/public/hooks/use_fetcher.tsx +++ b/x-pack/plugins/observability/public/hooks/use_fetcher.tsx @@ -28,7 +28,7 @@ type InferResponseType = Exclude extends Promise( - fn: () => TReturn, + fn: ({}: { signal: AbortSignal }) => TReturn, fnDeps: any[], options: { preservePreviousData?: boolean; @@ -43,8 +43,16 @@ export function useFetcher( }); const [counter, setCounter] = useState(0); useEffect(() => { + let controller: AbortController = new AbortController(); + async function doFetch() { - const promise = fn(); + controller.abort(); + + controller = new AbortController(); + + const signal = controller.signal; + + const promise = fn({ signal }); if (!promise) { return; } @@ -58,22 +66,34 @@ export function useFetcher( try { const data = await promise; - setResult({ - data, - status: FETCH_STATUS.SUCCESS, - error: undefined, - } as FetcherResult>); + // when http fetches are aborted, the promise will be rejected + // and this code is never reached. For async operations that are + // not cancellable, we need to check whether the signal was + // aborted before updating the result. + if (!signal.aborted) { + setResult({ + data, + status: FETCH_STATUS.SUCCESS, + error: undefined, + } as FetcherResult>); + } } catch (e) { - setResult((prevResult) => ({ - data: preservePreviousData ? prevResult.data : undefined, - status: FETCH_STATUS.FAILURE, - error: e, - loading: false, - })); + if (!signal.aborted) { + setResult((prevResult) => ({ + data: preservePreviousData ? prevResult.data : undefined, + status: FETCH_STATUS.FAILURE, + error: e, + loading: false, + })); + } } } doFetch(); + + return () => { + controller.abort(); + }; // eslint-disable-next-line react-hooks/exhaustive-deps }, [counter, ...fnDeps]); diff --git a/x-pack/plugins/observability/public/hooks/use_time_range.test.ts b/x-pack/plugins/observability/public/hooks/use_time_range.test.ts index 184ec4f3390f4..61505d4850dc4 100644 --- a/x-pack/plugins/observability/public/hooks/use_time_range.test.ts +++ b/x-pack/plugins/observability/public/hooks/use_time_range.test.ts @@ -10,6 +10,7 @@ import * as pluginContext from './use_plugin_context'; import { AppMountParameters, CoreStart } from 'kibana/public'; import { ObservabilityPublicPluginsStart } from '../plugin'; import * as kibanaUISettings from './use_kibana_ui_settings'; +import { createObservabilityRuleRegistryMock } from '../rules/observability_rule_registry_mock'; jest.mock('react-router-dom', () => ({ useLocation: () => ({ @@ -37,6 +38,7 @@ describe('useTimeRange', () => { }, }, } as unknown) as ObservabilityPublicPluginsStart, + observabilityRuleRegistry: createObservabilityRuleRegistryMock(), })); jest.spyOn(kibanaUISettings, 'useKibanaUISettings').mockImplementation(() => ({ from: '2020-10-08T05:00:00.000Z', @@ -77,6 +79,7 @@ describe('useTimeRange', () => { }, }, } as unknown) as ObservabilityPublicPluginsStart, + observabilityRuleRegistry: createObservabilityRuleRegistryMock(), })); }); it('returns ranges and absolute times from kibana default settings', () => { diff --git a/x-pack/plugins/observability/public/index.ts b/x-pack/plugins/observability/public/index.ts index 837404d273ee4..ee2df9369aa39 100644 --- a/x-pack/plugins/observability/public/index.ts +++ b/x-pack/plugins/observability/public/index.ts @@ -56,3 +56,5 @@ export { useChartTheme } from './hooks/use_chart_theme'; export { useTheme } from './hooks/use_theme'; export { getApmTraceUrl } from './utils/get_apm_trace_url'; export { createExploratoryViewUrl } from './components/shared/exploratory_view/configurations/utils'; + +export { FormatterRuleRegistry } from './rules/formatter_rule_registry'; diff --git a/x-pack/plugins/observability/public/pages/alerts/alerts.stories.tsx b/x-pack/plugins/observability/public/pages/alerts/alerts.stories.tsx index 4adff299c30b7..33eec65c40dce 100644 --- a/x-pack/plugins/observability/public/pages/alerts/alerts.stories.tsx +++ b/x-pack/plugins/observability/public/pages/alerts/alerts.stories.tsx @@ -5,68 +5,98 @@ * 2.0. */ +import { StoryContext } from '@storybook/react'; import React, { ComponentType } from 'react'; import { IntlProvider } from 'react-intl'; +import { MemoryRouter } from 'react-router-dom'; import { AlertsPage } from '.'; +import { HttpSetup } from '../../../../../../src/core/public'; import { KibanaContextProvider } from '../../../../../../src/plugins/kibana_react/public'; import { PluginContext, PluginContextValue } from '../../context/plugin_context'; +import { createObservabilityRuleRegistryMock } from '../../rules/observability_rule_registry_mock'; +import { createCallObservabilityApi } from '../../services/call_observability_api'; +import type { ObservabilityAPIReturnType } from '../../services/call_observability_api/types'; import { AlertsFlyout } from './alerts_flyout'; -import { AlertItem } from './alerts_table'; -import { eventLogPocData, wireframeData } from './example_data'; +import { TopAlert } from './alerts_table'; +import { apmAlertResponseExample, dynamicIndexPattern, flyoutItemExample } from './example_data'; + +interface PageArgs { + items: ObservabilityAPIReturnType<'GET /api/observability/rules/alerts/top'>; +} + +interface FlyoutArgs { + alert: TopAlert; +} export default { title: 'app/Alerts', component: AlertsPage, decorators: [ - (Story: ComponentType) => { + (Story: ComponentType, { args: { items = [] } }: StoryContext) => { + createCallObservabilityApi(({ + get: async (endpoint: string) => { + if (endpoint === '/api/observability/rules/alerts/top') { + return items; + } else if (endpoint === '/api/observability/rules/alerts/dynamic_index_pattern') { + return dynamicIndexPattern; + } + }, + } as unknown) as HttpSetup); + return ( - - {} }, - uiSettings: { - get: (setting: string) => { - if (setting === 'dateFormat') { - return ''; - } else { - return []; - } - }, - }, - }} - > - '' } }, + + + false }, query: {} }, + docLinks: { links: { query: {} } }, + storage: { get: () => {} }, + uiSettings: { + get: (setting: string) => { + if (setting === 'dateFormat') { + return ''; + } else { + return []; + } }, - } as unknown) as PluginContextValue - } + }, + }} > - - - - + '' } }, + }, + observabilityRuleRegistry: createObservabilityRuleRegistryMock(), + } as unknown) as PluginContextValue + } + > + + + + + ); }, ], }; -export function Example() { - return ; -} - -export function EventLog() { - return ; +export function Example(_args: PageArgs) { + return ( + + ); } +Example.args = { + items: apmAlertResponseExample, +} as PageArgs; -export function EmptyState() { - return ; +export function EmptyState(_args: PageArgs) { + return ; } +EmptyState.args = { items: [] } as PageArgs; -export function Flyout() { - return {}} />; +export function Flyout({ alert }: FlyoutArgs) { + return {}} />; } +Flyout.args = { alert: flyoutItemExample } as FlyoutArgs; diff --git a/x-pack/plugins/observability/public/pages/alerts/alerts_flyout.tsx b/x-pack/plugins/observability/public/pages/alerts/alerts_flyout.tsx index 0b63049ec1f72..4b383283c4d4b 100644 --- a/x-pack/plugins/observability/public/pages/alerts/alerts_flyout.tsx +++ b/x-pack/plugins/observability/public/pages/alerts/alerts_flyout.tsx @@ -6,7 +6,6 @@ */ import { - EuiBadge, EuiFlyout, EuiFlyoutHeader, EuiFlyoutProps, @@ -17,57 +16,46 @@ import { } from '@elastic/eui'; import { i18n } from '@kbn/i18n'; import React from 'react'; -import { AlertItem } from './alerts_table'; +import { asDuration } from '../../../common/utils/formatters'; +import { TopAlert } from './alerts_table'; -type AlertsFlyoutProps = AlertItem & EuiFlyoutProps; +type AlertsFlyoutProps = { alert: TopAlert } & EuiFlyoutProps; export function AlertsFlyout(props: AlertsFlyoutProps) { - const { - actualValue, - affectedEntity, - expectedValue, - onClose, - reason, - severity, - severityLog, - status, - duration, - type, - } = props; - const timestamp = props['@timestamp']; + const { onClose, alert } = props; const overviewListItems = [ { title: 'Status', - description: status || '-', + description: alert.active ? 'Active' : 'Recovered', }, { title: 'Severity', - description: severity || '-', // TODO: badge and "(changed 2 min ago)" - }, - { - title: 'Affected entity', - description: affectedEntity || '-', // TODO: link to entity + description: alert.severityLevel || '-', // TODO: badge and "(changed 2 min ago)" }, + // { + // title: 'Affected entity', + // description: affectedEntity || '-', // TODO: link to entity + // }, { title: 'Triggered', - description: timestamp, // TODO: format date + description: alert.start, // TODO: format date }, { title: 'Duration', - description: duration || '-', // TODO: format duration - }, - { - title: 'Expected value', - description: expectedValue || '-', + description: asDuration(alert.duration, { extended: true }) || '-', // TODO: format duration }, + // { + // title: 'Expected value', + // description: expectedValue || '-', + // }, + // { + // title: 'Actual value', + // description: actualValue || '-', + // }, { - title: 'Actual value', - description: actualValue || '-', - }, - { - title: 'Type', - description: type || '-', + title: 'Rule type', + description: alert.ruleCategory || '-', }, ]; @@ -87,7 +75,7 @@ export function AlertsFlyout(props: AlertsFlyoutProps) { ]} items={overviewListItems} /> - + {/*

Severity log

@@ -105,7 +93,7 @@ export function AlertsFlyout(props: AlertsFlyoutProps) { }, ]} items={severityLog ?? []} - /> + /> */} ), }, @@ -123,7 +111,7 @@ export function AlertsFlyout(props: AlertsFlyoutProps) { -

{reason}

+

{alert.ruleName}

diff --git a/x-pack/plugins/observability/public/pages/alerts/alerts_search_bar.tsx b/x-pack/plugins/observability/public/pages/alerts/alerts_search_bar.tsx index 1afab90f2999e..97595b456d503 100644 --- a/x-pack/plugins/observability/public/pages/alerts/alerts_search_bar.tsx +++ b/x-pack/plugins/observability/public/pages/alerts/alerts_search_bar.tsx @@ -6,19 +6,56 @@ */ import { i18n } from '@kbn/i18n'; -import React from 'react'; +import React, { useMemo } from 'react'; import { SearchBar, TimeHistory } from '../../../../../../src/plugins/data/public'; import { Storage } from '../../../../../../src/plugins/kibana_utils/public'; +import { useFetcher } from '../../hooks/use_fetcher'; +import { callObservabilityApi } from '../../services/call_observability_api'; + +export function AlertsSearchBar({ + rangeFrom, + rangeTo, + onQueryChange, + query, +}: { + rangeFrom?: string; + rangeTo?: string; + query?: string; + onQueryChange: ({}: { + dateRange: { from: string; to: string; mode?: 'absolute' | 'relative' }; + query?: string; + }) => void; +}) { + const timeHistory = useMemo(() => { + return new TimeHistory(new Storage(localStorage)); + }, []); + + const { data: dynamicIndexPattern } = useFetcher(({ signal }) => { + return callObservabilityApi({ + signal, + endpoint: 'GET /api/observability/rules/alerts/dynamic_index_pattern', + }); + }, []); -export function AlertsSearchBar() { return ( { + onQueryChange({ dateRange, query }); + }} + onQuerySubmit={({ dateRange, query: nextQuery }) => { + onQueryChange({ + dateRange, + query: typeof nextQuery?.query === 'string' ? nextQuery.query : '', + }); + }} /> ); } diff --git a/x-pack/plugins/observability/public/pages/alerts/alerts_table.tsx b/x-pack/plugins/observability/public/pages/alerts/alerts_table.tsx index 057e3e74b84d8..0985597cc4b69 100644 --- a/x-pack/plugins/observability/public/pages/alerts/alerts_table.tsx +++ b/x-pack/plugins/observability/public/pages/alerts/alerts_table.tsx @@ -12,66 +12,38 @@ import { DefaultItemAction, EuiTableSelectionType, EuiLink, + EuiBadge, } from '@elastic/eui'; +import { i18n } from '@kbn/i18n'; import React, { useState } from 'react'; +import { asDuration } from '../../../common/utils/formatters'; +import { TimestampTooltip } from '../../components/shared/timestamp_tooltip'; import { usePluginContext } from '../../hooks/use_plugin_context'; import { AlertsFlyout } from './alerts_flyout'; -/** - * The type of an item in the alert list. - * - * The fields here are the minimum to make this work at this time, but - * eventually this type should be derived from the schema of what is returned in - * the API response. - */ -export interface AlertItem { - '@timestamp': number; +export interface TopAlert { + start: number; + duration: number; reason: string; - severity: string; - // These are just made up so we can make example links - service?: { name?: string }; - pod?: string; - log?: boolean; - // Other fields used in the flyout - actualValue?: string; - affectedEntity?: string; - expectedValue?: string; - severityLog?: Array<{ '@timestamp': number; severity: string; message: string }>; - status?: string; - duration?: string; - type?: string; + link?: string; + severityLevel?: string; + active: boolean; + ruleName: string; + ruleCategory: string; } type AlertsTableProps = Omit< - EuiBasicTableProps, + EuiBasicTableProps, 'columns' | 'isSelectable' | 'pagination' | 'selection' >; export function AlertsTable(props: AlertsTableProps) { - const [flyoutAlert, setFlyoutAlert] = useState(undefined); + const [flyoutAlert, setFlyoutAlert] = useState(undefined); const handleFlyoutClose = () => setFlyoutAlert(undefined); - const { prepend } = usePluginContext().core.http.basePath; - - // This is a contrived implementation of the reason field that shows how - // you could link to certain types of resources based on what's contained - // in their alert data. - function reasonRenderer(text: string, item: AlertItem) { - const serviceName = item.service?.name; - const pod = item.pod; - const log = item.log; - - if (serviceName) { - return {text}; - } else if (pod) { - return {text}; - } else if (log) { - return {text}; - } else { - return <>{text}; - } - } + const { core } = usePluginContext(); + const { prepend } = core.http.basePath; - const actions: Array> = [ + const actions: Array> = [ { name: 'Alert details', description: 'Alert details', @@ -82,25 +54,53 @@ export function AlertsTable(props: AlertsTableProps) { }, ]; - const columns: Array> = [ + const columns: Array> = [ + { + field: 'active', + name: 'Status', + width: '112px', + render: (_, { active }) => { + const style = { + width: '96px', + textAlign: 'center' as const, + }; + + return active ? ( + + {i18n.translate('xpack.observability.alertsTable.status.active', { + defaultMessage: 'Active', + })} + + ) : ( + + {i18n.translate('xpack.observability.alertsTable.status.recovered', { + defaultMessage: 'Recovered', + })} + + ); + }, + }, { - field: '@timestamp', + field: 'start', name: 'Triggered', - dataType: 'date', + render: (_, item) => { + return ; + }, }, { field: 'duration', name: 'Duration', - }, - { - field: 'severity', - name: 'Severity', + render: (_, { duration, active }) => { + return active ? null : asDuration(duration, { extended: true }); + }, }, { field: 'reason', name: 'Reason', dataType: 'string', - render: reasonRenderer, + render: (_, item) => { + return item.link ? {item.reason} : item.reason; + }, }, { actions, @@ -110,12 +110,13 @@ export function AlertsTable(props: AlertsTableProps) { return ( <> - {flyoutAlert && } - + {flyoutAlert && } + {...props} isSelectable={true} - selection={{} as EuiTableSelectionType} + selection={{} as EuiTableSelectionType} columns={columns} + tableLayout="auto" pagination={{ pageIndex: 0, pageSize: 0, totalItemCount: 0 }} /> diff --git a/x-pack/plugins/observability/public/pages/alerts/example_data.ts b/x-pack/plugins/observability/public/pages/alerts/example_data.ts index 584408a23d9bd..860c8d059f00d 100644 --- a/x-pack/plugins/observability/public/pages/alerts/example_data.ts +++ b/x-pack/plugins/observability/public/pages/alerts/example_data.ts @@ -5,505 +5,237 @@ * 2.0. */ -/** - * Example data from Whimsical wireframes: https://whimsical.com/observability-alerting-user-journeys-8TFDcHRPMQDJgtLpJ7XuBj - */ -export const wireframeData = [ - { - '@timestamp': 1615392661000, - duration: '10 min 2 s', - severity: '-', - reason: 'Error count is greater than 100 (current value is 135) on shippingService', - service: { name: 'opbeans-go' }, - affectedEntity: 'opbeans-go service', - status: 'Active', - expectedValue: '< 100', - actualValue: '135', - severityLog: [ - { '@timestamp': 1615392661000, severity: 'critical', message: 'Load is 3.5' }, - { '@timestamp': 1615392600000, severity: 'warning', message: 'Load is 2.5' }, - { '@timestamp': 1615392552000, severity: 'critical', message: 'Load is 3.5' }, - ], - type: 'APM Error count', - }, +export const apmAlertResponseExample = [ { - '@timestamp': 1615392600000, - duration: '11 min 1 s', - severity: '-', - reason: 'Latency is greater than 1500ms (current value is 1700ms) on frontend', - service: { name: 'opbeans-go' }, - severityLog: [], + 'rule.id': 'apm.error_rate', + 'service.name': 'opbeans-java', + 'rule.name': 'Error count threshold | opbeans-java (smith test)', + 'kibana.rac.alert.duration.us': 180057000, + 'kibana.rac.alert.status': 'open', + tags: ['apm', 'service.name:opbeans-java'], + 'kibana.rac.alert.uuid': '0175ec0a-a3b1-4d41-b557-e21c2d024352', + 'rule.uuid': '474920d0-93e9-11eb-ac86-0b455460de81', + 'event.action': 'active', + '@timestamp': '2021-04-12T13:53:49.550Z', + 'kibana.rac.alert.id': 'apm.error_rate_opbeans-java_production', + 'kibana.rac.alert.start': '2021-04-12T13:50:49.493Z', + 'kibana.rac.producer': 'apm', + 'event.kind': 'state', + 'rule.category': 'Error count threshold', + 'service.environment': ['production'], + 'processor.event': ['error'], }, { - '@timestamp': 1615392552000, - duration: '10 min 2 s', - severity: 'critical', - reason: 'Latency anomaly score is 84 on checkoutService', - service: { name: 'opbeans-go' }, - severityLog: [], - }, - { - '@timestamp': 1615392391000, - duration: '10 min 2 s', - severity: '-', - reason: - 'CPU is greater than a threshold of 75% (current value is 83%) on gke-eden-3-prod-pool-2-395ef018-06xg', - pod: 'gke-dev-oblt-dev-oblt-pool-30f1ba48-skw', - severityLog: [], - }, - { - '@timestamp': 1615392363000, - duration: '10 min 2 s', - severity: '-', - reason: - "Log count with 'Log.level.error' and 'service.name; frontend' is greater than 75 (current value 122)", - log: true, - severityLog: [], - }, - { - '@timestamp': 1615392361000, - duration: '10 min 2 s', - severity: 'critical', - reason: 'Load is greater than 2 (current value is 3.5) on gke-eden-3-prod-pool-2-395ef018-06xg', - pod: 'gke-dev-oblt-dev-oblt-pool-30f1ba48-skw', - severityLog: [], + 'rule.id': 'apm.error_rate', + 'service.name': 'opbeans-java', + 'rule.name': 'Error count threshold | opbeans-java (smith test)', + 'kibana.rac.alert.duration.us': 2419005000, + 'kibana.rac.alert.end': '2021-04-12T13:49:49.446Z', + 'kibana.rac.alert.status': 'closed', + tags: ['apm', 'service.name:opbeans-java'], + 'kibana.rac.alert.uuid': '32b940e1-3809-4c12-8eee-f027cbb385e2', + 'rule.uuid': '474920d0-93e9-11eb-ac86-0b455460de81', + 'event.action': 'close', + '@timestamp': '2021-04-12T13:49:49.446Z', + 'kibana.rac.alert.id': 'apm.error_rate_opbeans-java_production', + 'kibana.rac.alert.start': '2021-04-12T13:09:30.441Z', + 'kibana.rac.producer': 'apm', + 'event.kind': 'state', + 'rule.category': 'Error count threshold', + 'service.environment': ['production'], + 'processor.event': ['error'], }, ]; -/** - * Example data from this proof of concept: https://github.com/dgieselaar/kibana/tree/alerting-event-log-poc - */ -export const eventLogPocData = [ - { - '@timestamp': 1615395754597, - first_seen: 1615362488702, - severity: 'warning', - severity_value: 1241.4546, - reason: - 'Transaction duration for opbeans-java/request in production was above the threshold of 1.0 ms (1.2 ms)', - rule_id: 'cb1fc3e0-7fef-11eb-827d-d94e80a23d8d', - rule_name: 'Latency threshold | opbeans-java', - rule_type_id: 'apm.transaction_duration', - rule_type_name: 'Latency threshold', - alert_instance_title: ['opbeans-java/request:production'], - alert_instance_name: 'apm.transaction_duration_production', - unique: 1, - group_by_field: 'alert_instance.uuid', - group_by_value: '1b354805-4bf3-4626-b6be-5801d7d1e256', - influencers: [ - 'service.name:opbeans-java', - 'service.environment:production', - 'transaction.type:request', - ], - fields: { - 'processor.event': 'transaction', - 'service.name': 'opbeans-java', - 'service.environment': 'production', - 'transaction.type': 'request', +export const flyoutItemExample = { + link: '/app/apm/services/opbeans-java?rangeFrom=now-15m&rangeTo=now', + reason: 'Error count for opbeans-java was above the threshold', + active: true, + start: 1618235449493, + duration: 180057000, + ruleCategory: 'Error count threshold', + ruleName: 'Error count threshold | opbeans-java (smith test)', +}; + +export const dynamicIndexPattern = { + fields: [ + { + name: '@timestamp', + type: 'date', + esTypes: ['date'], + searchable: true, + aggregatable: true, + readFromDocValues: true, }, - timeseries: [ - { - x: 1615359600000, - y: 48805, - threshold: 1000, - }, - { - x: 1615370400000, - y: 3992.5, - threshold: 1000, - }, - { - x: 1615381200000, - y: 4296.7998046875, - threshold: 1000, - }, - { - x: 1615392000000, - y: 1633.8182373046875, - threshold: 1000, - }, - ], - recovered: false, - }, - { - '@timestamp': 1615326143423, - first_seen: 1615323802378, - severity: 'warning', - severity_value: 27, - reason: 'Error count for opbeans-node in production was above the threshold of 2 (27)', - rule_id: '335b38d0-80be-11eb-9fd1-d3725789930d', - rule_name: 'Error count threshold', - rule_type_id: 'apm.error_rate', - rule_type_name: 'Error count threshold', - alert_instance_title: ['opbeans-node:production'], - alert_instance_name: 'opbeans-node_production', - unique: 1, - group_by_field: 'alert_instance.uuid', - group_by_value: '19165a4f-296a-4045-9448-40c793d97d02', - influencers: ['service.name:opbeans-node', 'service.environment:production'], - fields: { - 'processor.event': 'error', - 'service.name': 'opbeans-node', - 'service.environment': 'production', + { + name: 'event.action', + type: 'string', + esTypes: ['keyword'], + searchable: true, + aggregatable: true, + readFromDocValues: true, }, - timeseries: [ - { - x: 1615323780000, - y: 32, - threshold: 2, - }, - { - x: 1615324080000, - y: 34, - threshold: 2, - }, - { - x: 1615324380000, - y: 32, - threshold: 2, - }, - { - x: 1615324680000, - y: 34, - threshold: 2, - }, - { - x: 1615324980000, - y: 35, - threshold: 2, - }, - { - x: 1615325280000, - y: 31, - threshold: 2, - }, - { - x: 1615325580000, - y: 36, - threshold: 2, - }, - { - x: 1615325880000, - y: 35, - threshold: 2, - }, - ], - recovered: true, - }, - { - '@timestamp': 1615326143423, - first_seen: 1615325783256, - severity: 'warning', - severity_value: 27, - reason: 'Error count for opbeans-java in production was above the threshold of 2 (27)', - rule_id: '335b38d0-80be-11eb-9fd1-d3725789930d', - rule_name: 'Error count threshold', - rule_type_id: 'apm.error_rate', - rule_type_name: 'Error count threshold', - alert_instance_title: ['opbeans-java:production'], - alert_instance_name: 'opbeans-java_production', - unique: 1, - group_by_field: 'alert_instance.uuid', - group_by_value: '73075d90-e27a-4e20-9ba0-3512a16c2829', - influencers: ['service.name:opbeans-java', 'service.environment:production'], - fields: { - 'processor.event': 'error', - 'service.name': 'opbeans-java', - 'service.environment': 'production', + { + name: 'event.kind', + type: 'string', + esTypes: ['keyword'], + searchable: true, + aggregatable: true, + readFromDocValues: true, }, - timeseries: [ - { - x: 1615325760000, - y: 36, - threshold: 2, - }, - { - x: 1615325820000, - y: 26, - threshold: 2, - }, - { - x: 1615325880000, - y: 28, - threshold: 2, - }, - { - x: 1615325940000, - y: 35, - threshold: 2, - }, - { - x: 1615326000000, - y: 32, - threshold: 2, - }, - { - x: 1615326060000, - y: 23, - threshold: 2, - }, - { - x: 1615326120000, - y: 27, - threshold: 2, - }, - ], - recovered: true, - }, - { - '@timestamp': 1615326143423, - first_seen: 1615323802378, - severity: 'warning', - severity_value: 4759.9116, - reason: - 'Transaction duration for opbeans-java/request in production was above the threshold of 1.0 ms (4.8 ms)', - rule_id: 'cb1fc3e0-7fef-11eb-827d-d94e80a23d8d', - rule_name: 'Latency threshold | opbeans-java', - rule_type_id: 'apm.transaction_duration', - rule_type_name: 'Latency threshold', - alert_instance_title: ['opbeans-java/request:production'], - alert_instance_name: 'apm.transaction_duration_production', - unique: 1, - group_by_field: 'alert_instance.uuid', - group_by_value: 'ffa0437d-6656-4553-a1cd-c170fc6e2f81', - influencers: [ - 'service.name:opbeans-java', - 'service.environment:production', - 'transaction.type:request', - ], - fields: { - 'processor.event': 'transaction', - 'service.name': 'opbeans-java', - 'service.environment': 'production', - 'transaction.type': 'request', + { + name: 'host.name', + type: 'string', + esTypes: ['keyword'], + searchable: true, + aggregatable: true, + readFromDocValues: true, }, - timeseries: [ - { - x: 1615323780000, - y: 13145.51171875, - threshold: 1000, - }, - { - x: 1615324080000, - y: 15995.15625, - threshold: 1000, - }, - { - x: 1615324380000, - y: 18974.59375, - threshold: 1000, - }, - { - x: 1615324680000, - y: 11604.87890625, - threshold: 1000, - }, - { - x: 1615324980000, - y: 17945.9609375, - threshold: 1000, - }, - { - x: 1615325280000, - y: 9933.22265625, - threshold: 1000, - }, - { - x: 1615325580000, - y: 10011.58984375, - threshold: 1000, - }, - { - x: 1615325880000, - y: 10953.1845703125, - threshold: 1000, - }, - ], - recovered: true, - }, - { - '@timestamp': 1615325663207, - first_seen: 1615324762861, - severity: 'warning', - severity_value: 27, - reason: 'Error count for opbeans-java in production was above the threshold of 2 (27)', - rule_id: '335b38d0-80be-11eb-9fd1-d3725789930d', - rule_name: 'Error count threshold', - rule_type_id: 'apm.error_rate', - rule_type_name: 'Error count threshold', - alert_instance_title: ['opbeans-java:production'], - alert_instance_name: 'opbeans-java_production', - unique: 1, - group_by_field: 'alert_instance.uuid', - group_by_value: 'bf5f9574-57c8-44ed-9a3c-512b446695cf', - influencers: ['service.name:opbeans-java', 'service.environment:production'], - fields: { - 'processor.event': 'error', - 'service.name': 'opbeans-java', - 'service.environment': 'production', + { + name: 'kibana.rac.alert.duration.us', + type: 'number', + esTypes: ['long'], + searchable: true, + aggregatable: true, + readFromDocValues: true, }, - timeseries: [ - { - x: 1615324740000, - y: 34, - threshold: 2, - }, - { - x: 1615325040000, - y: 35, - threshold: 2, - }, - { - x: 1615325340000, - y: 31, - threshold: 2, - }, - { - x: 1615325640000, - y: 27, - threshold: 2, - }, - ], - recovered: true, - }, - { - '@timestamp': 1615324642764, - first_seen: 1615324402620, - severity: 'warning', - severity_value: 32, - reason: 'Error count for opbeans-java in production was above the threshold of 2 (32)', - rule_id: '335b38d0-80be-11eb-9fd1-d3725789930d', - rule_name: 'Error count threshold', - rule_type_id: 'apm.error_rate', - rule_type_name: 'Error count threshold', - alert_instance_title: ['opbeans-java:production'], - alert_instance_name: 'opbeans-java_production', - unique: 1, - group_by_field: 'alert_instance.uuid', - group_by_value: '87768bef-67a3-4ddd-b95d-7ab8830b30ef', - influencers: ['service.name:opbeans-java', 'service.environment:production'], - fields: { - 'processor.event': 'error', - 'service.name': 'opbeans-java', - 'service.environment': 'production', + { + name: 'kibana.rac.alert.end', + type: 'date', + esTypes: ['date'], + searchable: true, + aggregatable: true, + readFromDocValues: true, }, - timeseries: [ - { - x: 1615324402000, - y: 30, - threshold: 2, - }, - { - x: 1615324432000, - y: null, - threshold: null, - }, - { - x: 1615324462000, - y: 28, - threshold: 2, - }, - { - x: 1615324492000, - y: null, - threshold: null, - }, - { - x: 1615324522000, - y: 30, - threshold: 2, - }, - { - x: 1615324552000, - y: null, - threshold: null, - }, - { - x: 1615324582000, - y: 18, - threshold: 2, - }, - { - x: 1615324612000, - y: null, - threshold: null, - }, - { - x: 1615324642000, - y: 32, - threshold: 2, - }, - ], - recovered: true, - }, - { - '@timestamp': 1615324282583, - first_seen: 1615323802378, - severity: 'warning', - severity_value: 30, - reason: 'Error count for opbeans-java in production was above the threshold of 2 (30)', - rule_id: '335b38d0-80be-11eb-9fd1-d3725789930d', - rule_name: 'Error count threshold', - rule_type_id: 'apm.error_rate', - rule_type_name: 'Error count threshold', - alert_instance_title: ['opbeans-java:production'], - alert_instance_name: 'opbeans-java_production', - unique: 1, - group_by_field: 'alert_instance.uuid', - group_by_value: '31d087bd-51ae-419d-81c0-d0671eb97392', - influencers: ['service.name:opbeans-java', 'service.environment:production'], - fields: { - 'processor.event': 'error', - 'service.name': 'opbeans-java', - 'service.environment': 'production', + { + name: 'kibana.rac.alert.id', + type: 'string', + esTypes: ['keyword'], + searchable: true, + aggregatable: true, + readFromDocValues: true, }, - timeseries: [ - { - x: 1615323780000, - y: 31, - threshold: 2, - }, - { - x: 1615323840000, - y: 30, - threshold: 2, - }, - { - x: 1615323900000, - y: 24, - threshold: 2, - }, - { - x: 1615323960000, - y: 32, - threshold: 2, - }, - { - x: 1615324020000, - y: 32, - threshold: 2, - }, - { - x: 1615324080000, - y: 30, - threshold: 2, - }, - { - x: 1615324140000, - y: 25, - threshold: 2, - }, - { - x: 1615324200000, - y: 34, - threshold: 2, - }, - { - x: 1615324260000, - y: 30, - threshold: 2, - }, - ], - recovered: true, - }, -]; + { + name: 'kibana.rac.alert.severity.level', + type: 'string', + esTypes: ['keyword'], + searchable: true, + aggregatable: true, + readFromDocValues: true, + }, + { + name: 'kibana.rac.alert.severity.value', + type: 'number', + esTypes: ['long'], + searchable: true, + aggregatable: true, + readFromDocValues: true, + }, + { + name: 'kibana.rac.alert.start', + type: 'date', + esTypes: ['date'], + searchable: true, + aggregatable: true, + readFromDocValues: true, + }, + { + name: 'kibana.rac.alert.status', + type: 'string', + esTypes: ['keyword'], + searchable: true, + aggregatable: true, + readFromDocValues: true, + }, + { + name: 'kibana.rac.alert.uuid', + type: 'string', + esTypes: ['keyword'], + searchable: true, + aggregatable: true, + readFromDocValues: true, + }, + { + name: 'kibana.rac.producer', + type: 'string', + esTypes: ['keyword'], + searchable: true, + aggregatable: true, + readFromDocValues: true, + }, + { + name: 'processor.event', + type: 'string', + esTypes: ['keyword'], + searchable: true, + aggregatable: true, + readFromDocValues: true, + }, + { + name: 'rule.category', + type: 'string', + esTypes: ['keyword'], + searchable: true, + aggregatable: true, + readFromDocValues: true, + }, + { + name: 'rule.id', + type: 'string', + esTypes: ['keyword'], + searchable: true, + aggregatable: true, + readFromDocValues: true, + }, + { + name: 'rule.name', + type: 'string', + esTypes: ['keyword'], + searchable: true, + aggregatable: true, + readFromDocValues: true, + }, + { + name: 'rule.uuid', + type: 'string', + esTypes: ['keyword'], + searchable: true, + aggregatable: true, + readFromDocValues: true, + }, + { + name: 'service.environment', + type: 'string', + esTypes: ['keyword'], + searchable: true, + aggregatable: true, + readFromDocValues: true, + }, + { + name: 'service.name', + type: 'string', + esTypes: ['keyword'], + searchable: true, + aggregatable: true, + readFromDocValues: true, + }, + { + name: 'tags', + type: 'string', + esTypes: ['keyword'], + searchable: true, + aggregatable: true, + readFromDocValues: true, + }, + { + name: 'transaction.type', + type: 'string', + esTypes: ['keyword'], + searchable: true, + aggregatable: true, + readFromDocValues: true, + }, + ], + timeFieldName: '@timestamp', + title: '.kibana_smith-alerts-observability*', +}; diff --git a/x-pack/plugins/observability/public/pages/alerts/index.tsx b/x-pack/plugins/observability/public/pages/alerts/index.tsx index b4cc600e59d56..0089465003393 100644 --- a/x-pack/plugins/observability/public/pages/alerts/index.tsx +++ b/x-pack/plugins/observability/public/pages/alerts/index.tsx @@ -16,26 +16,28 @@ import { } from '@elastic/eui'; import { i18n } from '@kbn/i18n'; import React from 'react'; +import { useHistory } from 'react-router-dom'; +import { format, parse } from 'url'; import { ExperimentalBadge } from '../../components/shared/experimental_badge'; +import { useFetcher } from '../../hooks/use_fetcher'; import { usePluginContext } from '../../hooks/use_plugin_context'; import { RouteParams } from '../../routes'; +import { callObservabilityApi } from '../../services/call_observability_api'; +import { getAbsoluteDateRange } from '../../utils/date'; import { AlertsSearchBar } from './alerts_search_bar'; -import { AlertItem, AlertsTable } from './alerts_table'; -import { wireframeData } from './example_data'; +import { AlertsTable } from './alerts_table'; interface AlertsPageProps { - items?: AlertItem[]; routeParams: RouteParams<'/alerts'>; } -export function AlertsPage({ items }: AlertsPageProps) { - // For now, if we're not passed any items load the example wireframe data. - if (!items) { - items = wireframeData; - } - - const { core } = usePluginContext(); +export function AlertsPage({ routeParams }: AlertsPageProps) { + const { core, observabilityRuleRegistry } = usePluginContext(); const { prepend } = core.http.basePath; + const history = useHistory(); + const { + query: { rangeFrom = 'now-15m', rangeTo = 'now', kuery = '' }, + } = routeParams; // In a future milestone we'll have a page dedicated to rule management in // observability. For now link to the settings page. @@ -43,6 +45,59 @@ export function AlertsPage({ items }: AlertsPageProps) { '/app/management/insightsAndAlerting/triggersActions/alerts' ); + const { data: topAlerts } = useFetcher( + ({ signal }) => { + const { start, end } = getAbsoluteDateRange({ rangeFrom, rangeTo }); + + if (!start || !end) { + return; + } + return callObservabilityApi({ + signal, + endpoint: 'GET /api/observability/rules/alerts/top', + params: { + query: { + start, + end, + kuery, + }, + }, + }).then((alerts) => { + return alerts.map((alert) => { + const ruleType = observabilityRuleRegistry.getTypeByRuleId(alert['rule.id']); + const formatted = { + link: undefined, + reason: alert['rule.name'], + ...(ruleType?.format?.({ alert }) ?? {}), + }; + + const parsedLink = formatted.link ? parse(formatted.link, true) : undefined; + + return { + ...formatted, + link: parsedLink + ? format({ + ...parsedLink, + query: { + ...parsedLink.query, + rangeFrom, + rangeTo, + }, + }) + : undefined, + active: alert['event.action'] !== 'close', + severityLevel: alert['kibana.rac.alert.severity.level'], + start: new Date(alert['kibana.rac.alert.start']).getTime(), + duration: alert['kibana.rac.alert.duration.us'], + ruleCategory: alert['rule.category'], + ruleName: alert['rule.name'], + }; + }); + }); + }, + [kuery, observabilityRuleRegistry, rangeFrom, rangeTo] + ); + return ( - + { + const nextSearchParams = new URLSearchParams(history.location.search); + + nextSearchParams.set('rangeFrom', dateRange.from); + nextSearchParams.set('rangeTo', dateRange.to); + nextSearchParams.set('kuery', query ?? ''); + + history.push({ + ...history.location, + search: nextSearchParams.toString(), + }); + }} + /> - + diff --git a/x-pack/plugins/observability/public/pages/overview/overview.stories.tsx b/x-pack/plugins/observability/public/pages/overview/overview.stories.tsx index 56019eeccfd5a..6fc573b11109a 100644 --- a/x-pack/plugins/observability/public/pages/overview/overview.stories.tsx +++ b/x-pack/plugins/observability/public/pages/overview/overview.stories.tsx @@ -23,6 +23,7 @@ import { emptyResponse as emptyLogsResponse, fetchLogsData } from './mock/logs.m import { emptyResponse as emptyMetricsResponse, fetchMetricsData } from './mock/metrics.mock'; import { newsFeedFetchData } from './mock/news_feed.mock'; import { emptyResponse as emptyUptimeResponse, fetchUptimeData } from './mock/uptime.mock'; +import { createObservabilityRuleRegistryMock } from '../../rules/observability_rule_registry_mock'; function unregisterAll() { unregisterDataHandler({ appName: 'apm' }); @@ -52,6 +53,7 @@ const withCore = makeDecorator({ }, }, } as unknown) as ObservabilityPublicPluginsStart, + observabilityRuleRegistry: createObservabilityRuleRegistryMock(), }} > diff --git a/x-pack/plugins/observability/public/plugin.ts b/x-pack/plugins/observability/public/plugin.ts index cd3cb66187c6f..491eb36d01ac0 100644 --- a/x-pack/plugins/observability/public/plugin.ts +++ b/x-pack/plugins/observability/public/plugin.ts @@ -7,7 +7,11 @@ import { BehaviorSubject } from 'rxjs'; import { i18n } from '@kbn/i18n'; -import { DataPublicPluginSetup, DataPublicPluginStart } from '../../../../src/plugins/data/public'; +import type { RuleRegistryPublicPluginSetupContract } from '../../rule_registry/public'; +import type { + DataPublicPluginSetup, + DataPublicPluginStart, +} from '../../../../src/plugins/data/public'; import { AppMountParameters, AppUpdater, @@ -17,17 +21,23 @@ import { PluginInitializerContext, CoreStart, } from '../../../../src/core/public'; -import { HomePublicPluginSetup, HomePublicPluginStart } from '../../../../src/plugins/home/public'; +import type { + HomePublicPluginSetup, + HomePublicPluginStart, +} from '../../../../src/plugins/home/public'; import { registerDataHandler } from './data_handler'; import { toggleOverviewLinkInNav } from './toggle_overview_link_in_nav'; -import { LensPublicStart } from '../../lens/public'; +import type { LensPublicStart } from '../../lens/public'; +import { createCallObservabilityApi } from './services/call_observability_api'; +import { observabilityRuleRegistrySettings } from '../common/observability_rule_registry'; +import { FormatterRuleRegistry } from './rules/formatter_rule_registry'; -export interface ObservabilityPublicSetup { - dashboard: { register: typeof registerDataHandler }; -} +export type ObservabilityPublicSetup = ReturnType; +export type ObservabilityRuleRegistry = ObservabilityPublicSetup['ruleRegistry']; export interface ObservabilityPublicPluginsSetup { data: DataPublicPluginSetup; + ruleRegistry: RuleRegistryPublicPluginSetupContract; home?: HomePublicPluginSetup; } @@ -52,22 +62,36 @@ export class Plugin constructor(context: PluginInitializerContext) {} public setup( - core: CoreSetup, - plugins: ObservabilityPublicPluginsSetup + coreSetup: CoreSetup, + pluginsSetup: ObservabilityPublicPluginsSetup ) { const category = DEFAULT_APP_CATEGORIES.observability; const euiIconType = 'logoObservability'; + + createCallObservabilityApi(coreSetup.http); + + const observabilityRuleRegistry = pluginsSetup.ruleRegistry.registry.create({ + ...observabilityRuleRegistrySettings, + ctor: FormatterRuleRegistry, + }); + const mount = async (params: AppMountParameters) => { // Load application bundle const { renderApp } = await import('./application'); // Get start services - const [coreStart, startPlugins] = await core.getStartServices(); + const [coreStart, pluginsStart] = await coreSetup.getStartServices(); - return renderApp(coreStart, startPlugins, params); + return renderApp({ + core: coreStart, + plugins: pluginsStart, + appMountParameters: params, + observabilityRuleRegistry, + }); }; + const updater$ = this.appUpdater$; - core.application.register({ + coreSetup.application.register({ id: 'observability-overview', title: 'Overview', appRoute: '/app/observability', @@ -78,8 +102,8 @@ export class Plugin updater$, }); - if (core.uiSettings.get('observability:enableAlertingExperience')) { - core.application.register({ + if (coreSetup.uiSettings.get('observability:enableAlertingExperience')) { + coreSetup.application.register({ id: 'observability-alerts', title: 'Alerts', appRoute: '/app/observability/alerts', @@ -90,7 +114,7 @@ export class Plugin updater$, }); - core.application.register({ + coreSetup.application.register({ id: 'observability-cases', title: 'Cases', appRoute: '/app/observability/cases', @@ -102,8 +126,8 @@ export class Plugin }); } - if (plugins.home) { - plugins.home.featureCatalogue.registerSolution({ + if (pluginsSetup.home) { + pluginsSetup.home.featureCatalogue.registerSolution({ id: 'observability', title: i18n.translate('xpack.observability.featureCatalogueTitle', { defaultMessage: 'Observability', @@ -134,6 +158,7 @@ export class Plugin return { dashboard: { register: registerDataHandler }, + ruleRegistry: observabilityRuleRegistry, }; } public start({ application }: CoreStart) { diff --git a/x-pack/plugins/observability/public/routes/index.tsx b/x-pack/plugins/observability/public/routes/index.tsx index 49cc55832dcf2..3e5c3ddc553ef 100644 --- a/x-pack/plugins/observability/public/routes/index.tsx +++ b/x-pack/plugins/observability/public/routes/index.tsx @@ -104,6 +104,7 @@ export const routes = { query: t.partial({ rangeFrom: t.string, rangeTo: t.string, + kuery: t.string, refreshPaused: jsonRt.pipe(t.boolean), refreshInterval: jsonRt.pipe(t.number), }), diff --git a/x-pack/plugins/observability/public/rules/formatter_rule_registry.ts b/x-pack/plugins/observability/public/rules/formatter_rule_registry.ts new file mode 100644 index 0000000000000..87e6b3c324634 --- /dev/null +++ b/x-pack/plugins/observability/public/rules/formatter_rule_registry.ts @@ -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 + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ +import type { RuleType } from '../../../rule_registry/public'; +import type { BaseRuleFieldMap, OutputOfFieldMap } from '../../../rule_registry/common'; +import { RuleRegistry } from '../../../rule_registry/public'; + +type AlertTypeOf = OutputOfFieldMap; + +type FormattableRuleType = RuleType & { + format?: (options: { + alert: AlertTypeOf; + }) => { + reason?: string; + link?: string; + }; +}; + +export class FormatterRuleRegistry extends RuleRegistry< + TFieldMap, + FormattableRuleType +> {} diff --git a/x-pack/plugins/observability/public/rules/observability_rule_registry_mock.ts b/x-pack/plugins/observability/public/rules/observability_rule_registry_mock.ts new file mode 100644 index 0000000000000..939e3a3608f8b --- /dev/null +++ b/x-pack/plugins/observability/public/rules/observability_rule_registry_mock.ts @@ -0,0 +1,17 @@ +/* + * 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 { ObservabilityRuleRegistry } from '../plugin'; + +const createRuleRegistryMock = () => ({ + registerType: () => {}, + getTypeByRuleId: () => {}, + create: () => createRuleRegistryMock(), +}); + +export const createObservabilityRuleRegistryMock = () => + createRuleRegistryMock() as ObservabilityRuleRegistry & ReturnType; diff --git a/x-pack/plugins/observability/public/services/call_observability_api/index.ts b/x-pack/plugins/observability/public/services/call_observability_api/index.ts new file mode 100644 index 0000000000000..c87a97fb1dc8a --- /dev/null +++ b/x-pack/plugins/observability/public/services/call_observability_api/index.ts @@ -0,0 +1,30 @@ +/* + * 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 { formatRequest } from '@kbn/server-route-repository/target/format_request'; +import type { HttpSetup } from 'kibana/public'; +import type { AbstractObservabilityClient, ObservabilityClient } from './types'; + +export let callObservabilityApi: ObservabilityClient = () => { + throw new Error('callObservabilityApi has not been initialized via createCallObservabilityApi'); +}; + +export function createCallObservabilityApi(http: HttpSetup) { + const client: AbstractObservabilityClient = (options) => { + const { params: { path, body, query } = {}, endpoint, ...rest } = options; + + const { method, pathname } = formatRequest(endpoint, path); + + return http[method](pathname, { + ...rest, + body, + query, + }); + }; + + callObservabilityApi = client; +} diff --git a/x-pack/plugins/observability/public/services/call_observability_api/types.ts b/x-pack/plugins/observability/public/services/call_observability_api/types.ts new file mode 100644 index 0000000000000..8722aecd90800 --- /dev/null +++ b/x-pack/plugins/observability/public/services/call_observability_api/types.ts @@ -0,0 +1,34 @@ +/* + * 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 type { RouteRepositoryClient } from '@kbn/server-route-repository'; +import { HttpFetchOptions } from 'kibana/public'; +import type { + AbstractObservabilityServerRouteRepository, + ObservabilityServerRouteRepository, + ObservabilityAPIReturnType, + // eslint-disable-next-line @kbn/eslint/no-restricted-paths +} from '../../../server'; + +export type ObservabilityClientOptions = Omit< + HttpFetchOptions, + 'query' | 'body' | 'pathname' | 'signal' +> & { + signal: AbortSignal | null; +}; + +export type AbstractObservabilityClient = RouteRepositoryClient< + AbstractObservabilityServerRouteRepository, + ObservabilityClientOptions & { params?: Record } +>; + +export type ObservabilityClient = RouteRepositoryClient< + ObservabilityServerRouteRepository, + ObservabilityClientOptions +>; + +export { ObservabilityAPIReturnType }; diff --git a/x-pack/plugins/observability/public/utils/date.ts b/x-pack/plugins/observability/public/utils/date.ts index a91a9f519a4f3..b694bd61d39a9 100644 --- a/x-pack/plugins/observability/public/utils/date.ts +++ b/x-pack/plugins/observability/public/utils/date.ts @@ -7,9 +7,33 @@ import datemath from '@elastic/datemath'; -export function getAbsoluteTime(range: string, opts = {}) { +export function getAbsoluteTime(range: string, opts: Parameters[1] = {}) { const parsed = datemath.parse(range, opts); if (parsed) { return parsed.valueOf(); } } + +export function getAbsoluteDateRange({ + rangeFrom, + rangeTo, +}: { + rangeFrom?: string; + rangeTo?: string; +}) { + if (!rangeFrom || !rangeTo) { + return { start: undefined, end: undefined }; + } + + const absoluteStart = getAbsoluteTime(rangeFrom); + const absoluteEnd = getAbsoluteTime(rangeTo, { roundUp: true }); + + if (!absoluteStart || !absoluteEnd) { + throw new Error('Could not parse date range'); + } + + return { + start: new Date(absoluteStart).toISOString(), + end: new Date(absoluteEnd).toISOString(), + }; +} diff --git a/x-pack/plugins/observability/public/utils/test_helper.tsx b/x-pack/plugins/observability/public/utils/test_helper.tsx index 885303ea0c54b..97916b414330f 100644 --- a/x-pack/plugins/observability/public/utils/test_helper.tsx +++ b/x-pack/plugins/observability/public/utils/test_helper.tsx @@ -15,6 +15,7 @@ import translations from '../../../translations/translations/ja-JP.json'; import { PluginContext } from '../context/plugin_context'; import { ObservabilityPublicPluginsStart } from '../plugin'; import { EuiThemeProvider } from '../../../../../src/plugins/kibana_react/common'; +import { createObservabilityRuleRegistryMock } from '../rules/observability_rule_registry_mock'; const appMountParameters = ({ setHeaderActionMenu: () => {} } as unknown) as AppMountParameters; @@ -34,11 +35,15 @@ const plugins = ({ data: { query: { timefilter: { timefilter: { setTime: jest.fn() } } } }, } as unknown) as ObservabilityPublicPluginsStart; +const observabilityRuleRegistry = createObservabilityRuleRegistryMock(); + export const render = (component: React.ReactNode) => { return testLibRender( - + {component} diff --git a/x-pack/plugins/observability/server/index.ts b/x-pack/plugins/observability/server/index.ts index 2676e40a4902f..6785436042f97 100644 --- a/x-pack/plugins/observability/server/index.ts +++ b/x-pack/plugins/observability/server/index.ts @@ -11,6 +11,9 @@ import { ObservabilityPlugin, ObservabilityPluginSetup } from './plugin'; import { createOrUpdateIndex, Mappings } from './utils/create_or_update_index'; import { ScopedAnnotationsClient } from './lib/annotations/bootstrap_annotations'; import { unwrapEsResponse, WrappedElasticsearchClientError } from './utils/unwrap_es_response'; +export { rangeQuery, kqlQuery } from './utils/queries'; + +export * from './types'; export const config = { schema: schema.object({ diff --git a/x-pack/plugins/observability/server/lib/annotations/bootstrap_annotations.ts b/x-pack/plugins/observability/server/lib/annotations/bootstrap_annotations.ts index 268e8e027736a..c4a44f47ecf09 100644 --- a/x-pack/plugins/observability/server/lib/annotations/bootstrap_annotations.ts +++ b/x-pack/plugins/observability/server/lib/annotations/bootstrap_annotations.ts @@ -5,11 +5,16 @@ * 2.0. */ -import { CoreSetup, PluginInitializerContext, KibanaRequest } from 'kibana/server'; +import { + CoreSetup, + PluginInitializerContext, + KibanaRequest, + RequestHandlerContext, +} from 'kibana/server'; +import { LicensingApiRequestHandlerContext } from '../../../../licensing/server'; import { PromiseReturnType } from '../../../typings/common'; import { createAnnotationsClient } from './create_annotations_client'; import { registerAnnotationAPIs } from './register_annotation_apis'; -import type { ObservabilityRequestHandlerContext } from '../../types'; interface Params { index: string; @@ -35,7 +40,7 @@ export async function bootstrapAnnotations({ index, core, context }: Params) { return { getScopedAnnotationsClient: ( - requestContext: ObservabilityRequestHandlerContext, + requestContext: RequestHandlerContext & { licensing: LicensingApiRequestHandlerContext }, request: KibanaRequest ) => { return createAnnotationsClient({ diff --git a/x-pack/plugins/observability/server/lib/rules/get_top_alerts.ts b/x-pack/plugins/observability/server/lib/rules/get_top_alerts.ts new file mode 100644 index 0000000000000..0045c0f0c6757 --- /dev/null +++ b/x-pack/plugins/observability/server/lib/rules/get_top_alerts.ts @@ -0,0 +1,55 @@ +/* + * 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 { Required } from 'utility-types'; +import { ObservabilityRuleRegistryClient } from '../../types'; +import { kqlQuery, rangeQuery } from '../../utils/queries'; + +export async function getTopAlerts({ + ruleRegistryClient, + start, + end, + kuery, + size, +}: { + ruleRegistryClient: ObservabilityRuleRegistryClient; + start: number; + end: number; + kuery?: string; + size: number; +}) { + const response = await ruleRegistryClient.search({ + body: { + query: { + bool: { + filter: [...rangeQuery(start, end), ...kqlQuery(kuery)], + }, + }, + fields: ['*'], + collapse: { + field: 'kibana.rac.alert.uuid', + }, + size, + sort: { + '@timestamp': 'desc', + }, + _source: false, + }, + }); + + return response.events.map((event) => { + return event as Required< + typeof event, + | 'rule.id' + | 'rule.name' + | 'kibana.rac.alert.start' + | 'event.action' + | 'rule.category' + | 'rule.name' + | 'kibana.rac.alert.duration.us' + >; + }); +} diff --git a/x-pack/plugins/observability/server/plugin.ts b/x-pack/plugins/observability/server/plugin.ts index c59b4dbe373dd..b167600e788a4 100644 --- a/x-pack/plugins/observability/server/plugin.ts +++ b/x-pack/plugins/observability/server/plugin.ts @@ -6,7 +6,6 @@ */ import { PluginInitializerContext, Plugin, CoreSetup } from 'src/core/server'; -import { pickWithPatterns } from '../../rule_registry/server'; import { ObservabilityConfig } from '.'; import { bootstrapAnnotations, @@ -15,9 +14,12 @@ import { } from './lib/annotations/bootstrap_annotations'; import type { RuleRegistryPluginSetupContract } from '../../rule_registry/server'; import { uiSettings } from './ui_settings'; -import { ecsFieldMap } from '../../rule_registry/server'; +import { registerRoutes } from './routes/register_routes'; +import { getGlobalObservabilityServerRouteRepository } from './routes/get_global_observability_server_route_repository'; +import { observabilityRuleRegistrySettings } from '../common/observability_rule_registry'; export type ObservabilityPluginSetup = ReturnType; +export type ObservabilityRuleRegistry = ObservabilityPluginSetup['ruleRegistry']; export class ObservabilityPlugin implements Plugin { constructor(private readonly initContext: PluginInitializerContext) { @@ -48,17 +50,26 @@ export class ObservabilityPlugin implements Plugin { }); } + const observabilityRuleRegistry = plugins.ruleRegistry.create( + observabilityRuleRegistrySettings + ); + + registerRoutes({ + core: { + setup: core, + start: () => core.getStartServices().then(([coreStart]) => coreStart), + }, + ruleRegistry: observabilityRuleRegistry, + logger: this.initContext.logger.get(), + repository: getGlobalObservabilityServerRouteRepository(), + }); + return { getScopedAnnotationsClient: async (...args: Parameters) => { const api = await annotationsApiPromise; return api?.getScopedAnnotationsClient(...args); }, - ruleRegistry: plugins.ruleRegistry.create({ - name: 'observability', - fieldMap: { - ...pickWithPatterns(ecsFieldMap, 'host.name', 'service.name'), - }, - }), + ruleRegistry: observabilityRuleRegistry, }; } diff --git a/x-pack/plugins/observability/server/routes/create_observability_server_route.ts b/x-pack/plugins/observability/server/routes/create_observability_server_route.ts new file mode 100644 index 0000000000000..6a3a29028b2a4 --- /dev/null +++ b/x-pack/plugins/observability/server/routes/create_observability_server_route.ts @@ -0,0 +1,13 @@ +/* + * 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 { createServerRouteFactory } from '@kbn/server-route-repository'; +import { ObservabilityRouteCreateOptions, ObservabilityRouteHandlerResources } from './types'; + +export const createObservabilityServerRoute = createServerRouteFactory< + ObservabilityRouteHandlerResources, + ObservabilityRouteCreateOptions +>(); diff --git a/x-pack/plugins/observability/server/routes/create_observability_server_route_repository.ts b/x-pack/plugins/observability/server/routes/create_observability_server_route_repository.ts new file mode 100644 index 0000000000000..dab1ecdef7cf8 --- /dev/null +++ b/x-pack/plugins/observability/server/routes/create_observability_server_route_repository.ts @@ -0,0 +1,15 @@ +/* + * 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 { createServerRouteRepository } from '@kbn/server-route-repository'; +import { ObservabilityRouteHandlerResources, ObservabilityRouteCreateOptions } from './types'; + +export const createObservabilityServerRouteRepository = () => { + return createServerRouteRepository< + ObservabilityRouteHandlerResources, + ObservabilityRouteCreateOptions + >(); +}; diff --git a/x-pack/plugins/observability/server/routes/get_global_observability_server_route_repository.ts b/x-pack/plugins/observability/server/routes/get_global_observability_server_route_repository.ts new file mode 100644 index 0000000000000..8bc7c79a40b1b --- /dev/null +++ b/x-pack/plugins/observability/server/routes/get_global_observability_server_route_repository.ts @@ -0,0 +1,15 @@ +/* + * 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 { rulesRouteRepository } from './rules'; + +export function getGlobalObservabilityServerRouteRepository() { + return rulesRouteRepository; +} + +export type ObservabilityServerRouteRepository = ReturnType< + typeof getGlobalObservabilityServerRouteRepository +>; diff --git a/x-pack/plugins/observability/server/routes/register_routes.ts b/x-pack/plugins/observability/server/routes/register_routes.ts new file mode 100644 index 0000000000000..85ee456b812b8 --- /dev/null +++ b/x-pack/plugins/observability/server/routes/register_routes.ts @@ -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 + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ +import * as t from 'io-ts'; +import { + decodeRequestParams, + parseEndpoint, + routeValidationObject, +} from '@kbn/server-route-repository'; +import { CoreSetup, CoreStart, Logger, RouteRegistrar } from 'kibana/server'; +import Boom from '@hapi/boom'; +import { RequestAbortedError } from '@elastic/elasticsearch/lib/errors'; +import { ObservabilityRuleRegistry } from '../plugin'; +import { ObservabilityRequestHandlerContext } from '../types'; +import { AbstractObservabilityServerRouteRepository } from './types'; + +export function registerRoutes({ + ruleRegistry, + repository, + core, + logger, +}: { + core: { + setup: CoreSetup; + start: () => Promise; + }; + ruleRegistry: ObservabilityRuleRegistry; + repository: AbstractObservabilityServerRouteRepository; + logger: Logger; +}) { + const routes = repository.getRoutes(); + + const router = core.setup.http.createRouter(); + + routes.forEach((route) => { + const { endpoint, options, handler, params } = route; + const { pathname, method } = parseEndpoint(endpoint); + + (router[method] as RouteRegistrar)( + { + path: pathname, + validate: routeValidationObject, + options, + }, + async (context, request, response) => { + try { + const decodedParams = decodeRequestParams( + { + params: request.params, + body: request.body, + query: request.query, + }, + params ?? t.strict({}) + ); + + const data = (await handler({ + context, + request, + ruleRegistry, + core, + logger, + params: decodedParams, + })) as any; + + return response.ok({ body: data }); + } catch (error) { + logger.error(error); + const opts = { + statusCode: 500, + body: { + message: error.message, + }, + }; + + if (Boom.isBoom(error)) { + opts.statusCode = error.output.statusCode; + } + + if (error instanceof RequestAbortedError) { + opts.statusCode = 499; + opts.body.message = 'Client closed request'; + } + + return response.custom(opts); + } + } + ); + }); +} diff --git a/x-pack/plugins/observability/server/routes/rules.ts b/x-pack/plugins/observability/server/routes/rules.ts new file mode 100644 index 0000000000000..10f2f50886f07 --- /dev/null +++ b/x-pack/plugins/observability/server/routes/rules.ts @@ -0,0 +1,76 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ +import * as t from 'io-ts'; +import { isoToEpochRt, toNumberRt } from '@kbn/io-ts-utils'; +import Boom from '@hapi/boom'; +import { createObservabilityServerRoute } from './create_observability_server_route'; +import { createObservabilityServerRouteRepository } from './create_observability_server_route_repository'; +import { getTopAlerts } from '../lib/rules/get_top_alerts'; + +const alertsListRoute = createObservabilityServerRoute({ + endpoint: 'GET /api/observability/rules/alerts/top', + options: { + tags: [], + }, + params: t.type({ + query: t.intersection([ + t.type({ + start: isoToEpochRt, + end: isoToEpochRt, + }), + t.partial({ + kuery: t.string, + size: toNumberRt, + }), + ]), + }), + handler: async ({ ruleRegistry, context, params }) => { + const ruleRegistryClient = await ruleRegistry.createScopedRuleRegistryClient({ + context, + alertsClient: context.alerting.getAlertsClient(), + }); + + if (!ruleRegistryClient) { + throw Boom.failedDependency(); + } + + const { + query: { start, end, kuery, size = 100 }, + } = params; + + return getTopAlerts({ + ruleRegistryClient, + start, + end, + kuery, + size, + }); + }, +}); + +const alertsDynamicIndexPatternRoute = createObservabilityServerRoute({ + endpoint: 'GET /api/observability/rules/alerts/dynamic_index_pattern', + options: { + tags: [], + }, + handler: async ({ ruleRegistry, context }) => { + const ruleRegistryClient = await ruleRegistry.createScopedRuleRegistryClient({ + context, + alertsClient: context.alerting.getAlertsClient(), + }); + + if (!ruleRegistryClient) { + throw Boom.failedDependency(); + } + + return ruleRegistryClient.getDynamicIndexPattern(); + }, +}); + +export const rulesRouteRepository = createObservabilityServerRouteRepository() + .add(alertsListRoute) + .add(alertsDynamicIndexPatternRoute); diff --git a/x-pack/plugins/observability/server/routes/types.ts b/x-pack/plugins/observability/server/routes/types.ts new file mode 100644 index 0000000000000..0588bf8df2292 --- /dev/null +++ b/x-pack/plugins/observability/server/routes/types.ts @@ -0,0 +1,56 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ +import * as t from 'io-ts'; +import type { + EndpointOf, + ReturnOf, + ServerRoute, + ServerRouteRepository, +} from '@kbn/server-route-repository'; +import { CoreSetup, CoreStart, KibanaRequest, Logger } from 'kibana/server'; +import { ObservabilityRuleRegistry } from '../plugin'; + +import { ObservabilityServerRouteRepository } from './get_global_observability_server_route_repository'; +import { ObservabilityRequestHandlerContext } from '../types'; + +export { ObservabilityServerRouteRepository }; + +export interface ObservabilityRouteHandlerResources { + core: { + start: () => Promise; + setup: CoreSetup; + }; + ruleRegistry: ObservabilityRuleRegistry; + request: KibanaRequest; + context: ObservabilityRequestHandlerContext; + logger: Logger; +} + +export interface ObservabilityRouteCreateOptions { + options: { + tags: string[]; + }; +} + +export type AbstractObservabilityServerRouteRepository = ServerRouteRepository< + ObservabilityRouteHandlerResources, + ObservabilityRouteCreateOptions, + Record< + string, + ServerRoute< + string, + t.Mixed | undefined, + ObservabilityRouteHandlerResources, + any, + ObservabilityRouteCreateOptions + > + > +>; + +export type ObservabilityAPIReturnType< + TEndpoint extends EndpointOf +> = ReturnOf; diff --git a/x-pack/plugins/observability/server/types.ts b/x-pack/plugins/observability/server/types.ts index 5178bddfd6f74..81b32b3f8db7b 100644 --- a/x-pack/plugins/observability/server/types.ts +++ b/x-pack/plugins/observability/server/types.ts @@ -6,16 +6,32 @@ */ import type { IRouter, RequestHandlerContext } from 'src/core/server'; +import type { AlertingApiRequestHandlerContext } from '../../alerting/server'; +import type { ScopedRuleRegistryClient, FieldMapOf } from '../../rule_registry/server'; import type { LicensingApiRequestHandlerContext } from '../../licensing/server'; +import type { ObservabilityRuleRegistry } from './plugin'; + +export type { + ObservabilityRouteCreateOptions, + ObservabilityRouteHandlerResources, + AbstractObservabilityServerRouteRepository, + ObservabilityServerRouteRepository, + ObservabilityAPIReturnType, +} from './routes/types'; /** * @internal */ export interface ObservabilityRequestHandlerContext extends RequestHandlerContext { licensing: LicensingApiRequestHandlerContext; + alerting: AlertingApiRequestHandlerContext; } /** * @internal */ export type ObservabilityPluginRouter = IRouter; + +export type ObservabilityRuleRegistryClient = ScopedRuleRegistryClient< + FieldMapOf +>; diff --git a/x-pack/plugins/observability/server/utils/queries.ts b/x-pack/plugins/observability/server/utils/queries.ts new file mode 100644 index 0000000000000..584719532ddee --- /dev/null +++ b/x-pack/plugins/observability/server/utils/queries.ts @@ -0,0 +1,32 @@ +/* + * 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 { QueryContainer } from '@elastic/elasticsearch/api/types'; +import { esKuery } from '../../../../../src/plugins/data/server'; + +export function rangeQuery(start: number, end: number, field = '@timestamp'): QueryContainer[] { + return [ + { + range: { + [field]: { + gte: start, + lte: end, + format: 'epoch_millis', + }, + }, + }, + ]; +} + +export function kqlQuery(kql?: string): QueryContainer[] { + if (!kql) { + return []; + } + + const ast = esKuery.fromKueryExpression(kql); + return [esKuery.toElasticsearchQuery(ast)]; +} diff --git a/x-pack/plugins/observability/tsconfig.json b/x-pack/plugins/observability/tsconfig.json index bd37bc09bc130..814d55bfd61fb 100644 --- a/x-pack/plugins/observability/tsconfig.json +++ b/x-pack/plugins/observability/tsconfig.json @@ -23,9 +23,9 @@ { "path": "../../../src/plugins/kibana_utils/tsconfig.json" }, { "path": "../../../src/plugins/usage_collection/tsconfig.json" }, { "path": "../alerting/tsconfig.json" }, - { "path": "../rule_registry/tsconfig.json" }, { "path": "../licensing/tsconfig.json" }, { "path": "../lens/tsconfig.json" }, + { "path": "../rule_registry/tsconfig.json" }, { "path": "../translations/tsconfig.json" } ] } diff --git a/x-pack/plugins/rule_registry/server/rule_registry/defaults/field_map.ts b/x-pack/plugins/rule_registry/common/field_map/base_rule_field_map.ts similarity index 80% rename from x-pack/plugins/rule_registry/server/rule_registry/defaults/field_map.ts rename to x-pack/plugins/rule_registry/common/field_map/base_rule_field_map.ts index db851b7b94c76..22a74212d2ce0 100644 --- a/x-pack/plugins/rule_registry/server/rule_registry/defaults/field_map.ts +++ b/x-pack/plugins/rule_registry/common/field_map/base_rule_field_map.ts @@ -4,11 +4,10 @@ * 2.0; you may not use this file except in compliance with the Elastic License * 2.0. */ +import { ecsFieldMap } from './ecs_field_map'; +import { pickWithPatterns } from '../pick_with_patterns'; -import { ecsFieldMap } from '../../generated/ecs_field_map'; -import { pickWithPatterns } from '../field_map/pick_with_patterns'; - -export const defaultFieldMap = { +export const baseRuleFieldMap = { ...pickWithPatterns( ecsFieldMap, '@timestamp', @@ -31,4 +30,4 @@ export const defaultFieldMap = { 'kibana.rac.alert.status': { type: 'keyword' }, } as const; -export type DefaultFieldMap = typeof defaultFieldMap; +export type BaseRuleFieldMap = typeof baseRuleFieldMap; diff --git a/x-pack/plugins/rule_registry/server/generated/ecs_field_map.ts b/x-pack/plugins/rule_registry/common/field_map/ecs_field_map.ts similarity index 99% rename from x-pack/plugins/rule_registry/server/generated/ecs_field_map.ts rename to x-pack/plugins/rule_registry/common/field_map/ecs_field_map.ts index cd8865a3f57c2..7ed76328ba919 100644 --- a/x-pack/plugins/rule_registry/server/generated/ecs_field_map.ts +++ b/x-pack/plugins/rule_registry/common/field_map/ecs_field_map.ts @@ -5,6 +5,10 @@ * 2.0. */ +/* This file is generated by x-pack/plugins/rule_registry/scripts/generate_ecs_fieldmap/index.js, +do not manually edit +*/ + export const ecsFieldMap = { '@timestamp': { type: 'date', @@ -3372,3 +3376,5 @@ export const ecsFieldMap = { required: false, }, } as const; + +export type EcsFieldMap = typeof ecsFieldMap; diff --git a/x-pack/plugins/rule_registry/common/field_map/es_field_type_map.ts b/x-pack/plugins/rule_registry/common/field_map/es_field_type_map.ts new file mode 100644 index 0000000000000..df41a020d274b --- /dev/null +++ b/x-pack/plugins/rule_registry/common/field_map/es_field_type_map.ts @@ -0,0 +1,23 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ +import * as t from 'io-ts'; + +export const esFieldTypeMap = { + keyword: t.string, + text: t.string, + date: t.string, + boolean: t.boolean, + byte: t.number, + long: t.number, + integer: t.number, + short: t.number, + double: t.number, + float: t.number, + scaled_float: t.number, + unsigned_long: t.number, + flattened: t.record(t.string, t.array(t.string)), +}; diff --git a/x-pack/plugins/rule_registry/common/field_map/index.ts b/x-pack/plugins/rule_registry/common/field_map/index.ts new file mode 100644 index 0000000000000..8db5c2738439b --- /dev/null +++ b/x-pack/plugins/rule_registry/common/field_map/index.ts @@ -0,0 +1,12 @@ +/* + * 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. + */ + +export * from './base_rule_field_map'; +export * from './ecs_field_map'; +export * from './merge_field_maps'; +export * from './runtime_type_from_fieldmap'; +export * from './types'; diff --git a/x-pack/plugins/rule_registry/server/rule_registry/field_map/merge_field_maps.ts b/x-pack/plugins/rule_registry/common/field_map/merge_field_maps.ts similarity index 89% rename from x-pack/plugins/rule_registry/server/rule_registry/field_map/merge_field_maps.ts rename to x-pack/plugins/rule_registry/common/field_map/merge_field_maps.ts index e15b228b0f287..124de243352ea 100644 --- a/x-pack/plugins/rule_registry/server/rule_registry/field_map/merge_field_maps.ts +++ b/x-pack/plugins/rule_registry/common/field_map/merge_field_maps.ts @@ -4,8 +4,7 @@ * 2.0; you may not use this file except in compliance with the Elastic License * 2.0. */ -import util from 'util'; -import { FieldMap } from '../types'; +import { FieldMap } from './types'; export function mergeFieldMaps( first: T1, @@ -39,7 +38,7 @@ export function mergeFieldMaps( if (conflicts.length) { const err = new Error(`Could not merge mapping due to conflicts`); - Object.assign(err, { conflicts: util.inspect(conflicts, { depth: null }) }); + Object.assign(err, { conflicts }); throw err; } diff --git a/x-pack/plugins/rule_registry/server/rule_registry/field_map/runtime_type_from_fieldmap.test.ts b/x-pack/plugins/rule_registry/common/field_map/runtime_type_from_fieldmap.test.ts similarity index 100% rename from x-pack/plugins/rule_registry/server/rule_registry/field_map/runtime_type_from_fieldmap.test.ts rename to x-pack/plugins/rule_registry/common/field_map/runtime_type_from_fieldmap.test.ts diff --git a/x-pack/plugins/rule_registry/server/rule_registry/field_map/runtime_type_from_fieldmap.ts b/x-pack/plugins/rule_registry/common/field_map/runtime_type_from_fieldmap.ts similarity index 67% rename from x-pack/plugins/rule_registry/server/rule_registry/field_map/runtime_type_from_fieldmap.ts rename to x-pack/plugins/rule_registry/common/field_map/runtime_type_from_fieldmap.ts index 6dc557c016d1a..039424d34bfa1 100644 --- a/x-pack/plugins/rule_registry/server/rule_registry/field_map/runtime_type_from_fieldmap.ts +++ b/x-pack/plugins/rule_registry/common/field_map/runtime_type_from_fieldmap.ts @@ -4,11 +4,10 @@ * 2.0; you may not use this file except in compliance with the Elastic License * 2.0. */ - +import { Optional } from 'utility-types'; import { mapValues, pickBy } from 'lodash'; import * as t from 'io-ts'; -import { Mutable, PickByValueExact } from 'utility-types'; -import { FieldMap } from '../types'; +import { FieldMap } from './types'; const esFieldTypeMap = { keyword: t.string, @@ -32,22 +31,6 @@ type EsFieldTypeOf = T extends keyof EsFieldTypeMap ? EsFieldTypeMap[T] : t.UnknownC; -type RequiredKeysOf> = keyof PickByValueExact< - { - [key in keyof T]: T[key]['required']; - }, - true ->; - -type IntersectionTypeOf< - T extends Record -> = t.IntersectionC< - [ - t.TypeC>>, - t.PartialC<{ [key in keyof T]: T[key]['type'] }> - ] ->; - type CastArray> = t.Type< t.TypeOf | Array>, Array>, @@ -71,25 +54,39 @@ const createCastSingleRt = >(type: T): CastSingle => { return new t.Type('castSingle', union.is, union.validate, (a) => (Array.isArray(a) ? a[0] : a)); }; -type MapTypeValues = { - [key in keyof T]: { - required: T[key]['required']; - type: T[key]['array'] extends true - ? CastArray> - : CastSingle>; - }; +type SetOptional = Optional< + T, + { + [key in keyof T]: T[key]['required'] extends true ? never : key; + }[keyof T] +>; + +type OutputOfField = T['array'] extends true + ? Array>> + : t.OutputOf>; + +type TypeOfField = + | t.TypeOf> + | Array>>; + +type OutputOf = { + [key in keyof T]: OutputOfField>; +}; + +type TypeOf = { + [key in keyof T]: TypeOfField>; }; -type FieldMapType = IntersectionTypeOf>; +export type TypeOfFieldMap = TypeOf>; +export type OutputOfFieldMap = OutputOf>; -export type TypeOfFieldMap = Mutable>>; -export type OutputOfFieldMap = Mutable>>; +export type FieldMapType = t.Type, OutputOfFieldMap>; export function runtimeTypeFromFieldMap( fieldMap: TFieldMap ): FieldMapType { function mapToType(fields: FieldMap) { - return mapValues(fields, (field, key) => { + return mapValues(fields, (field) => { const type = field.type in esFieldTypeMap ? esFieldTypeMap[field.type as keyof EsFieldTypeMap] diff --git a/x-pack/plugins/rule_registry/common/field_map/types.ts b/x-pack/plugins/rule_registry/common/field_map/types.ts new file mode 100644 index 0000000000000..3ff68315e93a6 --- /dev/null +++ b/x-pack/plugins/rule_registry/common/field_map/types.ts @@ -0,0 +1,10 @@ +/* + * 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. + */ + +export interface FieldMap { + [key: string]: { type: string; required?: boolean; array?: boolean }; +} diff --git a/x-pack/plugins/rule_registry/common/index.ts b/x-pack/plugins/rule_registry/common/index.ts index 6cc0ccaa93a6d..b614feebc974a 100644 --- a/x-pack/plugins/rule_registry/common/index.ts +++ b/x-pack/plugins/rule_registry/common/index.ts @@ -4,5 +4,5 @@ * 2.0; you may not use this file except in compliance with the Elastic License * 2.0. */ - -export * from './types'; +export * from './field_map'; +export * from './pick_with_patterns'; diff --git a/x-pack/plugins/rule_registry/server/rule_registry/field_map/pick_with_patterns.test.ts b/x-pack/plugins/rule_registry/common/pick_with_patterns/index.test.ts similarity index 97% rename from x-pack/plugins/rule_registry/server/rule_registry/field_map/pick_with_patterns.test.ts rename to x-pack/plugins/rule_registry/common/pick_with_patterns/index.test.ts index 48ba7c873db25..e93254c3c5a66 100644 --- a/x-pack/plugins/rule_registry/server/rule_registry/field_map/pick_with_patterns.test.ts +++ b/x-pack/plugins/rule_registry/common/pick_with_patterns/index.test.ts @@ -5,7 +5,7 @@ * 2.0. */ -import { pickWithPatterns } from './pick_with_patterns'; +import { pickWithPatterns } from './'; describe('pickWithPatterns', () => { const fieldMap = { diff --git a/x-pack/plugins/rule_registry/server/rule_registry/field_map/pick_with_patterns.ts b/x-pack/plugins/rule_registry/common/pick_with_patterns/index.ts similarity index 100% rename from x-pack/plugins/rule_registry/server/rule_registry/field_map/pick_with_patterns.ts rename to x-pack/plugins/rule_registry/common/pick_with_patterns/index.ts diff --git a/x-pack/plugins/rule_registry/common/types.ts b/x-pack/plugins/rule_registry/common/types.ts deleted file mode 100644 index d0d15d86a2248..0000000000000 --- a/x-pack/plugins/rule_registry/common/types.ts +++ /dev/null @@ -1,20 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License - * 2.0; you may not use this file except in compliance with the Elastic License - * 2.0. - */ - -export enum AlertSeverityLevel { - warning = 'warning', - critical = 'critical', -} - -const alertSeverityLevelValues = { - [AlertSeverityLevel.warning]: 70, - [AlertSeverityLevel.critical]: 90, -}; - -export function getAlertSeverityLevelValue(level: AlertSeverityLevel) { - return alertSeverityLevelValues[level]; -} diff --git a/x-pack/plugins/rule_registry/kibana.json b/x-pack/plugins/rule_registry/kibana.json index dea6ef560cc2d..1636f88a21a61 100644 --- a/x-pack/plugins/rule_registry/kibana.json +++ b/x-pack/plugins/rule_registry/kibana.json @@ -7,7 +7,12 @@ "ruleRegistry" ], "requiredPlugins": [ - "alerting" + "alerting", + "triggersActionsUi" ], - "server": true + "server": true, + "ui": true, + "extraPublicDirs": [ + "common" + ] } diff --git a/x-pack/plugins/rule_registry/public/index.ts b/x-pack/plugins/rule_registry/public/index.ts new file mode 100644 index 0000000000000..55662dbcc8bfc --- /dev/null +++ b/x-pack/plugins/rule_registry/public/index.ts @@ -0,0 +1,17 @@ +/* + * 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 { PluginInitializerContext } from 'kibana/public'; +import { Plugin } from './plugin'; + +export { RuleRegistryPublicPluginSetupContract } from './plugin'; +export { RuleRegistry } from './rule_registry'; +export type { IRuleRegistry, RuleType } from './rule_registry/types'; + +export const plugin = (context: PluginInitializerContext) => { + return new Plugin(context); +}; diff --git a/x-pack/plugins/rule_registry/public/plugin.ts b/x-pack/plugins/rule_registry/public/plugin.ts new file mode 100644 index 0000000000000..66c9a4fa224a5 --- /dev/null +++ b/x-pack/plugins/rule_registry/public/plugin.ts @@ -0,0 +1,56 @@ +/* + * 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 type { + CoreSetup, + CoreStart, + Plugin as PluginClass, + PluginInitializerContext, +} from '../../../../src/core/public'; +import type { + PluginSetupContract as AlertingPluginPublicSetupContract, + PluginStartContract as AlertingPluginPublicStartContract, +} from '../../alerting/public'; +import type { + TriggersAndActionsUIPublicPluginSetup, + TriggersAndActionsUIPublicPluginStart, +} from '../../triggers_actions_ui/public'; +import { baseRuleFieldMap } from '../common'; +import { RuleRegistry } from './rule_registry'; + +interface RuleRegistrySetupPlugins { + alerting: AlertingPluginPublicSetupContract; + triggersActionsUi: TriggersAndActionsUIPublicPluginSetup; +} + +interface RuleRegistryStartPlugins { + alerting: AlertingPluginPublicStartContract; + triggersActionsUi: TriggersAndActionsUIPublicPluginStart; +} + +export type RuleRegistryPublicPluginSetupContract = ReturnType; + +export class Plugin + implements PluginClass { + constructor(context: PluginInitializerContext) {} + + public setup(core: CoreSetup, plugins: RuleRegistrySetupPlugins) { + const rootRegistry = new RuleRegistry({ + fieldMap: baseRuleFieldMap, + alertTypeRegistry: plugins.triggersActionsUi.alertTypeRegistry, + }); + return { + registry: rootRegistry, + }; + } + + start(core: CoreStart, plugins: RuleRegistryStartPlugins) { + return { + registerType: plugins.triggersActionsUi.alertTypeRegistry, + }; + } +} diff --git a/x-pack/plugins/rule_registry/public/rule_registry/index.ts b/x-pack/plugins/rule_registry/public/rule_registry/index.ts new file mode 100644 index 0000000000000..ea47fe2e26aad --- /dev/null +++ b/x-pack/plugins/rule_registry/public/rule_registry/index.ts @@ -0,0 +1,47 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import type { BaseRuleFieldMap } from '../../common'; +import type { RuleType, CreateRuleRegistry, RuleRegistryConstructorOptions } from './types'; + +export class RuleRegistry { + protected types: TRuleType[] = []; + + constructor(private readonly options: RuleRegistryConstructorOptions) {} + + getTypes(): TRuleType[] { + return this.types; + } + + getTypeByRuleId(id: string): TRuleType | undefined { + return this.types.find((type) => type.id === id); + } + + registerType(type: TRuleType) { + this.types.push(type); + if (this.options.parent) { + this.options.parent.registerType(type); + } else { + this.options.alertTypeRegistry.register(type); + } + } + + create: CreateRuleRegistry = ({ fieldMap, ctor }) => { + const createOptions = { + fieldMap: { + ...this.options.fieldMap, + ...fieldMap, + }, + alertTypeRegistry: this.options.alertTypeRegistry, + parent: this, + }; + + const registry = ctor ? new ctor(createOptions) : new RuleRegistry(createOptions); + + return registry as any; + }; +} diff --git a/x-pack/plugins/rule_registry/public/rule_registry/types.ts b/x-pack/plugins/rule_registry/public/rule_registry/types.ts new file mode 100644 index 0000000000000..bb16227cbab5f --- /dev/null +++ b/x-pack/plugins/rule_registry/public/rule_registry/types.ts @@ -0,0 +1,63 @@ +/* + * 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 { AlertTypeRegistryContract } from '../../../triggers_actions_ui/public'; +import { BaseRuleFieldMap, FieldMap } from '../../common'; + +export interface RuleRegistryConstructorOptions { + fieldMap: TFieldMap; + alertTypeRegistry: AlertTypeRegistryContract; + parent?: IRuleRegistry; +} + +export type RuleType = Parameters[0]; + +export type RegisterRuleType< + TFieldMap extends BaseRuleFieldMap, + TAdditionalRegisterOptions = {} +> = (type: RuleType & TAdditionalRegisterOptions) => void; + +export type RuleRegistryExtensions = Record< + T, + (...args: any[]) => any +>; + +export type CreateRuleRegistry< + TFieldMap extends BaseRuleFieldMap, + TRuleType extends RuleType, + TInstanceType = undefined +> = < + TNextFieldMap extends FieldMap, + TRuleRegistryInstance extends IRuleRegistry< + TFieldMap & TNextFieldMap, + any + > = TInstanceType extends IRuleRegistry + ? TInstanceType + : IRuleRegistry +>(options: { + fieldMap: TNextFieldMap; + ctor?: new ( + options: RuleRegistryConstructorOptions + ) => TRuleRegistryInstance; +}) => TRuleRegistryInstance; + +export interface IRuleRegistry< + TFieldMap extends BaseRuleFieldMap, + TRuleType extends RuleType, + TInstanceType = undefined +> { + create: CreateRuleRegistry; + registerType(type: TRuleType): void; + getTypeByRuleId(ruleId: string): TRuleType; + getTypes(): TRuleType[]; +} + +export type FieldMapOfRuleRegistry = TRuleRegistry extends IRuleRegistry< + infer TFieldMap, + any +> + ? TFieldMap + : never; diff --git a/x-pack/plugins/rule_registry/scripts/generate_ecs_fieldmap/index.js b/x-pack/plugins/rule_registry/scripts/generate_ecs_fieldmap/index.js index 6e3a8f7cbe663..6b10ca5f837d5 100644 --- a/x-pack/plugins/rule_registry/scripts/generate_ecs_fieldmap/index.js +++ b/x-pack/plugins/rule_registry/scripts/generate_ecs_fieldmap/index.js @@ -14,36 +14,23 @@ const { mapValues } = require('lodash'); const exists = util.promisify(fs.exists); const readFile = util.promisify(fs.readFile); const writeFile = util.promisify(fs.writeFile); -const mkdir = util.promisify(fs.mkdir); -const rmdir = util.promisify(fs.rmdir); const exec = util.promisify(execCb); const ecsDir = path.resolve(__dirname, '../../../../../../ecs'); -const ecsTemplateFilename = path.join(ecsDir, 'generated/elasticsearch/7/template.json'); -const flatYamlFilename = path.join(ecsDir, 'generated/ecs/ecs_flat.yml'); +const ecsYamlFilename = path.join(ecsDir, 'generated/ecs/ecs_flat.yml'); -const outputDir = path.join(__dirname, '../../server/generated'); +const outputDir = path.join(__dirname, '../../common/field_map'); const outputFieldMapFilename = path.join(outputDir, 'ecs_field_map.ts'); -const outputMappingFilename = path.join(outputDir, 'ecs_mappings.json'); async function generate() { - const allExists = await Promise.all([exists(ecsDir), exists(ecsTemplateFilename)]); - - if (!allExists.every(Boolean)) { + if (!(await exists(ecsYamlFilename))) { throw new Error( - `Directory not found: ${ecsDir} - did you checkout elastic/ecs as a peer of this repo?` + `Directory not found: ${ecsYamlFilename} - did you checkout elastic/ecs as a peer of this repo?` ); } - const [template, flatYaml] = await Promise.all([ - readFile(ecsTemplateFilename, { encoding: 'utf-8' }).then((str) => JSON.parse(str)), - (async () => yaml.safeLoad(await readFile(flatYamlFilename)))(), - ]); - - const mappings = { - properties: template.mappings.properties, - }; + const flatYaml = await yaml.safeLoad(await readFile(ecsYamlFilename)); const fields = mapValues(flatYaml, (description) => { return { @@ -53,25 +40,22 @@ async function generate() { }; }); - const hasOutputDir = await exists(outputDir); - - if (hasOutputDir) { - await rmdir(outputDir, { recursive: true }); - } - - await mkdir(outputDir); - await Promise.all([ writeFile( outputFieldMapFilename, ` +/* This file is generated by x-pack/plugins/rule_registry/scripts/generate_ecs_fieldmap/index.js, +do not manually edit +*/ + export const ecsFieldMap = ${JSON.stringify(fields, null, 2)} as const + + export type EcsFieldMap = typeof ecsFieldMap; `, { encoding: 'utf-8' } ).then(() => { return exec(`node scripts/eslint --fix ${outputFieldMapFilename}`); }), - writeFile(outputMappingFilename, JSON.stringify(mappings, null, 2)), ]); } diff --git a/x-pack/plugins/rule_registry/server/generated/ecs_mappings.json b/x-pack/plugins/rule_registry/server/generated/ecs_mappings.json deleted file mode 100644 index f7cbfc3dfaae3..0000000000000 --- a/x-pack/plugins/rule_registry/server/generated/ecs_mappings.json +++ /dev/null @@ -1,3416 +0,0 @@ -{ - "properties": { - "@timestamp": { - "type": "date" - }, - "agent": { - "properties": { - "build": { - "properties": { - "original": { - "ignore_above": 1024, - "type": "keyword" - } - } - }, - "ephemeral_id": { - "ignore_above": 1024, - "type": "keyword" - }, - "id": { - "ignore_above": 1024, - "type": "keyword" - }, - "name": { - "ignore_above": 1024, - "type": "keyword" - }, - "type": { - "ignore_above": 1024, - "type": "keyword" - }, - "version": { - "ignore_above": 1024, - "type": "keyword" - } - } - }, - "client": { - "properties": { - "address": { - "ignore_above": 1024, - "type": "keyword" - }, - "as": { - "properties": { - "number": { - "type": "long" - }, - "organization": { - "properties": { - "name": { - "fields": { - "text": { - "norms": false, - "type": "text" - } - }, - "ignore_above": 1024, - "type": "keyword" - } - } - } - } - }, - "bytes": { - "type": "long" - }, - "domain": { - "ignore_above": 1024, - "type": "keyword" - }, - "geo": { - "properties": { - "city_name": { - "ignore_above": 1024, - "type": "keyword" - }, - "continent_name": { - "ignore_above": 1024, - "type": "keyword" - }, - "country_iso_code": { - "ignore_above": 1024, - "type": "keyword" - }, - "country_name": { - "ignore_above": 1024, - "type": "keyword" - }, - "location": { - "type": "geo_point" - }, - "name": { - "ignore_above": 1024, - "type": "keyword" - }, - "region_iso_code": { - "ignore_above": 1024, - "type": "keyword" - }, - "region_name": { - "ignore_above": 1024, - "type": "keyword" - } - } - }, - "ip": { - "type": "ip" - }, - "mac": { - "ignore_above": 1024, - "type": "keyword" - }, - "nat": { - "properties": { - "ip": { - "type": "ip" - }, - "port": { - "type": "long" - } - } - }, - "packets": { - "type": "long" - }, - "port": { - "type": "long" - }, - "registered_domain": { - "ignore_above": 1024, - "type": "keyword" - }, - "subdomain": { - "ignore_above": 1024, - "type": "keyword" - }, - "top_level_domain": { - "ignore_above": 1024, - "type": "keyword" - }, - "user": { - "properties": { - "domain": { - "ignore_above": 1024, - "type": "keyword" - }, - "email": { - "ignore_above": 1024, - "type": "keyword" - }, - "full_name": { - "fields": { - "text": { - "norms": false, - "type": "text" - } - }, - "ignore_above": 1024, - "type": "keyword" - }, - "group": { - "properties": { - "domain": { - "ignore_above": 1024, - "type": "keyword" - }, - "id": { - "ignore_above": 1024, - "type": "keyword" - }, - "name": { - "ignore_above": 1024, - "type": "keyword" - } - } - }, - "hash": { - "ignore_above": 1024, - "type": "keyword" - }, - "id": { - "ignore_above": 1024, - "type": "keyword" - }, - "name": { - "fields": { - "text": { - "norms": false, - "type": "text" - } - }, - "ignore_above": 1024, - "type": "keyword" - }, - "roles": { - "ignore_above": 1024, - "type": "keyword" - } - } - } - } - }, - "cloud": { - "properties": { - "account": { - "properties": { - "id": { - "ignore_above": 1024, - "type": "keyword" - }, - "name": { - "ignore_above": 1024, - "type": "keyword" - } - } - }, - "availability_zone": { - "ignore_above": 1024, - "type": "keyword" - }, - "instance": { - "properties": { - "id": { - "ignore_above": 1024, - "type": "keyword" - }, - "name": { - "ignore_above": 1024, - "type": "keyword" - } - } - }, - "machine": { - "properties": { - "type": { - "ignore_above": 1024, - "type": "keyword" - } - } - }, - "project": { - "properties": { - "id": { - "ignore_above": 1024, - "type": "keyword" - }, - "name": { - "ignore_above": 1024, - "type": "keyword" - } - } - }, - "provider": { - "ignore_above": 1024, - "type": "keyword" - }, - "region": { - "ignore_above": 1024, - "type": "keyword" - } - } - }, - "container": { - "properties": { - "id": { - "ignore_above": 1024, - "type": "keyword" - }, - "image": { - "properties": { - "name": { - "ignore_above": 1024, - "type": "keyword" - }, - "tag": { - "ignore_above": 1024, - "type": "keyword" - } - } - }, - "labels": { - "type": "object" - }, - "name": { - "ignore_above": 1024, - "type": "keyword" - }, - "runtime": { - "ignore_above": 1024, - "type": "keyword" - } - } - }, - "destination": { - "properties": { - "address": { - "ignore_above": 1024, - "type": "keyword" - }, - "as": { - "properties": { - "number": { - "type": "long" - }, - "organization": { - "properties": { - "name": { - "fields": { - "text": { - "norms": false, - "type": "text" - } - }, - "ignore_above": 1024, - "type": "keyword" - } - } - } - } - }, - "bytes": { - "type": "long" - }, - "domain": { - "ignore_above": 1024, - "type": "keyword" - }, - "geo": { - "properties": { - "city_name": { - "ignore_above": 1024, - "type": "keyword" - }, - "continent_name": { - "ignore_above": 1024, - "type": "keyword" - }, - "country_iso_code": { - "ignore_above": 1024, - "type": "keyword" - }, - "country_name": { - "ignore_above": 1024, - "type": "keyword" - }, - "location": { - "type": "geo_point" - }, - "name": { - "ignore_above": 1024, - "type": "keyword" - }, - "region_iso_code": { - "ignore_above": 1024, - "type": "keyword" - }, - "region_name": { - "ignore_above": 1024, - "type": "keyword" - } - } - }, - "ip": { - "type": "ip" - }, - "mac": { - "ignore_above": 1024, - "type": "keyword" - }, - "nat": { - "properties": { - "ip": { - "type": "ip" - }, - "port": { - "type": "long" - } - } - }, - "packets": { - "type": "long" - }, - "port": { - "type": "long" - }, - "registered_domain": { - "ignore_above": 1024, - "type": "keyword" - }, - "subdomain": { - "ignore_above": 1024, - "type": "keyword" - }, - "top_level_domain": { - "ignore_above": 1024, - "type": "keyword" - }, - "user": { - "properties": { - "domain": { - "ignore_above": 1024, - "type": "keyword" - }, - "email": { - "ignore_above": 1024, - "type": "keyword" - }, - "full_name": { - "fields": { - "text": { - "norms": false, - "type": "text" - } - }, - "ignore_above": 1024, - "type": "keyword" - }, - "group": { - "properties": { - "domain": { - "ignore_above": 1024, - "type": "keyword" - }, - "id": { - "ignore_above": 1024, - "type": "keyword" - }, - "name": { - "ignore_above": 1024, - "type": "keyword" - } - } - }, - "hash": { - "ignore_above": 1024, - "type": "keyword" - }, - "id": { - "ignore_above": 1024, - "type": "keyword" - }, - "name": { - "fields": { - "text": { - "norms": false, - "type": "text" - } - }, - "ignore_above": 1024, - "type": "keyword" - }, - "roles": { - "ignore_above": 1024, - "type": "keyword" - } - } - } - } - }, - "dll": { - "properties": { - "code_signature": { - "properties": { - "exists": { - "type": "boolean" - }, - "status": { - "ignore_above": 1024, - "type": "keyword" - }, - "subject_name": { - "ignore_above": 1024, - "type": "keyword" - }, - "trusted": { - "type": "boolean" - }, - "valid": { - "type": "boolean" - } - } - }, - "hash": { - "properties": { - "md5": { - "ignore_above": 1024, - "type": "keyword" - }, - "sha1": { - "ignore_above": 1024, - "type": "keyword" - }, - "sha256": { - "ignore_above": 1024, - "type": "keyword" - }, - "sha512": { - "ignore_above": 1024, - "type": "keyword" - } - } - }, - "name": { - "ignore_above": 1024, - "type": "keyword" - }, - "path": { - "ignore_above": 1024, - "type": "keyword" - }, - "pe": { - "properties": { - "architecture": { - "ignore_above": 1024, - "type": "keyword" - }, - "company": { - "ignore_above": 1024, - "type": "keyword" - }, - "description": { - "ignore_above": 1024, - "type": "keyword" - }, - "file_version": { - "ignore_above": 1024, - "type": "keyword" - }, - "imphash": { - "ignore_above": 1024, - "type": "keyword" - }, - "original_file_name": { - "ignore_above": 1024, - "type": "keyword" - }, - "product": { - "ignore_above": 1024, - "type": "keyword" - } - } - } - } - }, - "dns": { - "properties": { - "answers": { - "properties": { - "class": { - "ignore_above": 1024, - "type": "keyword" - }, - "data": { - "ignore_above": 1024, - "type": "keyword" - }, - "name": { - "ignore_above": 1024, - "type": "keyword" - }, - "ttl": { - "type": "long" - }, - "type": { - "ignore_above": 1024, - "type": "keyword" - } - }, - "type": "object" - }, - "header_flags": { - "ignore_above": 1024, - "type": "keyword" - }, - "id": { - "ignore_above": 1024, - "type": "keyword" - }, - "op_code": { - "ignore_above": 1024, - "type": "keyword" - }, - "question": { - "properties": { - "class": { - "ignore_above": 1024, - "type": "keyword" - }, - "name": { - "ignore_above": 1024, - "type": "keyword" - }, - "registered_domain": { - "ignore_above": 1024, - "type": "keyword" - }, - "subdomain": { - "ignore_above": 1024, - "type": "keyword" - }, - "top_level_domain": { - "ignore_above": 1024, - "type": "keyword" - }, - "type": { - "ignore_above": 1024, - "type": "keyword" - } - } - }, - "resolved_ip": { - "type": "ip" - }, - "response_code": { - "ignore_above": 1024, - "type": "keyword" - }, - "type": { - "ignore_above": 1024, - "type": "keyword" - } - } - }, - "ecs": { - "properties": { - "version": { - "ignore_above": 1024, - "type": "keyword" - } - } - }, - "error": { - "properties": { - "code": { - "ignore_above": 1024, - "type": "keyword" - }, - "id": { - "ignore_above": 1024, - "type": "keyword" - }, - "message": { - "norms": false, - "type": "text" - }, - "stack_trace": { - "doc_values": false, - "fields": { - "text": { - "norms": false, - "type": "text" - } - }, - "ignore_above": 1024, - "index": false, - "type": "keyword" - }, - "type": { - "ignore_above": 1024, - "type": "keyword" - } - } - }, - "event": { - "properties": { - "action": { - "ignore_above": 1024, - "type": "keyword" - }, - "category": { - "ignore_above": 1024, - "type": "keyword" - }, - "code": { - "ignore_above": 1024, - "type": "keyword" - }, - "created": { - "type": "date" - }, - "dataset": { - "ignore_above": 1024, - "type": "keyword" - }, - "duration": { - "type": "long" - }, - "end": { - "type": "date" - }, - "hash": { - "ignore_above": 1024, - "type": "keyword" - }, - "id": { - "ignore_above": 1024, - "type": "keyword" - }, - "ingested": { - "type": "date" - }, - "kind": { - "ignore_above": 1024, - "type": "keyword" - }, - "module": { - "ignore_above": 1024, - "type": "keyword" - }, - "original": { - "doc_values": false, - "ignore_above": 1024, - "index": false, - "type": "keyword" - }, - "outcome": { - "ignore_above": 1024, - "type": "keyword" - }, - "provider": { - "ignore_above": 1024, - "type": "keyword" - }, - "reason": { - "ignore_above": 1024, - "type": "keyword" - }, - "reference": { - "ignore_above": 1024, - "type": "keyword" - }, - "risk_score": { - "type": "float" - }, - "risk_score_norm": { - "type": "float" - }, - "sequence": { - "type": "long" - }, - "severity": { - "type": "long" - }, - "start": { - "type": "date" - }, - "timezone": { - "ignore_above": 1024, - "type": "keyword" - }, - "type": { - "ignore_above": 1024, - "type": "keyword" - }, - "url": { - "ignore_above": 1024, - "type": "keyword" - } - } - }, - "file": { - "properties": { - "accessed": { - "type": "date" - }, - "attributes": { - "ignore_above": 1024, - "type": "keyword" - }, - "code_signature": { - "properties": { - "exists": { - "type": "boolean" - }, - "status": { - "ignore_above": 1024, - "type": "keyword" - }, - "subject_name": { - "ignore_above": 1024, - "type": "keyword" - }, - "trusted": { - "type": "boolean" - }, - "valid": { - "type": "boolean" - } - } - }, - "created": { - "type": "date" - }, - "ctime": { - "type": "date" - }, - "device": { - "ignore_above": 1024, - "type": "keyword" - }, - "directory": { - "ignore_above": 1024, - "type": "keyword" - }, - "drive_letter": { - "ignore_above": 1, - "type": "keyword" - }, - "extension": { - "ignore_above": 1024, - "type": "keyword" - }, - "gid": { - "ignore_above": 1024, - "type": "keyword" - }, - "group": { - "ignore_above": 1024, - "type": "keyword" - }, - "hash": { - "properties": { - "md5": { - "ignore_above": 1024, - "type": "keyword" - }, - "sha1": { - "ignore_above": 1024, - "type": "keyword" - }, - "sha256": { - "ignore_above": 1024, - "type": "keyword" - }, - "sha512": { - "ignore_above": 1024, - "type": "keyword" - } - } - }, - "inode": { - "ignore_above": 1024, - "type": "keyword" - }, - "mime_type": { - "ignore_above": 1024, - "type": "keyword" - }, - "mode": { - "ignore_above": 1024, - "type": "keyword" - }, - "mtime": { - "type": "date" - }, - "name": { - "ignore_above": 1024, - "type": "keyword" - }, - "owner": { - "ignore_above": 1024, - "type": "keyword" - }, - "path": { - "fields": { - "text": { - "norms": false, - "type": "text" - } - }, - "ignore_above": 1024, - "type": "keyword" - }, - "pe": { - "properties": { - "architecture": { - "ignore_above": 1024, - "type": "keyword" - }, - "company": { - "ignore_above": 1024, - "type": "keyword" - }, - "description": { - "ignore_above": 1024, - "type": "keyword" - }, - "file_version": { - "ignore_above": 1024, - "type": "keyword" - }, - "imphash": { - "ignore_above": 1024, - "type": "keyword" - }, - "original_file_name": { - "ignore_above": 1024, - "type": "keyword" - }, - "product": { - "ignore_above": 1024, - "type": "keyword" - } - } - }, - "size": { - "type": "long" - }, - "target_path": { - "fields": { - "text": { - "norms": false, - "type": "text" - } - }, - "ignore_above": 1024, - "type": "keyword" - }, - "type": { - "ignore_above": 1024, - "type": "keyword" - }, - "uid": { - "ignore_above": 1024, - "type": "keyword" - }, - "x509": { - "properties": { - "alternative_names": { - "ignore_above": 1024, - "type": "keyword" - }, - "issuer": { - "properties": { - "common_name": { - "ignore_above": 1024, - "type": "keyword" - }, - "country": { - "ignore_above": 1024, - "type": "keyword" - }, - "distinguished_name": { - "ignore_above": 1024, - "type": "keyword" - }, - "locality": { - "ignore_above": 1024, - "type": "keyword" - }, - "organization": { - "ignore_above": 1024, - "type": "keyword" - }, - "organizational_unit": { - "ignore_above": 1024, - "type": "keyword" - }, - "state_or_province": { - "ignore_above": 1024, - "type": "keyword" - } - } - }, - "not_after": { - "type": "date" - }, - "not_before": { - "type": "date" - }, - "public_key_algorithm": { - "ignore_above": 1024, - "type": "keyword" - }, - "public_key_curve": { - "ignore_above": 1024, - "type": "keyword" - }, - "public_key_exponent": { - "doc_values": false, - "index": false, - "type": "long" - }, - "public_key_size": { - "type": "long" - }, - "serial_number": { - "ignore_above": 1024, - "type": "keyword" - }, - "signature_algorithm": { - "ignore_above": 1024, - "type": "keyword" - }, - "subject": { - "properties": { - "common_name": { - "ignore_above": 1024, - "type": "keyword" - }, - "country": { - "ignore_above": 1024, - "type": "keyword" - }, - "distinguished_name": { - "ignore_above": 1024, - "type": "keyword" - }, - "locality": { - "ignore_above": 1024, - "type": "keyword" - }, - "organization": { - "ignore_above": 1024, - "type": "keyword" - }, - "organizational_unit": { - "ignore_above": 1024, - "type": "keyword" - }, - "state_or_province": { - "ignore_above": 1024, - "type": "keyword" - } - } - }, - "version_number": { - "ignore_above": 1024, - "type": "keyword" - } - } - } - } - }, - "group": { - "properties": { - "domain": { - "ignore_above": 1024, - "type": "keyword" - }, - "id": { - "ignore_above": 1024, - "type": "keyword" - }, - "name": { - "ignore_above": 1024, - "type": "keyword" - } - } - }, - "host": { - "properties": { - "architecture": { - "ignore_above": 1024, - "type": "keyword" - }, - "domain": { - "ignore_above": 1024, - "type": "keyword" - }, - "geo": { - "properties": { - "city_name": { - "ignore_above": 1024, - "type": "keyword" - }, - "continent_name": { - "ignore_above": 1024, - "type": "keyword" - }, - "country_iso_code": { - "ignore_above": 1024, - "type": "keyword" - }, - "country_name": { - "ignore_above": 1024, - "type": "keyword" - }, - "location": { - "type": "geo_point" - }, - "name": { - "ignore_above": 1024, - "type": "keyword" - }, - "region_iso_code": { - "ignore_above": 1024, - "type": "keyword" - }, - "region_name": { - "ignore_above": 1024, - "type": "keyword" - } - } - }, - "hostname": { - "ignore_above": 1024, - "type": "keyword" - }, - "id": { - "ignore_above": 1024, - "type": "keyword" - }, - "ip": { - "type": "ip" - }, - "mac": { - "ignore_above": 1024, - "type": "keyword" - }, - "name": { - "ignore_above": 1024, - "type": "keyword" - }, - "os": { - "properties": { - "family": { - "ignore_above": 1024, - "type": "keyword" - }, - "full": { - "fields": { - "text": { - "norms": false, - "type": "text" - } - }, - "ignore_above": 1024, - "type": "keyword" - }, - "kernel": { - "ignore_above": 1024, - "type": "keyword" - }, - "name": { - "fields": { - "text": { - "norms": false, - "type": "text" - } - }, - "ignore_above": 1024, - "type": "keyword" - }, - "platform": { - "ignore_above": 1024, - "type": "keyword" - }, - "type": { - "ignore_above": 1024, - "type": "keyword" - }, - "version": { - "ignore_above": 1024, - "type": "keyword" - } - } - }, - "type": { - "ignore_above": 1024, - "type": "keyword" - }, - "uptime": { - "type": "long" - }, - "user": { - "properties": { - "domain": { - "ignore_above": 1024, - "type": "keyword" - }, - "email": { - "ignore_above": 1024, - "type": "keyword" - }, - "full_name": { - "fields": { - "text": { - "norms": false, - "type": "text" - } - }, - "ignore_above": 1024, - "type": "keyword" - }, - "group": { - "properties": { - "domain": { - "ignore_above": 1024, - "type": "keyword" - }, - "id": { - "ignore_above": 1024, - "type": "keyword" - }, - "name": { - "ignore_above": 1024, - "type": "keyword" - } - } - }, - "hash": { - "ignore_above": 1024, - "type": "keyword" - }, - "id": { - "ignore_above": 1024, - "type": "keyword" - }, - "name": { - "fields": { - "text": { - "norms": false, - "type": "text" - } - }, - "ignore_above": 1024, - "type": "keyword" - }, - "roles": { - "ignore_above": 1024, - "type": "keyword" - } - } - } - } - }, - "http": { - "properties": { - "request": { - "properties": { - "body": { - "properties": { - "bytes": { - "type": "long" - }, - "content": { - "fields": { - "text": { - "norms": false, - "type": "text" - } - }, - "ignore_above": 1024, - "type": "keyword" - } - } - }, - "bytes": { - "type": "long" - }, - "method": { - "ignore_above": 1024, - "type": "keyword" - }, - "mime_type": { - "ignore_above": 1024, - "type": "keyword" - }, - "referrer": { - "ignore_above": 1024, - "type": "keyword" - } - } - }, - "response": { - "properties": { - "body": { - "properties": { - "bytes": { - "type": "long" - }, - "content": { - "fields": { - "text": { - "norms": false, - "type": "text" - } - }, - "ignore_above": 1024, - "type": "keyword" - } - } - }, - "bytes": { - "type": "long" - }, - "mime_type": { - "ignore_above": 1024, - "type": "keyword" - }, - "status_code": { - "type": "long" - } - } - }, - "version": { - "ignore_above": 1024, - "type": "keyword" - } - } - }, - "labels": { - "type": "object" - }, - "log": { - "properties": { - "file": { - "properties": { - "path": { - "ignore_above": 1024, - "type": "keyword" - } - } - }, - "level": { - "ignore_above": 1024, - "type": "keyword" - }, - "logger": { - "ignore_above": 1024, - "type": "keyword" - }, - "origin": { - "properties": { - "file": { - "properties": { - "line": { - "type": "integer" - }, - "name": { - "ignore_above": 1024, - "type": "keyword" - } - } - }, - "function": { - "ignore_above": 1024, - "type": "keyword" - } - } - }, - "original": { - "doc_values": false, - "ignore_above": 1024, - "index": false, - "type": "keyword" - }, - "syslog": { - "properties": { - "facility": { - "properties": { - "code": { - "type": "long" - }, - "name": { - "ignore_above": 1024, - "type": "keyword" - } - } - }, - "priority": { - "type": "long" - }, - "severity": { - "properties": { - "code": { - "type": "long" - }, - "name": { - "ignore_above": 1024, - "type": "keyword" - } - } - } - }, - "type": "object" - } - } - }, - "message": { - "norms": false, - "type": "text" - }, - "network": { - "properties": { - "application": { - "ignore_above": 1024, - "type": "keyword" - }, - "bytes": { - "type": "long" - }, - "community_id": { - "ignore_above": 1024, - "type": "keyword" - }, - "direction": { - "ignore_above": 1024, - "type": "keyword" - }, - "forwarded_ip": { - "type": "ip" - }, - "iana_number": { - "ignore_above": 1024, - "type": "keyword" - }, - "inner": { - "properties": { - "vlan": { - "properties": { - "id": { - "ignore_above": 1024, - "type": "keyword" - }, - "name": { - "ignore_above": 1024, - "type": "keyword" - } - } - } - }, - "type": "object" - }, - "name": { - "ignore_above": 1024, - "type": "keyword" - }, - "packets": { - "type": "long" - }, - "protocol": { - "ignore_above": 1024, - "type": "keyword" - }, - "transport": { - "ignore_above": 1024, - "type": "keyword" - }, - "type": { - "ignore_above": 1024, - "type": "keyword" - }, - "vlan": { - "properties": { - "id": { - "ignore_above": 1024, - "type": "keyword" - }, - "name": { - "ignore_above": 1024, - "type": "keyword" - } - } - } - } - }, - "observer": { - "properties": { - "egress": { - "properties": { - "interface": { - "properties": { - "alias": { - "ignore_above": 1024, - "type": "keyword" - }, - "id": { - "ignore_above": 1024, - "type": "keyword" - }, - "name": { - "ignore_above": 1024, - "type": "keyword" - } - } - }, - "vlan": { - "properties": { - "id": { - "ignore_above": 1024, - "type": "keyword" - }, - "name": { - "ignore_above": 1024, - "type": "keyword" - } - } - }, - "zone": { - "ignore_above": 1024, - "type": "keyword" - } - }, - "type": "object" - }, - "geo": { - "properties": { - "city_name": { - "ignore_above": 1024, - "type": "keyword" - }, - "continent_name": { - "ignore_above": 1024, - "type": "keyword" - }, - "country_iso_code": { - "ignore_above": 1024, - "type": "keyword" - }, - "country_name": { - "ignore_above": 1024, - "type": "keyword" - }, - "location": { - "type": "geo_point" - }, - "name": { - "ignore_above": 1024, - "type": "keyword" - }, - "region_iso_code": { - "ignore_above": 1024, - "type": "keyword" - }, - "region_name": { - "ignore_above": 1024, - "type": "keyword" - } - } - }, - "hostname": { - "ignore_above": 1024, - "type": "keyword" - }, - "ingress": { - "properties": { - "interface": { - "properties": { - "alias": { - "ignore_above": 1024, - "type": "keyword" - }, - "id": { - "ignore_above": 1024, - "type": "keyword" - }, - "name": { - "ignore_above": 1024, - "type": "keyword" - } - } - }, - "vlan": { - "properties": { - "id": { - "ignore_above": 1024, - "type": "keyword" - }, - "name": { - "ignore_above": 1024, - "type": "keyword" - } - } - }, - "zone": { - "ignore_above": 1024, - "type": "keyword" - } - }, - "type": "object" - }, - "ip": { - "type": "ip" - }, - "mac": { - "ignore_above": 1024, - "type": "keyword" - }, - "name": { - "ignore_above": 1024, - "type": "keyword" - }, - "os": { - "properties": { - "family": { - "ignore_above": 1024, - "type": "keyword" - }, - "full": { - "fields": { - "text": { - "norms": false, - "type": "text" - } - }, - "ignore_above": 1024, - "type": "keyword" - }, - "kernel": { - "ignore_above": 1024, - "type": "keyword" - }, - "name": { - "fields": { - "text": { - "norms": false, - "type": "text" - } - }, - "ignore_above": 1024, - "type": "keyword" - }, - "platform": { - "ignore_above": 1024, - "type": "keyword" - }, - "type": { - "ignore_above": 1024, - "type": "keyword" - }, - "version": { - "ignore_above": 1024, - "type": "keyword" - } - } - }, - "product": { - "ignore_above": 1024, - "type": "keyword" - }, - "serial_number": { - "ignore_above": 1024, - "type": "keyword" - }, - "type": { - "ignore_above": 1024, - "type": "keyword" - }, - "vendor": { - "ignore_above": 1024, - "type": "keyword" - }, - "version": { - "ignore_above": 1024, - "type": "keyword" - } - } - }, - "organization": { - "properties": { - "id": { - "ignore_above": 1024, - "type": "keyword" - }, - "name": { - "fields": { - "text": { - "norms": false, - "type": "text" - } - }, - "ignore_above": 1024, - "type": "keyword" - } - } - }, - "package": { - "properties": { - "architecture": { - "ignore_above": 1024, - "type": "keyword" - }, - "build_version": { - "ignore_above": 1024, - "type": "keyword" - }, - "checksum": { - "ignore_above": 1024, - "type": "keyword" - }, - "description": { - "ignore_above": 1024, - "type": "keyword" - }, - "install_scope": { - "ignore_above": 1024, - "type": "keyword" - }, - "installed": { - "type": "date" - }, - "license": { - "ignore_above": 1024, - "type": "keyword" - }, - "name": { - "ignore_above": 1024, - "type": "keyword" - }, - "path": { - "ignore_above": 1024, - "type": "keyword" - }, - "reference": { - "ignore_above": 1024, - "type": "keyword" - }, - "size": { - "type": "long" - }, - "type": { - "ignore_above": 1024, - "type": "keyword" - }, - "version": { - "ignore_above": 1024, - "type": "keyword" - } - } - }, - "process": { - "properties": { - "args": { - "ignore_above": 1024, - "type": "keyword" - }, - "args_count": { - "type": "long" - }, - "code_signature": { - "properties": { - "exists": { - "type": "boolean" - }, - "status": { - "ignore_above": 1024, - "type": "keyword" - }, - "subject_name": { - "ignore_above": 1024, - "type": "keyword" - }, - "trusted": { - "type": "boolean" - }, - "valid": { - "type": "boolean" - } - } - }, - "command_line": { - "fields": { - "text": { - "norms": false, - "type": "text" - } - }, - "ignore_above": 1024, - "type": "keyword" - }, - "entity_id": { - "ignore_above": 1024, - "type": "keyword" - }, - "executable": { - "fields": { - "text": { - "norms": false, - "type": "text" - } - }, - "ignore_above": 1024, - "type": "keyword" - }, - "exit_code": { - "type": "long" - }, - "hash": { - "properties": { - "md5": { - "ignore_above": 1024, - "type": "keyword" - }, - "sha1": { - "ignore_above": 1024, - "type": "keyword" - }, - "sha256": { - "ignore_above": 1024, - "type": "keyword" - }, - "sha512": { - "ignore_above": 1024, - "type": "keyword" - } - } - }, - "name": { - "fields": { - "text": { - "norms": false, - "type": "text" - } - }, - "ignore_above": 1024, - "type": "keyword" - }, - "parent": { - "properties": { - "args": { - "ignore_above": 1024, - "type": "keyword" - }, - "args_count": { - "type": "long" - }, - "code_signature": { - "properties": { - "exists": { - "type": "boolean" - }, - "status": { - "ignore_above": 1024, - "type": "keyword" - }, - "subject_name": { - "ignore_above": 1024, - "type": "keyword" - }, - "trusted": { - "type": "boolean" - }, - "valid": { - "type": "boolean" - } - } - }, - "command_line": { - "fields": { - "text": { - "norms": false, - "type": "text" - } - }, - "ignore_above": 1024, - "type": "keyword" - }, - "entity_id": { - "ignore_above": 1024, - "type": "keyword" - }, - "executable": { - "fields": { - "text": { - "norms": false, - "type": "text" - } - }, - "ignore_above": 1024, - "type": "keyword" - }, - "exit_code": { - "type": "long" - }, - "hash": { - "properties": { - "md5": { - "ignore_above": 1024, - "type": "keyword" - }, - "sha1": { - "ignore_above": 1024, - "type": "keyword" - }, - "sha256": { - "ignore_above": 1024, - "type": "keyword" - }, - "sha512": { - "ignore_above": 1024, - "type": "keyword" - } - } - }, - "name": { - "fields": { - "text": { - "norms": false, - "type": "text" - } - }, - "ignore_above": 1024, - "type": "keyword" - }, - "pe": { - "properties": { - "architecture": { - "ignore_above": 1024, - "type": "keyword" - }, - "company": { - "ignore_above": 1024, - "type": "keyword" - }, - "description": { - "ignore_above": 1024, - "type": "keyword" - }, - "file_version": { - "ignore_above": 1024, - "type": "keyword" - }, - "imphash": { - "ignore_above": 1024, - "type": "keyword" - }, - "original_file_name": { - "ignore_above": 1024, - "type": "keyword" - }, - "product": { - "ignore_above": 1024, - "type": "keyword" - } - } - }, - "pgid": { - "type": "long" - }, - "pid": { - "type": "long" - }, - "ppid": { - "type": "long" - }, - "start": { - "type": "date" - }, - "thread": { - "properties": { - "id": { - "type": "long" - }, - "name": { - "ignore_above": 1024, - "type": "keyword" - } - } - }, - "title": { - "fields": { - "text": { - "norms": false, - "type": "text" - } - }, - "ignore_above": 1024, - "type": "keyword" - }, - "uptime": { - "type": "long" - }, - "working_directory": { - "fields": { - "text": { - "norms": false, - "type": "text" - } - }, - "ignore_above": 1024, - "type": "keyword" - } - } - }, - "pe": { - "properties": { - "architecture": { - "ignore_above": 1024, - "type": "keyword" - }, - "company": { - "ignore_above": 1024, - "type": "keyword" - }, - "description": { - "ignore_above": 1024, - "type": "keyword" - }, - "file_version": { - "ignore_above": 1024, - "type": "keyword" - }, - "imphash": { - "ignore_above": 1024, - "type": "keyword" - }, - "original_file_name": { - "ignore_above": 1024, - "type": "keyword" - }, - "product": { - "ignore_above": 1024, - "type": "keyword" - } - } - }, - "pgid": { - "type": "long" - }, - "pid": { - "type": "long" - }, - "ppid": { - "type": "long" - }, - "start": { - "type": "date" - }, - "thread": { - "properties": { - "id": { - "type": "long" - }, - "name": { - "ignore_above": 1024, - "type": "keyword" - } - } - }, - "title": { - "fields": { - "text": { - "norms": false, - "type": "text" - } - }, - "ignore_above": 1024, - "type": "keyword" - }, - "uptime": { - "type": "long" - }, - "working_directory": { - "fields": { - "text": { - "norms": false, - "type": "text" - } - }, - "ignore_above": 1024, - "type": "keyword" - } - } - }, - "registry": { - "properties": { - "data": { - "properties": { - "bytes": { - "ignore_above": 1024, - "type": "keyword" - }, - "strings": { - "ignore_above": 1024, - "type": "keyword" - }, - "type": { - "ignore_above": 1024, - "type": "keyword" - } - } - }, - "hive": { - "ignore_above": 1024, - "type": "keyword" - }, - "key": { - "ignore_above": 1024, - "type": "keyword" - }, - "path": { - "ignore_above": 1024, - "type": "keyword" - }, - "value": { - "ignore_above": 1024, - "type": "keyword" - } - } - }, - "related": { - "properties": { - "hash": { - "ignore_above": 1024, - "type": "keyword" - }, - "hosts": { - "ignore_above": 1024, - "type": "keyword" - }, - "ip": { - "type": "ip" - }, - "user": { - "ignore_above": 1024, - "type": "keyword" - } - } - }, - "rule": { - "properties": { - "author": { - "ignore_above": 1024, - "type": "keyword" - }, - "category": { - "ignore_above": 1024, - "type": "keyword" - }, - "description": { - "ignore_above": 1024, - "type": "keyword" - }, - "id": { - "ignore_above": 1024, - "type": "keyword" - }, - "license": { - "ignore_above": 1024, - "type": "keyword" - }, - "name": { - "ignore_above": 1024, - "type": "keyword" - }, - "reference": { - "ignore_above": 1024, - "type": "keyword" - }, - "ruleset": { - "ignore_above": 1024, - "type": "keyword" - }, - "uuid": { - "ignore_above": 1024, - "type": "keyword" - }, - "version": { - "ignore_above": 1024, - "type": "keyword" - } - } - }, - "server": { - "properties": { - "address": { - "ignore_above": 1024, - "type": "keyword" - }, - "as": { - "properties": { - "number": { - "type": "long" - }, - "organization": { - "properties": { - "name": { - "fields": { - "text": { - "norms": false, - "type": "text" - } - }, - "ignore_above": 1024, - "type": "keyword" - } - } - } - } - }, - "bytes": { - "type": "long" - }, - "domain": { - "ignore_above": 1024, - "type": "keyword" - }, - "geo": { - "properties": { - "city_name": { - "ignore_above": 1024, - "type": "keyword" - }, - "continent_name": { - "ignore_above": 1024, - "type": "keyword" - }, - "country_iso_code": { - "ignore_above": 1024, - "type": "keyword" - }, - "country_name": { - "ignore_above": 1024, - "type": "keyword" - }, - "location": { - "type": "geo_point" - }, - "name": { - "ignore_above": 1024, - "type": "keyword" - }, - "region_iso_code": { - "ignore_above": 1024, - "type": "keyword" - }, - "region_name": { - "ignore_above": 1024, - "type": "keyword" - } - } - }, - "ip": { - "type": "ip" - }, - "mac": { - "ignore_above": 1024, - "type": "keyword" - }, - "nat": { - "properties": { - "ip": { - "type": "ip" - }, - "port": { - "type": "long" - } - } - }, - "packets": { - "type": "long" - }, - "port": { - "type": "long" - }, - "registered_domain": { - "ignore_above": 1024, - "type": "keyword" - }, - "subdomain": { - "ignore_above": 1024, - "type": "keyword" - }, - "top_level_domain": { - "ignore_above": 1024, - "type": "keyword" - }, - "user": { - "properties": { - "domain": { - "ignore_above": 1024, - "type": "keyword" - }, - "email": { - "ignore_above": 1024, - "type": "keyword" - }, - "full_name": { - "fields": { - "text": { - "norms": false, - "type": "text" - } - }, - "ignore_above": 1024, - "type": "keyword" - }, - "group": { - "properties": { - "domain": { - "ignore_above": 1024, - "type": "keyword" - }, - "id": { - "ignore_above": 1024, - "type": "keyword" - }, - "name": { - "ignore_above": 1024, - "type": "keyword" - } - } - }, - "hash": { - "ignore_above": 1024, - "type": "keyword" - }, - "id": { - "ignore_above": 1024, - "type": "keyword" - }, - "name": { - "fields": { - "text": { - "norms": false, - "type": "text" - } - }, - "ignore_above": 1024, - "type": "keyword" - }, - "roles": { - "ignore_above": 1024, - "type": "keyword" - } - } - } - } - }, - "service": { - "properties": { - "ephemeral_id": { - "ignore_above": 1024, - "type": "keyword" - }, - "id": { - "ignore_above": 1024, - "type": "keyword" - }, - "name": { - "ignore_above": 1024, - "type": "keyword" - }, - "node": { - "properties": { - "name": { - "ignore_above": 1024, - "type": "keyword" - } - } - }, - "state": { - "ignore_above": 1024, - "type": "keyword" - }, - "type": { - "ignore_above": 1024, - "type": "keyword" - }, - "version": { - "ignore_above": 1024, - "type": "keyword" - } - } - }, - "source": { - "properties": { - "address": { - "ignore_above": 1024, - "type": "keyword" - }, - "as": { - "properties": { - "number": { - "type": "long" - }, - "organization": { - "properties": { - "name": { - "fields": { - "text": { - "norms": false, - "type": "text" - } - }, - "ignore_above": 1024, - "type": "keyword" - } - } - } - } - }, - "bytes": { - "type": "long" - }, - "domain": { - "ignore_above": 1024, - "type": "keyword" - }, - "geo": { - "properties": { - "city_name": { - "ignore_above": 1024, - "type": "keyword" - }, - "continent_name": { - "ignore_above": 1024, - "type": "keyword" - }, - "country_iso_code": { - "ignore_above": 1024, - "type": "keyword" - }, - "country_name": { - "ignore_above": 1024, - "type": "keyword" - }, - "location": { - "type": "geo_point" - }, - "name": { - "ignore_above": 1024, - "type": "keyword" - }, - "region_iso_code": { - "ignore_above": 1024, - "type": "keyword" - }, - "region_name": { - "ignore_above": 1024, - "type": "keyword" - } - } - }, - "ip": { - "type": "ip" - }, - "mac": { - "ignore_above": 1024, - "type": "keyword" - }, - "nat": { - "properties": { - "ip": { - "type": "ip" - }, - "port": { - "type": "long" - } - } - }, - "packets": { - "type": "long" - }, - "port": { - "type": "long" - }, - "registered_domain": { - "ignore_above": 1024, - "type": "keyword" - }, - "subdomain": { - "ignore_above": 1024, - "type": "keyword" - }, - "top_level_domain": { - "ignore_above": 1024, - "type": "keyword" - }, - "user": { - "properties": { - "domain": { - "ignore_above": 1024, - "type": "keyword" - }, - "email": { - "ignore_above": 1024, - "type": "keyword" - }, - "full_name": { - "fields": { - "text": { - "norms": false, - "type": "text" - } - }, - "ignore_above": 1024, - "type": "keyword" - }, - "group": { - "properties": { - "domain": { - "ignore_above": 1024, - "type": "keyword" - }, - "id": { - "ignore_above": 1024, - "type": "keyword" - }, - "name": { - "ignore_above": 1024, - "type": "keyword" - } - } - }, - "hash": { - "ignore_above": 1024, - "type": "keyword" - }, - "id": { - "ignore_above": 1024, - "type": "keyword" - }, - "name": { - "fields": { - "text": { - "norms": false, - "type": "text" - } - }, - "ignore_above": 1024, - "type": "keyword" - }, - "roles": { - "ignore_above": 1024, - "type": "keyword" - } - } - } - } - }, - "span": { - "properties": { - "id": { - "ignore_above": 1024, - "type": "keyword" - } - } - }, - "tags": { - "ignore_above": 1024, - "type": "keyword" - }, - "threat": { - "properties": { - "framework": { - "ignore_above": 1024, - "type": "keyword" - }, - "tactic": { - "properties": { - "id": { - "ignore_above": 1024, - "type": "keyword" - }, - "name": { - "ignore_above": 1024, - "type": "keyword" - }, - "reference": { - "ignore_above": 1024, - "type": "keyword" - } - } - }, - "technique": { - "properties": { - "id": { - "ignore_above": 1024, - "type": "keyword" - }, - "name": { - "fields": { - "text": { - "norms": false, - "type": "text" - } - }, - "ignore_above": 1024, - "type": "keyword" - }, - "reference": { - "ignore_above": 1024, - "type": "keyword" - }, - "subtechnique": { - "properties": { - "id": { - "ignore_above": 1024, - "type": "keyword" - }, - "name": { - "fields": { - "text": { - "norms": false, - "type": "text" - } - }, - "ignore_above": 1024, - "type": "keyword" - }, - "reference": { - "ignore_above": 1024, - "type": "keyword" - } - } - } - } - } - } - }, - "tls": { - "properties": { - "cipher": { - "ignore_above": 1024, - "type": "keyword" - }, - "client": { - "properties": { - "certificate": { - "ignore_above": 1024, - "type": "keyword" - }, - "certificate_chain": { - "ignore_above": 1024, - "type": "keyword" - }, - "hash": { - "properties": { - "md5": { - "ignore_above": 1024, - "type": "keyword" - }, - "sha1": { - "ignore_above": 1024, - "type": "keyword" - }, - "sha256": { - "ignore_above": 1024, - "type": "keyword" - } - } - }, - "issuer": { - "ignore_above": 1024, - "type": "keyword" - }, - "ja3": { - "ignore_above": 1024, - "type": "keyword" - }, - "not_after": { - "type": "date" - }, - "not_before": { - "type": "date" - }, - "server_name": { - "ignore_above": 1024, - "type": "keyword" - }, - "subject": { - "ignore_above": 1024, - "type": "keyword" - }, - "supported_ciphers": { - "ignore_above": 1024, - "type": "keyword" - }, - "x509": { - "properties": { - "alternative_names": { - "ignore_above": 1024, - "type": "keyword" - }, - "issuer": { - "properties": { - "common_name": { - "ignore_above": 1024, - "type": "keyword" - }, - "country": { - "ignore_above": 1024, - "type": "keyword" - }, - "distinguished_name": { - "ignore_above": 1024, - "type": "keyword" - }, - "locality": { - "ignore_above": 1024, - "type": "keyword" - }, - "organization": { - "ignore_above": 1024, - "type": "keyword" - }, - "organizational_unit": { - "ignore_above": 1024, - "type": "keyword" - }, - "state_or_province": { - "ignore_above": 1024, - "type": "keyword" - } - } - }, - "not_after": { - "type": "date" - }, - "not_before": { - "type": "date" - }, - "public_key_algorithm": { - "ignore_above": 1024, - "type": "keyword" - }, - "public_key_curve": { - "ignore_above": 1024, - "type": "keyword" - }, - "public_key_exponent": { - "doc_values": false, - "index": false, - "type": "long" - }, - "public_key_size": { - "type": "long" - }, - "serial_number": { - "ignore_above": 1024, - "type": "keyword" - }, - "signature_algorithm": { - "ignore_above": 1024, - "type": "keyword" - }, - "subject": { - "properties": { - "common_name": { - "ignore_above": 1024, - "type": "keyword" - }, - "country": { - "ignore_above": 1024, - "type": "keyword" - }, - "distinguished_name": { - "ignore_above": 1024, - "type": "keyword" - }, - "locality": { - "ignore_above": 1024, - "type": "keyword" - }, - "organization": { - "ignore_above": 1024, - "type": "keyword" - }, - "organizational_unit": { - "ignore_above": 1024, - "type": "keyword" - }, - "state_or_province": { - "ignore_above": 1024, - "type": "keyword" - } - } - }, - "version_number": { - "ignore_above": 1024, - "type": "keyword" - } - } - } - } - }, - "curve": { - "ignore_above": 1024, - "type": "keyword" - }, - "established": { - "type": "boolean" - }, - "next_protocol": { - "ignore_above": 1024, - "type": "keyword" - }, - "resumed": { - "type": "boolean" - }, - "server": { - "properties": { - "certificate": { - "ignore_above": 1024, - "type": "keyword" - }, - "certificate_chain": { - "ignore_above": 1024, - "type": "keyword" - }, - "hash": { - "properties": { - "md5": { - "ignore_above": 1024, - "type": "keyword" - }, - "sha1": { - "ignore_above": 1024, - "type": "keyword" - }, - "sha256": { - "ignore_above": 1024, - "type": "keyword" - } - } - }, - "issuer": { - "ignore_above": 1024, - "type": "keyword" - }, - "ja3s": { - "ignore_above": 1024, - "type": "keyword" - }, - "not_after": { - "type": "date" - }, - "not_before": { - "type": "date" - }, - "subject": { - "ignore_above": 1024, - "type": "keyword" - }, - "x509": { - "properties": { - "alternative_names": { - "ignore_above": 1024, - "type": "keyword" - }, - "issuer": { - "properties": { - "common_name": { - "ignore_above": 1024, - "type": "keyword" - }, - "country": { - "ignore_above": 1024, - "type": "keyword" - }, - "distinguished_name": { - "ignore_above": 1024, - "type": "keyword" - }, - "locality": { - "ignore_above": 1024, - "type": "keyword" - }, - "organization": { - "ignore_above": 1024, - "type": "keyword" - }, - "organizational_unit": { - "ignore_above": 1024, - "type": "keyword" - }, - "state_or_province": { - "ignore_above": 1024, - "type": "keyword" - } - } - }, - "not_after": { - "type": "date" - }, - "not_before": { - "type": "date" - }, - "public_key_algorithm": { - "ignore_above": 1024, - "type": "keyword" - }, - "public_key_curve": { - "ignore_above": 1024, - "type": "keyword" - }, - "public_key_exponent": { - "doc_values": false, - "index": false, - "type": "long" - }, - "public_key_size": { - "type": "long" - }, - "serial_number": { - "ignore_above": 1024, - "type": "keyword" - }, - "signature_algorithm": { - "ignore_above": 1024, - "type": "keyword" - }, - "subject": { - "properties": { - "common_name": { - "ignore_above": 1024, - "type": "keyword" - }, - "country": { - "ignore_above": 1024, - "type": "keyword" - }, - "distinguished_name": { - "ignore_above": 1024, - "type": "keyword" - }, - "locality": { - "ignore_above": 1024, - "type": "keyword" - }, - "organization": { - "ignore_above": 1024, - "type": "keyword" - }, - "organizational_unit": { - "ignore_above": 1024, - "type": "keyword" - }, - "state_or_province": { - "ignore_above": 1024, - "type": "keyword" - } - } - }, - "version_number": { - "ignore_above": 1024, - "type": "keyword" - } - } - } - } - }, - "version": { - "ignore_above": 1024, - "type": "keyword" - }, - "version_protocol": { - "ignore_above": 1024, - "type": "keyword" - } - } - }, - "trace": { - "properties": { - "id": { - "ignore_above": 1024, - "type": "keyword" - } - } - }, - "transaction": { - "properties": { - "id": { - "ignore_above": 1024, - "type": "keyword" - } - } - }, - "url": { - "properties": { - "domain": { - "ignore_above": 1024, - "type": "keyword" - }, - "extension": { - "ignore_above": 1024, - "type": "keyword" - }, - "fragment": { - "ignore_above": 1024, - "type": "keyword" - }, - "full": { - "fields": { - "text": { - "norms": false, - "type": "text" - } - }, - "ignore_above": 1024, - "type": "keyword" - }, - "original": { - "fields": { - "text": { - "norms": false, - "type": "text" - } - }, - "ignore_above": 1024, - "type": "keyword" - }, - "password": { - "ignore_above": 1024, - "type": "keyword" - }, - "path": { - "ignore_above": 1024, - "type": "keyword" - }, - "port": { - "type": "long" - }, - "query": { - "ignore_above": 1024, - "type": "keyword" - }, - "registered_domain": { - "ignore_above": 1024, - "type": "keyword" - }, - "scheme": { - "ignore_above": 1024, - "type": "keyword" - }, - "subdomain": { - "ignore_above": 1024, - "type": "keyword" - }, - "top_level_domain": { - "ignore_above": 1024, - "type": "keyword" - }, - "username": { - "ignore_above": 1024, - "type": "keyword" - } - } - }, - "user": { - "properties": { - "changes": { - "properties": { - "domain": { - "ignore_above": 1024, - "type": "keyword" - }, - "email": { - "ignore_above": 1024, - "type": "keyword" - }, - "full_name": { - "fields": { - "text": { - "norms": false, - "type": "text" - } - }, - "ignore_above": 1024, - "type": "keyword" - }, - "group": { - "properties": { - "domain": { - "ignore_above": 1024, - "type": "keyword" - }, - "id": { - "ignore_above": 1024, - "type": "keyword" - }, - "name": { - "ignore_above": 1024, - "type": "keyword" - } - } - }, - "hash": { - "ignore_above": 1024, - "type": "keyword" - }, - "id": { - "ignore_above": 1024, - "type": "keyword" - }, - "name": { - "fields": { - "text": { - "norms": false, - "type": "text" - } - }, - "ignore_above": 1024, - "type": "keyword" - }, - "roles": { - "ignore_above": 1024, - "type": "keyword" - } - } - }, - "domain": { - "ignore_above": 1024, - "type": "keyword" - }, - "effective": { - "properties": { - "domain": { - "ignore_above": 1024, - "type": "keyword" - }, - "email": { - "ignore_above": 1024, - "type": "keyword" - }, - "full_name": { - "fields": { - "text": { - "norms": false, - "type": "text" - } - }, - "ignore_above": 1024, - "type": "keyword" - }, - "group": { - "properties": { - "domain": { - "ignore_above": 1024, - "type": "keyword" - }, - "id": { - "ignore_above": 1024, - "type": "keyword" - }, - "name": { - "ignore_above": 1024, - "type": "keyword" - } - } - }, - "hash": { - "ignore_above": 1024, - "type": "keyword" - }, - "id": { - "ignore_above": 1024, - "type": "keyword" - }, - "name": { - "fields": { - "text": { - "norms": false, - "type": "text" - } - }, - "ignore_above": 1024, - "type": "keyword" - }, - "roles": { - "ignore_above": 1024, - "type": "keyword" - } - } - }, - "email": { - "ignore_above": 1024, - "type": "keyword" - }, - "full_name": { - "fields": { - "text": { - "norms": false, - "type": "text" - } - }, - "ignore_above": 1024, - "type": "keyword" - }, - "group": { - "properties": { - "domain": { - "ignore_above": 1024, - "type": "keyword" - }, - "id": { - "ignore_above": 1024, - "type": "keyword" - }, - "name": { - "ignore_above": 1024, - "type": "keyword" - } - } - }, - "hash": { - "ignore_above": 1024, - "type": "keyword" - }, - "id": { - "ignore_above": 1024, - "type": "keyword" - }, - "name": { - "fields": { - "text": { - "norms": false, - "type": "text" - } - }, - "ignore_above": 1024, - "type": "keyword" - }, - "roles": { - "ignore_above": 1024, - "type": "keyword" - }, - "target": { - "properties": { - "domain": { - "ignore_above": 1024, - "type": "keyword" - }, - "email": { - "ignore_above": 1024, - "type": "keyword" - }, - "full_name": { - "fields": { - "text": { - "norms": false, - "type": "text" - } - }, - "ignore_above": 1024, - "type": "keyword" - }, - "group": { - "properties": { - "domain": { - "ignore_above": 1024, - "type": "keyword" - }, - "id": { - "ignore_above": 1024, - "type": "keyword" - }, - "name": { - "ignore_above": 1024, - "type": "keyword" - } - } - }, - "hash": { - "ignore_above": 1024, - "type": "keyword" - }, - "id": { - "ignore_above": 1024, - "type": "keyword" - }, - "name": { - "fields": { - "text": { - "norms": false, - "type": "text" - } - }, - "ignore_above": 1024, - "type": "keyword" - }, - "roles": { - "ignore_above": 1024, - "type": "keyword" - } - } - } - } - }, - "user_agent": { - "properties": { - "device": { - "properties": { - "name": { - "ignore_above": 1024, - "type": "keyword" - } - } - }, - "name": { - "ignore_above": 1024, - "type": "keyword" - }, - "original": { - "fields": { - "text": { - "norms": false, - "type": "text" - } - }, - "ignore_above": 1024, - "type": "keyword" - }, - "os": { - "properties": { - "family": { - "ignore_above": 1024, - "type": "keyword" - }, - "full": { - "fields": { - "text": { - "norms": false, - "type": "text" - } - }, - "ignore_above": 1024, - "type": "keyword" - }, - "kernel": { - "ignore_above": 1024, - "type": "keyword" - }, - "name": { - "fields": { - "text": { - "norms": false, - "type": "text" - } - }, - "ignore_above": 1024, - "type": "keyword" - }, - "platform": { - "ignore_above": 1024, - "type": "keyword" - }, - "type": { - "ignore_above": 1024, - "type": "keyword" - }, - "version": { - "ignore_above": 1024, - "type": "keyword" - } - } - }, - "version": { - "ignore_above": 1024, - "type": "keyword" - } - } - }, - "vulnerability": { - "properties": { - "category": { - "ignore_above": 1024, - "type": "keyword" - }, - "classification": { - "ignore_above": 1024, - "type": "keyword" - }, - "description": { - "fields": { - "text": { - "norms": false, - "type": "text" - } - }, - "ignore_above": 1024, - "type": "keyword" - }, - "enumeration": { - "ignore_above": 1024, - "type": "keyword" - }, - "id": { - "ignore_above": 1024, - "type": "keyword" - }, - "reference": { - "ignore_above": 1024, - "type": "keyword" - }, - "report_id": { - "ignore_above": 1024, - "type": "keyword" - }, - "scanner": { - "properties": { - "vendor": { - "ignore_above": 1024, - "type": "keyword" - } - } - }, - "score": { - "properties": { - "base": { - "type": "float" - }, - "environmental": { - "type": "float" - }, - "temporal": { - "type": "float" - }, - "version": { - "ignore_above": 1024, - "type": "keyword" - } - } - }, - "severity": { - "ignore_above": 1024, - "type": "keyword" - } - } - } - } -} \ No newline at end of file diff --git a/x-pack/plugins/rule_registry/server/index.ts b/x-pack/plugins/rule_registry/server/index.ts index 7c46717300819..3d492bb690b05 100644 --- a/x-pack/plugins/rule_registry/server/index.ts +++ b/x-pack/plugins/rule_registry/server/index.ts @@ -11,8 +11,6 @@ import { RuleRegistryPlugin } from './plugin'; export { RuleRegistryPluginSetupContract } from './plugin'; export { createLifecycleRuleTypeFactory } from './rule_registry/rule_type_helpers/create_lifecycle_rule_type_factory'; -export { ecsFieldMap } from './generated/ecs_field_map'; -export { pickWithPatterns } from './rule_registry/field_map/pick_with_patterns'; export { FieldMapOf } from './types'; export { ScopedRuleRegistryClient } from './rule_registry/create_scoped_rule_registry_client/types'; diff --git a/x-pack/plugins/rule_registry/server/plugin.ts b/x-pack/plugins/rule_registry/server/plugin.ts index 9e83d938d508b..dabedc2849d07 100644 --- a/x-pack/plugins/rule_registry/server/plugin.ts +++ b/x-pack/plugins/rule_registry/server/plugin.ts @@ -9,10 +9,10 @@ import { PluginInitializerContext, Plugin, CoreSetup } from 'src/core/server'; import { PluginSetupContract as AlertingPluginSetupContract } from '../../alerting/server'; import { RuleRegistry } from './rule_registry'; import { defaultIlmPolicy } from './rule_registry/defaults/ilm_policy'; -import { defaultFieldMap } from './rule_registry/defaults/field_map'; +import { BaseRuleFieldMap, baseRuleFieldMap } from '../common'; import { RuleRegistryConfig } from '.'; -export type RuleRegistryPluginSetupContract = RuleRegistry; +export type RuleRegistryPluginSetupContract = RuleRegistry; export class RuleRegistryPlugin implements Plugin { constructor(private readonly initContext: PluginInitializerContext) { @@ -31,7 +31,7 @@ export class RuleRegistryPlugin implements Plugin { - const options = { - type: 'alert', - ...(namespace ? { namespace } : {}), - }; - - const pitFinder = savedObjectsClient.createPointInTimeFinder({ - ...options, - }); - - const ruleUuids: string[] = []; - - for await (const response of pitFinder.find()) { - ruleUuids.push(...response.saved_objects.map((object) => object.id)); - } - - await pitFinder.close(); - - return ruleUuids; -}; +import { BaseRuleFieldMap } from '../../../common'; +import { RuleRegistry } from '..'; const createPathReporterError = (either: Either) => { const error = new Error(`Failed to validate alert event`); @@ -49,28 +24,26 @@ const createPathReporterError = (either: Either) => { return error; }; -export function createScopedRuleRegistryClient({ - fieldMap, +export function createScopedRuleRegistryClient({ + ruleUuids, scopedClusterClient, - savedObjectsClient, - namespace, clusterClientAdapter, indexAliasName, indexTarget, logger, + registry, ruleData, }: { - fieldMap: TFieldMap; + ruleUuids: string[]; scopedClusterClient: ScopedClusterClient; - savedObjectsClient: SavedObjectsClientContract; - namespace?: string; clusterClientAdapter: ClusterClientAdapter<{ - body: OutputOfFieldMap; + body: TypeOfFieldMap; index: string; }>; indexAliasName: string; indexTarget: string; logger: Logger; + registry: RuleRegistry; ruleData?: { rule: { id: string; @@ -82,9 +55,9 @@ export function createScopedRuleRegistryClient { - const docRt = runtimeTypeFromFieldMap(fieldMap); + const fieldmapType = registry.getFieldMapType(); - const defaults: Partial> = ruleData + const defaults = ruleData ? { 'rule.uuid': ruleData.rule.uuid, 'rule.id': ruleData.rule.id, @@ -95,12 +68,12 @@ export function createScopedRuleRegistryClient = { + const client: ScopedRuleRegistryClient = { search: async (searchRequest) => { - const ruleUuids = await getRuleUuids({ - savedObjectsClient, - namespace, - }); + const fields = [ + 'rule.id', + ...(searchRequest.body?.fields ? castArray(searchRequest.body.fields) : []), + ]; const response = await scopedClusterClient.asInternalUser.search({ ...searchRequest, @@ -111,10 +84,11 @@ export function createScopedRuleRegistryClient { - const validation = docRt.decode(hit.fields); + const ruleTypeId: string = hit.fields!['rule.id'][0]; + + const registryOfType = registry.getRegistryByRuleTypeId(ruleTypeId); + + if (ruleTypeId && !registryOfType) { + logger.warn( + `Could not find type ${ruleTypeId} in registry, decoding with default type` + ); + } + + const type = registryOfType?.getFieldMapType() ?? fieldmapType; + + const validation = type.decode(hit.fields); if (isLeft(validation)) { const error = createPathReporterError(validation); logger.error(error); return undefined; } - return docRt.encode(validation.right); + return type.encode(validation.right); }) ) as EventsOf, }; }, + getDynamicIndexPattern: async () => { + const indexPatternsFetcher = new IndexPatternsFetcher(scopedClusterClient.asInternalUser); + + const fields = await indexPatternsFetcher.getFieldsForWildcard({ + pattern: indexTarget, + }); + + return { + fields, + timeFieldName: '@timestamp', + title: indexTarget, + }; + }, index: (doc) => { - const validation = docRt.decode({ + const validation = fieldmapType.decode({ ...doc, ...defaults, }); @@ -143,11 +142,14 @@ export function createScopedRuleRegistryClient { const validations = docs.map((doc) => { - return docRt.decode({ + return fieldmapType.decode({ ...doc, ...defaults, }); @@ -170,5 +172,8 @@ export function createScopedRuleRegistryClient + // when creating the client, due to #41693 which will be fixed in 4.2 return client; } diff --git a/x-pack/plugins/rule_registry/server/rule_registry/create_scoped_rule_registry_client/types.ts b/x-pack/plugins/rule_registry/server/rule_registry/create_scoped_rule_registry_client/types.ts index 95aa180709a51..f7b2394fe3a32 100644 --- a/x-pack/plugins/rule_registry/server/rule_registry/create_scoped_rule_registry_client/types.ts +++ b/x-pack/plugins/rule_registry/server/rule_registry/create_scoped_rule_registry_client/types.ts @@ -5,45 +5,51 @@ * 2.0. */ +import { FieldDescriptor } from 'src/plugins/data/server'; import { ESSearchRequest, ESSearchResponse } from 'typings/elasticsearch'; -import { DefaultFieldMap } from '../defaults/field_map'; -import { PatternsUnionOf, PickWithPatterns } from '../field_map/pick_with_patterns'; -import { OutputOfFieldMap } from '../field_map/runtime_type_from_fieldmap'; +import { + PatternsUnionOf, + PickWithPatterns, + OutputOfFieldMap, + BaseRuleFieldMap, +} from '../../../common'; -export type PrepopulatedRuleEventFields = - | 'rule.uuid' - | 'rule.id' - | 'rule.name' - | 'rule.type' - | 'rule.category' - | 'producer'; +export type PrepopulatedRuleEventFields = keyof Pick< + BaseRuleFieldMap, + 'rule.uuid' | 'rule.id' | 'rule.name' | 'rule.category' | 'kibana.rac.producer' +>; -type FieldsOf = +type FieldsOf = | Array<{ field: PatternsUnionOf } | PatternsUnionOf> | PatternsUnionOf; type Fields = Array<{ field: TPattern } | TPattern> | TPattern; -type FieldsESSearchRequest = ESSearchRequest & { +type FieldsESSearchRequest = ESSearchRequest & { body?: { fields: FieldsOf }; }; export type EventsOf< TFieldsESSearchRequest extends ESSearchRequest, - TFieldMap extends DefaultFieldMap + TFieldMap extends BaseRuleFieldMap > = TFieldsESSearchRequest extends { body: { fields: infer TFields } } ? TFields extends Fields ? Array>> : never : never; -export interface ScopedRuleRegistryClient { +export interface ScopedRuleRegistryClient { search>( request: TSearchRequest ): Promise<{ body: ESSearchResponse; events: EventsOf; }>; + getDynamicIndexPattern(): Promise<{ + title: string; + timeFieldName: string; + fields: FieldDescriptor[]; + }>; index(doc: Omit, PrepopulatedRuleEventFields>): void; bulkIndex( doc: Array, PrepopulatedRuleEventFields>> diff --git a/x-pack/plugins/rule_registry/server/rule_registry/field_map/mapping_from_field_map.ts b/x-pack/plugins/rule_registry/server/rule_registry/field_map/mapping_from_field_map.ts index 6e4e13b01d2c5..f1d7126906431 100644 --- a/x-pack/plugins/rule_registry/server/rule_registry/field_map/mapping_from_field_map.ts +++ b/x-pack/plugins/rule_registry/server/rule_registry/field_map/mapping_from_field_map.ts @@ -6,7 +6,8 @@ */ import { set } from '@elastic/safer-lodash-set'; -import { FieldMap, Mappings } from '../types'; +import { FieldMap } from '../../../common'; +import { Mappings } from '../types'; export function mappingFromFieldMap(fieldMap: FieldMap): Mappings { const mappings = { diff --git a/x-pack/plugins/rule_registry/server/rule_registry/index.ts b/x-pack/plugins/rule_registry/server/rule_registry/index.ts index f1d24550ade0a..bbc381f60a809 100644 --- a/x-pack/plugins/rule_registry/server/rule_registry/index.ts +++ b/x-pack/plugins/rule_registry/server/rule_registry/index.ts @@ -7,6 +7,7 @@ import { CoreSetup, Logger, RequestHandlerContext } from 'kibana/server'; import { inspect } from 'util'; +import { AlertsClient } from '../../../alerting/server'; import { SpacesServiceStart } from '../../../spaces/server'; import { ActionVariable, @@ -15,14 +16,19 @@ import { AlertTypeState, } from '../../../alerting/common'; import { createReadySignal, ClusterClientAdapter } from '../../../event_log/server'; -import { FieldMap, ILMPolicy } from './types'; +import { ILMPolicy } from './types'; import { RuleParams, RuleType } from '../types'; -import { mergeFieldMaps } from './field_map/merge_field_maps'; -import { OutputOfFieldMap } from './field_map/runtime_type_from_fieldmap'; +import { + mergeFieldMaps, + TypeOfFieldMap, + FieldMap, + FieldMapType, + BaseRuleFieldMap, + runtimeTypeFromFieldMap, +} from '../../common'; import { mappingFromFieldMap } from './field_map/mapping_from_field_map'; import { PluginSetupContract as AlertingPluginSetupContract } from '../../../alerting/server'; import { createScopedRuleRegistryClient } from './create_scoped_rule_registry_client'; -import { DefaultFieldMap } from './defaults/field_map'; import { ScopedRuleRegistryClient } from './create_scoped_rule_registry_client/types'; interface RuleRegistryOptions { @@ -38,20 +44,25 @@ interface RuleRegistryOptions { writeEnabled: boolean; } -export class RuleRegistry { +export class RuleRegistry { private readonly esAdapter: ClusterClientAdapter<{ - body: OutputOfFieldMap; + body: TypeOfFieldMap; index: string; }>; private readonly children: Array> = []; + private readonly types: Array> = []; + + private readonly fieldmapType: FieldMapType; constructor(private readonly options: RuleRegistryOptions) { const { logger, coreSetup } = options; + this.fieldmapType = runtimeTypeFromFieldMap(options.fieldMap); + const { wait, signal } = createReadySignal(); this.esAdapter = new ClusterClientAdapter<{ - body: OutputOfFieldMap; + body: TypeOfFieldMap; index: string; }>({ wait, @@ -103,6 +114,11 @@ export class RuleRegistry { const templateExists = await this.esAdapter.doesIndexTemplateExist(indexAliasName); + const mappings = mappingFromFieldMap(this.options.fieldMap); + + const esClient = (await this.options.coreSetup.getStartServices())[0].elasticsearch.client + .asInternalUser; + if (!templateExists) { await this.esAdapter.createIndexTemplate(indexAliasName, { index_patterns: [`${indexAliasName}-*`], @@ -114,7 +130,16 @@ export class RuleRegistry { 'sort.field': '@timestamp', 'sort.order': 'desc', }, - mappings: mappingFromFieldMap(this.options.fieldMap), + mappings, + }); + } else { + await esClient.indices.putTemplate({ + name: indexAliasName, + body: { + index_patterns: [`${indexAliasName}-*`], + mappings, + }, + create: false, }); } @@ -128,24 +153,86 @@ export class RuleRegistry { }, }, }); + } else { + const { body: aliases } = (await esClient.indices.getAlias({ + index: indexAliasName, + })) as { body: Record }> }; + + const writeIndex = Object.entries(aliases).find( + ([indexName, alias]) => alias.aliases[indexAliasName]?.is_write_index === true + )![0]; + + const { body: fieldsInWriteIndex } = await esClient.fieldCaps({ + index: writeIndex, + fields: '*', + }); + + const fieldsNotOrDifferentInIndex = Object.entries(this.options.fieldMap).filter( + ([fieldName, descriptor]) => { + return ( + !fieldsInWriteIndex.fields[fieldName] || + !fieldsInWriteIndex.fields[fieldName][descriptor.type] + ); + } + ); + + if (fieldsNotOrDifferentInIndex.length > 0) { + this.options.logger.debug( + `Some fields were not found in write index mapping: ${Object.keys( + Object.fromEntries(fieldsNotOrDifferentInIndex) + ).join(',')}` + ); + this.options.logger.info(`Updating index mapping due to new fields`); + + await esClient.indices.putMapping({ + index: indexAliasName, + body: mappings, + }); + } + } + } + + getFieldMapType() { + return this.fieldmapType; + } + + getRuleTypeById(ruleTypeId: string) { + return this.types.find((type) => type.id === ruleTypeId); + } + + getRegistryByRuleTypeId(ruleTypeId: string): RuleRegistry | undefined { + if (this.getRuleTypeById(ruleTypeId)) { + return this; } + + return this.children.find((child) => child.getRegistryByRuleTypeId(ruleTypeId)); } - createScopedRuleRegistryClient({ + async createScopedRuleRegistryClient({ context, + alertsClient, }: { context: RequestHandlerContext; - }): ScopedRuleRegistryClient | undefined { + alertsClient: AlertsClient; + }): Promise | undefined> { if (!this.options.writeEnabled) { return undefined; } const { indexAliasName, indexTarget } = this.getEsNames(); + const frameworkAlerts = ( + await alertsClient.find({ + options: { + perPage: 1000, + }, + }) + ).data; + return createScopedRuleRegistryClient({ - savedObjectsClient: context.core.savedObjects.getClient({ includedHiddenTypes: ['alert'] }), + ruleUuids: frameworkAlerts.map((frameworkAlert) => frameworkAlert.id), scopedClusterClient: context.core.elasticsearch.client, clusterClientAdapter: this.esAdapter, - fieldMap: this.options.fieldMap, + registry: this, indexAliasName, indexTarget, logger: this.options.logger, @@ -159,6 +246,8 @@ export class RuleRegistry { const { indexAliasName, indexTarget } = this.getEsNames(); + this.types.push(type); + this.options.alertingPluginSetupContract.registerType< AlertTypeParams, AlertTypeState, @@ -168,7 +257,7 @@ export class RuleRegistry { >({ ...type, executor: async (executorOptions) => { - const { services, namespace, alertId, name, tags } = executorOptions; + const { services, alertId, name, tags } = executorOptions; const rule = { id: type.id, @@ -189,13 +278,12 @@ export class RuleRegistry { ...(this.options.writeEnabled ? { scopedRuleRegistryClient: createScopedRuleRegistryClient({ - savedObjectsClient: services.savedObjectsClient, scopedClusterClient: services.scopedClusterClient, + ruleUuids: [rule.uuid], clusterClientAdapter: this.esAdapter, - fieldMap: this.options.fieldMap, + registry: this, indexAliasName, indexTarget, - namespace, ruleData: { producer, rule, diff --git a/x-pack/plugins/rule_registry/server/rule_registry/rule_type_helpers/create_lifecycle_rule_type_factory.ts b/x-pack/plugins/rule_registry/server/rule_registry/rule_type_helpers/create_lifecycle_rule_type_factory.ts index 9c64e85f839bb..65eaf0964cfca 100644 --- a/x-pack/plugins/rule_registry/server/rule_registry/rule_type_helpers/create_lifecycle_rule_type_factory.ts +++ b/x-pack/plugins/rule_registry/server/rule_registry/rule_type_helpers/create_lifecycle_rule_type_factory.ts @@ -7,28 +7,28 @@ import * as t from 'io-ts'; import { isLeft } from 'fp-ts/lib/Either'; import v4 from 'uuid/v4'; +import { Mutable } from 'utility-types'; import { AlertInstance } from '../../../../alerting/server'; import { ActionVariable, AlertInstanceState } from '../../../../alerting/common'; import { RuleParams, RuleType } from '../../types'; -import { DefaultFieldMap } from '../defaults/field_map'; -import { OutputOfFieldMap } from '../field_map/runtime_type_from_fieldmap'; +import { BaseRuleFieldMap, OutputOfFieldMap } from '../../../common'; import { PrepopulatedRuleEventFields } from '../create_scoped_rule_registry_client/types'; import { RuleRegistry } from '..'; -type UserDefinedAlertFields = Omit< +type UserDefinedAlertFields = Omit< OutputOfFieldMap, PrepopulatedRuleEventFields | 'kibana.rac.alert.id' | 'kibana.rac.alert.uuid' | '@timestamp' >; type LifecycleAlertService< - TFieldMap extends DefaultFieldMap, + TFieldMap extends BaseRuleFieldMap, TActionVariable extends ActionVariable > = (alert: { id: string; fields: UserDefinedAlertFields; }) => AlertInstance; -type CreateLifecycleRuleType = < +type CreateLifecycleRuleType = < TRuleParams extends RuleParams, TActionVariable extends ActionVariable >( @@ -52,12 +52,12 @@ const wrappedStateRt = t.type({ }); export function createLifecycleRuleTypeFactory< - TRuleRegistry extends RuleRegistry + TRuleRegistry extends RuleRegistry >(): TRuleRegistry extends RuleRegistry ? CreateLifecycleRuleType : never; -export function createLifecycleRuleTypeFactory(): CreateLifecycleRuleType { +export function createLifecycleRuleTypeFactory(): CreateLifecycleRuleType { return (type) => { return { ...type, @@ -79,7 +79,7 @@ export function createLifecycleRuleTypeFactory(): CreateLifecycleRuleType & { 'kibana.rac.alert.id': string } + UserDefinedAlertFields & { 'kibana.rac.alert.id': string } > = {}; const timestamp = options.startedAt.toISOString(); @@ -113,7 +113,7 @@ export function createLifecycleRuleTypeFactory(): CreateLifecycleRuleType> = { + const alertsDataMap: Record> = { ...currentAlerts, }; @@ -156,7 +156,7 @@ export function createLifecycleRuleTypeFactory(): CreateLifecycleRuleType> = allAlertIds.map( + const eventsToIndex: Array> = allAlertIds.map( (alertId) => { const alertData = alertsDataMap[alertId]; @@ -164,7 +164,7 @@ export function createLifecycleRuleTypeFactory(): CreateLifecycleRuleType = { + const event: Mutable> = { ...alertData, '@timestamp': timestamp, 'event.kind': 'state', diff --git a/x-pack/plugins/rule_registry/server/rule_registry/types.ts b/x-pack/plugins/rule_registry/server/rule_registry/types.ts index f6baf8bcecbd0..ec7293d1c1d4c 100644 --- a/x-pack/plugins/rule_registry/server/rule_registry/types.ts +++ b/x-pack/plugins/rule_registry/server/rule_registry/types.ts @@ -40,5 +40,3 @@ export interface ILMPolicy { >; }; } - -export type FieldMap = Record; diff --git a/x-pack/plugins/rule_registry/server/types.ts b/x-pack/plugins/rule_registry/server/types.ts index e6b53a8558964..dd54046365d98 100644 --- a/x-pack/plugins/rule_registry/server/types.ts +++ b/x-pack/plugins/rule_registry/server/types.ts @@ -16,14 +16,14 @@ import { import { ActionGroup, AlertExecutorOptions } from '../../alerting/server'; import { RuleRegistry } from './rule_registry'; import { ScopedRuleRegistryClient } from './rule_registry/create_scoped_rule_registry_client/types'; -import { DefaultFieldMap } from './rule_registry/defaults/field_map'; +import { BaseRuleFieldMap } from '../common'; export type RuleParams = Type; type TypeOfRuleParams = TypeOf; type RuleExecutorServices< - TFieldMap extends DefaultFieldMap, + TFieldMap extends BaseRuleFieldMap, TActionVariable extends ActionVariable > = AlertExecutorOptions< AlertTypeParams, @@ -48,7 +48,7 @@ type PassthroughAlertExecutorOptions = Pick< >; type RuleExecutorFunction< - TFieldMap extends DefaultFieldMap, + TFieldMap extends BaseRuleFieldMap, TRuleParams extends RuleParams, TActionVariable extends ActionVariable, TAdditionalRuleExecutorServices extends Record @@ -76,7 +76,7 @@ interface RuleTypeBase { } export type RuleType< - TFieldMap extends DefaultFieldMap, + TFieldMap extends BaseRuleFieldMap, TRuleParams extends RuleParams, TActionVariable extends ActionVariable, TAdditionalRuleExecutorServices extends Record = {} diff --git a/x-pack/plugins/rule_registry/tsconfig.json b/x-pack/plugins/rule_registry/tsconfig.json index 2961abe6cfecd..707e1ccb98dad 100644 --- a/x-pack/plugins/rule_registry/tsconfig.json +++ b/x-pack/plugins/rule_registry/tsconfig.json @@ -7,9 +7,10 @@ "declaration": true, "declarationMap": true }, - "include": ["common/**/*", "server/**/*", "../../../typings/**/*"], + "include": ["common/**/*", "server/**/*", "public/**/*", "../../../typings/**/*"], "references": [ { "path": "../../../src/core/tsconfig.json" }, { "path": "../alerting/tsconfig.json" }, + { "path": "../triggers_actions_ui/tsconfig.json" } ] } diff --git a/x-pack/test/apm_api_integration/tests/alerts/rule_registry.ts b/x-pack/test/apm_api_integration/tests/alerts/rule_registry.ts index 97026d126d2a1..8d0b87782ff7c 100644 --- a/x-pack/test/apm_api_integration/tests/alerts/rule_registry.ts +++ b/x-pack/test/apm_api_integration/tests/alerts/rule_registry.ts @@ -7,6 +7,7 @@ import expect from '@kbn/expect'; import { get, merge, omit } from 'lodash'; +import { format } from 'url'; import { FtrProviderContext } from '../../common/ftr_provider_context'; import { registry } from '../../common/registry'; @@ -276,8 +277,11 @@ export default function ApiTest({ getService }: FtrProviderContext) { query: { match_all: {}, }, + size: 1, + sort: { + '@timestamp': 'desc', + }, }, - size: 1, }); expect(beforeDataResponse.body.hits.hits.length).to.be(0); @@ -300,8 +304,11 @@ export default function ApiTest({ getService }: FtrProviderContext) { query: { match_all: {}, }, + size: 1, + sort: { + '@timestamp': 'desc', + }, }, - size: 1, }); expect(afterInitialDataResponse.body.hits.hits.length).to.be(0); @@ -324,8 +331,11 @@ export default function ApiTest({ getService }: FtrProviderContext) { query: { match_all: {}, }, + size: 1, + sort: { + '@timestamp': 'desc', + }, }, - size: 1, }); expect(afterViolatingDataResponse.body.hits.hits.length).to.be(1); @@ -335,33 +345,142 @@ export default function ApiTest({ getService }: FtrProviderContext) { any >; - const toCompare = omit( - alertEvent, + const exclude = [ '@timestamp', 'kibana.rac.alert.start', 'kibana.rac.alert.uuid', - 'rule.uuid' + 'rule.uuid', + ]; + + const toCompare = omit(alertEvent, exclude); + + expect(toCompare).to.eql({ + 'event.action': 'open', + 'event.kind': 'state', + 'kibana.rac.alert.duration.us': 0, + 'kibana.rac.alert.id': 'apm.transaction_error_rate_opbeans-go_request', + 'kibana.rac.alert.status': 'open', + 'kibana.rac.producer': 'apm', + 'kibana.observability.evaluation.threshold': 30, + 'kibana.observability.evaluation.value': 50, + 'processor.event': 'transaction', + 'rule.category': 'Transaction error rate threshold', + 'rule.id': 'apm.transaction_error_rate', + 'rule.name': 'Transaction error rate threshold | opbeans-go', + 'service.name': 'opbeans-go', + tags: ['apm', 'service.name:opbeans-go'], + 'transaction.type': 'request', + }); + + const now = new Date().getTime(); + + const { body: topAlerts, status: topAlertStatus } = await supertest + .get( + format({ + pathname: '/api/observability/rules/alerts/top', + query: { + start: new Date(now - 30 * 60 * 1000).toISOString(), + end: new Date(now).toISOString(), + }, + }) + ) + .set('kbn-xsrf', 'foo'); + + expect(topAlertStatus).to.eql(200); + + expect(topAlerts.length).to.be.greaterThan(0); + + expect(omit(topAlerts[0], exclude)).to.eql(toCompare); + + await es.bulk({ + index: APM_TRANSACTION_INDEX_NAME, + body: [ + { index: {} }, + createTransactionEvent({ + event: { + outcome: 'success', + }, + }), + { index: {} }, + createTransactionEvent({ + event: { + outcome: 'success', + }, + }), + ], + refresh: true, + }); + + alert = await waitUntilNextExecution(alert); + + const afterRecoveryResponse = await es.search({ + index: ALERTS_INDEX_TARGET, + body: { + query: { + match_all: {}, + }, + size: 1, + sort: { + '@timestamp': 'desc', + }, + }, + }); + + expect(afterRecoveryResponse.body.hits.hits.length).to.be(1); + + const recoveredAlertEvent = afterRecoveryResponse.body.hits.hits[0]._source as Record< + string, + any + >; + + expect(recoveredAlertEvent['kibana.rac.alert.status']).to.eql('closed'); + expect(recoveredAlertEvent['kibana.rac.alert.duration.us']).to.be.greaterThan(0); + expect(new Date(recoveredAlertEvent['kibana.rac.alert.end']).getTime()).to.be.greaterThan( + 0 ); - expectSnapshot(toCompare).toMatchInline(` - Object { - "event.action": "open", - "event.kind": "state", - "kibana.rac.alert.duration.us": 0, - "kibana.rac.alert.id": "apm.transaction_error_rate_opbeans-go_request", - "kibana.rac.alert.status": "open", - "kibana.rac.producer": "apm", - "rule.category": "Transaction error rate threshold", - "rule.id": "apm.transaction_error_rate", - "rule.name": "Transaction error rate threshold | opbeans-go", - "service.name": "opbeans-go", - "tags": Array [ - "apm", - "service.name:opbeans-go", - ], - "transaction.type": "request", - } - `); + expect( + omit( + recoveredAlertEvent, + exclude.concat(['kibana.rac.alert.duration.us', 'kibana.rac.alert.end']) + ) + ).to.eql({ + 'event.action': 'close', + 'event.kind': 'state', + 'kibana.rac.alert.id': 'apm.transaction_error_rate_opbeans-go_request', + 'kibana.rac.alert.status': 'closed', + 'kibana.rac.producer': 'apm', + 'kibana.observability.evaluation.threshold': 30, + 'kibana.observability.evaluation.value': 50, + 'processor.event': 'transaction', + 'rule.category': 'Transaction error rate threshold', + 'rule.id': 'apm.transaction_error_rate', + 'rule.name': 'Transaction error rate threshold | opbeans-go', + 'service.name': 'opbeans-go', + tags: ['apm', 'service.name:opbeans-go'], + 'transaction.type': 'request', + }); + + const { + body: topAlertsAfterRecovery, + status: topAlertStatusAfterRecovery, + } = await supertest + .get( + format({ + pathname: '/api/observability/rules/alerts/top', + query: { + start: new Date(now - 30 * 60 * 1000).toISOString(), + end: new Date().toISOString(), + }, + }) + ) + .set('kbn-xsrf', 'foo'); + + expect(topAlertStatusAfterRecovery).to.eql(200); + + expect(topAlertsAfterRecovery.length).to.be(1); + + expect(topAlertsAfterRecovery[0]['kibana.rac.alert.status']).to.be('closed'); }); }); });