[8.14] [Obs AI Assistant] Address feedback for contextual insights on alert details page (#181049) (#181414)

# Backport

This will backport the following commits from `main` to `8.14`:
- [[Obs AI Assistant] Address feedback for contextual insights on alert
details page (#181049)](https://github.com/elastic/kibana/pull/181049)

<!--- Backport version: 9.4.3 -->

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

<!--BACKPORT [{"author":{"name":"Søren
Louv-Jansen","email":"soren.louv@elastic.co"},"sourceCommit":{"committedDate":"2024-04-23T10:48:08Z","message":"[Obs
AI Assistant] Address feedback for contextual insights on alert details
page (#181049)\n\n- This PR addresses comments made
in\r\nhttps://github.com/elastic/kibana/pull/180766\r\n- Adds API tests
for `GET\r\n/internal/apm/assistant/get_obs_alert_details_context`\r\n-
Fixes some bugs I found during testing\r\n- Removes the option
`forceSyntheticSource` in the APM app since it is\r\nno longer needed
and causes problems when applied to the traces
data\r\nstream","sha":"2141ba76ce4bd690dd5c96e8e41480383797615f","branchLabelMapping":{"^v8.15.0$":"main","^v(\\d+).(\\d+).\\d+$":"$1.$2"}},"sourcePullRequest":{"labels":["release_note:skip","Team:obs-ux-infra_services","Team:obs-ux-management","apm:review","v8.14.0","v8.15.0"],"title":"[Obs
AI Assistant] Address feedback for contextual insights on alert details
page","number":181049,"url":"https://github.com/elastic/kibana/pull/181049","mergeCommit":{"message":"[Obs
AI Assistant] Address feedback for contextual insights on alert details
page (#181049)\n\n- This PR addresses comments made
in\r\nhttps://github.com/elastic/kibana/pull/180766\r\n- Adds API tests
for `GET\r\n/internal/apm/assistant/get_obs_alert_details_context`\r\n-
Fixes some bugs I found during testing\r\n- Removes the option
`forceSyntheticSource` in the APM app since it is\r\nno longer needed
and causes problems when applied to the traces
data\r\nstream","sha":"2141ba76ce4bd690dd5c96e8e41480383797615f"}},"sourceBranch":"main","suggestedTargetBranches":["8.14"],"targetPullRequestStates":[{"branch":"8.14","label":"v8.14.0","branchLabelMappingKey":"^v(\\d+).(\\d+).\\d+$","isSourceBranch":false,"state":"NOT_CREATED"},{"branch":"main","label":"v8.15.0","branchLabelMappingKey":"^v8.15.0$","isSourceBranch":true,"state":"MERGED","url":"https://github.com/elastic/kibana/pull/181049","number":181049,"mergeCommit":{"message":"[Obs
AI Assistant] Address feedback for contextual insights on alert details
page (#181049)\n\n- This PR addresses comments made
in\r\nhttps://github.com/elastic/kibana/pull/180766\r\n- Adds API tests
for `GET\r\n/internal/apm/assistant/get_obs_alert_details_context`\r\n-
Fixes some bugs I found during testing\r\n- Removes the option
`forceSyntheticSource` in the APM app since it is\r\nno longer needed
and causes problems when applied to the traces
data\r\nstream","sha":"2141ba76ce4bd690dd5c96e8e41480383797615f"}}]}]
BACKPORT-->

Co-authored-by: Søren Louv-Jansen <soren.louv@elastic.co>
This commit is contained in:
Kibana Machine 2024-04-23 08:31:18 -04:00 committed by GitHub
parent ae58cd36b4
commit 2a5bc89e8a
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
14 changed files with 744 additions and 217 deletions

View file

@ -50,7 +50,8 @@ const configSchema = schema.object({
enabled: schema.boolean({ defaultValue: false }),
}),
}),
forceSyntheticSource: schema.boolean({ defaultValue: false }),
forceSyntheticSource: schema.boolean({ defaultValue: false }), // deprecated
latestAgentVersionsUrl: schema.string({
defaultValue: 'https://apm-agent-versions.elastic.co/versions.json',
}),
@ -103,6 +104,9 @@ export const config: PluginConfigDescriptor<APMConfig> = {
renameFromRoot('xpack.apm.maxServiceSelection', `uiSettings.overrides[${maxSuggestions}]`, {
level: 'warning',
}),
unused('forceSyntheticSource', {
level: 'warning',
}),
],
exposeToBrowser: {
serviceMapEnabled: true,

View file

@ -45,7 +45,6 @@ describe('APMEventClient', () => {
indices: {} as any,
options: {
includeFrozen: false,
forceSyntheticSource: false,
},
});

View file

@ -90,7 +90,6 @@ export interface APMEventClientConfig {
indices: APMIndices;
options: {
includeFrozen: boolean;
forceSyntheticSource: boolean;
};
}
@ -100,7 +99,6 @@ export class APMEventClient {
private readonly request: KibanaRequest;
public readonly indices: APMIndices;
private readonly includeFrozen: boolean;
private readonly forceSyntheticSource: boolean;
constructor(config: APMEventClientConfig) {
this.esClient = config.esClient;
@ -108,7 +106,6 @@ export class APMEventClient {
this.request = config.request;
this.indices = config.indices;
this.includeFrozen = config.options.includeFrozen;
this.forceSyntheticSource = config.options.forceSyntheticSource;
}
private callAsyncWithDebug<T extends { body: any }>({
@ -156,14 +153,11 @@ export class APMEventClient {
operationName: string,
params: TParams
): Promise<TypedSearchResponse<TParams>> {
const { events, index, filters } = getRequestBase({
const { index, filters } = getRequestBase({
apm: params.apm,
indices: this.indices,
});
const forceSyntheticSourceForThisRequest =
this.forceSyntheticSource && events.includes(ProcessorEvent.metric);
const searchParams = {
...omit(params, 'apm', 'body'),
index,
@ -180,7 +174,6 @@ export class APMEventClient {
ignore_unavailable: true,
preference: 'any',
expand_wildcards: ['open' as const, 'hidden' as const],
...(forceSyntheticSourceForThisRequest ? { force_synthetic_source: true } : {}),
};
return this.callAsyncWithDebug({

View file

@ -36,7 +36,6 @@ export async function getApmEventClient({
indices,
options: {
includeFrozen,
forceSyntheticSource: config.forceSyntheticSource,
},
});
});

View file

@ -9,7 +9,12 @@ import datemath from '@elastic/datemath';
import { ElasticsearchClient } from '@kbn/core-elasticsearch-server';
import type { CoreRequestHandlerContext } from '@kbn/core/server';
import { aiAssistantLogsIndexPattern } from '@kbn/observability-ai-assistant-plugin/server';
import { SERVICE_NAME, CONTAINER_ID, HOST_NAME } from '../../../../common/es_fields/apm';
import {
SERVICE_NAME,
CONTAINER_ID,
HOST_NAME,
KUBERNETES_POD_NAME,
} from '../../../../common/es_fields/apm';
import { getTypedSearch } from '../../../utils/create_typed_es_client';
export type LogCategories =
@ -33,6 +38,7 @@ export async function getLogCategories({
'service.name'?: string;
'host.name'?: string;
'container.id'?: string;
'kubernetes.pod.name'?: string;
};
}): Promise<LogCategories> {
const start = datemath.parse(args.start)?.valueOf()!;
@ -42,10 +48,10 @@ export async function getLogCategories({
{ field: SERVICE_NAME, value: args[SERVICE_NAME] },
{ field: CONTAINER_ID, value: args[CONTAINER_ID] },
{ field: HOST_NAME, value: args[HOST_NAME] },
{ field: KUBERNETES_POD_NAME, value: args[KUBERNETES_POD_NAME] },
]);
const index =
(await coreContext.uiSettings.client.get<string>(aiAssistantLogsIndexPattern)) ?? 'logs-*';
const index = await coreContext.uiSettings.client.get<string>(aiAssistantLogsIndexPattern);
const search = getTypedSearch(esClient);

View file

@ -8,11 +8,15 @@
import type { ElasticsearchClient } from '@kbn/core-elasticsearch-server';
import { CoreRequestHandlerContext } from '@kbn/core-http-request-handler-context-server';
import { aiAssistantLogsIndexPattern } from '@kbn/observability-ai-assistant-plugin/common';
import { rangeQuery, termQuery, typedSearch } from '@kbn/observability-plugin/server/utils/queries';
import { rangeQuery, typedSearch } from '@kbn/observability-plugin/server/utils/queries';
import * as t from 'io-ts';
import moment from 'moment';
import { ESSearchRequest } from '@kbn/es-types';
import { ApmDocumentType } from '../../../../common/document_type';
import { APMEventClient } from '../../../lib/helpers/create_es_client/create_apm_event_client';
import {
APMEventClient,
APMEventESSearchRequest,
} from '../../../lib/helpers/create_es_client/create_apm_event_client';
import { observabilityAlertDetailsContextRt } from '.';
import { RollupInterval } from '../../../../common/rollup';
@ -31,37 +35,14 @@ export async function getContainerIdFromSignals({
return query['container.id'];
}
if (query['service.name']) {
const containerId = await getContainerIdFromTrace({
query,
apmEventClient,
});
if (containerId) {
return containerId;
}
return getContainerIdFromLogs({ query, esClient, coreContext });
if (!query['service.name']) {
return;
}
}
async function getContainerIdFromLogs({
query,
esClient,
coreContext,
}: {
query: t.TypeOf<typeof observabilityAlertDetailsContextRt>;
esClient: ElasticsearchClient;
coreContext: CoreRequestHandlerContext;
}) {
const index =
(await coreContext.uiSettings.client.get<string>(aiAssistantLogsIndexPattern)) ?? 'logs-*';
const start = moment(query.alert_started_at).subtract(30, 'minutes').valueOf();
const end = moment(query.alert_started_at).valueOf();
const start = moment(query.alert_started_at).subtract(30, 'minutes').unix();
const end = moment(query.alert_started_at).unix();
const res = await typedSearch<{ container: { id: string } }, any>(esClient, {
index,
const params: APMEventESSearchRequest['body'] = {
_source: ['container.id'],
terminate_after: 1,
size: 1,
@ -69,28 +50,51 @@ async function getContainerIdFromLogs({
query: {
bool: {
filter: [
{ term: { 'service.name': query['service.name'] } },
{ exists: { field: 'container.id' } },
...termQuery('service.name', query['service.name']),
...rangeQuery(start, end),
],
},
},
};
const containerId = await getContainerIdFromTraces({
params,
apmEventClient,
});
if (containerId) {
return containerId;
}
return getContainerIdFromLogs({ params, esClient, coreContext });
}
async function getContainerIdFromLogs({
params,
esClient,
coreContext,
}: {
params: ESSearchRequest['body'];
esClient: ElasticsearchClient;
coreContext: CoreRequestHandlerContext;
}) {
const index = await coreContext.uiSettings.client.get<string>(aiAssistantLogsIndexPattern);
const res = await typedSearch<{ container: { id: string } }, any>(esClient, {
index,
...params,
});
return res.hits.hits[0]?._source?.container?.id;
}
async function getContainerIdFromTrace({
query,
async function getContainerIdFromTraces({
params,
apmEventClient,
}: {
query: t.TypeOf<typeof observabilityAlertDetailsContextRt>;
params: APMEventESSearchRequest['body'];
apmEventClient: APMEventClient;
}) {
const start = moment(query.alert_started_at).subtract(30, 'minutes').unix();
const end = moment(query.alert_started_at).unix();
const res = await apmEventClient.search('get_container_id', {
const res = await apmEventClient.search('get_container_id_from_traces', {
apm: {
sources: [
{
@ -99,21 +103,7 @@ async function getContainerIdFromTrace({
},
],
},
body: {
_source: ['container.id'],
terminate_after: 1,
size: 1,
track_total_hits: false,
query: {
bool: {
filter: [
{ exists: { field: 'container.id' } },
...termQuery('service.name', query['service.name']),
...rangeQuery(start, end),
],
},
},
},
body: params,
});
return res.hits.hits[0]?._source.container?.id;

View file

@ -11,8 +11,12 @@ import { aiAssistantLogsIndexPattern } from '@kbn/observability-ai-assistant-plu
import { rangeQuery, termQuery, typedSearch } from '@kbn/observability-plugin/server/utils/queries';
import * as t from 'io-ts';
import moment from 'moment';
import { ESSearchRequest } from '@kbn/es-types';
import { ApmDocumentType } from '../../../../common/document_type';
import { APMEventClient } from '../../../lib/helpers/create_es_client/create_apm_event_client';
import {
APMEventClient,
APMEventESSearchRequest,
} from '../../../lib/helpers/create_es_client/create_apm_event_client';
import { observabilityAlertDetailsContextRt } from '.';
import { RollupInterval } from '../../../../common/rollup';
@ -31,37 +35,14 @@ export async function getServiceNameFromSignals({
return query['service.name'];
}
if (query['container.id']) {
const serviceName = await getServiceNameFromTraces({
query,
apmEventClient,
});
if (serviceName) {
return serviceName;
}
return getServiceNameFromLogs({ query, esClient, coreContext });
if (!query['kubernetes.pod.name'] && !query['container.id']) {
return;
}
}
async function getServiceNameFromLogs({
query,
esClient,
coreContext,
}: {
query: t.TypeOf<typeof observabilityAlertDetailsContextRt>;
esClient: ElasticsearchClient;
coreContext: CoreRequestHandlerContext;
}) {
const index =
(await coreContext.uiSettings.client.get<string>(aiAssistantLogsIndexPattern)) ?? 'logs-*';
const start = moment(query.alert_started_at).subtract(30, 'minutes').valueOf();
const end = moment(query.alert_started_at).valueOf();
const start = moment(query.alert_started_at).subtract(30, 'minutes').unix();
const end = moment(query.alert_started_at).unix();
const res = await typedSearch<{ service: { name: string } }, any>(esClient, {
index,
const params: APMEventESSearchRequest['body'] = {
_source: ['service.name'],
terminate_after: 1,
size: 1,
@ -69,28 +50,60 @@ async function getServiceNameFromLogs({
query: {
bool: {
filter: [
{
bool: {
should: [
...termQuery('container.id', query['container.id']),
...termQuery('kubernetes.pod.name', query['kubernetes.pod.name']),
],
minimum_should_match: 1,
},
},
{ exists: { field: 'service.name' } },
...termQuery('container.id', query['container.id']),
...rangeQuery(start, end),
],
},
},
};
const serviceName = await getServiceNameFromTraces({
params,
apmEventClient,
});
if (serviceName) {
return serviceName;
}
return getServiceNameFromLogs({ params, esClient, coreContext });
}
async function getServiceNameFromLogs({
params,
esClient,
coreContext,
}: {
params: ESSearchRequest['body'];
esClient: ElasticsearchClient;
coreContext: CoreRequestHandlerContext;
}) {
const index = await coreContext.uiSettings.client.get<string>(aiAssistantLogsIndexPattern);
const res = await typedSearch<{ service: { name: string } }, any>(esClient, {
index,
...params,
});
return res.hits.hits[0]?._source?.service?.name;
}
async function getServiceNameFromTraces({
query,
params,
apmEventClient,
}: {
query: t.TypeOf<typeof observabilityAlertDetailsContextRt>;
params: APMEventESSearchRequest['body'];
apmEventClient: APMEventClient;
}) {
const start = moment(query.alert_started_at).subtract(30, 'minutes').unix();
const end = moment(query.alert_started_at).unix();
const res = await apmEventClient.search('get_service_name', {
const res = await apmEventClient.search('get_service_name_from_traces', {
apm: {
sources: [
{
@ -99,21 +112,7 @@ async function getServiceNameFromTraces({
},
],
},
body: {
_source: ['service.name'],
terminate_after: 1,
size: 1,
track_total_hits: false,
query: {
bool: {
filter: [
{ exists: { field: 'service.name' } },
...termQuery('container.id', query['container.id']),
...rangeQuery(start, end),
],
},
},
},
body: params,
});
return res.hits.hits[0]?._source.service.name;

View file

@ -36,6 +36,7 @@ export const observabilityAlertDetailsContextRt = t.intersection([
// infrastructure fields
'host.name': t.string,
'container.id': t.string,
'kubernetes.pod.name': t.string,
}),
]);
@ -61,6 +62,7 @@ export async function getObservabilityAlertDetailsContext({
const alertStartedAt = query.alert_started_at;
const serviceEnvironment = query['service.environment'];
const hostName = query['host.name'];
const kubernetesPodName = query['kubernetes.pod.name'];
const [serviceName, containerId] = await Promise.all([
getServiceNameFromSignals({
query,
@ -76,70 +78,93 @@ export async function getObservabilityAlertDetailsContext({
}),
]);
async function handleError<T>(cb: () => Promise<T>): Promise<T | undefined> {
try {
return await cb();
} catch (error) {
logger.error('Error while fetching observability alert details context');
logger.error(error);
return;
}
}
const serviceSummaryPromise = serviceName
? getApmServiceSummary({
apmEventClient,
annotationsClient,
esClient,
apmAlertsClient,
mlClient,
logger,
arguments: {
'service.name': serviceName,
'service.environment': serviceEnvironment,
start: moment(alertStartedAt).subtract(5, 'minute').toISOString(),
end: alertStartedAt,
},
})
? handleError(() =>
getApmServiceSummary({
apmEventClient,
annotationsClient,
esClient,
apmAlertsClient,
mlClient,
logger,
arguments: {
'service.name': serviceName,
'service.environment': serviceEnvironment,
start: moment(alertStartedAt).subtract(5, 'minute').toISOString(),
end: alertStartedAt,
},
})
)
: undefined;
const downstreamDependenciesPromise = serviceName
? getAssistantDownstreamDependencies({
apmEventClient,
arguments: {
'service.name': serviceName,
'service.environment': serviceEnvironment,
start: moment(alertStartedAt).subtract(5, 'minute').toISOString(),
end: alertStartedAt,
},
})
? handleError(() =>
getAssistantDownstreamDependencies({
apmEventClient,
arguments: {
'service.name': serviceName,
'service.environment': serviceEnvironment,
start: moment(alertStartedAt).subtract(5, 'minute').toISOString(),
end: alertStartedAt,
},
})
)
: undefined;
const logCategoriesPromise = getLogCategories({
esClient,
coreContext,
arguments: {
start: moment(alertStartedAt).subtract(5, 'minute').toISOString(),
end: alertStartedAt,
'service.name': serviceName,
'host.name': hostName,
'container.id': containerId,
},
});
const logCategoriesPromise = handleError(() =>
getLogCategories({
esClient,
coreContext,
arguments: {
start: moment(alertStartedAt).subtract(5, 'minute').toISOString(),
end: alertStartedAt,
'service.name': serviceName,
'host.name': hostName,
'container.id': containerId,
'kubernetes.pod.name': kubernetesPodName,
},
})
);
const serviceChangePointsPromise = getServiceChangePoints({
apmEventClient,
alertStartedAt,
serviceName,
serviceEnvironment,
transactionType: query['transaction.type'],
transactionName: query['transaction.name'],
});
const serviceChangePointsPromise = handleError(() =>
getServiceChangePoints({
apmEventClient,
alertStartedAt,
serviceName,
serviceEnvironment,
transactionType: query['transaction.type'],
transactionName: query['transaction.name'],
})
);
const exitSpanChangePointsPromise = getExitSpanChangePoints({
apmEventClient,
alertStartedAt,
serviceName,
serviceEnvironment,
});
const exitSpanChangePointsPromise = handleError(() =>
getExitSpanChangePoints({
apmEventClient,
alertStartedAt,
serviceName,
serviceEnvironment,
})
);
const anomaliesPromise = getAnomalies({
start: moment(alertStartedAt).subtract(1, 'hour').valueOf(),
end: moment(alertStartedAt).valueOf(),
environment: serviceEnvironment,
mlClient,
logger,
});
const anomaliesPromise = handleError(() =>
getAnomalies({
start: moment(alertStartedAt).subtract(1, 'hour').valueOf(),
end: moment(alertStartedAt).valueOf(),
environment: serviceEnvironment,
mlClient,
logger,
})
);
const [
serviceSummary,

View file

@ -34,7 +34,7 @@ import { LogCategories } from './get_log_categories';
const getObservabilityAlertDetailsContextRoute = createApmServerRoute({
endpoint: 'GET /internal/apm/assistant/get_obs_alert_details_context',
options: {
tags: ['access:apm', 'access:ai_assistant'],
tags: ['access:apm'],
},
params: t.type({
@ -45,16 +45,16 @@ const getObservabilityAlertDetailsContextRoute = createApmServerRoute({
): Promise<{
serviceSummary?: ServiceSummary;
downstreamDependencies?: APMDownstreamDependency[];
logCategories: LogCategories;
serviceChangePoints: Array<{
logCategories?: LogCategories;
serviceChangePoints?: Array<{
title: string;
changes: TimeseriesChangePoint[];
}>;
exitSpanChangePoints: Array<{
exitSpanChangePoints?: Array<{
title: string;
changes: TimeseriesChangePoint[];
}>;
anomalies: ApmAnomalies;
anomalies?: ApmAnomalies;
}> => {
const { context, request, plugins, logger, params } = resources;
const { query } = params;

View file

@ -171,7 +171,8 @@ export async function getServiceMetadataDetails({
const response = await apmEventClient.search('get_service_metadata_details', params);
if (response.hits.total.value === 0) {
const hit = response.hits.hits[0]?._source as ServiceMetadataDetailsRaw | undefined;
if (!hit) {
return {
service: undefined,
container: undefined,
@ -179,8 +180,7 @@ export async function getServiceMetadataDetails({
};
}
const { service, agent, host, kubernetes, container, cloud, labels } = response.hits.hits[0]
._source as ServiceMetadataDetailsRaw;
const { service, agent, host, kubernetes, container, cloud, labels } = hit;
const serviceMetadataDetails = {
versions: response.aggregations?.serviceVersions.buckets.map((bucket) => bucket.key as string),

View file

@ -28,40 +28,42 @@ export function AlertDetailContextualInsights({ alert }: { alert: AlertData | nu
return [];
}
const res = await http.get('/internal/apm/assistant/get_obs_alert_details_context', {
query: {
alert_started_at: new Date(alert.formatted.start).toISOString(),
try {
const res = await http.get('/internal/apm/assistant/get_obs_alert_details_context', {
query: {
alert_started_at: new Date(alert.formatted.start).toISOString(),
// service fields
'service.name': fields['service.name'],
'service.environment': fields['service.environment'],
'transaction.type': fields['transaction.type'],
'transaction.name': fields['transaction.name'],
// service fields
'service.name': fields['service.name'],
'service.environment': fields['service.environment'],
'transaction.type': fields['transaction.type'],
'transaction.name': fields['transaction.name'],
// infra fields
'host.name': fields['host.name'],
'container.id': fields['container.id'],
},
});
// infra fields
'host.name': fields['host.name'],
'container.id': fields['container.id'],
'kubernetes.pod.name': fields['kubernetes.pod.name'],
},
});
const {
serviceSummary,
downstreamDependencies,
logCategories,
serviceChangePoints,
exitSpanChangePoints,
anomalies,
} = res as any;
const {
serviceSummary,
downstreamDependencies,
logCategories,
serviceChangePoints,
exitSpanChangePoints,
anomalies,
} = res as any;
const serviceName = fields['service.name'];
const serviceEnvironment = fields['service.environment'];
const serviceName = fields['service.name'];
const serviceEnvironment = fields['service.environment'];
const obsAlertContext = `${
!isEmpty(serviceSummary)
? `Metadata for the service where the alert occurred:
const obsAlertContext = `${
!isEmpty(serviceSummary)
? `Metadata for the service where the alert occurred:
${JSON.stringify(serviceSummary, null, 2)}`
: ''
}
: ''
}
${
!isEmpty(downstreamDependencies)
@ -99,21 +101,29 @@ ${JSON.stringify(downstreamDependencies, null, 2)}`
}
`;
return observabilityAIAssistant.getContextualInsightMessages({
message: `I'm looking at an alert and trying to understand why it was triggered`,
instructions: dedent(
`I'm an SRE. I am looking at an alert that was triggered. I want to understand why it was triggered, what it means, and what I should do next.
return observabilityAIAssistant.getContextualInsightMessages({
message: `I'm looking at an alert and trying to understand why it was triggered`,
instructions: dedent(
`I'm an SRE. I am looking at an alert that was triggered. I want to understand why it was triggered, what it means, and what I should do next.
The following contextual information is available to help me understand the alert:
${obsAlertContext}
Be brief and to the point.
Do not list the alert details as bullet points.
Do refer to the contextual information provided above when relevant.
Refer to the contextual information provided above when relevant.
Pay specific attention to why the alert happened and what may have contributed to it.
`
),
});
),
});
} catch (e) {
return observabilityAIAssistant.getContextualInsightMessages({
message: `I'm looking at an alert and trying to understand why it was triggered`,
instructions: dedent(
`I'm an SRE. I am looking at an alert that was triggered. I want to understand why it was triggered, what it means, and what I should do next.`
),
});
}
}, [alert, http, observabilityAIAssistant]);
if (!ObservabilityAIAssistantContextualInsight) {

View file

@ -31,6 +31,9 @@ export function createApmApiClient(st: supertest.SuperTest<supertest.Test>) {
const pathnameWithSpaceId = options.spaceId ? `/s/${options.spaceId}${pathname}` : pathname;
const url = format({ pathname: pathnameWithSpaceId, query: params?.query });
// eslint-disable-next-line no-console
console.debug(`Calling APM API: ${method.toUpperCase()} ${url}`);
const headers: Record<string, string> = {
'kbn-xsrf': 'foo',
'x-elastic-internal-origin': 'foo',

View file

@ -7,7 +7,13 @@
import { ApmUsername } from '@kbn/apm-plugin/server/test_helpers/create_apm_users/authentication';
import { createApmUsers } from '@kbn/apm-plugin/server/test_helpers/create_apm_users/create_apm_users';
import { ApmSynthtraceEsClient, ApmSynthtraceKibanaClient } from '@kbn/apm-synthtrace';
import {
ApmSynthtraceEsClient,
ApmSynthtraceKibanaClient,
LogsSynthtraceEsClient,
createLogger,
LogLevel,
} from '@kbn/apm-synthtrace';
import { FtrConfigProviderContext, kbnTestConfig } from '@kbn/test';
import supertest from 'supertest';
import { format, UrlObject } from 'url';
@ -66,6 +72,9 @@ export interface CreateTest {
services: InheritedServices & {
apmFtrConfig: () => ApmFtrConfig;
registry: ({ getService }: FtrProviderContext) => ReturnType<typeof RegistryProvider>;
logSynthtraceEsClient: (
context: InheritedFtrProviderContext
) => Promise<LogsSynthtraceEsClient>;
synthtraceEsClient: (context: InheritedFtrProviderContext) => Promise<ApmSynthtraceEsClient>;
synthtraceKibanaClient: (
context: InheritedFtrProviderContext
@ -106,6 +115,12 @@ export function createTestConfig(
synthtraceEsClient: (context: InheritedFtrProviderContext) => {
return bootstrapApmSynthtrace(context, synthtraceKibanaClient);
},
logSynthtraceEsClient: (context: InheritedFtrProviderContext) =>
new LogsSynthtraceEsClient({
client: context.getService('es'),
logger: createLogger(LogLevel.info),
refreshAfterIndex: true,
}),
synthtraceKibanaClient: () => synthtraceKibanaClient,
apmApiClient: async (context: InheritedFtrProviderContext) => {
const { username, password } = servers.kibana;

View file

@ -0,0 +1,484 @@
/*
* 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';
import { log, apm, generateShortId, timerange } from '@kbn/apm-synthtrace-client';
import expect from '@kbn/expect';
import { FtrProviderContext } from '../../common/ftr_provider_context';
import { SupertestReturnType } from '../../common/apm_api_supertest';
export default function ApiTest({ getService }: FtrProviderContext) {
const registry = getService('registry');
const apmApiClient = getService('apmApiClient');
const apmSynthtraceClient = getService('synthtraceEsClient');
const logSynthtraceClient = getService('logSynthtraceEsClient');
registry.when(
'fetching observability alerts details context for AI assistant contextual insights',
{ config: 'trial', archives: [] },
() => {
const start = moment().subtract(10, 'minutes').valueOf();
const end = moment().valueOf();
const range = timerange(start, end);
describe('when no traces or logs are available', async () => {
let response: SupertestReturnType<'GET /internal/apm/assistant/get_obs_alert_details_context'>;
before(async () => {
response = await apmApiClient.writeUser({
endpoint: 'GET /internal/apm/assistant/get_obs_alert_details_context',
params: {
query: {
alert_started_at: new Date(end).toISOString(),
},
},
});
});
it('returns nothing', () => {
expect(response.body).to.eql({
serviceChangePoints: [],
exitSpanChangePoints: [],
anomalies: [],
});
});
});
describe('when traces and logs are ingested and logs are not annotated with service.name', async () => {
before(async () => {
await ingestTraces({ 'service.name': 'Backend', 'container.id': 'my-container-a' });
await ingestLogs({
'container.id': 'my-container-a',
'kubernetes.pod.name': 'pod-a',
});
});
after(async () => {
await cleanup();
});
describe('when no params are specified', async () => {
let response: SupertestReturnType<'GET /internal/apm/assistant/get_obs_alert_details_context'>;
before(async () => {
response = await apmApiClient.writeUser({
endpoint: 'GET /internal/apm/assistant/get_obs_alert_details_context',
params: {
query: {
alert_started_at: new Date(end).toISOString(),
},
},
});
});
it('returns no service summary', async () => {
expect(response.body.serviceSummary).to.be(undefined);
});
it('returns no downstream dependencies', async () => {
expect(response.body.downstreamDependencies ?? []).to.eql([]);
});
it('returns 1 log category', async () => {
expect(response.body.logCategories?.map(({ errorCategory }) => errorCategory)).to.eql([
'Error message from container my-container-a',
]);
});
});
describe('when service name is specified', async () => {
let response: SupertestReturnType<'GET /internal/apm/assistant/get_obs_alert_details_context'>;
before(async () => {
response = await apmApiClient.writeUser({
endpoint: 'GET /internal/apm/assistant/get_obs_alert_details_context',
params: {
query: {
alert_started_at: new Date(end).toISOString(),
'service.name': 'Backend',
},
},
});
});
it('returns service summary', () => {
expect(response.body.serviceSummary).to.eql({
'service.name': 'Backend',
'service.environment': ['production'],
'agent.name': 'java',
'service.version': ['1.0.0'],
'language.name': 'java',
instances: 1,
anomalies: [],
alerts: [],
deployments: [],
});
});
it('returns downstream dependencies', async () => {
expect(response.body.downstreamDependencies).to.eql([
{
'span.destination.service.resource': 'elasticsearch',
'span.type': 'db',
'span.subtype': 'elasticsearch',
},
]);
});
it('returns log categories', () => {
expect(response.body.logCategories).to.have.length(1);
const logCategory = response.body.logCategories?.[0];
expect(logCategory?.sampleMessage).to.match(
/Error message #\d{16} from container my-container-a/
);
expect(logCategory?.docCount).to.be.greaterThan(0);
expect(logCategory?.errorCategory).to.be('Error message from container my-container-a');
});
});
describe('when container id is specified', async () => {
let response: SupertestReturnType<'GET /internal/apm/assistant/get_obs_alert_details_context'>;
before(async () => {
response = await apmApiClient.writeUser({
endpoint: 'GET /internal/apm/assistant/get_obs_alert_details_context',
params: {
query: {
alert_started_at: new Date(end).toISOString(),
'container.id': 'my-container-a',
},
},
});
});
it('returns service summary', () => {
expect(response.body.serviceSummary).to.eql({
'service.name': 'Backend',
'service.environment': ['production'],
'agent.name': 'java',
'service.version': ['1.0.0'],
'language.name': 'java',
instances: 1,
anomalies: [],
alerts: [],
deployments: [],
});
});
it('returns downstream dependencies', async () => {
expect(response.body.downstreamDependencies).to.eql([
{
'span.destination.service.resource': 'elasticsearch',
'span.type': 'db',
'span.subtype': 'elasticsearch',
},
]);
});
it('returns log categories', () => {
expect(response.body.logCategories).to.have.length(1);
const logCategory = response.body.logCategories?.[0];
expect(logCategory?.sampleMessage).to.match(
/Error message #\d{16} from container my-container-a/
);
expect(logCategory?.docCount).to.be.greaterThan(0);
expect(logCategory?.errorCategory).to.be('Error message from container my-container-a');
});
});
describe('when non-existing container id is specified', async () => {
let response: SupertestReturnType<'GET /internal/apm/assistant/get_obs_alert_details_context'>;
before(async () => {
response = await apmApiClient.writeUser({
endpoint: 'GET /internal/apm/assistant/get_obs_alert_details_context',
params: {
query: {
alert_started_at: new Date(end).toISOString(),
'container.id': 'non-existing-container',
},
},
});
});
it('returns nothing', () => {
expect(response.body).to.eql({
logCategories: [],
serviceChangePoints: [],
exitSpanChangePoints: [],
anomalies: [],
});
});
});
describe('when non-existing service.name is specified', async () => {
let response: SupertestReturnType<'GET /internal/apm/assistant/get_obs_alert_details_context'>;
before(async () => {
response = await apmApiClient.writeUser({
endpoint: 'GET /internal/apm/assistant/get_obs_alert_details_context',
params: {
query: {
alert_started_at: new Date(end).toISOString(),
'service.name': 'non-existing-service',
},
},
});
});
it('returns empty service summary', () => {
expect(response.body.serviceSummary).to.eql({
'service.name': 'non-existing-service',
'service.environment': [],
instances: 1,
anomalies: [],
alerts: [],
deployments: [],
});
});
it('returns no downstream dependencies', async () => {
expect(response.body.downstreamDependencies).to.eql([]);
});
it('returns log categories', () => {
expect(response.body.logCategories).to.have.length(1);
});
});
});
describe('when traces and logs are ingested and logs are annotated with service.name', async () => {
before(async () => {
await ingestTraces({ 'service.name': 'Backend', 'container.id': 'my-container-a' });
await ingestLogs({
'service.name': 'Backend',
'container.id': 'my-container-a',
'kubernetes.pod.name': 'pod-a',
});
// also ingest unrelated Frontend traces and logs that should not show up in the response when fetching "Backend"-related things
await ingestTraces({ 'service.name': 'Frontend', 'container.id': 'my-container-b' });
await ingestLogs({
'service.name': 'Frontend',
'container.id': 'my-container-b',
'kubernetes.pod.name': 'pod-b',
});
// also ingest logs that are not annotated with service.name
await ingestLogs({
'container.id': 'my-container-c',
'kubernetes.pod.name': 'pod-c',
});
});
after(async () => {
await cleanup();
});
describe('when no params are specified', async () => {
let response: SupertestReturnType<'GET /internal/apm/assistant/get_obs_alert_details_context'>;
before(async () => {
response = await apmApiClient.writeUser({
endpoint: 'GET /internal/apm/assistant/get_obs_alert_details_context',
params: {
query: {
alert_started_at: new Date(end).toISOString(),
},
},
});
});
it('returns no service summary', async () => {
expect(response.body.serviceSummary).to.be(undefined);
});
it('returns 1 log category', async () => {
expect(response.body.logCategories?.map(({ errorCategory }) => errorCategory)).to.eql([
'Error message from service',
'Error message from container my-container-c',
]);
});
});
describe('when service name is specified', async () => {
let response: SupertestReturnType<'GET /internal/apm/assistant/get_obs_alert_details_context'>;
before(async () => {
response = await apmApiClient.writeUser({
endpoint: 'GET /internal/apm/assistant/get_obs_alert_details_context',
params: {
query: {
alert_started_at: new Date(end).toISOString(),
'service.name': 'Backend',
},
},
});
});
it('returns log categories', () => {
expect(response.body.logCategories).to.have.length(1);
const logCategory = response.body.logCategories?.[0];
expect(logCategory?.sampleMessage).to.match(
/Error message #\d{16} from service Backend/
);
expect(logCategory?.docCount).to.be.greaterThan(0);
expect(logCategory?.errorCategory).to.be('Error message from service Backend');
});
});
describe('when container id is specified', async () => {
let response: SupertestReturnType<'GET /internal/apm/assistant/get_obs_alert_details_context'>;
before(async () => {
response = await apmApiClient.writeUser({
endpoint: 'GET /internal/apm/assistant/get_obs_alert_details_context',
params: {
query: {
alert_started_at: new Date(end).toISOString(),
'container.id': 'my-container-a',
},
},
});
});
it('returns log categories', () => {
expect(response.body.logCategories).to.have.length(1);
const logCategory = response.body.logCategories?.[0];
expect(logCategory?.sampleMessage).to.match(
/Error message #\d{16} from service Backend/
);
expect(logCategory?.docCount).to.be.greaterThan(0);
expect(logCategory?.errorCategory).to.be('Error message from service Backend');
});
});
describe('when non-existing service.name is specified', async () => {
let response: SupertestReturnType<'GET /internal/apm/assistant/get_obs_alert_details_context'>;
before(async () => {
response = await apmApiClient.writeUser({
endpoint: 'GET /internal/apm/assistant/get_obs_alert_details_context',
params: {
query: {
alert_started_at: new Date(end).toISOString(),
'service.name': 'non-existing-service',
},
},
});
});
it('returns empty service summary', () => {
expect(response.body.serviceSummary).to.eql({
'service.name': 'non-existing-service',
'service.environment': [],
instances: 1,
anomalies: [],
alerts: [],
deployments: [],
});
});
it('does not return log categories', () => {
expect(response.body.logCategories?.map(({ errorCategory }) => errorCategory)).to.eql([
'Error message from container my-container-c',
]);
});
});
});
async function ingestTraces(eventMetadata: {
'service.name': string;
'container.id'?: string;
'host.name'?: string;
'kubernetes.pod.name'?: string;
}) {
const serviceInstance = apm
.service({
name: eventMetadata['service.name'],
environment: 'production',
agentName: 'java',
})
.instance('my-instance');
const events = range
.interval('1m')
.rate(1)
.generator((timestamp) => {
return serviceInstance
.transaction({ transactionName: 'tx' })
.timestamp(timestamp)
.duration(10000)
.defaults({ 'service.version': '1.0.0', ...eventMetadata })
.outcome('success')
.children(
serviceInstance
.span({
spanName: 'GET apm-*/_search',
spanType: 'db',
spanSubtype: 'elasticsearch',
})
.duration(1000)
.success()
.destination('elasticsearch')
.timestamp(timestamp)
);
});
await apmSynthtraceClient.index(events);
}
function ingestLogs(eventMetadata: {
'service.name'?: string;
'container.id'?: string;
'kubernetes.pod.name'?: string;
'host.name'?: string;
}) {
const getMessage = () => {
const msgPrefix = `Error message #${generateShortId()}`;
if (eventMetadata['service.name']) {
return `${msgPrefix} from service ${eventMetadata['service.name']}`;
}
if (eventMetadata['container.id']) {
return `${msgPrefix} from container ${eventMetadata['container.id']}`;
}
if (eventMetadata['kubernetes.pod.name']) {
return `${msgPrefix} from pod ${eventMetadata['kubernetes.pod.name']}`;
}
if (eventMetadata['host.name']) {
return `${msgPrefix} from host ${eventMetadata['host.name']}`;
}
return msgPrefix;
};
const events = range
.interval('1m')
.rate(1)
.generator((timestamp) => {
return [
log
.create()
.message(getMessage())
.logLevel('error')
.defaults({
'trace.id': generateShortId(),
'agent.name': 'synth-agent',
...eventMetadata,
})
.timestamp(timestamp),
];
});
return logSynthtraceClient.index(events);
}
async function cleanup() {
await apmSynthtraceClient.clean();
await logSynthtraceClient.clean();
}
}
);
}