[RAC] Populate Observability alerts table with data from alerts indices (#96692)

* Set up Observability rule APIs

* Populate alerts table with data from API

* Move field map types/utils to common

* Format reason/link in alert type

* Format reason/link in alert type

* Fix issues with tsconfigs

* Storybook cleanup for example alerts

* Use `MemoryRouter` in the stories and `useHistory` in the component to get the history
* Replace examples with ones from "real" data
* Use `() => {}` instead of `jest.fn()` in mock registry data

* Store/display evaluations, add active/recovered badge

* Some more story fixes

* Decode rule data with type from owning registry

* Use transaction type/environment in link to app

* Fix type issues

* Fix API tests

* Undo changes in task_runner.ts

* Remove Mutable<> wrappers for field map

* Remove logger.debug calls in alerting es client

* Add API test for recovery of alerts

* Revert changes to src/core/server/http/router

* Use type imports where possible

* Update limits

* Set limit to 100kb

Co-authored-by: Nathan L Smith <smith@nlsmith.com>
This commit is contained in:
Dario Gieselaar 2021-04-15 18:25:50 +02:00 committed by GitHub
parent 5ecf09843e
commit 5bb9eecd26
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
110 changed files with 3379 additions and 4430 deletions

View file

@ -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';

View file

@ -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';

View file

@ -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<number, string, unknown>(
(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()
);

View file

@ -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';

View file

@ -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';

View file

@ -61,6 +61,7 @@ pageLoadAssetSize:
remoteClusters: 51327
reporting: 183418
rollup: 97204
ruleRegistry: 100000
savedObjects: 108518
savedObjectsManagement: 101836
savedObjectsTagging: 59482

View file

@ -0,0 +1,25 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 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;

View file

@ -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\\}\\}
`,
}
),

View file

@ -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<ApmPlugin['setup']>;
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<ApmPluginSetup, ApmPluginStart> {
},
});
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<ApmPluginSetup, ApmPluginStart> {
);
},
});
return {
ruleRegistry: apmRuleRegistry,
};
}
public start(core: CoreStart, plugins: ApmPluginStartDeps) {
toggleAppLinkInNav(core, this.initializerContext.config.get());
registerApmAlerts(plugins.triggersActionsUi.alertTypeRegistry);
}
}

View file

@ -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

View file

@ -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'),
];

View file

@ -11,14 +11,17 @@ import {
} from '../../../../../../typings/elasticsearch';
import { AlertServices } from '../../../../alerting/server';
export async function alertingEsClient<TParams extends ESSearchRequest>(
export async function alertingEsClient<TParams extends ESSearchRequest>({
scopedClusterClient,
params,
}: {
scopedClusterClient: AlertServices<
never,
never,
never
>['scopedClusterClient'],
params: TParams
): Promise<ESSearchResponse<unknown, TParams>> {
>['scopedClusterClient'];
params: TParams;
}): Promise<ESSearchResponse<unknown, TParams>> {
const response = await scopedClusterClient.asCurrentUser.search({
...params,
ignore_unavailable: true,

View file

@ -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, {

View file

@ -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, {

View file

@ -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,
});
});

View file

@ -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, {

View file

@ -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<APMPlugin['setup']>['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,

View file

@ -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,

View file

@ -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';

View file

@ -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';

View file

@ -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';

View file

@ -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];
}

View file

@ -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',
});

View file

@ -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,
},
},
};

View file

@ -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> = T | null | undefined;

View file

@ -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 = <T extends any[]>(array: T): Array<ValuesType<T>> => {
return array;
};

View file

@ -0,0 +1,41 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 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<any> type is not compatible
// with Array<any>. This function returns a mutable version of a type.
export function asMutableArray<T extends Readonly<any>>(
arr: T
): T extends Readonly<[...infer U]> ? U : unknown[] {
return arr as any;
}

View file

@ -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
);
});
});
});

View file

@ -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}`;
}

