diff --git a/x-pack/plugins/infra/common/http_api/log_analysis/results/index.ts b/x-pack/plugins/infra/common/http_api/log_analysis/results/index.ts index 15615046bdd6..30b6be435837 100644 --- a/x-pack/plugins/infra/common/http_api/log_analysis/results/index.ts +++ b/x-pack/plugins/infra/common/http_api/log_analysis/results/index.ts @@ -8,3 +8,4 @@ export * from './log_entry_categories'; export * from './log_entry_category_datasets'; export * from './log_entry_category_examples'; export * from './log_entry_rate'; +export * from './log_entry_rate_examples'; diff --git a/x-pack/plugins/infra/common/http_api/log_analysis/results/log_entry_rate.ts b/x-pack/plugins/infra/common/http_api/log_analysis/results/log_entry_rate.ts index dfc3d2aabd11..b7e8a4973515 100644 --- a/x-pack/plugins/infra/common/http_api/log_analysis/results/log_entry_rate.ts +++ b/x-pack/plugins/infra/common/http_api/log_analysis/results/log_entry_rate.ts @@ -30,6 +30,7 @@ export type GetLogEntryRateRequestPayload = rt.TypeOf; + export const logEntryRatePartitionRT = rt.type({ analysisBucketCount: rt.number, anomalies: rt.array(logEntryRateAnomalyRT), diff --git a/x-pack/plugins/infra/common/http_api/log_analysis/results/log_entry_rate_examples.ts b/x-pack/plugins/infra/common/http_api/log_analysis/results/log_entry_rate_examples.ts new file mode 100644 index 000000000000..700f87ec3beb --- /dev/null +++ b/x-pack/plugins/infra/common/http_api/log_analysis/results/log_entry_rate_examples.ts @@ -0,0 +1,77 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import * as rt from 'io-ts'; + +import { + badRequestErrorRT, + forbiddenErrorRT, + timeRangeRT, + routeTimingMetadataRT, +} from '../../shared'; + +export const LOG_ANALYSIS_GET_LOG_ENTRY_RATE_EXAMPLES_PATH = + '/api/infra/log_analysis/results/log_entry_rate_examples'; + +/** + * request + */ + +export const getLogEntryRateExamplesRequestPayloadRT = rt.type({ + data: rt.type({ + // the dataset to fetch the log rate examples from + dataset: rt.string, + // the number of examples to fetch + exampleCount: rt.number, + // the id of the source configuration + sourceId: rt.string, + // the time range to fetch the log rate examples from + timeRange: timeRangeRT, + }), +}); + +export type GetLogEntryRateExamplesRequestPayload = rt.TypeOf< + typeof getLogEntryRateExamplesRequestPayloadRT +>; + +/** + * response + */ + +const logEntryRateExampleRT = rt.type({ + id: rt.string, + dataset: rt.string, + message: rt.string, + timestamp: rt.number, + tiebreaker: rt.number, +}); + +export type LogEntryRateExample = rt.TypeOf; + +export const getLogEntryRateExamplesSuccessReponsePayloadRT = rt.intersection([ + rt.type({ + data: rt.type({ + examples: rt.array(logEntryRateExampleRT), + }), + }), + rt.partial({ + timing: routeTimingMetadataRT, + }), +]); + +export type GetLogEntryRateExamplesSuccessReponsePayload = rt.TypeOf< + typeof getLogEntryRateExamplesSuccessReponsePayloadRT +>; + +export const getLogEntryRateExamplesResponsePayloadRT = rt.union([ + getLogEntryRateExamplesSuccessReponsePayloadRT, + badRequestErrorRT, + forbiddenErrorRT, +]); + +export type GetLogEntryRateExamplesResponsePayload = rt.TypeOf< + typeof getLogEntryRateExamplesResponsePayloadRT +>; diff --git a/x-pack/plugins/infra/public/pages/logs/log_entry_categories/sections/top_categories/anomaly_severity_indicator.tsx b/x-pack/plugins/infra/public/components/logging/log_analysis_results/anomaly_severity_indicator.tsx similarity index 95% rename from x-pack/plugins/infra/public/pages/logs/log_entry_categories/sections/top_categories/anomaly_severity_indicator.tsx rename to x-pack/plugins/infra/public/components/logging/log_analysis_results/anomaly_severity_indicator.tsx index e50231316fb5..e85145b83a30 100644 --- a/x-pack/plugins/infra/public/pages/logs/log_entry_categories/sections/top_categories/anomaly_severity_indicator.tsx +++ b/x-pack/plugins/infra/public/components/logging/log_analysis_results/anomaly_severity_indicator.tsx @@ -10,7 +10,7 @@ import { formatAnomalyScore, getSeverityCategoryForScore, ML_SEVERITY_COLORS, -} from '../../../../../../common/log_analysis'; +} from '../../../../common/log_analysis'; export const AnomalySeverityIndicator: React.FunctionComponent<{ anomalyScore: number; diff --git a/x-pack/plugins/infra/public/components/logging/log_entry_examples/log_entry_examples.tsx b/x-pack/plugins/infra/public/components/logging/log_entry_examples/log_entry_examples.tsx new file mode 100644 index 000000000000..2ec9922d9455 --- /dev/null +++ b/x-pack/plugins/infra/public/components/logging/log_entry_examples/log_entry_examples.tsx @@ -0,0 +1,49 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import React from 'react'; + +import { euiStyled } from '../../../../../observability/public'; +import { LogEntryExampleMessagesEmptyIndicator } from './log_entry_examples_empty_indicator'; +import { LogEntryExampleMessagesFailureIndicator } from './log_entry_examples_failure_indicator'; +import { LogEntryExampleMessagesLoadingIndicator } from './log_entry_examples_loading_indicator'; + +interface Props { + isLoading: boolean; + hasFailedLoading: boolean; + hasResults: boolean; + exampleCount: number; + onReload: () => void; +} +export const LogEntryExampleMessages: React.FunctionComponent = ({ + isLoading, + hasFailedLoading, + exampleCount, + hasResults, + onReload, + children, +}) => { + return ( + + {isLoading ? ( + + ) : hasFailedLoading ? ( + + ) : !hasResults ? ( + + ) : ( + children + )} + + ); +}; + +const Wrapper = euiStyled.div` + align-items: stretch; + flex-direction: column; + flex: 1 0 0%; + overflow: hidden; +`; diff --git a/x-pack/plugins/infra/public/pages/logs/log_entry_categories/sections/top_categories/category_example_messages_empty_indicator.tsx b/x-pack/plugins/infra/public/components/logging/log_entry_examples/log_entry_examples_empty_indicator.tsx similarity index 81% rename from x-pack/plugins/infra/public/pages/logs/log_entry_categories/sections/top_categories/category_example_messages_empty_indicator.tsx rename to x-pack/plugins/infra/public/components/logging/log_entry_examples/log_entry_examples_empty_indicator.tsx index ac572a5f6cf2..1d6028ed032a 100644 --- a/x-pack/plugins/infra/public/pages/logs/log_entry_categories/sections/top_categories/category_example_messages_empty_indicator.tsx +++ b/x-pack/plugins/infra/public/components/logging/log_entry_examples/log_entry_examples_empty_indicator.tsx @@ -7,20 +7,20 @@ import { EuiButton, EuiFlexGroup, EuiFlexItem } from '@elastic/eui'; import { FormattedMessage } from '@kbn/i18n/react'; import React from 'react'; -export const CategoryExampleMessagesEmptyIndicator: React.FunctionComponent<{ +export const LogEntryExampleMessagesEmptyIndicator: React.FunctionComponent<{ onReload: () => void; }> = ({ onReload }) => ( diff --git a/x-pack/plugins/infra/public/pages/logs/log_entry_categories/sections/top_categories/category_example_messages_failure_indicator.tsx b/x-pack/plugins/infra/public/components/logging/log_entry_examples/log_entry_examples_failure_indicator.tsx similarity index 75% rename from x-pack/plugins/infra/public/pages/logs/log_entry_categories/sections/top_categories/category_example_messages_failure_indicator.tsx rename to x-pack/plugins/infra/public/components/logging/log_entry_examples/log_entry_examples_failure_indicator.tsx index 7865dcd0226e..dca786bce3b7 100644 --- a/x-pack/plugins/infra/public/pages/logs/log_entry_categories/sections/top_categories/category_example_messages_failure_indicator.tsx +++ b/x-pack/plugins/infra/public/components/logging/log_entry_examples/log_entry_examples_failure_indicator.tsx @@ -7,22 +7,22 @@ import { EuiButton, EuiFlexGroup, EuiFlexItem, EuiTextColor } from '@elastic/eui import { FormattedMessage } from '@kbn/i18n/react'; import React from 'react'; -export const CategoryExampleMessagesFailureIndicator: React.FunctionComponent<{ +export const LogEntryExampleMessagesFailureIndicator: React.FunctionComponent<{ onRetry: () => void; }> = ({ onRetry }) => ( diff --git a/x-pack/plugins/infra/public/pages/logs/log_entry_categories/sections/top_categories/category_example_messages_loading_indicator.tsx b/x-pack/plugins/infra/public/components/logging/log_entry_examples/log_entry_examples_loading_indicator.tsx similarity index 89% rename from x-pack/plugins/infra/public/pages/logs/log_entry_categories/sections/top_categories/category_example_messages_loading_indicator.tsx rename to x-pack/plugins/infra/public/components/logging/log_entry_examples/log_entry_examples_loading_indicator.tsx index cad87a96a132..8217b6ef8096 100644 --- a/x-pack/plugins/infra/public/pages/logs/log_entry_categories/sections/top_categories/category_example_messages_loading_indicator.tsx +++ b/x-pack/plugins/infra/public/components/logging/log_entry_examples/log_entry_examples_loading_indicator.tsx @@ -7,7 +7,7 @@ import { EuiLoadingContent } from '@elastic/eui'; import React from 'react'; -export const CategoryExampleMessagesLoadingIndicator: React.FunctionComponent<{ +export const LogEntryExampleMessagesLoadingIndicator: React.FunctionComponent<{ exampleCount: number; }> = ({ exampleCount }) => ( <> diff --git a/x-pack/plugins/infra/public/components/logging/log_text_stream/column_headers.tsx b/x-pack/plugins/infra/public/components/logging/log_text_stream/column_headers.tsx index bc592c71898b..c50a82006941 100644 --- a/x-pack/plugins/infra/public/components/logging/log_text_stream/column_headers.tsx +++ b/x-pack/plugins/infra/public/components/logging/log_text_stream/column_headers.tsx @@ -68,7 +68,7 @@ export const LogColumnHeaders: React.FunctionComponent<{ ); }; -const LogColumnHeader: React.FunctionComponent<{ +export const LogColumnHeader: React.FunctionComponent<{ columnWidth: LogEntryColumnWidth; 'data-test-subj'?: string; }> = ({ children, columnWidth, 'data-test-subj': dataTestSubj }) => ( @@ -77,7 +77,7 @@ const LogColumnHeader: React.FunctionComponent<{ ); -const LogColumnHeadersWrapper = euiStyled.div.attrs((props) => ({ +export const LogColumnHeadersWrapper = euiStyled.div.attrs((props) => ({ role: props.role ?? 'row', }))` align-items: stretch; diff --git a/x-pack/plugins/infra/public/components/logging/log_text_stream/index.ts b/x-pack/plugins/infra/public/components/logging/log_text_stream/index.ts index dbf162171cac..bc687baf7c46 100644 --- a/x-pack/plugins/infra/public/components/logging/log_text_stream/index.ts +++ b/x-pack/plugins/infra/public/components/logging/log_text_stream/index.ts @@ -4,9 +4,15 @@ * you may not use this file except in compliance with the Elastic License. */ -export { LogEntryColumn, LogEntryColumnWidths, useColumnWidths } from './log_entry_column'; +export { + LogEntryColumn, + LogEntryColumnWidths, + useColumnWidths, + iconColumnId, +} from './log_entry_column'; export { LogEntryFieldColumn } from './log_entry_field_column'; export { LogEntryMessageColumn } from './log_entry_message_column'; export { LogEntryRowWrapper } from './log_entry_row'; export { LogEntryTimestampColumn } from './log_entry_timestamp_column'; export { ScrollableLogTextStreamView } from './scrollable_log_text_stream_view'; +export { LogEntryContextMenu } from './log_entry_context_menu'; diff --git a/x-pack/plugins/infra/public/components/logging/log_text_stream/log_entry_context_menu.tsx b/x-pack/plugins/infra/public/components/logging/log_text_stream/log_entry_context_menu.tsx index 4aa81846d90e..adc1ce4d8c9f 100644 --- a/x-pack/plugins/infra/public/components/logging/log_text_stream/log_entry_context_menu.tsx +++ b/x-pack/plugins/infra/public/components/logging/log_text_stream/log_entry_context_menu.tsx @@ -13,7 +13,8 @@ import { LogEntryColumnContent } from './log_entry_column'; interface LogEntryContextMenuItem { label: string; - onClick: () => void; + onClick: (e: React.MouseEvent) => void; + href?: string; } interface LogEntryContextMenuProps { @@ -40,9 +41,9 @@ export const LogEntryContextMenu: React.FC = ({ }) => { const closeMenuAndCall = useMemo(() => { return (callback: LogEntryContextMenuItem['onClick']) => { - return () => { + return (e: React.MouseEvent) => { onClose(); - callback(); + callback(e); }; }; }, [onClose]); @@ -60,7 +61,7 @@ export const LogEntryContextMenu: React.FC = ({ const wrappedItems = useMemo(() => { return items.map((item, i) => ( - + {item.label} )); diff --git a/x-pack/plugins/infra/public/pages/logs/log_entry_categories/sections/top_categories/anomaly_severity_indicator_list.tsx b/x-pack/plugins/infra/public/pages/logs/log_entry_categories/sections/top_categories/anomaly_severity_indicator_list.tsx index dafaa37006be..47bb31ab4ae3 100644 --- a/x-pack/plugins/infra/public/pages/logs/log_entry_categories/sections/top_categories/anomaly_severity_indicator_list.tsx +++ b/x-pack/plugins/infra/public/pages/logs/log_entry_categories/sections/top_categories/anomaly_severity_indicator_list.tsx @@ -8,7 +8,7 @@ import React from 'react'; import { LogEntryCategoryDataset } from '../../../../../../common/http_api/log_analysis'; import { getFriendlyNameForPartitionId } from '../../../../../../common/log_analysis'; -import { AnomalySeverityIndicator } from './anomaly_severity_indicator'; +import { AnomalySeverityIndicator } from '../../../../../components/logging/log_analysis_results/anomaly_severity_indicator'; export const AnomalySeverityIndicatorList: React.FunctionComponent<{ datasets: LogEntryCategoryDataset[]; diff --git a/x-pack/plugins/infra/public/pages/logs/log_entry_categories/sections/top_categories/category_details_row.tsx b/x-pack/plugins/infra/public/pages/logs/log_entry_categories/sections/top_categories/category_details_row.tsx index c0728c0a5548..d939d6738c53 100644 --- a/x-pack/plugins/infra/public/pages/logs/log_entry_categories/sections/top_categories/category_details_row.tsx +++ b/x-pack/plugins/infra/public/pages/logs/log_entry_categories/sections/top_categories/category_details_row.tsx @@ -5,14 +5,10 @@ */ import React, { useEffect } from 'react'; - -import { euiStyled } from '../../../../../../../observability/public'; -import { TimeRange } from '../../../../../../common/http_api/shared'; import { useLogEntryCategoryExamples } from '../../use_log_entry_category_examples'; +import { LogEntryExampleMessages } from '../../../../../components/logging/log_entry_examples/log_entry_examples'; +import { TimeRange } from '../../../../../../common/http_api/shared'; import { CategoryExampleMessage } from './category_example_message'; -import { CategoryExampleMessagesEmptyIndicator } from './category_example_messages_empty_indicator'; -import { CategoryExampleMessagesFailureIndicator } from './category_example_messages_failure_indicator'; -import { CategoryExampleMessagesLoadingIndicator } from './category_example_messages_loading_indicator'; const exampleCount = 5; @@ -39,30 +35,21 @@ export const CategoryDetailsRow: React.FunctionComponent<{ }, [getLogEntryCategoryExamples]); return ( - - {isLoadingLogEntryCategoryExamples ? ( - - ) : hasFailedLoadingLogEntryCategoryExamples ? ( - - ) : logEntryCategoryExamples.length === 0 ? ( - - ) : ( - logEntryCategoryExamples.map((categoryExample, categoryExampleIndex) => ( - - )) - )} - + 0} + exampleCount={exampleCount} + onReload={getLogEntryCategoryExamples} + > + {logEntryCategoryExamples.map((example, exampleIndex) => ( + + ))} + ); }; - -const CategoryExampleMessages = euiStyled.div` - align-items: stretch; - flex-direction: column; - flex: 1 0 0%; - overflow: hidden; -`; diff --git a/x-pack/plugins/infra/public/pages/logs/log_entry_rate/sections/anomalies/expanded_row.tsx b/x-pack/plugins/infra/public/pages/logs/log_entry_rate/sections/anomalies/expanded_row.tsx index a1d3d56beee2..c527b8c49d09 100644 --- a/x-pack/plugins/infra/public/pages/logs/log_entry_rate/sections/anomalies/expanded_row.tsx +++ b/x-pack/plugins/infra/public/pages/logs/log_entry_rate/sections/anomalies/expanded_row.tsx @@ -4,86 +4,129 @@ * you may not use this file except in compliance with the Elastic License. */ -import { EuiFlexGroup, EuiFlexItem, EuiSpacer, EuiStat } from '@elastic/eui'; +import { EuiFlexGroup, EuiFlexItem, EuiTitle, EuiStat } from '@elastic/eui'; import numeral from '@elastic/numeral'; import { i18n } from '@kbn/i18n'; -import React, { useMemo } from 'react'; - +import React from 'react'; +import { useMount } from 'react-use'; import { TimeRange } from '../../../../../../common/http_api/shared/time_range'; -import { AnalyzeInMlButton } from '../../../../../components/logging/log_analysis_results'; -import { LogEntryRateResults } from '../../use_log_entry_rate_results'; -import { - getAnnotationsForPartition, - getLogEntryRateSeriesForPartition, - getTotalNumberOfLogEntriesForPartition, -} from '../helpers/data_formatters'; -import { AnomaliesChart } from './chart'; +import { AnomalyRecord } from '../../use_log_entry_rate_results'; +import { useLogEntryRateModuleContext } from '../../use_log_entry_rate_module'; +import { useLogEntryRateExamples } from '../../use_log_entry_rate_examples'; +import { LogEntryExampleMessages } from '../../../../../components/logging/log_entry_examples/log_entry_examples'; +import { LogEntryRateExampleMessage, LogEntryRateExampleMessageHeaders } from './log_entry_example'; +import { euiStyled } from '../../../../../../../observability/public'; + +const EXAMPLE_COUNT = 5; + +const examplesTitle = i18n.translate('xpack.infra.logs.analysis.anomaliesTableExamplesTitle', { + defaultMessage: 'Example log entries', +}); export const AnomaliesTableExpandedRow: React.FunctionComponent<{ - partitionId: string; - results: LogEntryRateResults; - setTimeRange: (timeRange: TimeRange) => void; + anomaly: AnomalyRecord; timeRange: TimeRange; jobId: string; -}> = ({ results, timeRange, setTimeRange, partitionId, jobId }) => { - const logEntryRateSeries = useMemo( - () => - results?.histogramBuckets ? getLogEntryRateSeriesForPartition(results, partitionId) : [], - /* eslint-disable-next-line react-hooks/exhaustive-deps */ - [results, partitionId] - ); - const anomalyAnnotations = useMemo( - () => - results?.histogramBuckets - ? getAnnotationsForPartition(results, partitionId) - : { - warning: [], - minor: [], - major: [], - critical: [], - }, - /* eslint-disable-next-line react-hooks/exhaustive-deps */ - [results, partitionId] - ); - const totalNumberOfLogEntries = useMemo( - () => - results?.histogramBuckets - ? getTotalNumberOfLogEntriesForPartition(results, partitionId) - : undefined, - /* eslint-disable-next-line react-hooks/exhaustive-deps */ - [results, partitionId] - ); +}> = ({ anomaly, timeRange, jobId }) => { + const { + sourceConfiguration: { sourceId }, + } = useLogEntryRateModuleContext(); + + const { + getLogEntryRateExamples, + hasFailedLoadingLogEntryRateExamples, + isLoadingLogEntryRateExamples, + logEntryRateExamples, + } = useLogEntryRateExamples({ + dataset: anomaly.partitionId, + endTime: anomaly.startTime + anomaly.duration, + exampleCount: EXAMPLE_COUNT, + sourceId, + startTime: anomaly.startTime, + }); + + useMount(() => { + getLogEntryRateExamples(); + }); + return ( - - - - - - - - - - - - - - - + <> + + + +

{examplesTitle}

+
+ 0} + exampleCount={EXAMPLE_COUNT} + onReload={getLogEntryRateExamples} + > + {logEntryRateExamples.length > 0 ? ( + <> + + {logEntryRateExamples.map((example, exampleIndex) => ( + + ))} + + ) : null} + +
+ + + + + + + + + + +
+ ); }; + +const ExpandedContentWrapper = euiStyled(EuiFlexGroup)` + overflow: hidden; +`; diff --git a/x-pack/plugins/infra/public/pages/logs/log_entry_rate/sections/anomalies/index.tsx b/x-pack/plugins/infra/public/pages/logs/log_entry_rate/sections/anomalies/index.tsx index 5ff3f318629f..a2d37455eac1 100644 --- a/x-pack/plugins/infra/public/pages/logs/log_entry_rate/sections/anomalies/index.tsx +++ b/x-pack/plugins/infra/public/pages/logs/log_entry_rate/sections/anomalies/index.tsx @@ -9,23 +9,15 @@ import { EuiFlexGroup, EuiFlexItem, EuiSpacer, - EuiStat, EuiTitle, EuiLoadingSpinner, } from '@elastic/eui'; -import numeral from '@elastic/numeral'; import { i18n } from '@kbn/i18n'; import React, { useMemo } from 'react'; - import { euiStyled } from '../../../../../../../observability/public'; import { LogEntryRateResults } from '../../use_log_entry_rate_results'; import { TimeRange } from '../../../../../../common/http_api/shared/time_range'; -import { formatAnomalyScore } from '../../../../../../common/log_analysis'; -import { - getAnnotationsForAll, - getLogEntryRateCombinedSeries, - getTopAnomalyScoreAcrossAllPartitions, -} from '../helpers/data_formatters'; +import { getAnnotationsForAll, getLogEntryRateCombinedSeries } from '../helpers/data_formatters'; import { AnomaliesChart } from './chart'; import { AnomaliesTable } from './table'; import { RecreateJobButton } from '../../../../../components/logging/log_analysis_job_status'; @@ -67,14 +59,6 @@ export const AnomaliesResults: React.FunctionComponent<{ [results] ); - const topAnomalyScore = useMemo( - () => - results && results.histogramBuckets - ? getTopAnomalyScoreAcrossAllPartitions(results) - : undefined, - [results] - ); - return ( <> @@ -124,7 +108,7 @@ export const AnomaliesResults: React.FunctionComponent<{ ) : ( <> - + - - - - ; + interface ParsedAnnotationDetails { anomalyScoresByPartition: Array<{ partitionName: string; maximumAnomalyScore: number }>; } @@ -222,10 +189,3 @@ const renderAnnotationTooltip = (details?: string) => { const TooltipWrapper = euiStyled('div')` white-space: nowrap; `; - -const loadingAriaLabel = i18n.translate( - 'xpack.infra.logs.analysis.anomaliesSectionLoadingAriaLabel', - { defaultMessage: 'Loading anomalies' } -); - -const LoadingOverlayContent = () => ; diff --git a/x-pack/plugins/infra/public/pages/logs/log_entry_rate/sections/anomalies/log_entry_example.tsx b/x-pack/plugins/infra/public/pages/logs/log_entry_rate/sections/anomalies/log_entry_example.tsx new file mode 100644 index 000000000000..96f665b3693c --- /dev/null +++ b/x-pack/plugins/infra/public/pages/logs/log_entry_rate/sections/anomalies/log_entry_example.tsx @@ -0,0 +1,291 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import React, { useMemo, useCallback, useState } from 'react'; +import moment from 'moment'; +import { encode } from 'rison-node'; +import { i18n } from '@kbn/i18n'; +import { euiStyled } from '../../../../../../../observability/public'; +import { getFriendlyNameForPartitionId } from '../../../../../../common/log_analysis'; +import { + LogEntryColumn, + LogEntryFieldColumn, + LogEntryMessageColumn, + LogEntryRowWrapper, + LogEntryTimestampColumn, + LogEntryContextMenu, + LogEntryColumnWidths, + iconColumnId, +} from '../../../../../components/logging/log_text_stream'; +import { + LogColumnHeadersWrapper, + LogColumnHeader, +} from '../../../../../components/logging/log_text_stream/column_headers'; +import { useLinkProps } from '../../../../../hooks/use_link_props'; +import { TimeRange } from '../../../../../../common/http_api/shared/time_range'; +import { partitionField } from '../../../../../../common/log_analysis/job_parameters'; +import { getEntitySpecificSingleMetricViewerLink } from '../../../../../components/logging/log_analysis_results/analyze_in_ml_button'; +import { LogEntryRateExample } from '../../../../../../common/http_api/log_analysis/results'; +import { + LogColumnConfiguration, + isTimestampLogColumnConfiguration, + isFieldLogColumnConfiguration, + isMessageLogColumnConfiguration, +} from '../../../../../utils/source_configuration'; +import { localizedDate } from '../../../../../../common/formatters/datetime'; + +export const exampleMessageScale = 'medium' as const; +export const exampleTimestampFormat = 'time' as const; + +const MENU_LABEL = i18n.translate('xpack.infra.logAnomalies.logEntryExamplesMenuLabel', { + defaultMessage: 'View actions for log entry', +}); + +const VIEW_IN_STREAM_LABEL = i18n.translate( + 'xpack.infra.logs.analysis.logEntryExamplesViewInStreamLabel', + { + defaultMessage: 'View in stream', + } +); + +const VIEW_ANOMALY_IN_ML_LABEL = i18n.translate( + 'xpack.infra.logs.analysis.logEntryExamplesViewAnomalyInMlLabel', + { + defaultMessage: 'View anomaly in machine learning', + } +); + +type Props = LogEntryRateExample & { + timeRange: TimeRange; + jobId: string; +}; + +export const LogEntryRateExampleMessage: React.FunctionComponent = ({ + id, + dataset, + message, + timestamp, + tiebreaker, + timeRange, + jobId, +}) => { + const [isHovered, setIsHovered] = useState(false); + const [isMenuOpen, setIsMenuOpen] = useState(false); + const openMenu = useCallback(() => setIsMenuOpen(true), []); + const closeMenu = useCallback(() => setIsMenuOpen(false), []); + const setItemIsHovered = useCallback(() => setIsHovered(true), []); + const setItemIsNotHovered = useCallback(() => setIsHovered(false), []); + + // the dataset must be encoded for the field column and the empty value must + // be turned into a user-friendly value + const encodedDatasetFieldValue = useMemo( + () => JSON.stringify(getFriendlyNameForPartitionId(dataset)), + [dataset] + ); + + const viewInStreamLinkProps = useLinkProps({ + app: 'logs', + pathname: 'stream', + search: { + logPosition: encode({ + end: moment(timeRange.endTime).format('YYYY-MM-DDTHH:mm:ss.SSSZ'), + position: { tiebreaker, time: timestamp }, + start: moment(timeRange.startTime).format('YYYY-MM-DDTHH:mm:ss.SSSZ'), + streamLive: false, + }), + flyoutOptions: encode({ + surroundingLogsId: id, + }), + logFilter: encode({ + expression: `${partitionField}: ${dataset}`, + kind: 'kuery', + }), + }, + }); + + const viewAnomalyInMachineLearningLinkProps = useLinkProps( + getEntitySpecificSingleMetricViewerLink(jobId, timeRange, { + [partitionField]: dataset, + }) + ); + + const menuItems = useMemo(() => { + if (!viewInStreamLinkProps.onClick || !viewAnomalyInMachineLearningLinkProps.onClick) { + return undefined; + } + + return [ + { + label: VIEW_IN_STREAM_LABEL, + onClick: viewInStreamLinkProps.onClick, + href: viewInStreamLinkProps.href, + }, + { + label: VIEW_ANOMALY_IN_ML_LABEL, + onClick: viewAnomalyInMachineLearningLinkProps.onClick, + href: viewAnomalyInMachineLearningLinkProps.href, + }, + ]; + }, [viewInStreamLinkProps, viewAnomalyInMachineLearningLinkProps]); + + return ( + + + + + + + + + + + + {(isHovered || isMenuOpen) && menuItems ? ( + + ) : null} + + + ); +}; + +const noHighlights: never[] = []; +const timestampColumnId = 'log-entry-example-timestamp-column' as const; +const messageColumnId = 'log-entry-examples-message-column' as const; +const datasetColumnId = 'log-entry-examples-dataset-column' as const; + +const DETAIL_FLYOUT_ICON_MIN_WIDTH = 32; +const COLUMN_PADDING = 8; + +export const columnWidths: LogEntryColumnWidths = { + [timestampColumnId]: { + growWeight: 0, + shrinkWeight: 0, + // w_score - w_padding = 130 px - 8 px + baseWidth: '122px', + }, + [messageColumnId]: { + growWeight: 1, + shrinkWeight: 0, + baseWidth: '0%', + }, + [datasetColumnId]: { + growWeight: 0, + shrinkWeight: 0, + baseWidth: '250px', + }, + [iconColumnId]: { + growWeight: 0, + shrinkWeight: 0, + baseWidth: `${DETAIL_FLYOUT_ICON_MIN_WIDTH + 2 * COLUMN_PADDING}px`, + }, +}; + +export const exampleMessageColumnConfigurations: LogColumnConfiguration[] = [ + { + timestampColumn: { + id: timestampColumnId, + }, + }, + { + messageColumn: { + id: messageColumnId, + }, + }, + { + fieldColumn: { + field: 'event.dataset', + id: datasetColumnId, + }, + }, +]; + +export const LogEntryRateExampleMessageHeaders: React.FunctionComponent<{ + dateTime: number; +}> = ({ dateTime }) => { + return ( + + <> + {exampleMessageColumnConfigurations.map((columnConfiguration) => { + if (isTimestampLogColumnConfiguration(columnConfiguration)) { + return ( + + {localizedDate(dateTime)} + + ); + } else if (isMessageLogColumnConfiguration(columnConfiguration)) { + return ( + + Message + + ); + } else if (isFieldLogColumnConfiguration(columnConfiguration)) { + return ( + + {columnConfiguration.fieldColumn.field} + + ); + } + })} + + {null} + + + + ); +}; + +const LogEntryRateExampleMessageHeadersWrapper = euiStyled(LogColumnHeadersWrapper)` + border-bottom: none; + box-shadow: none; + padding-right: 0; +`; diff --git a/x-pack/plugins/infra/public/pages/logs/log_entry_rate/sections/anomalies/table.tsx b/x-pack/plugins/infra/public/pages/logs/log_entry_rate/sections/anomalies/table.tsx index a9090a90c0b9..c70a456bfe06 100644 --- a/x-pack/plugins/infra/public/pages/logs/log_entry_rate/sections/anomalies/table.tsx +++ b/x-pack/plugins/infra/public/pages/logs/log_entry_rate/sections/anomalies/table.tsx @@ -6,10 +6,10 @@ import { EuiBasicTable, EuiBasicTableColumn } from '@elastic/eui'; import { RIGHT_ALIGNMENT } from '@elastic/eui/lib/services'; +import moment from 'moment'; import { i18n } from '@kbn/i18n'; import React, { useCallback, useMemo, useState } from 'react'; import { useSet } from 'react-use'; -import { euiStyled } from '../../../../../../../observability/public'; import { TimeRange } from '../../../../../../common/http_api/shared/time_range'; import { formatAnomalyScore, @@ -18,11 +18,16 @@ import { import { RowExpansionButton } from '../../../../../components/basic_table'; import { LogEntryRateResults } from '../../use_log_entry_rate_results'; import { AnomaliesTableExpandedRow } from './expanded_row'; +import { AnomalySeverityIndicator } from '../../../../../components/logging/log_analysis_results/anomaly_severity_indicator'; +import { useKibanaUiSetting } from '../../../../../utils/use_kibana_ui_setting'; interface TableItem { - partitionName: string; - partitionId: string; - topAnomalyScore: number; + id: string; + dataset: string; + datasetName: string; + anomalyScore: number; + anomalyMessage: string; + startTime: number; } interface SortingOptions { @@ -32,73 +37,132 @@ interface SortingOptions { }; } -const partitionColumnName = i18n.translate( - 'xpack.infra.logs.analysis.anomaliesTablePartitionColumnName', +interface PaginationOptions { + pageIndex: number; + pageSize: number; + totalItemCount: number; + pageSizeOptions: number[]; + hidePerPageOptions: boolean; +} + +const anomalyScoreColumnName = i18n.translate( + 'xpack.infra.logs.analysis.anomaliesTableAnomalyScoreColumnName', { - defaultMessage: 'Partition', + defaultMessage: 'Anomaly score', } ); -const maxAnomalyScoreColumnName = i18n.translate( - 'xpack.infra.logs.analysis.anomaliesTableMaxAnomalyScoreColumnName', +const anomalyMessageColumnName = i18n.translate( + 'xpack.infra.logs.analysis.anomaliesTableAnomalyMessageName', { - defaultMessage: 'Max anomaly score', + defaultMessage: 'Anomaly', } ); +const anomalyStartTimeColumnName = i18n.translate( + 'xpack.infra.logs.analysis.anomaliesTableAnomalyStartTime', + { + defaultMessage: 'Start time', + } +); + +const datasetColumnName = i18n.translate( + 'xpack.infra.logs.analysis.anomaliesTableAnomalyDatasetName', + { + defaultMessage: 'Dataset', + } +); + +const moreThanExpectedAnomalyMessage = i18n.translate( + 'xpack.infra.logs.analysis.anomaliesTableMoreThanExpectedAnomalyMessage', + { + defaultMessage: 'More log messages in this dataset than expected', + } +); + +const fewerThanExpectedAnomalyMessage = i18n.translate( + 'xpack.infra.logs.analysis.anomaliesTableFewerThanExpectedAnomalyMessage', + { + defaultMessage: 'Fewer log messages in this dataset than expected', + } +); + +const getAnomalyMessage = (actualRate: number, typicalRate: number): string => { + return actualRate < typicalRate + ? fewerThanExpectedAnomalyMessage + : moreThanExpectedAnomalyMessage; +}; + export const AnomaliesTable: React.FunctionComponent<{ results: LogEntryRateResults; setTimeRange: (timeRange: TimeRange) => void; timeRange: TimeRange; jobId: string; }> = ({ results, timeRange, setTimeRange, jobId }) => { + const [dateFormat] = useKibanaUiSetting('dateFormat', 'Y-MM-DD HH:mm:ss'); + const tableItems: TableItem[] = useMemo(() => { - return Object.entries(results.partitionBuckets).map(([key, value]) => { + return results.anomalies.map((anomaly) => { return { - // The real ID - partitionId: key, - // Note: EUI's table expanded rows won't work with a key of '' in itemIdToExpandedRowMap, so we have to use the friendly name here - partitionName: getFriendlyNameForPartitionId(key), - topAnomalyScore: formatAnomalyScore(value.topAnomalyScore), + id: anomaly.id, + dataset: anomaly.partitionId, + datasetName: getFriendlyNameForPartitionId(anomaly.partitionId), + anomalyScore: formatAnomalyScore(anomaly.anomalyScore), + anomalyMessage: getAnomalyMessage(anomaly.actualLogEntryRate, anomaly.typicalLogEntryRate), + startTime: anomaly.startTime, }; }); }, [results]); - const [expandedDatasetIds, { add: expandDataset, remove: collapseDataset }] = useSet( - new Set() - ); + const [expandedIds, { add: expandId, remove: collapseId }] = useSet(new Set()); const expandedDatasetRowContents = useMemo( () => - [...expandedDatasetIds].reduce>( - (aggregatedDatasetRows, datasetId) => { - return { - ...aggregatedDatasetRows, - [getFriendlyNameForPartitionId(datasetId)]: ( - - ), - }; - }, - {} - ), - [expandedDatasetIds, jobId, results, setTimeRange, timeRange] + [...expandedIds].reduce>((aggregatedDatasetRows, id) => { + const anomaly = results.anomalies.find((_anomaly) => _anomaly.id === id); + + return { + ...aggregatedDatasetRows, + [id]: anomaly ? ( + + ) : null, + }; + }, {}), + [expandedIds, results, timeRange, jobId] ); const [sorting, setSorting] = useState({ sort: { - field: 'topAnomalyScore', + field: 'anomalyScore', direction: 'desc', }, }); + const [_pagination, setPagination] = useState({ + pageIndex: 0, + pageSize: 20, + totalItemCount: results.anomalies.length, + pageSizeOptions: [10, 20, 50], + hidePerPageOptions: false, + }); + + const paginationOptions = useMemo(() => { + return { + ..._pagination, + totalItemCount: results.anomalies.length, + }; + }, [_pagination, results]); + const handleTableChange = useCallback( - ({ sort = {} }) => { + ({ page = {}, sort = {} }) => { + const { index, size } = page; + setPagination((currentPagination) => { + return { + ...currentPagination, + pageIndex: index, + pageSize: size, + }; + }); const { field, direction } = sort; setSorting({ sort: { @@ -107,33 +171,58 @@ export const AnomaliesTable: React.FunctionComponent<{ }, }); }, - [setSorting] + [setSorting, setPagination] ); const sortedTableItems = useMemo(() => { let sortedItems: TableItem[] = []; - if (sorting.sort.field === 'partitionName') { - sortedItems = tableItems.sort((a, b) => (a.partitionId > b.partitionId ? 1 : -1)); - } else if (sorting.sort.field === 'topAnomalyScore') { - sortedItems = tableItems.sort((a, b) => a.topAnomalyScore - b.topAnomalyScore); + if (sorting.sort.field === 'datasetName') { + sortedItems = tableItems.sort((a, b) => (a.datasetName > b.datasetName ? 1 : -1)); + } else if (sorting.sort.field === 'anomalyScore') { + sortedItems = tableItems.sort((a, b) => a.anomalyScore - b.anomalyScore); + } else if (sorting.sort.field === 'startTime') { + sortedItems = tableItems.sort((a, b) => a.startTime - b.startTime); } + return sorting.sort.direction === 'asc' ? sortedItems : sortedItems.reverse(); }, [tableItems, sorting]); + const pageOfItems: TableItem[] = useMemo(() => { + const { pageIndex, pageSize } = paginationOptions; + return sortedTableItems.slice(pageIndex * pageSize, pageIndex * pageSize + pageSize); + }, [paginationOptions, sortedTableItems]); + const columns: Array> = useMemo( () => [ { - field: 'partitionName', - name: partitionColumnName, - sortable: true, - truncateText: true, - }, - { - field: 'topAnomalyScore', - name: maxAnomalyScoreColumnName, + field: 'anomalyScore', + name: anomalyScoreColumnName, sortable: true, truncateText: true, dataType: 'number' as const, + width: '130px', + render: (anomalyScore: number) => , + }, + { + field: 'anomalyMessage', + name: anomalyMessageColumnName, + sortable: false, + truncateText: true, + }, + { + field: 'startTime', + name: anomalyStartTimeColumnName, + sortable: true, + truncateText: true, + width: '230px', + render: (startTime: number) => moment(startTime).format(dateFormat), + }, + { + field: 'datasetName', + name: datasetColumnName, + sortable: true, + truncateText: true, + width: '200px', }, { align: RIGHT_ALIGNMENT, @@ -141,33 +230,28 @@ export const AnomaliesTable: React.FunctionComponent<{ isExpander: true, render: (item: TableItem) => ( ), }, ], - [collapseDataset, expandDataset, expandedDatasetIds] + [collapseId, expandId, expandedIds, dateFormat] ); return ( - ); }; - -const StyledEuiBasicTable: typeof EuiBasicTable = euiStyled(EuiBasicTable as any)` - & .euiTable { - table-layout: auto; - } -` as any; // eslint-disable-line @typescript-eslint/no-explicit-any diff --git a/x-pack/plugins/infra/public/pages/logs/log_entry_rate/service_calls/get_log_entry_rate_examples.ts b/x-pack/plugins/infra/public/pages/logs/log_entry_rate/service_calls/get_log_entry_rate_examples.ts new file mode 100644 index 000000000000..d3b30da72af9 --- /dev/null +++ b/x-pack/plugins/infra/public/pages/logs/log_entry_rate/service_calls/get_log_entry_rate_examples.ts @@ -0,0 +1,47 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { fold } from 'fp-ts/lib/Either'; +import { pipe } from 'fp-ts/lib/pipeable'; +import { identity } from 'fp-ts/lib/function'; +import { npStart } from '../../../../legacy_singletons'; + +import { + getLogEntryRateExamplesRequestPayloadRT, + getLogEntryRateExamplesSuccessReponsePayloadRT, + LOG_ANALYSIS_GET_LOG_ENTRY_RATE_EXAMPLES_PATH, +} from '../../../../../common/http_api/log_analysis'; +import { createPlainError, throwErrors } from '../../../../../common/runtime_types'; + +export const callGetLogEntryRateExamplesAPI = async ( + sourceId: string, + startTime: number, + endTime: number, + dataset: string, + exampleCount: number +) => { + const response = await npStart.http.fetch(LOG_ANALYSIS_GET_LOG_ENTRY_RATE_EXAMPLES_PATH, { + method: 'POST', + body: JSON.stringify( + getLogEntryRateExamplesRequestPayloadRT.encode({ + data: { + dataset, + exampleCount, + sourceId, + timeRange: { + startTime, + endTime, + }, + }, + }) + ), + }); + + return pipe( + getLogEntryRateExamplesSuccessReponsePayloadRT.decode(response), + fold(throwErrors(createPlainError), identity) + ); +}; diff --git a/x-pack/plugins/infra/public/pages/logs/log_entry_rate/use_log_entry_rate_examples.ts b/x-pack/plugins/infra/public/pages/logs/log_entry_rate/use_log_entry_rate_examples.ts new file mode 100644 index 000000000000..12bcdb2a4b4d --- /dev/null +++ b/x-pack/plugins/infra/public/pages/logs/log_entry_rate/use_log_entry_rate_examples.ts @@ -0,0 +1,63 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { useMemo, useState } from 'react'; + +import { LogEntryRateExample } from '../../../../common/http_api'; +import { useTrackedPromise } from '../../../utils/use_tracked_promise'; +import { callGetLogEntryRateExamplesAPI } from './service_calls/get_log_entry_rate_examples'; + +export const useLogEntryRateExamples = ({ + dataset, + endTime, + exampleCount, + sourceId, + startTime, +}: { + dataset: string; + endTime: number; + exampleCount: number; + sourceId: string; + startTime: number; +}) => { + const [logEntryRateExamples, setLogEntryRateExamples] = useState([]); + + const [getLogEntryRateExamplesRequest, getLogEntryRateExamples] = useTrackedPromise( + { + cancelPreviousOn: 'creation', + createPromise: async () => { + return await callGetLogEntryRateExamplesAPI( + sourceId, + startTime, + endTime, + dataset, + exampleCount + ); + }, + onResolve: ({ data: { examples } }) => { + setLogEntryRateExamples(examples); + }, + }, + [dataset, endTime, exampleCount, sourceId, startTime] + ); + + const isLoadingLogEntryRateExamples = useMemo( + () => getLogEntryRateExamplesRequest.state === 'pending', + [getLogEntryRateExamplesRequest.state] + ); + + const hasFailedLoadingLogEntryRateExamples = useMemo( + () => getLogEntryRateExamplesRequest.state === 'rejected', + [getLogEntryRateExamplesRequest.state] + ); + + return { + getLogEntryRateExamples, + hasFailedLoadingLogEntryRateExamples, + isLoadingLogEntryRateExamples, + logEntryRateExamples, + }; +}; diff --git a/x-pack/plugins/infra/public/pages/logs/log_entry_rate/use_log_entry_rate_results.ts b/x-pack/plugins/infra/public/pages/logs/log_entry_rate/use_log_entry_rate_results.ts index de2b873001cc..1cd27c64af53 100644 --- a/x-pack/plugins/infra/public/pages/logs/log_entry_rate/use_log_entry_rate_results.ts +++ b/x-pack/plugins/infra/public/pages/logs/log_entry_rate/use_log_entry_rate_results.ts @@ -10,6 +10,7 @@ import { GetLogEntryRateSuccessResponsePayload, LogEntryRateHistogramBucket, LogEntryRatePartition, + LogEntryRateAnomaly, } from '../../../../common/http_api/log_analysis'; import { useTrackedPromise } from '../../../utils/use_tracked_promise'; import { callGetLogEntryRateAPI } from './service_calls/get_log_entry_rate'; @@ -23,11 +24,16 @@ type PartitionRecord = Record< { buckets: PartitionBucket[]; topAnomalyScore: number; totalNumberOfLogEntries: number } >; +export type AnomalyRecord = LogEntryRateAnomaly & { + partitionId: string; +}; + export interface LogEntryRateResults { bucketDuration: number; totalNumberOfLogEntries: number; histogramBuckets: LogEntryRateHistogramBucket[]; partitionBuckets: PartitionRecord; + anomalies: AnomalyRecord[]; } export const useLogEntryRateResults = ({ @@ -55,6 +61,7 @@ export const useLogEntryRateResults = ({ totalNumberOfLogEntries: data.totalNumberOfLogEntries, histogramBuckets: data.histogramBuckets, partitionBuckets: formatLogEntryRateResultsByPartition(data), + anomalies: formatLogEntryRateResultsByAllAnomalies(data), }); }, onReject: () => { @@ -117,3 +124,23 @@ const formatLogEntryRateResultsByPartition = ( return resultsByPartition; }; + +const formatLogEntryRateResultsByAllAnomalies = ( + results: GetLogEntryRateSuccessResponsePayload['data'] +): AnomalyRecord[] => { + return results.histogramBuckets.reduce((anomalies, bucket) => { + return bucket.partitions.reduce((_anomalies, partition) => { + if (partition.anomalies.length > 0) { + partition.anomalies.forEach((anomaly) => { + _anomalies.push({ + partitionId: partition.partitionId, + ...anomaly, + }); + }); + return _anomalies; + } else { + return _anomalies; + } + }, anomalies); + }, []); +}; diff --git a/x-pack/plugins/infra/server/infra_server.ts b/x-pack/plugins/infra/server/infra_server.ts index 6fbdeff950d1..8af37a36ef74 100644 --- a/x-pack/plugins/infra/server/infra_server.ts +++ b/x-pack/plugins/infra/server/infra_server.ts @@ -15,6 +15,7 @@ import { initGetLogEntryCategoryDatasetsRoute, initGetLogEntryCategoryExamplesRoute, initGetLogEntryRateRoute, + initGetLogEntryRateExamplesRoute, initValidateLogAnalysisDatasetsRoute, initValidateLogAnalysisIndicesRoute, } from './routes/log_analysis'; @@ -56,6 +57,7 @@ export const initInfraServer = (libs: InfraBackendLibs) => { initValidateLogAnalysisDatasetsRoute(libs); initValidateLogAnalysisIndicesRoute(libs); initLogEntriesRoute(libs); + initGetLogEntryRateExamplesRoute(libs); initLogEntriesHighlightsRoute(libs); initLogEntriesSummaryRoute(libs); initLogEntriesSummaryHighlightsRoute(libs); diff --git a/x-pack/plugins/infra/server/lib/log_analysis/log_entry_rate_analysis.ts b/x-pack/plugins/infra/server/lib/log_analysis/log_entry_rate_analysis.ts index 125cc2b196e0..290cf03b6736 100644 --- a/x-pack/plugins/infra/server/lib/log_analysis/log_entry_rate_analysis.ts +++ b/x-pack/plugins/infra/server/lib/log_analysis/log_entry_rate_analysis.ts @@ -7,16 +7,30 @@ import { pipe } from 'fp-ts/lib/pipeable'; import { map, fold } from 'fp-ts/lib/Either'; import { identity } from 'fp-ts/lib/function'; -import { getJobId } from '../../../common/log_analysis'; +import { RequestHandlerContext } from 'src/core/server'; import { throwErrors, createPlainError } from '../../../common/runtime_types'; -import { NoLogAnalysisResultsIndexError } from './errors'; import { logRateModelPlotResponseRT, createLogEntryRateQuery, LogRateModelPlotBucket, CompositeTimestampPartitionKey, } from './queries'; -import { MlSystem } from '../../types'; +import { startTracingSpan } from '../../../common/performance_tracing'; +import { decodeOrThrow } from '../../../common/runtime_types'; +import { getJobId, jobCustomSettingsRT } from '../../../common/log_analysis'; +import { + createLogEntryRateExamplesQuery, + logEntryRateExamplesResponseRT, +} from './queries/log_entry_rate_examples'; +import { + InsufficientLogAnalysisMlJobConfigurationError, + NoLogAnalysisMlJobError, + NoLogAnalysisResultsIndexError, +} from './errors'; +import { InfraSource } from '../sources'; +import type { MlSystem } from '../../types'; +import { InfraRequestHandlerContext } from '../../types'; +import { KibanaFramework } from '../adapters/framework/kibana_framework_adapter'; const COMPOSITE_AGGREGATION_BATCH_SIZE = 1000; @@ -73,6 +87,7 @@ export async function getLogEntryRateBuckets( partitions: Array<{ analysisBucketCount: number; anomalies: Array<{ + id: string; actualLogEntryRate: number; anomalyScore: number; duration: number; @@ -91,7 +106,8 @@ export async function getLogEntryRateBuckets( const partition = { analysisBucketCount: timestampPartitionBucket.filter_model_plot.doc_count, anomalies: timestampPartitionBucket.filter_records.top_hits_record.hits.hits.map( - ({ _source: record }) => ({ + ({ _id, _source: record }) => ({ + id: _id, actualLogEntryRate: record.actual[0], anomalyScore: record.record_score, duration: record.bucket_span * 1000, @@ -127,3 +143,130 @@ export async function getLogEntryRateBuckets( } }, []); } + +export async function getLogEntryRateExamples( + context: RequestHandlerContext & { infra: Required }, + sourceId: string, + startTime: number, + endTime: number, + dataset: string, + exampleCount: number, + sourceConfiguration: InfraSource, + callWithRequest: KibanaFramework['callWithRequest'] +) { + const finalizeLogEntryRateExamplesSpan = startTracingSpan( + 'get log entry rate example log entries' + ); + + const jobId = getJobId(context.infra.spaceId, sourceId, 'log-entry-rate'); + + const { + mlJob, + timing: { spans: fetchMlJobSpans }, + } = await fetchMlJob(context, jobId); + + const customSettings = decodeOrThrow(jobCustomSettingsRT)(mlJob.custom_settings); + const indices = customSettings?.logs_source_config?.indexPattern; + const timestampField = customSettings?.logs_source_config?.timestampField; + const tiebreakerField = sourceConfiguration.configuration.fields.tiebreaker; + + if (indices == null || timestampField == null) { + throw new InsufficientLogAnalysisMlJobConfigurationError( + `Failed to find index configuration for ml job ${jobId}` + ); + } + + const { + examples, + timing: { spans: fetchLogEntryRateExamplesSpans }, + } = await fetchLogEntryRateExamples( + context, + indices, + timestampField, + tiebreakerField, + startTime, + endTime, + dataset, + exampleCount, + callWithRequest + ); + + const logEntryRateExamplesSpan = finalizeLogEntryRateExamplesSpan(); + + return { + data: examples, + timing: { + spans: [logEntryRateExamplesSpan, ...fetchMlJobSpans, ...fetchLogEntryRateExamplesSpans], + }, + }; +} + +export async function fetchLogEntryRateExamples( + context: RequestHandlerContext & { infra: Required }, + indices: string, + timestampField: string, + tiebreakerField: string, + startTime: number, + endTime: number, + dataset: string, + exampleCount: number, + callWithRequest: KibanaFramework['callWithRequest'] +) { + const finalizeEsSearchSpan = startTracingSpan('Fetch log rate examples from ES'); + + const { + hits: { hits }, + } = decodeOrThrow(logEntryRateExamplesResponseRT)( + await callWithRequest( + context, + 'search', + createLogEntryRateExamplesQuery( + indices, + timestampField, + tiebreakerField, + startTime, + endTime, + dataset, + exampleCount + ) + ) + ); + + const esSearchSpan = finalizeEsSearchSpan(); + + return { + examples: hits.map((hit) => ({ + id: hit._id, + dataset, + message: hit._source.message ?? '', + timestamp: hit.sort[0], + tiebreaker: hit.sort[1], + })), + timing: { + spans: [esSearchSpan], + }, + }; +} + +async function fetchMlJob( + context: RequestHandlerContext & { infra: Required }, + logEntryRateJobId: string +) { + const finalizeMlGetJobSpan = startTracingSpan('Fetch ml job from ES'); + const { + jobs: [mlJob], + } = await context.infra.mlAnomalyDetectors.jobs(logEntryRateJobId); + + const mlGetJobSpan = finalizeMlGetJobSpan(); + + if (mlJob == null) { + throw new NoLogAnalysisMlJobError(`Failed to find ml job ${logEntryRateJobId}.`); + } + + return { + mlJob, + timing: { + spans: [mlGetJobSpan], + }, + }; +} diff --git a/x-pack/plugins/infra/server/lib/log_analysis/queries/log_entry_rate.ts b/x-pack/plugins/infra/server/lib/log_analysis/queries/log_entry_rate.ts index 269850e29263..8d9c586b2ef6 100644 --- a/x-pack/plugins/infra/server/lib/log_analysis/queries/log_entry_rate.ts +++ b/x-pack/plugins/infra/server/lib/log_analysis/queries/log_entry_rate.ts @@ -143,6 +143,7 @@ export const logRateModelPlotBucketRT = rt.type({ hits: rt.type({ hits: rt.array( rt.type({ + _id: rt.string, _source: logRateMlRecordRT, }) ), diff --git a/x-pack/plugins/infra/server/lib/log_analysis/queries/log_entry_rate_examples.ts b/x-pack/plugins/infra/server/lib/log_analysis/queries/log_entry_rate_examples.ts new file mode 100644 index 000000000000..ef06641caf79 --- /dev/null +++ b/x-pack/plugins/infra/server/lib/log_analysis/queries/log_entry_rate_examples.ts @@ -0,0 +1,72 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import * as rt from 'io-ts'; + +import { commonSearchSuccessResponseFieldsRT } from '../../../utils/elasticsearch_runtime_types'; +import { defaultRequestParameters } from './common'; +import { partitionField } from '../../../../common/log_analysis'; + +export const createLogEntryRateExamplesQuery = ( + indices: string, + timestampField: string, + tiebreakerField: string, + startTime: number, + endTime: number, + dataset: string, + exampleCount: number +) => ({ + ...defaultRequestParameters, + body: { + query: { + bool: { + filter: [ + { + range: { + [timestampField]: { + gte: startTime, + lte: endTime, + }, + }, + }, + { + term: { + [partitionField]: dataset, + }, + }, + ], + }, + }, + sort: [{ [timestampField]: 'asc' }, { [tiebreakerField]: 'asc' }], + }, + _source: ['event.dataset', 'message'], + index: indices, + size: exampleCount, +}); + +export const logEntryRateExampleHitRT = rt.type({ + _id: rt.string, + _source: rt.partial({ + event: rt.partial({ + dataset: rt.string, + }), + message: rt.string, + }), + sort: rt.tuple([rt.number, rt.number]), +}); + +export type LogEntryRateExampleHit = rt.TypeOf; + +export const logEntryRateExamplesResponseRT = rt.intersection([ + commonSearchSuccessResponseFieldsRT, + rt.type({ + hits: rt.type({ + hits: rt.array(logEntryRateExampleHitRT), + }), + }), +]); + +export type LogEntryRateExamplesResponse = rt.TypeOf; diff --git a/x-pack/plugins/infra/server/routes/log_analysis/results/index.ts b/x-pack/plugins/infra/server/routes/log_analysis/results/index.ts index 15615046bdd6..30b6be435837 100644 --- a/x-pack/plugins/infra/server/routes/log_analysis/results/index.ts +++ b/x-pack/plugins/infra/server/routes/log_analysis/results/index.ts @@ -8,3 +8,4 @@ export * from './log_entry_categories'; export * from './log_entry_category_datasets'; export * from './log_entry_category_examples'; export * from './log_entry_rate'; +export * from './log_entry_rate_examples'; diff --git a/x-pack/plugins/infra/server/routes/log_analysis/results/log_entry_rate_examples.ts b/x-pack/plugins/infra/server/routes/log_analysis/results/log_entry_rate_examples.ts new file mode 100644 index 000000000000..b8ebcc66911d --- /dev/null +++ b/x-pack/plugins/infra/server/routes/log_analysis/results/log_entry_rate_examples.ts @@ -0,0 +1,82 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import Boom from 'boom'; +import { createValidationFunction } from '../../../../common/runtime_types'; +import { InfraBackendLibs } from '../../../lib/infra_types'; +import { NoLogAnalysisResultsIndexError, getLogEntryRateExamples } from '../../../lib/log_analysis'; +import { assertHasInfraMlPlugins } from '../../../utils/request_context'; +import { + getLogEntryRateExamplesRequestPayloadRT, + getLogEntryRateExamplesSuccessReponsePayloadRT, + LOG_ANALYSIS_GET_LOG_ENTRY_RATE_EXAMPLES_PATH, +} from '../../../../common/http_api/log_analysis'; + +export const initGetLogEntryRateExamplesRoute = ({ framework, sources }: InfraBackendLibs) => { + framework.registerRoute( + { + method: 'post', + path: LOG_ANALYSIS_GET_LOG_ENTRY_RATE_EXAMPLES_PATH, + validate: { + body: createValidationFunction(getLogEntryRateExamplesRequestPayloadRT), + }, + }, + framework.router.handleLegacyErrors(async (requestContext, request, response) => { + const { + data: { + dataset, + exampleCount, + sourceId, + timeRange: { startTime, endTime }, + }, + } = request.body; + + const sourceConfiguration = await sources.getSourceConfiguration( + requestContext.core.savedObjects.client, + sourceId + ); + + try { + assertHasInfraMlPlugins(requestContext); + + const { data: logEntryRateExamples, timing } = await getLogEntryRateExamples( + requestContext, + sourceId, + startTime, + endTime, + dataset, + exampleCount, + sourceConfiguration, + framework.callWithRequest + ); + + return response.ok({ + body: getLogEntryRateExamplesSuccessReponsePayloadRT.encode({ + data: { + examples: logEntryRateExamples, + }, + timing, + }), + }); + } catch (error) { + if (Boom.isBoom(error)) { + throw error; + } + + if (error instanceof NoLogAnalysisResultsIndexError) { + return response.notFound({ body: { message: error.message } }); + } + + return response.customError({ + statusCode: error.statusCode ?? 500, + body: { + message: error.message ?? 'An unexpected error occurred', + }, + }); + } + }) + ); +}; diff --git a/x-pack/plugins/translations/translations/ja-JP.json b/x-pack/plugins/translations/translations/ja-JP.json index 9a3f52a0ce47..683d83dde4e0 100644 --- a/x-pack/plugins/translations/translations/ja-JP.json +++ b/x-pack/plugins/translations/translations/ja-JP.json @@ -7473,12 +7473,9 @@ "xpack.infra.logs.alerting.threshold.documentCountActionVariableDescription": "指定された条件と一致したログエントリ数", "xpack.infra.logs.alerting.threshold.fired": "実行", "xpack.infra.logs.analysis.analyzeInMlButtonLabel": "ML で分析", - "xpack.infra.logs.analysis.anomaliesExpandedRowNumberOfLogEntriesDescription": "ログエントリーの数です", "xpack.infra.logs.analysis.anomaliesSectionLineSeriesName": "15 分ごとのログエントリー (平均)", "xpack.infra.logs.analysis.anomaliesSectionLoadingAriaLabel": "異常を読み込み中", "xpack.infra.logs.analysis.anomaliesSectionTitle": "異常", - "xpack.infra.logs.analysis.anomaliesTableMaxAnomalyScoreColumnName": "最高異常スコア", - "xpack.infra.logs.analysis.anomaliesTablePartitionColumnName": "パーティション", "xpack.infra.logs.analysis.anomalySectionNoAnomaliesTitle": "異常が検出されませんでした。", "xpack.infra.logs.analysis.anomalySectionNoDataBody": "時間範囲を調整する必要があるかもしれません。", "xpack.infra.logs.analysis.anomalySectionNoDataTitle": "表示するデータがありません。", @@ -7505,9 +7502,6 @@ "xpack.infra.logs.analysis.mlUnavailableTitle": "この機能には機械学習が必要です", "xpack.infra.logs.analysis.onboardingSuccessContent": "機械学習ロボットがデータの収集を開始するまでしばらくお待ちください。", "xpack.infra.logs.analysis.onboardingSuccessTitle": "成功!", - "xpack.infra.logs.analysis.overallAnomaliesNumberOfLogEntriesDescription": "ログエントリーの数です", - "xpack.infra.logs.analysis.overallAnomaliesTopAnomalyScoreDescription": "最高異常スコア", - "xpack.infra.logs.analysis.overallAnomalyChartMaxScoresLabel": "最高異常スコア", "xpack.infra.logs.analysis.partitionMaxAnomalyScoreAnnotationLabel": "最高異常スコア: {maxAnomalyScore}", "xpack.infra.logs.analysis.recreateJobButtonLabel": "ML ジョブを再作成", "xpack.infra.logs.analysis.setupStatusTryAgainButton": "再試行", @@ -7552,10 +7546,6 @@ "xpack.infra.logs.logEntryCategories.countColumnTitle": "メッセージ数", "xpack.infra.logs.logEntryCategories.datasetColumnTitle": "データセット", "xpack.infra.logs.logEntryCategories.datasetFilterPlaceholder": "データセットでフィルター", - "xpack.infra.logs.logEntryCategories.exampleEmptyDescription": "選択した時間範囲内に例は見つかりませんでした。ログエントリー保持期間を長くするとメッセージサンプルの可用性が向上します。", - "xpack.infra.logs.logEntryCategories.exampleEmptyReloadButtonLabel": "再読み込み", - "xpack.infra.logs.logEntryCategories.exampleLoadingFailureDescription": "カテゴリーの例を読み込めませんでした。", - "xpack.infra.logs.logEntryCategories.exampleLoadingFailureRetryButtonLabel": "再試行", "xpack.infra.logs.logEntryCategories.jobStatusLoadingMessage": "分類ジョブのステータスを確認中...", "xpack.infra.logs.logEntryCategories.loadDataErrorTitle": "カテゴリーデータを読み込めませんでした", "xpack.infra.logs.logEntryCategories.manyCategoriesWarningReasonDescription": "分析されたドキュメントごとのカテゴリ比率が{categoriesDocumentRatio, number }で、非常に高い値です。", diff --git a/x-pack/plugins/translations/translations/zh-CN.json b/x-pack/plugins/translations/translations/zh-CN.json index 7aebb59e0f8a..ca065c952363 100644 --- a/x-pack/plugins/translations/translations/zh-CN.json +++ b/x-pack/plugins/translations/translations/zh-CN.json @@ -7477,12 +7477,9 @@ "xpack.infra.logs.alerting.threshold.documentCountActionVariableDescription": "匹配所提供条件的日志条目数", "xpack.infra.logs.alerting.threshold.fired": "已触发", "xpack.infra.logs.analysis.analyzeInMlButtonLabel": "在 ML 中分析", - "xpack.infra.logs.analysis.anomaliesExpandedRowNumberOfLogEntriesDescription": "日志条目数", "xpack.infra.logs.analysis.anomaliesSectionLineSeriesName": "每 15 分钟日志条目数(平均值)", "xpack.infra.logs.analysis.anomaliesSectionLoadingAriaLabel": "正在加载异常", "xpack.infra.logs.analysis.anomaliesSectionTitle": "异常", - "xpack.infra.logs.analysis.anomaliesTableMaxAnomalyScoreColumnName": "最大异常分数", - "xpack.infra.logs.analysis.anomaliesTablePartitionColumnName": "分区", "xpack.infra.logs.analysis.anomalySectionNoAnomaliesTitle": "未检测到任何异常。", "xpack.infra.logs.analysis.anomalySectionNoDataBody": "您可能想调整时间范围。", "xpack.infra.logs.analysis.anomalySectionNoDataTitle": "没有可显示的数据。", @@ -7509,9 +7506,6 @@ "xpack.infra.logs.analysis.mlUnavailableTitle": "此功能需要 Machine Learning", "xpack.infra.logs.analysis.onboardingSuccessContent": "请注意,我们的 Machine Learning 机器人若干分钟后才会开始收集数据。", "xpack.infra.logs.analysis.onboardingSuccessTitle": "成功!", - "xpack.infra.logs.analysis.overallAnomaliesNumberOfLogEntriesDescription": "日志条目数", - "xpack.infra.logs.analysis.overallAnomaliesTopAnomalyScoreDescription": "最大异常分数", - "xpack.infra.logs.analysis.overallAnomalyChartMaxScoresLabel": "最大异常分数:", "xpack.infra.logs.analysis.partitionMaxAnomalyScoreAnnotationLabel": "最大异常分数:{maxAnomalyScore}", "xpack.infra.logs.analysis.recreateJobButtonLabel": "重新创建 ML 作业", "xpack.infra.logs.analysis.setupStatusTryAgainButton": "重试", @@ -7556,10 +7550,6 @@ "xpack.infra.logs.logEntryCategories.countColumnTitle": "消息计数", "xpack.infra.logs.logEntryCategories.datasetColumnTitle": "数据集", "xpack.infra.logs.logEntryCategories.datasetFilterPlaceholder": "按数据集筛选", - "xpack.infra.logs.logEntryCategories.exampleEmptyDescription": "选定时间范围内未找到任何示例。增大日志条目保留期限以改善消息样例可用性。", - "xpack.infra.logs.logEntryCategories.exampleEmptyReloadButtonLabel": "重新加载", - "xpack.infra.logs.logEntryCategories.exampleLoadingFailureDescription": "无法加载类别示例。", - "xpack.infra.logs.logEntryCategories.exampleLoadingFailureRetryButtonLabel": "重试", "xpack.infra.logs.logEntryCategories.jobStatusLoadingMessage": "正在检查归类作业的状态......", "xpack.infra.logs.logEntryCategories.loadDataErrorTitle": "无法加载类别数据", "xpack.infra.logs.logEntryCategories.manyCategoriesWarningReasonDescription": "每个分析文档的类别比率非常高,达到 {categoriesDocumentRatio, number }。",