[EEM][POC] The POC for creating entity-centric indices using entity definitions (#183205)

## Summary

This is a "proof of concept" for generating entity-centric indices for
the OAM. This exposes an API (`/api/entities`) for creating "asset
definitions" (`EntityDefinition`) that manages a transform and ingest
pipeline to produce documents into an index which could be used to
create a search experience or lookups for different services.

### Features
- Data schema agnostic, works with known schemas OR custom logs
- Supports defining multiple `identityFields` along with an
`identityTemplate` for formatting the `asset.id`
- Supports optional `identityFields` using `{ "field": "path-to-field",
"optional": true }` definition instead of a `string`.
- Supports defining key `metrics` with equations which are compatible
with the SLO product
- Supports adding `metadata` fields which will include multiple values. 
- Supports `metadata` fields can be re-mapped to a new destination path
using `{ "source": "path-to-source-field", "limit": 1000, "destination":
"path-to-destination-in-output" }` definition instead of a `string`
- Supports adding  `staticFields` which can also use template variables
- Support fine grain control over the frequency and sync settings for
the underlying transform
- Installs the index template components and index template settings for
the destination index
- Allow the user to configure the index patterns and timestamp field
along with the lookback
- The documents for each definition will be stored in their own index
(`.entities-observability.summary-v1.{defintion.id}`)

### Notes
- We are currently considering adding a historical index which will
track changes to the assets over time. If we choose to do this, the
summary index would remain the same but we'd add a second transform with
a group_by on the `definition.timestampField` and break the indices into
monthly indexes (configurable in the settings).
- We are looking into ways to add `firstSeenTimestamp`, this is a
difficult due to scaling issue. Essentially, we would need to find the
`minimum` timestamp for each entity which could be extremely costly on a
large datasets.
- There is nothing stopping you from creating an asset definition that
uses the `.entities-observability.summary-v1.*` index pattern to create
summaries of summaries... it can be very "meta".

### API
- `POST /api/entities/definition` - Creates a new asset definition and
starts the indexing. See examples below.
- `DELETE /api/entities/definition/{id}` - Deletes the asset definition
along with cleaning up the transform, ingest pipeline, and deletes the
destination index.
- `POST /api/entities/definition/{id}/_reset` - Resets the transform,
ingest pipeline, and destination index. This is useful for upgrading
asset definitions to new features.

## Example Definitions and Output

Here is a definition for creating services for each of the custom log
sources in the `fake_stack` dataset from `x-pack/packages/data-forge`.

```JSON
POST kbn:/api/entities/definition
{
  "id": "admin-console-logs-service",
  "name": "Services for Admin Console",
  "type": "service",
  "indexPatterns": ["kbn-data-forge-fake_stack.*"],
  "timestampField": "@timestamp",
  "lookback": "5m",
  "identityFields": ["log.logger"],
  "identityTemplate": "{{log.logger}}",
  "metadata": [
    "tags",
    "host.name"
  ],
  "metrics": [
    {
      "name": "logRate",
      "equation": "A / 5", 
      "metrics": [
        {
          "name": "A",
          "aggregation": "doc_count",
          "filter": "log.level: *"
        }
      ]
    },
    {
      "name": "errorRate",
      "equation": "A / 5", 
      "metrics": [
        {
          "name": "A",
          "aggregation": "doc_count",
          "filter": "log.level: \"ERROR\""
        }
      ]
    }
  ]
}
```
Which produces:
```JSON
{
  "host": {
    "name": [
      "admin-console.prod.020",
      "admin-console.prod.010",
      "admin-console.prod.011",
      "admin-console.prod.001",
      "admin-console.prod.012",
      "admin-console.prod.002",
      "admin-console.prod.013",
      "admin-console.prod.003",
      "admin-console.prod.014",
      "admin-console.prod.004",
      "admin-console.prod.015",
      "admin-console.prod.016",
      "admin-console.prod.005",
      "admin-console.prod.017",
      "admin-console.prod.006",
      "admin-console.prod.018",
      "admin-console.prod.007",
      "admin-console.prod.019",
      "admin-console.prod.008",
      "admin-console.prod.009"
    ]
  },
  "entity": {
    "latestTimestamp": "2024-05-10T22:04:51.481Z",
    "metric": {
      "logRate": 37.4,
      "errorRate": 1
    },
    "identity": {
      "log": {
        "logger": "admin-console"
      }
    },
    "id": "admin-console",
    "indexPatterns": [
      "kbn-data-forge-fake_stack.*"
    ],
    "definitionId": "admin-console-logs-service"
  },
  "event": {
    "ingested": "2024-05-10T22:05:51.955691Z"
  },
  "tags": [
    "infra:admin-console"
  ]
}
```

Here is an example of a definition for APM Services:

```JSON
POST kbn:/api/entities/definition
{
  "id": "apm-services",
  "name": "Services for APM",
  "type": "service", 
  "indexPatterns": ["logs-*", "metrics-*"],
  "timestampField": "@timestamp",
  "lookback": "5m",
  "identityFields": ["service.name", "service.environment"],
  "identityTemplate": "{{service.name}}:{{service.environment}}",
  "metadata": [
    "tags",
    "host.name"
  ],
  "metrics": [
    {
      "name": "latency",
      "equation": "A",
      "metrics": [
        {
          "name": "A",
          "aggregation": "avg",
          "field": "transaction.duration.histogram"
        }
      ]
    },
    {
      "name": "throughput",
      "equation": "A / 5",
      "metrics": [
        {
          "name": "A",
          "aggregation": "doc_count"
        }
      ]
    },
    {
      "name": "failedTransRate",
      "equation": "A / B",
      "metrics": [
        {
          "name": "A",
          "aggregation": "doc_count",
          "filter": "event.outcome: \"failure\""
        },
        {
          "name": "B",
          "aggregation": "doc_count",
          "filter": "event.outcome: *"
        }
      ]
    }
  ]
}
```
Which produces:
```JSON
{
  "host": {
    "name": [
      "simianhacker's-macbook-pro"
    ]
  },
  "entity": {
    "latestTimestamp": "2024-05-10T21:38:22.513Z",
    "metric": {
      "latency": 615276.8812785388,
      "throughput": 50.6,
      "failedTransRate": 0.0091324200913242
    },
    "identity": {
      "service": {
        "environment": "development",
        "name": "admin-console"
      }
    },
    "id": "admin-console:development",
    "indexPatterns": [
      "logs-*",
      "metrics-*"
    ],
    "definitionId": "apm-services"
  },
  "event": {
    "ingested": "2024-05-10T21:39:33.636225Z"
  },
  "tags": [
    "_geoip_database_unavailable_GeoLite2-City.mmdb"
  ]
}
```

### Getting Started 

The easiest way to get started is to use the`kbn-data-forge` config
below. Save this YAML to `~/Desktop/fake_stack.yaml` then run `node
x-pack/scripts/data_forge.js --config ~/Desktop/fake_stack.yaml`. Then
create a definition using the first example above.

```YAML
---
elasticsearch:
  installKibanaUser: false

kibana:
  installAssets: true
  host: "http://localhost:5601/kibana"

indexing:
  dataset: "fake_stack"
  eventsPerCycle: 50
  reduceWeekendTrafficBy: 0.5

schedule:
  # Start with good events
  - template: "good"
    start: "now-1d"
    end: "now-20m"
    eventsPerCycle: 50
    randomness: 0.8
  - template: "bad"
    start: "now-20m"
    end: "now-10m"
    eventsPerCycle: 50
    randomness: 0.8
  - template: "good"
    start: "now-10m"
    end: false
    eventsPerCycle: 50
    randomness: 0.8
```

---------

Co-authored-by: kibanamachine <42973632+kibanamachine@users.noreply.github.com>
This commit is contained in:
Chris Cowan 2024-05-17 12:03:42 -06:00 committed by GitHub
parent e24502d70a
commit 7ae07f8913
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
56 changed files with 1790 additions and 23 deletions

1
.github/CODEOWNERS vendored
View file

@ -390,6 +390,7 @@ src/plugins/embeddable @elastic/kibana-presentation
x-pack/examples/embedded_lens_example @elastic/kibana-visualizations
x-pack/plugins/encrypted_saved_objects @elastic/kibana-security
x-pack/plugins/enterprise_search @elastic/enterprise-search-frontend
x-pack/packages/kbn-entities-schema @elastic/obs-knowledge-team
examples/error_boundary @elastic/appex-sharedux
packages/kbn-es @elastic/kibana-operations
packages/kbn-es-archiver @elastic/kibana-operations @elastic/appex-qa

View file

@ -439,6 +439,7 @@
"@kbn/embedded-lens-example-plugin": "link:x-pack/examples/embedded_lens_example",
"@kbn/encrypted-saved-objects-plugin": "link:x-pack/plugins/encrypted_saved_objects",
"@kbn/enterprise-search-plugin": "link:x-pack/plugins/enterprise_search",
"@kbn/entities-schema": "link:x-pack/packages/kbn-entities-schema",
"@kbn/error-boundary-example-plugin": "link:examples/error_boundary",
"@kbn/es-errors": "link:packages/kbn-es-errors",
"@kbn/es-query": "link:packages/kbn-es-query",

View file

@ -774,6 +774,8 @@
"@kbn/encrypted-saved-objects-plugin/*": ["x-pack/plugins/encrypted_saved_objects/*"],
"@kbn/enterprise-search-plugin": ["x-pack/plugins/enterprise_search"],
"@kbn/enterprise-search-plugin/*": ["x-pack/plugins/enterprise_search/*"],
"@kbn/entities-schema": ["x-pack/packages/kbn-entities-schema"],
"@kbn/entities-schema/*": ["x-pack/packages/kbn-entities-schema/*"],
"@kbn/error-boundary-example-plugin": ["examples/error_boundary"],
"@kbn/error-boundary-example-plugin/*": ["examples/error_boundary/*"],
"@kbn/es": ["packages/kbn-es"],

View file

@ -0,0 +1,3 @@
# @kbn/entities-schema
The entities schema for the asset model for Observability

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 * from './src/schema/entity_definition';
export * from './src/schema/entity';
export * from './src/schema/common';

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.
*/
module.exports = {
preset: '@kbn/test',
rootDir: '../../..',
roots: ['<rootDir>/x-pack/packages/kbn-entities-schema'],
};

View file

@ -0,0 +1,5 @@
{
"type": "shared-common",
"id": "@kbn/entities-schema",
"owner": "@elastic/obs-knowledge-team"
}

View file

@ -0,0 +1,6 @@
{
"name": "@kbn/entities-schema",
"private": true,
"version": "1.0.0",
"license": "Elastic License 2.0"
}

View file

@ -0,0 +1,106 @@
/*
* 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 { z } from 'zod';
import moment from 'moment';
export enum EntityType {
service = 'service',
host = 'host',
pod = 'pod',
node = 'node',
}
export const arrayOfStringsSchema = z.array(z.string());
export const entityTypeSchema = z.nativeEnum(EntityType);
export enum BasicAggregations {
avg = 'avg',
max = 'max',
min = 'min',
sum = 'sum',
cardinality = 'cardinality',
last_value = 'last_value',
std_deviation = 'std_deviation',
}
export const basicAggregationsSchema = z.nativeEnum(BasicAggregations);
const metricNameSchema = z
.string()
.length(1)
.regex(/[a-zA-Z]/)
.toUpperCase();
export const filterSchema = z.optional(z.string());
export const basicMetricWithFieldSchema = z.object({
name: metricNameSchema,
aggregation: basicAggregationsSchema,
field: z.string(),
filter: filterSchema,
});
export const docCountMetricSchema = z.object({
name: metricNameSchema,
aggregation: z.literal('doc_count'),
filter: filterSchema,
});
export const durationSchema = z
.string()
.regex(/\d+[m|d|s|h]/)
.transform((val: string) => {
const parts = val.match(/(\d+)([m|s|h|d])/);
if (parts === null) {
throw new Error('Unable to parse duration');
}
const value = parseInt(parts[1], 10);
const unit = parts[2] as 'm' | 's' | 'h' | 'd';
const duration = moment.duration(value, unit);
return { ...duration, toJSON: () => val };
});
export const percentileMetricSchema = z.object({
name: metricNameSchema,
aggregation: z.literal('percentile'),
field: z.string(),
percentile: z.number(),
filter: filterSchema,
});
export const metricSchema = z.discriminatedUnion('aggregation', [
basicMetricWithFieldSchema,
docCountMetricSchema,
percentileMetricSchema,
]);
export type Metric = z.infer<typeof metricSchema>;
export const keyMetricSchema = z.object({
name: z.string(),
metrics: z.array(metricSchema),
equation: z.string(),
});
export type KeyMetric = z.infer<typeof keyMetricSchema>;
export const metadataSchema = z
.object({
source: z.string(),
destination: z.optional(z.string()),
limit: z.optional(z.number().default(1000)),
})
.or(z.string().transform((value) => ({ source: value, destination: value, limit: 1000 })));
export const identityFieldsSchema = z
.object({
field: z.string(),
optional: z.boolean(),
})
.or(z.string().transform((value) => ({ field: value, optional: false })));

View file

@ -0,0 +1,21 @@
/*
* 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 { z } from 'zod';
import { arrayOfStringsSchema } from './common';
export const entitySchema = z.intersection(
z.object({
entity: z.object({
id: z.string(),
indexPatterns: arrayOfStringsSchema,
identityFields: arrayOfStringsSchema,
metric: z.record(z.string(), z.number()),
}),
}),
z.record(z.string(), z.string().or(z.number()))
);

View file

@ -0,0 +1,42 @@
/*
* 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 { z } from 'zod';
import {
arrayOfStringsSchema,
entityTypeSchema,
keyMetricSchema,
metadataSchema,
filterSchema,
durationSchema,
identityFieldsSchema,
} from './common';
export const entityDefinitionSchema = z.object({
id: z.string().regex(/^[\w-]+$/),
name: z.string(),
description: z.optional(z.string()),
type: entityTypeSchema,
filter: filterSchema,
indexPatterns: arrayOfStringsSchema,
identityFields: z.array(identityFieldsSchema),
identityTemplate: z.string(),
metadata: z.optional(z.array(metadataSchema)),
metrics: z.optional(z.array(keyMetricSchema)),
staticFields: z.optional(z.record(z.string(), z.string())),
lookback: durationSchema,
timestampField: z.string(),
settings: z.optional(
z.object({
syncField: z.optional(z.string()),
syncDelay: z.optional(z.string()),
frequency: z.optional(z.string()),
})
),
});
export type EntityDefinition = z.infer<typeof entityDefinitionSchema>;

View file

@ -0,0 +1,18 @@
{
"extends": "../../../tsconfig.base.json",
"compilerOptions": {
"outDir": "target/types",
"types": [
"jest",
"node"
]
},
"include": [
"**/*.ts"
],
"exclude": [
"target/**/*"
],
"kbn_references": [
]
}

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.
*/
export const ENTITY_VERSION = 'v1';
export const ENTITY_BASE_PREFIX = `.entities-observability.summary-${ENTITY_VERSION}`;
export const ENTITY_TRANSFORM_PREFIX = `entities-observability-summary-${ENTITY_VERSION}`;
export const ENTITY_DEFAULT_FREQUENCY = '1m';
export const ENTITY_DEFAULT_SYNC_DELAY = '60s';
export const ENTITY_API_PREFIX = '/api/entities';