View file

@ -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');
});
});
});

View file

@ -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<number>, 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<number>;
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<number>) {
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<number>,
{ 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<number>) {
return convertTo({
unit: 'milliseconds',
microseconds: value,
}).formatted;
}

View file

@ -0,0 +1,54 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 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');
});
});
});

View file

@ -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<number>,
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);
}

View file

@ -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';

View file

@ -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('');
});
});
});
});

View file

@ -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<number>) => {
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;
};

View file

@ -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);
}

View file

@ -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: '',
},
});
});
});

View file

@ -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<T extends Record<string, any>, U extends UnionToIntersection<T>> = Array<
Partial<U> &
{
[k in keyof T]: T[k];
}
>;
type ArrayOrSingle<T> = T | T[];
export function joinByKey<
T extends Record<string, any>,
U extends UnionToIntersection<T>,
V extends ArrayOrSingle<keyof T & keyof U>
>(items: T[], key: V): JoinedReturnType<T, U>;
export function joinByKey<
T extends Record<string, any>,
U extends UnionToIntersection<T>,
V extends ArrayOrSingle<keyof T & keyof U>,
W extends JoinedReturnType<T, U>,
X extends (a: T, b: T) => ValuesType<W>
>(items: T[], key: V, mergeFn: X): W;
export function joinByKey(
items: Array<Record<string, any>>,
key: string | string[],
mergeFn: Function = (a: Record<string, any>, b: Record<string, any>) => merge({}, a, b)
) {
const keys = castArray(key);
return items.reduce<Array<Record<string, any>>>((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;
}, []);
}

View file

@ -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<T>(value: T): T | null | undefined {
return value;
}

View file

@ -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<T, K extends keyof T>(obj: T, ...keys: K[]) {
return pick(obj, keys) as Pick<T, K>;
}

View file

@ -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"
]
}

View file

@ -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();
});

View file

@ -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(
<KibanaContextProvider services={{ ...core, ...plugins, storage: new Storage(localStorage) }}>
<PluginContext.Provider value={{ appMountParameters, core, plugins }}>
<PluginContext.Provider
value={{ appMountParameters, core, plugins, observabilityRuleRegistry }}
>
<Router history={history}>
<EuiThemeProvider darkMode={isDarkMode}>
<i18nCore.Context>

View file

@ -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: {

View file

@ -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', () => {

View file

@ -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(<TimestampTooltip time={timestamp} />)).toMatchInlineSnapshot(`
<EuiToolTip
content="Oct 10, 2019, 08:06:40.123 (UTC-7)"
delay="regular"
position="top"
>
5 hours ago
</EuiToolTip>
`);
});
it('should format with precision in milliseconds by default', () => {
expect(
shallow(<TimestampTooltip time={timestamp} />)
.find('EuiToolTip')
.prop('content')
).toBe('Oct 10, 2019, 08:06:40.123 (UTC-7)');
});
it('should format with precision in seconds', () => {
expect(
shallow(<TimestampTooltip time={timestamp} timeUnit="seconds" />)
.find('EuiToolTip')
.prop('content')
).toBe('Oct 10, 2019, 08:06:40 (UTC-7)');
});
it('should format with precision in minutes', () => {
expect(
shallow(<TimestampTooltip time={timestamp} timeUnit="minutes" />)
.find('EuiToolTip')
.prop('content')
).toBe('Oct 10, 2019, 08:06 (UTC-7)');
});
});

View file

@ -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 (
<EuiToolTip content={absoluteTimeLabel}>
<>{relativeTimeLabel}</>
</EuiToolTip>
);
}

View file

@ -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);

View file

