[APM] Storage Usage Explorer view (#130152)

Co-authored-by: kibanamachine <42973632+kibanamachine@users.noreply.github.com>
Co-authored-by: boriskirov <boris.kirov@elastic.co>
This commit is contained in:
Giorgos Bamparopoulos 2022-09-06 15:38:23 +03:00 committed by GitHub
parent 0dee85815b
commit cfce8825d3
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
31 changed files with 1631 additions and 89 deletions

View file

@ -83,6 +83,8 @@ exports[`Error HTTP_REQUEST_METHOD 1`] = `undefined`;
exports[`Error HTTP_RESPONSE_STATUS_CODE 1`] = `undefined`;
exports[`Error INDEX 1`] = `undefined`;
exports[`Error KUBERNETES 1`] = `undefined`;
exports[`Error LABEL_NAME 1`] = `undefined`;
@ -205,6 +207,8 @@ exports[`Error SPAN_SUBTYPE 1`] = `undefined`;
exports[`Error SPAN_TYPE 1`] = `undefined`;
exports[`Error TIER 1`] = `undefined`;
exports[`Error TRACE_ID 1`] = `"trace id"`;
exports[`Error TRANSACTION_DURATION 1`] = `undefined`;
@ -318,6 +322,8 @@ exports[`Span HTTP_REQUEST_METHOD 1`] = `undefined`;
exports[`Span HTTP_RESPONSE_STATUS_CODE 1`] = `undefined`;
exports[`Span INDEX 1`] = `undefined`;
exports[`Span KUBERNETES 1`] = `undefined`;
exports[`Span LABEL_NAME 1`] = `undefined`;
@ -436,6 +442,8 @@ exports[`Span SPAN_SUBTYPE 1`] = `"my subtype"`;
exports[`Span SPAN_TYPE 1`] = `"span type"`;
exports[`Span TIER 1`] = `undefined`;
exports[`Span TRACE_ID 1`] = `"trace id"`;
exports[`Span TRANSACTION_DURATION 1`] = `undefined`;
@ -553,6 +561,8 @@ exports[`Transaction HTTP_REQUEST_METHOD 1`] = `"GET"`;
exports[`Transaction HTTP_RESPONSE_STATUS_CODE 1`] = `200`;
exports[`Transaction INDEX 1`] = `undefined`;
exports[`Transaction KUBERNETES 1`] = `
Object {
"pod": Object {
@ -681,6 +691,8 @@ exports[`Transaction SPAN_SUBTYPE 1`] = `undefined`;
exports[`Transaction SPAN_TYPE 1`] = `undefined`;
exports[`Transaction TIER 1`] = `undefined`;
exports[`Transaction TRACE_ID 1`] = `"trace id"`;
exports[`Transaction TRANSACTION_DURATION 1`] = `1337`;

View file

@ -147,3 +147,7 @@ export const PROFILE_INUSE_SPACE = 'profile.inuse_space.bytes';
export const FAAS_ID = 'faas.id';
export const FAAS_COLDSTART = 'faas.coldstart';
export const FAAS_TRIGGER_TYPE = 'faas.trigger.type';
// Metadata
export const TIER = '_tier';
export const INDEX = '_index';

View file

@ -0,0 +1,33 @@
/*
* 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';
export enum IndexLifecyclePhaseSelectOption {
All = 'all',
Hot = 'hot',
Warm = 'warm',
Cold = 'cold',
Frozen = 'frozen',
}
export const indexLifeCyclePhaseToDataTier = {
[IndexLifecyclePhaseSelectOption.Hot]: 'data_hot',
[IndexLifecyclePhaseSelectOption.Warm]: 'data_warm',
[IndexLifecyclePhaseSelectOption.Cold]: 'data_cold',
[IndexLifecyclePhaseSelectOption.Frozen]: 'data_frozen',
};
export const indexLifecyclePhaseRt = t.type({
indexLifecyclePhase: t.union([
t.literal(IndexLifecyclePhaseSelectOption.All),
t.literal(IndexLifecyclePhaseSelectOption.Hot),
t.literal(IndexLifecyclePhaseSelectOption.Warm),
t.literal(IndexLifecyclePhaseSelectOption.Cold),
t.literal(IndexLifecyclePhaseSelectOption.Frozen),
]),
});

View file

@ -72,7 +72,7 @@ const serviceOverviewLink = apmRouter.link('/services/:serviceName', {
If you're not in React context, you can also import `apmRouter` directly and call its `link` function - but you have to prepend the basePath manually in that case.
We also have the [`getLegacyApmHref` function and `APMLink` component](../public/components/shared/links/apm/APMLink.tsx), but we should consider them deprecated, in favor of `router.link`. Other components inside that directory contain other functions and components that provide the same functionality for linking to more specific sections inside the APM plugin.
We also have the [`getLegacyApmHref` function and `LegacyAPMLink` component](../public/components/shared/links/apm/apm_link.tsx), but we should consider them deprecated, in favor of `router.link`. Other components inside that directory contain other functions and components that provide the same functionality for linking to more specific sections inside the APM plugin.
### Cross-app linking

View file

@ -52,7 +52,9 @@ const stories: Meta<Args> = {
return (
<MemoryRouter
initialEntries={['/service-map?rangeFrom=now-15m&rangeTo=now']}
initialEntries={[
'/service-map?rangeFrom=now-15m&rangeTo=now&comparisonEnabled=true&offset=1d',
]}
>
<KibanaReactContext.Provider>
<MockUrlParamsContextProvider>

View file

@ -17,7 +17,7 @@ import {
} from '../../../../../../../common/agent_configuration/all_option';
import { useFetcher, FETCH_STATUS } from '../../../../../../hooks/use_fetcher';
import { FormRowSelect } from './form_row_select';
import { APMLink } from '../../../../../shared/links/apm/apm_link';
import { LegacyAPMLink } from '../../../../../shared/links/apm/apm_link';
import { FormRowSuggestionsSelect } from './form_row_suggestions_select';
import { SERVICE_NAME } from '../../../../../../../common/elasticsearch_fieldnames';
interface Props {
@ -142,14 +142,14 @@ export function ServicePage({ newConfig, setNewConfig, onClickNext }: Props) {
<EuiFlexGroup justifyContent="flexEnd">
{/* Cancel button */}
<EuiFlexItem grow={false}>
<APMLink path="/settings/agent-configuration">
<LegacyAPMLink path="/settings/agent-configuration">
<EuiButtonEmpty color="primary">
{i18n.translate(
'xpack.apm.agentConfig.servicePage.cancelButton',
{ defaultMessage: 'Cancel' }
)}
</EuiButtonEmpty>
</APMLink>
</LegacyAPMLink>
</EuiFlexItem>
{/* Next button */}

View file

@ -9,7 +9,7 @@ import { EuiButton, EuiSpacer, EuiText } from '@elastic/eui';
import { i18n } from '@kbn/i18n';
import { FormattedMessage } from '@kbn/i18n-react';
import React from 'react';
import { APMLink } from '../../../../shared/links/apm/apm_link';
import { LegacyAPMLink } from '../../../../shared/links/apm/apm_link';
import { useFleetCloudAgentPolicyHref } from '../../../../shared/links/kibana';
export function CardFooterContent() {
@ -31,12 +31,12 @@ export function CardFooterContent() {
defaultMessage="or simply return to the {serviceInventoryLink}."
values={{
serviceInventoryLink: (
<APMLink path="/services">
<LegacyAPMLink path="/services">
{i18n.translate(
'xpack.apm.settings.schema.success.returnText.serviceInventoryLink',
{ defaultMessage: 'Service inventory' }
)}
</APMLink>
</LegacyAPMLink>
),
}}
/>

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 React from 'react';
import { EuiFlexGroup, EuiFlexItem, EuiSpacer } from '@elastic/eui';
import { IndexLifecyclePhaseSelect } from './index_lifecycle_phase_select';
import { ServicesTable } from './services_table';
import { SearchBar } from '../../shared/search_bar';
export function StorageExplorer() {
return (
<>
<SearchBar />
<EuiFlexGroup justifyContent="flexEnd">
<EuiFlexItem grow={false}>
<IndexLifecyclePhaseSelect />
</EuiFlexItem>
</EuiFlexGroup>
<EuiSpacer />
<ServicesTable />
</>
);
}

View file

@ -0,0 +1,135 @@
/*
* 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 from 'react';
import { EuiSuperSelect, EuiText } from '@elastic/eui';
import { i18n } from '@kbn/i18n';
import { useHistory } from 'react-router-dom';
import { IndexLifecyclePhaseSelectOption } from '../../../../common/storage_explorer_types';
import * as urlHelpers from '../../shared/links/url_helpers';
import { useApmParams } from '../../../hooks/use_apm_params';
export function IndexLifecyclePhaseSelect() {
const history = useHistory();
const {
query: { indexLifecyclePhase },
} = useApmParams('/storage-explorer');
const options = [
{
value: IndexLifecyclePhaseSelectOption.All,
label: i18n.translate(
'xpack.apm.settings.storageExplorer.indexLifecyclePhase.all.label',
{
defaultMessage: 'All',
}
),
description: i18n.translate(
'xpack.apm.settings.storageExplorer.indexLifecyclePhase.all.description',
{
defaultMessage: 'Search data in all lifecycle phases.',
}
),
},
{
value: IndexLifecyclePhaseSelectOption.Hot,
label: i18n.translate(
'xpack.apm.settings.storageExplorer.indexLifecyclePhase.hot.label',
{
defaultMessage: 'Hot',
}
),
description: i18n.translate(
'xpack.apm.settings.storageExplorer.indexLifecyclePhase.hot.description',
{
defaultMessage:
'Holds your most-recent, most-frequently-searched data.',
}
),
},
{
value: IndexLifecyclePhaseSelectOption.Warm,
label: i18n.translate(
'xpack.apm.settings.storageExplorer.indexLifecyclePhase.warm.label',
{
defaultMessage: 'Warm',
}
),
description: i18n.translate(
'xpack.apm.settings.storageExplorer.indexLifecyclePhase.warm.description',
{
defaultMessage:
'Holds data from recent weeks. Updates are still allowed, but likely infrequent.',
}
),
},
{
value: IndexLifecyclePhaseSelectOption.Cold,
label: i18n.translate(
'xpack.apm.settings.storageExplorer.indexLifecyclePhase.cold.label',
{
defaultMessage: 'Cold',
}
),
description: i18n.translate(
'xpack.apm.settings.storageExplorer.indexLifecyclePhase.cold.description',
{
defaultMessage:
'While still searchable, this tier is typically optimized for lower storage costs rather than search speed.',
}
),
},
{
value: IndexLifecyclePhaseSelectOption.Frozen,
label: i18n.translate(
'xpack.apm.settings.storageExplorer.indexLifecyclePhase.frozen.label',
{
defaultMessage: 'Frozen',
}
),
description: i18n.translate(
'xpack.apm.settings.storageExplorer.indexLifecyclePhase.frozen.description',
{
defaultMessage:
'Holds data that are no longer being queried, or being queried rarely.',
}
),
},
].map(({ value, label, description }) => ({
value,
inputDisplay: label,
dropdownDisplay: (
<>
<strong>{label}</strong>
<EuiText size="s" color="subdued">
<p>{description}</p>
</EuiText>
</>
),
}));
return (
<EuiSuperSelect
prepend={i18n.translate(
'xpack.apm.settings.storageExplorer.indexLifecyclePhase.label',
{
defaultMessage: 'Index lifecycle phase',
}
)}
options={options}
valueOfSelected={indexLifecyclePhase}
onChange={(value) => {
urlHelpers.push(history, {
query: { indexLifecyclePhase: value },
});
}}
hasDividers
style={{ minWidth: 200 }}
/>
);
}

View file

@ -0,0 +1,275 @@
/*
* 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, { useState, useEffect, ReactNode } from 'react';
import {
EuiInMemoryTable,
EuiBasicTableColumn,
EuiButtonIcon,
EuiScreenReaderOnly,
RIGHT_ALIGNMENT,
EuiToolTip,
EuiIcon,
} from '@elastic/eui';
import { i18n } from '@kbn/i18n';
import { ValuesType } from 'utility-types';
import { EnvironmentBadge } from '../../../shared/environment_badge';
import { asPercent } from '../../../../../common/utils/formatters';
import { ServiceLink } from '../../../shared/service_link';
import { TruncateWithTooltip } from '../../../shared/truncate_with_tooltip';
import { StorageDetailsPerService } from './storage_details_per_service';
import { getComparisonEnabled } from '../../../shared/time_comparison/get_comparison_enabled';
import { useApmPluginContext } from '../../../../context/apm_plugin/use_apm_plugin_context';
import { asDynamicBytes } from '../../../../../common/utils/formatters';
import { NOT_AVAILABLE_LABEL } from '../../../../../common/i18n';
import { useApmParams } from '../../../../hooks/use_apm_params';
import { FETCH_STATUS } from '../../../../hooks/use_fetcher';
import { useProgressiveFetcher } from '../../../../hooks/use_progressive_fetcher';
import { useTimeRange } from '../../../../hooks/use_time_range';
import { SizeLabel } from './size_label';
import type { APIReturnType } from '../../../../services/rest/create_call_apm_api';
type StorageExplorerItems =
APIReturnType<'GET /internal/apm/storage_explorer'>['serviceStatistics'];
export function ServicesTable() {
const [itemIdToExpandedRowMap, setItemIdToExpandedRowMap] = useState<
Record<string, ReactNode>
>({});
const { core } = useApmPluginContext();
const {
query: {
rangeFrom,
rangeTo,
environment,
kuery,
indexLifecyclePhase,
comparisonEnabled: urlComparisonEnabled,
},
} = useApmParams('/storage-explorer');
const { start, end } = useTimeRange({ rangeFrom, rangeTo });
const comparisonEnabled = getComparisonEnabled({
core,
urlComparisonEnabled,
});
const toggleRowDetails = (selectedServiceName: string) => {
const expandedRowMapValues = { ...itemIdToExpandedRowMap };
if (expandedRowMapValues[selectedServiceName]) {
delete expandedRowMapValues[selectedServiceName];
} else {
expandedRowMapValues[selectedServiceName] = (
<StorageDetailsPerService
serviceName={selectedServiceName}
indexLifecyclePhase={indexLifecyclePhase}
/>
);
}
setItemIdToExpandedRowMap(expandedRowMapValues);
};
const { data, status } = useProgressiveFetcher(
(callApmApi) => {
return callApmApi('GET /internal/apm/storage_explorer', {
params: {
query: {
indexLifecyclePhase,
start,
end,
environment,
kuery,
},
},
});
},
[indexLifecyclePhase, start, end, environment, kuery]
);
useEffect(() => {
// Closes any open rows when fetching new items
setItemIdToExpandedRowMap({});
}, [status]);
const loading =
status === FETCH_STATUS.NOT_INITIATED || status === FETCH_STATUS.LOADING;
const columns: Array<EuiBasicTableColumn<ValuesType<StorageExplorerItems>>> =
[
{
field: 'serviceName',
name: i18n.translate(
'xpack.apm.settings.storageExplorer.table.serviceColumnName',
{
defaultMessage: 'Service',
}
),
sortable: true,
render: (_, { serviceName, agentName }) => {
const serviceLinkQuery = {
comparisonEnabled,
environment,
kuery,
rangeFrom,
rangeTo,
serviceGroup: '',
};
return (
<TruncateWithTooltip
data-test-subj="apmStorageExplorerServiceLink"
text={serviceName || NOT_AVAILABLE_LABEL}
content={
<ServiceLink
query={serviceLinkQuery}
serviceName={serviceName}
agentName={agentName}
/>
}
/>
);
},
},
{
field: 'environment',
name: i18n.translate(
'xpack.apm.settings.storageExplorer.table.environmentColumnName',
{
defaultMessage: 'Environment',
}
),
render: (_, { environments }) => (
<EnvironmentBadge environments={environments ?? []} />
),
sortable: true,
},
{
field: 'sampling',
name: (
<EuiToolTip
content={i18n.translate(
'xpack.apm.settings.storageExplorer.table.samplingColumnDescription',
{
defaultMessage: `The number of sampled transactions divided by total throughput. This value may differ from the configured transaction sample rate because it might be affected by the initial service's decision when using head-based sampling or by a set of policies when using tail-based sampling.`,
}
)}
>
<>
{i18n.translate(
'xpack.apm.settings.storageExplorer.table.samplingColumnName',
{
defaultMessage: 'Sample rate',
}
)}{' '}
<EuiIcon
size="s"
color="subdued"
type="questionInCircle"
className="eui-alignTop"
/>
</>
</EuiToolTip>
),
render: (value: string) => asPercent(parseFloat(value), 1),
sortable: true,
},
{
field: 'size',
name: <SizeLabel />,
render: (_, { size }) => asDynamicBytes(size) || NOT_AVAILABLE_LABEL,
sortable: true,
},
{
align: RIGHT_ALIGNMENT,
width: '40px',
isExpander: true,
name: (
<EuiScreenReaderOnly>
<span>
{i18n.translate(
'xpack.apm.settings.storageExplorer.table.expandRow',
{
defaultMessage: 'Expand row',
}
)}
</span>
</EuiScreenReaderOnly>
),
render: ({ serviceName }: { serviceName: string }) => {
return (
<EuiButtonIcon
data-test-subj={`storageDetailsButton_${serviceName}`}
onClick={() => toggleRowDetails(serviceName)}
aria-label={
itemIdToExpandedRowMap[serviceName]
? i18n.translate(
'xpack.apm.settings.storageExplorer.table.collapse',
{
defaultMessage: 'Collapse',
}
)
: i18n.translate(
'xpack.apm.settings.storageExplorer.table.expand',
{
defaultMessage: 'Expand',
}
)
}
iconType={
itemIdToExpandedRowMap[serviceName] ? 'arrowUp' : 'arrowDown'
}
/>
);
},
},
];
return (
<EuiInMemoryTable
tableCaption={i18n.translate(
'xpack.apm.settings.storageExplorer.table.caption',
{
defaultMessage: 'Storage explorer',
}
)}
items={data?.serviceStatistics ?? []}
columns={columns}
pagination={true}
sorting={true}
itemId="serviceName"
itemIdToExpandedRowMap={itemIdToExpandedRowMap}
loading={loading}
data-test-subj="storageExplorerServicesTable"
error={
status === FETCH_STATUS.FAILURE
? i18n.translate(
'xpack.apm.settings.storageExplorer.table.errorMessage',
{
defaultMessage: 'Failed to fetch',
}
)
: ''
}
message={
loading
? i18n.translate('xpack.apm.settings.storageExplorer.table.loading', {
defaultMessage: 'Loading...',
})
: i18n.translate(
'xpack.apm.settings.storageExplorer.table.noResults',
{
defaultMessage: 'No data found',
}
)
}
/>
);
}

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 React from 'react';
import { i18n } from '@kbn/i18n';
import { EuiToolTip, EuiIcon } from '@elastic/eui';
export function SizeLabel() {
return (
<EuiToolTip
content={i18n.translate(
'xpack.apm.settings.storageExplorer.sizeLabel.description',
{
defaultMessage: `The estimated storage size per service. This estimate includes primary and replica shards and is calculated by prorating the total size of your indices by the service's document count divided by the total number of documents.`,
}
)}
>
<>
{i18n.translate('xpack.apm.settings.storageExplorer.sizeLabel.title', {
defaultMessage: 'Size',
})}{' '}
<EuiIcon
size="s"
color="subdued"
type="questionInCircle"
className="eui-alignTop"
/>
</>
</EuiToolTip>
);
}