View file

@ -0,0 +1,39 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import { ElasticsearchClient, Logger } from '@kbn/core/server';
import { EntityDefinition } from '@kbn/entities-schema';
import { generateProcessors } from './ingest_pipeline/generate_processors';
import { retryTransientEsErrors } from './helpers/retry';
import { EntitySecurityException } from './errors/entity_security_exception';
import { generateIngestPipelineId } from './ingest_pipeline/generate_ingest_pipeline_id';
export async function createAndInstallIngestPipeline(
esClient: ElasticsearchClient,
definition: EntityDefinition,
logger: Logger
) {
const processors = generateProcessors(definition);
const id = generateIngestPipelineId(definition);
try {
await retryTransientEsErrors(
() =>
esClient.ingest.putPipeline({
id,
processors,
}),
{ logger }
);
} catch (e) {
logger.error(`Cannot create entity ingest pipeline for [${definition.id}] entity defintion`);
if (e.meta?.body?.error?.type === 'security_exception') {
throw new EntitySecurityException(e.meta.body.error.reason, definition);
}
throw e;
}
return id;
}

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 { ElasticsearchClient, Logger } from '@kbn/core/server';
import { EntityDefinition } from '@kbn/entities-schema';
import { generateTransform } from './transform/generate_transform';
import { retryTransientEsErrors } from './helpers/retry';
import { EntitySecurityException } from './errors/entity_security_exception';
export async function createAndInstallTransform(
esClient: ElasticsearchClient,
definition: EntityDefinition,
logger: Logger
) {
const transform = generateTransform(definition);
try {
await retryTransientEsErrors(() => esClient.transform.putTransform(transform), { logger });
} catch (e) {
logger.error(`Cannot create entity transform for [${definition.id}] entity definition`);
if (e.meta?.body?.error?.type === 'security_exception') {
throw new EntitySecurityException(e.meta.body.error.reason, definition);
}
throw e;
}
return transform.transform_id;
}

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 { Logger, SavedObjectsClientContract } from '@kbn/core/server';
import { EntityDefinition } from '@kbn/entities-schema';
import { SO_ENTITY_DEFINITION_TYPE } from '../../saved_objects';
import { EntityDefinitionNotFound } from './errors/entity_not_found';
export async function deleteEntityDefinition(
soClient: SavedObjectsClientContract,
definition: EntityDefinition,
logger: Logger
) {
const response = await soClient.find<EntityDefinition>({
type: SO_ENTITY_DEFINITION_TYPE,
page: 1,
perPage: 1,
filter: `${SO_ENTITY_DEFINITION_TYPE}.attributes.id:(${definition.id})`,
});
if (response.total === 0) {
logger.error(`Unable to delete entity definition [${definition.id}] because it doesn't exist.`);
throw new EntityDefinitionNotFound(`Entity defintion with [${definition.id}] not found.`);
}
await soClient.delete(SO_ENTITY_DEFINITION_TYPE, response.saved_objects[0].id);
}

