[ObsUX] Add Top Functions to Infra Profilling tab (#171974)

Closes https://github.com/elastic/kibana/issues/171962

## Summary

This integrated Profiling Top Functions embeddable into the Infra's
Profiling tab in asset details.

![CleanShot 2023-11-28 at 14 20
38@2x](408ca866-1bc9-4b66-9ba1-d090cce0f7da)

## How to Test

* Connect local kibana to oblt cluster that has Profiling configured
(e.g. edge)
* Add this to your dev `kibana.yml`
```
xpack.profiling.enabled: true
xpack.infra.profilingEnabled: true

# Direct ES URL on the oblt cluster that you're using, in case of edge it's https://edge-oblt.es.us-west2.gcp.elastic-cloud.com:443
xpack.profiling.elasticsearch.hosts: REMOTE_CLUSTER_ES_URL

# If needed create a new user on the remote oblt cluster
xpack.profiling.elasticsearch.username: REMOTE_CLUSTER_USER
xpack.profiling.elasticsearch.password: REMOTE_CLUSTER_PASWORD
```

* Open kibana, go to Hosts
* Open a flyout for one of the hosts and make sure you see the Profiling
tab with both Flamegraph and Top Functions
* Open Host details as a full page and also make sure you see the same
* Make sure Profiling data updates when you change dates in the date
picker
This commit is contained in:
Mykola Harmash 2023-11-29 16:59:37 +01:00 committed by GitHub
parent c662bb32e9
commit ad2ca2443c
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
12 changed files with 365 additions and 91 deletions

View file

@ -7,12 +7,24 @@
import * as rt from 'io-ts';
export const InfraProfilingRequestParamsRT = rt.type({
export const InfraProfilingFlamegraphRequestParamsRT = rt.type({
hostname: rt.string,
timeRange: rt.type({
from: rt.number,
to: rt.number,
}),
from: rt.number,
to: rt.number,
});
export type InfraProfilingRequestParams = rt.TypeOf<typeof InfraProfilingRequestParamsRT>;
export const InfraProfilingFunctionsRequestParamsRT = rt.type({
hostname: rt.string,
from: rt.number,
to: rt.number,
startIndex: rt.number,
endIndex: rt.number,
});
export type InfraProfilingFlamegraphRequestParams = rt.TypeOf<
typeof InfraProfilingFlamegraphRequestParamsRT
>;
export type InfraProfilingFunctionsRequestParams = rt.TypeOf<
typeof InfraProfilingFunctionsRequestParamsRT
>;

View file

@ -0,0 +1,46 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import { useEffect, useMemo } from 'react';
import type { BaseFlameGraph } from '@kbn/profiling-utils';
import { type InfraProfilingFlamegraphRequestParams } from '../../../../common/http_api/profiling_api';
import { useHTTPRequest } from '../../../hooks/use_http_request';
import { useRequestObservable } from './use_request_observable';
interface Props {
params: InfraProfilingFlamegraphRequestParams;
isActive: boolean;
}
export function useProfilingFlamegraphData({ params, isActive }: Props) {
const { request$ } = useRequestObservable<BaseFlameGraph>();
const fetchOptions = useMemo(() => ({ query: params }), [params]);
const { loading, error, response, makeRequest } = useHTTPRequest<BaseFlameGraph>(
`/api/infra/profiling/flamegraph`,
'GET',
undefined,
undefined,
undefined,
undefined,
true,
fetchOptions
);
useEffect(() => {
if (!isActive) {
return;
}
request$.next(makeRequest);
}, [isActive, makeRequest, request$]);
return {
loading,
error,
response,
};
}

View file

@ -0,0 +1,46 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import { useEffect, useMemo } from 'react';
import type { TopNFunctions } from '@kbn/profiling-utils';
import { type InfraProfilingFunctionsRequestParams } from '../../../../common/http_api/profiling_api';
import { useHTTPRequest } from '../../../hooks/use_http_request';
import { useRequestObservable } from './use_request_observable';
interface Props {
params: InfraProfilingFunctionsRequestParams;
isActive: boolean;
}
export function useProfilingFunctionsData({ params, isActive }: Props) {
const { request$ } = useRequestObservable<TopNFunctions>();
const fetchOptions = useMemo(() => ({ query: params }), [params]);
const { loading, error, response, makeRequest } = useHTTPRequest<TopNFunctions>(
'/api/infra/profiling/functions',
'GET',
undefined,
undefined,
undefined,
undefined,
true,
fetchOptions
);
useEffect(() => {
if (!isActive) {
return;
}
request$.next(makeRequest);
}, [isActive, makeRequest, request$]);
return {
loading,
error,
response,
};
}

View file

@ -1,50 +0,0 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import { useEffect } from 'react';
import type { BaseFlameGraph } from '@kbn/profiling-utils';
import type { Subject } from 'rxjs';
import {
type InfraProfilingRequestParams,
InfraProfilingRequestParamsRT,
} from '../../../../common/http_api/profiling_api';
import { useHTTPRequest } from '../../../hooks/use_http_request';
interface Props extends InfraProfilingRequestParams {
request$?: Subject<() => Promise<BaseFlameGraph>>;
active: boolean;
}
export function useProfilingFlamegraphData({ request$, active, ...params }: Props) {
const { loading, error, response, makeRequest } = useHTTPRequest<BaseFlameGraph>(
'/api/infra/profiling/flamegraph',
'POST',
JSON.stringify(InfraProfilingRequestParamsRT.encode(params)),
undefined,
undefined,
undefined,
true
);
useEffect(() => {
if (!active) {
return;
}
if (request$) {
request$.next(makeRequest);
} else {
makeRequest();
}
}, [active, makeRequest, request$]);
return {
loading,
error,
response,
};
}

View file

@ -0,0 +1,35 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import { EuiEmptyPrompt } from '@elastic/eui';
import { i18n } from '@kbn/i18n';
import React from 'react';
export function ErrorPrompt() {
return (
<EuiEmptyPrompt
color="warning"
iconType="warning"
titleSize="xs"
title={
<h2>
{i18n.translate('xpack.infra.profiling.loadErrorTitle', {
defaultMessage: 'Unable to load the Profiling data',
})}
</h2>
}
body={
<p>
{i18n.translate('xpack.infra.profiling.loadErrorBody', {
defaultMessage:
'There was an error while trying to load profiling data. Try refreshing the page',
})}
</p>
}
/>
);
}

View file

@ -0,0 +1,41 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import React, { useMemo } from 'react';
import { EmbeddableFlamegraph } from '@kbn/observability-shared-plugin/public';
import { useAssetDetailsRenderPropsContext } from '../../hooks/use_asset_details_render_props';
import { useDatePickerContext } from '../../hooks/use_date_picker';
import { useProfilingFlamegraphData } from '../../hooks/use_profiling_flamegraph_data';
import { useTabSwitcherContext } from '../../hooks/use_tab_switcher';
import { ContentTabIds } from '../../types';
import { ErrorPrompt } from './error_prompt';
export function Flamegraph() {
const { asset } = useAssetDetailsRenderPropsContext();
const { activeTabId } = useTabSwitcherContext();
const { getDateRangeInTimestamp } = useDatePickerContext();
const { from, to } = getDateRangeInTimestamp();
const params = useMemo(
() => ({
hostname: asset.name,
from,
to,
}),
[asset.name, from, to]
);
const { error, loading, response } = useProfilingFlamegraphData({
isActive: activeTabId === ContentTabIds.PROFILING,
params,
});
if (error !== null) {
return <ErrorPrompt />;
}
return <EmbeddableFlamegraph data={response ?? undefined} isLoading={loading} height="60vh" />;
}

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 React, { useMemo } from 'react';
import { EmbeddableFunctions } from '@kbn/observability-shared-plugin/public';
import { useAssetDetailsRenderPropsContext } from '../../hooks/use_asset_details_render_props';
import { useDatePickerContext } from '../../hooks/use_date_picker';
import { useProfilingFunctionsData } from '../../hooks/use_profiling_functions_data';
import { useTabSwitcherContext } from '../../hooks/use_tab_switcher';
import { ContentTabIds } from '../../types';
import { ErrorPrompt } from './error_prompt';
export function Functions() {
const { asset } = useAssetDetailsRenderPropsContext();
const { activeTabId } = useTabSwitcherContext();
const { getDateRangeInTimestamp } = useDatePickerContext();
const { from, to } = getDateRangeInTimestamp();
const params = useMemo(
() => ({
hostname: asset.name,
from,
to,
startIndex: 0,
endIndex: 10,
}),
[asset.name, from, to]
);
const { error, loading, response } = useProfilingFunctionsData({
isActive: activeTabId === ContentTabIds.PROFILING,
params,
});
if (error !== null) {
return <ErrorPrompt />;
}
return (
<EmbeddableFunctions
data={response ?? undefined}
isLoading={loading}
rangeFrom={from}
rangeTo={to}
/>
);
}

View file

@ -4,28 +4,44 @@
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import { i18n } from '@kbn/i18n';
import { EuiSpacer, EuiTabbedContent, type EuiTabbedContentProps } from '@elastic/eui';
import React from 'react';
import { EmbeddableFlamegraph } from '@kbn/observability-shared-plugin/public';
import { BaseFlameGraph } from '@kbn/profiling-utils';
import { useAssetDetailsRenderPropsContext } from '../../hooks/use_asset_details_render_props';
import { useDatePickerContext } from '../../hooks/use_date_picker';
import { useProfilingFlamegraphData } from '../../hooks/use_profilling_flamegraph_data';
import { useRequestObservable } from '../../hooks/use_request_observable';
import { useTabSwitcherContext } from '../../hooks/use_tab_switcher';
import { ContentTabIds } from '../../types';
import { Flamegraph } from './flamegraph';
import { Functions } from './functions';
export function Profiling() {
const { request$ } = useRequestObservable<BaseFlameGraph>();
const { asset } = useAssetDetailsRenderPropsContext();
const { activeTabId } = useTabSwitcherContext();
const { getDateRangeInTimestamp } = useDatePickerContext();
const { loading, response } = useProfilingFlamegraphData({
active: activeTabId === ContentTabIds.PROFILING,
request$,
hostname: asset.name,
timeRange: getDateRangeInTimestamp(),
});
const tabs: EuiTabbedContentProps['tabs'] = [
{
id: 'flamegraph',
name: i18n.translate('xpack.infra.profiling.flamegraphTabName', {
defaultMessage: 'Flamegraph',
}),
content: (
<>
<EuiSpacer />
<Flamegraph />
</>
),
},
{
id: 'functions',
name: i18n.translate('xpack.infra.tabs.profiling.functionsTabName', {
defaultMessage: 'Top 10 Functions',
}),
content: (
<>
<EuiSpacer />
<Functions />
</>
),
},
];
return <EmbeddableFlamegraph data={response ?? undefined} isLoading={loading} height="60vh" />;
return (
<>
<EuiTabbedContent tabs={tabs} initialSelectedTab={tabs[0]} />
</>
);
}

View file

@ -7,7 +7,7 @@
import React, { useCallback, useEffect, useRef, useState } from 'react';
import { i18n } from '@kbn/i18n';
import { HttpHandler, ToastInput } from '@kbn/core/public';
import { HttpFetchOptions, HttpHandler, ToastInput } from '@kbn/core/public';
import { useKibana } from '@kbn/kibana-react-plugin/public';
import { AbortError } from '@kbn/kibana-utils-plugin/common';
import { useTrackedPromise, CanceledPromiseError } from '../utils/use_tracked_promise';
@ -20,7 +20,8 @@ export function useHTTPRequest<Response>(
decode: (response: any) => Response = (response) => response,
fetch?: HttpHandler,
toastDanger?: (input: ToastInput) => void,
abortable = false
abortable = false,
fetchOptions?: Omit<HttpFetchOptions, 'body' | 'method' | 'signal'>
) {
const kibana = useKibana();
const fetchService = fetch ? fetch : kibana.services.http?.fetch;
@ -100,6 +101,7 @@ export function useHTTPRequest<Response>(
signal: abortController.current.signal,
method,
body,
...fetchOptions,
});
},
onResolve: (resp) => {
@ -114,7 +116,7 @@ export function useHTTPRequest<Response>(
onError(e);
},
},
[pathname, body, method, fetch, toast, onError]
[pathname, body, method, fetch, toast, onError, fetchOptions]
);
const loading = request.state === 'uninitialized' || request.state === 'pending';

View file

@ -7,12 +7,18 @@
import { schema } from '@kbn/config-schema';
import { decodeOrThrow } from '@kbn/io-ts-utils';
import { InfraProfilingRequestParamsRT } from '../../../common/http_api/profiling_api';
import {
InfraProfilingFlamegraphRequestParamsRT,
InfraProfilingFunctionsRequestParamsRT,
} from '../../../common/http_api/profiling_api';
import type { InfraBackendLibs } from '../../lib/infra_types';
import { fetchProfilingFlamegraph } from './lib/fetch_profiling_flamechart';
import { fetchProfilingFlamegraph } from './lib/fetch_profiling_flamegraph';
import { fetchProfilingFunctions } from './lib/fetch_profiling_functions';
import { fetchProfilingStatus } from './lib/fetch_profiling_status';
import { getProfilingDataAccess } from './lib/get_profiling_data_access';
const CACHE_CONTROL_HEADER_VALUE = 'private, max-age=3600';
export function initProfilingRoutes({ framework, getStartServices, logger }: InfraBackendLibs) {
if (!Object.hasOwn(framework.plugins, 'profilingDataAccess')) {
logger.info(
@ -48,18 +54,18 @@ export function initProfilingRoutes({ framework, getStartServices, logger }: Inf
framework.registerRoute(
{
method: 'post',
method: 'get',
path: '/api/infra/profiling/flamegraph',
validate: {
/**
* Allow any body object and validate it inside
* the handler using RT.
*/
body: schema.object({}, { unknowns: 'allow' }),
query: schema.object({
hostname: schema.string(),
from: schema.number(),
to: schema.number(),
}),
},
},
async (requestContext, request, response) => {
const params = decodeOrThrow(InfraProfilingRequestParamsRT)(request.body);
const params = decodeOrThrow(InfraProfilingFlamegraphRequestParamsRT)(request.query);
const [coreRequestContext, profilingDataAccess] = await Promise.all([
requestContext.core,
@ -74,6 +80,46 @@ export function initProfilingRoutes({ framework, getStartServices, logger }: Inf
return response.ok({
body: flamegraph,
headers: {
'cache-control': CACHE_CONTROL_HEADER_VALUE,
},
});
}
);
framework.registerRoute(
{
method: 'get',
path: '/api/infra/profiling/functions',
validate: {
query: schema.object({
hostname: schema.string(),
from: schema.number(),
to: schema.number(),
startIndex: schema.number(),
endIndex: schema.number(),
}),
},
},
async (requestContext, request, response) => {
const params = decodeOrThrow(InfraProfilingFunctionsRequestParamsRT)(request.query);
const [coreRequestContext, profilingDataAccess] = await Promise.all([
requestContext.core,
getProfilingDataAccess(getStartServices),
]);
const functions = await fetchProfilingFunctions(
params,
profilingDataAccess,
coreRequestContext
);
return response.ok({
body: functions,
headers: {
'cache-control': CACHE_CONTROL_HEADER_VALUE,
},
});
}
);

View file

@ -9,11 +9,11 @@ import type { CoreRequestHandlerContext } from '@kbn/core-http-request-handler-c
import type { ProfilingDataAccessPluginStart } from '@kbn/profiling-data-access-plugin/server';
import type { BaseFlameGraph } from '@kbn/profiling-utils';
import { profilingUseLegacyFlamegraphAPI } from '@kbn/observability-plugin/common';
import type { InfraProfilingRequestParams } from '../../../../common/http_api/profiling_api';
import type { InfraProfilingFlamegraphRequestParams } from '../../../../common/http_api/profiling_api';
import { HOST_FIELD } from '../../../../common/constants';
export async function fetchProfilingFlamegraph(
{ hostname, timeRange }: InfraProfilingRequestParams,
{ hostname, from, to }: InfraProfilingFlamegraphRequestParams,
profilingDataAccess: ProfilingDataAccessPluginStart,
coreRequestContext: CoreRequestHandlerContext
): Promise<BaseFlameGraph> {
@ -23,8 +23,8 @@ export async function fetchProfilingFlamegraph(
return await profilingDataAccess.services.fetchFlamechartData({
esClient: coreRequestContext.elasticsearch.client.asCurrentUser,
rangeFromMs: timeRange.from,
rangeToMs: timeRange.to,
rangeFromMs: from,
rangeToMs: to,
kuery: `${HOST_FIELD} : "${hostname}"`,
useLegacyFlamegraphAPI,
});

View file

@ -0,0 +1,29 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import type { CoreRequestHandlerContext } from '@kbn/core-http-request-handler-context-server';
import type { ProfilingDataAccessPluginStart } from '@kbn/profiling-data-access-plugin/server';
import type { TopNFunctions } from '@kbn/profiling-utils';
import { HOST_FIELD } from '../../../../common/constants';
import type { InfraProfilingFunctionsRequestParams } from '../../../../common/http_api/profiling_api';
export async function fetchProfilingFunctions(
params: InfraProfilingFunctionsRequestParams,
profilingDataAccess: ProfilingDataAccessPluginStart,
coreRequestContext: CoreRequestHandlerContext
): Promise<TopNFunctions> {
const { hostname, from, to, startIndex, endIndex } = params;
return await profilingDataAccess.services.fetchFunction({
esClient: coreRequestContext.elasticsearch.client.asCurrentUser,
rangeFromMs: from,
rangeToMs: to,
kuery: `${HOST_FIELD} : "${hostname}"`,
startIndex,
endIndex,
});
}