[Observability] Co-pilot (tech preview) (#158678)

This commit is contained in:
Dario Gieselaar 2023-06-07 13:42:03 +02:00 committed by GitHub
parent 58c4e73c39
commit 4a5dcbdea8
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
40 changed files with 1366 additions and 102 deletions

View file

@ -875,6 +875,7 @@
"normalize-path": "^3.0.0",
"object-hash": "^1.3.1",
"object-path-immutable": "^3.1.1",
"openai": "^3.2.1",
"openpgp": "5.3.0",
"opn": "^5.5.0",
"ora": "^4.0.4",

View file

@ -851,4 +851,53 @@ describe('Fetch', () => {
expect(usedSpy).toHaveBeenCalledTimes(2);
});
});
describe('rawResponse', () => {
it("throws if rawResponse is set to true but asResponse isn't", async () => {
fetchMock.get('*', { foo: 'bar' });
await expect(async () =>
fetchInstance.fetch('/my/path', {
rawResponse: true,
})
).rejects.toThrowError(
'Invalid fetch arguments, rawResponse = true is only supported when asResponse = true'
);
});
it('immediately returns an unawaited Response object if rawResponse = true', async () => {
fetchMock.get('*', { foo: 'bar' });
const response = await fetchInstance.fetch('/my/path', {
rawResponse: true,
asResponse: true,
});
expect(response.response).toBeInstanceOf(Response);
expect(response.body).toEqual(null);
const body = await response.response?.json();
expect(body).toEqual({ foo: 'bar' });
});
it('calls the request/response interceptors if rawResponse = true', async () => {
fetchMock.get('*', { foo: 'bar' });
const requestSpy = jest.fn();
const responseSpy = jest.fn();
fetchInstance.intercept({ request: requestSpy, response: responseSpy });
await fetchInstance.fetch('/my/path', {
rawResponse: true,
asResponse: true,
});
expect(requestSpy).toHaveBeenCalled();
expect(responseSpy).toHaveBeenCalled();
});
});
});

View file