View file

@ -0,0 +1,264 @@
/*
* 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 from 'react';
import {
EuiLoadingContent,
EuiTitle,
EuiFlexGroup,
EuiFlexItem,
EuiLink,
euiPaletteColorBlind,
EuiPanel,
EuiFlexGrid,
EuiSpacer,
} from '@elastic/eui';
import { useChartTheme } from '@kbn/observability-plugin/public';
import {
Chart,
Partition,
Settings,
Datum,
PartitionLayout,
} from '@elastic/charts';
import { i18n } from '@kbn/i18n';
import { css } from '@emotion/react';
import { useEuiTheme } from '@elastic/eui';
import { ProcessorEvent } from '@kbn/observability-plugin/common';
import { IndexLifecyclePhaseSelectOption } from '../../../../../common/storage_explorer_types';
import { useApmParams } from '../../../../hooks/use_apm_params';
import { useTimeRange } from '../../../../hooks/use_time_range';
import { FETCH_STATUS } from '../../../../hooks/use_fetcher';
import { useProgressiveFetcher } from '../../../../hooks/use_progressive_fetcher';
import { useApmRouter } from '../../../../hooks/use_apm_router';
import { asInteger } from '../../../../../common/utils/formatters/formatters';
import { NOT_AVAILABLE_LABEL } from '../../../../../common/i18n';
import { asDynamicBytes } from '../../../../../common/utils/formatters';
import { getComparisonEnabled } from '../../../shared/time_comparison/get_comparison_enabled';
import { useApmPluginContext } from '../../../../context/apm_plugin/use_apm_plugin_context';
import { SizeLabel } from './size_label';
interface Props {
serviceName: string;
indexLifecyclePhase: IndexLifecyclePhaseSelectOption;
}
const ProcessorEventLabelMap = {
[ProcessorEvent.transaction]: i18n.translate(
'xpack.apm.settings.storageExplorer.serviceDetails.transactions',
{
defaultMessage: 'Transactions',
}
),
[ProcessorEvent.span]: i18n.translate(
'xpack.apm.settings.storageExplorer.serviceDetails.spans',
{
defaultMessage: 'Spans',
}
),
[ProcessorEvent.metric]: i18n.translate(
'xpack.apm.settings.storageExplorer.serviceDetails.metrics',
{
defaultMessage: 'Metrics',
}
),
[ProcessorEvent.error]: i18n.translate(
'xpack.apm.settings.storageExplorer.serviceDetails.errors',
{
defaultMessage: 'Errors',
}
),
};
export function StorageDetailsPerService({
serviceName,
indexLifecyclePhase,
}: Props) {
const { core } = useApmPluginContext();
const chartTheme = useChartTheme();
const router = useApmRouter();
const { euiTheme } = useEuiTheme();
const { query } = useApmParams('/storage-explorer');
const {
rangeFrom,
rangeTo,
environment,
kuery,
comparisonEnabled: urlComparisonEnabled,
} = query;
const { start, end } = useTimeRange({ rangeFrom, rangeTo });
const serviceOverviewLink = router.link('/services/{serviceName}/overview', {
path: {
serviceName,
},
query: {
...query,
serviceGroup: '',
comparisonEnabled: getComparisonEnabled({ core, urlComparisonEnabled }),
},
});
const groupedPalette = euiPaletteColorBlind();
const { data, status } = useProgressiveFetcher(
(callApmApi) => {
return callApmApi(
'GET /internal/apm/services/{serviceName}/storage_details',
{
params: {
path: {
serviceName,
},
query: {
indexLifecyclePhase,
start,
end,
environment,
kuery,
},
},
}
);
},
[indexLifecyclePhase, start, end, environment, kuery, serviceName]
);
if (
status === FETCH_STATUS.LOADING ||
status === FETCH_STATUS.NOT_INITIATED
) {
return (
<div style={{ width: '50%' }}>
<EuiLoadingContent data-test-subj="loadingSpinner" />
</div>
);
}
if (!data || !data.processorEventStats) {
return null;
}
const processorEventStats = data.processorEventStats.map(
({ processorEvent, docs, size }) => ({
processorEventLabel: ProcessorEventLabelMap[processorEvent],
docs,
size,
})
);
return (
<>
<EuiFlexGroup direction="column" responsive={false} gutterSize="m">
<EuiFlexItem>
<EuiFlexGroup justifyContent="spaceBetween">
<EuiFlexItem>
<EuiTitle size="xs">
<h4>
{i18n.translate(
'xpack.apm.settings.storageExplorer.serviceDetails.title',
{
defaultMessage: 'Service storage details',
}
)}
</h4>
</EuiTitle>
</EuiFlexItem>
<EuiFlexItem grow={false}>
<EuiLink href={serviceOverviewLink}>
{i18n.translate(
'xpack.apm.settings.storageExplorer.serviceDetails.serviceOverviewLink',
{
defaultMessage: 'Go to service overview',
}
)}
</EuiLink>
</EuiFlexItem>
</EuiFlexGroup>
</EuiFlexItem>
<EuiFlexItem>
<EuiFlexGroup justifyContent="spaceBetween" gutterSize="m">
<EuiFlexItem>
<EuiPanel hasShadow={false}>
<Chart>
<Settings
theme={[
{
partition: {
fillLabel: {
textColor: euiTheme.colors.emptyShade,
},
emptySizeRatio: 0.3,
},
},
...chartTheme,
]}
showLegend
/>
<Partition
layout={PartitionLayout.sunburst}
id="storageExplorerSizeByProcessorType"
data={processorEventStats}
valueAccessor={(d) => d.size ?? 0}
valueGetter="percent"
valueFormatter={(d: number) =>
asDynamicBytes(d) || NOT_AVAILABLE_LABEL
}
layers={[
{
groupByRollup: (d: Datum) => d.processorEventLabel,
shape: {
fillColor: (d) => groupedPalette[d.sortIndex],
},
},
]}
/>
</Chart>
</EuiPanel>
</EuiFlexItem>
<EuiFlexItem>
<EuiPanel hasShadow={false} paddingSize="l">
{processorEventStats.map(
({ processorEventLabel, docs, size }) => (
<>
<EuiFlexGrid
columns={2}
css={css`
font-weight: ${euiTheme.font.weight.semiBold};
`}
>
<EuiFlexItem>{processorEventLabel}</EuiFlexItem>
<EuiFlexItem>
<SizeLabel />
</EuiFlexItem>
</EuiFlexGrid>
<EuiFlexGrid
columns={2}
css={css`
background-color: ${euiTheme.colors.lightestShade};
border-top: 1px solid ${euiTheme.colors.lightShade};
border-bottom: 1px solid ${euiTheme.colors.lightShade};
`}
>
<EuiFlexItem>{asInteger(docs)}</EuiFlexItem>
<EuiFlexItem>{asDynamicBytes(size)}</EuiFlexItem>
</EuiFlexGrid>
<EuiSpacer />
</>
)
)}
</EuiPanel>
</EuiFlexItem>
</EuiFlexGroup>
</EuiFlexItem>
</EuiFlexGroup>
</>
);
}

View file

@ -27,6 +27,7 @@ import { ApmMainTemplate } from '../templates/apm_main_template';
import { ServiceGroupTemplate } from '../templates/service_group_template';
import { dependencies } from './dependencies';
import { legacyBackends } from './legacy_backends';
import { storageExplorer } from './storage_explorer';
export function page<
TPath extends string,
@ -232,6 +233,7 @@ export const home = {
}),
...dependencies,
...legacyBackends,
...storageExplorer,
'/': {
element: (
<ServiceGroupsRedirect>

View file

@ -0,0 +1,65 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import React from 'react';
import { EuiTitle, EuiFlexGroup, EuiFlexItem } from '@elastic/eui';
import { i18n } from '@kbn/i18n';
import * as t from 'io-ts';
import { StorageExplorer } from '../../app/storage_explorer';
import { BetaBadge } from '../../shared/beta_badge';
import { ApmMainTemplate } from '../templates/apm_main_template';
import { Breadcrumb } from '../../app/breadcrumb';
import {
indexLifecyclePhaseRt,
IndexLifecyclePhaseSelectOption,
} from '../../../../common/storage_explorer_types';
export const storageExplorer = {
'/storage-explorer': {
element: (
<Breadcrumb
title={i18n.translate('xpack.apm.views.storageExplorer.title', {
defaultMessage: 'Storage explorer',
})}
href="/storage-explorer"
>
<ApmMainTemplate
pageTitle={
<EuiFlexGroup
justifyContent="flexStart"
gutterSize="s"
alignItems="baseline"
>
<EuiFlexItem grow={false}>
<EuiTitle size="l">
<h2>
{i18n.translate('xpack.apm.views.storageExplorer.title', {
defaultMessage: 'Storage explorer',
})}
</h2>
</EuiTitle>
</EuiFlexItem>
<EuiFlexItem grow={false}>
<BetaBadge />
</EuiFlexItem>
</EuiFlexGroup>
}
>
<StorageExplorer />
</ApmMainTemplate>
</Breadcrumb>
),
params: t.type({
query: indexLifecyclePhaseRt,
}),
defaults: {
query: {
indexLifecyclePhase: IndexLifecyclePhaseSelectOption.All,
},
},
},
};

View file

@ -42,6 +42,16 @@ export function ApmHeaderActionMenu() {
return (
<EuiHeaderLinks gutterSize="xs">
<EuiHeaderLink
color="text"
href={apmHref('/storage-explorer')}
iconType="beaker"
data-test-subj="apmStorageExplorerHeaderLink"
>
{i18n.translate('xpack.apm.storageExplorerLinkLabel', {
defaultMessage: 'Storage Explorer',
})}
</EuiHeaderLink>
<EuiHeaderLink
color="text"
href={apmHref('/settings')}

View file

@ -24,6 +24,7 @@ export function isRouteWithTimeRange({
route.path === '/dependencies/inventory' ||
route.path === '/services/{serviceName}' ||
route.path === '/service-groups' ||
route.path === '/storage-explorer' ||
location.pathname === '/' ||
location.pathname === ''
);

View file

@ -8,12 +8,14 @@
import { Location } from 'history';
import React from 'react';
import { getRenderedHref } from '../../../../utils/test_helpers';
import { APMLink } from './apm_link';
import { LegacyAPMLink } from './apm_link';
describe('APMLink', () => {
test('APMLink should produce the correct URL', async () => {
describe('LegacyAPMLink', () => {
test('LegacyAPMLink should produce the correct URL', async () => {
const href = await getRenderedHref(
() => <APMLink path="/some/path" query={{ transactionId: 'blah' }} />,
() => (
<LegacyAPMLink path="/some/path" query={{ transactionId: 'blah' }} />
),
{
search:
'?rangeFrom=now-5h&rangeTo=now-2h&refreshPaused=true&refreshInterval=0',
@ -25,9 +27,11 @@ describe('APMLink', () => {
);
});
test('APMLink should retain current kuery value if it exists', async () => {
test('LegacyAPMLink should retain current kuery value if it exists', async () => {
const href = await getRenderedHref(
() => <APMLink path="/some/path" query={{ transactionId: 'blah' }} />,
() => (
<LegacyAPMLink path="/some/path" query={{ transactionId: 'blah' }} />
),
{
search:
'?kuery=host.hostname~20~3A~20~22fakehostname~22&rangeFrom=now-5h&rangeTo=now-2h&refreshPaused=true&refreshInterval=0',
@ -39,10 +43,10 @@ describe('APMLink', () => {
);
});
test('APMLink should overwrite current kuery value if new kuery value is provided', async () => {
test('LegacyAPMLink should overwrite current kuery value if new kuery value is provided', async () => {
const href = await getRenderedHref(
() => (
<APMLink
<LegacyAPMLink
path="/some/path"
query={{ kuery: 'host.os~20~3A~20~22linux~22' }}
/>

View file

@ -33,6 +33,7 @@ export const PERSISTENT_APM_PARAMS: Array<keyof APMQueryParams> = [
'refreshInterval',
'environment',
'serviceGroup',
'comparisonEnabled',
];
/**
@ -85,7 +86,12 @@ export function getLegacyApmHref({
});
}
export function APMLink({ path = '', query, mergeQuery, ...rest }: Props) {
export function LegacyAPMLink({
path = '',
query,
mergeQuery,
...rest
}: Props) {
const { core } = useApmPluginContext();
const { search } = useLocation();
const { basePath } = core.http;

View file

@ -1,60 +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 { Location } from 'history';
import React from 'react';
import { getRenderedHref } from '../../../../utils/test_helpers';
import { APMLink } from './apm_link';
describe('APMLink', () => {
test('APMLink should produce the correct URL', async () => {
const href = await getRenderedHref(
() => <APMLink path="/some/path" query={{ transactionId: 'blah' }} />,
{
search:
'?rangeFrom=now-5h&rangeTo=now-2h&refreshPaused=true&refreshInterval=0',
} as Location
);
expect(href).toMatchInlineSnapshot(
`"/basepath/app/apm/some/path?rangeFrom=now-5h&rangeTo=now-2h&refreshPaused=true&refreshInterval=0&transactionId=blah"`
);
});
test('APMLink should retain current kuery value if it exists', async () => {
const href = await getRenderedHref(
() => <APMLink path="/some/path" query={{ transactionId: 'blah' }} />,
{
search:
'?kuery=host.hostname~20~3A~20~22fakehostname~22&rangeFrom=now-5h&rangeTo=now-2h&refreshPaused=true&refreshInterval=0',
} as Location
);
expect(href).toMatchInlineSnapshot(
`"/basepath/app/apm/some/path?kuery=host.hostname~20~3A~20~22fakehostname~22&rangeFrom=now-5h&rangeTo=now-2h&refreshPaused=true&refreshInterval=0&transactionId=blah"`
);
});
test('APMLink should overwrite current kuery value if new kuery value is provided', async () => {
const href = await getRenderedHref(
() => (
<APMLink
path="/some/path"
query={{ kuery: 'host.os~20~3A~20~22linux~22' }}
/>
),
{
search:
'?kuery=host.hostname~20~3A~20~22fakehostname~22&rangeFrom=now-5h&rangeTo=now-2h&refreshPaused=true&refreshInterval=0',
} as Location
);
expect(href).toMatchInlineSnapshot(
`"/basepath/app/apm/some/path?kuery=host.os~20~3A~20~22linux~22&rangeFrom=now-5h&rangeTo=now-2h&refreshPaused=true&refreshInterval=0"`
);
});
});

View file

@ -6,7 +6,7 @@
*/
import React from 'react';
import { APMLink, APMLinkExtendProps } from './apm_link';
import { LegacyAPMLink, APMLinkExtendProps } from './apm_link';
interface Props extends APMLinkExtendProps {
serviceName: string;
@ -15,7 +15,7 @@ interface Props extends APMLinkExtendProps {
function ErrorDetailLink({ serviceName, errorGroupId, ...rest }: Props) {
return (
<APMLink
<LegacyAPMLink
path={`/services/${serviceName}/errors/${errorGroupId}`}
{...rest}
/>

View file

@ -6,10 +6,10 @@
*/
import React from 'react';
import { APMLink, APMLinkExtendProps } from './apm_link';
import { LegacyAPMLink, APMLinkExtendProps } from './apm_link';
function HomeLink(props: APMLinkExtendProps) {
return <APMLink path="/" {...props} />;
return <LegacyAPMLink path="/" {...props} />;
}
export { HomeLink };

View file

@ -7,7 +7,7 @@
import React from 'react';
import { APMQueryParams } from '../url_helpers';
import { APMLink, APMLinkExtendProps, useAPMHref } from './apm_link';
import { LegacyAPMLink, APMLinkExtendProps, useAPMHref } from './apm_link';
const persistedFilters: Array<keyof APMQueryParams> = [
'host',
@ -28,5 +28,5 @@ interface Props extends APMLinkExtendProps {
}
export function MetricOverviewLink({ serviceName, ...rest }: Props) {
return <APMLink path={`/services/${serviceName}/metrics`} {...rest} />;
return <LegacyAPMLink path={`/services/${serviceName}/metrics`} {...rest} />;
}

View file

@ -16,7 +16,7 @@ import { i18n } from '@kbn/i18n';
import React, { useState } from 'react';
import { AnomalyDetectionSetupState } from '../../../../common/anomaly_detection/get_anomaly_detection_setup_state';
import { useMlManageJobsHref } from '../../../hooks/use_ml_manage_jobs_href';
import { APMLink } from '../links/apm/apm_link';
import { LegacyAPMLink } from '../links/apm/apm_link';
export function shouldDisplayMlCallout(
anomalyDetectionSetupState: AnomalyDetectionSetupState
@ -58,7 +58,7 @@ export function MLCallout({
const getLearnMoreLink = (color: 'primary' | 'success') => (
<EuiButton color={color}>
<APMLink
<LegacyAPMLink
path="/settings/anomaly-detection"
style={{ whiteSpace: 'nowrap' }}
color={color}
@ -66,7 +66,7 @@ export function MLCallout({
{i18n.translate('xpack.apm.mlCallout.learnMoreButton', {
defaultMessage: `Learn more`,
})}
</APMLink>
</LegacyAPMLink>
</EuiButton>
);

View file

@ -16,7 +16,7 @@ import {
import { i18n } from '@kbn/i18n';
import { NO_PERMISSION_LABEL } from '../../../../../common/custom_link';
import { useApmPluginContext } from '../../../../context/apm_plugin/use_apm_plugin_context';
import { APMLink } from '../../links/apm/apm_link';
import { LegacyAPMLink } from '../../links/apm/apm_link';
export function CustomLinkToolbar({
onClickCreate,
@ -39,13 +39,13 @@ export function CustomLinkToolbar({
defaultMessage: 'Manage custom links',
})}
>
<APMLink path={`/settings/custom-links`}>
<LegacyAPMLink path={`/settings/custom-links`}>
<EuiIcon
type="gear"
color="text"
aria-label="Custom links settings page"
/>
</APMLink>
</LegacyAPMLink>
</EuiToolTip>
</EuiFlexItem>
{showCreateButton && (

View file

@ -130,6 +130,13 @@ const apmSettingsTitle = i18n.translate(
}
);
const apmStorageExplorerTitle = i18n.translate(
'xpack.apm.navigation.apmStorageExplorerTitle',
{
defaultMessage: 'Storage Explorer',
}
);
export class ApmPlugin implements Plugin<ApmPluginSetup, ApmPluginStart> {
constructor(
private readonly initializerContext: PluginInitializerContext<ConfigSchema>
@ -311,6 +318,11 @@ export class ApmPlugin implements Plugin<ApmPluginSetup, ApmPluginStart> {
path: '/dependencies/inventory',
},
{ id: 'settings', title: apmSettingsTitle, path: '/settings' },
{
id: 'storage-explorer',
title: apmStorageExplorerTitle,
path: '/storage-explorer',
},
],
async mount(appMountParameters: AppMountParameters<unknown>) {

View file

@ -40,6 +40,7 @@ import { suggestionsRouteRepository } from '../suggestions/route';
import { timeRangeMetadataRoute } from '../time_range_metadata/route';
import { traceRouteRepository } from '../traces/route';
import { transactionRouteRepository } from '../transactions/route';
import { storageExplorerRouteRepository } from '../storage_explorer/route';
function getTypedGlobalApmServerRouteRepository() {
const repository = {
@ -69,6 +70,7 @@ function getTypedGlobalApmServerRouteRepository() {
...historicalDataRouteRepository,
...eventMetadataRouteRepository,
...agentKeysRouteRepository,
...storageExplorerRouteRepository,
...spanLinksRouteRepository,
...infrastructureRouteRepository,
...debugTelemetryRoute,

View file

@ -0,0 +1,241 @@
/*
* 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 { QueryDslQueryContainer } from '@elastic/elasticsearch/lib/api/typesWithBodyKey';
import {
termQuery,
kqlQuery,
rangeQuery,
} from '@kbn/observability-plugin/server';
import { ProcessorEvent } from '@kbn/observability-plugin/common';
import { ApmPluginRequestHandlerContext } from '../typings';
import { Setup } from '../../lib/helpers/setup_request';
import {
IndexLifecyclePhaseSelectOption,
indexLifeCyclePhaseToDataTier,
} from '../../../common/storage_explorer_types';
import { getTotalTransactionsPerService } from './get_total_transactions_per_service';
import {
PROCESSOR_EVENT,
SERVICE_NAME,
SERVICE_ENVIRONMENT,
TIER,
TRANSACTION_SAMPLED,
AGENT_NAME,
INDEX,
} from '../../../common/elasticsearch_fieldnames';
import { environmentQuery } from '../../../common/utils/environment_query';
import { AgentName } from '../../../typings/es_schemas/ui/fields/agent';
import {
getTotalIndicesStats,
getEstimatedSizeForDocumentsInIndex,
} from './indices_stats_helpers';
import { RandomSampler } from '../../lib/helpers/get_random_sampler';
async function getMainServiceStatistics({
setup,
context,
indexLifecyclePhase,
randomSampler,
start,
end,
environment,
kuery,
}: {
setup: Setup;
context: ApmPluginRequestHandlerContext;
indexLifecyclePhase: IndexLifecyclePhaseSelectOption;
randomSampler: RandomSampler;
start: number;
end: number;
environment: string;
kuery: string;
}) {
const { apmEventClient } = setup;
const [allIndicesStats, response] = await Promise.all([
getTotalIndicesStats({ context, setup }),
apmEventClient.search('get_main_service_statistics', {
apm: {
events: [
ProcessorEvent.span,
ProcessorEvent.transaction,
ProcessorEvent.error,
ProcessorEvent.metric,
],
},
body: {
size: 0,
query: {
bool: {
filter: [
...environmentQuery(environment),
...kqlQuery(kuery),
...rangeQuery(start, end),
...(indexLifecyclePhase !== IndexLifecyclePhaseSelectOption.All
? termQuery(
TIER,
indexLifeCyclePhaseToDataTier[indexLifecyclePhase]
)
: []),
] as QueryDslQueryContainer[],
},
},
aggs: {
sample: {
random_sampler: randomSampler,
aggs: {
services: {
terms: {
field: SERVICE_NAME,
size: 500,
},
aggs: {
sample: {
top_metrics: {
size: 1,
metrics: { field: AGENT_NAME },
sort: {
'@timestamp': 'desc',
},
},
},
indices: {
terms: {
field: INDEX,
size: 500,
},
aggs: {
number_of_metric_docs: {
value_count: {
field: INDEX,
},
},
},
},
environments: {
terms: {
field: SERVICE_ENVIRONMENT,
},
},
transactions: {
filter: {
term: { [PROCESSOR_EVENT]: ProcessorEvent.transaction },
},
aggs: {
sampled_transactions: {
terms: {
field: TRANSACTION_SAMPLED,
size: 10,
},
},
},
},
},
},
},
},
},
},
}),
]);
const serviceStats = response.aggregations?.sample.services.buckets.map(
(bucket) => {
const estimatedSize = allIndicesStats
? bucket.indices.buckets.reduce((prev, curr) => {
return (
prev +
getEstimatedSizeForDocumentsInIndex({
allIndicesStats,
indexName: curr.key as string,
numberOfDocs: curr.number_of_metric_docs.value,
})
);
}, 0)
: 0;
return {
serviceName: bucket.key as string,
environments: bucket.environments.buckets.map(
({ key }) => key as string
),
sampledTransactionDocs:
bucket.transactions.sampled_transactions.buckets[0]?.doc_count,
size: estimatedSize,
agentName: bucket.sample.top[0]?.metrics[AGENT_NAME] as AgentName,
};
}
);
return serviceStats ?? [];
}
export async function getServiceStatistics({
setup,
context,
indexLifecyclePhase,
randomSampler,
start,
end,
environment,
kuery,
searchAggregatedTransactions,
}: {
setup: Setup;
context: ApmPluginRequestHandlerContext;
indexLifecyclePhase: IndexLifecyclePhaseSelectOption;
randomSampler: RandomSampler;
start: number;
end: number;
environment: string;
kuery: string;
searchAggregatedTransactions: boolean;
}) {
const [docCountPerProcessorEvent, totalTransactionsPerService] =
await Promise.all([
getMainServiceStatistics({
setup,
context,
indexLifecyclePhase,
randomSampler,
environment,
kuery,
start,
end,
}),
getTotalTransactionsPerService({
setup,
searchAggregatedTransactions,
indexLifecyclePhase,
randomSampler,
environment,
kuery,
start,
end,
}),
]);
const serviceStatistics = docCountPerProcessorEvent.map(
({ serviceName, sampledTransactionDocs, ...rest }) => {
const sampling =
sampledTransactionDocs && totalTransactionsPerService[serviceName]
? Math.min(
sampledTransactionDocs / totalTransactionsPerService[serviceName],
1
)
: 0;
return {
...rest,
serviceName,
sampling,
};
}
);
return serviceStatistics;
}

View file

@ -0,0 +1,157 @@
/*
* 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 { QueryDslQueryContainer } from '@elastic/elasticsearch/lib/api/typesWithBodyKey';
import {
termQuery,
kqlQuery,
rangeQuery,
} from '@kbn/observability-plugin/server';
import { ProcessorEvent } from '@kbn/observability-plugin/common';
import { Setup } from '../../lib/helpers/setup_request';
import {
PROCESSOR_EVENT,
SERVICE_NAME,
TIER,
INDEX,
} from '../../../common/elasticsearch_fieldnames';
import {
IndexLifecyclePhaseSelectOption,
indexLifeCyclePhaseToDataTier,
} from '../../../common/storage_explorer_types';
import { environmentQuery } from '../../../common/utils/environment_query';
import { ApmPluginRequestHandlerContext } from '../typings';
import {
getTotalIndicesStats,
getEstimatedSizeForDocumentsInIndex,
} from './indices_stats_helpers';
import { RandomSampler } from '../../lib/helpers/get_random_sampler';
export async function getStorageDetailsPerProcessorEvent({
setup,
context,
indexLifecyclePhase,
randomSampler,
start,
end,
environment,
kuery,
serviceName,
}: {
setup: Setup;
context: ApmPluginRequestHandlerContext;
indexLifecyclePhase: IndexLifecyclePhaseSelectOption;
randomSampler: RandomSampler;
start: number;
end: number;
environment: string;
kuery: string;
serviceName: string;
}) {
const { apmEventClient } = setup;
const [allIndicesStats, response] = await Promise.all([
getTotalIndicesStats({ setup, context }),
apmEventClient.search('get_storage_details_per_processor_event', {
apm: {
events: [
ProcessorEvent.span,
ProcessorEvent.transaction,
ProcessorEvent.error,
ProcessorEvent.metric,
],
},
body: {
size: 0,
query: {
bool: {
filter: [
...environmentQuery(environment),
...kqlQuery(kuery),
...rangeQuery(start, end),
...termQuery(SERVICE_NAME, serviceName),
...(indexLifecyclePhase !== IndexLifecyclePhaseSelectOption.All
? termQuery(
TIER,
indexLifeCyclePhaseToDataTier[indexLifecyclePhase]
)
: []),
] as QueryDslQueryContainer[],
},
},
aggs: {
sample: {
random_sampler: randomSampler,
aggs: {
processor_event: {
terms: {
field: PROCESSOR_EVENT,
size: 10,
},
aggs: {
number_of_metric_docs_for_processor_event: {
value_count: {
field: PROCESSOR_EVENT,
},
},
indices: {
terms: {
field: INDEX,
size: 500,
},
aggs: {
number_of_metric_docs_for_index: {
value_count: {
field: INDEX,
},
},
},
},
},
},
},
},
},
},
}),
]);
return [
ProcessorEvent.transaction,
ProcessorEvent.span,
ProcessorEvent.metric,
ProcessorEvent.error,
].map((processorEvent) => {
const bucketForProcessorEvent =
response.aggregations?.sample.processor_event.buckets?.find(
(x) => x.key === processorEvent
);
return {
processorEvent: processorEvent as Exclude<
ProcessorEvent,
ProcessorEvent.profile
>,
docs:
bucketForProcessorEvent?.number_of_metric_docs_for_processor_event
.value ?? 0,
size:
allIndicesStats && bucketForProcessorEvent
? bucketForProcessorEvent.indices.buckets.reduce((prev, curr) => {
return (
prev +
getEstimatedSizeForDocumentsInIndex({
allIndicesStats,
indexName: curr.key as string,
numberOfDocs: curr.number_of_metric_docs_for_index.value,
})
);
}, 0)
: 0,
};
});
}

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 { QueryDslQueryContainer } from '@elastic/elasticsearch/lib/api/typesWithBodyKey';
import {
termQuery,
kqlQuery,
rangeQuery,
} from '@kbn/observability-plugin/server';
import { Setup } from '../../lib/helpers/setup_request';
import {
getProcessorEventForTransactions,
getDocumentTypeFilterForTransactions,
} from '../../lib/helpers/transactions';
import { SERVICE_NAME, TIER } from '../../../common/elasticsearch_fieldnames';
import {
IndexLifecyclePhaseSelectOption,
indexLifeCyclePhaseToDataTier,
} from '../../../common/storage_explorer_types';
import { environmentQuery } from '../../../common/utils/environment_query';
import { RandomSampler } from '../../lib/helpers/get_random_sampler';
export async function getTotalTransactionsPerService({
setup,
searchAggregatedTransactions,
indexLifecyclePhase,
randomSampler,
start,
end,
environment,
kuery,
}: {
setup: Setup;
searchAggregatedTransactions: boolean;
indexLifecyclePhase: IndexLifecyclePhaseSelectOption;
randomSampler: RandomSampler;
start: number;
end: number;
environment: string;
kuery: string;
}) {
const { apmEventClient } = setup;
const response = await apmEventClient.search(
'get_total_transactions_per_service',
{
apm: {
events: [
getProcessorEventForTransactions(searchAggregatedTransactions),
],
},
body: {
size: 0,
query: {
bool: {
filter: [
...getDocumentTypeFilterForTransactions(
searchAggregatedTransactions
),
...environmentQuery(environment),
...kqlQuery(kuery),
...rangeQuery(start, end),
...(indexLifecyclePhase !== IndexLifecyclePhaseSelectOption.All
? termQuery(
TIER,
indexLifeCyclePhaseToDataTier[indexLifecyclePhase]
)
: []),
] as QueryDslQueryContainer[],
},
},
aggs: {
sample: {
random_sampler: randomSampler,
aggs: {
services: {
terms: {
field: SERVICE_NAME,
size: 500,
},
},
},
},
},
},
}
);
return (
response.aggregations?.sample.services.buckets.reduce(
(transactionsPerService, bucket) => {
transactionsPerService[bucket.key as string] = bucket.doc_count;
return transactionsPerService;
},
{} as Record<string, number>
) ?? {}
);
}

View file

@ -0,0 +1,48 @@
/*
* 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 { uniq } from 'lodash';
import { IndicesStatsIndicesStats } from '@elastic/elasticsearch/lib/api/types';
import { Setup } from '../../lib/helpers/setup_request';
import { ApmPluginRequestHandlerContext } from '../typings';
export async function getTotalIndicesStats({
context,
setup,
}: {
context: ApmPluginRequestHandlerContext;
setup: Setup;
}) {
const {
indices: { transaction, span, metric, error },
} = setup;
const index = uniq([transaction, span, metric, error]).join();
const esClient = (await context.core).elasticsearch.client;
const indicesStats = (await esClient.asCurrentUser.indices.stats({ index }))
.indices;
return indicesStats;
}
export function getEstimatedSizeForDocumentsInIndex({
allIndicesStats,
indexName,
numberOfDocs,
}: {
allIndicesStats: Record<string, IndicesStatsIndicesStats>;
indexName: string;
numberOfDocs: number;
}) {
const indexStats = allIndicesStats[indexName];
const indexTotalSize = indexStats?.total?.store?.size_in_bytes ?? 0;
const indexTotalDocCount = indexStats?.total?.docs?.count;
const estimatedSize = indexTotalDocCount
? (numberOfDocs / indexTotalDocCount) * indexTotalSize
: 0;
return estimatedSize;
}

View file

@ -0,0 +1,166 @@
/*
* 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 { ProcessorEvent } from '@kbn/observability-plugin/common';
import { createApmServerRoute } from '../apm_routes/create_apm_server_route';
import { getSearchAggregatedTransactions } from '../../lib/helpers/transactions';
import { setupRequest } from '../../lib/helpers/setup_request';
import { indexLifecyclePhaseRt } from '../../../common/storage_explorer_types';
import { getServiceStatistics } from './get_service_statistics';
import {
probabilityRt,
environmentRt,
kueryRt,
rangeRt,
} from '../default_api_types';
import { AgentName } from '../../../typings/es_schemas/ui/fields/agent';
import { getStorageDetailsPerProcessorEvent } from './get_storage_details_per_processor_event';
import { getRandomSampler } from '../../lib/helpers/get_random_sampler';
const storageExplorerRoute = createApmServerRoute({
endpoint: 'GET /internal/apm/storage_explorer',
options: { tags: ['access:apm'] },
params: t.type({
query: t.intersection([
indexLifecyclePhaseRt,
probabilityRt,
environmentRt,
kueryRt,
rangeRt,
]),
}),
handler: async (
resources
): Promise<{
serviceStatistics: Array<{
serviceName: string;
environments: string[];
size?: number;
agentName: AgentName;
sampling: number;
}>;
}> => {
const {
params,
context,
request,
plugins: { security },
} = resources;
const {
query: {
indexLifecyclePhase,
probability,
environment,
kuery,
start,
end,
},
} = params;
const [setup, randomSampler] = await Promise.all([
setupRequest(resources),
getRandomSampler({ security, request, probability }),
]);
const searchAggregatedTransactions = await getSearchAggregatedTransactions({
apmEventClient: setup.apmEventClient,
config: setup.config,
kuery,
});
const serviceStatistics = await getServiceStatistics({
setup,
context,
indexLifecyclePhase,
randomSampler,
environment,
kuery,
start,
end,
searchAggregatedTransactions,
});
return {
serviceStatistics,
};
},
});
const storageExplorerServiceDetailsRoute = createApmServerRoute({
endpoint: 'GET /internal/apm/services/{serviceName}/storage_details',
options: { tags: ['access:apm'] },
params: t.type({
path: t.type({
serviceName: t.string,
}),
query: t.intersection([
indexLifecyclePhaseRt,
probabilityRt,
environmentRt,
kueryRt,
rangeRt,
]),
}),
handler: async (
resources
): Promise<{
processorEventStats: Array<{
processorEvent:
| ProcessorEvent.transaction
| ProcessorEvent.error
| ProcessorEvent.metric
| ProcessorEvent.span;
docs: number;
size: number;
}>;
}> => {
const {
params,
context,
request,
plugins: { security },
} = resources;
const {
path: { serviceName },
query: {
indexLifecyclePhase,
probability,
environment,
kuery,
start,
end,
},
} = params;
const [setup, randomSampler] = await Promise.all([
setupRequest(resources),
getRandomSampler({ security, request, probability }),
]);
const processorEventStats = await getStorageDetailsPerProcessorEvent({
setup,
context,
indexLifecyclePhase,
randomSampler,
environment,
kuery,
start,
end,
serviceName,
});
return { processorEventStats };
},
});
export const storageExplorerRouteRepository = {
...storageExplorerRoute,
...storageExplorerServiceDetailsRoute,
};