From a0921f2e8807612ca97ce4bbce9b6755ae865150 Mon Sep 17 00:00:00 2001 From: Ivana Huckova <30407135+ivanahuckova@users.noreply.github.com> Date: Tue, 24 Jan 2023 19:10:27 +0100 Subject: [PATCH] Explore: Implement logs sample in Explore (#61864) * Implement log samples * Explore: Implement logs sample panel * Log samples: Add documentation * Update docs * Add info for log sample * Fix label * Update * Default to true * Fix copy in test * Update public/app/features/explore/LogsSamplePanel.tsx Co-authored-by: Sven Grossmann * Use timeZone from grafana/schema * Rename data props to queryResponse * Unify name to logs sample * Remove redundant optional parameters in LogsSamplePanel * Make intervalMs parameter optional in dataFrameToLogsModel and remove undefined argument when not needed * Fix incorrect position of copy log line button * Update public/app/core/logsModel.ts Co-authored-by: Sven Grossmann Co-authored-by: Sven Grossmann --- docs/sources/explore/logs-integration.md | 4 + public/app/core/logsModel.ts | 8 +- public/app/features/explore/Explore.test.tsx | 3 + public/app/features/explore/Explore.tsx | 38 ++++++- .../app/features/explore/LogsSample.test.tsx | 103 ++++++++++++++++++ .../app/features/explore/LogsSamplePanel.tsx | 67 ++++++++++++ .../app/features/explore/LogsVolumePanel.tsx | 37 +------ .../explore/SupplementaryResultError.test.tsx | 30 +++++ .../explore/SupplementaryResultError.tsx | 36 ++++++ .../app/features/explore/state/query.test.ts | 2 +- .../explore/utils/supplementaryQueries.ts | 3 +- .../app/features/inspector/InspectDataTab.tsx | 2 +- .../logs/components/LogRowMessage.tsx | 6 +- .../app/plugins/datasource/loki/datasource.ts | 2 +- 14 files changed, 296 insertions(+), 45 deletions(-) create mode 100644 public/app/features/explore/LogsSample.test.tsx create mode 100644 public/app/features/explore/LogsSamplePanel.tsx create mode 100644 public/app/features/explore/SupplementaryResultError.test.tsx create mode 100644 public/app/features/explore/SupplementaryResultError.tsx diff --git a/docs/sources/explore/logs-integration.md b/docs/sources/explore/logs-integration.md index 323bbc312cc..c40fb5642a8 100644 --- a/docs/sources/explore/logs-integration.md +++ b/docs/sources/explore/logs-integration.md @@ -141,6 +141,10 @@ after switching to the Logs data source, the query changes to: This will return a chunk of logs in the selected time range that can be grepped/text searched. +### Logs sample + +If the selected data source implements logs sample, and supports both log and metric queries, then for metric queries you will be able to automatically see samples of log lines that contributed to visualized metrics. This feature is currently supported by Loki data sources. + #### Live tailing Use the Live tailing feature to see real-time logs on supported data sources. diff --git a/public/app/core/logsModel.ts b/public/app/core/logsModel.ts index 0f27b512b46..c3ea25df244 100644 --- a/public/app/core/logsModel.ts +++ b/public/app/core/logsModel.ts @@ -198,11 +198,13 @@ function isLogsData(series: DataFrame) { * Convert dataFrame into LogsModel which consists of creating separate array of log rows and metrics series. Metrics * series can be either already included in the dataFrame or will be computed from the log rows. * @param dataFrame - * @param intervalMs In case there are no metrics series, we use this for computing it from log rows. + * @param intervalMs Optional. In case there are no metrics series, we use this for computing it from log rows. + * @param absoluteRange Optional. Used to store absolute range of executed queries in logs model. This is used for pagination. + * @param queries Optional. Used to store executed queries in logs model. This is used for pagination. */ export function dataFrameToLogsModel( dataFrame: DataFrame[], - intervalMs: number | undefined, + intervalMs?: number, absoluteRange?: AbsoluteTimeRange, queries?: DataQuery[] ): LogsModel { @@ -748,7 +750,7 @@ export function queryLogsVolume( datasource: DataSourceApi, diff --git a/public/app/features/explore/Explore.test.tsx b/public/app/features/explore/Explore.test.tsx index baf7bcec799..528fbff1d53 100644 --- a/public/app/features/explore/Explore.test.tsx +++ b/public/app/features/explore/Explore.test.tsx @@ -87,6 +87,9 @@ const dummyProps: Props = { isFromCompactUrl: false, eventBus: new EventBusSrv(), showRawPrometheus: false, + showLogsSample: false, + logsSample: { enabled: false }, + setSupplementaryQueryEnabled: jest.fn(), }; jest.mock('@grafana/runtime/src/services/dataSourceSrv', () => { diff --git a/public/app/features/explore/Explore.tsx b/public/app/features/explore/Explore.tsx index 7c5df5d230b..82bf6d498a0 100644 --- a/public/app/features/explore/Explore.tsx +++ b/public/app/features/explore/Explore.tsx @@ -15,6 +15,7 @@ import { RawTimeRange, EventBus, SplitOpenOptions, + SupplementaryQueryType, } from '@grafana/data'; import { selectors } from '@grafana/e2e-selectors'; import { config, getDataSourceSrv, reportInteraction } from '@grafana/runtime'; @@ -44,6 +45,7 @@ import { ExploreToolbar } from './ExploreToolbar'; import { FlameGraphExploreContainer } from './FlameGraphExploreContainer'; import { GraphContainer } from './Graph/GraphContainer'; import LogsContainer from './LogsContainer'; +import { LogsSamplePanel } from './LogsSamplePanel'; import { NoData } from './NoData'; import { NoDataSourceCallToAction } from './NoDataSourceCallToAction'; import { NodeGraphContainer } from './NodeGraphContainer'; @@ -56,7 +58,14 @@ import TableContainer from './TableContainer'; import { TraceViewContainer } from './TraceView/TraceViewContainer'; import { changeSize } from './state/explorePane'; import { splitOpen } from './state/main'; -import { addQueryRow, modifyQueries, scanStart, scanStopAction, setQueries } from './state/query'; +import { + addQueryRow, + modifyQueries, + scanStart, + scanStopAction, + setQueries, + setSupplementaryQueryEnabled, +} from './state/query'; import { isSplit } from './state/selectors'; import { makeAbsoluteTime, updateTimeRange } from './state/time'; @@ -357,6 +366,22 @@ export class Explore extends React.PureComponent { ); } + renderLogsSamplePanel() { + const { logsSample, timeZone, setSupplementaryQueryEnabled, exploreId, datasourceInstance } = this.props; + + return ( + + setSupplementaryQueryEnabled(exploreId, enabled, SupplementaryQueryType.LogsSample) + } + /> + ); + } + renderNodeGraphPanel() { const { exploreId, showTrace, queryResponse, datasourceInstance } = this.props; const datasourceType = datasourceInstance ? datasourceInstance?.type : 'unknown'; @@ -416,6 +441,7 @@ export class Explore extends React.PureComponent { showFlameGraph, timeZone, isFromCompactUrl, + showLogsSample, } = this.props; const { openDrawer } = this.state; const styles = getStyles(theme); @@ -486,6 +512,7 @@ export class Explore extends React.PureComponent { {this.renderFlameGraphPanel()} )} {showTrace && {this.renderTraceViewPanel()}} + {showLogsSample && {this.renderLogsSamplePanel()}} {showNoData && {this.renderNoData()}} )} @@ -528,6 +555,7 @@ function mapStateToProps(state: StoreState, { exploreId }: ExploreProps) { queries, isLive, graphResult, + tableResult, logsResult, showLogs, showMetrics, @@ -540,8 +568,13 @@ function mapStateToProps(state: StoreState, { exploreId }: ExploreProps) { loading, isFromCompactUrl, showRawPrometheus, + supplementaryQueries, } = item; + const logsSample = supplementaryQueries[SupplementaryQueryType.LogsSample]; + // We want to show logs sample only if there are no log results and if there is already graph or table result + const showLogsSample = !!(logsSample.dataProvider !== undefined && !logsResult && (graphResult || tableResult)); + return { datasourceInstance, datasourceMissing, @@ -564,6 +597,8 @@ function mapStateToProps(state: StoreState, { exploreId }: ExploreProps) { splitted: isSplit(state), loading, isFromCompactUrl: isFromCompactUrl || false, + logsSample, + showLogsSample, }; } @@ -577,6 +612,7 @@ const mapDispatchToProps = { makeAbsoluteTime, addQueryRow, splitOpen, + setSupplementaryQueryEnabled, }; const connector = connect(mapStateToProps, mapDispatchToProps); diff --git a/public/app/features/explore/LogsSample.test.tsx b/public/app/features/explore/LogsSample.test.tsx new file mode 100644 index 00000000000..967457fdb86 --- /dev/null +++ b/public/app/features/explore/LogsSample.test.tsx @@ -0,0 +1,103 @@ +import { render, screen } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import React, { ComponentProps } from 'react'; + +import { ArrayVector, FieldType, LoadingState, MutableDataFrame } from '@grafana/data'; + +import { LogsSamplePanel } from './LogsSamplePanel'; + +jest.mock('@grafana/runtime', () => { + return { + ...jest.requireActual('@grafana/runtime'), + reportInteraction: jest.fn(), + }; +}); + +const createProps = (propOverrides?: Partial>) => { + const props = { + queryResponse: undefined, + enabled: true, + timeZone: 'timeZone', + datasourceInstance: undefined, + setLogsSampleEnabled: jest.fn(), + }; + + return { ...props, ...propOverrides }; +}; + +const sampleDataFrame = new MutableDataFrame({ + meta: { + custom: { frameType: 'LabeledTimeValues' }, + }, + fields: [ + { + name: 'labels', + type: FieldType.other, + values: new ArrayVector([ + { place: 'luna', source: 'data' }, + { place: 'luna', source: 'data' }, + ]), + }, + { + name: 'Time', + type: FieldType.time, + values: new ArrayVector(['2022-02-22T09:28:11.352440161Z', '2022-02-22T14:42:50.991981292Z']), + }, + { + name: 'Line', + type: FieldType.string, + values: new ArrayVector(['line1 ', 'line2']), + }, + ], +}); + +describe('LogsSamplePanel', () => { + it('shows empty panel if no data', () => { + render(); + expect(screen.getByText('Logs sample')).toBeInTheDocument(); + }); + + it('shows loading message', () => { + render(); + expect(screen.getByText('Logs sample is loading...')).toBeInTheDocument(); + }); + + it('shows no data message', () => { + render(); + expect(screen.getByText('No logs sample data.')).toBeInTheDocument(); + }); + + it('shows logs sample data', () => { + render( + + ); + expect(screen.getByText('2022-02-22 04:28:11')).toBeInTheDocument(); + expect(screen.getByText('line1')).toBeInTheDocument(); + expect(screen.getByText('2022-02-22 09:42:50')).toBeInTheDocument(); + expect(screen.getByText('line2')).toBeInTheDocument(); + }); + + it('shows log details', async () => { + render( + + ); + const line = screen.getByText('line1'); + expect(screen.queryByText('foo')).not.toBeInTheDocument(); + await userEvent.click(line); + expect(await screen.findByText('Fields')).toBeInTheDocument(); + expect(await screen.findByText('place')).toBeInTheDocument(); + expect(await screen.findByText('luna')).toBeInTheDocument(); + }); + + it('shows warning message', () => { + render( + + ); + expect(screen.getByText('Failed to load logs sample for this query')).toBeInTheDocument(); + expect(screen.getByText('Test error message')).toBeInTheDocument(); + }); +}); diff --git a/public/app/features/explore/LogsSamplePanel.tsx b/public/app/features/explore/LogsSamplePanel.tsx new file mode 100644 index 00000000000..72dbc4d8bc0 --- /dev/null +++ b/public/app/features/explore/LogsSamplePanel.tsx @@ -0,0 +1,67 @@ +import React from 'react'; + +import { DataQueryResponse, DataSourceApi, LoadingState, LogsDedupStrategy } from '@grafana/data'; +import { reportInteraction } from '@grafana/runtime'; +import { TimeZone } from '@grafana/schema'; +import { Collapse } from '@grafana/ui'; +import { dataFrameToLogsModel } from 'app/core/logsModel'; +import store from 'app/core/store'; + +import { LogRows } from '../logs/components/LogRows'; + +import { SupplementaryResultError } from './SupplementaryResultError'; +import { SETTINGS_KEYS } from './utils/logs'; + +type Props = { + queryResponse: DataQueryResponse | undefined; + enabled: boolean; + timeZone: TimeZone; + datasourceInstance: DataSourceApi | null | undefined; + setLogsSampleEnabled: (enabled: boolean) => void; +}; + +export function LogsSamplePanel(props: Props) { + const { queryResponse, timeZone, enabled, setLogsSampleEnabled, datasourceInstance } = props; + + const onToggleLogsSampleCollapse = (isOpen: boolean) => { + setLogsSampleEnabled(isOpen); + reportInteraction('grafana_explore_logs_sample_toggle_clicked', { + datasourceType: datasourceInstance?.type ?? 'unknown', + type: isOpen ? 'open' : 'close', + }); + }; + + let LogsSamplePanelContent: JSX.Element | null; + + if (queryResponse === undefined) { + LogsSamplePanelContent = null; + } else if (queryResponse.error !== undefined) { + LogsSamplePanelContent = ( + + ); + } else if (queryResponse.state === LoadingState.Loading) { + LogsSamplePanelContent = Logs sample is loading...; + } else if (queryResponse.data.length === 0 || queryResponse.data[0].length === 0) { + LogsSamplePanelContent = No logs sample data.; + } else { + const logs = dataFrameToLogsModel(queryResponse.data); + LogsSamplePanelContent = ( + + ); + } + + return ( + + {LogsSamplePanelContent} + + ); +} diff --git a/public/app/features/explore/LogsVolumePanel.tsx b/public/app/features/explore/LogsVolumePanel.tsx index eecda2d8807..d5c3423ea75 100644 --- a/public/app/features/explore/LogsVolumePanel.tsx +++ b/public/app/features/explore/LogsVolumePanel.tsx @@ -1,9 +1,8 @@ import { css } from '@emotion/css'; -import React, { useState } from 'react'; +import React from 'react'; import { AbsoluteTimeRange, - DataQueryError, DataQueryResponse, GrafanaTheme2, LoadingState, @@ -11,9 +10,10 @@ import { TimeZone, EventBus, } from '@grafana/data'; -import { Alert, Button, Collapse, InlineField, TooltipDisplayMode, useStyles2, useTheme2 } from '@grafana/ui'; +import { Button, Collapse, InlineField, TooltipDisplayMode, useStyles2, useTheme2 } from '@grafana/ui'; import { ExploreGraph } from './Graph/ExploreGraph'; +import { SupplementaryResultError } from './SupplementaryResultError'; type Props = { logsVolumeData: DataQueryResponse | undefined; @@ -29,35 +29,6 @@ type Props = { eventBus: EventBus; }; -const SHORT_ERROR_MESSAGE_LIMIT = 100; - -function ErrorAlert(props: { error: DataQueryError }) { - const [isOpen, setIsOpen] = useState(false); - // generic get-error-message-logic, taken from - // /public/app/features/explore/ErrorContainer.tsx - const message = props.error.message || props.error.data?.message || ''; - - const showButton = !isOpen && message.length > SHORT_ERROR_MESSAGE_LIMIT; - - return ( - - {showButton ? ( - - ) : ( - message - )} - - ); -} - function createVisualisationData( logLinesBased: DataQueryResponse | undefined, logLinesBasedVisibleRange: AbsoluteTimeRange | undefined, @@ -110,7 +81,7 @@ export function LogsVolumePanel(props: Props) { const { logsVolumeData, fullRangeData, range } = data; if (logsVolumeData.error !== undefined) { - return ; + return ; } let LogsVolumePanelContent; diff --git a/public/app/features/explore/SupplementaryResultError.test.tsx b/public/app/features/explore/SupplementaryResultError.test.tsx new file mode 100644 index 00000000000..c735d1b9818 --- /dev/null +++ b/public/app/features/explore/SupplementaryResultError.test.tsx @@ -0,0 +1,30 @@ +import { render, screen } from '@testing-library/react'; +import React from 'react'; + +import { SupplementaryResultError } from './SupplementaryResultError'; + +describe('SupplementaryResultError', () => { + it('shows short warning message', () => { + const error = { data: { message: 'Test error message' } }; + const title = 'Error loading supplementary query'; + + render(); + expect(screen.getByText(title)).toBeInTheDocument(); + expect(screen.getByText(error.data.message)).toBeInTheDocument(); + }); + + it('shows long warning message', () => { + // we make a long message + const messagePart = 'One two three four five six seven eight nine ten.'; + const message = messagePart.repeat(3); + const error = { data: { message } }; + const title = 'Error loading supplementary query'; + + render(); + expect(screen.getByText(title)).toBeInTheDocument(); + expect(screen.queryByText(message)).not.toBeInTheDocument(); + const button = screen.getByText('Show details'); + button.click(); + expect(screen.getByText(message)).toBeInTheDocument(); + }); +}); diff --git a/public/app/features/explore/SupplementaryResultError.tsx b/public/app/features/explore/SupplementaryResultError.tsx new file mode 100644 index 00000000000..237c2072db5 --- /dev/null +++ b/public/app/features/explore/SupplementaryResultError.tsx @@ -0,0 +1,36 @@ +import React, { useState } from 'react'; + +import { DataQueryError } from '@grafana/data'; +import { Alert, Button } from '@grafana/ui'; + +type Props = { + error: DataQueryError; + title: string; +}; +export function SupplementaryResultError(props: Props) { + const [isOpen, setIsOpen] = useState(false); + const SHORT_ERROR_MESSAGE_LIMIT = 100; + const { error, title } = props; + // generic get-error-message-logic, taken from + // /public/app/features/explore/ErrorContainer.tsx + const message = error.message || error.data?.message || ''; + const showButton = !isOpen && message.length > SHORT_ERROR_MESSAGE_LIMIT; + + return ( + + {showButton ? ( + + ) : ( + message + )} + + ); +} diff --git a/public/app/features/explore/state/query.test.ts b/public/app/features/explore/state/query.test.ts index 5e039079985..bfb5223a52b 100644 --- a/public/app/features/explore/state/query.test.ts +++ b/public/app/features/explore/state/query.test.ts @@ -517,7 +517,7 @@ describe('reducer', () => { mockDataProvider = () => { return of({ state: LoadingState.Done, error: undefined, data: [{}] }); }; - // turn logs volume off (but keep log sample on) + // turn logs volume off (but keep logs sample on) dispatch(setSupplementaryQueryEnabled(ExploreId.left, false, SupplementaryQueryType.LogsVolume)); expect(getState().explore[ExploreId.left].supplementaryQueries[SupplementaryQueryType.LogsVolume].enabled).toBe( false diff --git a/public/app/features/explore/utils/supplementaryQueries.ts b/public/app/features/explore/utils/supplementaryQueries.ts index 3847d6775f1..cef393d2401 100644 --- a/public/app/features/explore/utils/supplementaryQueries.ts +++ b/public/app/features/explore/utils/supplementaryQueries.ts @@ -17,8 +17,7 @@ export const loadSupplementaryQueries = (): SupplementaryQueries => { // We default to true for all supp queries let supplementaryQueries: SupplementaryQueries = { [SupplementaryQueryType.LogsVolume]: { enabled: true }, - // This is set to false temporarily, until we have UI to display logs sample and a way how to enable/disable it - [SupplementaryQueryType.LogsSample]: { enabled: false }, + [SupplementaryQueryType.LogsSample]: { enabled: true }, }; for (const type of supplementaryQueryTypes) { diff --git a/public/app/features/inspector/InspectDataTab.tsx b/public/app/features/inspector/InspectDataTab.tsx index df9239a76fa..b615a008614 100644 --- a/public/app/features/inspector/InspectDataTab.tsx +++ b/public/app/features/inspector/InspectDataTab.tsx @@ -108,7 +108,7 @@ export class InspectDataTab extends PureComponent { area: 'inspector', }); - const logsModel = dataFrameToLogsModel(data || [], undefined); + const logsModel = dataFrameToLogsModel(data || []); downloadLogsModelAsTxt(logsModel, panel ? panel.getDisplayTitle() : 'Explore'); }; diff --git a/public/app/features/logs/components/LogRowMessage.tsx b/public/app/features/logs/components/LogRowMessage.tsx index 37dffc7228b..e0fd1d69fb7 100644 --- a/public/app/features/logs/components/LogRowMessage.tsx +++ b/public/app/features/logs/components/LogRowMessage.tsx @@ -32,7 +32,7 @@ interface Props extends Themeable2 { logsSortOrder?: LogsSortOrder | null; } -const getStyles = (theme: GrafanaTheme2, showContextButton: boolean, isInDashboard: boolean | undefined) => { +const getStyles = (theme: GrafanaTheme2, showContextButton: boolean, isInExplore: boolean) => { const outlineColor = tinycolor(theme.components.dashboard.background).setAlpha(0.7).toRgbString(); return { @@ -74,7 +74,7 @@ const getStyles = (theme: GrafanaTheme2, showContextButton: boolean, isInDashboa `, logRowMenuCell: css` position: absolute; - right: ${isInDashboard ? '40px' : `calc(75px + ${theme.spacing()} + ${showContextButton ? '80px' : '40px'})`}; + right: ${!isInExplore ? '40px' : `calc(75px + ${theme.spacing()} + ${showContextButton ? '80px' : '40px'})`}; margin-top: -${theme.spacing(0.125)}; `, logLine: css` @@ -169,7 +169,7 @@ class UnThemedLogRowMessage extends PureComponent { const { hasAnsi, raw } = row; const restructuredEntry = restructureLog(raw, prettifyLogMessage); const shouldShowContextToggle = showContextToggle ? showContextToggle(row) : false; - const styles = getStyles(theme, shouldShowContextToggle, app === CoreApp.Dashboard); + const styles = getStyles(theme, shouldShowContextToggle, app === CoreApp.Explore); return ( <> diff --git a/public/app/plugins/datasource/loki/datasource.ts b/public/app/plugins/datasource/loki/datasource.ts index 7713d39b36b..a88ef2a63d1 100644 --- a/public/app/plugins/datasource/loki/datasource.ts +++ b/public/app/plugins/datasource/loki/datasource.ts @@ -471,7 +471,7 @@ export class LokiDatasource } async getDataSamples(query: LokiQuery): Promise { - // Currently works only for log samples + // Currently works only for logs sample if (!isValidQuery(query.expr) || !isLogsQuery(query.expr)) { return []; }