@ -28,7 +28,7 @@ type InferResponseType<TReturn> = Exclude<TReturn, undefined> extends Promise<in
: unknown;
export function useFetcher<TReturn>(
fn: () => TReturn,
fn: ({}: { signal: AbortSignal }) => TReturn,
fnDeps: any[],
options: {
preservePreviousData?: boolean;
@ -43,8 +43,16 @@ export function useFetcher<TReturn>(
});
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<TReturn>(
try {
const data = await promise;
setResult({
data,
status: FETCH_STATUS.SUCCESS,
error: undefined,
} as FetcherResult<InferResponseType<TReturn>>);
// 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<InferResponseType<TReturn>>);
}
} 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]);

View file

@ -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', () => {

View file

@ -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';

View file

@ -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 (
<IntlProvider locale="en">
<KibanaContextProvider
services={{
data: { query: {} },
docLinks: { links: { query: {} } },
storage: { get: () => {} },
uiSettings: {
get: (setting: string) => {
if (setting === 'dateFormat') {
return '';
} else {
return [];
}
},
},
}}
>
<PluginContext.Provider
value={
({
core: {
http: { basePath: { prepend: (_: string) => '' } },
<MemoryRouter>
<IntlProvider locale="en">
<KibanaContextProvider
services={{
data: { autocomplete: { hasQuerySuggestions: () => false }, query: {} },
docLinks: { links: { query: {} } },
storage: { get: () => {} },
uiSettings: {
get: (setting: string) => {
if (setting === 'dateFormat') {
return '';
} else {
return [];
}
},
} as unknown) as PluginContextValue
}
},
}}
>
<Story />
</PluginContext.Provider>
</KibanaContextProvider>
</IntlProvider>
<PluginContext.Provider
value={
({
core: {
http: { basePath: { prepend: (_: string) => '' } },
},
observabilityRuleRegistry: createObservabilityRuleRegistryMock(),
} as unknown) as PluginContextValue
}
>
<Story />
</PluginContext.Provider>
</KibanaContextProvider>
</IntlProvider>
</MemoryRouter>
);
},
],
};
export function Example() {
return <AlertsPage items={wireframeData} routeParams={{ query: {} }} />;
export function Example(_args: PageArgs) {
return (
<AlertsPage routeParams={{ query: { rangeFrom: 'now-15m', rangeTo: 'now', kuery: '' } }} />
);
}
Example.args = {
items: apmAlertResponseExample,
} as PageArgs;
export function EventLog() {
return <AlertsPage items={eventLogPocData as AlertItem[]} routeParams={{ query: {} }} />;
export function EmptyState(_args: PageArgs) {
return <AlertsPage routeParams={{ query: {} }} />;
}
EmptyState.args = { items: [] } as PageArgs;
export function EmptyState() {
return <AlertsPage items={[]} routeParams={{ query: {} }} />;
}
export function Flyout() {
return <AlertsFlyout {...wireframeData[0]} onClose={() => {}} />;
export function Flyout({ alert }: FlyoutArgs) {
return <AlertsFlyout alert={alert} onClose={() => {}} />;
}
Flyout.args = { alert: flyoutItemExample } as FlyoutArgs;

View file

@ -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
description: asDuration(alert.duration, { extended: true }) || '-', // TODO: format duration
},
// {
// title: 'Expected value',
// description: expectedValue || '-',
// },
// {
// title: 'Actual value',
// description: actualValue || '-',
// },
{
title: 'Expected value',
description: expectedValue || '-',
},
{
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}
/>
<EuiSpacer />
{/* <EuiSpacer />
<EuiTitle size="xs">
<h4>Severity log</h4>
</EuiTitle>
@ -105,7 +93,7 @@ export function AlertsFlyout(props: AlertsFlyoutProps) {
},
]}
items={severityLog ?? []}
/>
/> */}
</>
),
},
@ -123,7 +111,7 @@ export function AlertsFlyout(props: AlertsFlyoutProps) {
<EuiFlyout onClose={onClose} size="s">
<EuiFlyoutHeader>
<EuiTitle size="xs">
<h2>{reason}</h2>
<h2>{alert.ruleName}</h2>
</EuiTitle>
<EuiTabbedContent size="s" tabs={tabs} />
</EuiFlyoutHeader>

View file

@ -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 (
<SearchBar
indexPatterns={[]}
indexPatterns={dynamicIndexPattern ? [dynamicIndexPattern] : []}
placeholder={i18n.translate('xpack.observability.alerts.searchBarPlaceholder', {
defaultMessage: '"domain": "ecommerce" AND ("service.name": "ProductCatalogService" …)',
})}
query={{ query: '', language: 'kuery' }}
timeHistory={new TimeHistory(new Storage(localStorage))}
query={{ query: query ?? '', language: 'kuery' }}
timeHistory={timeHistory}
dateRangeFrom={rangeFrom}
dateRangeTo={rangeTo}
onRefresh={({ dateRange }) => {
onQueryChange({ dateRange, query });
}}
onQuerySubmit={({ dateRange, query: nextQuery }) => {
onQueryChange({
dateRange,
query: typeof nextQuery?.query === 'string' ? nextQuery.query : '',
});
}}
/>
);
}

