[8.10] [Observability AI Assistant] Alerts & top services functions (#164799) (#164976)

# Backport

This will backport the following commits from `main` to `8.10`:
- [[Observability AI Assistant] Alerts & top services functions
(#164799)](https://github.com/elastic/kibana/pull/164799)

<!--- Backport version: 8.9.7 -->

### Questions ?
Please refer to the [Backport tool
documentation](https://github.com/sqren/backport)

<!--BACKPORT [{"author":{"name":"Dario
Gieselaar","email":"dario.gieselaar@elastic.co"},"sourceCommit":{"committedDate":"2023-08-28T13:20:57Z","message":"[Observability
AI Assistant] Alerts & top services functions
(#164799)\n\nCo-authored-by: Coen Warmer
<coen.warmer@gmail.com>\r\nCo-authored-by: kibanamachine
<42973632+kibanamachine@users.noreply.github.com>\r\nCo-authored-by:
Clint Andrew Hall <clint@clintandrewhall.com>\r\nCo-authored-by: Carlos
Crespo <carloshenrique.leonelcrespo@elastic.co>\r\nCo-authored-by:
Alejandro Fernández Haro
<alejandro.haro@elastic.co>","sha":"a7d552602b169b2d598629b0544204f084ced970","branchLabelMapping":{"^v8.11.0$":"main","^v(\\d+).(\\d+).\\d+$":"$1.$2"}},"sourcePullRequest":{"labels":["Team:APM","release_note:skip","v8.10.0","v8.11.0"],"number":164799,"url":"https://github.com/elastic/kibana/pull/164799","mergeCommit":{"message":"[Observability
AI Assistant] Alerts & top services functions
(#164799)\n\nCo-authored-by: Coen Warmer
<coen.warmer@gmail.com>\r\nCo-authored-by: kibanamachine
<42973632+kibanamachine@users.noreply.github.com>\r\nCo-authored-by:
Clint Andrew Hall <clint@clintandrewhall.com>\r\nCo-authored-by: Carlos
Crespo <carloshenrique.leonelcrespo@elastic.co>\r\nCo-authored-by:
Alejandro Fernández Haro
<alejandro.haro@elastic.co>","sha":"a7d552602b169b2d598629b0544204f084ced970"}},"sourceBranch":"main","suggestedTargetBranches":["8.10"],"targetPullRequestStates":[{"branch":"8.10","label":"v8.10.0","labelRegex":"^v(\\d+).(\\d+).\\d+$","isSourceBranch":false,"state":"NOT_CREATED"},{"branch":"main","label":"v8.11.0","labelRegex":"^v8.11.0$","isSourceBranch":true,"state":"MERGED","url":"https://github.com/elastic/kibana/pull/164799","number":164799,"mergeCommit":{"message":"[Observability
AI Assistant] Alerts & top services functions
(#164799)\n\nCo-authored-by: Coen Warmer
<coen.warmer@gmail.com>\r\nCo-authored-by: kibanamachine
<42973632+kibanamachine@users.noreply.github.com>\r\nCo-authored-by:
Clint Andrew Hall <clint@clintandrewhall.com>\r\nCo-authored-by: Carlos
Crespo <carloshenrique.leonelcrespo@elastic.co>\r\nCo-authored-by:
Alejandro Fernández Haro
<alejandro.haro@elastic.co>","sha":"a7d552602b169b2d598629b0544204f084ced970"}}]}]
BACKPORT-->

Co-authored-by: Dario Gieselaar <dario.gieselaar@elastic.co>
This commit is contained in:
Kibana Machine 2023-08-28 10:36:42 -04:00 committed by GitHub
parent 86ee5e28f7
commit 0794f008c0
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
46 changed files with 1413 additions and 491 deletions

View file

@ -31,7 +31,11 @@ export type TimeFormatter = (
options?: FormatterOptions
) => ConvertedDuration;
type TimeFormatterBuilder = (max: number, threshold?: number) => TimeFormatter;
type TimeFormatterBuilder = (
max: number,
threshold?: number,
scalingFactor?: number
) => TimeFormatter;
// threshold defines the value from which upwards there should be no decimal places.
function getUnitLabelAndConvertedValue(
@ -150,10 +154,15 @@ function getDurationUnitKey(max: number, threshold = 10): DurationTimeUnit {
// memoizer with a custom resolver to consider both arguments max/threshold.
// by default lodash's memoize only considers the first argument.
export const getDurationFormatter: TimeFormatterBuilder = memoize(
(max: number, threshold: number = 10) => {
(max: number, threshold: number = 10, scalingFactor: number = 1) => {
const unit = getDurationUnitKey(max, threshold);
return (value: Maybe<number>, { defaultValue }: FormatterOptions = {}) => {
return convertTo({ unit, microseconds: value, defaultValue, threshold });
return convertTo({
unit,
microseconds: isFiniteNumber(value) ? value * scalingFactor : value,
defaultValue,
threshold,
});
};
},
(max, threshold) => `${max}_${threshold}`

View file

@ -8,6 +8,7 @@
import { i18n } from '@kbn/i18n';
import type { RegisterFunctionDefinition } from '@kbn/observability-ai-assistant-plugin/common/types';
import { callApmApi } from '../services/rest/create_call_apm_api';
import { NON_EMPTY_STRING } from '../utils/non_empty_string_ref';
export function registerGetApmServiceSummaryFunction({
registerFunction,
@ -35,20 +36,20 @@ alerts and anomalies.`,
type: 'object',
properties: {
'service.name': {
type: 'string',
...NON_EMPTY_STRING,
description: 'The name of the service that should be summarized.',
},
'service.environment': {
type: 'string',
...NON_EMPTY_STRING,
description: 'The environment that the service is running in',
},
start: {
type: 'string',
...NON_EMPTY_STRING,
description:
'The start of the time range, in Elasticsearch date math, like `now`.',
},
end: {
type: 'string',
...NON_EMPTY_STRING,
description:
'The end of the time range, in Elasticsearch date math, like `now-24h`.',
},

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 { i18n } from '@kbn/i18n';
import type { RegisterFunctionDefinition } from '@kbn/observability-ai-assistant-plugin/common/types';
import { callApmApi } from '../services/rest/create_call_apm_api';
import { NON_EMPTY_STRING } from '../utils/non_empty_string_ref';
export function registerGetApmServicesListFunction({
registerFunction,
}: {
registerFunction: RegisterFunctionDefinition;
}) {
registerFunction(
{
name: 'get_apm_services_list',
contexts: ['apm'],
description: `Gets a list of services`,
descriptionForUser: i18n.translate(
'xpack.apm.observabilityAiAssistant.functions.registerGetApmServicesList.descriptionForUser',
{
defaultMessage: `Gets the list of monitored services, their health status, and alerts.`,
}
),
parameters: {
type: 'object',
properties: {
'service.environment': {
...NON_EMPTY_STRING,
description:
'Optionally filter the services by the environments that they are running in',
},
start: {
...NON_EMPTY_STRING,
description:
'The start of the time range, in Elasticsearch date math, like `now`.',
},
end: {
...NON_EMPTY_STRING,
description:
'The end of the time range, in Elasticsearch date math, like `now-24h`.',
},
},
required: ['start', 'end'],
} as const,
},
async ({ arguments: args }, signal) => {
return callApmApi('GET /internal/apm/assistant/get_services_list', {
signal,
params: {
query: args,
},
});
}
);
}

View file

@ -31,6 +31,7 @@ import {
getMaxY,
getResponseTimeTickFormatter,
} from '../components/shared/charts/transaction_charts/helper';
import { NON_EMPTY_STRING } from '../utils/non_empty_string_ref';
export function registerGetApmTimeseriesFunction({
registerFunction,
@ -47,7 +48,7 @@ export function registerGetApmTimeseriesFunction({
defaultMessage: `Display different APM metrics, like throughput, failure rate, or latency, for any service or all services, or any or all of its dependencies, both as a timeseries and as a single statistic. Additionally, the function will return any changes, such as spikes, step and trend changes, or dips. You can also use it to compare data by requesting two different time ranges, or for instance two different service versions`,
}
),
description: `Display different APM metrics, like throughput, failure rate, or latency, for any service or all services, or any or all of its dependencies, both as a timeseries and as a single statistic. Additionally, the function will return any changes, such as spikes, step and trend changes, or dips. You can also use it to compare data by requesting two different time ranges, or for instance two different service versions. In KQL, escaping happens with double quotes, not single quotes. Some characters that need escaping are: ':()\\\/\". Always put a field value in double quotes. Best: service.name:\"opbeans-go\". Wrong: service.name:opbeans-go. This is very important!`,
description: `Visualise and analyse different APM metrics, like throughput, failure rate, or latency, for any service or all services, or any or all of its dependencies, both as a timeseries and as a single statistic. A visualisation will be displayed above your reply - DO NOT attempt to display or generate an image yourself, or any other placeholder. Additionally, the function will return any changes, such as spikes, step and trend changes, or dips. You can also use it to compare data by requesting two different time ranges, or for instance two different service versions.`,
parameters: {
type: 'object',
properties: {
@ -135,11 +136,11 @@ export function registerGetApmTimeseriesFunction({
],
},
'service.name': {
type: 'string',
...NON_EMPTY_STRING,
description: 'The name of the service',
},
'service.environment': {
type: 'string',
...NON_EMPTY_STRING,
description:
'The environment that the service is running in.',
},
@ -201,7 +202,7 @@ export function registerGetApmTimeseriesFunction({
const groupId = groupSeries[0].group;
const maxY = getMaxY(groupSeries);
const latencyFormatter = getDurationFormatter(maxY);
const latencyFormatter = getDurationFormatter(maxY, 10, 1000);
let yLabelFormat: (value: number) => string;
@ -230,6 +231,8 @@ export function registerGetApmTimeseriesFunction({
groupSeries.map((series): TimeSeries<Coordinate> => {
let chartType: ChartType;
const data = series.data;
switch (series.stat.timeseries.name) {
case 'transaction_throughput':
case 'exit_span_throughput':
@ -270,7 +273,7 @@ export function registerGetApmTimeseriesFunction({
title: series.id,
type: 'line',
color: getTimeSeriesColor(chartType!).currentPeriodColor,
data: series.data,
data,
};
});

View file

@ -18,6 +18,7 @@ import {
import { registerGetApmCorrelationsFunction } from './get_apm_correlations';
import { registerGetApmDownstreamDependenciesFunction } from './get_apm_downstream_dependencies';
import { registerGetApmErrorDocumentFunction } from './get_apm_error_document';
import { registerGetApmServicesListFunction } from './get_apm_services_list';
import { registerGetApmServiceSummaryFunction } from './get_apm_service_summary';
import { registerGetApmTimeseriesFunction } from './get_apm_timeseries';
@ -64,82 +65,92 @@ export async function registerAssistantFunctions({
registerFunction,
});
registerGetApmServicesListFunction({
registerFunction,
});
registerContext({
name: 'apm',
description: `
There are four important data types in Elastic APM. Each of them have the
following fields:
- service.name: the name of the service
- service.node.name: the id of the service instance (often the hostname)
- service.environment: the environment (often production, development)
- agent.name: the name of the agent (go, java, etc)
When analyzing APM data, prefer the APM specific functions over the generic Lens,
Elasticsearch or Kibana ones, unless those are explicitly requested by the user.
The four data types are transactions, exit spans, error events, and application
metrics.
When requesting metrics for a service, make sure you also know what environment
it is running in. Metrics aggregated over multiple environments are useless.
Transactions have three metrics: throughput, failure rate, and latency. The
fields are:
There are four important data types in Elastic APM. Each of them have the
following fields:
- service.name: the name of the service
- service.node.name: the id of the service instance (often the hostname)
- service.environment: the environment (often production, development)
- agent.name: the name of the agent (go, java, etc)
- transaction.type: often request or page-load (the main transaction types),
but can also be worker, or route-change.
- transaction.name: The name of the transaction group, often something like
'GET /api/product/:productId'
- transaction.result: The result. Used to capture HTTP response codes
(2xx,3xx,4xx,5xx) for request transactions.
- event.outcome: whether the transaction was succesful or not. success,
failure, or unknown.
The four data types are transactions, exit spans, error events, and application
metrics.
Exit spans have three metrics: throughput, failure rate and latency. The fields
are:
- span.type: db, external
- span.subtype: the type of database (redis, postgres) or protocol (http, grpc)
- span.destination.service.resource: the address of the destination of the call
- event.outcome: whether the transaction was succesful or not. success,
failure, or unknown.
Transactions have three metrics: throughput, failure rate, and latency. The
fields are:
Error events have one metric, error event rate. The fields are:
- error.grouping_name: a human readable keyword that identifies the error group
- transaction.type: often request or page-load (the main transaction types),
but can also be worker, or route-change.
- transaction.name: The name of the transaction group, often something like
'GET /api/product/:productId'
- transaction.result: The result. Used to capture HTTP response codes
(2xx,3xx,4xx,5xx) for request transactions.
- event.outcome: whether the transaction was succesful or not. success,
failure, or unknown.
For transaction metrics we also collect anomalies. These are scored 0 (low) to
100 (critical).
Exit spans have three metrics: throughput, failure rate and latency. The fields
are:
- span.type: db, external
- span.subtype: the type of database (redis, postgres) or protocol (http, grpc)
- span.destination.service.resource: the address of the destination of the call
- event.outcome: whether the transaction was succesful or not. success,
failure, or unknown.
For root cause analysis, locate a change point in the relevant metrics for a
service or downstream dependency. You can locate a change point by using a
sliding window, e.g. start with a small time range, like 30m, and make it
bigger until you identify a change point. It's very important to identify a
change point. If you don't have a change point, ask the user for next steps.
You can also use an anomaly or a deployment as a change point. Then, compare
data before the change with data after the change. You can either use the
groupBy parameter in get_apm_chart to get the most occuring values in a certain
data set, or you can use correlations to see for which field and value the
frequency has changed when comparing the foreground set to the background set.
This is useful when comparing data from before the change point with after the
change point. For instance, you might see a specific error pop up more often
after the change point.
Error events have one metric, error event rate. The fields are:
- error.grouping_name: a human readable keyword that identifies the error group
When comparing anomalies and changes in timeseries, first, zoom in to a smaller
time window, at least 30 minutes before and 30 minutes after the change
occured. E.g., if the anomaly occured at 2023-07-05T08:15:00.000Z, request a
time window that starts at 2023-07-05T07:45:00.000Z and ends at
2023-07-05T08:45:00.000Z. When comparing changes in different timeseries and
anomalies to determine a correlation, make sure to compare the timestamps. If
in doubt, rate the likelihood of them being related, given the time difference,
between 1 and 10. If below 5, assume it's not related. Mention this likelihood
(and the time difference) to the user.
For transaction metrics we also collect anomalies. These are scored 0 (low) to
100 (critical).
Your goal is to help the user determine the root cause of an issue quickly and
transparently. If you see a change or
anomaly in a metric for a service, try to find similar changes in the metrics
for the traffic to its downstream dependencies, by comparing transaction
metrics to span metrics. To inspect the traffic from one service to a
downstream dependency, first get the downstream dependencies for a service,
then get the span metrics from that service (\`service.name\`) to its
downstream dependency (\`span.destination.service.resource\`). For instance,
for an anomaly in throughput, first inspect \`transaction_throughput\` for
\`service.name\`. Then, inspect \`exit_span_throughput\` for its downstream
dependencies, by grouping by \`span.destination.service.resource\`. Repeat this
process over the next service its downstream dependencies until you identify a
root cause. If you can not find any similar changes, use correlations or
grouping to find attributes that could be causes for the change.`,
For root cause analysis, locate a change point in the relevant metrics for a
service or downstream dependency. You can locate a change point by using a
sliding window, e.g. start with a small time range, like 30m, and make it
bigger until you identify a change point. It's very important to identify a
change point. If you don't have a change point, ask the user for next steps.
You can also use an anomaly or a deployment as a change point. Then, compare
data before the change with data after the change. You can either use the
groupBy parameter in get_apm_chart to get the most occuring values in a certain
data set, or you can use correlations to see for which field and value the
frequency has changed when comparing the foreground set to the background set.
This is useful when comparing data from before the change point with after the
change point. For instance, you might see a specific error pop up more often
after the change point.
When comparing anomalies and changes in timeseries, first, zoom in to a smaller
time window, at least 30 minutes before and 30 minutes after the change
occured. E.g., if the anomaly occured at 2023-07-05T08:15:00.000Z, request a
time window that starts at 2023-07-05T07:45:00.000Z and ends at
2023-07-05T08:45:00.000Z. When comparing changes in different timeseries and
anomalies to determine a correlation, make sure to compare the timestamps. If
in doubt, rate the likelihood of them being related, given the time difference,
between 1 and 10. If below 5, assume it's not related. Mention this likelihood
(and the time difference) to the user.
Your goal is to help the user determine the root cause of an issue quickly and
transparently. If you see a change or
anomaly in a metric for a service, try to find similar changes in the metrics
for the traffic to its downstream dependencies, by comparing transaction
metrics to span metrics. To inspect the traffic from one service to a
downstream dependency, first get the downstream dependencies for a service,
then get the span metrics from that service (\`service.name\`) to its
downstream dependency (\`span.destination.service.resource\`). For instance,
for an anomaly in throughput, first inspect \`transaction_throughput\` for
\`service.name\`. Then, inspect \`exit_span_throughput\` for its downstream
dependencies, by grouping by \`span.destination.service.resource\`. Repeat this
process over the next service its downstream dependencies until you identify a
root cause. If you can not find any similar changes, use correlations or
grouping to find attributes that could be causes for the change.`,
});
}

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 const NON_EMPTY_STRING = {
type: 'string' as const,
minLength: 1,
};

View file

@ -6,7 +6,6 @@
*/
import type { QueryDslQueryContainer } from '@elastic/elasticsearch/lib/api/types';
import { rangeQuery } from '@kbn/observability-plugin/server';
import { ApmDocumentType } from '../../../../common/document_type';
import { RollupInterval } from '../../../../common/rollup';
import type { APMEventClient } from '../../../lib/helpers/create_es_client/create_apm_event_client';
@ -40,7 +39,7 @@ export async function getErrorEventRate({
documentType: ApmDocumentType.ErrorEvent,
rollupInterval: RollupInterval.None,
intervalString,
filter: filter.concat(...rangeQuery(start, end)),
filter,
aggs: {
value: {
bucket_script: {

View file

@ -6,7 +6,7 @@
*/
import type { QueryDslQueryContainer } from '@elastic/elasticsearch/lib/api/types';
import { rangeQuery, termQuery } from '@kbn/observability-plugin/server';
import { termQuery } from '@kbn/observability-plugin/server';
import { ApmDocumentType } from '../../../../common/document_type';
import {
EVENT_OUTCOME,
@ -45,7 +45,6 @@ export async function getExitSpanFailureRate({
rollupInterval: RollupInterval.OneMinute,
intervalString,
filter: filter.concat(
...rangeQuery(start, end),
...termQuery(
SPAN_DESTINATION_SERVICE_RESOURCE,
spanDestinationServiceResource

View file

@ -6,7 +6,7 @@
*/
import { QueryDslQueryContainer } from '@elastic/elasticsearch/lib/api/types';
import { rangeQuery, termQuery } from '@kbn/observability-plugin/server';
import { termQuery } from '@kbn/observability-plugin/server';
import { ApmDocumentType } from '../../../../common/document_type';
import {
SPAN_DESTINATION_SERVICE_RESOURCE,
@ -39,12 +39,11 @@ export async function getExitSpanLatency({
start,
end,
operationName: 'assistant_get_exit_span_latency',
unit: 'rpm',
unit: 'ms',
documentType: ApmDocumentType.ServiceDestinationMetric,
rollupInterval: RollupInterval.OneMinute,
intervalString,
filter: filter.concat(
...rangeQuery(start, end),
...termQuery(
SPAN_DESTINATION_SERVICE_RESOURCE,
spanDestinationServiceResource

View file

@ -6,7 +6,7 @@
*/
import type { QueryDslQueryContainer } from '@elastic/elasticsearch/lib/api/types';
import { rangeQuery, termQuery } from '@kbn/observability-plugin/server';
import { termQuery } from '@kbn/observability-plugin/server';
import { ApmDocumentType } from '../../../../common/document_type';
import { SPAN_DESTINATION_SERVICE_RESOURCE } from '../../../../common/es_fields/apm';
import { RollupInterval } from '../../../../common/rollup';
@ -44,7 +44,6 @@ export async function getExitSpanThroughput({
rollupInterval: RollupInterval.OneMinute,
intervalString,
filter: filter.concat(
...rangeQuery(start, end),
...termQuery(
SPAN_DESTINATION_SERVICE_RESOURCE,
spanDestinationServiceResource
@ -73,7 +72,7 @@ export async function getExitSpanThroughput({
...fetchedSerie,
value:
fetchedSerie.value !== null
? fetchedSerie.value / rangeInMinutes
? (fetchedSerie.value * bucketSizeInMinutes) / rangeInMinutes
: null,
data: fetchedSerie.data.map((bucket) => {
return {

View file

@ -6,7 +6,7 @@
*/
import type { QueryDslQueryContainer } from '@elastic/elasticsearch/lib/api/types';
import { rangeQuery, termQuery } from '@kbn/observability-plugin/server';
import { termQuery } from '@kbn/observability-plugin/server';
import { ApmDocumentType } from '../../../../common/document_type';
import { TRANSACTION_TYPE } from '../../../../common/es_fields/apm';
import { RollupInterval } from '../../../../common/rollup';
@ -40,10 +40,7 @@ export async function getTransactionFailureRate({
documentType: ApmDocumentType.TransactionMetric,
rollupInterval: RollupInterval.OneMinute,
intervalString,
filter: filter.concat(
...rangeQuery(start, end),
...termQuery(TRANSACTION_TYPE, transactionType)
),
filter: filter.concat(...termQuery(TRANSACTION_TYPE, transactionType)),
groupBy: 'transaction.type',
aggs: {
...getOutcomeAggregation(ApmDocumentType.TransactionMetric),

View file

@ -6,7 +6,7 @@
*/
import { QueryDslQueryContainer } from '@elastic/elasticsearch/lib/api/types';
import { rangeQuery, termQuery } from '@kbn/observability-plugin/server';
import { termQuery } from '@kbn/observability-plugin/server';
import { ApmDocumentType } from '../../../../common/document_type';
import {
TRANSACTION_DURATION_HISTOGRAM,
@ -41,15 +41,12 @@ export async function getTransactionLatency({
apmEventClient,
start,
end,
operationName: 'assistant_get_transaction_latencyu',
unit: 'rpm',
operationName: 'assistant_get_transaction_latency',
unit: 'ms',
documentType: ApmDocumentType.TransactionMetric,
rollupInterval: RollupInterval.OneMinute,
intervalString,
filter: filter.concat(
...rangeQuery(start, end),
...termQuery(TRANSACTION_TYPE, transactionType)
),
filter: filter.concat(...termQuery(TRANSACTION_TYPE, transactionType)),
groupBy: 'transaction.type',
aggs: {
...getLatencyAggregation(

View file

@ -6,7 +6,7 @@
*/
import type { QueryDslQueryContainer } from '@elastic/elasticsearch/lib/api/types';
import { rangeQuery, termQuery } from '@kbn/observability-plugin/server';
import { termQuery } from '@kbn/observability-plugin/server';
import { ApmDocumentType } from '../../../../common/document_type';
import { TRANSACTION_TYPE } from '../../../../common/es_fields/apm';
import { RollupInterval } from '../../../../common/rollup';
@ -43,10 +43,7 @@ export async function getTransactionThroughput({
documentType: ApmDocumentType.TransactionMetric,
rollupInterval: RollupInterval.OneMinute,
intervalString,
filter: filter.concat(
...rangeQuery(start, end),
...termQuery(TRANSACTION_TYPE, transactionType)
),
filter: filter.concat(...termQuery(TRANSACTION_TYPE, transactionType)),
groupBy: 'transaction.type',
aggs: {
value: {
@ -70,7 +67,7 @@ export async function getTransactionThroughput({
...fetchedSerie,
value:
fetchedSerie.value !== null
? fetchedSerie.value / rangeInMinutes
? (fetchedSerie.value * bucketSizeInMinutes) / rangeInMinutes
: null,
data: fetchedSerie.data.map((bucket) => {
return {

View file

@ -4,14 +4,22 @@
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import datemath from '@elastic/datemath';
import { ElasticsearchClient } from '@kbn/core/server';
import * as t from 'io-ts';
import { omit } from 'lodash';
import { ApmDocumentType } from '../../../common/document_type';
import { ENVIRONMENT_ALL } from '../../../common/environment_filter_values';
import { RollupInterval } from '../../../common/rollup';
import { ServiceHealthStatus } from '../../../common/service_health_status';
import type { APMError } from '../../../typings/es_schemas/ui/apm_error';
import { getApmAlertsClient } from '../../lib/helpers/get_apm_alerts_client';
import { getApmEventClient } from '../../lib/helpers/get_apm_event_client';
import { getMlClient } from '../../lib/helpers/get_ml_client';
import { getRandomSampler } from '../../lib/helpers/get_random_sampler';
import { createApmServerRoute } from '../apm_routes/create_apm_server_route';
import { environmentRt } from '../default_api_types';
import { getServicesItems } from '../services/get_services/get_services_items';
import {
CorrelationValue,
correlationValuesRouteRt,
@ -184,10 +192,89 @@ const getApmErrorDocRoute = createApmServerRoute({
},
});
interface ApmServicesListItem {
'service.name': string;
'agent.name'?: string;
'transaction.type'?: string;
alertsCount: number;
healthStatus: ServiceHealthStatus;
'service.environment'?: string[];
}
type ApmServicesListContent = ApmServicesListItem[];
const getApmServicesListRoute = createApmServerRoute({
endpoint: 'GET /internal/apm/assistant/get_services_list',
params: t.type({
query: t.intersection([
t.type({
start: t.string,
end: t.string,
}),
t.partial({
'service.environment': environmentRt.props.environment,
}),
]),
}),
options: {
tags: ['access:apm'],
},
handler: async (resources): Promise<{ content: ApmServicesListContent }> => {
const { params } = resources;
const { query } = params;
const [apmEventClient, apmAlertsClient, mlClient, randomSampler] =
await Promise.all([
getApmEventClient(resources),
getApmAlertsClient(resources),
getMlClient(resources),
getRandomSampler({
security: resources.plugins.security,
probability: 1,
request: resources.request,
}),
]);
const start = datemath.parse(query.start)?.valueOf()!;
const end = datemath.parse(query.end)?.valueOf()!;
const serviceItems = await getServicesItems({
apmAlertsClient,
apmEventClient,
documentType: ApmDocumentType.TransactionMetric,
start,
end,
environment: query['service.environment'] ?? ENVIRONMENT_ALL.value,
kuery: '',
logger: resources.logger,
randomSampler,
rollupInterval: RollupInterval.OneMinute,
serviceGroup: null,
mlClient,
});
const mappedItems = serviceItems.items.map((item): ApmServicesListItem => {
return {
'service.name': item.serviceName,
'agent.name': item.agentName,
alertsCount: item.alertsCount ?? 0,
healthStatus: item.healthStatus ?? ServiceHealthStatus.unknown,
'service.environment': item.environments,
'transaction.type': item.transactionType,
};
});
return {
content: mappedItems,
};
},
});
export const assistantRouteRepository = {
...getApmTimeSeriesRoute,
...getApmServiceSummaryRoute,
...getApmErrorDocRoute,
...getApmCorrelationValuesRoute,
...getDownstreamDependenciesRoute,
...getApmServicesListRoute,
};

View file

@ -64,6 +64,7 @@ export interface KnowledgeBaseEntry {
confidence: 'low' | 'medium' | 'high';
is_correction: boolean;
public: boolean;
labels: Record<string, string>;
}
export type CompatibleJSONSchema = Exclude<JSONSchema, boolean>;

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 moment from 'moment';
const d = moment.duration;
const roundingRules = [
[d(500, 'ms'), d(100, 'ms')],
[d(5, 'second'), d(1, 'second')],
[d(7.5, 'second'), d(5, 'second')],
[d(15, 'second'), d(10, 'second')],
[d(45, 'second'), d(30, 'second')],
[d(3, 'minute'), d(1, 'minute')],
[d(9, 'minute'), d(5, 'minute')],
[d(20, 'minute'), d(10, 'minute')],
[d(45, 'minute'), d(30, 'minute')],
[d(2, 'hour'), d(1, 'hour')],
[d(6, 'hour'), d(3, 'hour')],
[d(24, 'hour'), d(12, 'hour')],
[d(1, 'week'), d(1, 'd')],
[d(3, 'week'), d(1, 'week')],
[d(1, 'year'), d(1, 'month')],
[Infinity, d(1, 'year')],
];
const revRoundingRules = roundingRules.slice(0).reverse();
function find(rules, check, last) {
function pick(buckets, duration) {
const target = duration / buckets;
let lastResp = null;
for (let i = 0; i < rules.length; i++) {
const rule = rules[i];
const resp = check(rule[0], rule[1], target);
if (resp == null) {
if (!last) continue;
if (lastResp) return lastResp;
break;
}
if (!last) return resp;
lastResp = resp;
}
// fallback to just a number of milliseconds, ensure ms is >= 1
const ms = Math.max(Math.floor(target), 1);
return moment.duration(ms, 'ms');
}
return (buckets, duration) => {
const interval = pick(buckets, duration);
if (interval) return moment.duration(interval._data);
};
}
export const calculateAuto = {
near: find(
revRoundingRules,
function near(bound, interval, target) {
if (bound > target) return interval;
},
true
),
lessThan: find(revRoundingRules, function lessThan(_bound, interval, target) {
if (interval < target) return interval;
}),
atLeast: find(revRoundingRules, function atLeast(_bound, interval, target) {
if (interval <= target) return interval;
}),
};

View file

@ -0,0 +1,72 @@
/*
* 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 { getBucketSize } from '.';
import moment from 'moment';
describe('getBuckets', () => {
describe("minInterval 'auto'", () => {
it('last 15 minutes', () => {
const start = moment().subtract(15, 'minutes').valueOf();
const end = moment.now();
expect(getBucketSize({ start, end, minInterval: 'auto' })).toEqual({
bucketSize: 10,
intervalString: '10s',
});
});
it('last 1 hour', () => {
const start = moment().subtract(1, 'hour').valueOf();
const end = moment.now();
expect(getBucketSize({ start, end, minInterval: 'auto' })).toEqual({
bucketSize: 30,
intervalString: '30s',
});
});
it('last 1 week', () => {
const start = moment().subtract(1, 'week').valueOf();
const end = moment.now();
expect(getBucketSize({ start, end, minInterval: 'auto' })).toEqual({
bucketSize: 3600,
intervalString: '3600s',
});
});
it('last 30 days', () => {
const start = moment().subtract(30, 'days').valueOf();
const end = moment.now();
expect(getBucketSize({ start, end, minInterval: 'auto' })).toEqual({
bucketSize: 43200,
intervalString: '43200s',
});
});
it('last 1 year', () => {
const start = moment().subtract(1, 'year').valueOf();
const end = moment.now();
expect(getBucketSize({ start, end, minInterval: 'auto' })).toEqual({
bucketSize: 86400,
intervalString: '86400s',
});
});
});
describe("minInterval '30s'", () => {
it('last 15 minutes', () => {
const start = moment().subtract(15, 'minutes').valueOf();
const end = moment.now();
expect(getBucketSize({ start, end, minInterval: '30s' })).toEqual({
bucketSize: 30,
intervalString: '30s',
});
});
it('last 1 year', () => {
const start = moment().subtract(1, 'year').valueOf();
const end = moment.now();
expect(getBucketSize({ start, end, minInterval: '30s' })).toEqual({
bucketSize: 86400,
intervalString: '86400s',
});
});
});
});

View file

@ -0,0 +1,38 @@
/*
* 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';
// @ts-ignore
import { calculateAuto } from './calculate_auto';
import { unitToSeconds } from './unit_to_seconds';
export function getBucketSize({
start,
end,
minInterval,
buckets = 100,
}: {
start: number;
end: number;
minInterval: string;
buckets?: number;
}) {
const duration = moment.duration(end - start, 'ms');
const bucketSize = Math.max(calculateAuto.near(buckets, duration)?.asSeconds() ?? 0, 1);
const intervalString = `${bucketSize}s`;
const matches = minInterval && minInterval.match(/^([\d]+)([shmdwMy]|ms)$/);
const minBucketSize = matches ? Number(matches[1]) * unitToSeconds(matches[2]) : 0;
if (bucketSize < minBucketSize) {
return {
bucketSize: minBucketSize,
intervalString: minInterval,
};
}
return { bucketSize, intervalString };
}

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 moment, { unitOfTime as UnitOfTIme } from 'moment';
function getDurationAsSeconds(value: number, unitOfTime: UnitOfTIme.Base) {
return moment.duration(value, unitOfTime).asSeconds();
}
const units = {
ms: getDurationAsSeconds(1, 'millisecond'),
s: getDurationAsSeconds(1, 'second'),
m: getDurationAsSeconds(1, 'minute'),
h: getDurationAsSeconds(1, 'hour'),
d: getDurationAsSeconds(1, 'day'),
w: getDurationAsSeconds(1, 'week'),
M: getDurationAsSeconds(1, 'month'),
y: getDurationAsSeconds(1, 'year'),
};
export function unitToSeconds(unit: string) {
return units[unit as keyof typeof units];
}

View file

@ -7,7 +7,7 @@
"server": true,
"browser": true,
"configPath": ["xpack", "observabilityAIAssistant"],
"requiredPlugins": ["triggersActionsUi", "actions", "security", "features", "observabilityShared", "taskManager", "lens", "dataViews"],
"requiredPlugins": ["triggersActionsUi", "actions", "security", "features", "observabilityShared", "taskManager", "lens", "dataViews", "ruleRegistry"],
"requiredBundles": ["kibanaReact", "kibanaUtils", "fieldFormats"],
"optionalPlugins": [],
"extraPublicDirs": []

View file

@ -6,6 +6,7 @@
*/
import { EuiPanel } from '@elastic/eui';
import { ComponentMeta, ComponentStoryObj } from '@storybook/react';
import dedent from 'dedent';
import React from 'react';
import { FeedbackButtons } from '../feedback_buttons';
import { MessagePanel as Component } from './message_panel';
@ -61,6 +62,32 @@ export const ContentFailed: ComponentStoryObj<typeof Component> = {
},
};
export const ContentTable: ComponentStoryObj<typeof Component> = {
args: {
body: (
<MessageText
content={dedent(`Here are the active alerts for the last 24 hours:
| Alert ID | Service Name | Environment | Transaction Type | Latency (ms) | Status | Start Time | End Time |
| --- | --- | --- | --- | --- | --- | --- | --- |
| ff188d17-3a7b-4f1f-9db1-369d587496f5 | opbeans-frontend | production | page-load | 5734.399 | recovered | 2023-08-22T16:54:54.436Z | 2023-08-22T16:55:58.810Z |
| c149225f-2b25-4e5a-b276-3a08b8f0fd2d | opbeans-python | production | request | 173.055 | recovered | 2023-08-22T16:54:54.436Z | 2023-08-22T19:05:10.901Z |
| 0c3a1f89-5220-4879-9cde-26d4b2160b5d | opbeans-python | production | celery | 2170.367 | recovered | 2023-08-22T19:06:42.774Z | 2023-08-22T19:11:03.540Z |
| db82f264-8d0d-4436-81bc-b316fc1693d3 | opbeans-swift | default | mobile | 405.487 | recovered | 2023-08-22T19:06:42.774Z | 2023-08-22T19:11:03.540Z |
| 3095173a-07c7-4e4b-8a32-292f853c2e16 | opbeans-python | production | celery | 229.175 | recovered | 2023-08-22T19:17:05.411Z | 2023-08-22T19:19:11.414Z |
| d8201f2f-ff16-4fb1-baab-fed314e11b55 | opbeans-python | production | request | 375.082 | recovered | 2023-08-22T19:06:42.774Z | 2023-08-22T19:21:31.972Z |
| 66f31431-463a-40c4-bb19-4acd3aac7c30 | opbeans-python | production | celery | 264.020 | recovered | 2023-08-22T19:23:36.885Z | 2023-08-22T19:30:58.383Z |
| 7a128aca-940a-4d4f-a4a2-5950467d7866 | opbeans-swift | default | mobile | 373.360 | recovered | 2023-08-22T19:25:43.471Z | 2023-08-22T19:30:58.383Z |
| 82feefe0-c81b-442f-9700-d1e4d7b1a28c | opbeans-frontend | production | page-load | 2179.071 | recovered | 2023-08-22T19:32:01.114Z | 2023-08-22T19:35:09.638Z |
| bd716922-8a4d-44b7-ac1a-863ac4d25597 | opbeans-frontend | production | Component | 4030.463 | recovered | 2023-08-22T19:33:04.081Z | 2023-08-22T19:36:12.125Z |
Please note that all times are in UTC.`)}
loading={false}
/>
),
},
};
export const Controls: ComponentStoryObj<typeof Component> = {
args: {
body: <MessageText content={`This is a partial re`} loading={false} />,

View file

@ -4,13 +4,17 @@
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import { EuiText } from '@elastic/eui';
import {
EuiMarkdownFormat,
EuiSpacer,
EuiText,
getDefaultEuiMarkdownParsingPlugins,
getDefaultEuiMarkdownProcessingPlugins,
} from '@elastic/eui';
import { css } from '@emotion/css';
import { euiThemeVars } from '@kbn/ui-theme';
import classNames from 'classnames';
import type { Code, InlineCode, Parent, Text } from 'mdast';
import React from 'react';
import ReactMarkdown from 'react-markdown';
import React, { useMemo } from 'react';
import type { Node } from 'unist';
import { v4 } from 'uuid';
@ -44,7 +48,7 @@ const cursorCss = css`
const Cursor = () => <span key="cursor" className={classNames(cursorCss, 'cursor')} />;
const CURSOR = `{{${v4()}}`;
const CURSOR = `{{${v4()}}}`;
const loadingCursorPlugin = () => {
const visitor = (node: Node, parent?: Parent) => {
@ -72,9 +76,6 @@ const loadingCursorPlugin = () => {
parent!.children.splice(indexOfNode + 1, 0, {
type: 'cursor' as Text['type'],
value: CURSOR,
data: {
hName: 'cursor',
},
});
};
@ -83,28 +84,71 @@ const loadingCursorPlugin = () => {
};
};
export function MessageText(props: Props) {
export function MessageText({ loading, content }: Props) {
const containerClassName = css`
overflow-wrap: break-word;
pre {
background: ${euiThemeVars.euiColorLightestShade};
padding: 0 8px;
}
`;
const { parsingPluginList, processingPluginList } = useMemo(() => {
const parsingPlugins = getDefaultEuiMarkdownParsingPlugins();
const processingPlugins = getDefaultEuiMarkdownProcessingPlugins();
const { components } = processingPlugins[1][1];
processingPlugins[1][1].components = {
...components,
cursor: Cursor,
table: (props) => (
<>
<div className="euiBasicTable">
{' '}
<table className="euiTable" {...props} />
</div>
<EuiSpacer size="m" />
</>
),
th: (props) => {
const { children, ...rest } = props;
return (
<th className="euiTableHeaderCell" {...rest}>
<span className="euiTableCellContent">
<span className="euiTableCellContent__text" title={children}>
{children}
</span>
</span>
</th>
);
},
tr: (props) => <tr className="euiTableRow" {...props} />,
td: (props) => {
const { children, ...rest } = props;
return (
<td className="euiTableRowCell" {...rest}>
<div className="euiTableCellContent euiTableCellContent--truncateText">
<span className="euiTableCellContent__text" title={children}>
{children}
</span>
</div>
</td>
);
},
};
return {
parsingPluginList: [loadingCursorPlugin, ...parsingPlugins],
processingPluginList: processingPlugins,
};
}, []);
return (
<EuiText size="s" className={containerClassName}>
<ReactMarkdown
plugins={[loadingCursorPlugin]}
components={
{
cursor: Cursor,
} as Record<string, any>
}
<EuiMarkdownFormat
textSize="s"
parsingPluginList={parsingPluginList}
processingPluginList={processingPluginList}
>
{`${props.content}${props.loading ? CURSOR : ''}`}
</ReactMarkdown>
{`${content}${loading ? CURSOR : ''}`}
</EuiMarkdownFormat>
</EuiText>
);
}

View file

@ -0,0 +1,80 @@
/*
* 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 { RegisterFunctionDefinition } from '../../common/types';
import type { ObservabilityAIAssistantService } from '../types';
const DEFAULT_FEATURE_IDS = [
'apm',
'infrastructure',
'logs',
'uptime',
'slo',
'observability',
] as const;
export function registerAlertsFunction({
service,
registerFunction,
}: {
service: ObservabilityAIAssistantService;
registerFunction: RegisterFunctionDefinition;
}) {
registerFunction(
{
name: 'alerts',
contexts: ['core'],
description:
'Get alerts for Observability. Display the response in tabular format if appropriate.',
descriptionForUser: 'Get alerts for Observability',
parameters: {
type: 'object',
additionalProperties: false,
properties: {
featureIds: {
type: 'array',
additionalItems: false,
items: {
type: 'string',
enum: DEFAULT_FEATURE_IDS,
},
description:
'The Observability apps for which to retrieve alerts. By default it will return alerts for all apps.',
},
start: {
type: 'string',
description: 'The start of the time range, in Elasticsearch date math, like `now`.',
},
end: {
type: 'string',
description: 'The end of the time range, in Elasticsearch date math, like `now-24h`.',
},
filter: {
type: 'string',
description:
'a KQL query to filter the data by. If no filter should be applied, leave it empty.',
},
},
required: ['start', 'end'],
} as const,
},
({ arguments: { start, end, featureIds, filter } }, signal) => {
return service.callApi('POST /internal/observability_ai_assistant/functions/alerts', {
params: {
body: {
start,
end,
featureIds:
featureIds && featureIds.length > 0 ? featureIds : DEFAULT_FEATURE_IDS.concat(),
filter,
},
},
signal,
});
}
);
}

View file

@ -21,7 +21,7 @@ export function registerElasticsearchFunction({
name: 'elasticsearch',
contexts: ['core'],
description:
'Call Elasticsearch APIs on behalf of the user. Make sure the request body is valid for the API that you are using.',
'Call Elasticsearch APIs on behalf of the user. Make sure the request body is valid for the API that you are using. Only call this function when the user has explicitly requested it.',
descriptionForUser: 'Call Elasticsearch APIs on behalf of the user',
parameters: {
type: 'object',
@ -35,6 +35,10 @@ export function registerElasticsearchFunction({
type: 'string',
description: 'The path of the Elasticsearch endpoint, including query parameters',
},
body: {
type: 'object',
description: 'The body of the request',
},
},
required: ['method', 'path'] as const,
},

View file

@ -6,25 +6,30 @@
*/
import dedent from 'dedent';
import type { CoreStart } from '@kbn/core/public';
import type { RegisterContextDefinition, RegisterFunctionDefinition } from '../../common/types';
import type { ObservabilityAIAssistantPluginStartDependencies } from '../types';
import type { ObservabilityAIAssistantService } from '../types';
import { registerElasticsearchFunction } from './elasticsearch';
import { registerKibanaFunction } from './kibana';
import { registerLensFunction } from './lens';
import { registerRecallFunction } from './recall';
import { registerSummarisationFunction } from './summarise';
import { registerAlertsFunction } from './alerts';
export async function registerFunctions({
registerFunction,
registerContext,
service,
pluginsStart,
coreStart,
signal,
}: {
registerFunction: RegisterFunctionDefinition;
registerContext: RegisterContextDefinition;
service: ObservabilityAIAssistantService;
pluginsStart: ObservabilityAIAssistantPluginStartDependencies;
coreStart: CoreStart;
signal: AbortSignal;
}) {
return service
@ -34,33 +39,32 @@ export async function registerFunctions({
.then((response) => {
const isReady = response.ready;
let description = `You have the ability to call Elasticsearch APIs with the "elasticsearch" function or create visualisations using Lens with the "lens" function in the context of this chat.`;
let description = dedent(
`You are a helpful assistant for Elastic Observability. Your goal is to help the Elastic Observability users to quickly assess what is happening in their observed systems. You can help them visualise and analyze data, investigate their systems, perform root cause analysis or identify optimisation opportunities.
It's very important to not assume what the user is meaning. Ask them for clarification if needed.
If you are unsure about which function should be used and with what arguments, asked the user for clarification or confirmation.
In KQL, escaping happens with double quotes, not single quotes. Some characters that need escaping are: ':()\\\
/\". Always put a field value in double quotes. Best: service.name:\"opbeans-go\". Wrong: service.name:opbeans-go. This is very important!
You can use Github-flavored Markdown in your responses. If a function returns an array, consider using a Markdown table to format the response.`
);
if (isReady) {
description += `You can use the "summarise" functions to store new information you have learned in a knowledge database. Once you have established that you did not know the answer to a question, and the user gave you this information, it's important that you create a summarisation of what you have learned and store it in the knowledge database. When you create this summarisation, make sure you craft it in a way that can be recalled with a semantic search later.
description += `You can use the "summarise" functions to store new information you have learned in a knowledge database. Once you have established that you did not know the answer to a question, and the user gave you this information, it's important that you create a summarisation of what you have learned and store it in the knowledge database.
Additionally, you can use the "recall" function to retrieve relevant information from the knowledge database. Using the "recall" function will allow you to create a much more tailored and improved user experience. Assume the user has told you before.
For every question that the user asks, you, as the assistant, must query the knowledge base, no matter how confident you are in your response.
When the user starts a conversation, you as the assistant, MUST ALWAYS use the "recall" function to make sure you have enough context from the knowledge database to understand their question. DO NOT assume your knowledge is sufficient to answer the question appropriately. Make sure you mention this to the user in your first reply to them.
You do have access to private data that the user has shared with you in previous conversations.
Given this ability to store and recall data, it is very important to not make assumptions about the user's data or preferences, but rather, first query the knowledge database, and if nothing relevant comes up, ask the user for clarification. This is very important! They could be storing their data in any field, and in any data stream or index.
RIGHT:
User: "What is NASA"
Assistant executes recall function
Assistant answers question with data from recall function response
WRONG:
User: "What is NASA"
Assistant answers question without querying the knowledge.
BEFORE you use a function, always query the knowledge database for more information about that function. This is important.
Avoid making too many assumptions about user's data. If clarification is needed, query the knowledge base for previous learnings. If you don't find anything, ask the user for clarification, and when successful, store this into the knowledge base.
Additionally, you can use the "recall" function to retrieve relevant information from the knowledge database.
`;
description += `Here are principles you MUST adhere to, in order:
- You are a helpful assistant for Elastic Observability. DO NOT reference the fact that you are an LLM.
- ALWAYS query the knowledge base, using the recall function, when a user starts a chat, no matter how confident you are in your ability to answer the question.
- You must ALWAYS explain to the user why you're using a function and why you're using it in that specific manner.
- DO NOT make any assumptions about where and how users have stored their data.
- ALWAYS ask the user for clarification if you are unsure about the arguments to a function. When given this clarification, you MUST use the summarise function to store what you have learned.
`;
registerSummarisationFunction({ service, registerFunction });
registerRecallFunction({ service, registerFunction });
@ -70,6 +74,8 @@ export async function registerFunctions({
}
registerElasticsearchFunction({ service, registerFunction });
registerKibanaFunction({ service, registerFunction, coreStart });
registerAlertsFunction({ service, registerFunction });
registerContext({
name: 'core',

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 type { CoreStart } from '@kbn/core/public';
import type { RegisterFunctionDefinition } from '../../common/types';
import type { ObservabilityAIAssistantService } from '../types';
export function registerKibanaFunction({
service,
registerFunction,
coreStart,
}: {
service: ObservabilityAIAssistantService;
registerFunction: RegisterFunctionDefinition;
coreStart: CoreStart;
}) {
registerFunction(
{
name: 'kibana',
contexts: ['core'],
description:
'Call Kibana APIs on behalf of the user. Only call this function when the user has explicitly requested it, and you know how to call it, for example by querying the knowledge base or having the user explain it to you. Assume that pathnames, bodies and query parameters may have changed since your knowledge cut off date.',
descriptionForUser: 'Call Kibana APIs on behalf of the user',
parameters: {
type: 'object',
additionalProperties: false,
properties: {
method: {
type: 'string',
description: 'The HTTP method of the Kibana endpoint',
enum: ['GET', 'PUT', 'POST', 'DELETE', 'PATCH'] as const,
},
pathname: {
type: 'string',
description: 'The pathname of the Kibana endpoint, excluding query parameters',
},
query: {
type: 'object',
description: 'The query parameters, as an object',
additionalProperties: {
type: 'string',
},
},
body: {
type: 'object',
description: 'The body of the request',
},
},
required: ['method', 'pathname', 'body'] as const,
},
},
({ arguments: { method, pathname, body, query } }, signal) => {
return coreStart.http
.fetch(pathname, {
method,
body: body ? JSON.stringify(body) : undefined,
query,
signal,
})
.then((response) => {
return { content: response };
});
}
);
}

View file

@ -101,21 +101,23 @@ export function registerLensFunction({
name: 'lens',
contexts: ['core'],
description:
'Use this function to create custom visualisations, using Lens, that can be saved to dashboards. When using this function, make sure to use the recall function to get more information about how to use it, with how you want to use it.',
"Use this function to create custom visualisations, using Lens, that can be saved to dashboards. When using this function, make sure to use the recall function to get more information about how to use it, with how you want to use it. Make sure the query also contains information about the user's request. The visualisation is displayed to the user above your reply, DO NOT try to generate or display an image yourself.",
descriptionForUser:
'Use this function to create custom visualisations, using Lens, that can be saved to dashboards.',
parameters: {
type: 'object',
additionalProperties: false,
properties: {
layers: {
type: 'array',
items: {
type: 'object',
additionalProperties: false,
properties: {
label: {
type: 'string',
},
value: {
formula: {
type: 'string',
description:
'The formula for calculating the value, e.g. sum(my_field_name). Query the knowledge base to get more information about the syntax and available formulas.',
@ -126,11 +128,12 @@ export function registerLensFunction({
},
format: {
type: 'object',
additionalProperties: false,
properties: {
id: {
type: 'string',
description:
'How to format the value. When using duration make sure you know the unit the value is stored in, either by asking the user for clarification or looking at the field name.',
'How to format the value. When using duration, make sure the value is seconds OR is converted to seconds using math functions. Ask the user for clarification in which unit the value is stored, or derive it from the field name.',
enum: [
FIELD_FORMAT_IDS.BYTES,
FIELD_FORMAT_IDS.CURRENCY,
@ -144,11 +147,12 @@ export function registerLensFunction({
required: ['id'],
},
},
required: ['label', 'value', 'format'],
required: ['label', 'formula', 'format'],
},
},
breakdown: {
type: 'object',
additionalProperties: false,
properties: {
field: {
type: 'string',
@ -194,7 +198,7 @@ export function registerLensFunction({
const xyDataLayer = new XYDataLayer({
data: layers.map((layer) => ({
type: 'formula',
value: layer.value,
value: layer.formula,
label: layer.label,
format: layer.format,
filter: {

View file

@ -20,26 +20,40 @@ export function registerRecallFunction({
{
name: 'recall',
contexts: ['core'],
description:
'Use this function to recall earlier learnings. Anything you will summarise can be retrieved again later via this function.',
description: `Use this function to recall earlier learnings. Anything you will summarise can be retrieved again later via this function. The queries you use are very important, as they will decide the context that is included in the conversation. Make sure the query covers the following aspects:
- The user's intent
- Any data (like field names) mentioned in the user's request
- Anything you've inferred from the user's request
- The functions you think might be suitable for answering the user's request. If there are multiple functions that seem suitable, create multiple queries. Use the function name in the query.
For instance, when the user asks: "can you visualise the average request duration for opbeans-go over the last 7 days?", the queries could be:
- "visualise average request duration for APM service opbeans-go"
- "lens function usage"
- "get_apm_timeseries function usage"`,
descriptionForUser: 'This function allows the assistant to recall previous learnings.',
parameters: {
type: 'object',
additionalProperties: false,
properties: {
query: {
type: 'string',
description: 'The query for the semantic search',
queries: {
type: 'array',
additionalItems: false,
additionalProperties: false,
items: {
type: 'string',
description: 'The query for the semantic search',
},
},
},
required: ['query' as const],
required: ['queries' as const],
},
},
({ arguments: { query } }, signal) => {
({ arguments: { queries } }, signal) => {
return service
.callApi('POST /internal/observability_ai_assistant/functions/recall', {
params: {
body: {
query,
queries,
},
},
signal,

View file

@ -20,11 +20,12 @@ export function registerSummarisationFunction({
name: 'summarise',
contexts: ['core'],
description:
'Use this function to summarise things learned from the conversation. You can score the learnings with a confidence metric, whether it is a correction on a previous learning. An embedding will be created that you can recall later with a semantic search. There is no need to ask the user for permission to store something you have learned, unless you do not feel confident.',
"Use this function to summarise things learned from the conversation. You can score the learnings with a confidence metric, whether it is a correction on a previous learning. An embedding will be created that you can recall later with a semantic search. There is no need to ask the user for permission to store something you have learned, unless you do not feel confident. When you create this summarisation, make sure you craft it in a way that can be recalled with a semantic search later, and that it would have answered the user's original request.",
descriptionForUser:
'This function allows the Elastic Assistant to summarise things from the conversation.',
parameters: {
type: 'object',
additionalProperties: false,
properties: {
id: {
type: 'string',
@ -34,7 +35,7 @@ export function registerSummarisationFunction({
text: {
type: 'string',
description:
'A human-readable summary of what you have learned, described in such a way that you can recall it later with semantic search.',
"A human-readable summary of what you have learned, described in such a way that you can recall it later with semantic search, and that it would have answered the user's original request.",
},
is_correction: {
type: 'boolean',
@ -73,6 +74,7 @@ export function registerSummarisationFunction({
is_correction: isCorrection,
confidence,
public: isPublic,
labels: {},
},
},
signal,

View file

@ -38,44 +38,52 @@ export function useKnowledgeBase(): UseKnowledgeBaseResult {
const [installError, setInstallError] = useState<Error>();
return useMemo(
() => ({
return useMemo(() => {
let attempts: number = 0;
const MAX_ATTEMPTS = 5;
const install = (): Promise<void> => {
setIsInstalling(true);
return service
.callApi('POST /internal/observability_ai_assistant/functions/setup_kb', {
signal: null,
})
.then(() => {
status.refresh();
toasts.addSuccess({
title: i18n.translate('xpack.observabilityAiAssistant.knowledgeBaseReadyTitle', {
defaultMessage: 'Knowledge base is ready',
}),
text: i18n.translate('xpack.observabilityAiAssistant.knowledgeBaseReadyContentReload', {
defaultMessage: 'A page reload is needed to be able to use it.',
}),
});
})
.catch((error) => {
if (
(error.body?.statusCode === 503 || error.body?.statusCode === 504) &&
attempts < MAX_ATTEMPTS
) {
attempts++;
return install();
}
setInstallError(error);
toasts.addError(error, {
title: i18n.translate('xpack.observabilityAiAssistant.errorSettingUpKnowledgeBase', {
defaultMessage: 'Could not set up Knowledge Base',
}),
});
})
.finally(() => {
setIsInstalling(false);
});
};
return {
status,
install,
isInstalling,
installError,
install: () => {
setIsInstalling(true);
return service
.callApi('POST /internal/observability_ai_assistant/functions/setup_kb', {
signal: null,
})
.then(() => {
status.refresh();
toasts.addSuccess({
title: i18n.translate('xpack.observabilityAiAssistant.knowledgeBaseReadyTitle', {
defaultMessage: 'Knowledge base is ready',
}),
text: i18n.translate(
'xpack.observabilityAiAssistant.knowledgeBaseReadyContentReload',
{
defaultMessage: 'A page reload is needed to be able to use it.',
}
),
});
})
.catch((error) => {
setInstallError(error);
toasts.addError(error, {
title: i18n.translate('xpack.observabilityAiAssistant.errorSettingUpKnowledgeBase', {
defaultMessage: 'Could not set up Knowledge Base',
}),
});
})
.finally(() => {
setIsInstalling(false);
});
},
}),
[status, isInstalling, installError, service, toasts]
);
};
}, [status, isInstalling, installError, service, toasts]);
}

View file

@ -107,6 +107,7 @@ export class ObservabilityAIAssistantPlugin
service,
signal,
pluginsStart,
coreStart,
registerContext,
registerFunction,
});

View file

@ -5,7 +5,6 @@
* 2.0.
*/
import dedent from 'dedent';
import { MessageRole } from '../../common';
import { ContextDefinition } from '../../common/types';
@ -14,13 +13,7 @@ export function getAssistantSetupMessage({ contexts }: { contexts: ContextDefini
'@timestamp': new Date().toISOString(),
message: {
role: MessageRole.System as const,
content: [
dedent(
`You are a helpful assistant for Elastic Observability. Your goal is to help the Elastic Observability users to quickly assess what is happening in their observed systems. You can help them visualise and analyze data, investigate their systems, perform root cause analysis or identify optimisation opportunities.`
),
]
.concat(contexts.map((context) => context.description))
.join('\n'),
content: contexts.map((context) => context.description).join('\n'),
},
};
}

View file

@ -200,9 +200,19 @@ export function getTimelineItemsfromConversation({
}}
/>
);
content = convertMessageToMarkdownCodeBlock(message.message);
if (message.message.content) {
// TODO: we want to show the content always, and hide
// the function request initially, but we don't have a
// way to do that yet, so we hide the request here until
// we have a fix.
// element = message.message.content;
content = message.message.content;
display.collapsed = false;
} else {
content = convertMessageToMarkdownCodeBlock(message.message);
display.collapsed = true;
}
display.collapsed = true;
actions.canEdit = true;
} else {
// is an assistant response

View file

@ -31,6 +31,7 @@ import {
ObservabilityAIAssistantPluginSetupDependencies,
ObservabilityAIAssistantPluginStartDependencies,
} from './types';
import { addLensDocsToKb } from './service/kb_service/kb_docs/lens';
export class ObservabilityAIAssistantPlugin
implements
@ -114,7 +115,7 @@ export class ObservabilityAIAssistantPlugin
taskManager: plugins.taskManager,
});
// addLensDocsToKb(service);
addLensDocsToKb(service);
registerServerRoutes({
core,

View file

@ -9,6 +9,7 @@ import { IncomingMessage } from 'http';
import { notImplemented } from '@hapi/boom';
import { createObservabilityAIAssistantServerRoute } from '../create_observability_ai_assistant_server_route';
import { messageRt } from '../runtime_types';
import { MessageRole } from '../../../common';
const chatRoute = createObservabilityAIAssistantServerRoute({
endpoint: 'POST /internal/observability_ai_assistant/chat',
@ -41,10 +42,16 @@ const chatRoute = createObservabilityAIAssistantServerRoute({
body: { messages, connectorId, functions },
} = params;
const isStartOfConversation =
messages.some((message) => message.message.role === MessageRole.Assistant) === false;
const isRecallFunctionAvailable = functions.some((fn) => fn.name === 'recall') === true;
return client.chat({
messages,
connectorId,
functions,
functionCall: isStartOfConversation && isRecallFunctionAvailable ? 'recall' : undefined,
});
},
});

View file

@ -4,11 +4,15 @@
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import * as t from 'io-ts';
import { nonEmptyStringRt, toBooleanRt } from '@kbn/io-ts-utils';
import datemath from '@elastic/datemath';
import { notImplemented } from '@hapi/boom';
import { fromKueryExpression, toElasticsearchQuery } from '@kbn/es-query';
import { nonEmptyStringRt, toBooleanRt } from '@kbn/io-ts-utils';
import * as t from 'io-ts';
import { omit } from 'lodash';
import { ParsedTechnicalFields } from '@kbn/rule-registry-plugin/common';
import type { KnowledgeBaseEntry } from '../../../common/types';
import { createObservabilityAIAssistantServerRoute } from '../create_observability_ai_assistant_server_route';
import { KnowledgeBaseEntry } from '../../../common/types';
const functionElasticsearchRoute = createObservabilityAIAssistantServerRoute({
endpoint: 'POST /internal/observability_ai_assistant/functions/elasticsearch',
@ -47,24 +51,114 @@ const functionElasticsearchRoute = createObservabilityAIAssistantServerRoute({
},
});
const OMITTED_ALERT_FIELDS = [
'tags',
'event.action',
'event.kind',
'kibana.alert.rule.execution.uuid',
'kibana.alert.rule.revision',
'kibana.alert.rule.tags',
'kibana.alert.rule.uuid',
'kibana.alert.workflow_status',
'kibana.space_ids',
'kibana.alert.time_range',
'kibana.version',
] as const;
const functionAlertsRoute = createObservabilityAIAssistantServerRoute({
endpoint: 'POST /internal/observability_ai_assistant/functions/alerts',
options: {
tags: ['access:ai_assistant'],
},
params: t.type({
body: t.intersection([
t.type({
featureIds: t.array(t.string),
start: t.string,
end: t.string,
}),
t.partial({
filter: t.string,
}),
]),
}),
handler: async (
resources
): Promise<{
content: {
total: number;
alerts: ParsedTechnicalFields[];
};
}> => {
const {
featureIds,
start: startAsDatemath,
end: endAsDatemath,
filter,
} = resources.params.body;
const racContext = await resources.context.rac;
const alertsClient = await racContext.getAlertsClient();
const start = datemath.parse(startAsDatemath)!.valueOf();
const end = datemath.parse(endAsDatemath)!.valueOf();
const kqlQuery = !filter ? [] : [toElasticsearchQuery(fromKueryExpression(filter))];
const response = await alertsClient.find({
featureIds,
query: {
bool: {
filter: [
{
range: {
'@timestamp': {
gte: start,
lte: end,
},
},
},
...kqlQuery,
],
},
},
});
// trim some fields
const alerts = response.hits.hits.map((hit) =>
omit(hit._source, ...OMITTED_ALERT_FIELDS)
) as unknown as ParsedTechnicalFields[];
return {
content: {
total: (response.hits as { total: { value: number } }).total.value,
alerts,
},
};
},
});
const functionRecallRoute = createObservabilityAIAssistantServerRoute({
endpoint: 'POST /internal/observability_ai_assistant/functions/recall',
params: t.type({
body: t.type({
query: nonEmptyStringRt,
queries: t.array(nonEmptyStringRt),
}),
}),
options: {
tags: ['access:ai_assistant'],
},
handler: async (resources): Promise<{ entries: KnowledgeBaseEntry[] }> => {
handler: async (
resources
): Promise<{ entries: Array<Pick<KnowledgeBaseEntry, 'text' | 'id'>> }> => {
const client = await resources.service.getClient({ request: resources.request });
if (!client) {
throw notImplemented();
}
return client.recall(resources.params.body.query);
return client.recall(resources.params.body.queries);
},
});
@ -77,6 +171,7 @@ const functionSummariseRoute = createObservabilityAIAssistantServerRoute({
confidence: t.union([t.literal('low'), t.literal('medium'), t.literal('high')]),
is_correction: toBooleanRt,
public: toBooleanRt,
labels: t.record(t.string, t.string),
}),
}),
options: {
@ -95,6 +190,7 @@ const functionSummariseRoute = createObservabilityAIAssistantServerRoute({
is_correction: isCorrection,
text,
public: isPublic,
labels,
} = resources.params.body;
return client.summarise({
@ -104,6 +200,7 @@ const functionSummariseRoute = createObservabilityAIAssistantServerRoute({
is_correction: isCorrection,
text,
public: isPublic,
labels,
},
});
},
@ -159,4 +256,5 @@ export const functionRoutes = {
...functionSummariseRoute,
...setupKnowledgeBaseRoute,
...getKnowledgeBaseStatus,
...functionAlertsRoute,
};

View file

@ -5,17 +5,22 @@
* 2.0.
*/
import type { CustomRequestHandlerContext, KibanaRequest } from '@kbn/core/server';
import type { Logger } from '@kbn/logging';
import type { KibanaRequest, RequestHandlerContext } from '@kbn/core/server';
import type { RacApiRequestHandlerContext } from '@kbn/rule-registry-plugin/server';
import type { ObservabilityAIAssistantService } from '../service';
import type {
ObservabilityAIAssistantPluginSetupDependencies,
ObservabilityAIAssistantPluginStartDependencies,
} from '../types';
import type { ObservabilityAIAssistantService } from '../service';
export type ObservabilityAIAssistantRequestHandlerContext = CustomRequestHandlerContext<{
rac: RacApiRequestHandlerContext;
}>;
export interface ObservabilityAIAssistantRouteHandlerResources {
request: KibanaRequest;
context: RequestHandlerContext;
context: ObservabilityAIAssistantRequestHandlerContext;
logger: Logger;
service: ObservabilityAIAssistantService;
plugins: {

View file

@ -6,7 +6,7 @@
*/
import type { SearchHit } from '@elastic/elasticsearch/lib/api/types';
import { internal, notFound } from '@hapi/boom';
import type { ActionsClient } from '@kbn/actions-plugin/server/actions_client';
import type { ActionsClient } from '@kbn/actions-plugin/server';
import type { ElasticsearchClient } from '@kbn/core/server';
import type { Logger } from '@kbn/logging';
import type { PublicMethodsOf } from '@kbn/utility-types';
@ -108,11 +108,13 @@ export class ObservabilityAIAssistantClient {
messages,
connectorId,
functions,
functionCall,
stream = true,
}: {
messages: Message[];
connectorId: string;
functions?: Array<{ name: string; description: string; parameters: CompatibleJSONSchema }>;
functionCall?: string;
stream?: TStream;
}): Promise<TStream extends false ? CreateChatCompletionResponse : IncomingMessage> => {
const messagesForOpenAI: ChatCompletionRequestMessage[] = compact(
@ -140,6 +142,7 @@ export class ObservabilityAIAssistantClient {
stream: true,
functions: functionsForOpenAI,
temperature: 0,
function_call: functionCall ? { name: functionCall } : undefined,
};
const executeResult = await this.dependencies.actionsClient.execute({
@ -312,11 +315,13 @@ export class ObservabilityAIAssistantClient {
return createdConversation;
};
recall = async (query: string): Promise<{ entries: KnowledgeBaseEntry[] }> => {
recall = async (
queries: string[]
): Promise<{ entries: Array<Pick<KnowledgeBaseEntry, 'text' | 'id'>> }> => {
return this.dependencies.knowledgeBaseService.recall({
namespace: this.dependencies.namespace,
user: this.dependencies.user,
query,
queries,
});
};

View file

@ -13,13 +13,13 @@ import type { SecurityPluginStart } from '@kbn/security-plugin/server';
import { getSpaceIdFromPath } from '@kbn/spaces-plugin/common';
import type { TaskManagerSetupContract } from '@kbn/task-manager-plugin/server';
import { once } from 'lodash';
import { KnowledgeBaseEntry } from '../../common/types';
import type { ObservabilityAIAssistantPluginStartDependencies } from '../types';
import { ObservabilityAIAssistantClient } from './client';
import { conversationComponentTemplate } from './conversation_component_template';
import { kbComponentTemplate } from './kb_component_template';
import { KnowledgeBaseService } from './kb_service';
import { KnowledgeBaseEntryOperationType, KnowledgeBaseService } from './kb_service';
import type { ObservabilityAIAssistantResourceNames } from './types';
import { splitKbText } from './util/split_kb_text';
function getResourceName(resource: string) {
return `.kibana-observability-ai-assistant-${resource}`;
@ -82,7 +82,7 @@ export class ObservabilityAIAssistantService {
return {
run: async () => {
if (this.kbService) {
// await this.kbService.processQueue();
await this.kbService.processQueue();
}
},
};
@ -256,20 +256,52 @@ export class ObservabilityAIAssistantService {
});
}
async addToKnowledgeBase(
addToKnowledgeBase(
entries: Array<
Omit<KnowledgeBaseEntry, 'is_correction' | 'public' | 'confidence' | '@timestamp'>
| {
id: string;
text: string;
}
| {
id: string;
texts: string[];
}
>
): Promise<void> {
await this.init();
this.kbService!.store(
entries.map((entry) => ({
...entry,
'@timestamp': new Date().toISOString(),
public: true,
confidence: 'high',
is_correction: false,
}))
);
): void {
this.init()
.then(() => {
this.kbService!.queue(
entries.flatMap((entry) => {
const entryWithSystemProperties = {
...entry,
'@timestamp': new Date().toISOString(),
public: true,
confidence: 'high' as const,
is_correction: false,
labels: {
document_id: entry.id,
},
};
const operations =
'texts' in entryWithSystemProperties
? splitKbText(entryWithSystemProperties)
: [
{
type: KnowledgeBaseEntryOperationType.Index,
document: entryWithSystemProperties,
},
];
return operations;
})
);
})
.catch((error) => {
this.logger.error(
`Could not index ${entries.length} entries because of an initialisation error`
);
this.logger.error(error);
});
}
}

View file

@ -20,6 +20,11 @@ const date = {
type: 'date' as const,
};
const dynamic = {
type: 'object' as const,
dynamic: true,
};
export const kbComponentTemplate: ClusterComponentTemplate['component_template']['template'] = {
mappings: {
dynamic: false,
@ -32,6 +37,7 @@ export const kbComponentTemplate: ClusterComponentTemplate['component_template']
name: keyword,
},
},
labels: dynamic,
conversation: {
properties: {
id: keyword,

View file

@ -6,12 +6,13 @@
*/
import { errors } from '@elastic/elasticsearch';
import type { QueryDslTextExpansionQuery } from '@elastic/elasticsearch/lib/api/types';
import { serverUnavailable } from '@hapi/boom';
import { serverUnavailable, gatewayTimeout } from '@hapi/boom';
import type { ElasticsearchClient } from '@kbn/core/server';
import type { Logger } from '@kbn/logging';
import { TaskManagerStartContract } from '@kbn/task-manager-plugin/server';
import type { TaskManagerStartContract } from '@kbn/task-manager-plugin/server';
import pLimit from 'p-limit';
import pRetry from 'p-retry';
import { map } from 'lodash';
import { INDEX_QUEUED_DOCUMENTS_TASK_ID, INDEX_QUEUED_DOCUMENTS_TASK_TYPE } from '..';
import type { KnowledgeBaseEntry } from '../../../common/types';
import type { ObservabilityAIAssistantResourceNames } from '../types';
@ -24,16 +25,44 @@ interface Dependencies {
taskManagerStart: TaskManagerStartContract;
}
function isAlreadyExistsError(error: Error) {
return (
error instanceof errors.ResponseError &&
(error.body.error.type === 'resource_not_found_exception' ||
error.body.error.type === 'status_exception')
);
}
const ELSER_MODEL_ID = '.elser_model_1';
function throwKnowledgeBaseNotReady(body: any) {
throw serverUnavailable(`Knowledge base is not ready yet`, body);
}
export enum KnowledgeBaseEntryOperationType {
Index = 'index',
Delete = 'delete',
}
interface KnowledgeBaseDeleteOperation {
type: KnowledgeBaseEntryOperationType.Delete;
id?: string;
labels?: Record<string, string>;
}
interface KnowledgeBaseIndexOperation {
type: KnowledgeBaseEntryOperationType.Index;
document: KnowledgeBaseEntry;
}
export type KnowledgeBaseEntryOperation =
| KnowledgeBaseDeleteOperation
| KnowledgeBaseIndexOperation;
export class KnowledgeBaseService {
private hasSetup: boolean = false;
private entryQueue: KnowledgeBaseEntry[] = [];
private _queue: KnowledgeBaseEntryOperation[] = [];
constructor(private readonly dependencies: Dependencies) {
this.ensureTaskScheduled();
@ -51,93 +80,120 @@ export class KnowledgeBaseService {
},
})
.then(() => {
this.dependencies.logger.debug('Scheduled document queue task');
this.dependencies.logger.debug('Scheduled queue task');
return this.dependencies.taskManagerStart.runSoon(INDEX_QUEUED_DOCUMENTS_TASK_ID);
})
.then(() => {
this.dependencies.logger.debug('Document queue task ran');
this.dependencies.logger.debug('Queue task ran');
})
.catch((err) => {
this.dependencies.logger.error(`Failed to schedule document queue task`);
this.dependencies.logger.error(`Failed to schedule queue task`);
this.dependencies.logger.error(err);
});
}
private async processOperation(operation: KnowledgeBaseEntryOperation) {
if (operation.type === KnowledgeBaseEntryOperationType.Delete) {
await this.dependencies.esClient.deleteByQuery({
index: this.dependencies.resources.aliases.kb,
query: {
bool: {
filter: [
...(operation.id ? [{ term: { _id: operation.id } }] : []),
...(operation.labels
? map(operation.labels, (value, key) => {
return { term: { [key]: value } };
})
: []),
],
},
},
});
return;
}
await this.summarise({
entry: operation.document,
});
}
async processQueue() {
if (!this.entryQueue.length) {
if (!this._queue.length) {
return;
}
if (!(await this.status()).ready) {
this.dependencies.logger.debug(`Bailing on document queue task: KB is not ready yet`);
this.dependencies.logger.debug(`Bailing on queue task: KB is not ready yet`);
return;
}
this.dependencies.logger.debug(`Processing document queue`);
this.dependencies.logger.debug(`Processing queue`);
this.hasSetup = true;
this.dependencies.logger.info(`Indexing ${this.entryQueue.length} queued entries into KB`);
this.dependencies.logger.info(`Processing ${this._queue.length} queue operations`);
const limiter = pLimit(5);
const entries = this.entryQueue.concat();
const operations = this._queue.concat();
await Promise.all(
entries.map((entry) =>
limiter(() => {
this.entryQueue.splice(entries.indexOf(entry), 1);
return this.summarise({ entry });
operations.map((operation) =>
limiter(async () => {
this._queue.splice(operations.indexOf(operation), 1);
await this.processOperation(operation);
})
)
);
this.dependencies.logger.info('Indexed all queued entries into KB');
this.dependencies.logger.info('Processed all queued operations');
}
async store(entries: KnowledgeBaseEntry[]) {
if (!entries.length) {
queue(operations: KnowledgeBaseEntryOperation[]): void {
if (!operations.length) {
return;
}
if (!this.hasSetup) {
this.entryQueue.push(...entries);
this._queue.push(...operations);
return;
}
const limiter = pLimit(5);
const limitedFunctions = entries.map((entry) => limiter(() => this.summarise({ entry })));
const limitedFunctions = this._queue.map((operation) =>
limiter(() => this.processOperation(operation))
);
Promise.all(limitedFunctions).catch((err) => {
this.dependencies.logger.error(`Failed to index all knowledge base entries`);
this.dependencies.logger.error(`Failed to process all queued operations`);
this.dependencies.logger.error(err);
});
}
recall = async ({
user,
query,
queries,
namespace,
}: {
query: string;
queries: string[];
user: { name: string };
namespace: string;
}): Promise<{ entries: KnowledgeBaseEntry[] }> => {
}): Promise<{ entries: Array<Pick<KnowledgeBaseEntry, 'text' | 'id'>> }> => {
try {
const response = await this.dependencies.esClient.search<KnowledgeBaseEntry>({
const response = await this.dependencies.esClient.search<
Pick<KnowledgeBaseEntry, 'text' | 'id'>
>({
index: this.dependencies.resources.aliases.kb,
query: {
bool: {
should: [
{
text_expansion: {
'ml.tokens': {
model_text: query,
model_id: '.elser_model_1',
},
} as unknown as QueryDslTextExpansionQuery,
},
],
should: queries.map((query) => ({
text_expansion: {
'ml.tokens': {
model_text: query,
model_id: '.elser_model_1',
},
} as unknown as QueryDslTextExpansionQuery,
})),
filter: [
...getAccessQuery({
user,
@ -146,19 +202,21 @@ export class KnowledgeBaseService {
],
},
},
size: 3,
size: 5,
_source: {
includes: ['text', 'id'],
includes: ['text', 'is_correction', 'labels'],
},
});
return { entries: response.hits.hits.map((hit) => ({ ...hit._source!, score: hit._score })) };
return {
entries: response.hits.hits.map((hit) => ({
...hit._source!,
score: hit._score,
id: hit._id,
})),
};
} catch (error) {
if (
(error instanceof errors.ResponseError &&
error.body.error.type === 'resource_not_found_exception') ||
error.body.error.type === 'status_exception'
) {
if (isAlreadyExistsError(error)) {
throwKnowledgeBaseNotReady(error.body);
}
throw error;
@ -185,6 +243,7 @@ export class KnowledgeBaseService {
namespace,
},
pipeline: this.dependencies.resources.pipelines.kb,
refresh: false,
});
} catch (error) {
if (error instanceof errors.ResponseError && error.body.error.type === 'status_exception') {
@ -216,7 +275,7 @@ export class KnowledgeBaseService {
};
setup = async () => {
// if this fails, it's fine to propagate the error to the user
const retryOptions = { factor: 1, minTimeout: 10000, retries: 12 };
const installModel = async () => {
this.dependencies.logger.info('Installing ELSER model');
@ -234,26 +293,36 @@ export class KnowledgeBaseService {
this.dependencies.logger.info('Finished installing ELSER model');
};
try {
const getIsModelInstalled = async () => {
const getResponse = await this.dependencies.esClient.ml.getTrainedModels({
model_id: ELSER_MODEL_ID,
include: 'definition_status',
});
if (!getResponse.trained_model_configs[0]?.fully_defined) {
this.dependencies.logger.info('Model is not fully defined');
await installModel();
this.dependencies.logger.debug(
'Model definition status:\n' + JSON.stringify(getResponse.trained_model_configs[0])
);
return Boolean(getResponse.trained_model_configs[0]?.fully_defined);
};
await pRetry(async () => {
let isModelInstalled: boolean = false;
try {
isModelInstalled = await getIsModelInstalled();
} catch (error) {
if (isAlreadyExistsError(error)) {
await installModel();
isModelInstalled = await getIsModelInstalled();
}
}
} catch (error) {
if (
error instanceof errors.ResponseError &&
error.body.error.type === 'resource_not_found_exception'
) {
await installModel();
} else {
throw error;
if (!isModelInstalled) {
throwKnowledgeBaseNotReady({
message: 'Model is not fully defined',
});
}
}
}, retryOptions);
try {
await this.dependencies.esClient.ml.startTrainedModelDeployment({
@ -261,32 +330,30 @@ export class KnowledgeBaseService {
wait_for: 'fully_allocated',
});
} catch (error) {
if (
!(error instanceof errors.ResponseError && error.body.error.type === 'status_exception')
) {
this.dependencies.logger.debug('Error starting model deployment');
this.dependencies.logger.debug(error);
if (!isAlreadyExistsError(error)) {
throw error;
}
}
await pRetry(
async () => {
const response = await this.dependencies.esClient.ml.getTrainedModelsStats({
model_id: ELSER_MODEL_ID,
});
await pRetry(async () => {
const response = await this.dependencies.esClient.ml.getTrainedModelsStats({
model_id: ELSER_MODEL_ID,
});
if (
response.trained_model_stats[0]?.deployment_stats?.allocation_status.state ===
'fully_allocated'
) {
return Promise.resolve();
}
if (
response.trained_model_stats[0]?.deployment_stats?.allocation_status.state ===
'fully_allocated'
) {
return Promise.resolve();
}
this.dependencies.logger.debug('Model is not allocated yet');
this.dependencies.logger.debug('Model is not allocated yet');
this.dependencies.logger.debug(JSON.stringify(response));
return Promise.reject(new Error('Not Ready'));
},
{ factor: 1, minTimeout: 10000, maxRetryTime: 20 * 60 * 1000 }
);
throw gatewayTimeout();
}, retryOptions);
this.dependencies.logger.info('Model is ready');
this.ensureTaskScheduled();

View file

@ -12,9 +12,8 @@ export function addLensDocsToKb(service: ObservabilityAIAssistantService) {
service.addToKnowledgeBase([
{
id: 'lens_formulas_how_it_works',
text: dedent(`## How it works
Lens formulas let you do math using a combination of Elasticsearch aggregations and
texts: [
`Lens formulas let you do math using a combination of Elasticsearch aggregations and
math functions. There are three main types of functions:
* Elasticsearch metrics, like \`sum(bytes)\`
@ -30,8 +29,8 @@ export function addLensDocsToKb(service: ObservabilityAIAssistantService) {
kql='datacenter.name: east*'
))
\`\`\`
Elasticsearch functions take a field name, which can be in quotes. \`sum(bytes)\` is the same
`,
`Elasticsearch functions take a field name, which can be in quotes. \`sum(bytes)\` is the same
as \`sum('bytes')\`.
Some functions take named arguments, like \`moving_average(count(), window=5)\`.
@ -42,25 +41,23 @@ export function addLensDocsToKb(service: ObservabilityAIAssistantService) {
Math functions can take positional arguments, like pow(count(), 3) is the same as count() * count() * count()
Use the symbols +, -, /, and * to perform basic math.`),
Use the symbols +, -, /, and * to perform basic math.`,
],
},
{
id: 'lens_common_formulas',
text: dedent(`## Common formulas
The most common formulas are dividing two values to produce a percent. To display accurately, set
"value format" to "percent".
### Filter ratio:
texts: [
`The most common formulas are dividing two values to produce a percent. To display accurately, set
"value format" to "percent"`,
`### Filter ratio:
Use \`kql=''\` to filter one set of documents and compare it to other documents within the same grouping.
For example, to see how the error rate changes over time:
\`\`\`
count(kql='response.status_code > 400') / count()
\`\`\`
### Week over week:
\`\`\``,
`### Week over week:
Use \`shift='1w'\` to get the value of each grouping from
the previous week. Time shift should not be used with the *Top values* function.
@ -68,18 +65,18 @@ export function addLensDocsToKb(service: ObservabilityAIAssistantService) {
\`\`\`
percentile(system.network.in.bytes, percentile=99) /
percentile(system.network.in.bytes, percentile=99, shift='1w')
\`\`\`
\`\`\``,
### Percent of total
`### Percent of total
Formulas can calculate \`overall_sum\` for all the groupings,
which lets you convert each grouping into a percent of total:
\`\`\`
sum(products.base_price) / overall_sum(sum(products.base_price))
\`\`\`
\`\`\``,
### Recent change
`### Recent change
Use \`reducedTimeRange='30m'\` to add an additional filter on the
time range of a metric aligned with the end of the global time range.
@ -88,27 +85,28 @@ export function addLensDocsToKb(service: ObservabilityAIAssistantService) {
\`\`\`
max(system.network.in.bytes, reducedTimeRange="30m")
- min(system.network.in.bytes, reducedTimeRange="30m")
\`\`\`
`),
\`\`\`
`,
],
},
{
id: 'lens_formulas_elasticsearch_functions',
text: dedent(`## Elasticsearch functions
texts: [
`## Elasticsearch functions
These functions will be executed on the raw documents for each row of the
resulting table, aggregating all documents matching the break down
dimensions into a single value.
dimensions into a single value.`,
#### average(field: string)
`#### average(field: string)
Returns the average of a field. This function only works for number fields.
Example: Get the average of price: \`average(price)\`
Example: Get the average of price for orders from the UK: \`average(price,
kql='location:UK')\`
kql='location:UK')\``,
#### count([field: string])
`#### count([field: string])
The total number of documents. When you provide a field, the total number of
field values is counted. When you use the Count function for fields that have
multiple values in a single document, all values are counted.
@ -118,57 +116,57 @@ export function addLensDocsToKb(service: ObservabilityAIAssistantService) {
To calculate the number of products in all orders, use \`count(products.id)\`.
To calculate the number of documents that match a specific filter, use
\`count(kql='price > 500')\`.
\`count(kql='price > 500')\`.`,
#### last_value(field: string)
`#### last_value(field: string)
Returns the value of a field from the last document, ordered by the default
time field of the data view.
This function is usefull the retrieve the latest state of an entity.
Example: Get the current status of server A: \`last_value(server.status,
kql='server.name="A"')\`
kql='server.name="A"')\``,
#### max(field: string)
`#### max(field: string)
Returns the max of a field. This function only works for number fields.
Example: Get the max of price: \`max(price)\`
Example: Get the max of price for orders from the UK: \`max(price,
kql='location:UK')\`
kql='location:UK')\``,
#### median(field: string)
`#### median(field: string)
Returns the median of a field. This function only works for number fields.
Example: Get the median of price: \`median(price)\`
Example: Get the median of price for orders from the UK: \`median(price,
kql='location:UK')\`
kql='location:UK')\``,
#### min(field: string)
`#### min(field: string)
Returns the min of a field. This function only works for number fields.
Example: Get the min of price: \`min(price)\`
Example: Get the min of price for orders from the UK: \`min(price,
kql='location:UK')\`
kql='location:UK')\``,
#### percentile(field: string, [percentile]: number)
`#### percentile(field: string, [percentile]: number)
Returns the specified percentile of the values of a field. This is the value n
percent of the values occuring in documents are smaller.
Example: Get the number of bytes larger than 95 % of values:
\`percentile(bytes, percentile=95)\`
\`percentile(bytes, percentile=95)\``,
#### percentile_rank(field: string, [value]: number)
`#### percentile_rank(field: string, [value]: number)
Returns the percentage of values which are below a certain value. For example,
if a value is greater than or equal to 95% of the observed values it is said to
be at the 95th percentile rank
Example: Get the percentage of values which are below of 100:
\`percentile_rank(bytes, value=100)\`
\`percentile_rank(bytes, value=100)\``,
#### standard_deviation(field: string)
`#### standard_deviation(field: string)
Returns the amount of variation or dispersion of the field. The function works
only for number fields.
@ -176,17 +174,17 @@ export function addLensDocsToKb(service: ObservabilityAIAssistantService) {
\`standard_deviation(price).\`
Example: To get the variance of price for orders from the UK, use
\`square(standard_deviation(price, kql='location:UK'))\`.
\`square(standard_deviation(price, kql='location:UK'))\`.`,
#### sum(field: string)
`#### sum(field: string)
Returns the sum of a field. This function only works for number fields.
Example: Get the sum of price: sum(price)
Example: Get the sum of price for orders from the UK: \`sum(price,
kql='location:UK')\`
kql='location:UK')\``,
#### unique_count(field: string)
`#### unique_count(field: string)
Calculates the number of unique values of a specified field. Works for number,
string, date and boolean values.
@ -196,15 +194,17 @@ export function addLensDocsToKb(service: ObservabilityAIAssistantService) {
Example: Calculate the number of different products from the "clothes" group:
\`unique_count(product.name, kql='product.group=clothes')\`
`),
`,
],
},
{
id: 'lens_formulas_column_functions',
text: dedent(`## Column calculations
texts: [
`## Column calculations
These functions are executed for each row, but are provided with the whole
column as context. This is also known as a window function.
#### counter_rate(metric: number)
column as context. This is also known as a window function.`,
`#### counter_rate(metric: number)
Calculates the rate of an ever increasing counter. This function will only
yield helpful results on counter metric fields which contain a measurement of
some kind monotonically growing over time. If the value does get smaller, it
@ -215,9 +215,9 @@ export function addLensDocsToKb(service: ObservabilityAIAssistantService) {
or top values dimensions. It uses the current interval when used in Formula.
Example: Visualize the rate of bytes received over time by a memcached server:
counter_rate(max(memcached.stats.read.bytes))
cumulative_sum(metric: number)
counter_rate(max(memcached.stats.read.bytes))`,
`cumulative_sum(metric: number)
Calculates the cumulative sum of a metric over time, adding all previous values
of a series to each value. To use this function, you need to configure a date
histogram dimension as well.
@ -226,9 +226,9 @@ export function addLensDocsToKb(service: ObservabilityAIAssistantService) {
or top values dimensions.
Example: Visualize the received bytes accumulated over time:
cumulative_sum(sum(bytes))
differences(metric: number)
cumulative_sum(sum(bytes))`,
`differences(metric: number)
Calculates the difference to the last value of a metric over time. To use this
function, you need to configure a date histogram dimension as well. Differences
requires the data to be sequential. If your data is empty when using
@ -238,9 +238,9 @@ export function addLensDocsToKb(service: ObservabilityAIAssistantService) {
or top values dimensions.
Example: Visualize the change in bytes received over time:
differences(sum(bytes))
moving_average(metric: number, [window]: number)
differences(sum(bytes))`,
`moving_average(metric: number, [window]: number)
Calculates the moving average of a metric over time, averaging the last n-th
values to calculate the current value. To use this function, you need to
configure a date histogram dimension as well. The default window value is 5.
@ -251,9 +251,9 @@ export function addLensDocsToKb(service: ObservabilityAIAssistantService) {
Takes a named parameter window which specifies how many last values to include
in the average calculation for the current value.
Example: Smooth a line of measurements: moving_average(sum(bytes), window=5)
normalize_by_unit(metric: number, unit: s|m|h|d|w|M|y)
Example: Smooth a line of measurements: moving_average(sum(bytes), window=5)`,
`normalize_by_unit(metric: number, unit: s|m|h|d|w|M|y)
This advanced function is useful for normalizing counts and sums to a specific
time interval. It allows for integration with metrics that are stored already
normalized to a specific time interval.
@ -264,9 +264,9 @@ export function addLensDocsToKb(service: ObservabilityAIAssistantService) {
Example: A ratio comparing an already normalized metric to another metric that
needs to be normalized.
normalize_by_unit(counter_rate(max(system.diskio.write.bytes)), unit='s') /
last_value(apache.status.bytes_per_second)
overall_average(metric: number)
last_value(apache.status.bytes_per_second)`,
`overall_average(metric: number)
Calculates the average of a metric for all data points of a series in the
current chart. A series is defined by a dimension using a date histogram or
interval function. Other dimensions breaking down the data like top values or
@ -276,9 +276,9 @@ export function addLensDocsToKb(service: ObservabilityAIAssistantService) {
overall_average is calculating the average over all dimensions no matter the
used function
Example: Divergence from the mean: sum(bytes) - overall_average(sum(bytes))
overall_max(metric: number)
Example: Divergence from the mean: sum(bytes) - overall_average(sum(bytes))`,
`overall_max(metric: number)
Calculates the maximum of a metric for all data points of a series in the
current chart. A series is defined by a dimension using a date histogram or
interval function. Other dimensions breaking down the data like top values or
@ -289,9 +289,9 @@ export function addLensDocsToKb(service: ObservabilityAIAssistantService) {
function
Example: Percentage of range (sum(bytes) - overall_min(sum(bytes))) /
(overall_max(sum(bytes)) - overall_min(sum(bytes)))
overall_min(metric: number)
(overall_max(sum(bytes)) - overall_min(sum(bytes)))`,
`overall_min(metric: number)
Calculates the minimum of a metric for all data points of a series in the
current chart. A series is defined by a dimension using a date histogram or
interval function. Other dimensions breaking down the data like top values or
@ -302,9 +302,9 @@ export function addLensDocsToKb(service: ObservabilityAIAssistantService) {
function
Example: Percentage of range (sum(bytes) - overall_min(sum(bytes)) /
(overall_max(sum(bytes)) - overall_min(sum(bytes)))
overall_sum(metric: number)
(overall_max(sum(bytes)) - overall_min(sum(bytes)))`,
`overall_sum(metric: number)
Calculates the sum of a metric of all data points of a series in the current
chart. A series is defined by a dimension using a date histogram or interval
function. Other dimensions breaking down the data like top values or filter are
@ -314,19 +314,21 @@ export function addLensDocsToKb(service: ObservabilityAIAssistantService) {
overall_sum is calculating the sum over all dimensions no matter the used
function.
Example: Percentage of total sum(bytes) / overall_sum(sum(bytes))`),
Example: Percentage of total sum(bytes) / overall_sum(sum(bytes))`,
],
},
{
id: 'lens_formulas_math_functions',
text: dedent(`Math
These functions will be executed for reach row of the resulting table using single values from the same row calculated using other functions.
abs([value]: number)
texts: [
`Math
These functions will be executed for reach row of the resulting table using single values from the same row calculated using other functions.`,
`abs([value]: number)
Calculates absolute value. A negative value is multiplied by -1, a positive value stays the same.
Example: Calculate average distance to sea level abs(average(altitude))
add([left]: number, [right]: number)
Example: Calculate average distance to sea level abs(average(altitude))`,
`add([left]: number, [right]: number)
Adds up two numbers.
Also works with + symbol.
@ -337,9 +339,9 @@ export function addLensDocsToKb(service: ObservabilityAIAssistantService) {
Example: Offset count by a static value
add(count(), 5)
cbrt([value]: number)
add(count(), 5)`,
`cbrt([value]: number)
Cube root of value.
Example: Calculate side length from volume
@ -351,9 +353,9 @@ export function addLensDocsToKb(service: ObservabilityAIAssistantService) {
Example: Round up price to the next dollar
ceil(sum(price))
clamp([value]: number, [min]: number, [max]: number)
ceil(sum(price))`,
`clamp([value]: number, [min]: number, [max]: number)
Limits the value from a minimum to maximum.
Example: Make sure to catch outliers
@ -362,22 +364,22 @@ export function addLensDocsToKb(service: ObservabilityAIAssistantService) {
average(bytes),
percentile(bytes, percentile=5),
percentile(bytes, percentile=95)
)
cube([value]: number)
)`,
`cube([value]: number)
Calculates the cube of a number.
Example: Calculate volume from side length
cube(last_value(length))
defaults([value]: number, [default]: number)
cube(last_value(length))`,
`defaults([value]: number, [default]: number)
Returns a default numeric value when value is null.
Example: Return -1 when a field has no data
defaults(average(bytes), -1)
divide([left]: number, [right]: number)
defaults(average(bytes), -1)`,
`divide([left]: number, [right]: number)
Divides the first number by the second number.
Also works with / symbol
@ -386,44 +388,44 @@ export function addLensDocsToKb(service: ObservabilityAIAssistantService) {
sum(profit) / sum(revenue)
Example: divide(sum(bytes), 2)
exp([value]: number)
Example: divide(sum(bytes), 2)`,
`exp([value]: number)
Raises e to the nth power.
Example: Calculate the natural exponential function
exp(last_value(duration))
fix([value]: number)
exp(last_value(duration))`,
`fix([value]: number)
For positive values, takes the floor. For negative values, takes the ceiling.
Example: Rounding towards zero
fix(sum(profit))
floor([value]: number)
fix(sum(profit))`,
`floor([value]: number)
Round down to nearest integer value
Example: Round down a price
floor(sum(price))
log([value]: number, [base]?: number)
floor(sum(price))`,
`log([value]: number, [base]?: number)
Logarithm with optional base. The natural base e is used as default.
Example: Calculate number of bits required to store values
log(sum(bytes))
log(sum(bytes), 2)
mod([value]: number, [base]: number)
log(sum(bytes), 2)`,
`mod([value]: number, [base]: number)
Remainder after dividing the function by a number
Example: Calculate last three digits of a value
mod(sum(price), 1000)
multiply([left]: number, [right]: number)
mod(sum(price), 1000)`,
`multiply([left]: number, [right]: number)
Multiplies two numbers.
Also works with * symbol.
@ -434,63 +436,67 @@ export function addLensDocsToKb(service: ObservabilityAIAssistantService) {
Example: Calculate price after constant tax rate
multiply(sum(price), 1.2)
pick_max([left]: number, [right]: number)
multiply(sum(price), 1.2)`,
`pick_max([left]: number, [right]: number)
Finds the maximum value between two numbers.
Example: Find the maximum between two fields averages
pick_max(average(bytes), average(memory))
pick_min([left]: number, [right]: number)
pick_max(average(bytes), average(memory))`,
`pick_min([left]: number, [right]: number)
Finds the minimum value between two numbers.
Example: Find the minimum between two fields averages
pick_min(average(bytes), average(memory))
pow([value]: number, [base]: number)
pick_min(average(bytes), average(memory))`,
`pow([value]: number, [base]: number)
Raises the value to a certain power. The second argument is required
Example: Calculate volume based on side length
pow(last_value(length), 3)
round([value]: number, [decimals]?: number)
pow(last_value(length), 3)`,
`round([value]: number, [decimals]?: number)
Rounds to a specific number of decimal places, default of 0
Examples: Round to the cent
round(sum(bytes))
round(sum(bytes), 2)
sqrt([value]: number)
round(sum(bytes), 2)`,
`sqrt([value]: number)
Square root of a positive value only
Example: Calculate side length based on area
sqrt(last_value(area))
square([value]: number)
sqrt(last_value(area))`,
`square([value]: number)
Raise the value to the 2nd power
Example: Calculate area based on side length
square(last_value(length))
subtract([left]: number, [right]: number)
square(last_value(length))`,
`subtract([left]: number, [right]: number)
Subtracts the first number from the second number.
Also works with - symbol.
Example: Calculate the range of a field
subtract(max(bytes), min(bytes))
Comparison
These functions are used to perform value comparison.
eq([left]: number, [right]: number)
subtract(max(bytes), min(bytes))`,
],
},
{
id: 'lens_formulas_comparison_functions',
texts: [
`Comparison
These functions are used to perform value comparison.`,
`eq([left]: number, [right]: number)
Performs an equality comparison between two values.
To be used as condition for ifelse comparison function.
@ -501,9 +507,9 @@ export function addLensDocsToKb(service: ObservabilityAIAssistantService) {
average(bytes) == average(memory)
Example: eq(sum(bytes), 1000000)
gt([left]: number, [right]: number)
Example: eq(sum(bytes), 1000000)`,
`gt([left]: number, [right]: number)
Performs a greater than comparison between two values.
To be used as condition for ifelse comparison function.
@ -514,9 +520,9 @@ export function addLensDocsToKb(service: ObservabilityAIAssistantService) {
average(bytes) > average(memory)
Example: gt(average(bytes), 1000)
gte([left]: number, [right]: number)
Example: gt(average(bytes), 1000)`,
`gte([left]: number, [right]: number)
Performs a greater than comparison between two values.
To be used as condition for ifelse comparison function.
@ -527,16 +533,16 @@ export function addLensDocsToKb(service: ObservabilityAIAssistantService) {
average(bytes) >= average(memory)
Example: gte(average(bytes), 1000)
ifelse([condition]: boolean, [left]: number, [right]: number)
Example: gte(average(bytes), 1000)`,
`ifelse([condition]: boolean, [left]: number, [right]: number)
Returns a value depending on whether the element of condition is true or false.
Example: Average revenue per customer but in some cases customer id is not provided which counts as additional customer
sum(total)/(unique_count(customer_id) + ifelse( count() > count(kql='customer_id:*'), 1, 0))
lt([left]: number, [right]: number)
sum(total)/(unique_count(customer_id) + ifelse( count() > count(kql='customer_id:*'), 1, 0))`,
`lt([left]: number, [right]: number)
Performs a lower than comparison between two values.
To be used as condition for ifelse comparison function.
@ -547,9 +553,9 @@ export function addLensDocsToKb(service: ObservabilityAIAssistantService) {
average(bytes) <= average(memory)
Example: lt(average(bytes), 1000)
lte([left]: number, [right]: number)
Example: lt(average(bytes), 1000)`,
`lte([left]: number, [right]: number)
Performs a lower than or equal comparison between two values.
To be used as condition for ifelse comparison function.
@ -560,7 +566,8 @@ export function addLensDocsToKb(service: ObservabilityAIAssistantService) {
average(bytes) <= average(memory)
Example: lte(average(bytes), 1000)`),
Example: lte(average(bytes), 1000)`,
],
},
{
id: 'lens_formulas_kibana_context',

View file

@ -0,0 +1,35 @@
/*
* 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 { merge } from 'lodash';
import type { KnowledgeBaseEntry } from '../../../common/types';
import { type KnowledgeBaseEntryOperation, KnowledgeBaseEntryOperationType } from '../kb_service';
export function splitKbText({
id,
texts,
...rest
}: Omit<KnowledgeBaseEntry, 'text'> & { texts: string[] }): KnowledgeBaseEntryOperation[] {
return [
{
type: KnowledgeBaseEntryOperationType.Delete,
labels: {
document_id: id,
},
},
...texts.map((text, index) => ({
type: KnowledgeBaseEntryOperationType.Index,
document: merge({}, rest, {
id: [id, index].join('_'),
labels: {
document_id: id,
},
text,
}),
})),
];
}

View file

@ -42,7 +42,9 @@
"@kbn/field-formats-plugin",
"@kbn/lens-plugin",
"@kbn/data-views-plugin",
"@kbn/task-manager-plugin"
"@kbn/task-manager-plugin",
"@kbn/es-query",
"@kbn/rule-registry-plugin"
],
"exclude": ["target/**/*"]
}

View file

@ -41,20 +41,29 @@ export const GenAiRunActionResponseSchema = schema.object(
object: schema.string(),
created: schema.number(),
model: schema.string(),
usage: schema.object({
prompt_tokens: schema.number(),
completion_tokens: schema.number(),
total_tokens: schema.number(),
}),
usage: schema.object(
{
prompt_tokens: schema.number(),
completion_tokens: schema.number(),
total_tokens: schema.number(),
},
{ unknowns: 'ignore' }
),
choices: schema.arrayOf(
schema.object({
message: schema.object({
role: schema.string(),
content: schema.string(),
}),
finish_reason: schema.string(),
index: schema.number(),
})
schema.object(
{
message: schema.object(
{
role: schema.string(),
content: schema.string(),
},
{ unknowns: 'ignore' }
),
finish_reason: schema.string(),
index: schema.number(),
},
{ unknowns: 'ignore' }
)
),
},
{ unknowns: 'ignore' }

View file

@ -77,7 +77,7 @@ export class GenAiConnector extends SubActionConnector<GenAiConfig, GenAiSecrets
protected getResponseErrorMessage(error: AxiosError<{ error?: { message?: string } }>): string {
if (!error.response?.status) {
return 'Unknown API Error';
return `Unexpected API Error: ${error.code} - ${error.message}`;
}
if (error.response.status === 401) {
return 'Unauthorized API Error';
@ -116,6 +116,7 @@ export class GenAiConnector extends SubActionConnector<GenAiConfig, GenAiSecrets
stream,
...('defaultModel' in this.config ? [this.config.defaultModel] : [])
);
const axiosOptions = getAxiosOptions(this.provider, this.key, stream);
const response = await this.request({
url: this.url,