@ -90,6 +90,7 @@ export class Fetch {
controller
);
const initialResponse = this.fetchResponse(interceptedOptions);
const interceptedResponse = await interceptResponse(
interceptedOptions,
initialResponse,
@ -115,6 +116,7 @@ export class Fetch {
private createRequest(options: HttpFetchOptionsWithPath): Request {
const context = this.params.executionContext.withGlobalContext(options.context);
const { version } = options;
// Merge and destructure options out that are not applicable to the Fetch API.
const {
query,
@ -168,7 +170,9 @@ export class Fetch {
const contentType = response.headers.get('Content-Type') || '';
try {
if (NDJSON_CONTENT.test(contentType) || ZIP_CONTENT.test(contentType)) {
if (fetchOptions.rawResponse) {
body = null;
} else if (NDJSON_CONTENT.test(contentType) || ZIP_CONTENT.test(contentType)) {
body = await response.blob();
} else if (JSON_CONTENT.test(contentType)) {
body = await response.json();
@ -227,6 +231,12 @@ const validateFetchArguments = (
);
}
if (fullOptions.rawResponse && !fullOptions.asResponse) {
throw new Error(
'Invalid fetch arguments, rawResponse = true is only supported when asResponse = true'
);
}
const invalidKbnHeaders = Object.keys(fullOptions.headers ?? {}).filter((headerName) =>
headerName.startsWith('kbn-')
);

View file

@ -281,6 +281,12 @@ export interface HttpFetchOptions extends HttpRequestInit {
*/
asResponse?: boolean;
/**
* When true, the response from the `fetch` call will be returned as is, without being awaited or processed.
* Defaults to `false`.
*/
rawResponse?: boolean;
context?: KibanaExecutionContext;
/** @experimental */

View file

@ -252,6 +252,7 @@ export default function ({ getService }: PluginFunctionalProviderContext) {
'xpack.upgrade_assistant.featureSet.mlSnapshots (boolean)',
'xpack.upgrade_assistant.featureSet.reindexCorrectiveActions (boolean)',
'xpack.upgrade_assistant.ui.enabled (boolean)',
'xpack.observability.coPilot.enabled (boolean)',
'xpack.observability.unsafe.alertDetails.metrics.enabled (boolean)',
'xpack.observability.unsafe.alertDetails.logs.enabled (boolean)',
'xpack.observability.unsafe.alertDetails.uptime.enabled (boolean)',

View file

@ -0,0 +1,85 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import React, { useMemo, useState } from 'react';
import { useCoPilot, CoPilotPrompt } from '@kbn/observability-plugin/public';
import { EuiFlexItem, EuiSpacer } from '@elastic/eui';
import { i18n } from '@kbn/i18n';
import { CoPilotPromptId } from '@kbn/observability-plugin/common';
import { APMError } from '../../../../../typings/es_schemas/ui/apm_error';
import { Transaction } from '../../../../../typings/es_schemas/ui/transaction';
import { exceptionStacktraceTab, logStacktraceTab } from './error_tabs';
import { ErrorSampleDetailTabContent } from './error_sample_detail';
export function ErrorSampleCoPilotPrompt({
error,
transaction,
}: {
error: APMError;
transaction?: Transaction;
}) {
const coPilot = useCoPilot();
const [logStacktrace, setLogStacktrace] = useState('');
const [exceptionStacktrace, setExceptionStacktrace] = useState('');
const promptParams = useMemo(() => {
return {
serviceName: error.service.name,
languageName: error.service.language?.name ?? '',
runtimeName: error.service.runtime?.name ?? '',
runtimeVersion: error.service.runtime?.version ?? '',
transactionName: transaction?.transaction.name ?? '',
logStacktrace,
exceptionStacktrace,
};
}, [error, transaction, logStacktrace, exceptionStacktrace]);
return coPilot?.isEnabled() && promptParams ? (
<>
<EuiFlexItem>
<CoPilotPrompt
coPilot={coPilot}
title={i18n.translate(
'xpack.apm.errorGroupCoPilotPrompt.explainErrorTitle',
{ defaultMessage: "What's this error?" }
)}
promptId={CoPilotPromptId.ApmExplainError}
params={promptParams}
/>
</EuiFlexItem>
<EuiSpacer size="s" />
<div
ref={(next) => {
setLogStacktrace(next?.innerText ?? '');
}}
style={{ display: 'none' }}
>
{error.error.log?.message && (
<ErrorSampleDetailTabContent
error={error}
currentTab={logStacktraceTab}
/>
)}
</div>
<div
ref={(next) => {
setExceptionStacktrace(next?.innerText ?? '');
}}
style={{ display: 'none' }}
>
{error.error.exception?.length && (
<ErrorSampleDetailTabContent
error={error}
currentTab={exceptionStacktraceTab}
/>
)}
</div>
</>
) : (
<></>
);
}

View file

@ -49,12 +49,8 @@ import { HttpInfoSummaryItem } from '../../../shared/summary/http_info_summary_i
import { UserAgentSummaryItem } from '../../../shared/summary/user_agent_summary_item';
import { TimestampTooltip } from '../../../shared/timestamp_tooltip';
import { TransactionTab } from '../../transaction_details/waterfall_with_summary/transaction_tabs';
import {
ErrorTab,
exceptionStacktraceTab,
getTabs,
logStacktraceTab,
} from './error_tabs';
import { ErrorSampleCoPilotPrompt } from './error_sample_co_pilot_prompt';
import { ErrorTab, ErrorTabKey, getTabs } from './error_tabs';
import { ErrorUiActionsContextMenu } from './error_ui_actions_context_menu';
import { ExceptionStacktrace } from './exception_stacktrace';
import { SampleSummary } from './sample_summary';
@ -340,6 +336,8 @@ export function ErrorSampleDetails({
<SampleSummary error={error} />
)}
<ErrorSampleCoPilotPrompt error={error} transaction={transaction} />
<EuiTabs>
{tabs.map(({ key, label }) => {
return (
@ -365,13 +363,13 @@ export function ErrorSampleDetails({
{isLoading || !error ? (
<EuiSkeletonText lines={3} data-test-sub="loading-content" />
) : (
<TabContent error={error} currentTab={currentTab} />
<ErrorSampleDetailTabContent error={error} currentTab={currentTab} />
)}
</EuiPanel>
);
}
function TabContent({
export function ErrorSampleDetailTabContent({
error,
currentTab,
}: {
@ -383,11 +381,11 @@ function TabContent({
const logStackframes = error?.error.log?.stacktrace;
switch (currentTab.key) {
case logStacktraceTab.key:
case ErrorTabKey.LogStackTrace:
return (
<Stacktrace stackframes={logStackframes} codeLanguage={codeLanguage} />
);
case exceptionStacktraceTab.key:
case ErrorTabKey.ExceptionStacktrace:
return (
<ExceptionStacktrace
codeLanguage={codeLanguage}

View file

@ -9,27 +9,33 @@ import { i18n } from '@kbn/i18n';
import { isEmpty } from 'lodash';
import { APMError } from '../../../../../typings/es_schemas/ui/apm_error';
export enum ErrorTabKey {
LogStackTrace = 'log_stacktrace',
ExceptionStacktrace = 'exception_stacktrace',
Metadata = 'metadata',
}
export interface ErrorTab {
key: 'log_stacktrace' | 'exception_stacktrace' | 'metadata' | 'summary';
key: ErrorTabKey;
label: string;
}
export const logStacktraceTab: ErrorTab = {
key: 'log_stacktrace',
key: ErrorTabKey.LogStackTrace,
label: i18n.translate('xpack.apm.errorGroup.tabs.logStacktraceLabel', {
defaultMessage: 'Log stack trace',
}),
};
export const exceptionStacktraceTab: ErrorTab = {
key: 'exception_stacktrace',
key: ErrorTabKey.ExceptionStacktrace,
label: i18n.translate('xpack.apm.errorGroup.tabs.exceptionStacktraceLabel', {
defaultMessage: 'Exception stack trace',
}),
};
export const metadataTab: ErrorTab = {
key: 'metadata',
key: ErrorTabKey.Metadata,
label: i18n.translate('xpack.apm.errorGroup.tabs.metadataLabel', {
defaultMessage: 'Metadata',
}),

View file

@ -19,6 +19,7 @@ import { euiDarkVars, euiLightVars } from '@kbn/ui-theme';
import React from 'react';
import { Route } from '@kbn/shared-ux-router';
import { DefaultTheme, ThemeProvider } from 'styled-components';
import { CoPilotContextProvider } from '@kbn/observability-plugin/public';
import { AnomalyDetectionJobsContextProvider } from '../../../context/anomaly_detection_jobs/anomaly_detection_jobs_context';
import {
ApmPluginContext,
@ -54,6 +55,9 @@ export function ApmAppRoot({
const { history } = appMountParameters;
const i18nCore = core.i18n;
const coPilotService =
apmPluginContextValue.plugins.observability.getCoPilotService();
return (
<RedirectAppLinks
application={core.application}
@ -66,38 +70,42 @@ export function ApmAppRoot({
<i18nCore.Context>
<TimeRangeIdContextProvider>
<RouterProvider history={history} router={apmRouter as any}>
<ApmErrorBoundary>
<RedirectDependenciesToDependenciesInventory>
<RedirectWithDefaultEnvironment>
<RedirectWithDefaultDateRange>
<RedirectWithOffset>
<TrackPageview>
<UpdateExecutionContextOnRouteChange>
<BreadcrumbsContextProvider>
<UrlParamsProvider>
<LicenseProvider>
<AnomalyDetectionJobsContextProvider>
<InspectorContextProvider>
<ApmThemeProvider>
<MountApmHeaderActionMenu />
<CoPilotContextProvider value={coPilotService}>
<ApmErrorBoundary>
<RedirectDependenciesToDependenciesInventory>
<RedirectWithDefaultEnvironment>
<RedirectWithDefaultDateRange>
<RedirectWithOffset>
<TrackPageview>
<UpdateExecutionContextOnRouteChange>
<BreadcrumbsContextProvider>
<UrlParamsProvider>
<LicenseProvider>
<AnomalyDetectionJobsContextProvider>
<InspectorContextProvider>
<ApmThemeProvider>
<MountApmHeaderActionMenu />
<Route
component={ScrollToTopOnPathChange}
/>
<RouteRenderer />
</ApmThemeProvider>
</InspectorContextProvider>
</AnomalyDetectionJobsContextProvider>
</LicenseProvider>
</UrlParamsProvider>
</BreadcrumbsContextProvider>
</UpdateExecutionContextOnRouteChange>
</TrackPageview>
</RedirectWithOffset>
</RedirectWithDefaultDateRange>
</RedirectWithDefaultEnvironment>
</RedirectDependenciesToDependenciesInventory>
</ApmErrorBoundary>
<Route
component={
ScrollToTopOnPathChange
}
/>
<RouteRenderer />
</ApmThemeProvider>
</InspectorContextProvider>
</AnomalyDetectionJobsContextProvider>
</LicenseProvider>
</UrlParamsProvider>
</BreadcrumbsContextProvider>
</UpdateExecutionContextOnRouteChange>
</TrackPageview>
</RedirectWithOffset>
</RedirectWithDefaultDateRange>
</RedirectWithDefaultEnvironment>
</RedirectDependenciesToDependenciesInventory>
</ApmErrorBoundary>
</CoPilotContextProvider>
</RouterProvider>
</TimeRangeIdContextProvider>
</i18nCore.Context>

View file

@ -14,6 +14,7 @@ import { Route } from '@kbn/shared-ux-router';
import { AppMountParameters } from '@kbn/core/public';
import { Storage } from '@kbn/kibana-utils-plugin/public';
import '../index.scss';
import { CoPilotContextProvider } from '@kbn/observability-plugin/public';
import { LinkToLogsPage } from '../pages/link_to/link_to_logs';
import { LogsPage } from '../pages/logs';
import { InfraClientStartDeps, InfraClientStartExports } from '../types';
@ -69,17 +70,19 @@ const LogsApp: React.FC<{
theme$={theme$}
triggersActionsUI={plugins.triggersActionsUi}
>
<Router history={history}>
<KbnUrlStateStorageFromRouterProvider
history={history}
toastsService={core.notifications.toasts}
>
<Switch>
<Route path="/link-to" component={LinkToLogsPage} />
{uiCapabilities?.logs?.show && <Route path="/" component={LogsPage} />}
</Switch>
</KbnUrlStateStorageFromRouterProvider>
</Router>
<CoPilotContextProvider value={plugins.observability.getCoPilotService()}>
<Router history={history}>
<KbnUrlStateStorageFromRouterProvider
history={history}
toastsService={core.notifications.toasts}
>
<Switch>
<Route path="/link-to" component={LinkToLogsPage} />
{uiCapabilities?.logs?.show && <Route path="/" component={LogsPage} />}
</Switch>
</KbnUrlStateStorageFromRouterProvider>
</Router>
</CoPilotContextProvider>
</CommonInfraProviders>
</CoreProviders>
);

View file

@ -18,11 +18,18 @@ import {
import { i18n } from '@kbn/i18n';
import { FormattedMessage } from '@kbn/i18n-react';
import type { Query } from '@kbn/es-query';
import React, { useCallback, useEffect, useRef } from 'react';
import React, { useCallback, useEffect, useMemo, useRef } from 'react';
import { createKibanaReactContext } from '@kbn/kibana-react-plugin/public';
import { OverlayRef } from '@kbn/core/public';
import { useKibana } from '@kbn/kibana-react-plugin/public';
import { DataPublicPluginStart } from '@kbn/data-plugin/public';
import {
useCoPilot,
CoPilotPrompt,
ObservabilityPublicStart,
CoPilotContextProvider,
} from '@kbn/observability-plugin/public';
import { CoPilotPromptId } from '@kbn/observability-plugin/common';
import { LogViewReference } from '../../../../common/log_views';
import { TimeKey } from '../../../../common/time';
import { useLogEntry } from '../../../containers/logs/log_entry';
@ -42,9 +49,9 @@ export interface LogEntryFlyoutProps {
export const useLogEntryFlyout = (logViewReference: LogViewReference) => {
const flyoutRef = useRef<OverlayRef>();
const {
services: { http, data, uiSettings, application },
services: { http, data, uiSettings, application, observability },
overlays: { openFlyout },
} = useKibana<{ data: DataPublicPluginStart }>();
} = useKibana<{ data: DataPublicPluginStart; observability?: ObservabilityPublicStart }>();
const closeLogEntryFlyout = useCallback(() => {
flyoutRef.current?.close();
@ -61,15 +68,26 @@ export const useLogEntryFlyout = (logViewReference: LogViewReference) => {
flyoutRef.current = openFlyout(
<KibanaReactContextProvider>
<LogEntryFlyout
logEntryId={logEntryId}
onCloseFlyout={closeLogEntryFlyout}
logViewReference={logViewReference}
/>
<CoPilotContextProvider value={observability?.getCoPilotService()}>
<LogEntryFlyout
logEntryId={logEntryId}
onCloseFlyout={closeLogEntryFlyout}
logViewReference={logViewReference}
/>
</CoPilotContextProvider>
</KibanaReactContextProvider>
);
},
[http, data, uiSettings, application, openFlyout, logViewReference, closeLogEntryFlyout]
[
http,
data,
uiSettings,
application,
openFlyout,
logViewReference,
closeLogEntryFlyout,
observability,
]
);
useEffect(() => {
@ -109,6 +127,16 @@ export const LogEntryFlyout = ({
}
}, [fetchLogEntry, logViewReference, logEntryId]);
const explainLogMessageParams = useMemo(() => {
return logEntry ? { logEntry: { fields: logEntry.fields } } : undefined;
}, [logEntry]);
const similarLogMessageParams = useMemo(() => {
return logEntry ? { logEntry: { fields: logEntry.fields } } : undefined;
}, [logEntry]);
const coPilotService = useCoPilot();
return (
<EuiFlyout onClose={onCloseFlyout} size="m">
<EuiFlyoutHeader hasBorder>
@ -168,7 +196,31 @@ export const LogEntryFlyout = ({
) : undefined
}
>
<LogEntryFieldsTable logEntry={logEntry} onSetFieldFilter={onSetFieldFilter} />
<EuiFlexGroup direction="column" gutterSize="m">
{coPilotService?.isEnabled() && explainLogMessageParams ? (
<EuiFlexItem grow={false}>
<CoPilotPrompt
coPilot={coPilotService}
title={explainLogMessageTitle}
params={explainLogMessageParams}
promptId={CoPilotPromptId.LogsExplainMessage}
/>
</EuiFlexItem>
) : null}
{coPilotService?.isEnabled() && similarLogMessageParams ? (
<EuiFlexItem grow={false}>
<CoPilotPrompt
coPilot={coPilotService}
title={similarLogMessagesTitle}
params={similarLogMessageParams}
promptId={CoPilotPromptId.LogsFindSimilar}
/>
</EuiFlexItem>
) : null}
<EuiFlexItem grow={false}>
<LogEntryFieldsTable logEntry={logEntry} onSetFieldFilter={onSetFieldFilter} />
</EuiFlexItem>
</EuiFlexGroup>
</EuiFlyoutBody>
) : (
<CenteredEuiFlyoutBody>
@ -185,6 +237,14 @@ export const LogEntryFlyout = ({
);
};
const explainLogMessageTitle = i18n.translate('xpack.infra.logFlyout.explainLogMessageTitle', {
defaultMessage: "What's this message?",
});
const similarLogMessagesTitle = i18n.translate('xpack.infra.logFlyout.similarLogMessagesTitle', {
defaultMessage: 'How do I find similar log messages?',
});
const loadingProgressMessage = i18n.translate('xpack.infra.logFlyout.loadingMessage', {
defaultMessage: 'Searching log entry in shards',
});

View file

@ -0,0 +1,240 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import * as t from 'io-ts';
import {
type ChatCompletionRequestMessage,
type CreateChatCompletionResponse,
type CreateChatCompletionResponseChoicesInner,
} from 'openai';
export enum OpenAIProvider {
OpenAI = 'openAI',
AzureOpenAI = 'azureOpenAI',
}
export enum CoPilotPromptId {
ProfilingExplainFunction = 'profilingExplainFunction',
ProfilingOptimizeFunction = 'profilingOptimizeFunction',
ApmExplainError = 'apmExplainError',
LogsExplainMessage = 'logsExplainMessage',
LogsFindSimilar = 'logsFindSimilar',
}
const PERF_GPT_SYSTEM_MESSAGE = {
content: `You are perf-gpt, a helpful assistant for performance analysis and optimisation
of software. Answer as concisely as possible.`,
role: 'system' as const,
};
const APM_GPT_SYSTEM_MESSAGE = {
content: `You are apm-gpt, a helpful assistant for performance analysis, optimisation and
root cause analysis of software. Answer as concisely as possible.`,
role: 'system' as const,
};
const LOGS_SYSTEM_MESSAGE = {
content: `You logsapm-gpt, a helpful assistant for logs-based observability. Answer as
concisely as possible.`,
role: 'system' as const,
};
function prompt<TParams extends t.Type<any, any, any>>({
params,
messages,
}: {
params: TParams;
messages: (params: t.OutputOf<TParams>) => ChatCompletionRequestMessage[];
}) {
return {
params,
messages,
};
}
const logEntryRt = t.type({
fields: t.array(
t.type({
field: t.string,
value: t.array(t.any),
})
),
});
export const coPilotPrompts = {
[CoPilotPromptId.ProfilingOptimizeFunction]: prompt({
params: t.type({
library: t.string,
functionName: t.string,
}),
messages: ({ library, functionName }) => {
return [
PERF_GPT_SYSTEM_MESSAGE,
{
content: `Assuming the function ${functionName} from the library ${library} is consuming significant CPU resources.
Suggest ways to optimize or improve the system that involve the ${functionName} function from the
${library} library. Types of improvements that would be useful to me are improvements that result in:
- Higher performance so that the system runs faster or uses less CPU
- Better memory efficient so that the system uses less RAM
- Better storage efficient so that the system stores less data on disk.
- Better network I/O efficiency so that less data is sent over the network
- Better disk I/O efficiency so that less data is read and written from disk
Make up to five suggestions. Your suggestions must meet all of the following criteria:
1. Your suggestions should detailed, technical and include concrete examples.
2. Your suggestions should be specific to improving performance of a system in which the ${functionName} function from
the ${library} library is consuming significant CPU.
2. If you suggest replacing the function or library with a more efficient replacement you must suggest at least
one concrete replacement.
If you know of fewer than five ways to improve the performance of a system in which the ${functionName} function from the
${library} library is consuming significant CPU, then provide fewer than five suggestions. If you do not know of any
way in which to improve the performance then say "I do not know how to improve the performance of systems where
this function is consuming a significant amount of CPU".
If you have suggestions, the output format should look as follows:
Here are some suggestions as to how you might optimize your system if ${functionName} in ${library} is consuming
significant CPU resources:
1. Insert first suggestion
2. Insert second suggestion
etc.`,
role: 'user',
},
];
},
}),
[CoPilotPromptId.ProfilingExplainFunction]: prompt({
params: t.type({
library: t.string,
functionName: t.string,
}),
messages: ({ library, functionName }) => {
return [
PERF_GPT_SYSTEM_MESSAGE,
{
content: `I am a software engineer. I am trying to understand what a function in a particular
software library does.
The library is: ${library}
The function is: ${functionName}
Your task is to desribe what the library is and what its use cases are, and to describe what the function
does. The output format should look as follows:
Library description: Provide a concise description of the library
Library use-cases: Provide a concise description of what the library is typically used for.
Function description: Provide a concise, technical, description of what the function does.
`,
role: 'user',
},
];
},
}),
[CoPilotPromptId.ApmExplainError]: prompt({
params: t.intersection([
t.type({
serviceName: t.string,
languageName: t.string,
runtimeName: t.string,
runtimeVersion: t.string,
transactionName: t.string,
logStacktrace: t.string,
exceptionStacktrace: t.string,
}),
t.partial({
spanName: t.string,
}),
]),
messages: ({
serviceName,
languageName,
runtimeName,
runtimeVersion,
transactionName,
logStacktrace,
exceptionStacktrace,
}) => {
return [
APM_GPT_SYSTEM_MESSAGE,
{
content: `I'm an SRE. I am looking at an exception and trying to understand what it means.
Your task is to describe what the error means and what it could be caused by.
The error occurred on a service called ${serviceName}, which is a ${runtimeName} service written in ${languageName}. The
runtime version is ${runtimeVersion}.
The request it occurred for is called ${transactionName}.
${
logStacktrace
? `The log stacktrace:
${logStacktrace}`
: ''
}
${
exceptionStacktrace
? `The exception stacktrace:
${exceptionStacktrace}`
: ''
}
`,
role: 'user',
},
];
},
}),
[CoPilotPromptId.LogsExplainMessage]: prompt({
params: t.type({
logEntry: logEntryRt,
}),
messages: ({ logEntry }) => {
return [
LOGS_SYSTEM_MESSAGE,
{
content: `I'm looking at a log entry. Can you explain me what the log message means? Where it could be coming from, whether it is expected and whether it is an issue. Here's the context, serialized: ${JSON.stringify(
logEntry
)} `,
role: 'user',
},
];
},
}),
[CoPilotPromptId.LogsFindSimilar]: prompt({
params: t.type({
logEntry: logEntryRt,
}),
messages: ({ logEntry }) => {
const message = logEntry.fields.find((field) => field.field === 'message')?.value[0];
return [
LOGS_SYSTEM_MESSAGE,
{
content: `I'm looking at a log entry. Can you construct a Kibana KQL query that I can enter in the search bar that gives me similar log entries, based on the \`message\` field: ${message}`,
role: 'user',
},
];
},
}),
};
export type CoPilotPromptMap = typeof coPilotPrompts;
export type PromptParamsOf<TPromptId extends CoPilotPromptId> = t.OutputOf<
{
[TKey in keyof CoPilotPromptMap]: CoPilotPromptMap[TKey];
}[TPromptId]['params']
>;
export type CreateChatCompletionResponseChunk = Omit<CreateChatCompletionResponse, 'choices'> & {
choices: Array<
Omit<CreateChatCompletionResponseChoicesInner, 'message'> & {
delta: { content?: string };
}
>;
};

View file

@ -84,3 +84,5 @@ export {
SYNTHETICS_TOTAL_TIMINGS,
SYNTHETICS_WAIT_TIMINGS,
} from './field_names/synthetics';
export { CoPilotPromptId, coPilotPrompts } from './co_pilot';

View file

@ -0,0 +1,104 @@
# CoPilotPrompt
CoPilotPrompt is a React component that allows for interaction with OpenAI-compatible APIs. The component supports streaming of responses and basic error handling. As of now, it doesn't support chat or any kind of persistence. We will likely add a feedback button before the first release.
## Usage
### Step 1: Define a Prompt
Firstly, define a prompt in `x-pack/plugins/observability/common/co_pilot.ts`.
```typescript
[CoPilotPromptId.ProfilingExplainFunction]: prompt({
params: t.type({
library: t.string,
functionName: t.string,
}),
messages: ({ library, functionName }) => {
return [
PERF_GPT_SYSTEM_MESSAGE,
{
content: `I am a software engineer. I am trying to understand what a function in a particular
software library does.
The library is: ${library}
The function is: ${functionName}
Your task is to describe what the library is and what its use cases are, and to describe what the function
does. The output format should look as follows:
Library description: Provide a concise description of the library
Library use-cases: Provide a concise description of what the library is typically used for.
Function description: Provide a concise, technical, description of what the function does.
`,
role: 'user',
},
];
},
});
```
Here, the key is a prompt ID, `params` define the expected inputs, and `PERF_GPT_SYSTEM_MESSAGE` is used to instruct ChatGPT's role.
### Step 2: Wrap your app in CoPilotContextProvider
Next, we need to make the CoPilot service available through context, so we can use it in our components. Wrap your app in the CoPilotContextProvider, by calling `getCoPilotService()` from the Observability plugin setup contract:
```typescript
function renderMyApp(pluginsSetup) {
const coPilotService = pluginsSetup.observability.getCoPilotService();
return (
<CoPilotContextProvider value={coPilotService}>
<MyApp />
</CoPilotContextProvider>
);
}
```
### Step 2: Retrieve the CoPilot Service
You can use the `useCoPilot` hook from `@kbn/observability-plugin/public` to retrieve the co-pilot service.
```typescript
const coPilot = useCoPilot();
```
Note: `useCoPilot.isEnabled()` will return undefined if co-pilot has not been enabled. You can use this to render the `CoPilotPrompt` component conditionally.
### Step 3: Use the CoPilotPrompt Component
Finally, you can use the `CoPilotPrompt` component like so:
```jsx
{
coPilot.isEnabled() && (
<CoPilotPrompt
coPilot={coPilot}
promptId={CoPilotPromptId.ProfilingExplainFunction}
params={promptParams}
title={i18n.translate('xpack.profiling.frameInformationWindow.explainFunction', {
defaultMessage: 'Explain function',
})}
/>
);
}
```
## Properties
### coPilot
A `CoPilotService` instance. This is required for establishing connection with the OpenAI-compatible API.
### promptId
A unique identifier for the prompt. This should match one of the keys you defined in `x-pack/plugins/observability/common/co_pilot.ts`.
### params
Parameters for the prompt. These should align with the `params` in the prompt definition.
### title
The title that will be displayed on the component. It can be a simple string or a localized string via the `i18n` library.

View file

@ -0,0 +1,172 @@
/*
* 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 {
EuiAccordion,
EuiFlexGroup,
EuiFlexItem,
EuiIcon,
EuiLoadingSpinner,
EuiPanel,
EuiSpacer,
EuiText,
EuiToolTip,
useEuiTheme,
} from '@elastic/eui';
import { css } from '@emotion/css';
import { i18n } from '@kbn/i18n';
import React, { useEffect, useMemo, useState } from 'react';
import useObservable from 'react-use/lib/useObservable';
import { catchError, Observable, of } from 'rxjs';
import { CoPilotPromptId } from '../../../common';
import type { PromptParamsOf } from '../../../common/co_pilot';
import type { CoPilotService, PromptObservableState } from '../../typings/co_pilot';
const cursorCss = css`
@keyframes blink {
0% {
opacity: 1;
}
50% {
opacity: 0;
}
100% {
opacity: 1;
}
}
animation: blink 1s infinite;
width: 10px;
height: 16px;
vertical-align: middle;
display: inline-block;
background: rgba(0, 0, 0, 0.25);
`;
export interface CoPilotPromptProps<TPromptId extends CoPilotPromptId> {
title: string;
promptId: TPromptId;
coPilot: CoPilotService;
params: PromptParamsOf<TPromptId>;
}
// eslint-disable-next-line import/no-default-export
export default function CoPilotPrompt<TPromptId extends CoPilotPromptId>({
title,
coPilot,
promptId,
params,
}: CoPilotPromptProps<TPromptId>) {
const [hasOpened, setHasOpened] = useState(false);
const theme = useEuiTheme();
const conversation$ = useMemo(() => {
return hasOpened
? coPilot
.prompt(promptId, params)
.pipe(
catchError((err) => of({ loading: false, error: err, message: String(err.message) }))
)
: new Observable<PromptObservableState>(() => {});
}, [params, promptId, coPilot, hasOpened]);
const conversation = useObservable(conversation$);
useEffect(() => {}, [conversation$]);
const content = conversation?.message ?? '';
let state: 'init' | 'loading' | 'streaming' | 'error' | 'complete' = 'init';
if (conversation?.loading) {
state = content ? 'streaming' : 'loading';
} else if (conversation && 'error' in conversation && conversation.error) {
state = 'error';
} else if (content) {
state = 'complete';
}
let inner: React.ReactElement;
if (state === 'complete' || state === 'streaming') {
inner = (
<p style={{ whiteSpace: 'pre-wrap', lineHeight: 1.5 }}>
{content}
{state === 'streaming' ? <span className={cursorCss} /> : <></>}
</p>
);
} else if (state === 'init' || state === 'loading') {
inner = (
<EuiFlexGroup direction="row" gutterSize="s" alignItems="center">
<EuiFlexItem grow={false}>
<EuiLoadingSpinner size="s" />
</EuiFlexItem>
<EuiFlexItem grow={false}>
<EuiText size="s">
{i18n.translate('xpack.observability.coPilotPrompt.chatLoading', {
defaultMessage: 'Waiting for a response...',
})}
</EuiText>
</EuiFlexItem>
</EuiFlexGroup>
);
} else {
/* if (state === 'error') {*/
inner = (
<EuiFlexGroup direction="row" gutterSize="s" alignItems="center">
<EuiFlexItem grow={false}>
<EuiIcon color="danger" type="warning" />
</EuiFlexItem>
<EuiFlexItem grow={false}>
<EuiText size="s">{content}</EuiText>
</EuiFlexItem>
</EuiFlexGroup>
);
}
const tooltipContent = i18n.translate('xpack.observability.coPilotPrompt.askCoPilot', {
defaultMessage: 'Ask Observability Co-Pilot for assistence',
});
return (
<EuiPanel color="primary">
<EuiAccordion
id={title}
css={css`
.euiButtonIcon {
color: ${theme.euiTheme.colors.primaryText};
}
`}
buttonClassName={css`
display: block;
width: 100%;
`}
buttonContent={
<EuiFlexGroup direction="row" alignItems="center">
<EuiFlexItem grow>
<EuiText size="m" color={theme.euiTheme.colors.primaryText}>
<strong>{title}</strong>
</EuiText>
</EuiFlexItem>
<EuiFlexItem grow={false}>
<EuiToolTip content={tooltipContent}>
<EuiIcon color={theme.euiTheme.colors.primaryText} type="questionInCircle" />
</EuiToolTip>
</EuiFlexItem>
</EuiFlexGroup>
}
initialIsOpen={false}
onToggle={() => {
setHasOpened(true);
}}
>
<EuiSpacer size="s" />
{inner}
</EuiAccordion>
</EuiPanel>
);
}

View file

@ -0,0 +1,18 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import React, { lazy, Suspense } from 'react';
import { EuiLoadingSpinner } from '@elastic/eui';
const LazyCoPilotPrompt = lazy(() => import('./co_pilot_prompt'));
export function CoPilotPrompt(props: React.ComponentProps<typeof LazyCoPilotPrompt>) {
return (
<Suspense fallback={<EuiLoadingSpinner size="s" />}>
<LazyCoPilotPrompt {...props} />
</Suspense>
);
}

View file

@ -0,0 +1,101 @@
/*
* 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 HttpSetup } from '@kbn/core/public';
import { concatMap, delay, Observable, of } from 'rxjs';
import { type CreateChatCompletionResponseChunk } from '../../../common/co_pilot';
import { type CoPilotService, type PromptObservableState } from '../../typings/co_pilot';
function getMessageFromChunks(chunks: CreateChatCompletionResponseChunk[]) {
let message = '';
chunks.forEach((chunk) => {
message += chunk.choices[0]?.delta.content ?? '';
});
return message;
}
export function createCoPilotService({ enabled, http }: { enabled: boolean; http: HttpSetup }) {
const service: CoPilotService = {
isEnabled: () => enabled,
prompt: (promptId, params) => {
return new Observable<PromptObservableState>((observer) => {
observer.next({ chunks: [], loading: true });
http
.post(`/internal/observability/copilot/prompts/${promptId}`, {
body: JSON.stringify(params),
asResponse: true,
rawResponse: true,
})
.then((response) => {
const status = response.response?.status;
if (!status || status >= 400) {
throw new Error(response.response?.statusText || 'Unexpected error');
}
const reader = response.response.body?.getReader();
if (!reader) {
throw new Error('Could not get reader from response');
}
const decoder = new TextDecoder();
const chunks: CreateChatCompletionResponseChunk[] = [];
function read() {
reader!.read().then(({ done, value }) => {
try {
if (done) {
observer.next({
chunks,
message: getMessageFromChunks(chunks),
loading: false,
});
observer.complete();
return;
}
const lines = decoder
.decode(value)
.trim()
.split('\n')
.map((str) => str.substr(6))
.filter((str) => !!str && str !== '[DONE]');
const nextChunks: CreateChatCompletionResponseChunk[] = lines.map((line) =>
JSON.parse(line)
);
nextChunks.forEach((chunk) => {
chunks.push(chunk);
observer.next({ chunks, message: getMessageFromChunks(chunks), loading: true });
});
} catch (err) {
observer.error(err);
return;
}
read();
});
}
read();
return () => {
reader.cancel();
};
})
.catch((err) => {
observer.error(err);
});
}).pipe(concatMap((value) => of(value).pipe(delay(50))));
},
};
return service;
}

View file

@ -0,0 +1,13 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import { createContext } from 'react';
import { type CoPilotService } from '../../typings/co_pilot';
export const CoPilotContext = createContext<CoPilotService | undefined>(undefined);
export const CoPilotContextProvider = CoPilotContext.Provider;

View file

@ -0,0 +1,18 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import { useContext } from 'react';
import { CoPilotContext } from '../context/co_pilot_context';
export function useCoPilot() {
const coPilotService = useContext(CoPilotContext);
// Ideally we throw, but we can't guarantee coPilotService being available
// in some embedded contexts
return coPilotService;
}

View file

@ -84,3 +84,7 @@ export { calculateTimeRangeBucketSize } from './pages/overview/helpers/calculate
export { convertTo } from '../common/utils/formatters/duration';
export { formatAlertEvaluationValue } from './utils/format_alert_evaluation_value';
export { CoPilotPrompt } from './components/co_pilot_prompt';
export { useCoPilot } from './hooks/use_co_pilot';
export { CoPilotContextProvider } from './context/co_pilot_context';

View file

@ -86,6 +86,9 @@ const withCore = makeDecorator({
uptime: { enabled: false },
},
},
coPilot: {
enabled: false,
},
};
return (

View file

@ -42,6 +42,9 @@ jest.spyOn(pluginContext, 'usePluginContext').mockImplementation(() => ({
uptime: { enabled: false },
},
},
coPilot: {
enabled: false,
},
},
observabilityRuleTypeRegistry: createObservabilityRuleTypeRegistryMock(),
ObservabilityPageTemplate: KibanaPageTemplate,

View file

@ -60,6 +60,8 @@ import {
} from './rules/create_observability_rule_type_registry';
import { createUseRulesLink } from './hooks/create_use_rules_link';
import { registerObservabilityRuleTypes } from './rules/register_observability_rule_types';
import { createCoPilotService } from './context/co_pilot_context/create_co_pilot_service';
import { type CoPilotService } from './typings/co_pilot';
export interface ConfigSchema {
unsafe: {
@ -75,6 +77,9 @@ export interface ConfigSchema {
};
};
};
coPilot?: {
enabled?: boolean;
};
}
export type ObservabilityPublicSetup = ReturnType<Plugin['setup']>;
@ -125,6 +130,8 @@ export class Plugin
private observabilityRuleTypeRegistry: ObservabilityRuleTypeRegistry =
{} as ObservabilityRuleTypeRegistry;
private coPilotService: CoPilotService | undefined;
// Define deep links as constant and hidden. Whether they are shown or hidden
// in the global navigation will happen in `updateGlobalNavigation`.
private readonly deepLinks: AppDeepLink[] = [
@ -314,6 +321,11 @@ export class Plugin
)
);
this.coPilotService = createCoPilotService({
enabled: !!config.coPilot?.enabled,
http: coreSetup.http,
});
return {
dashboard: { register: registerDataHandler },
observabilityRuleTypeRegistry: this.observabilityRuleTypeRegistry,
@ -321,6 +333,7 @@ export class Plugin
rulesLocator,
ruleDetailsLocator,
sloDetailsLocator,
getCoPilotService: () => this.coPilotService!,
};
}
@ -350,6 +363,7 @@ export class Plugin
return {
observabilityRuleTypeRegistry: this.observabilityRuleTypeRegistry,
useRulesLink: createUseRulesLink(),
getCoPilotService: () => this.coPilotService!,
};
}
}

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 type { Observable } from 'rxjs';
import {
type CoPilotPromptId,
type PromptParamsOf,
type CreateChatCompletionResponseChunk,
} from '../../common/co_pilot';
export interface PromptObservableState {
chunks: CreateChatCompletionResponseChunk[];
message?: string;
loading: boolean;
}
export interface CoPilotService {
isEnabled: () => boolean;
prompt<TPromptId extends CoPilotPromptId>(
promptId: TPromptId,
params: PromptParamsOf<TPromptId>
): Observable<PromptObservableState>;
}

View file

@ -33,6 +33,9 @@ export function KibanaReactStorybookDecorator(Story: ComponentType) {
uptime: { enabled: false },
},
},
coPilot: {
enabled: false,
},
};
const mockTheme: CoreTheme = {
darkMode: false,

View file

@ -37,6 +37,9 @@ const defaultConfig: ConfigSchema = {
uptime: { enabled: false },
},
},
coPilot: {
enabled: false,
},
};
const queryClient = new QueryClient({

View file

@ -18,6 +18,7 @@ import {
unwrapEsResponse,
WrappedElasticsearchClientError,
} from '../common/utils/unwrap_es_response';
import { observabilityCoPilotConfig } from './services/openai/config';
export { rangeQuery, kqlQuery, termQuery, termsQuery } from './utils/queries';
export { getInspectResponse } from '../common/utils/get_inspect_response';
@ -42,11 +43,15 @@ const configSchema = schema.object({
}),
}),
enabled: schema.boolean({ defaultValue: true }),
coPilot: schema.maybe(observabilityCoPilotConfig),
});
export const config: PluginConfigDescriptor = {
exposeToBrowser: {
unsafe: true,
coPilot: {
enabled: true,
},
},
schema: configSchema,
};

View file

@ -49,6 +49,7 @@ import { registerRuleTypes } from './lib/rules/register_rule_types';
import { SLO_BURN_RATE_RULE_ID } from '../common/constants';
import { registerSloUsageCollector } from './lib/collectors/register';
import { sloRuleFieldMap } from './lib/rules/slo_burn_rate/field_map';
import { OpenAIService } from './services/openai';
export type ObservabilityPluginSetup = ReturnType<ObservabilityPlugin['setup']>;
@ -253,12 +254,15 @@ export class ObservabilityPlugin implements Plugin<ObservabilityPluginSetup> {
);
registerSloUsageCollector(plugins.usageCollection);
const openAIService = config.coPilot?.enabled ? new OpenAIService(config.coPilot) : undefined;
core.getStartServices().then(([coreStart, pluginStart]) => {
registerRoutes({
core,
dependencies: {
ruleDataService,
getRulesClientWithRequest: pluginStart.alerting.getRulesClientWithRequest,
getOpenAIClient: () => openAIService?.client,
},
logger: this.logger,
repository: getObservabilityServerRouteRepository(),
@ -278,6 +282,9 @@ export class ObservabilityPlugin implements Plugin<ObservabilityPluginSetup> {
const api = await annotationsApiPromise;
return api?.getScopedAnnotationsClient(...args);
},
getOpenAIClient() {
return openAIService?.client;
},
alertsLocator,
};
}

View file

@ -0,0 +1,51 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import Boom from '@hapi/boom';
import { ServerRoute } from '@kbn/server-route-repository';
import * as t from 'io-ts';
import { map } from 'lodash';
import { CreateChatCompletionResponse } from 'openai';
import { Readable } from 'stream';
import { CoPilotPromptMap, coPilotPrompts } from '../../../common/co_pilot';
import { createObservabilityServerRoute } from '../create_observability_server_route';
import { ObservabilityRouteCreateOptions, ObservabilityRouteHandlerResources } from '../types';
const promptRoutes: {
[TPromptId in keyof CoPilotPromptMap as `POST /internal/observability/copilot/prompts/${TPromptId}`]: ServerRoute<
`POST /internal/observability/copilot/prompts/${TPromptId}`,
t.TypeC<{ body: CoPilotPromptMap[TPromptId]['params'] }>,
ObservabilityRouteHandlerResources,
unknown,
ObservabilityRouteCreateOptions
>;
} = Object.assign(
{},
...map(coPilotPrompts, (prompt, promptId) => {
return createObservabilityServerRoute({
endpoint: `POST /internal/observability/copilot/prompts/${promptId}`,
params: t.type({
body: prompt.params,
}),
options: {
tags: [],
},
handler: async (resources): Promise<CreateChatCompletionResponse | Readable> => {
const client = resources.dependencies.getOpenAIClient();
if (!client) {
throw Boom.notImplemented();
}
return client.chatCompletion.create(prompt.messages(resources.params.body as any));
},
});
})
);
export const observabilityCoPilotRouteRepository = {
...promptRoutes,
};

View file

@ -6,6 +6,7 @@
*/
import { compositeSloRouteRepository } from './composite_slo/route';
import { observabilityCoPilotRouteRepository } from './copilot/route';
import { rulesRouteRepository } from './rules/route';
import { sloRouteRepository } from './slo/route';
@ -14,6 +15,7 @@ export function getObservabilityServerRouteRepository() {
...rulesRouteRepository,
...sloRouteRepository,
...compositeSloRouteRepository,
...observabilityCoPilotRouteRepository,
};
return repository;
}

View file

@ -4,21 +4,22 @@
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import * as t from 'io-ts';
import { errors } from '@elastic/elasticsearch';
import Boom from '@hapi/boom';
import { RulesClientApi } from '@kbn/alerting-plugin/server/types';
import { CoreSetup, KibanaRequest, Logger, RouteRegistrar } from '@kbn/core/server';
import { RuleDataPluginService } from '@kbn/rule-registry-plugin/server';
import {
decodeRequestParams,
parseEndpoint,
routeValidationObject,
} from '@kbn/server-route-repository';
import { CoreSetup, KibanaRequest, Logger, RouteRegistrar } from '@kbn/core/server';
import Boom from '@hapi/boom';
import { errors } from '@elastic/elasticsearch';
import { RuleDataPluginService } from '@kbn/rule-registry-plugin/server';
import { RulesClientApi } from '@kbn/alerting-plugin/server/types';
import axios from 'axios';
import * as t from 'io-ts';
import { getHTTPResponseCode, ObservabilityError } from '../errors';
import { IOpenAIClient } from '../services/openai/types';
import { ObservabilityRequestHandlerContext } from '../types';
import { AbstractObservabilityServerRouteRepository } from './types';
import { getHTTPResponseCode, ObservabilityError } from '../errors';
interface RegisterRoutes {
core: CoreSetup;
@ -30,6 +31,7 @@ interface RegisterRoutes {
export interface RegisterRoutesDependencies {
ruleDataService: RuleDataPluginService;
getRulesClientWithRequest: (request: KibanaRequest) => RulesClientApi;
getOpenAIClient: () => IOpenAIClient | undefined;
}
export function registerRoutes({ repository, core, logger, dependencies }: RegisterRoutes) {
@ -82,6 +84,16 @@ export function registerRoutes({ repository, core, logger, dependencies }: Regis
});
}
if (axios.isAxiosError(error)) {
logger.error(error);
return response.customError({
statusCode: error.response?.status || 500,
body: {
message: error.message,
},
});
}
if (Boom.isBoom(error)) {
logger.error(error.output.payload.message);
return response.customError({

View file

@ -0,0 +1,49 @@
/*
* 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 axios from 'axios';
import { ChatCompletionRequestMessage, CreateChatCompletionResponse } from 'openai';
import { Readable } from 'stream';
import { format } from 'url';
import { AzureOpenAIConfig } from './config';
import { pipeStreamingResponse } from './pipe_streaming_response';
import { IOpenAIClient } from './types';
export class AzureOpenAIClient implements IOpenAIClient {
constructor(private readonly config: AzureOpenAIConfig) {}
chatCompletion: {
create: (
messages: ChatCompletionRequestMessage[]
) => Promise<CreateChatCompletionResponse | Readable>;
} = {
create: async (messages) => {
const response = await axios.post(
format({
host: `${this.config.resourceName}.openai.azure.com`,
pathname: `/openai/deployments/${this.config.deploymentId}/chat/completions`,
protocol: 'https',
query: {
'api-version': '2023-05-15',
},
}),
{
messages,
stream: true,
},
{
headers: {
'api-key': this.config.apiKey,
},
responseType: 'stream',
}
);
return pipeStreamingResponse(response);
},
};
}

View file

@ -0,0 +1,32 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import { schema, type TypeOf } from '@kbn/config-schema';
export const openAIConfig = schema.object({
openAI: schema.object({
model: schema.string(),
apiKey: schema.string(),
}),
});
export const azureOpenAIConfig = schema.object({
azureOpenAI: schema.object({
resourceName: schema.string(),
deploymentId: schema.string(),
apiKey: schema.string(),
}),
});
export const observabilityCoPilotConfig = schema.object({
enabled: schema.boolean({ defaultValue: false }),
provider: schema.oneOf([openAIConfig, azureOpenAIConfig]),
});
export type OpenAIConfig = TypeOf<typeof openAIConfig>['openAI'];
export type AzureOpenAIConfig = TypeOf<typeof azureOpenAIConfig>['azureOpenAI'];
export type ObservabilityCoPilotConfig = TypeOf<typeof observabilityCoPilotConfig>;

View file

@ -0,0 +1,23 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import { AzureOpenAIClient } from './azure_openai_client';
import { ObservabilityCoPilotConfig } from './config';
import { OpenAIClient } from './openai_client';
import { IOpenAIClient } from './types';
export class OpenAIService {
public readonly client: IOpenAIClient;
constructor(config: ObservabilityCoPilotConfig) {
if ('openAI' in config.provider) {
this.client = new OpenAIClient(config.provider.openAI);
} else {
this.client = new AzureOpenAIClient(config.provider.azureOpenAI);
}
}
}

View file

@ -0,0 +1,45 @@
/*
* 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 {
ChatCompletionRequestMessage,
Configuration,
CreateChatCompletionResponse,
OpenAIApi,
} from 'openai';
import type { OpenAIConfig } from './config';
import type { IOpenAIClient } from './types';
import { pipeStreamingResponse } from './pipe_streaming_response';
export class OpenAIClient implements IOpenAIClient {
private readonly client: OpenAIApi;
constructor(private readonly config: OpenAIConfig) {
const clientConfig = new Configuration({
apiKey: config.apiKey,
});
this.client = new OpenAIApi(clientConfig);
}
chatCompletion: {
create: (messages: ChatCompletionRequestMessage[]) => Promise<CreateChatCompletionResponse>;
} = {
create: async (messages) => {
const response = await this.client.createChatCompletion(
{
messages,
model: this.config.model,
stream: true,
},
{ responseType: 'stream' }
);
return pipeStreamingResponse(response);
},
};
}

View file

@ -0,0 +1,12 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
export function pipeStreamingResponse(response: { data: any; headers: Record<string, string> }) {
response.headers['Content-Type'] = 'dont-compress-this';
return response.data;
}

View file

@ -0,0 +1,17 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import { ChatCompletionRequestMessage, CreateChatCompletionResponse } from 'openai';
import { Readable } from 'stream';
export interface IOpenAIClient {
chatCompletion: {
create: (
messages: ChatCompletionRequestMessage[]
) => Promise<CreateChatCompletionResponse | Readable>;
};
}

View file

@ -13,6 +13,7 @@ import { RouteRenderer, RouterProvider } from '@kbn/typed-react-router-config';
import React, { useMemo } from 'react';
import ReactDOM from 'react-dom';
import { HeaderMenuPortal } from '@kbn/observability-shared-plugin/public';
import { CoPilotContextProvider } from '@kbn/observability-plugin/public';
import { CheckSetup } from './components/check_setup';
import { ProfilingDependenciesContextProvider } from './components/contexts/profiling_dependencies/profiling_dependencies_context';
import { RouteBreadcrumbsContextProvider } from './components/contexts/route_breadcrumbs_context';
@ -78,35 +79,39 @@ function App({
};
}, [coreStart, coreSetup, pluginsStart, pluginsSetup, profilingFetchServices]);
const coPilotService = pluginsSetup.observability.getCoPilotService();
return (
<KibanaThemeProvider theme$={theme$}>
<KibanaContextProvider services={{ ...coreStart, ...pluginsStart, storage }}>
<i18nCore.Context>
<RedirectAppLinks coreStart={coreStart} currentAppId="profiling">
<RouterProvider router={profilingRouter as any} history={history}>
<RouterErrorBoundary>
<TimeRangeContextProvider>
<ProfilingDependenciesContextProvider value={profilingDependencies}>
<LicenseProvider>
<>
<CheckSetup>
<RedirectWithDefaultDateRange>
<RouteBreadcrumbsContextProvider>
<RouteRenderer />
</RouteBreadcrumbsContextProvider>
</RedirectWithDefaultDateRange>
</CheckSetup>
<MountProfilingActionMenu
setHeaderActionMenu={setHeaderActionMenu}
theme$={theme$}
/>
</>
</LicenseProvider>
</ProfilingDependenciesContextProvider>
</TimeRangeContextProvider>
</RouterErrorBoundary>
</RouterProvider>
</RedirectAppLinks>
<CoPilotContextProvider value={coPilotService}>
<RedirectAppLinks coreStart={coreStart} currentAppId="profiling">
<RouterProvider router={profilingRouter as any} history={history}>
<RouterErrorBoundary>
<TimeRangeContextProvider>
<ProfilingDependenciesContextProvider value={profilingDependencies}>
<LicenseProvider>
<>
<CheckSetup>
<RedirectWithDefaultDateRange>
<RouteBreadcrumbsContextProvider>
<RouteRenderer />
</RouteBreadcrumbsContextProvider>
</RedirectWithDefaultDateRange>
</CheckSetup>
<MountProfilingActionMenu
setHeaderActionMenu={setHeaderActionMenu}
theme$={theme$}
/>
</>
</LicenseProvider>
</ProfilingDependenciesContextProvider>
</TimeRangeContextProvider>
</RouterErrorBoundary>
</RouterProvider>
</RedirectAppLinks>
</CoPilotContextProvider>
</i18nCore.Context>
</KibanaContextProvider>
</KibanaThemeProvider>

View file

@ -6,7 +6,9 @@
*/
import { EuiFlexGroup, EuiFlexItem, EuiText, EuiTitle } from '@elastic/eui';
import { i18n } from '@kbn/i18n';
import React from 'react';
import { useCoPilot, CoPilotPrompt } from '@kbn/observability-plugin/public';
import React, { useMemo } from 'react';
import { CoPilotPromptId } from '@kbn/observability-plugin/common';
import { FrameSymbolStatus, getFrameSymbolStatus } from '../../../common/profiling';
import { FrameInformationPanel } from './frame_information_panel';
import { getImpactRows } from './get_impact_rows';
@ -31,6 +33,17 @@ export interface Props {
}
export function FrameInformationWindow({ frame, totalSamples, totalSeconds }: Props) {
const coPilotService = useCoPilot();
const promptParams = useMemo(() => {
return frame?.functionName && frame?.exeFileName
? {
functionName: frame?.functionName,
library: frame?.exeFileName,
}
: undefined;
}, [frame?.functionName, frame?.exeFileName]);
if (!frame) {
return (
<FrameInformationPanel>
@ -84,6 +97,30 @@ export function FrameInformationWindow({ frame, totalSamples, totalSeconds }: Pr
<EuiFlexItem>
<KeyValueList rows={informationRows} />
</EuiFlexItem>
{coPilotService?.isEnabled() && promptParams ? (
<>
<EuiFlexItem>
<CoPilotPrompt
coPilot={coPilotService}
promptId={CoPilotPromptId.ProfilingExplainFunction}
params={promptParams}
title={i18n.translate('xpack.profiling.frameInformationWindow.explainFunction', {
defaultMessage: 'Explain function',
})}
/>
</EuiFlexItem>
<EuiFlexItem>
<CoPilotPrompt
coPilot={coPilotService}
promptId={CoPilotPromptId.ProfilingOptimizeFunction}
params={promptParams}
title={i18n.translate('xpack.profiling.frameInformationWindow.optimizeFunction', {
defaultMessage: 'Optimize function',
})}
/>
</EuiFlexItem>
</>
) : undefined}
{symbolStatus !== FrameSymbolStatus.SYMBOLIZED && (
<EuiFlexItem>
<MissingSymbolsCallout frameType={frame.frameType} />

View file

@ -10990,6 +10990,13 @@ axios@^0.21.1:
dependencies:
follow-redirects "^1.14.0"
axios@^0.26.0:
version "0.26.1"
resolved "https://registry.yarnpkg.com/axios/-/axios-0.26.1.tgz#1ede41c51fcf51bbbd6fd43669caaa4f0495aaa9"
integrity sha512-fPwcX4EvnSHuInCMItEhAGnaSEXRBjtzh9fOtsE6E1G6p7vl7edEeZe11QHf18+6+9gR5PbKV/sGKNaD8YaMeA==
dependencies:
follow-redirects "^1.14.8"
axios@^0.27.2:
version "0.27.2"
resolved "https://registry.yarnpkg.com/axios/-/axios-0.27.2.tgz#207658cc8621606e586c85db4b41a750e756d972"
@ -16294,7 +16301,7 @@ folktale@2.3.2:
resolved "https://registry.yarnpkg.com/folktale/-/folktale-2.3.2.tgz#38231b039e5ef36989920cbf805bf6b227bf4fd4"
integrity sha512-+8GbtQBwEqutP0v3uajDDoN64K2ehmHd0cjlghhxh0WpcfPzAIjPA03e1VvHlxL02FVGR0A6lwXsNQKn3H1RNQ==
follow-redirects@^1.0.0, follow-redirects@^1.14.0, follow-redirects@^1.14.9, follow-redirects@^1.15.0:
follow-redirects@^1.0.0, follow-redirects@^1.14.0, follow-redirects@^1.14.8, follow-redirects@^1.14.9, follow-redirects@^1.15.0:
version "1.15.2"
resolved "https://registry.yarnpkg.com/follow-redirects/-/follow-redirects-1.15.2.tgz#b460864144ba63f2681096f274c4e57026da2c13"
integrity sha512-VQLG33o04KaQ8uYi2tVNbdrWp1QWxNNea+nmIB4EVM28v0hmP17z7aG1+wAkNzVq4KeXTq3221ye5qTJP91JwA==
@ -22526,6 +22533,14 @@ open@^8.0.9, open@^8.4.0:
is-docker "^2.1.1"
is-wsl "^2.2.0"
openai@^3.2.1:
version "3.2.1"
resolved "https://registry.yarnpkg.com/openai/-/openai-3.2.1.tgz#1fa35bdf979cbde8453b43f2dd3a7d401ee40866"
integrity sha512-762C9BNlJPbjjlWZi4WYK9iM2tAVAv0uUp1UmI34vb0CN5T2mjB/qM6RYBmNKMh/dN9fC+bxqPwWJZUTWW052A==
dependencies:
axios "^0.26.0"
form-data "^4.0.0"
openapi-types@^10.0.0:
version "10.0.0"
resolved "https://registry.yarnpkg.com/openapi-types/-/openapi-types-10.0.0.tgz#0debbf663b2feed0322030b5b7c9080804076934"