View file

@ -0,0 +1,24 @@
/*
* 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 { ElasticsearchClient, Logger } from '@kbn/core/server';
import { EntityDefinition } from '@kbn/entities-schema';
import { generateIndexName } from './helpers/generate_index_name';
export async function deleteIndex(
esClient: ElasticsearchClient,
definition: EntityDefinition,
logger: Logger
) {
const indexName = generateIndexName(definition);
try {
await esClient.indices.delete({ index: indexName, ignore_unavailable: true });
} catch (e) {
logger.error(`Unable to remove entity defintion index [${definition.id}}]`);
throw e;
}
}

View file

@ -0,0 +1,27 @@
/*
* 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 { ElasticsearchClient, Logger } from '@kbn/core/server';
import { EntityDefinition } from '@kbn/entities-schema';
import { generateIngestPipelineId } from './ingest_pipeline/generate_ingest_pipeline_id';
import { retryTransientEsErrors } from './helpers/retry';
export async function deleteIngestPipeline(
esClient: ElasticsearchClient,
definition: EntityDefinition,
logger: Logger
) {
const pipelineId = generateIngestPipelineId(definition);
try {
await retryTransientEsErrors(() =>
esClient.ingest.deletePipeline({ id: pipelineId }, { ignore: [404] })
);
} catch (e) {
logger.error(`Unable to delete ingest pipeline [${pipelineId}]`);
throw e;
}
}

View file

@ -0,0 +1,18 @@
/*
* 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 { EntityDefinition } from '@kbn/entities-schema';
export class EntityIdConflict extends Error {
public defintion: EntityDefinition;
constructor(message: string, def: EntityDefinition) {
super(message);
this.name = 'EntityIdConflict';
this.defintion = def;
}
}

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.
*/
export class EntityDefinitionNotFound extends Error {
constructor(message: string) {
super(message);
this.name = 'EntityDefinitionNotFound';
}
}

View file

@ -0,0 +1,18 @@
/*
* 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 { EntityDefinition } from '@kbn/entities-schema';
export class EntitySecurityException extends Error {
public defintion: EntityDefinition;
constructor(message: string, def: EntityDefinition) {
super(message);
this.name = 'EntitySecurityException';
this.defintion = def;
}
}

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.
*/
export class InvalidTransformError extends Error {
constructor(message: string) {
super(message);
this.name = 'InvalidTransformError';
}
}

View file

@ -0,0 +1,46 @@
/*
* 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 { entityDefinitionSchema } from '@kbn/entities-schema';
export const entityDefinition = entityDefinitionSchema.parse({
id: 'admin-console-logs-service',
name: 'Services for Admin Console',
type: 'service',
indexPatterns: ['kbn-data-forge-fake_stack.*'],
timestampField: '@timestamp',
identityFields: ['log.logger'],
identityTemplate: 'service:{{log.logger}}',
metadata: ['tags', 'host.name', 'kubernetes.pod.name'],
staticFields: {
projectId: '1234',
},
lookback: '5m',
metrics: [
{
name: 'logRate',
equation: 'A / 5',
metrics: [
{
name: 'A',
aggregation: 'doc_count',
filter: 'log.level: *',
},
],
},
{
name: 'errorRate',
equation: 'A / 5',
metrics: [
{
name: 'A',
aggregation: 'doc_count',
filter: 'log.level: error',
},
],
},
],
});

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 { EntityDefinition } from '@kbn/entities-schema';
import { ENTITY_BASE_PREFIX } from '../../../../common/constants_entities';
export function generateIndexName(definition: EntityDefinition) {
return `${ENTITY_BASE_PREFIX}.${definition.id}`;
}

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 { fromKueryExpression, toElasticsearchQuery } from '@kbn/es-query';
import { InvalidTransformError } from '../errors/invalid_transform_error';
export function getElasticsearchQueryOrThrow(kuery: string) {
try {
return toElasticsearchQuery(fromKueryExpression(kuery));
} catch (err) {
throw new InvalidTransformError(`Invalid KQL: ${kuery}`);
}
}

View file

@ -0,0 +1,53 @@
/*
* 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 { setTimeout } from 'timers/promises';
import { errors as EsErrors } from '@elastic/elasticsearch';
import type { Logger } from '@kbn/logging';
const MAX_ATTEMPTS = 5;
const retryResponseStatuses = [
503, // ServiceUnavailable
408, // RequestTimeout
410, // Gone
];
const isRetryableError = (e: any) =>
e instanceof EsErrors.NoLivingConnectionsError ||
e instanceof EsErrors.ConnectionError ||
e instanceof EsErrors.TimeoutError ||
(e instanceof EsErrors.ResponseError && retryResponseStatuses.includes(e?.statusCode!));
/**
* Retries any transient network or configuration issues encountered from Elasticsearch with an exponential backoff.
* Should only be used to wrap operations that are idempotent and can be safely executed more than once.
*/
export const retryTransientEsErrors = async <T>(
esCall: () => Promise<T>,
{ logger, attempt = 0 }: { logger?: Logger; attempt?: number } = {}
): Promise<T> => {
try {
return await esCall();
} catch (e) {
if (attempt < MAX_ATTEMPTS && isRetryableError(e)) {
const retryCount = attempt + 1;
const retryDelaySec = Math.min(Math.pow(2, retryCount), 64); // 2s, 4s, 8s, 16s, 32s, 64s, 64s, 64s ...
logger?.warn(
`Retrying Elasticsearch operation after [${retryDelaySec}s] due to error: ${e.toString()} ${
e.stack
}`
);
await setTimeout(retryDelaySec * 1000);
return retryTransientEsErrors(esCall, { logger, attempt: retryCount });
}
throw e;
}
};