View file

@ -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<AlertItem>,
EuiBasicTableProps<TopAlert>,
'columns' | 'isSelectable' | 'pagination' | 'selection'
>;
export function AlertsTable(props: AlertsTableProps) {
const [flyoutAlert, setFlyoutAlert] = useState<AlertItem | undefined>(undefined);
const [flyoutAlert, setFlyoutAlert] = useState<TopAlert | undefined>(undefined);
const handleFlyoutClose = () => setFlyoutAlert(undefined);
const { prepend } = usePluginContext().core.http.basePath;
const { core } = usePluginContext();
const { prepend } = 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 <EuiLink href={prepend(`/app/apm/services/${serviceName}`)}>{text}</EuiLink>;
} else if (pod) {
return <EuiLink href={prepend(`/app/metrics/link-to/host-detail/${pod}`)}>{text}</EuiLink>;
} else if (log) {
return <EuiLink href={prepend(`/app/logs/stream`)}>{text}</EuiLink>;
} else {
return <>{text}</>;
}
}
const actions: Array<DefaultItemAction<AlertItem>> = [
const actions: Array<DefaultItemAction<TopAlert>> = [
{
name: 'Alert details',
description: 'Alert details',
@ -82,25 +54,53 @@ export function AlertsTable(props: AlertsTableProps) {
},
];
const columns: Array<EuiBasicTableColumn<AlertItem>> = [
const columns: Array<EuiBasicTableColumn<TopAlert>> = [
{
field: '@timestamp',
field: 'active',
name: 'Status',
width: '112px',
render: (_, { active }) => {
const style = {
width: '96px',
textAlign: 'center' as const,
};
return active ? (
<EuiBadge iconType="alert" color="danger" style={style}>
{i18n.translate('xpack.observability.alertsTable.status.active', {
defaultMessage: 'Active',
})}
</EuiBadge>
) : (
<EuiBadge iconType="check" color="hollow" style={style}>
{i18n.translate('xpack.observability.alertsTable.status.recovered', {
defaultMessage: 'Recovered',
})}
</EuiBadge>
);
},
},
{
field: 'start',
name: 'Triggered',
dataType: 'date',
render: (_, item) => {
return <TimestampTooltip time={new Date(item.start).getTime()} timeUnit="milliseconds" />;
},
},
{
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 ? <EuiLink href={prepend(item.link)}>{item.reason}</EuiLink> : item.reason;
},
},
{
actions,
@ -110,12 +110,13 @@ export function AlertsTable(props: AlertsTableProps) {
return (
<>
{flyoutAlert && <AlertsFlyout {...flyoutAlert} onClose={handleFlyoutClose} />}
<EuiBasicTable<AlertItem>
{flyoutAlert && <AlertsFlyout alert={flyoutAlert} onClose={handleFlyoutClose} />}
<EuiBasicTable<TopAlert>
{...props}
isSelectable={true}
selection={{} as EuiTableSelectionType<AlertItem>}
selection={{} as EuiTableSelectionType<TopAlert>}
columns={columns}
tableLayout="auto"
pagination={{ pageIndex: 0, pageSize: 0, totalItemCount: 0 }}
/>
</>

View file

@ -5,505 +5,237 @@
* 2.0.
*/
/**
* Example data from Whimsical wireframes: https://whimsical.com/observability-alerting-user-journeys-8TFDcHRPMQDJgtLpJ7XuBj
*/
export const wireframeData = [
export const apmAlertResponseExample = [
{
'@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',
'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': 1615392600000,
duration: '11 min 1 s',
severity: '-',
reason: 'Latency is greater than 1500ms (current value is 1700ms) on frontend',
service: { name: 'opbeans-go' },
severityLog: [],
},
{
'@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*',
};

View file

@ -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 (
<EuiPage>
<EuiPageHeader
@ -87,10 +142,26 @@ export function AlertsPage({ items }: AlertsPageProps) {
</EuiCallOut>
</EuiFlexItem>
<EuiFlexItem>
<AlertsSearchBar />
<AlertsSearchBar
rangeFrom={rangeFrom}
rangeTo={rangeTo}
query={kuery}
onQueryChange={({ dateRange, query }) => {
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(),
});
}}
/>
</EuiFlexItem>
<EuiFlexItem>
<AlertsTable items={items} />
<AlertsTable items={topAlerts ?? []} />
</EuiFlexItem>
</EuiFlexGroup>
</EuiPageHeader>

View file

@ -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(),
}}
>
<EuiThemeProvider>

View file

@ -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<Plugin['setup']>;
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<ObservabilityPublicPluginsStart>,
plugins: ObservabilityPublicPluginsSetup
coreSetup: CoreSetup<ObservabilityPublicPluginsStart>,
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<unknown>) => {
// 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) {

View file

@ -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),
}),

View file

@ -0,0 +1,25 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 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<TFieldMap extends BaseRuleFieldMap> = OutputOfFieldMap<TFieldMap>;
type FormattableRuleType<TFieldMap extends BaseRuleFieldMap> = RuleType & {
format?: (options: {
alert: AlertTypeOf<TFieldMap>;
}) => {
reason?: string;
link?: string;
};
};
export class FormatterRuleRegistry<TFieldMap extends BaseRuleFieldMap> extends RuleRegistry<
TFieldMap,
FormattableRuleType<TFieldMap>
> {}

View file

@ -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<typeof createRuleRegistryMock>;

View file

@ -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;
}

View file

@ -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<string, any> }
>;
export type ObservabilityClient = RouteRepositoryClient<
ObservabilityServerRouteRepository,
ObservabilityClientOptions
>;
export { ObservabilityAPIReturnType };

View file

@ -7,9 +7,33 @@
import datemath from '@elastic/datemath';
export function getAbsoluteTime(range: string, opts = {}) {
export function getAbsoluteTime(range: string, opts: Parameters<typeof datemath.parse>[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(),
};
}

View file

@ -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(
<IntlProvider locale="en-US" messages={translations.messages}>
<KibanaContextProvider services={{ ...core }}>
<PluginContext.Provider value={{ appMountParameters, core, plugins }}>
<PluginContext.Provider
value={{ appMountParameters, core, plugins, observabilityRuleRegistry }}
>
<EuiThemeProvider>{component}</EuiThemeProvider>
</PluginContext.Provider>
</KibanaContextProvider>

View file

@ -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({

View file

@ -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({

View file

@ -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'
>;
});
}

View file

@ -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<ObservabilityPlugin['setup']>;
export type ObservabilityRuleRegistry = ObservabilityPluginSetup['ruleRegistry'];
export class ObservabilityPlugin implements Plugin<ObservabilityPluginSetup> {
constructor(private readonly initContext: PluginInitializerContext) {
@ -48,17 +50,26 @@ export class ObservabilityPlugin implements Plugin<ObservabilityPluginSetup> {
});
}
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<ScopedAnnotationsClientFactory>) => {
const api = await annotationsApiPromise;
return api?.getScopedAnnotationsClient(...args);
},
ruleRegistry: plugins.ruleRegistry.create({
name: 'observability',
fieldMap: {
...pickWithPatterns(ecsFieldMap, 'host.name', 'service.name'),
},
}),
ruleRegistry: observabilityRuleRegistry,
};
}

View file

@ -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
>();

View file

@ -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
>();
};

