diff --git a/grafana-mixin/dashboards/grafana-overview.json b/grafana-mixin/dashboards/grafana-overview.json index 0d28955f46e..784d001297b 100644 --- a/grafana-mixin/dashboards/grafana-overview.json +++ b/grafana-mixin/dashboards/grafana-overview.json @@ -64,9 +64,7 @@ "orientation": "auto", "percentChangeColorMode": "standard", "reduceOptions": { - "calcs": [ - "mean" - ], + "calcs": ["mean"], "fields": "", "values": false }, @@ -127,9 +125,7 @@ "orientation": "auto", "percentChangeColorMode": "standard", "reduceOptions": { - "calcs": [ - "mean" - ], + "calcs": ["mean"], "fields": "", "values": false }, @@ -193,9 +189,7 @@ "footer": { "countRows": false, "fields": "", - "reducer": [ - "sum" - ], + "reducer": ["sum"], "show": false }, "showHeader": true @@ -493,9 +487,7 @@ "allValue": ".*", "current": { "text": "All", - "value": [ - "$__all" - ] + "value": ["$__all"] }, "datasource": "$datasource", "definition": "label_values(grafana_build_info, job)", @@ -538,17 +530,7 @@ "to": "now" }, "timepicker": { - "refresh_intervals": [ - "10s", - "30s", - "1m", - "5m", - "15m", - "30m", - "1h", - "2h", - "1d" - ] + "refresh_intervals": ["10s", "30s", "1m", "5m", "15m", "30m", "1h", "2h", "1d"] }, "timezone": "utc", "title": "Grafana Overview", diff --git a/public/app/features/logs/components/panel/InfiniteScroll.tsx b/public/app/features/logs/components/panel/InfiniteScroll.tsx index ef78316b69f..32017369eec 100644 --- a/public/app/features/logs/components/panel/InfiniteScroll.tsx +++ b/public/app/features/logs/components/panel/InfiniteScroll.tsx @@ -216,6 +216,8 @@ export const InfiniteScroll = ({ showTime={showTime} style={style} styles={styles} + timeRange={timeRange} + timeZone={timeZone} variant={getLogLineVariant(logs, index, lastLogOfPage.current)} virtualization={virtualization} wrapLogMessage={wrapLogMessage} @@ -233,6 +235,8 @@ export const InfiniteScroll = ({ showTime, sortOrder, styles, + timeRange, + timeZone, virtualization, wrapLogMessage, ] diff --git a/public/app/features/logs/components/panel/LogLine.test.tsx b/public/app/features/logs/components/panel/LogLine.test.tsx index 415163d0aa6..6ea14b9862c 100644 --- a/public/app/features/logs/components/panel/LogLine.test.tsx +++ b/public/app/features/logs/components/panel/LogLine.test.tsx @@ -1,7 +1,7 @@ import { render, screen } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; -import { CoreApp, createTheme, LogsDedupStrategy, LogsSortOrder } from '@grafana/data'; +import { CoreApp, createTheme, getDefaultTimeRange, LogsDedupStrategy, LogsSortOrder } from '@grafana/data'; import { LOG_LINE_BODY_FIELD_NAME } from '../LogDetailsBody'; import { createLogLine } from '../mocks/logRow'; @@ -55,6 +55,8 @@ describe.each(fontSizes)('LogLine', (fontSize: LogListFontSize) => { showTime: true, style: {}, styles: styles, + timeRange: getDefaultTimeRange(), + timeZone: 'browser', wrapLogMessage: true, }; }); diff --git a/public/app/features/logs/components/panel/LogLine.tsx b/public/app/features/logs/components/panel/LogLine.tsx index 699f77e0709..723f9cd54a5 100644 --- a/public/app/features/logs/components/panel/LogLine.tsx +++ b/public/app/features/logs/components/panel/LogLine.tsx @@ -3,7 +3,7 @@ import { CSSProperties, memo, useCallback, useEffect, useMemo, useRef, useState, import Highlighter from 'react-highlight-words'; import tinycolor from 'tinycolor2'; -import { findHighlightChunksInText, GrafanaTheme2, LogsDedupStrategy } from '@grafana/data'; +import { findHighlightChunksInText, GrafanaTheme2, LogsDedupStrategy, TimeRange } from '@grafana/data'; import { t } from '@grafana/i18n'; import { Button, Icon, Tooltip } from '@grafana/ui'; @@ -31,6 +31,8 @@ export interface Props { showTime: boolean; style: CSSProperties; styles: LogLineStyles; + timeRange: TimeRange; + timeZone: string; onClick: (e: MouseEvent, log: LogListModel) => void; onOverflow?: (index: number, id: string, height?: number) => void; variant?: 'infinite-scroll'; @@ -48,6 +50,8 @@ export const LogLine = ({ onClick, onOverflow, showTime, + timeRange, + timeZone, variant, virtualization, wrapLogMessage, @@ -64,6 +68,8 @@ export const LogLine = ({ onClick={onClick} onOverflow={onOverflow} showTime={showTime} + timeRange={timeRange} + timeZone={timeZone} variant={variant} virtualization={virtualization} wrapLogMessage={wrapLogMessage} @@ -87,6 +93,8 @@ const LogLineComponent = memo( onClick, onOverflow, showTime, + timeRange, + timeZone, variant, virtualization, wrapLogMessage, @@ -244,7 +252,9 @@ const LogLineComponent = memo( )} - {detailsMode === 'inline' && detailsShown && } + {detailsMode === 'inline' && detailsShown && ( + + )} ); } diff --git a/public/app/features/logs/components/panel/LogLineDetails.test.tsx b/public/app/features/logs/components/panel/LogLineDetails.test.tsx index 112ea2531ba..c42481d85f9 100644 --- a/public/app/features/logs/components/panel/LogLineDetails.test.tsx +++ b/public/app/features/logs/components/panel/LogLineDetails.test.tsx @@ -1,5 +1,6 @@ import { render, screen } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; +import { of } from 'rxjs'; import { Field, @@ -13,8 +14,10 @@ import { LogsSortOrder, DataFrame, ScopedVars, + getDefaultTimeRange, } from '@grafana/data'; import { setPluginLinksHook } from '@grafana/runtime'; +import { createTempoDatasource } from 'app/plugins/datasource/tempo/test/mocks'; import { LOG_LINE_BODY_FIELD_NAME } from '../LogDetailsBody'; import { createLogLine } from '../mocks/logRow'; @@ -30,13 +33,25 @@ jest.mock('@grafana/assistant', () => { }; }); +const tempoDS = createTempoDatasource(); + jest.mock('@grafana/runtime', () => { return { ...jest.requireActual('@grafana/runtime'), usePluginLinks: jest.fn().mockReturnValue({ links: [] }), + getDataSourceSrv: () => ({ + get: (uid: string) => Promise.resolve(tempoDS), + }), }; }); jest.mock('./LogListContext'); +jest.mock('app/features/explore/TraceView/TraceView', () => ({ + TraceView: () =>
Trace view
, +})); + +afterAll(() => { + jest.unmock('app/features/explore/TraceView/TraceView'); +}); const setup = ( propOverrides?: Partial, @@ -50,6 +65,8 @@ const setup = ( focusLogLine: jest.fn(), logs, onResize: jest.fn(), + timeRange: getDefaultTimeRange(), + timeZone: 'browser', ...(propOverrides || {}), }; @@ -559,6 +576,8 @@ describe('LogLineDetails', () => { containerElement: document.createElement('div'), focusLogLine: jest.fn(), logs: [logs[0]], + timeRange: getDefaultTimeRange(), + timeZone: 'browser', onResize: jest.fn(), }; @@ -606,4 +625,121 @@ describe('LogLineDetails', () => { expect(screen.getAllByText('Second log')).toHaveLength(1); }); }); + + test('Requests and shows an embedded trace', async () => { + const entry = 'traceId=1234 msg="some message"'; + const dataFrame = toDataFrame({ + fields: [ + { name: 'timestamp', config: {}, type: FieldType.time, values: [1] }, + { name: 'entry', values: [entry] }, + // As we have traceId in message already this will shadow it. + { + name: 'traceId', + values: ['1234'], + config: { links: [{ title: 'link title', url: 'localhost:3210/${__value.text}' }] }, + }, + { name: 'userId', values: ['5678'] }, + ], + }); + const log = createLogLine( + { entry, dataFrame, entryFieldIndex: 0, rowIndex: 0 }, + { + escape: false, + order: LogsSortOrder.Descending, + timeZone: 'browser', + virtualization: undefined, + wrapLogMessage: true, + getFieldLinks: (field: Field, rowIndex: number, dataFrame: DataFrame, vars: ScopedVars) => { + if (field.config && field.config.links) { + return field.config.links.map((link) => { + return { + href: '/explore?left=%7B%22range%22%3A%7B%22from%22%3A%22now-15m%22%2C%22to%22%3A%22now%22%7D%2C%22datasource%22%3A%22fetpfiwe8asqoe%22%2C%22queries%22%3A%5B%7B%22query%22%3A%22abcd1234%22%2C%22queryType%22%3A%22traceql%22%7D%5D%7D', + title: 'tempo', + target: '_blank', + origin: field, + }; + }); + } + return []; + }, + } + ); + + jest.spyOn(tempoDS, 'query').mockReturnValueOnce( + of({ + data: [ + createDataFrame({ + fields: [ + { name: 'traceID', values: ['5d5d850e24d89509'], type: FieldType.string }, + { name: 'spanID', values: ['5d5d850e24d89509'], type: FieldType.string }, + ], + }), + ], + }) + ); + + setup({ logs: [log] }, undefined, { showDetails: [log] }); + + expect(screen.getByText('Links')).toBeInTheDocument(); + expect(screen.getByText('Trace')).toBeInTheDocument(); + + await userEvent.click(screen.getByText('Trace')); + + expect(screen.getByText('Trace view')).toBeInTheDocument(); + }); + + test('Shows a message if the trace cannot be retrieved', async () => { + const entry = 'traceId=1234 msg="some message"'; + const dataFrame = toDataFrame({ + fields: [ + { name: 'timestamp', config: {}, type: FieldType.time, values: [1] }, + { name: 'entry', values: [entry] }, + // As we have traceId in message already this will shadow it. + { + name: 'traceId', + values: ['1234'], + config: { links: [{ title: 'link title', url: 'localhost:3210/${__value.text}' }] }, + }, + { name: 'userId', values: ['5678'] }, + ], + }); + const log = createLogLine( + { entry, dataFrame, entryFieldIndex: 0, rowIndex: 0 }, + { + escape: false, + order: LogsSortOrder.Descending, + timeZone: 'browser', + virtualization: undefined, + wrapLogMessage: true, + getFieldLinks: (field: Field, rowIndex: number, dataFrame: DataFrame, vars: ScopedVars) => { + if (field.config && field.config.links) { + return field.config.links.map((link) => { + return { + href: '/explore?left=%7B%22range%22%3A%7B%22from%22%3A%22now-15m%22%2C%22to%22%3A%22now%22%7D%2C%22datasource%22%3A%22fetpfiwe8asqoe%22%2C%22queries%22%3A%5B%7B%22query%22%3A%22abcd1234%22%2C%22queryType%22%3A%22traceql%22%7D%5D%7D', + title: 'tempo', + target: '_blank', + origin: field, + }; + }); + } + return []; + }, + } + ); + + jest.spyOn(tempoDS, 'query').mockReturnValueOnce( + of({ + data: [], + }) + ); + + setup({ logs: [log] }, undefined, { showDetails: [log] }); + + expect(screen.getByText('Links')).toBeInTheDocument(); + expect(screen.getByText('Trace')).toBeInTheDocument(); + + await userEvent.click(screen.getByText('Trace')); + + expect(screen.getByText('Could not retrieve trace.')).toBeInTheDocument(); + }); }); diff --git a/public/app/features/logs/components/panel/LogLineDetails.tsx b/public/app/features/logs/components/panel/LogLineDetails.tsx index 72d827b1ebe..1a3290a262c 100644 --- a/public/app/features/logs/components/panel/LogLineDetails.tsx +++ b/public/app/features/logs/components/panel/LogLineDetails.tsx @@ -3,7 +3,7 @@ import { Resizable } from 're-resizable'; import { memo, useCallback, useEffect, useRef, useState } from 'react'; import { usePrevious } from 'react-use'; -import { GrafanaTheme2 } from '@grafana/data'; +import { GrafanaTheme2, TimeRange } from '@grafana/data'; import { t } from '@grafana/i18n'; import { reportInteraction } from '@grafana/runtime'; import { getDragStyles, Icon, Tab, TabsBar, useStyles2 } from '@grafana/ui'; @@ -17,12 +17,14 @@ export interface Props { containerElement: HTMLDivElement; focusLogLine: (log: LogListModel) => void; logs: LogListModel[]; + timeRange: TimeRange; + timeZone: string; onResize(): void; } export type LogLineDetailsMode = 'inline' | 'sidebar'; -export const LogLineDetails = memo(({ containerElement, focusLogLine, logs, onResize }: Props) => { +export const LogLineDetails = memo(({ containerElement, focusLogLine, logs, timeRange, timeZone, onResize }: Props) => { const { detailsWidth, noInteractions, setDetailsWidth } = useLogListContext(); const styles = useStyles2(getStyles, 'sidebar'); const dragStyles = useStyles2(getDragStyles); @@ -57,83 +59,93 @@ export const LogLineDetails = memo(({ containerElement, focusLogLine, logs, onRe maxWidth={maxWidth} >
- +
); }); LogLineDetails.displayName = 'LogLineDetails'; -const LogLineDetailsTabs = memo(({ focusLogLine, logs }: Pick) => { - const { app, closeDetails, noInteractions, showDetails, toggleDetails } = useLogListContext(); - const [currentLog, setCurrentLog] = useState(showDetails[0]); - const previousShowDetails = usePrevious(showDetails); - const styles = useStyles2(getStyles, 'sidebar'); +const LogLineDetailsTabs = memo( + ({ focusLogLine, logs, timeRange, timeZone }: Pick) => { + const { app, closeDetails, noInteractions, showDetails, toggleDetails } = useLogListContext(); + const [currentLog, setCurrentLog] = useState(showDetails[0]); + const previousShowDetails = usePrevious(showDetails); + const styles = useStyles2(getStyles, 'sidebar'); - useEffect(() => { - focusLogLine(currentLog); - if (!noInteractions) { - reportInteraction('logs_log_line_details_displayed', { - mode: 'sidebar', - app, - }); - } - // Once - // eslint-disable-next-line react-hooks/exhaustive-deps - }, []); + useEffect(() => { + focusLogLine(currentLog); + if (!noInteractions) { + reportInteraction('logs_log_line_details_displayed', { + mode: 'sidebar', + app, + }); + } + // Once + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); - useEffect(() => { - if (!showDetails.length) { - closeDetails(); - return; - } - // Focus on the recently open - if (!previousShowDetails || showDetails.length > previousShowDetails.length) { - setCurrentLog(showDetails[showDetails.length - 1]); - return; - } else if (!showDetails.find((log) => log.uid === currentLog.uid)) { - setCurrentLog(showDetails[showDetails.length - 1]); - } - }, [closeDetails, currentLog.uid, previousShowDetails, showDetails]); + useEffect(() => { + if (!showDetails.length) { + closeDetails(); + return; + } + // Focus on the recently open + if (!previousShowDetails || showDetails.length > previousShowDetails.length) { + setCurrentLog(showDetails[showDetails.length - 1]); + return; + } else if (!showDetails.find((log) => log.uid === currentLog.uid)) { + setCurrentLog(showDetails[showDetails.length - 1]); + } + }, [closeDetails, currentLog.uid, previousShowDetails, showDetails]); - return ( - <> - {showDetails.length > 1 && ( - - {showDetails.map((log) => { - return ( - setCurrentLog(log)} - suffix={() => ( - toggleDetails(log)} - /> - )} - /> - ); - })} - - )} -
- -
- - ); -}); + return ( + <> + {showDetails.length > 1 && ( + + {showDetails.map((log) => { + return ( + setCurrentLog(log)} + suffix={() => ( + toggleDetails(log)} + /> + )} + /> + ); + })} + + )} +
+ +
+ + ); + } +); LogLineDetailsTabs.displayName = 'LogLineDetailsTabs'; export interface InlineLogLineDetailsProps { log: LogListModel; logs: LogListModel[]; + timeRange: TimeRange; + timeZone: string; } -export const InlineLogLineDetails = memo(({ logs, log }: InlineLogLineDetailsProps) => { +export const InlineLogLineDetails = memo(({ logs, log, timeRange, timeZone }: InlineLogLineDetailsProps) => { const { app, detailsWidth, noInteractions } = useLogListContext(); const styles = useStyles2(getStyles, 'inline'); const scrollRef = useRef(null); @@ -162,7 +174,7 @@ export const InlineLogLineDetails = memo(({ logs, log }: InlineLogLineDetailsPro
- +
diff --git a/public/app/features/logs/components/panel/LogLineDetailsComponent.tsx b/public/app/features/logs/components/panel/LogLineDetailsComponent.tsx index fd8490f74cb..34f2f25ccb1 100644 --- a/public/app/features/logs/components/panel/LogLineDetailsComponent.tsx +++ b/public/app/features/logs/components/panel/LogLineDetailsComponent.tsx @@ -2,7 +2,7 @@ import { css } from '@emotion/css'; import { camelCase, groupBy } from 'lodash'; import { memo, startTransition, useCallback, useMemo, useRef, useState } from 'react'; -import { DataFrameType, GrafanaTheme2, store } from '@grafana/data'; +import { DataFrameType, GrafanaTheme2, store, TimeRange } from '@grafana/data'; import { t, Trans } from '@grafana/i18n'; import { reportInteraction } from '@grafana/runtime'; import { ControlledCollapse, useStyles2 } from '@grafana/ui'; @@ -15,135 +15,193 @@ import { LogLineDetailsDisplayedFields } from './LogLineDetailsDisplayedFields'; import { LabelWithLinks, LogLineDetailsFields, LogLineDetailsLabelFields } from './LogLineDetailsFields'; import { LogLineDetailsHeader } from './LogLineDetailsHeader'; import { LogLineDetailsLog } from './LogLineDetailsLog'; +import { LogLineDetailsTrace } from './LogLineDetailsTrace'; import { useLogListContext } from './LogListContext'; +import { getTempoTraceFromLinks } from './links'; import { LogListModel } from './processing'; interface LogLineDetailsComponentProps { focusLogLine?: (log: LogListModel) => void; log: LogListModel; logs: LogListModel[]; + timeRange: TimeRange; + timeZone: string; } -export const LogLineDetailsComponent = memo(({ focusLogLine, log, logs }: LogLineDetailsComponentProps) => { - const { displayedFields, noInteractions, logOptionsStorageKey, setDisplayedFields, syntaxHighlighting } = - useLogListContext(); - const [search, setSearch] = useState(''); - const inputRef = useRef(''); - const styles = useStyles2(getStyles); - const extensionLinks = useAttributesExtensionLinks(log); - const fieldsWithLinks = useMemo(() => { - const fieldsWithLinks = log.fields.filter((f) => f.links?.length); - const displayedFieldsWithLinks = fieldsWithLinks.filter((f) => f.fieldIndex !== log.entryFieldIndex).sort(); - const hiddenFieldsWithLinks = fieldsWithLinks.filter((f) => f.fieldIndex === log.entryFieldIndex).sort(); - const fieldsWithLinksFromVariableMap = createLogLineLinks(hiddenFieldsWithLinks); - return { - links: displayedFieldsWithLinks, - linksFromVariableMap: fieldsWithLinksFromVariableMap, - }; - }, [log.entryFieldIndex, log.fields]); - const fieldsWithoutLinks = - log.dataFrame.meta?.type === DataFrameType.LogLines - ? // for LogLines frames (dataplane) we don't want to show any additional fields besides already extracted labels and links - [] - : // for other frames, do not show the log message unless there is a link attached - log.fields.filter((f) => f.links?.length === 0 && f.fieldIndex !== log.entryFieldIndex).sort(); - const labelsWithLinks: LabelWithLinks[] = useMemo( - () => - Object.keys(log.labels) - .sort() - .map((label) => ({ - key: label, - value: log.labels[label], - link: extensionLinks?.[label], - })), - [extensionLinks, log.labels] - ); - const groupedLabels = useMemo( - () => groupBy(labelsWithLinks, (label) => getLabelTypeFromRow(label.key, log, true) ?? ''), - [labelsWithLinks, log] - ); - const labelGroups = useMemo(() => Object.keys(groupedLabels), [groupedLabels]); +export const LogLineDetailsComponent = memo( + ({ focusLogLine, log, logs, timeRange, timeZone }: LogLineDetailsComponentProps) => { + const { displayedFields, noInteractions, logOptionsStorageKey, setDisplayedFields, syntaxHighlighting } = + useLogListContext(); + const [search, setSearch] = useState(''); + const inputRef = useRef(''); + const styles = useStyles2(getStyles); - const logLineOpen = logOptionsStorageKey - ? store.getBool(`${logOptionsStorageKey}.log-details.logLineOpen`, false) - : false; - const linksOpen = logOptionsStorageKey ? store.getBool(`${logOptionsStorageKey}.log-details.linksOpen`, true) : true; - const fieldsOpen = logOptionsStorageKey - ? store.getBool(`${logOptionsStorageKey}.log-details.fieldsOpen`, true) - : true; - const displayedFieldsOpen = logOptionsStorageKey - ? store.getBool(`${logOptionsStorageKey}.log-details.displayedFieldsOpen`, false) - : false; + const extensionLinks = useAttributesExtensionLinks(log); - const handleToggle = useCallback( - (option: string, isOpen: boolean) => { - store.set(`${logOptionsStorageKey}.log-details.${option}`, isOpen); - if (!noInteractions) { - reportInteraction('logs_log_line_details_section_toggled', { - section: option.replace('Open', ''), - state: isOpen ? 'open' : 'closed', - }); - } - }, - [logOptionsStorageKey, noInteractions] - ); + const fieldsWithLinks = useMemo(() => { + const fieldsWithLinks = log.fields.filter((f) => f.links?.length); + const displayedFieldsWithLinks = fieldsWithLinks.filter((f) => f.fieldIndex !== log.entryFieldIndex).sort(); + const hiddenFieldsWithLinks = fieldsWithLinks.filter((f) => f.fieldIndex === log.entryFieldIndex).sort(); + const fieldsWithLinksFromVariableMap = createLogLineLinks(hiddenFieldsWithLinks); + return { + links: displayedFieldsWithLinks, + linksFromVariableMap: fieldsWithLinksFromVariableMap, + }; + }, [log.entryFieldIndex, log.fields]); - const handleSearch = useCallback((newSearch: string) => { - inputRef.current = newSearch; - startTransition(() => { - setSearch(inputRef.current); - }); - }, []); + const fieldsWithoutLinks = + log.dataFrame.meta?.type === DataFrameType.LogLines + ? // for LogLines frames (dataplane) we don't want to show any additional fields besides already extracted labels and links + [] + : // for other frames, do not show the log message unless there is a link attached + log.fields.filter((f) => f.links?.length === 0 && f.fieldIndex !== log.entryFieldIndex).sort(); - const noDetails = - !fieldsWithLinks.links.length && - !fieldsWithLinks.linksFromVariableMap.length && - !labelGroups.length && - !fieldsWithoutLinks.length; + const labelsWithLinks: LabelWithLinks[] = useMemo( + () => + Object.keys(log.labels) + .sort() + .map((label) => ({ + key: label, + value: log.labels[label], + link: extensionLinks?.[label], + })), + [extensionLinks, log.labels] + ); - return ( - <> - -
- handleToggle('logLineOpen', isOpen)} - > - - - {displayedFields.length > 0 && setDisplayedFields && ( - handleToggle('displayedFieldsOpen', isOpen)} - > - - - )} - {fieldsWithLinks.links.length > 0 && ( + const trace = useMemo(() => getTempoTraceFromLinks(fieldsWithLinks.links), [fieldsWithLinks.links]); + + const groupedLabels = useMemo( + () => groupBy(labelsWithLinks, (label) => getLabelTypeFromRow(label.key, log, true) ?? ''), + [labelsWithLinks, log] + ); + const labelGroups = useMemo(() => Object.keys(groupedLabels), [groupedLabels]); + + const logLineOpen = logOptionsStorageKey + ? store.getBool(`${logOptionsStorageKey}.log-details.logLineOpen`, false) + : false; + const linksOpen = logOptionsStorageKey + ? store.getBool(`${logOptionsStorageKey}.log-details.linksOpen`, true) + : true; + const fieldsOpen = logOptionsStorageKey + ? store.getBool(`${logOptionsStorageKey}.log-details.fieldsOpen`, true) + : true; + const displayedFieldsOpen = logOptionsStorageKey + ? store.getBool(`${logOptionsStorageKey}.log-details.displayedFieldsOpen`, false) + : false; + const traceOpen = logOptionsStorageKey + ? store.getBool(`${logOptionsStorageKey}.log-details.traceOpen`, false) + : false; + + const handleToggle = useCallback( + (option: string, isOpen: boolean) => { + store.set(`${logOptionsStorageKey}.log-details.${option}`, isOpen); + if (!noInteractions) { + reportInteraction('logs_log_line_details_section_toggled', { + section: option.replace('Open', ''), + state: isOpen ? 'open' : 'closed', + }); + } + }, + [logOptionsStorageKey, noInteractions] + ); + + const handleSearch = useCallback((newSearch: string) => { + inputRef.current = newSearch; + startTransition(() => { + setSearch(inputRef.current); + }); + }, []); + + const noDetails = + !fieldsWithLinks.links.length && + !fieldsWithLinks.linksFromVariableMap.length && + !labelGroups.length && + !fieldsWithoutLinks.length; + + return ( + <> + +
handleToggle('linksOpen', isOpen)} + isOpen={logLineOpen} + onToggle={(isOpen: boolean) => handleToggle('logLineOpen', isOpen)} > - - + - )} - {labelGroups.map((group) => - group === '' ? ( + {displayedFields.length > 0 && setDisplayedFields && ( + handleToggle('displayedFieldsOpen', isOpen)} + > + + + )} + {fieldsWithLinks.links.length > 0 && ( + handleToggle('linksOpen', isOpen)} + > + + + + )} + {trace && ( + handleToggle('traceOpen', isOpen)} + > + + + )} + {labelGroups.map((group) => + group === '' ? ( + handleToggle('fieldsOpen', isOpen)} + > + + + + ) : ( + handleToggle(groupOptionName(group), isOpen)} + > + + + ) + )} + {!labelGroups.length && fieldsWithoutLinks.length > 0 && ( handleToggle('fieldsOpen', isOpen)} > - - ) : ( - handleToggle(groupOptionName(group), isOpen)} - > - - - ) - )} - {!labelGroups.length && fieldsWithoutLinks.length > 0 && ( - handleToggle('fieldsOpen', isOpen)} - > - - - )} - {noDetails && No fields to display.} -
- - ); -}); + )} + {noDetails && No fields to display.} +
+ + ); + } +); LogLineDetailsComponent.displayName = 'LogLineDetailsComponent'; function groupOptionName(group: string) { diff --git a/public/app/features/logs/components/panel/LogLineDetailsTrace.tsx b/public/app/features/logs/components/panel/LogLineDetailsTrace.tsx new file mode 100644 index 00000000000..a9f80b841a4 --- /dev/null +++ b/public/app/features/logs/components/panel/LogLineDetailsTrace.tsx @@ -0,0 +1,113 @@ +import { css } from '@emotion/css'; +import { useEffect, useMemo, useState } from 'react'; +import { isObservable, lastValueFrom } from 'rxjs'; + +import { DataFrame, DataQueryRequest, DataSourceApi, GrafanaTheme2, TimeRange } from '@grafana/data'; +import { t } from '@grafana/i18n'; +import { getDataSourceSrv } from '@grafana/runtime'; +import { Icon, Spinner, Tooltip, useStyles2 } from '@grafana/ui'; +import { TraceView } from 'app/features/explore/TraceView/TraceView'; +import { transformDataFrames } from 'app/features/explore/TraceView/utils/transform'; +import { SearchTableType, TempoQuery } from 'app/plugins/datasource/tempo/dataquery.gen'; + +import { useLogListContext } from './LogListContext'; +import { EmbeddedInternalLink } from './links'; + +interface Props { + traceRef: EmbeddedInternalLink; + timeRange: TimeRange; + timeZone: string; +} + +export const LogLineDetailsTrace = ({ timeRange, timeZone, traceRef }: Props) => { + const [dataSource, setDataSource] = useState(null); + const [dataFrames, setDataFrames] = useState(undefined); + const { app } = useLogListContext(); + const styles = useStyles2(getStyles); + + useEffect(() => { + setDataSource(null); + getDataSourceSrv() + .get(traceRef.dsUID) + .then((dataSource) => { + if (dataSource) { + setDataSource(dataSource); + } else { + setDataFrames(null); + } + }); + }, [traceRef.dsUID]); + + useEffect(() => { + if (!dataSource) { + return; + } + setDataFrames(undefined); + const request: DataQueryRequest = { + app, + requestId: `log-details-trace-${traceRef.query}`, + targets: [ + { + query: traceRef.query, + queryType: 'traceql', + refId: `log-details-trace-${traceRef.query}`, + tableType: SearchTableType.Traces, + filters: [], + }, + ], + interval: '', + intervalMs: 0, + range: timeRange, + scopedVars: {}, + timezone: timeZone, + startTime: Date.now(), + }; + const query = dataSource.query(request); + if (isObservable(query)) { + lastValueFrom(query) + .then((response) => { + setDataFrames(response.data?.length ? response.data : null); + }) + .catch(() => { + setDataFrames(null); + }); + } + }, [app, dataSource, timeRange, timeZone, traceRef.query]); + + const traceProp = useMemo(() => (dataFrames?.length ? transformDataFrames(dataFrames[0]) : undefined), [dataFrames]); + + return ( +
+ {dataSource && Array.isArray(dataFrames) && traceProp && ( + + )} + {dataFrames === null && ( +
+ + + + {t('logs.log-line-details.trace.error-message', 'Could not retrieve trace.')} +
+ )} + {dataFrames === undefined && ( +
+ + {t('logs.log-line-details.trace.loading-message', 'Loading trace...')} +
+ )} +
+ ); +}; + +const getStyles = (theme: GrafanaTheme2) => ({ + message: css({ + display: 'flex', + gap: theme.spacing(1), + alignItems: 'center', + }), +}); diff --git a/public/app/features/logs/components/panel/LogList.tsx b/public/app/features/logs/components/panel/LogList.tsx index 27e0b1ab9d8..bf412711af8 100644 --- a/public/app/features/logs/components/panel/LogList.tsx +++ b/public/app/features/logs/components/panel/LogList.tsx @@ -411,6 +411,8 @@ const LogListComponent = ({ containerElement={containerElement} focusLogLine={focusLogLine} logs={filteredLogs} + timeRange={timeRange} + timeZone={timeZone} onResize={handleLogDetailsResize} /> )} diff --git a/public/app/features/logs/components/panel/links.test.ts b/public/app/features/logs/components/panel/links.test.ts new file mode 100644 index 00000000000..bb3da98fc43 --- /dev/null +++ b/public/app/features/logs/components/panel/links.test.ts @@ -0,0 +1,82 @@ +import { FieldType, getDefaultTimeRange, LogsSortOrder, toDataFrame } from '@grafana/data'; +import { contextSrv } from 'app/core/services/context_srv'; +import { getFieldLinksForExplore } from 'app/features/explore/utils/links'; +import { GetFieldLinksFn } from 'app/plugins/panel/logs/types'; + +import { createLogLine } from '../mocks/logRow'; + +import { getTempoTraceFromLinks } from './links'; +import { LogListModel } from './processing'; + +describe('getTempoTraceFromLinks', () => { + let log: LogListModel; + + beforeEach(() => { + jest.spyOn(contextSrv, 'hasAccessToExplore').mockReturnValue(true); + + const getFieldLinks: GetFieldLinksFn = (field, rowIndex, dataFrame, vars) => { + return getFieldLinksForExplore({ field, rowIndex, range: getDefaultTimeRange(), dataFrame, vars }); + }; + + log = createLogLine( + { + dataFrame: toDataFrame({ + refId: 'A', + fields: [ + { name: 'Time', type: FieldType.time, values: [1] }, + { + name: 'Line', + type: FieldType.string, + values: ['log message 1 traceid=2203801e0171aa8b'], + }, + { + name: 'labels', + type: FieldType.other, + values: [ + { level: 'warn', logger: 'interceptor' }, + { method: 'POST', status: '200' }, + { kind: 'Event', stage: 'ResponseComplete' }, + ], + }, + { + name: 'link', + type: FieldType.string, + config: { + links: [ + { + internal: { + datasourceName: 'tempo', + datasourceUid: 'test', + query: { + query: '${__value.raw}', + queryType: 'traceql', + }, + }, + title: '', + url: '', + }, + ], + }, + values: ['2203801e0171aa8b'], + }, + ], + }), + }, + { + escape: false, + getFieldLinks, + order: LogsSortOrder.Descending, + timeZone: 'browser', + wrapLogMessage: true, + } + ); + }); + + test('Gets the trace information from a link', () => { + expect(getTempoTraceFromLinks(log.fields)).toEqual({ + dsUID: 'test', + query: '2203801e0171aa8b', + queryType: 'traceql', + }); + }); +}); diff --git a/public/app/features/logs/components/panel/links.ts b/public/app/features/logs/components/panel/links.ts new file mode 100644 index 00000000000..8996d6d2125 --- /dev/null +++ b/public/app/features/logs/components/panel/links.ts @@ -0,0 +1,61 @@ +import { LinkModel } from '@grafana/data'; + +import { FieldDef } from '../logParser'; + +export function getTempoTraceFromLinks(fields: FieldDef[]) { + for (const field of fields) { + if (!field.links) { + continue; + } + for (const link of field.links) { + const trace = getTempoTraceFromLink(link); + if (trace) { + return trace; + } + } + } + return null; +} + +function getTempoTraceFromLink(link: LinkModel) { + const queryData = getDataSourceAndQueryFromLink(link); + if (!queryData || queryData.queryType !== 'traceql') { + return null; + } + return queryData; +} + +export type EmbeddedInternalLink = { + dsUID: string; + query: string; + queryType: string; +}; + +function getDataSourceAndQueryFromLink(link: LinkModel): EmbeddedInternalLink | null { + if (!link.href) { + return null; + } + const paramsStrings = link.href.split('?')[1]; + if (!paramsStrings) { + return null; + } + const params = Object.values(Object.fromEntries(new URLSearchParams(paramsStrings))); + try { + const parsed = JSON.parse(params[0]); + const dsUID: string = 'datasource' in parsed && parsed.datasource ? parsed.datasource.toString() : ''; + const query: string = + 'queries' in parsed && Array.isArray(parsed.queries) && 'query' in parsed.queries[0] && parsed.queries[0].query + ? parsed.queries[0].query.toString() + : ''; + const queryType = + 'queryType' in parsed.queries[0] && parsed.queries[0].queryType ? parsed.queries[0].queryType.toString() : ''; + return dsUID && query && queryType + ? { + dsUID, + query, + queryType, + } + : null; + } catch (e) {} + return null; +} diff --git a/public/locales/en-US/grafana.json b/public/locales/en-US/grafana.json index 5dde61069ec..3c86d003d9a 100644 --- a/public/locales/en-US/grafana.json +++ b/public/locales/en-US/grafana.json @@ -9537,6 +9537,12 @@ "show-context": "Show context", "show-log-line": "Show log line", "sidebar-mode": "Anchor to the right", + "trace": { + "error-message": "Could not retrieve trace.", + "error-tooltip": "The trace could have been sampled or be temporarily unavailable.", + "loading-message": "Loading trace..." + }, + "trace-section": "Trace", "unpin-line": "Unpin log" }, "log-line-menu": {