View file

@ -0,0 +1,70 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`generateProcessors(definition) should genearte a valid pipeline 1`] = `
Array [
Object {
"set": Object {
"field": "event.ingested",
"value": "{{{_ingest.timestamp}}}",
},
},
Object {
"set": Object {
"field": "entity.definitionId",
"value": "admin-console-logs-service",
},
},
Object {
"set": Object {
"field": "entity.indexPatterns",
"value": "[\\"kbn-data-forge-fake_stack.*\\"]",
},
},
Object {
"json": Object {
"field": "entity.indexPatterns",
},
},
Object {
"set": Object {
"field": "entity.id",
"value": "service:{{entity.identity.log.logger}}",
},
},
Object {
"set": Object {
"field": "projectId",
"value": "1234",
},
},
Object {
"script": Object {
"source": "if (ctx.entity?.metadata?.tags != null) {
ctx[\\"tags\\"] = ctx.entity.metadata.tags.keySet();
}
if (ctx.entity?.metadata?.host?.name != null) {
ctx[\\"host\\"] = new HashMap();
ctx[\\"host\\"][\\"name\\"] = ctx.entity.metadata.host.name.keySet();
}
if (ctx.entity?.metadata?.kubernetes?.pod?.name != null) {
ctx[\\"kubernetes\\"] = new HashMap();
ctx[\\"kubernetes\\"][\\"pod\\"] = new HashMap();
ctx[\\"kubernetes\\"][\\"pod\\"][\\"name\\"] = ctx.entity.metadata.kubernetes.pod.name.keySet();
}
",
},
},
Object {
"remove": Object {
"field": "entity.metadata",
"ignore_missing": true,
},
},
Object {
"set": Object {
"field": "_index",
"value": ".entities-observability.summary-v1.admin-console-logs-service",
},
},
]
`;

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 { EntityDefinition } from '@kbn/entities-schema';
import { ENTITY_BASE_PREFIX } from '../../../../common/constants_entities';
export function generateIngestPipelineId(definition: EntityDefinition) {
return `${ENTITY_BASE_PREFIX}.${definition.id}`;
}

View file