View file

@ -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
>;

View file

@ -0,0 +1,92 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 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<CoreStart>;
};
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<typeof method, ObservabilityRequestHandlerContext>)(
{
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);
}
}
);
});
}

View file

@ -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);

View file

@ -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<CoreStart>;
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<ObservabilityServerRouteRepository>
> = ReturnOf<ObservabilityServerRouteRepository, TEndpoint>;

View file

@ -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<ObservabilityRequestHandlerContext>;
export type ObservabilityRuleRegistryClient = ScopedRuleRegistryClient<
FieldMapOf<ObservabilityRuleRegistry>
>;

View file

@ -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)];
}

View file

@ -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" }
]
}

View file

@ -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;

View file

@ -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;

View file

@ -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)),
};

View file

@ -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';

View file

@ -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<T1 extends FieldMap, T2 extends FieldMap>(
first: T1,
@ -39,7 +38,7 @@ export function mergeFieldMaps<T1 extends FieldMap, T2 extends FieldMap>(
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;
}

View file

@ -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 string> = T extends keyof EsFieldTypeMap
? EsFieldTypeMap[T]
: t.UnknownC;
type RequiredKeysOf<T extends Record<string, { required?: boolean }>> = keyof PickByValueExact<
{
[key in keyof T]: T[key]['required'];
},
true
>;
type IntersectionTypeOf<
T extends Record<string, { required?: boolean; type: t.Any }>
> = t.IntersectionC<
[
t.TypeC<Pick<{ [key in keyof T]: T[key]['type'] }, RequiredKeysOf<T>>>,
t.PartialC<{ [key in keyof T]: T[key]['type'] }>
]
>;
type CastArray<T extends t.Type<any>> = t.Type<
t.TypeOf<T> | Array<t.TypeOf<T>>,
Array<t.TypeOf<T>>,
@ -71,25 +54,39 @@ const createCastSingleRt = <T extends t.Type<any>>(type: T): CastSingle<T> => {
return new t.Type('castSingle', union.is, union.validate, (a) => (Array.isArray(a) ? a[0] : a));
};
type MapTypeValues<T extends FieldMap> = {
[key in keyof T]: {
required: T[key]['required'];
type: T[key]['array'] extends true
? CastArray<EsFieldTypeOf<T[key]['type']>>
: CastSingle<EsFieldTypeOf<T[key]['type']>>;
};
type SetOptional<T extends FieldMap> = Optional<
T,
{
[key in keyof T]: T[key]['required'] extends true ? never : key;
}[keyof T]
>;
type OutputOfField<T extends { type: string; array?: boolean }> = T['array'] extends true
? Array<t.OutputOf<EsFieldTypeOf<T['type']>>>
: t.OutputOf<EsFieldTypeOf<T['type']>>;
type TypeOfField<T extends { type: string; array?: boolean }> =
| t.TypeOf<EsFieldTypeOf<T['type']>>
| Array<t.TypeOf<EsFieldTypeOf<T['type']>>>;
type OutputOf<T extends FieldMap> = {
[key in keyof T]: OutputOfField<Exclude<T[key], undefined>>;
};
type FieldMapType<T extends FieldMap> = IntersectionTypeOf<MapTypeValues<T>>;
type TypeOf<T extends FieldMap> = {
[key in keyof T]: TypeOfField<Exclude<T[key], undefined>>;
};
export type TypeOfFieldMap<T extends FieldMap> = Mutable<t.TypeOf<FieldMapType<T>>>;
export type OutputOfFieldMap<T extends FieldMap> = Mutable<t.OutputOf<FieldMapType<T>>>;
export type TypeOfFieldMap<T extends FieldMap> = TypeOf<SetOptional<T>>;
export type OutputOfFieldMap<T extends FieldMap> = OutputOf<SetOptional<T>>;
export type FieldMapType<T extends FieldMap> = t.Type<TypeOfFieldMap<T>, OutputOfFieldMap<T>>;
export function runtimeTypeFromFieldMap<TFieldMap extends FieldMap>(
fieldMap: TFieldMap
): FieldMapType<TFieldMap> {
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]

View file

@ -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 };
}

