[APM] Logs only service details view (#187221)

closes https://github.com/elastic/kibana/issues/183013 

- Introduce`logs-services` route for the logs only entities with the
tabs
   - overview page
   - Logs 
   - Dashboard
-  Log error rate and Log rate charts
- Add 2 services in the logs access plugin to fetch the timeseries for
the above charts



6969b373-6710-44ab-8a2c-3e6c0e365004

### How to test

2. Enable `observability:apmEnableMultiSignal` in advansted settings
 
<details>


<summary>3. Run the entities definition in the dev tools</summary>


```
POST kbn:/internal/api/entities/definition
{
  "id": "apm-services-with-metadata",
  "name": "Services from logs and metrics",
  "displayNameTemplate": "test",
  "history": {
    "timestampField": "@timestamp",
    "interval": "5m"
  },
  "type": "service",
  "indexPatterns": [
    "logs-*",
    "metrics-*"
  ],
  "timestampField": "@timestamp",
  "lookback": "5m",
  "identityFields": [
    {
      "field": "service.name",
      "optional": false
    },
    {
      "field": "service.environment",
      "optional": true
    }
  ],
  "identityTemplate": "{{service.name}}:{{service.environment}}",
  "metadata": [
    "tags",
    "host.name",
    "data_stream.type",
    "service.name", 
    "service.instance.id",
    "service.namespace",
    "service.environment",
    "service.version",
    "service.runtime.name",
    "service.runtime.version",
    "service.node.name",
    "service.language.name",
    "agent.name",
    "cloud.provider",
    "cloud.instance.id",
    "cloud.availability_zone",
    "cloud.instance.name",
    "cloud.machine.type",
    "container.id"
  ],
  "metrics": [
    {
      "name": "latency",
      "equation": "A",
      "metrics": [
        {
          "name": "A",
          "aggregation": "avg",
          "field": "transaction.duration.histogram"
           
          
        }
      ]
    },
    {
      "name": "throughput",
      "equation": "A / 5",
      "metrics": [
        {
          "name": "A",
          "aggregation": "doc_count",
          "filter": "transaction.duration.histogram:*"
        }
      ]
    },
    {
      "name": "failedTransactionRate",
      "equation": "A / B",
      "metrics": [
        {
          "name": "A",
          "aggregation": "doc_count",
          "filter": "event.outcome: \"failure\""
        },
        {
          "name": "B",
          "aggregation": "doc_count",
          "filter": "event.outcome: *"
        }
      ]
    },
    {
      "name": "logErrorRate",
      "equation": "A / B",
      "metrics": [
        {
          "name": "A",
          "aggregation": "doc_count",
          "filter": "log.level: \"error\""
        },
        {
          "name": "B",
          "aggregation": "doc_count",
          "filter": "log.level: *"
        }
      ]
    },
     {
      "name": "logRatePerMinute",
      "equation": "A / 5",
      "metrics": [
        {
          "name": "A",
          "aggregation": "doc_count",
          "filter": "log.level: \"error\""
        }
      ]
    }
  ]
}
```

</details>

4. Generate data with synthrace

    1. logs only: `node scripts/synthtrace simple_logs.ts`
    2. APM only: `node scripts/synthtrace simple_trace.ts`

---------

Co-authored-by: kibanamachine <42973632+kibanamachine@users.noreply.github.com>
Co-authored-by: jennypavlova <jennypavlova94@gmail.com>
Co-authored-by: jennypavlova <dzheni.pavlova@elastic.co>
This commit is contained in:
Katerina 2024-07-04 14:20:49 +03:00 committed by GitHub
parent c4837014c5
commit 372f99b213
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
35 changed files with 1622 additions and 36 deletions

View file

@ -14,6 +14,7 @@ export type LogDocument = Fields &
'input.type': string;
'log.file.path'?: string;
'service.name'?: string;
'service.environment'?: string;
'data_stream.namespace': string;
'data_stream.type': string;
'data_stream.dataset': string;

View file

@ -10,5 +10,12 @@ import noResultsIllustrationDark from './src/assets/no_results_dark.svg';
import noResultsIllustrationLight from './src/assets/no_results_light.svg';
import dashboardsLight from './src/assets/dashboards_light.svg';
import dashboardsDark from './src/assets/dashboards_dark.svg';
import apmLight from './src/assets/oblt_apm_light.svg';
export { noResultsIllustrationDark, noResultsIllustrationLight, dashboardsLight, dashboardsDark };
export {
noResultsIllustrationDark,
noResultsIllustrationLight,
dashboardsLight,
dashboardsDark,
apmLight,
};

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 60 KiB

View file

@ -9,6 +9,7 @@ import { AgentName } from '../../typings/es_schemas/ui/fields/agent';
export enum SignalTypes {
METRICS = 'metrics',
TRACES = 'traces',
LOGS = 'logs',
}

View file

@ -5,6 +5,7 @@
* 2.0.
*/
import { asPercent as obltAsPercent } from '@kbn/observability-plugin/common';
import numeral from '@elastic/numeral';
import { Maybe } from '../../../typings/common';
import { NOT_AVAILABLE_LABEL } from '../../i18n';
@ -82,3 +83,7 @@ export function asBigNumber(value: number): string {
return `${asInteger(value / 1e12)}t`;
}
export const yLabelAsPercent = (y?: number | null) => {
return obltAsPercent(y || 0, 1);
};

View file

@ -0,0 +1,93 @@
/*
* 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 { EuiPanel, EuiTitle, EuiFlexItem, EuiFlexGroup } from '@elastic/eui';
import { useApmParams } from '../../../../hooks/use_apm_params';
import { useFetcher } from '../../../../hooks/use_fetcher';
import { useTimeRange } from '../../../../hooks/use_time_range';
import { APIReturnType } from '../../../../services/rest/create_call_apm_api';
import { getTimeSeriesColor, ChartType } from '../../../shared/charts/helper/get_timeseries_color';
import { TimeseriesChartWithContext } from '../../../shared/charts/timeseries_chart_with_context';
import { yLabelAsPercent } from '../../../../../common/utils/formatters';
type LogErrorRateReturnType =
APIReturnType<'GET /internal/apm/entities/services/{serviceName}/logs_error_rate_timeseries'>;
const INITIAL_STATE: LogErrorRateReturnType = {
currentPeriod: {},
};
export function LogErrorRateChart({ height }: { height: number }) {
const {
query: { rangeFrom, rangeTo, environment, kuery },
path: { serviceName },
} = useApmParams('/logs-services/{serviceName}');
const { start, end } = useTimeRange({ rangeFrom, rangeTo });
const { data = INITIAL_STATE, status } = useFetcher(
(callApmApi) => {
if (start && end) {
return callApmApi(
'GET /internal/apm/entities/services/{serviceName}/logs_error_rate_timeseries',
{
params: {
path: {
serviceName,
},
query: {
environment,
kuery,
start,
end,
},
},
}
);
}
},
[environment, kuery, serviceName, start, end]
);
const { currentPeriodColor } = getTimeSeriesColor(ChartType.LOG_ERROR_RATE);
const timeseries = [
{
data: data?.currentPeriod?.[serviceName] ?? [],
type: 'linemark',
color: currentPeriodColor,
title: i18n.translate('xpack.apm.logs.chart.logsErrorRate', {
defaultMessage: 'Log Error Rate',
}),
},
];
return (
<EuiPanel hasBorder>
<EuiFlexGroup alignItems="center" gutterSize="s" responsive={false}>
<EuiFlexItem grow={false}>
<EuiTitle size="xs">
<h2>
{i18n.translate('xpack.apm.logErrorRate', {
defaultMessage: 'Log error rate',
})}
</h2>
</EuiTitle>
</EuiFlexItem>
</EuiFlexGroup>
<TimeseriesChartWithContext
id="logErrorRate"
height={height}
showAnnotations={false}
fetchStatus={status}
timeseries={timeseries}
yLabelFormat={yLabelAsPercent}
yDomain={{ min: 0, max: 1 }}
/>
</EuiPanel>
);
}

View file

@ -0,0 +1,93 @@
/*
* 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 { EuiPanel, EuiTitle, EuiFlexItem, EuiFlexGroup } from '@elastic/eui';
import { useApmParams } from '../../../../hooks/use_apm_params';
import { useFetcher } from '../../../../hooks/use_fetcher';
import { useTimeRange } from '../../../../hooks/use_time_range';
import { APIReturnType } from '../../../../services/rest/create_call_apm_api';
import { getTimeSeriesColor, ChartType } from '../../../shared/charts/helper/get_timeseries_color';
import { TimeseriesChartWithContext } from '../../../shared/charts/timeseries_chart_with_context';
import { yLabelAsPercent } from '../../../../../common/utils/formatters';
type LogRateReturnType =
APIReturnType<'GET /internal/apm/entities/services/{serviceName}/logs_rate_timeseries'>;
const INITIAL_STATE: LogRateReturnType = {
currentPeriod: {},
};
export function LogRateChart({ height }: { height: number }) {
const {
query: { rangeFrom, rangeTo, environment, kuery },
path: { serviceName },
} = useApmParams('/logs-services/{serviceName}');
const { start, end } = useTimeRange({ rangeFrom, rangeTo });
const { data = INITIAL_STATE, status } = useFetcher(
(callApmApi) => {
if (start && end) {
return callApmApi(
'GET /internal/apm/entities/services/{serviceName}/logs_rate_timeseries',
{
params: {
path: {
serviceName,
},
query: {
environment,
kuery,
start,
end,
},
},
}
);
}
},
[environment, kuery, serviceName, start, end]
);
const { currentPeriodColor } = getTimeSeriesColor(ChartType.LOG_RATE);
const timeseries = [
{
data: data?.currentPeriod?.[serviceName] ?? [],
type: 'linemark',
color: currentPeriodColor,
title: i18n.translate('xpack.apm.logs.chart.logRate', {
defaultMessage: 'Log Rate',
}),
},
];
return (
<EuiPanel hasBorder>
<EuiFlexGroup alignItems="center" gutterSize="s" responsive={false}>
<EuiFlexItem grow={false}>
<EuiTitle size="xs">
<h2>
{i18n.translate('xpack.apm.logRate', {
defaultMessage: 'Log rate',
})}
</h2>
</EuiTitle>
</EuiFlexItem>
</EuiFlexGroup>
<TimeseriesChartWithContext
id="logRate"
height={height}
showAnnotations={false}
fetchStatus={status}
timeseries={timeseries}
yLabelFormat={yLabelAsPercent}
yDomain={{ min: 0, max: 1 }}
/>
</EuiPanel>
);
}

View file

@ -0,0 +1,96 @@
/*
* 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 {
EuiButton,
EuiFlexGroup,
EuiFlexItem,
EuiImage,
EuiPanel,
EuiSpacer,
EuiText,
EuiTitle,
EuiButtonEmpty,
useEuiTheme,
} from '@elastic/eui';
import { apmLight } from '@kbn/shared-svg';
import { FormattedMessage } from '@kbn/i18n-react';
import { useApmPluginContext } from '../../../../context/apm_plugin/use_apm_plugin_context';
export function AddAPMCallOut() {
const { core } = useApmPluginContext();
const { euiTheme } = useEuiTheme();
return (
<EuiPanel color="subdued" hasShadow={false}>
<EuiFlexGroup alignItems="center" gutterSize="s" justifyContent="flexStart">
<EuiFlexItem grow={0}>
<EuiImage
css={{
background: euiTheme.colors.emptyShade,
}}
width="160"
height="100"
size="m"
src={apmLight}
alt="apm-logo"
/>
</EuiFlexItem>
<EuiFlexItem grow={4}>
<EuiTitle size="xs">
<h1>
<FormattedMessage
id="xpack.apm.addAPMCallOut.title"
defaultMessage="Detect and resolve issues faster with deep visibility into your application"
/>
</h1>
</EuiTitle>
<EuiSpacer size="m" />
<EuiText size="s">
<p>
<FormattedMessage
id="xpack.apm.addAPMCallOut.description"
defaultMessage="Understanding your application performance, relationships and dependencies by
instrumenting with APM."
/>
</p>
</EuiText>
<EuiSpacer size="s" />
</EuiFlexItem>
</EuiFlexGroup>
<EuiFlexGroup alignItems="center" gutterSize="s" justifyContent="flexEnd">
<EuiFlexItem grow={false}>
<div>
<EuiButton
data-test-subj="apmAddApmCallOutButton"
href={core.http.basePath.prepend('/app/apm/tutorial')}
>
{i18n.translate('xpack.apm.logsServiceOverview.callout.addApm', {
defaultMessage: 'Add APM',
})}
</EuiButton>
</div>
</EuiFlexItem>
<EuiFlexItem grow={false}>
<EuiButtonEmpty
data-test-subj="apmAddApmCallOutLearnMoreButton"
iconType="popout"
iconSide="right"
href="https://www.elastic.co/observability/application-performance-monitoring"
>
{i18n.translate('xpack.apm.addAPMCallOut.linkToElasticcoButtonEmptyLabel', {
defaultMessage: 'Learn more',
})}
</EuiButtonEmpty>
</EuiFlexItem>
</EuiFlexGroup>
</EuiPanel>
);
}

View file

@ -0,0 +1,73 @@
/*
* 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.
*/
/*
* 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 { EuiFlexGroupProps, EuiFlexGroup, EuiFlexItem, EuiSpacer } from '@elastic/eui';
import { AnnotationsContextProvider } from '../../../../context/annotations/annotations_context';
import { useApmServiceContext } from '../../../../context/apm_service/use_apm_service_context';
import { ChartPointerEventContextProvider } from '../../../../context/chart_pointer_event/chart_pointer_event_context';
import { useBreakpoints } from '../../../../hooks/use_breakpoints';
import { useApmParams } from '../../../../hooks/use_apm_params';
import { useTimeRange } from '../../../../hooks/use_time_range';
import { AddAPMCallOut } from './add_apm_callout';
import { LogRateChart } from '../charts/log_rate_chart';
import { LogErrorRateChart } from '../charts/log_error_rate_chart';
/**
* The height a chart should be if it's next to a table with 5 rows and a title.
* Add the height of the pagination row.
*/
const chartHeight = 400;
export function LogsServiceOverview() {
const { serviceName } = useApmServiceContext();
const {
query: { environment, rangeFrom, rangeTo },
} = useApmParams('/logs-services/{serviceName}/overview');
const { start, end } = useTimeRange({ rangeFrom, rangeTo });
const { isLarge } = useBreakpoints();
const isSingleColumn = isLarge;
const rowDirection: EuiFlexGroupProps['direction'] = isSingleColumn ? 'column' : 'row';
return (
<AnnotationsContextProvider
serviceName={serviceName}
environment={environment}
start={start}
end={end}
>
<ChartPointerEventContextProvider>
<AddAPMCallOut />
<EuiSpacer size="l" />
<EuiFlexGroup direction="column" gutterSize="s">
<EuiFlexItem>
<EuiFlexGroup direction={rowDirection} gutterSize="s" responsive={false}>
<EuiFlexItem grow={4}>
<LogRateChart height={chartHeight} />
</EuiFlexItem>
<EuiFlexItem grow={4}>
<LogErrorRateChart height={chartHeight} />
</EuiFlexItem>
</EuiFlexGroup>
</EuiFlexItem>
</EuiFlexGroup>
</ChartPointerEventContextProvider>
</AnnotationsContextProvider>
);
}

View file

@ -45,13 +45,14 @@ export interface MergedServiceDashboard extends SavedApmCustomDashboard {
title: string;
}
export function ServiceDashboards() {
export function ServiceDashboards({ checkForEntities = false }: { checkForEntities?: boolean }) {
const {
path: { serviceName },
query: { environment, kuery, rangeFrom, rangeTo, dashboardId },
} = useAnyOfApmParams(
'/services/{serviceName}/dashboards',
'/mobile-services/{serviceName}/dashboards'
'/mobile-services/{serviceName}/dashboards',
'/logs-services/{serviceName}/dashboards'
);
const [dashboard, setDashboard] = useState<AwaitingDashboardAPI>();
const [serviceDashboards, setServiceDashboards] = useState<MergedServiceDashboard[]>([]);
@ -68,12 +69,12 @@ export function ServiceDashboards() {
isCachable: false,
params: {
path: { serviceName },
query: { start, end },
query: { start, end, checkFor: checkForEntities ? 'entities' : 'services' },
},
});
}
},
[serviceName, start, end]
[serviceName, start, end, checkForEntities]
);
useEffect(() => {

View file

@ -30,6 +30,7 @@ import { NotAvailableApmMetrics } from '../../../../shared/not_available_apm_met
import { TruncateWithTooltip } from '../../../../shared/truncate_with_tooltip';
import { ServiceInventoryFieldName } from './multi_signal_services_table';
import { EntityServiceListItem, SignalTypes } from '../../../../../../common/entities/types';
import { isApmSignal } from '../../../../../utils/get_signal_type';
export function getServiceColumns({
query,
breakpoints,
@ -46,14 +47,19 @@ export function getServiceColumns({
defaultMessage: 'Name',
}),
sortable: true,
render: (_, { serviceName, agentName }) => (
render: (_, { serviceName, agentName, signalTypes }) => (
<TruncateWithTooltip
data-test-subj="apmServiceListAppLink"
text={serviceName}
content={
<EuiFlexGroup gutterSize="s" justifyContent="flexStart">
<EuiFlexItem grow={false}>
<ServiceLink serviceName={serviceName} agentName={agentName} query={query} />
<ServiceLink
signalTypes={signalTypes}
serviceName={serviceName}
agentName={agentName}
query={query}
/>
</EuiFlexItem>
</EuiFlexGroup>
}
@ -87,7 +93,7 @@ export function getServiceColumns({
render: (_, { metrics, signalTypes }) => {
const { currentPeriodColor } = getTimeSeriesColor(ChartType.LATENCY_AVG);
return !signalTypes.includes(SignalTypes.METRICS) ? (
return !isApmSignal(signalTypes) ? (
<NotAvailableApmMetrics />
) : (
<ListMetric
@ -110,7 +116,7 @@ export function getServiceColumns({
render: (_, { metrics, signalTypes }) => {
const { currentPeriodColor } = getTimeSeriesColor(ChartType.THROUGHPUT);
return !signalTypes.includes(SignalTypes.METRICS) ? (
return !isApmSignal(signalTypes) ? (
<NotAvailableApmMetrics />
) : (
<ListMetric
@ -133,7 +139,7 @@ export function getServiceColumns({
render: (_, { metrics, signalTypes }) => {
const { currentPeriodColor } = getTimeSeriesColor(ChartType.FAILED_TRANSACTION_RATE);
return !signalTypes.includes(SignalTypes.METRICS) ? (
return !isApmSignal(signalTypes) ? (
<NotAvailableApmMetrics />
) : (
<ListMetric

View file

@ -14,7 +14,7 @@ import { useApmServiceContext } from '../../../context/apm_service/use_apm_servi
import { APIReturnType } from '../../../services/rest/create_call_apm_api';
import { CONTAINER_ID, SERVICE_ENVIRONMENT, SERVICE_NAME } from '../../../../common/es_fields/apm';
import { useApmParams } from '../../../hooks/use_apm_params';
import { useAnyOfApmParams } from '../../../hooks/use_apm_params';
import { useTimeRange } from '../../../hooks/use_time_range';
export function ServiceLogs() {
@ -22,7 +22,7 @@ export function ServiceLogs() {
const {
query: { environment, kuery, rangeFrom, rangeTo },
} = useApmParams('/services/{serviceName}/logs');
} = useAnyOfApmParams('/services/{serviceName}/logs', '/logs-services/{serviceName}/logs');
const { start, end } = useTimeRange({ rangeFrom, rangeTo });

View file

@ -16,6 +16,7 @@ import { TransactionLink } from '../app/transaction_link';
import { homeRoute } from './home';
import { serviceDetailRoute } from './service_detail';
import { mobileServiceDetailRoute } from './mobile_service_detail';
import { logsServiceDetailsRoute } from './entities/logs_service_details';
import { settingsRoute } from './settings';
import { onboarding } from './onboarding';
import { tutorialRedirectRoute } from './onboarding/redirect';
@ -130,6 +131,7 @@ const apmRoutes = {
...settingsRoute,
...serviceDetailRoute,
...mobileServiceDetailRoute,
...logsServiceDetailsRoute,
...homeRoute,
},
},

View file

@ -0,0 +1,149 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import { i18n } from '@kbn/i18n';
import { toBooleanRt, toNumberRt } from '@kbn/io-ts-utils';
import { Outlet } from '@kbn/typed-react-router-config';
import * as t from 'io-ts';
import React from 'react';
import { LogsServiceTemplate } from '../../templates/entities/logs_service_template';
import { offsetRt } from '../../../../../common/comparison_rt';
import { ENVIRONMENT_ALL } from '../../../../../common/environment_filter_values';
import { environmentRt } from '../../../../../common/environment_rt';
import { ApmTimeRangeMetadataContextProvider } from '../../../../context/time_range_metadata/time_range_metadata_context';
import { ServiceDashboards } from '../../../app/service_dashboards';
import { ServiceLogs } from '../../../app/service_logs';
import { LogsServiceOverview } from '../../../app/entities/logs/logs_service_overview';
import { RedirectToDefaultLogsServiceRouteView } from '../../service_detail/redirect_to_default_service_route_view';
export function page({
title,
tabKey,
element,
searchBarOptions,
}: {
title: string;
tabKey: React.ComponentProps<typeof LogsServiceTemplate>['selectedTabKey'];
element: React.ReactElement<any, any>;
searchBarOptions?: {
showUnifiedSearchBar?: boolean;
showTransactionTypeSelector?: boolean;
showTimeComparison?: boolean;
showMobileFilters?: boolean;
hidden?: boolean;
};
}): {
element: React.ReactElement<any, any>;
} {
return {
element: (
<LogsServiceTemplate
title={title}
selectedTabKey={tabKey}
searchBarOptions={searchBarOptions}
>
{element}
</LogsServiceTemplate>
),
};
}
export const logsServiceDetailsRoute = {
'/logs-services/{serviceName}': {
element: (
<ApmTimeRangeMetadataContextProvider>
<Outlet />
</ApmTimeRangeMetadataContextProvider>
),
params: t.intersection([
t.type({
path: t.type({
serviceName: t.string,
}),
}),
t.type({
query: t.intersection([
environmentRt,
t.type({
rangeFrom: t.string,
rangeTo: t.string,
kuery: t.string,
serviceGroup: t.string,
comparisonEnabled: toBooleanRt,
}),
t.partial({
transactionType: t.string,
refreshPaused: t.union([t.literal('true'), t.literal('false')]),
refreshInterval: t.string,
}),
offsetRt,
]),
}),
]),
defaults: {
query: {
kuery: '',
environment: ENVIRONMENT_ALL.value,
serviceGroup: '',
},
},
children: {
'/logs-services/{serviceName}/overview': {
...page({
element: <LogsServiceOverview />,
tabKey: 'overview',
title: i18n.translate('xpack.apm.views.overview.title', {
defaultMessage: 'Overview',
}),
searchBarOptions: {
showUnifiedSearchBar: true,
},
}),
params: t.partial({
query: t.partial({
page: toNumberRt,
pageSize: toNumberRt,
sortField: t.string,
sortDirection: t.union([t.literal('asc'), t.literal('desc')]),
}),
}),
},
'/logs-services/{serviceName}/logs': {
...page({
tabKey: 'logs',
title: i18n.translate('xpack.apm.views.logs.title', {
defaultMessage: 'Logs',
}),
element: <ServiceLogs />,
searchBarOptions: {
showUnifiedSearchBar: false,
},
}),
},
'/logs-services/{serviceName}/dashboards': {
...page({
tabKey: 'dashboards',
title: i18n.translate('xpack.apm.views.dashboard.title', {
defaultMessage: 'Dashboards',
}),
element: <ServiceDashboards checkForEntities />,
searchBarOptions: {
showUnifiedSearchBar: false,
},
}),
params: t.partial({
query: t.partial({
dashboardId: t.string,
}),
}),
},
'/logs-services/{serviceName}/': {
element: <RedirectToDefaultLogsServiceRouteView />,
},
},
},
};

View file

@ -19,3 +19,14 @@ export function RedirectToDefaultServiceRouteView() {
return <Redirect to={{ pathname: `/services/${serviceName}/overview`, search }} />;
}
export function RedirectToDefaultLogsServiceRouteView() {
const {
path: { serviceName },
query,
} = useApmParams('/logs-services/{serviceName}/*');
const search = qs.stringify(query);
return <Redirect to={{ pathname: `/logs-services/${serviceName}/overview`, search }} />;
}

View file

@ -0,0 +1,177 @@
/*
* 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 { EuiFlexGroup, EuiFlexItem, EuiPageHeaderProps, EuiTitle } from '@elastic/eui';
import { i18n } from '@kbn/i18n';
import { omit } from 'lodash';
import React from 'react';
import { ApmServiceContextProvider } from '../../../../../context/apm_service/apm_service_context';
import { useBreadcrumb } from '../../../../../context/breadcrumbs/use_breadcrumb';
import { ServiceAnomalyTimeseriesContextProvider } from '../../../../../context/service_anomaly_timeseries/service_anomaly_timeseries_context';
import { useApmParams } from '../../../../../hooks/use_apm_params';
import { useApmRouter } from '../../../../../hooks/use_apm_router';
import { useTimeRange } from '../../../../../hooks/use_time_range';
import { MobileSearchBar } from '../../../../app/mobile/search_bar';
import { SearchBar } from '../../../../shared/search_bar/search_bar';
import { ServiceIcons } from '../../../../shared/service_icons';
import { TechnicalPreviewBadge } from '../../../../shared/technical_preview_badge';
import { ApmMainTemplate } from '../../apm_main_template';
type Tab = NonNullable<EuiPageHeaderProps['tabs']>[0] & {
key: 'overview' | 'logs' | 'dashboards';
hidden?: boolean;
};
interface Props {
title: string;
children: React.ReactChild;
selectedTabKey: Tab['key'];
searchBarOptions?: React.ComponentProps<typeof MobileSearchBar>;
}
export function LogsServiceTemplate(props: Props) {
return (
<ApmServiceContextProvider>
<TemplateWithContext {...props} />
</ApmServiceContextProvider>
);
}
function TemplateWithContext({ title, children, selectedTabKey, searchBarOptions }: Props) {
const {
path: { serviceName },
query,
query: { rangeFrom, rangeTo, environment },
} = useApmParams('/logs-services/{serviceName}/*');
const { start, end } = useTimeRange({ rangeFrom, rangeTo });
const router = useApmRouter();
const tabs = useTabs({ selectedTabKey });
const selectedTab = tabs?.find(({ isSelected }) => isSelected);
const servicesLink = router.link('/services', {
query: { ...query },
});
useBreadcrumb(
() => [
{
title: i18n.translate('xpack.apm.logServices.breadcrumb.title', {
defaultMessage: 'Services',
}),
href: servicesLink,
},
...(selectedTab
? [
{
title: serviceName,
href: router.link('/logs-services/{serviceName}', {
path: { serviceName },
query,
}),
},
{
title: selectedTab.label,
href: selectedTab.href,
} as { title: string; href: string },
]
: []),
],
[query, router, selectedTab, serviceName, servicesLink]
);
return (
<ApmMainTemplate
pageHeader={{
tabs,
pageTitle: (
<EuiFlexGroup justifyContent="spaceBetween">
<EuiFlexItem>
<EuiFlexGroup alignItems="center">
<EuiFlexItem grow={false}>
<EuiTitle size="l">
<h1 data-test-subj="apmMainTemplateHeaderServiceName">
{serviceName} <TechnicalPreviewBadge icon="beaker" />
</h1>
</EuiTitle>
</EuiFlexItem>
<EuiFlexItem grow={false}>
<ServiceIcons
serviceName={serviceName}
environment={environment}
start={start}
end={end}
/>
</EuiFlexItem>
</EuiFlexGroup>
</EuiFlexItem>
</EuiFlexGroup>
),
}}
>
<SearchBar {...searchBarOptions} />
<ServiceAnomalyTimeseriesContextProvider>{children}</ServiceAnomalyTimeseriesContextProvider>
</ApmMainTemplate>
);
}
function useTabs({ selectedTabKey }: { selectedTabKey: Tab['key'] }) {
const router = useApmRouter();
const {
path: { serviceName },
query: queryFromUrl,
} = useApmParams(`/logs-services/{serviceName}/${selectedTabKey}` as const);
const query = omit(queryFromUrl, 'page', 'pageSize', 'sortField', 'sortDirection');
const tabs: Tab[] = [
{
key: 'overview',
href: router.link('/logs-services/{serviceName}/overview', {
path: { serviceName },
query,
}),
label: i18n.translate('xpack.apm.logsServiceDetails.overviewTabLabel', {
defaultMessage: 'Overview',
}),
},
{
key: 'logs',
href: router.link('/logs-services/{serviceName}/logs', {
path: { serviceName },
query,
}),
label: i18n.translate('xpack.apm.logsServiceDetails.logsTabLabel', {
defaultMessage: 'Logs',
}),
},
{
key: 'dashboards',
href: router.link('/logs-services/{serviceName}/dashboards', {
path: { serviceName },
query,
}),
append: <TechnicalPreviewBadge icon="beaker" />,
label: i18n.translate('xpack.apm.logsServiceDetails.dashboardsTabLabel', {
defaultMessage: 'Dashboards',
}),
},
];
return tabs
.filter((t) => !t.hidden)
.map(({ href, key, label, append }) => ({
href,
label,
append,
isSelected: key === selectedTabKey,
'data-test-subj': `${key}Tab`,
}));
}

View file

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

View file

@ -12,9 +12,11 @@ import { euiStyled } from '@kbn/kibana-react-plugin/common';
import { TypeOf } from '@kbn/typed-react-router-config';
import React from 'react';
import { isMobileAgentName } from '../../../../../../common/agent_name';
import { SignalTypes } from '../../../../../../common/entities/types';
import { NOT_AVAILABLE_LABEL } from '../../../../../../common/i18n';
import { AgentName } from '../../../../../../typings/es_schemas/ui/fields/agent';
import { useApmRouter } from '../../../../../hooks/use_apm_router';
import { isLogsSignal } from '../../../../../utils/get_signal_type';
import { truncate, unit } from '../../../../../utils/style';
import { ApmRoutes } from '../../../../routing/apm_route_config';
import { PopoverTooltip } from '../../../popover_tooltip';
@ -32,12 +34,20 @@ interface ServiceLinkProps {
query: TypeOf<ApmRoutes, '/services/{serviceName}/overview'>['query'];
serviceName: string;
serviceOverflowCount?: number;
signalTypes?: SignalTypes[];
}
export function ServiceLink({ agentName, query, serviceName }: ServiceLinkProps) {
const { link } = useApmRouter();
export function ServiceLink({
agentName,
query,
serviceName,
signalTypes = [SignalTypes.METRICS],
}: ServiceLinkProps) {
const apmRouter = useApmRouter();
const serviceLink = isMobileAgentName(agentName)
? '/mobile-services/{serviceName}/overview'
: isLogsSignal(signalTypes)
? '/logs-services/{serviceName}/overview'
: '/services/{serviceName}/overview';
if (serviceName === OTHER_SERVICE_NAME) {
@ -74,7 +84,7 @@ export function ServiceLink({ agentName, query, serviceName }: ServiceLinkProps)
content={
<StyledLink
data-test-subj={`serviceLink_${agentName}`}
href={link(serviceLink, {
href={apmRouter.link(serviceLink, {
path: { serviceName },
query,
})}

View file

@ -47,7 +47,11 @@ export function ApmServiceContextProvider({ children }: { children: ReactNode })
path: { serviceName },
query,
query: { kuery, rangeFrom, rangeTo },
} = useAnyOfApmParams('/services/{serviceName}', '/mobile-services/{serviceName}');
} = useAnyOfApmParams(
'/services/{serviceName}',
'/mobile-services/{serviceName}',
'/logs-services/{serviceName}'
);
const { start, end } = useTimeRange({ rangeFrom, rangeTo });

View file

@ -41,7 +41,11 @@ export function ServiceAnomalyTimeseriesContextProvider({
const {
query: { rangeFrom, rangeTo },
} = useAnyOfApmParams('/services/{serviceName}', '/mobile-services/{serviceName}');
} = useAnyOfApmParams(
'/services/{serviceName}',
'/mobile-services/{serviceName}',
'/logs-services/{serviceName}'
);
const { start, end } = useTimeRange({ rangeFrom, rangeTo });
const { preferredEnvironment } = useEnvironmentsContext();

View file

@ -0,0 +1,15 @@
/*
* 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 { SignalTypes } from '../../common/entities/types';
export function isApmSignal(signalTypes: SignalTypes[]) {
return signalTypes.includes(SignalTypes.METRICS) || signalTypes.includes(SignalTypes.TRACES);
}
export function isLogsSignal(signalTypes: SignalTypes[]) {
return signalTypes.includes(SignalTypes.LOGS) && !isApmSignal(signalTypes);
}

View file

@ -8,6 +8,10 @@ import { ESSearchRequest, InferSearchResponseOf } from '@kbn/es-types';
import type { KibanaRequest } from '@kbn/core/server';
import { ElasticsearchClient } from '@kbn/core/server';
import { unwrapEsResponse } from '@kbn/observability-plugin/common/utils/unwrap_es_response';
import {
MsearchMultisearchBody,
MsearchMultisearchHeader,
} from '@elastic/elasticsearch/lib/api/types';
import { withApmSpan } from '../../../../utils/with_apm_span';
const ENTITIES_INDEX_NAME = '.entities-observability.latest-*';
@ -29,6 +33,9 @@ export interface EntitiesESClient {
operationName: string,
searchRequest: TSearchRequest
): Promise<InferSearchResponseOf<TDocument, TSearchRequest>>;
msearch<TDocument = unknown, TSearchRequest extends ESSearchRequest = ESSearchRequest>(
allSearches: TSearchRequest[]
): Promise<{ responses: Array<InferSearchResponseOf<TDocument, TSearchRequest>> }>;
}
export async function createEntitiesESClient({
@ -63,5 +70,36 @@ export async function createEntitiesESClient({
return unwrapEsResponse(promise);
},
async msearch<TDocument = unknown, TSearchRequest extends ESSearchRequest = ESSearchRequest>(
allSearches: TSearchRequest[]
): Promise<{ responses: Array<InferSearchResponseOf<TDocument, TSearchRequest>> }> {
const searches = allSearches
.map((params) => {
const searchParams: [MsearchMultisearchHeader, MsearchMultisearchBody] = [
{
index: [ENTITIES_INDEX_NAME],
},
{
...params.body,
},
];
return searchParams;
})
.flat();
const promise = esClient.msearch(
{ searches },
{
meta: true,
}
) as unknown as Promise<{
body: { responses: Array<InferSearchResponseOf<TDocument, TSearchRequest>> };
}>;
const { body } = await promise;
return { responses: body.responses };
},
};
}

View file

@ -0,0 +1,61 @@
/*
* 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 { kqlQuery, termQuery } from '@kbn/observability-plugin/server';
import { estypes } from '@elastic/elasticsearch';
import { SERVICE_NAME } from '../../../common/es_fields/apm';
import { SavedApmCustomDashboard } from '../../../common/custom_dashboards';
import { EntitiesESClient } from '../../lib/helpers/create_es_client/create_assets_es_client/create_assets_es_clients';
function getSearchRequest(filters: estypes.QueryDslQueryContainer[]) {
return {
body: {
track_total_hits: false,
terminate_after: 1,
size: 1,
query: {
bool: {
filter: filters,
},
},
},
};
}
export async function getEntitiesWithDashboards({
entitiesESClient,
allLinkedCustomDashboards,
serviceName,
}: {
entitiesESClient: EntitiesESClient;
allLinkedCustomDashboards: SavedApmCustomDashboard[];
serviceName: string;
}): Promise<SavedApmCustomDashboard[]> {
const allKueryPerDashboard = allLinkedCustomDashboards.map(({ kuery }) => ({
kuery,
}));
const allSearches = allKueryPerDashboard.map((dashboard) =>
getSearchRequest([...kqlQuery(dashboard.kuery), ...termQuery(SERVICE_NAME, serviceName)])
);
const filteredDashboards = [];
if (allSearches.length > 0) {
const allResponses = (await entitiesESClient.msearch(allSearches)).responses;
for (let index = 0; index < allLinkedCustomDashboards.length; index++) {
const responsePerDashboard = allResponses[index];
const dashboard = allLinkedCustomDashboards[index];
if (responsePerDashboard.hits.hits.length > 0) {
filteredDashboards.push(dashboard);
}
}
}
return filteredDashboards;
}

View file

@ -14,6 +14,8 @@ import { getCustomDashboards } from './get_custom_dashboards';
import { getServicesWithDashboards } from './get_services_with_dashboards';
import { getApmEventClient } from '../../lib/helpers/get_apm_event_client';
import { rangeRt } from '../default_api_types';
import { createEntitiesESClient } from '../../lib/helpers/create_es_client/create_assets_es_client/create_assets_es_clients';
import { getEntitiesWithDashboards } from './get_entities_with_dashboards';
const serviceDashboardSaveRoute = createApmServerRoute({
endpoint: 'POST /internal/apm/custom-dashboard',
@ -53,14 +55,20 @@ const serviceDashboardsRoute = createApmServerRoute({
path: t.type({
serviceName: t.string,
}),
query: rangeRt,
query: t.intersection([
rangeRt,
t.partial({
checkFor: t.union([t.literal('entities'), t.literal('services')]),
}),
]),
}),
options: {
tags: ['access:apm'],
},
handler: async (resources): Promise<{ serviceDashboards: SavedApmCustomDashboard[] }> => {
const { context, params } = resources;
const { start, end } = params.query;
const { context, params, request } = resources;
const coreContext = await context.core;
const { start, end, checkFor } = params.query;
const { serviceName } = params.path;
@ -74,6 +82,21 @@ const serviceDashboardsRoute = createApmServerRoute({
savedObjectsClient,
});
if (checkFor === 'entities') {
const entitiesESClient = await createEntitiesESClient({
request,
esClient: coreContext.elasticsearch.client.asCurrentUser,
});
const entitiesWithDashboards = await getEntitiesWithDashboards({
entitiesESClient,
allLinkedCustomDashboards,
serviceName,
});
return { serviceDashboards: entitiesWithDashboards };
}
const servicesWithDashboards = await getServicesWithDashboards({
apmEventClient,
allLinkedCustomDashboards,

View file

@ -6,8 +6,8 @@
*/
import * as t from 'io-ts';
import { EntityServiceListItem } from '../../../../common/entities/types';
import { environmentQuery } from '../../../../common/utils/environment_query';
import { createEntitiesESClient } from '../../../lib/helpers/create_es_client/create_assets_es_client/create_assets_es_clients';
import { getApmEventClient } from '../../../lib/helpers/get_apm_event_client';
import { createApmServerRoute } from '../../apm_routes/create_apm_server_route';
import { environmentRt, kueryRt, rangeRt } from '../../default_api_types';
import { getServiceEntities } from './get_service_entities';
@ -23,12 +23,8 @@ const servicesEntitiesRoute = createApmServerRoute({
}),
options: { tags: ['access:apm'] },
async handler(resources): Promise<EntityServicesResponse> {
const { context, params, request, plugins } = resources;
const [coreContext] = await Promise.all([
context.core,
getApmEventClient(resources),
plugins.logsDataAccess.start(),
]);
const { context, params, request } = resources;
const coreContext = await context.core;
const entitiesESClient = await createEntitiesESClient({
request,
@ -50,6 +46,76 @@ const servicesEntitiesRoute = createApmServerRoute({
},
});
const serviceLogRateTimeseriesRoute = createApmServerRoute({
endpoint: 'GET /internal/apm/entities/services/{serviceName}/logs_rate_timeseries',
params: t.type({
path: t.type({
serviceName: t.string,
}),
query: t.intersection([environmentRt, kueryRt, rangeRt]),
}),
options: { tags: ['access:apm'] },
async handler(resources) {
const { context, params, plugins } = resources;
const [coreContext, logsDataAccessStart] = await Promise.all([
context.core,
plugins.logsDataAccess.start(),
]);
const { serviceName } = params.path;
const { start, end, kuery, environment } = params.query;
const curentPeriodlogsRateTimeseries = await logsDataAccessStart.services.getLogsRateTimeseries(
{
esClient: coreContext.elasticsearch.client.asCurrentUser,
identifyingMetadata: 'service.name',
timeFrom: start,
timeTo: end,
kuery,
serviceEnvironmentQuery: environmentQuery(environment),
serviceNames: [serviceName],
}
);
return { currentPeriod: curentPeriodlogsRateTimeseries };
},
});
const serviceLogErrorRateTimeseriesRoute = createApmServerRoute({
endpoint: 'GET /internal/apm/entities/services/{serviceName}/logs_error_rate_timeseries',
params: t.type({
path: t.type({
serviceName: t.string,
}),
query: t.intersection([environmentRt, kueryRt, rangeRt]),
}),
options: { tags: ['access:apm'] },
async handler(resources) {
const { context, params, plugins } = resources;
const [coreContext, logsDataAccessStart] = await Promise.all([
context.core,
plugins.logsDataAccess.start(),
]);
const { serviceName } = params.path;
const { start, end, kuery, environment } = params.query;
const logsErrorRateTimeseries = await logsDataAccessStart.services.getLogsErrorRateTimeseries({
esClient: coreContext.elasticsearch.client.asCurrentUser,
identifyingMetadata: 'service.name',
timeFrom: start,
timeTo: end,
kuery,
serviceEnvironmentQuery: environmentQuery(environment),
serviceNames: [serviceName],
});
return { currentPeriod: logsErrorRateTimeseries };
},
});
export const servicesEntitiesRoutesRepository = {
...servicesEntitiesRoute,
...serviceLogRateTimeseriesRoute,
...serviceLogErrorRateTimeseriesRoute,
};

View file

@ -0,0 +1,8 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
export const LOG_LEVEL = 'log.level';

View file

@ -0,0 +1,136 @@
/*
* 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/types';
import type { AggregationOptionsByType, AggregationResultOf } from '@kbn/es-types';
import { ElasticsearchClient } from '@kbn/core/server';
import { existsQuery, kqlQuery } from '@kbn/observability-plugin/server';
import { estypes } from '@elastic/elasticsearch';
import { getBucketSizeFromTimeRangeAndBucketCount, getLogErrorRate } from '../../utils';
import { LOG_LEVEL } from '../../es_fields';
export interface LogsErrorRateTimeseries {
esClient: ElasticsearchClient;
serviceEnvironmentQuery?: QueryDslQueryContainer[];
serviceNames: string[];
identifyingMetadata: string;
timeFrom: number;
timeTo: number;
kuery?: string;
}
export const getLogErrorsAggegation = () => ({
terms: {
field: LOG_LEVEL,
include: ['error', 'ERROR'],
},
});
type LogErrorsAggregation = ReturnType<typeof getLogErrorsAggegation>;
interface LogsErrorRateTimeseriesHistogram {
timeseries: AggregationResultOf<
{
date_histogram: AggregationOptionsByType['date_histogram'];
aggs: { logErrors: LogErrorsAggregation };
},
{}
>;
doc_count: number;
key: string;
}
interface LogRateQueryAggregation {
services: estypes.AggregationsTermsAggregateBase<LogsErrorRateTimeseriesHistogram>;
}
export interface LogsErrorRateTimeseriesReturnType {
[serviceName: string]: Array<{ x: number; y: number | null }>;
}
export function createGetLogErrorRateTimeseries() {
return async ({
esClient,
identifyingMetadata,
serviceNames,
timeFrom,
timeTo,
kuery,
serviceEnvironmentQuery = [],
}: LogsErrorRateTimeseries): Promise<LogsErrorRateTimeseriesReturnType> => {
const intervalString = getBucketSizeFromTimeRangeAndBucketCount(timeFrom, timeTo, 50);
const esResponse = await esClient.search({
index: 'logs-*-*',
size: 0,
query: {
bool: {
filter: [
...existsQuery(LOG_LEVEL),
...kqlQuery(kuery),
{
terms: {
[identifyingMetadata]: serviceNames,
},
},
...serviceEnvironmentQuery,
{
range: {
['@timestamp']: {
gte: timeFrom,
lte: timeTo,
format: 'epoch_millis',
},
},
},
],
},
},
aggs: {
services: {
terms: {
field: identifyingMetadata,
},
aggs: {
timeseries: {
date_histogram: {
field: '@timestamp',
fixed_interval: `${intervalString}s`,
min_doc_count: 0,
extended_bounds: {
min: timeFrom,
max: timeTo,
},
},
aggs: {
logErrors: getLogErrorsAggegation(),
},
},
},
},
},
});
const aggregations = esResponse.aggregations as LogRateQueryAggregation | undefined;
const buckets = aggregations?.services.buckets as LogsErrorRateTimeseriesHistogram[];
return buckets
? buckets.reduce<LogsErrorRateTimeseriesReturnType>((acc, bucket) => {
const timeseries = bucket.timeseries.buckets.map((timeseriesBucket) => {
const totalCount = timeseriesBucket.doc_count;
const logErrorCount = timeseriesBucket.logErrors.buckets[0]?.doc_count;
return {
x: timeseriesBucket.key,
y: logErrorCount ? getLogErrorRate({ logCount: totalCount, logErrorCount }) : null,
};
});
return {
...acc,
[bucket.key]: timeseries,
};
}, {})
: {};
};
}

View file

@ -0,0 +1,123 @@
/*
* 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/types';
import type { AggregationOptionsByType, AggregationResultOf } from '@kbn/es-types';
import { ElasticsearchClient } from '@kbn/core/server';
import { existsQuery, kqlQuery } from '@kbn/observability-plugin/server';
import { estypes } from '@elastic/elasticsearch';
import { getBucketSizeFromTimeRangeAndBucketCount } from '../../utils';
import { LOG_LEVEL } from '../../es_fields';
export interface LogsRateTimeseries {
esClient: ElasticsearchClient;
serviceEnvironmentQuery?: QueryDslQueryContainer[];
serviceNames: string[];
identifyingMetadata: string;
timeFrom: number;
timeTo: number;
kuery?: string;
}
interface LogsRateTimeseriesHistogram {
timeseries: AggregationResultOf<
{
date_histogram: AggregationOptionsByType['date_histogram'];
},
{}
>;
doc_count: number;
key: string;
}
interface LogRateQueryAggregation {
service: estypes.AggregationsTermsAggregateBase<LogsRateTimeseriesHistogram>;
}
export interface LogsRateTimeseriesReturnType {
[serviceName: string]: Array<{ x: number; y: number | null }>;
}
export function createGetLogsRateTimeseries() {
return async ({
esClient,
identifyingMetadata,
serviceNames,
timeFrom,
timeTo,
kuery,
serviceEnvironmentQuery = [],
}: LogsRateTimeseries): Promise<LogsRateTimeseriesReturnType> => {
const intervalString = getBucketSizeFromTimeRangeAndBucketCount(timeFrom, timeTo, 50);
const esResponse = await esClient.search({
index: 'logs-*-*',
size: 0,
query: {
bool: {
filter: [
...existsQuery(LOG_LEVEL),
...kqlQuery(kuery),
{
terms: {
[identifyingMetadata]: serviceNames,
},
},
...serviceEnvironmentQuery,
{
range: {
['@timestamp']: {
gte: timeFrom,
lte: timeTo,
format: 'epoch_millis',
},
},
},
],
},
},
aggs: {
service: {
terms: {
field: identifyingMetadata,
},
aggs: {
timeseries: {
date_histogram: {
field: '@timestamp',
fixed_interval: `${intervalString}s`,
min_doc_count: 0,
extended_bounds: {
min: timeFrom,
max: timeTo,
},
},
},
},
},
},
});
const aggregations = esResponse.aggregations as LogRateQueryAggregation | undefined;
const buckets = aggregations?.service.buckets as LogsRateTimeseriesHistogram[];
return buckets
? buckets.reduce<LogsRateTimeseriesReturnType>((acc, bucket) => {
const totalCount = bucket.doc_count;
const timeseries = bucket.timeseries.buckets.map((timeseriesBucket) => {
return {
x: timeseriesBucket.key,
y: timeseriesBucket.doc_count / totalCount,
};
});
return {
...acc,
[bucket.key]: timeseries,
};
}, {})
: {};
};
}

View file

@ -7,8 +7,8 @@
import { ElasticsearchClient } from '@kbn/core/server';
import { estypes } from '@elastic/elasticsearch';
import { RegisterServicesParams } from '../register_services';
import { getLogErrorRate, getLogRatePerMinute } from './utils';
import { getLogErrorRate, getLogRatePerMinute } from '../../utils';
import { LOG_LEVEL } from '../../es_fields';
export interface LogsRatesServiceParams {
esClient: ElasticsearchClient;
@ -35,7 +35,7 @@ export interface LogsRatesServiceReturnType {
[serviceName: string]: LogsRatesMetrics;
}
export function createGetLogsRatesService(params: RegisterServicesParams) {
export function createGetLogsRatesService() {
return async ({
esClient,
identifyingMetadata,
@ -52,7 +52,7 @@ export function createGetLogsRatesService(params: RegisterServicesParams) {
{
exists: {
// For now, we don't want to count APM server logs or any other logs that don't have the log.level field.
field: 'log.level',
field: LOG_LEVEL,
},
},
{
@ -80,7 +80,7 @@ export function createGetLogsRatesService(params: RegisterServicesParams) {
aggs: {
logErrors: {
terms: {
field: 'log.level',
field: LOG_LEVEL,
include: ['error', 'ERROR'],
},
},

View file

@ -8,6 +8,8 @@
import { SavedObjectsServiceStart } from '@kbn/core-saved-objects-server';
import { UiSettingsServiceStart } from '@kbn/core-ui-settings-server';
import { Logger } from '@kbn/logging';
import { createGetLogsRateTimeseries } from './get_logs_rate_timeseries/get_logs_rate_timeseries';
import { createGetLogErrorRateTimeseries } from './get_logs_error_rate_timeseries/get_logs_error_rate_timeseries';
import { createGetLogsRatesService } from './get_logs_rates_service';
import { createGetLogSourcesService } from './log_sources_service';
@ -21,7 +23,9 @@ export interface RegisterServicesParams {
export function registerServices(params: RegisterServicesParams) {
return {
getLogsRatesService: createGetLogsRatesService(params),
getLogsRatesService: createGetLogsRatesService(),
getLogsRateTimeseries: createGetLogsRateTimeseries(),
getLogsErrorRateTimeseries: createGetLogErrorRateTimeseries(),
getLogSourcesService: createGetLogSourcesService(params),
};
}

View file

@ -5,6 +5,19 @@
* 2.0.
*/
import moment from 'moment/moment';
import { calculateAuto } from '@kbn/calculate-auto';
export function getBucketSizeFromTimeRangeAndBucketCount(
timeFrom: number,
timeTo: number,
numBuckets: number
): number {
const duration = moment.duration(timeTo - timeFrom, 'ms');
return Math.max(calculateAuto.near(numBuckets, duration)?.asSeconds() ?? 0, 60);
}
export function getLogRatePerMinute({
logCount,
timeFrom,

View file

@ -4,7 +4,7 @@
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import { getLogRatePerMinute, getLogErrorRate } from './utils';
import { getLogRatePerMinute, getLogErrorRate } from '.';
describe('getLogRatePerMinute', () => {
it('should log rate per minute for one minute period', () => {

View file

@ -3,13 +3,15 @@
"compilerOptions": {
"outDir": "target/types"
},
"include": ["common/**/*", "server/**/*", "public/**/*", "jest.config.js"],
"include": ["common/**/*", "server/**/*", "public/**/*", "jest.config.js"],
"exclude": ["target/**/*"],
"kbn_references": [
"@kbn/core",
"@kbn/logging",
"@kbn/data-plugin",
"@kbn/data-views-plugin",
"@kbn/observability-plugin",
"@kbn/calculate-auto",
"@kbn/es-types",
"@kbn/core-http-server",
"@kbn/management-settings-ids",
"@kbn/config-schema",
@ -18,5 +20,6 @@
"@kbn/core-saved-objects-server",
"@kbn/core-ui-settings-server",
"@kbn/core-ui-settings-browser",
"@kbn/logging"
]
}

View file

@ -0,0 +1,175 @@
/*
* 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 expect from '@kbn/expect';
import { log, timerange } from '@kbn/apm-synthtrace-client';
import { first, last } from 'lodash';
import { RecursivePartial } from '@kbn/apm-plugin/typings/common';
import { APIClientRequestParamsOf } from '@kbn/apm-plugin/public/services/rest/create_call_apm_api';
import { FtrProviderContext } from '../../../common/ftr_provider_context';
export default function ApiTest({ getService }: FtrProviderContext) {
const registry = getService('registry');
const apmApiClient = getService('apmApiClient');
const logSynthtrace = getService('logSynthtraceEsClient');
const serviceName = 'synth-go';
const start = new Date('2024-01-01T00:00:00.000Z').getTime();
const end = new Date('2024-01-01T00:15:00.000Z').getTime() - 1;
const hostName = 'synth-host';
async function getLogsErrorRateTimeseries(
overrides?: RecursivePartial<
APIClientRequestParamsOf<'GET /internal/apm/entities/services/{serviceName}/logs_error_rate_timeseries'>['params']
>
) {
const response = await apmApiClient.readUser({
endpoint: 'GET /internal/apm/entities/services/{serviceName}/logs_error_rate_timeseries',
params: {
path: {
serviceName: 'synth-go',
...overrides?.path,
},
query: {
start: new Date(start).toISOString(),
end: new Date(end).toISOString(),
environment: 'ENVIRONMENT_ALL',
kuery: '',
...overrides?.query,
},
},
});
return response;
}
registry.when(
'Logs error rate timeseries when data is not loaded',
{ config: 'basic', archives: [] },
() => {
describe('Logs error rate api', () => {
it('handles the empty state', async () => {
const response = await getLogsErrorRateTimeseries();
expect(response.status).to.be(200);
expect(response.body.currentPeriod).to.empty();
});
});
}
);
registry.when(
'Logs error rate timeseries when data loaded',
{ config: 'basic', archives: [] },
() => {
describe('Logs without log level field', () => {
before(async () => {
return logSynthtrace.index([
timerange(start, end)
.interval('1m')
.rate(1)
.generator((timestamp) =>
log.create().message('This is a log message').timestamp(timestamp).defaults({
'log.file.path': '/my-service.log',
'service.name': serviceName,
'host.name': hostName,
})
),
]);
});
after(async () => {
await logSynthtrace.clean();
});
it('returns {} if log level is not available ', async () => {
const response = await getLogsErrorRateTimeseries();
expect(response.status).to.be(200);
});
});
describe('Logs with log.level=error', () => {
before(async () => {
return logSynthtrace.index([
timerange(start, end)
.interval('1m')
.rate(1)
.generator((timestamp) =>
log
.create()
.message('This is a log message')
.logLevel('error')
.timestamp(timestamp)
.defaults({
'log.file.path': '/my-service.log',
'service.name': serviceName,
'host.name': hostName,
'service.environment': 'test',
})
),
timerange(start, end)
.interval('2m')
.rate(1)
.generator((timestamp) =>
log
.create()
.message('This is an error log message')
.logLevel('error')
.timestamp(timestamp)
.defaults({
'log.file.path': '/my-service.log',
'service.name': 'my-service',
'host.name': hostName,
'service.environment': 'production',
})
),
timerange(start, end)
.interval('5m')
.rate(1)
.generator((timestamp) =>
log
.create()
.message('This is an info message')
.logLevel('info')
.timestamp(timestamp)
.defaults({
'log.file.path': '/my-service.log',
'service.name': 'my-service',
'host.name': hostName,
'service.environment': 'production',
})
),
]);
});
after(async () => {
await logSynthtrace.clean();
});
it('returns log error rate timeseries', async () => {
const response = await getLogsErrorRateTimeseries();
expect(response.status).to.be(200);
expect(response.body.currentPeriod[serviceName].every(({ y }) => y === 1)).to.be(true);
});
it('handles environment filter', async () => {
const response = await getLogsErrorRateTimeseries({ query: { environment: 'foo' } });
expect(response.status).to.be(200);
expect(response.body.currentPeriod).to.empty();
});
describe('when my-service is selected', () => {
it('returns some data', async () => {
const response = await getLogsErrorRateTimeseries({
path: { serviceName: 'my-service' },
});
expect(response.status).to.be(200);
expect(first(response.body.currentPeriod?.['my-service'])?.y).to.be(0.5);
expect(last(response.body.currentPeriod?.['my-service'])?.y).to.be(1);
});
});
});
}
);
}

View file

@ -0,0 +1,173 @@
/*
* 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 expect from '@kbn/expect';
import { log, timerange } from '@kbn/apm-synthtrace-client';
import { RecursivePartial } from '@kbn/apm-plugin/typings/common';
import { APIClientRequestParamsOf } from '@kbn/apm-plugin/public/services/rest/create_call_apm_api';
import { first, last } from 'lodash';
import { FtrProviderContext } from '../../../common/ftr_provider_context';
export default function ApiTest({ getService }: FtrProviderContext) {
const registry = getService('registry');
const apmApiClient = getService('apmApiClient');
const logSynthtrace = getService('logSynthtraceEsClient');
const serviceName = 'synth-go';
const start = new Date('2024-01-01T00:00:00.000Z').getTime();
const end = new Date('2024-01-01T00:15:00.000Z').getTime() - 1;
const hostName = 'synth-host';
async function getLogsRateTimeseries(
overrides?: RecursivePartial<
APIClientRequestParamsOf<'GET /internal/apm/entities/services/{serviceName}/logs_rate_timeseries'>['params']
>
) {
const response = await apmApiClient.readUser({
endpoint: 'GET /internal/apm/entities/services/{serviceName}/logs_rate_timeseries',
params: {
path: {
serviceName: 'synth-go',
...overrides?.path,
},
query: {
start: new Date(start).toISOString(),
end: new Date(end).toISOString(),
environment: 'ENVIRONMENT_ALL',
kuery: '',
...overrides?.query,
},
},
});
return response;
}
registry.when(
'Logs rate timeseries when data is not loaded',
{ config: 'basic', archives: [] },
() => {
describe('Logs rate api', () => {
it('handles the empty state', async () => {
const response = await getLogsRateTimeseries();
expect(response.status).to.be(200);
expect(response.body.currentPeriod).to.empty();
});
});
}
);
registry.when('Logs rate timeseries when data loaded', { config: 'basic', archives: [] }, () => {
describe('Logs without log level field', () => {
before(async () => {
return logSynthtrace.index([
timerange(start, end)
.interval('1m')
.rate(1)
.generator((timestamp) =>
log.create().message('This is a log message').timestamp(timestamp).defaults({
'log.file.path': '/my-service.log',
'service.name': serviceName,
'host.name': hostName,
})
),
]);
});
after(async () => {
await logSynthtrace.clean();
});
it('returns {} if log level is not available ', async () => {
const response = await getLogsRateTimeseries();
expect(response.status).to.be(200);
});
});
describe('Logs with log.level=error', () => {
before(async () => {
return logSynthtrace.index([
timerange(start, end)
.interval('1m')
.rate(1)
.generator((timestamp) =>
log
.create()
.message('This is a log message')
.logLevel('error')
.timestamp(timestamp)
.defaults({
'log.file.path': '/my-service.log',
'service.name': serviceName,
'host.name': hostName,
'service.environment': 'test',
})
),
timerange(start, end)
.interval('2m')
.rate(1)
.generator((timestamp) =>
log
.create()
.message('This is an error log message')
.logLevel('error')
.timestamp(timestamp)
.defaults({
'log.file.path': '/my-service.log',
'service.name': 'my-service',
'host.name': hostName,
'service.environment': 'production',
})
),
timerange(start, end)
.interval('5m')
.rate(1)
.generator((timestamp) =>
log
.create()
.message('This is an info message')
.logLevel('info')
.timestamp(timestamp)
.defaults({
'log.file.path': '/my-service.log',
'service.name': 'my-service',
'host.name': hostName,
'service.environment': 'production',
})
),
]);
});
after(async () => {
await logSynthtrace.clean();
});
it('returns log rate timeseries', async () => {
const response = await getLogsRateTimeseries();
expect(response.status).to.be(200);
expect(
response.body.currentPeriod[serviceName].every(({ y }) => y === 0.06666666666666667)
).to.be(true);
});
it('handles environment filter', async () => {
const response = await getLogsRateTimeseries({ query: { environment: 'foo' } });
expect(response.status).to.be(200);
expect(response.body.currentPeriod).to.empty();
});
describe('when my-service is selected', () => {
it('returns some data', async () => {
const response = await getLogsRateTimeseries({
path: { serviceName: 'my-service' },
});
expect(response.status).to.be(200);
expect(first(response.body.currentPeriod?.['my-service'])?.y).to.be(0.18181818181818182);
expect(last(response.body.currentPeriod?.['my-service'])?.y).to.be(0.09090909090909091);
});
});
});
});
}