@ -0,0 +1,16 @@
/*
* 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 { generateProcessors } from './generate_processors';
import { entityDefinition } from '../helpers/fixtures/entity_definition';
describe('generateProcessors(definition)', () => {
it('should genearte a valid pipeline', () => {
const processors = generateProcessors(entityDefinition);
expect(processors).toMatchSnapshot();
});
});

View file

@ -0,0 +1,98 @@
/*
* 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 { EntityDefinition } from '@kbn/entities-schema';
import { generateIndexName } from '../helpers/generate_index_name';
function createIdTemplate(definition: EntityDefinition) {
return definition.identityFields.reduce((template, id) => {
return template.replaceAll(id.field, `entity.identity.${id.field}`);
}, definition.identityTemplate);
}
function mapDesitnationToPainless(destination: string, source: string) {
const fieldParts = destination.split('.');
return fieldParts.reduce((acc, _part, currentIndex, parts) => {
if (currentIndex + 1 === parts.length) {
return `${acc}\n ctx${parts
.map((s) => `["${s}"]`)
.join('')} = ctx.entity.metadata.${source}.keySet();`;
}
return `${acc}\n ctx${parts
.slice(0, currentIndex + 1)
.map((s) => `["${s}"]`)
.join('')} = new HashMap();`;
}, '');
}
function createMetadataPainlessScript(definition: EntityDefinition) {
if (!definition.metadata) {
return '';
}
return definition.metadata.reduce((script, def) => {
const source = def.source;
const destination = def.destination || def.source;
return `${script}if (ctx.entity?.metadata?.${source.replaceAll(
'.',
'?.'
)} != null) {${mapDesitnationToPainless(destination, source)}\n}\n`;
}, '');
}
export function generateProcessors(definition: EntityDefinition) {
return [
{
set: {
field: 'event.ingested',
value: '{{{_ingest.timestamp}}}',
},
},
{
set: {
field: 'entity.definitionId',
value: definition.id,
},
},
{
set: {
field: 'entity.indexPatterns',
value: JSON.stringify(definition.indexPatterns),
},
},
{
json: {
field: 'entity.indexPatterns',
},
},
{
set: {
field: 'entity.id',
value: createIdTemplate(definition),
},
},
...(definition.staticFields != null
? Object.keys(definition.staticFields).map((field) => ({
set: { field, value: definition.staticFields![field] },
}))
: []),
...(definition.metadata != null
? [{ script: { source: createMetadataPainlessScript(definition) } }]
: []),
{
remove: {
field: 'entity.metadata',
ignore_missing: true,
},
},
{
set: {
field: '_index',
value: generateIndexName(definition),
},
},
];
}

View file

@ -0,0 +1,36 @@
/*
* 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 { Logger, SavedObjectsClientContract } from '@kbn/core/server';
import { EntityDefinition, entityDefinitionSchema } from '@kbn/entities-schema';
import { SO_ENTITY_DEFINITION_TYPE } from '../../saved_objects';
import { EntityDefinitionNotFound } from './errors/entity_not_found';
export async function readEntityDefinition(
soClient: SavedObjectsClientContract,
id: string,
logger: Logger
) {
const response = await soClient.find<EntityDefinition>({
type: SO_ENTITY_DEFINITION_TYPE,
page: 1,
perPage: 1,
filter: `${SO_ENTITY_DEFINITION_TYPE}.attributes.id:(${id})`,
});
if (response.total === 0) {
const message = `Unable to find entity defintion with [${id}]`;
logger.error(message);
throw new EntityDefinitionNotFound(message);
}
try {
return entityDefinitionSchema.parse(response.saved_objects[0].attributes);
} catch (e) {
logger.error(`Unable to parse entity defintion with [${id}]`);
throw e;
}
}

View file

@ -0,0 +1,37 @@
/*
* 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 { SavedObjectsClientContract } from '@kbn/core/server';
import { EntityDefinition } from '@kbn/entities-schema';
import { SO_ENTITY_DEFINITION_TYPE } from '../../saved_objects';
import { EntityIdConflict } from './errors/entity_id_conflict_error';
export async function saveEntityDefinition(
soClient: SavedObjectsClientContract,
definition: EntityDefinition
): Promise<EntityDefinition> {
const response = await soClient.find<EntityDefinition>({
type: SO_ENTITY_DEFINITION_TYPE,
page: 1,
perPage: 1,
filter: `${SO_ENTITY_DEFINITION_TYPE}.attributes.id:(${definition.id})`,
});
if (response.total === 1) {
throw new EntityIdConflict(
`Entity defintion with [${definition.id}] already exists.`,
definition
);
}
await soClient.create<EntityDefinition>(SO_ENTITY_DEFINITION_TYPE, definition, {
id: definition.id,
overwrite: true,
});
return definition;
}

View file

@ -0,0 +1,28 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import { ElasticsearchClient, Logger } from '@kbn/core/server';
import { EntityDefinition } from '@kbn/entities-schema';
import { retryTransientEsErrors } from './helpers/retry';
import { generateTransformId } from './transform/generate_transform_id';
export async function startTransform(
esClient: ElasticsearchClient,
definition: EntityDefinition,
logger: Logger
) {
const transformId = generateTransformId(definition);
try {
await retryTransientEsErrors(
() => esClient.transform.startTransform({ transform_id: transformId }, { ignore: [409] }),
{ logger }
);
} catch (err) {
logger.error(`Cannot start entity transform [${transformId}]`);
throw err;
}
}

View file

@ -0,0 +1,37 @@
/*
* 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 { ElasticsearchClient, Logger } from '@kbn/core/server';
import { EntityDefinition } from '@kbn/entities-schema';
import { generateTransformId } from './transform/generate_transform_id';
import { retryTransientEsErrors } from './helpers/retry';
export async function stopAndDeleteTransform(
esClient: ElasticsearchClient,
definition: EntityDefinition,
logger: Logger
) {
const transformId = generateTransformId(definition);
try {
await retryTransientEsErrors(
async () => {
await esClient.transform.stopTransform(
{ transform_id: transformId, wait_for_completion: true, force: true },
{ ignore: [409] }
);
await esClient.transform.deleteTransform(
{ transform_id: transformId, force: true },
{ ignore: [404] }
);
},
{ logger }
);
} catch (e) {
logger.error(`Cannot stop or delete entity transform [${transformId}]`);
throw e;
}
}

View file

@ -0,0 +1,126 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`generateTransform(definition) should generate a valid summary transform 1`] = `
Object {
"defer_validation": true,
"dest": Object {
"index": ".entities-observability.summary-v1.noop",
"pipeline": ".entities-observability.summary-v1.admin-console-logs-service",
},
"frequency": "1m",
"pivot": Object {
"aggs": Object {
"_errorRate_A": Object {
"filter": Object {
"bool": Object {
"minimum_should_match": 1,
"should": Array [
Object {
"match": Object {
"log.level": "error",
},
},
],
},
},
},
"_logRate_A": Object {
"filter": Object {
"bool": Object {
"minimum_should_match": 1,
"should": Array [
Object {
"exists": Object {
"field": "log.level",
},
},
],
},
},
},
"entity.latestTimestamp": Object {
"max": Object {
"field": "@timestamp",
},
},
"entity.metadata.host.name": Object {
"terms": Object {
"field": "host.name",
"size": 1000,
},
},
"entity.metadata.kubernetes.pod.name": Object {
"terms": Object {
"field": "kubernetes.pod.name",
"size": 1000,
},
},
"entity.metadata.tags": Object {
"terms": Object {
"field": "tags",
"size": 1000,
},
},
"entity.metric.errorRate": Object {
"bucket_script": Object {
"buckets_path": Object {
"A": "_errorRate_A>_count",
},
"script": Object {
"lang": "painless",
"source": "params.A / 5",
},
},
},
"entity.metric.logRate": Object {
"bucket_script": Object {
"buckets_path": Object {
"A": "_logRate_A>_count",
},
"script": Object {
"lang": "painless",
"source": "params.A / 5",
},
},
},
},
"group_by": Object {
"entity.identity.log.logger": Object {
"terms": Object {
"field": "log.logger",
"missing_bucket": false,
},
},
},
},
"settings": Object {
"deduce_mappings": false,
"unattended": true,
},
"source": Object {
"index": Array [
"kbn-data-forge-fake_stack.*",
],
"query": Object {
"bool": Object {
"filter": Array [
Object {
"range": Object {
"@timestamp": Object {
"gte": "now-5m",
},
},
},
],
},
},
},
"sync": Object {
"time": Object {
"delay": "60s",
"field": "@timestamp",
},
},
"transform_id": "entities-observability-summary-v1-admin-console-logs-service",
}
`;

View file

@ -0,0 +1,26 @@
/*
* 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 { EntityDefinition } from '@kbn/entities-schema';
export function generateMetadataAggregations(definition: EntityDefinition) {
if (!definition.metadata) {
return {};
}
return definition.metadata.reduce(
(aggs, metadata) => ({
...aggs,
[`entity.metadata.${metadata.destination ?? metadata.source}`]: {
terms: {
field: metadata.source,
size: metadata.limit ?? 1000,
},
},
}),
{}
);
}

View file

@ -0,0 +1,118 @@
/*
* 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 { KeyMetric, Metric, EntityDefinition } from '@kbn/entities-schema';
import { getElasticsearchQueryOrThrow } from '../helpers/get_elasticsearch_query_or_throw';
import { InvalidTransformError } from '../errors/invalid_transform_error';
function buildAggregation(metric: Metric, timestampField: string) {
const { aggregation } = metric;
switch (aggregation) {
case 'doc_count':
return {};
case 'std_deviation':
return {
extended_stats: { field: metric.field },
};
case 'percentile':
if (metric.percentile == null) {
throw new InvalidTransformError(
'You must provide a percentile value for percentile aggregations.'
);
}
return {
percentiles: {
field: metric.field,
percents: [metric.percentile],
keyed: true,
},
};
case 'last_value':
return {
top_metrics: {
metrics: { field: metric.field },
sort: { [timestampField]: 'desc' },
},
};
default:
if (metric.field == null) {
throw new InvalidTransformError('You must provide a field for basic metric aggregations.');
}
return {
[aggregation]: { field: metric.field },
};
}
}
function buildMetricAggregations(keyMetric: KeyMetric, timestampField: string) {
return keyMetric.metrics.reduce((acc, metric) => {
const filter = metric.filter ? getElasticsearchQueryOrThrow(metric.filter) : { match_all: {} };
const aggs = { metric: buildAggregation(metric, timestampField) };
return {
...acc,
[`_${keyMetric.name}_${metric.name}`]: {
filter,
...(metric.aggregation !== 'doc_count' ? { aggs } : {}),
},
};
}, {});
}
function buildBucketPath(prefix: string, metric: Metric) {
const { aggregation } = metric;
switch (aggregation) {
case 'doc_count':
return `${prefix}>_count`;
case 'std_deviation':
return `${prefix}>metric[std_deviation]`;
case 'percentile':
return `${prefix}>metric[${metric.percentile}]`;
case 'last_value':
return `${prefix}>metric[${metric.field}]`;
default:
return `${prefix}>metric`;
}
}
function convertEquationToPainless(bucketsPath: Record<string, string>, equation: string) {
const workingEquation = equation || Object.keys(bucketsPath).join(' + ');
return Object.keys(bucketsPath).reduce((acc, key) => {
return acc.replaceAll(key, `params.${key}`);
}, workingEquation);
}
function buildMetricEquation(keyMetric: KeyMetric) {
const bucketsPath = keyMetric.metrics.reduce(
(acc, metric) => ({
...acc,
[metric.name]: buildBucketPath(`_${keyMetric.name}_${metric.name}`, metric),
}),
{}
);
return {
bucket_script: {
buckets_path: bucketsPath,
script: {
source: convertEquationToPainless(bucketsPath, keyMetric.equation),
lang: 'painless',
},
},
};
}
export function generateMetricAggregations(definition: EntityDefinition) {
if (!definition.metrics) {
return {};
}
return definition.metrics.reduce((aggs, keyMetric) => {
return {
...aggs,
...buildMetricAggregations(keyMetric, definition.timestampField),
[`entity.metric.${keyMetric.name}`]: buildMetricEquation(keyMetric),
};
}, {});
}

View file

@ -0,0 +1,16 @@
/*
* 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 { entityDefinition } from '../helpers/fixtures/entity_definition';
import { generateTransform } from './generate_transform';
describe('generateTransform(definition)', () => {
it('should generate a valid summary transform', () => {
const transform = generateTransform(entityDefinition);
expect(transform).toMatchSnapshot();
});
});

View file

@ -0,0 +1,86 @@
/*
* 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 { EntityDefinition } from '@kbn/entities-schema';
import {
QueryDslQueryContainer,
TransformPutTransformRequest,
} from '@elastic/elasticsearch/lib/api/types';
import { getElasticsearchQueryOrThrow } from '../helpers/get_elasticsearch_query_or_throw';
import { generateMetricAggregations } from './generate_metric_aggregations';
import {
ENTITY_BASE_PREFIX,
ENTITY_DEFAULT_FREQUENCY,
ENTITY_DEFAULT_SYNC_DELAY,
} from '../../../../common/constants_entities';
import { generateMetadataAggregations } from './generate_metadata_aggregations';
import { generateTransformId } from './generate_transform_id';
import { generateIngestPipelineId } from '../ingest_pipeline/generate_ingest_pipeline_id';
export function generateTransform(definition: EntityDefinition): TransformPutTransformRequest {
const filter: QueryDslQueryContainer[] = [
{
range: {
[definition.timestampField]: {
gte: `now-${definition.lookback.toJSON()}`,
},
},
},
];
if (definition.filter) {
filter.push(getElasticsearchQueryOrThrow(definition.filter));
}
return {
transform_id: generateTransformId(definition),
defer_validation: true,
source: {
index: definition.indexPatterns,
query: {
bool: {
filter,
},
},
},
dest: {
index: `${ENTITY_BASE_PREFIX}.noop`,
pipeline: generateIngestPipelineId(definition),
},
frequency: definition.settings?.frequency || ENTITY_DEFAULT_FREQUENCY,
sync: {
time: {
field: definition.settings?.syncField ?? definition.timestampField,
delay: definition.settings?.syncDelay ?? ENTITY_DEFAULT_SYNC_DELAY,
},
},
settings: {
deduce_mappings: false,
unattended: true,
},
pivot: {
group_by: definition.identityFields.reduce(
(acc, id) => ({
...acc,
[`entity.identity.${id.field}`]: {
terms: { field: id.field, missing_bucket: id.optional },
},
}),
{}
),
aggs: {
...generateMetricAggregations(definition),
...generateMetadataAggregations(definition),
'entity.latestTimestamp': {
max: {
field: definition.timestampField,
},
},
},
},
};
}

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 { EntityDefinition } from '@kbn/entities-schema';
import { ENTITY_TRANSFORM_PREFIX } from '../../../../common/constants_entities';
export function generateTransformId(definition: EntityDefinition) {
return `${ENTITY_TRANSFORM_PREFIX}-${definition.id}`;
}

View file

@ -6,6 +6,7 @@
*/
import {
ClusterPutComponentTemplateRequest,
IndicesGetIndexTemplateResponse,
IndicesPutIndexTemplateRequest,
} from '@elastic/elasticsearch/lib/api/types';
@ -47,21 +48,18 @@ function templateExists(
});
}
// interface IndexPatternJson {
// index_patterns: string[];
// name: string;
// template: {
// mappings: Record<string, any>;
// settings: Record<string, any>;
// };
// }
interface TemplateManagementOptions {
esClient: ElasticsearchClient;
template: IndicesPutIndexTemplateRequest;
logger: Logger;
}
interface ComponentManagementOptions {
esClient: ElasticsearchClient;
component: ClusterPutComponentTemplateRequest;
logger: Logger;
}
export async function maybeCreateTemplate({
esClient,
template,
@ -93,9 +91,6 @@ export async function maybeCreateTemplate({
}
export async function upsertTemplate({ esClient, template, logger }: TemplateManagementOptions) {
const pattern = ASSETS_INDEX_PREFIX + '*';
template.index_patterns = [pattern];
try {
await esClient.indices.putIndexTemplate(template);
} catch (error: any) {
@ -108,3 +103,17 @@ export async function upsertTemplate({ esClient, template, logger }: TemplateMan
);
logger.debug(`Asset manager index template: ${JSON.stringify(template)}`);
}
export async function upsertComponent({ esClient, component, logger }: ComponentManagementOptions) {
try {
await esClient.cluster.putComponentTemplate(component);
} catch (error: any) {
logger.error(`Error updating asset manager component template: ${error.message}`);
return;
}
logger.info(
`Asset manager component template is up to date (use debug logging to see what was installed)`
);
logger.debug(`Asset manager component template: ${JSON.stringify(component)}`);
}

View file

@ -15,12 +15,17 @@ import {
Logger,
} from '@kbn/core/server';
import { upsertTemplate } from './lib/manage_index_templates';
import { upsertComponent, upsertTemplate } from './lib/manage_index_templates';
import { setupRoutes } from './routes';
import { assetsIndexTemplateConfig } from './templates/assets_template';
import { AssetClient } from './lib/asset_client';
import { AssetManagerPluginSetupDependencies, AssetManagerPluginStartDependencies } from './types';
import { AssetManagerConfig, configSchema, exposeToBrowserConfig } from '../common/config';
import { entitiesBaseComponentTemplateConfig } from './templates/components/base';
import { entitiesEventComponentTemplateConfig } from './templates/components/event';
import { entitiesIndexTemplateConfig } from './templates/entities_template';
import { entityDefinition } from './saved_objects';
import { entitiesEntityComponentTemplateConfig } from './templates/components/entity';
export type AssetManagerServerPluginSetup = ReturnType<AssetManagerServerPlugin['setup']>;
export type AssetManagerServerPluginStart = ReturnType<AssetManagerServerPlugin['start']>;
@ -56,6 +61,8 @@ export class AssetManagerServerPlugin
this.logger.info('Server is enabled');
core.savedObjects.registerType(entityDefinition);
const assetClient = new AssetClient({
sourceIndices: this.config.sourceIndices,
getApmIndices: plugins.apmDataAccess.getApmIndices,
@ -63,7 +70,7 @@ export class AssetManagerServerPlugin
});
const router = core.http.createRouter();
setupRoutes<RequestHandlerContext>({ router, assetClient });
setupRoutes<RequestHandlerContext>({ router, assetClient, logger: this.logger });
return {
assetClient,
@ -76,12 +83,36 @@ export class AssetManagerServerPlugin
return;
}
const esClient = core.elasticsearch.client.asInternalUser;
upsertTemplate({
esClient: core.elasticsearch.client.asInternalUser,
esClient,
template: assetsIndexTemplateConfig,
logger: this.logger,
}).catch(() => {}); // it shouldn't reject, but just in case
// Install entities compoent templates and index template
Promise.all([
upsertComponent({
esClient,
logger: this.logger,
component: entitiesBaseComponentTemplateConfig,
}),
upsertComponent({
esClient,
logger: this.logger,
component: entitiesEventComponentTemplateConfig,
}),
upsertComponent({
esClient,
logger: this.logger,
component: entitiesEntityComponentTemplateConfig,
}),
])
.then(() =>
upsertTemplate({ esClient, logger: this.logger, template: entitiesIndexTemplateConfig })
)
.catch(() => {});
return {};
}

View file

@ -0,0 +1,78 @@
/*
* 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 { RequestHandlerContext } from '@kbn/core/server';
import { EntityDefinition, entityDefinitionSchema } from '@kbn/entities-schema';
import { stringifyZodError } from '@kbn/zod-helpers';
import { SetupRouteOptions } from '../types';
import { saveEntityDefinition } from '../../lib/entities/save_entity_definition';
import { createAndInstallIngestPipeline } from '../../lib/entities/create_and_install_ingest_pipeline';
import { EntityIdConflict } from '../../lib/entities/errors/entity_id_conflict_error';
import { createAndInstallTransform } from '../../lib/entities/create_and_install_transform';
import { EntitySecurityException } from '../../lib/entities/errors/entity_security_exception';
import { InvalidTransformError } from '../../lib/entities/errors/invalid_transform_error';
import { startTransform } from '../../lib/entities/start_transform';
import { deleteEntityDefinition } from '../../lib/entities/delete_entity_definition';
import { deleteIngestPipeline } from '../../lib/entities/delete_ingest_pipeline';
import { stopAndDeleteTransform } from '../../lib/entities/stop_and_delete_transform';
import { ENTITY_API_PREFIX } from '../../../common/constants_entities';
export function createEntityDefinitionRoute<T extends RequestHandlerContext>({
router,
logger,
}: SetupRouteOptions<T>) {
router.post<unknown, unknown, EntityDefinition>(
{
path: `${ENTITY_API_PREFIX}/definition`,
validate: {
body: (body, res) => {
try {
return res.ok(entityDefinitionSchema.parse(body));
} catch (e) {
return res.badRequest(stringifyZodError(e));
}
},
},
},
async (context, req, res) => {
let definitionCreated = false;
let ingestPipelineCreated = false;
let transformCreated = false;
const soClient = (await context.core).savedObjects.client;
const esClient = (await context.core).elasticsearch.client.asCurrentUser;
try {
const definition = await saveEntityDefinition(soClient, req.body);
definitionCreated = true;
await createAndInstallIngestPipeline(esClient, definition, logger);
ingestPipelineCreated = true;
await createAndInstallTransform(esClient, definition, logger);
transformCreated = true;
await startTransform(esClient, definition, logger);
return res.ok({ body: definition });
} catch (e) {
// Clean up anything that was successful.
if (definitionCreated) {
await deleteEntityDefinition(soClient, req.body, logger);
}
if (ingestPipelineCreated) {
await deleteIngestPipeline(esClient, req.body, logger);
}
if (transformCreated) {
await stopAndDeleteTransform(esClient, req.body, logger);
}
if (e instanceof EntityIdConflict) {
return res.conflict({ body: e });
}
if (e instanceof EntitySecurityException || e instanceof InvalidTransformError) {
return res.customError({ body: e, statusCode: 400 });
}
return res.customError({ body: e, statusCode: 500 });
}
}
);
}

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 { RequestHandlerContext } from '@kbn/core/server';
import { schema } from '@kbn/config-schema';
import { SetupRouteOptions } from '../types';
import { EntitySecurityException } from '../../lib/entities/errors/entity_security_exception';
import { InvalidTransformError } from '../../lib/entities/errors/invalid_transform_error';
import { readEntityDefinition } from '../../lib/entities/read_entity_definition';
import { stopAndDeleteTransform } from '../../lib/entities/stop_and_delete_transform';
import { deleteIngestPipeline } from '../../lib/entities/delete_ingest_pipeline';
import { deleteEntityDefinition } from '../../lib/entities/delete_entity_definition';
import { EntityDefinitionNotFound } from '../../lib/entities/errors/entity_not_found';
import { ENTITY_API_PREFIX } from '../../../common/constants_entities';
export function deleteEntityDefinitionRoute<T extends RequestHandlerContext>({
router,
logger,
}: SetupRouteOptions<T>) {
router.delete<{ id: string }, unknown, unknown>(
{
path: `${ENTITY_API_PREFIX}/definition/{id}`,
validate: {
params: schema.object({
id: schema.string(),
}),
},
},
async (context, req, res) => {
try {
const soClient = (await context.core).savedObjects.client;
const esClient = (await context.core).elasticsearch.client.asCurrentUser;
const definition = await readEntityDefinition(soClient, req.params.id, logger);
await stopAndDeleteTransform(esClient, definition, logger);
await deleteIngestPipeline(esClient, definition, logger);
await deleteEntityDefinition(soClient, definition, logger);
return res.ok({ body: { acknowledged: true } });
} catch (e) {
if (e instanceof EntityDefinitionNotFound) {
return res.notFound({ body: e });
}
if (e instanceof EntitySecurityException || e instanceof InvalidTransformError) {
return res.customError({ body: e, statusCode: 400 });
}
return res.customError({ body: e, statusCode: 500 });
}
}
);
}

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 { RequestHandlerContext } from '@kbn/core/server';
import { schema } from '@kbn/config-schema';
import { SetupRouteOptions } from '../types';
import { EntitySecurityException } from '../../lib/entities/errors/entity_security_exception';
import { InvalidTransformError } from '../../lib/entities/errors/invalid_transform_error';
import { readEntityDefinition } from '../../lib/entities/read_entity_definition';
import { stopAndDeleteTransform } from '../../lib/entities/stop_and_delete_transform';
import { deleteIngestPipeline } from '../../lib/entities/delete_ingest_pipeline';
import { deleteIndex } from '../../lib/entities/delete_index';
import { createAndInstallIngestPipeline } from '../../lib/entities/create_and_install_ingest_pipeline';
import { createAndInstallTransform } from '../../lib/entities/create_and_install_transform';
import { startTransform } from '../../lib/entities/start_transform';
import { EntityDefinitionNotFound } from '../../lib/entities/errors/entity_not_found';
import { ENTITY_API_PREFIX } from '../../../common/constants_entities';
export function resetEntityDefinitionRoute<T extends RequestHandlerContext>({
router,
logger,
}: SetupRouteOptions<T>) {
router.post<{ id: string }, unknown, unknown>(
{
path: `${ENTITY_API_PREFIX}/definition/{id}/_reset`,
validate: {
params: schema.object({
id: schema.string(),
}),
},
},
async (context, req, res) => {
try {
const soClient = (await context.core).savedObjects.client;
const esClient = (await context.core).elasticsearch.client.asCurrentUser;
const definition = await readEntityDefinition(soClient, req.params.id, logger);
// Delete the transform and ingest pipeline
await stopAndDeleteTransform(esClient, definition, logger);
await deleteIngestPipeline(esClient, definition, logger);
await deleteIndex(esClient, definition, logger);
// Recreate everything
await createAndInstallIngestPipeline(esClient, definition, logger);
await createAndInstallTransform(esClient, definition, logger);
await startTransform(esClient, definition, logger);
return res.ok({ body: { acknowledged: true } });
} catch (e) {
if (e instanceof EntityDefinitionNotFound) {
return res.notFound({ body: e });
}
if (e instanceof EntitySecurityException || e instanceof InvalidTransformError) {
return res.customError({ body: e, statusCode: 400 });
}
return res.customError({ body: e, statusCode: 500 });
}
}
);
}

View file

@ -14,16 +14,23 @@ import { hostsRoutes } from './assets/hosts';
import { servicesRoutes } from './assets/services';
import { containersRoutes } from './assets/containers';
import { podsRoutes } from './assets/pods';
import { createEntityDefinitionRoute } from './entities/create';
import { deleteEntityDefinitionRoute } from './entities/delete';
import { resetEntityDefinitionRoute } from './entities/reset';
export function setupRoutes<T extends RequestHandlerContext>({
router,
assetClient,
logger,
}: SetupRouteOptions<T>) {
pingRoute<T>({ router, assetClient });
sampleAssetsRoutes<T>({ router, assetClient });
assetsRoutes<T>({ router, assetClient });
hostsRoutes<T>({ router, assetClient });
servicesRoutes<T>({ router, assetClient });
containersRoutes<T>({ router, assetClient });
podsRoutes<T>({ router, assetClient });
pingRoute<T>({ router, assetClient, logger });
sampleAssetsRoutes<T>({ router, assetClient, logger });
assetsRoutes<T>({ router, assetClient, logger });
hostsRoutes<T>({ router, assetClient, logger });
servicesRoutes<T>({ router, assetClient, logger });
containersRoutes<T>({ router, assetClient, logger });
podsRoutes<T>({ router, assetClient, logger });
createEntityDefinitionRoute<T>({ router, assetClient, logger });
deleteEntityDefinitionRoute<T>({ router, assetClient, logger });
resetEntityDefinitionRoute<T>({ router, assetClient, logger });
}

View file

@ -6,9 +6,11 @@
*/
import { IRouter, RequestHandlerContextBase } from '@kbn/core-http-server';
import { Logger } from '@kbn/core/server';
import { AssetClient } from '../lib/asset_client';
export interface SetupRouteOptions<T extends RequestHandlerContextBase> {
router: IRouter<T>;
assetClient: AssetClient;
logger: Logger;
}

View file

@ -0,0 +1,40 @@
/*
* 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 { SavedObject, SavedObjectsType } from '@kbn/core/server';
import { EntityDefinition } from '@kbn/entities-schema';
export const SO_ENTITY_DEFINITION_TYPE = 'entity-definition';
export const entityDefinition: SavedObjectsType = {
name: SO_ENTITY_DEFINITION_TYPE,
hidden: false,
namespaceType: 'multiple-isolated',
mappings: {
dynamic: false,
properties: {
id: { type: 'keyword' },
name: { type: 'text' },
description: { type: 'text' },
type: { type: 'keyword' },
filter: { type: 'keyword' },
indexPatterns: { type: 'keyword' },
identityFields: { type: 'object' },
categories: { type: 'keyword' },
metadata: { type: 'object' },
metrics: { type: 'object' },
staticFields: { type: 'object' },
},
},
management: {
displayName: 'Entity Definition',
importableAndExportable: false,
getTitle(sloSavedObject: SavedObject<EntityDefinition>) {
return `EntityDefinition: [${sloSavedObject.attributes.name}]`;
},
},
};

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 { entityDefinition, SO_ENTITY_DEFINITION_TYPE } from './entity_definition';

View file

@ -6,11 +6,13 @@
*/
import { IndicesPutIndexTemplateRequest } from '@elastic/elasticsearch/lib/api/types';
import { ASSETS_INDEX_PREFIX } from '../constants';
export const assetsIndexTemplateConfig: IndicesPutIndexTemplateRequest = {
name: 'assets',
priority: 100,
data_stream: {},
index_patterns: [`${ASSETS_INDEX_PREFIX}*`],
template: {
settings: {},
mappings: {

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 { ClusterPutComponentTemplateRequest } from '@elastic/elasticsearch/lib/api/types';
export const entitiesBaseComponentTemplateConfig: ClusterPutComponentTemplateRequest = {
name: 'entities_v1_base',
_meta: {
documentation: 'https://www.elastic.co/guide/en/ecs/current/ecs-base.html',
ecs_version: '8.0.0',
},
template: {
mappings: {
properties: {
'@timestamp': {
type: 'date',
},
labels: {
type: 'object',
},
tags: {
ignore_above: 1024,
type: 'keyword',
},
},
},
},
};

View file

@ -0,0 +1,40 @@
/*
* 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 { ClusterPutComponentTemplateRequest } from '@elastic/elasticsearch/lib/api/types';
export const entitiesEntityComponentTemplateConfig: ClusterPutComponentTemplateRequest = {
name: 'entities_v1_entity',
_meta: {
ecs_version: '8.0.0',
},
template: {
mappings: {
properties: {
entity: {
properties: {
id: {
ignore_above: 1024,
type: 'keyword',
},
indexPatterns: {
ignore_above: 1024,
type: 'keyword',
},
defintionId: {
ignore_above: 1024,
type: 'keyword',
},
latestTimestamp: {
type: 'date',
},
},
},
},
},
},
};

View file

@ -0,0 +1,29 @@
/*
* 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 { ClusterPutComponentTemplateRequest } from '@elastic/elasticsearch/lib/api/types';
export const entitiesEventComponentTemplateConfig: ClusterPutComponentTemplateRequest = {
name: 'entities_v1_event',
_meta: {
documentation: 'https://www.elastic.co/guide/en/ecs/current/ecs-event.html',
ecs_version: '8.0.0',
},
template: {
mappings: {
properties: {
event: {
properties: {
ingested: {
type: 'date',
},
},
},
},
},
},
};

View file

@ -0,0 +1,60 @@
/*
* 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 { IndicesPutIndexTemplateRequest } from '@elastic/elasticsearch/lib/api/types';
import { ENTITY_BASE_PREFIX } from '../../common/constants_entities';
export const entitiesIndexTemplateConfig: IndicesPutIndexTemplateRequest = {
name: 'entities_v1_index_template',
_meta: {
description: 'The entities index template',
ecs_version: '8.0.0',
},
composed_of: ['entities_v1_base', 'entities_v1_event', 'entities_v1_entity'],
index_patterns: [`${ENTITY_BASE_PREFIX}.*`],
priority: 1,
template: {
mappings: {
_meta: {
version: '1.6.0',
},
date_detection: false,
dynamic_templates: [
{
strings_as_keyword: {
mapping: {
ignore_above: 1024,
type: 'keyword',
},
match_mapping_type: 'string',
},
},
{
entity_metrics: {
mapping: {
// @ts-expect-error this should work per: https://www.elastic.co/guide/en/elasticsearch/reference/current/dynamic-templates.html#match-mapping-type
type: '{dynamic_type}',
},
// @ts-expect-error this should work per: https://www.elastic.co/guide/en/elasticsearch/reference/current/dynamic-templates.html#match-mapping-type
match_mapping_type: ['long', 'double'],
path_match: 'entity.metric.*',
},
},
],
},
settings: {
index: {
codec: 'best_compression',
mapping: {
total_fields: {
limit: 2000,
},
},
},
},
},
};

View file

@ -26,6 +26,9 @@
"@kbn/metrics-data-access-plugin",
"@kbn/core-elasticsearch-server",
"@kbn/core-saved-objects-api-server",
"@kbn/core-saved-objects-api-server-mocks"
"@kbn/core-saved-objects-api-server-mocks",
"@kbn/entities-schema",
"@kbn/es-query",
"@kbn/zod-helpers"
]
}

View file

@ -4583,6 +4583,10 @@
version "0.0.0"
uid ""
"@kbn/entities-schema@link:x-pack/packages/kbn-entities-schema":
version "0.0.0"
uid ""
"@kbn/error-boundary-example-plugin@link:examples/error_boundary":
version "0.0.0"
uid ""