View file

@ -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';

View file

@ -5,7 +5,7 @@
* 2.0.
*/
import { pickWithPatterns } from './pick_with_patterns';
import { pickWithPatterns } from './';
describe('pickWithPatterns', () => {
const fieldMap = {

View file

@ -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];
}

View file

@ -7,7 +7,12 @@
"ruleRegistry"
],
"requiredPlugins": [
"alerting"
"alerting",
"triggersActionsUi"
],
"server": true
"server": true,
"ui": true,
"extraPublicDirs": [
"common"
]
}

View file

@ -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);
};

View file

@ -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<Plugin['setup']>;
export class Plugin
implements PluginClass<void, void, RuleRegistrySetupPlugins, RuleRegistryStartPlugins> {
constructor(context: PluginInitializerContext) {}
public setup(core: CoreSetup<RuleRegistryStartPlugins>, 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,
};
}
}

View file

@ -0,0 +1,47 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import type { BaseRuleFieldMap } from '../../common';
import type { RuleType, CreateRuleRegistry, RuleRegistryConstructorOptions } from './types';
export class RuleRegistry<TFieldMap extends BaseRuleFieldMap, TRuleType extends RuleType> {
protected types: TRuleType[] = [];
constructor(private readonly options: RuleRegistryConstructorOptions<TFieldMap>) {}
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<TFieldMap, TRuleType> = ({ 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;
};
}

View file

@ -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<TFieldMap extends BaseRuleFieldMap> {
fieldMap: TFieldMap;
alertTypeRegistry: AlertTypeRegistryContract;
parent?: IRuleRegistry<any, any>;
}
export type RuleType = Parameters<AlertTypeRegistryContract['register']>[0];
export type RegisterRuleType<
TFieldMap extends BaseRuleFieldMap,
TAdditionalRegisterOptions = {}
> = (type: RuleType & TAdditionalRegisterOptions) => void;
export type RuleRegistryExtensions<T extends keyof any = never> = 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<TFieldMap & TNextFieldMap, TRuleType>
? TInstanceType
: IRuleRegistry<TFieldMap & TNextFieldMap, TRuleType>
>(options: {
fieldMap: TNextFieldMap;
ctor?: new (
options: RuleRegistryConstructorOptions<TFieldMap & TNextFieldMap>
) => TRuleRegistryInstance;
}) => TRuleRegistryInstance;
export interface IRuleRegistry<
TFieldMap extends BaseRuleFieldMap,
TRuleType extends RuleType,
TInstanceType = undefined
> {
create: CreateRuleRegistry<TFieldMap, TRuleType, TInstanceType>;
registerType(type: TRuleType): void;
getTypeByRuleId(ruleId: string): TRuleType;
getTypes(): TRuleType[];
}
export type FieldMapOfRuleRegistry<TRuleRegistry> = TRuleRegistry extends IRuleRegistry<
infer TFieldMap,
any
>
? TFieldMap
: never;

View file

@ -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)),
]);
}

File diff suppressed because it is too large Load diff

View file

@ -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';

Some files were not shown because too many files have changed in this diff Show more