Log Details: Show embedded trace if available (#109926)

* LogLineDetails: get link details from href

* links: create module

* LogList: pass time zone and time range to Details

* LogLineDetailsTrace: create component

* InlineLogDetails: pass time range and time zone

* LogLineDetailsTrace: show loading and error messages

* Update tests

* LogLineDetailsTrace: update message styles

* Prettier

* Add test

* LogLineDetails: add embedded metric test

* Prettier

* LogLineDetailsTrace: use unique request id

* LogLineDetailsTrace: reset state when props change

* Chore: rename

* Chore: rename

* links: add integration test

* Prettier
This commit is contained in:
Matias Chomicki 2025-08-25 16:06:01 +02:00 committed by GitHub
parent dcea3315fa
commit 9646a06a91
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
12 changed files with 676 additions and 232 deletions

View file

@ -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",

View file

@ -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,
]

View file

@ -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,
};
});

View file

@ -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<HTMLElement>, 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(
</Button>
</div>
)}
{detailsMode === 'inline' && detailsShown && <InlineLogLineDetails logs={logs} log={log} />}
{detailsMode === 'inline' && detailsShown && (
<InlineLogLineDetails logs={logs} log={log} timeRange={timeRange} timeZone={timeZone} />
)}
</>
);
}

View file

@ -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: () => <div>Trace view</div>,
}));
afterAll(() => {
jest.unmock('app/features/explore/TraceView/TraceView');
});
const setup = (
propOverrides?: Partial<Props>,
@ -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();
});
});

View file

@ -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}
>
<div className={styles.container} ref={containerRef}>
<LogLineDetailsTabs focusLogLine={focusLogLine} logs={logs} />
<LogLineDetailsTabs focusLogLine={focusLogLine} logs={logs} timeRange={timeRange} timeZone={timeZone} />
</div>
</Resizable>
);
});
LogLineDetails.displayName = 'LogLineDetails';
const LogLineDetailsTabs = memo(({ focusLogLine, logs }: Pick<Props, 'focusLogLine' | 'logs'>) => {
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<Props, 'focusLogLine' | 'logs' | 'timeRange' | 'timeZone'>) => {
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 && (
<TabsBar>
{showDetails.map((log) => {
return (
<Tab
key={log.uid}
truncate
label={log.entry.substring(0, 25)}
active={currentLog.uid === log.uid}
onChangeTab={() => setCurrentLog(log)}
suffix={() => (
<Icon
name="times"
aria-label={t('logs.log-line-details.remove-log', 'Remove log')}
onClick={() => toggleDetails(log)}
/>
)}
/>
);
})}
</TabsBar>
)}
<div className={styles.scrollContainer}>
<LogLineDetailsComponent focusLogLine={focusLogLine} log={currentLog} logs={logs} />
</div>
</>
);
});
return (
<>
{showDetails.length > 1 && (
<TabsBar>
{showDetails.map((log) => {
return (
<Tab
key={log.uid}
truncate
label={log.entry.substring(0, 25)}
active={currentLog.uid === log.uid}
onChangeTab={() => setCurrentLog(log)}
suffix={() => (
<Icon
name="times"
aria-label={t('logs.log-line-details.remove-log', 'Remove log')}
onClick={() => toggleDetails(log)}
/>
)}
/>
);
})}
</TabsBar>
)}
<div className={styles.scrollContainer}>
<LogLineDetailsComponent
focusLogLine={focusLogLine}
log={currentLog}
logs={logs}
timeRange={timeRange}
timeZone={timeZone}
/>
</div>
</>
);
}
);
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<HTMLDivElement | null>(null);
@ -162,7 +174,7 @@ export const InlineLogLineDetails = memo(({ logs, log }: InlineLogLineDetailsPro
<div className={`${styles.inlineWrapper} log-line-inline-details`} style={{ maxWidth: detailsWidth }}>
<div className={styles.container}>
<div className={styles.scrollContainer} ref={scrollRef} onScroll={saveScroll}>
<LogLineDetailsComponent log={log} logs={logs} />
<LogLineDetailsComponent log={log} logs={logs} timeRange={timeRange} timeZone={timeZone} />
</div>
</div>
</div>

View file

@ -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 (
<>
<LogLineDetailsHeader focusLogLine={focusLogLine} log={log} search={search} onSearch={handleSearch} />
<div className={styles.componentWrapper}>
<ControlledCollapse
className={styles.collapsable}
label={t('logs.log-line-details.log-line-section', 'Log line')}
collapsible
isOpen={logLineOpen}
onToggle={(isOpen: boolean) => handleToggle('logLineOpen', isOpen)}
>
<LogLineDetailsLog log={log} syntaxHighlighting={syntaxHighlighting ?? true} />
</ControlledCollapse>
{displayedFields.length > 0 && setDisplayedFields && (
<ControlledCollapse
label={t('logs.log-line-details.displayed-fields-section', 'Organize displayed fields')}
collapsible
isOpen={displayedFieldsOpen}
onToggle={(isOpen: boolean) => handleToggle('displayedFieldsOpen', isOpen)}
>
<LogLineDetailsDisplayedFields />
</ControlledCollapse>
)}
{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 (
<>
<LogLineDetailsHeader focusLogLine={focusLogLine} log={log} search={search} onSearch={handleSearch} />
<div className={styles.componentWrapper}>
<ControlledCollapse
className={styles.collapsable}
label={t('logs.log-line-details.links-section', 'Links')}
label={t('logs.log-line-details.log-line-section', 'Log line')}
collapsible
isOpen={linksOpen}
onToggle={(isOpen: boolean) => handleToggle('linksOpen', isOpen)}
isOpen={logLineOpen}
onToggle={(isOpen: boolean) => handleToggle('logLineOpen', isOpen)}
>
<LogLineDetailsFields disableActions log={log} logs={logs} fields={fieldsWithLinks.links} search={search} />
<LogLineDetailsFields
disableActions
log={log}
logs={logs}
fields={fieldsWithLinks.linksFromVariableMap}
search={search}
/>
<LogLineDetailsLog log={log} syntaxHighlighting={syntaxHighlighting ?? true} />
</ControlledCollapse>
)}
{labelGroups.map((group) =>
group === '' ? (
{displayedFields.length > 0 && setDisplayedFields && (
<ControlledCollapse
label={t('logs.log-line-details.displayed-fields-section', 'Organize displayed fields')}
collapsible
isOpen={displayedFieldsOpen}
onToggle={(isOpen: boolean) => handleToggle('displayedFieldsOpen', isOpen)}
>
<LogLineDetailsDisplayedFields />
</ControlledCollapse>
)}
{fieldsWithLinks.links.length > 0 && (
<ControlledCollapse
className={styles.collapsable}
label={t('logs.log-line-details.links-section', 'Links')}
collapsible
isOpen={linksOpen}
onToggle={(isOpen: boolean) => handleToggle('linksOpen', isOpen)}
>
<LogLineDetailsFields
disableActions
log={log}
logs={logs}
fields={fieldsWithLinks.links}
search={search}
/>
<LogLineDetailsFields
disableActions
log={log}
logs={logs}
fields={fieldsWithLinks.linksFromVariableMap}
search={search}
/>
</ControlledCollapse>
)}
{trace && (
<ControlledCollapse
label={t('logs.log-line-details.trace-section', 'Trace')}
collapsible
isOpen={traceOpen}
onToggle={(isOpen: boolean) => handleToggle('traceOpen', isOpen)}
>
<LogLineDetailsTrace timeRange={timeRange} timeZone={timeZone} traceRef={trace} />
</ControlledCollapse>
)}
{labelGroups.map((group) =>
group === '' ? (
<ControlledCollapse
className={styles.collapsable}
key={'fields'}
label={t('logs.log-line-details.fields-section', 'Fields')}
collapsible
isOpen={fieldsOpen}
onToggle={(isOpen: boolean) => handleToggle('fieldsOpen', isOpen)}
>
<LogLineDetailsLabelFields log={log} logs={logs} fields={groupedLabels[group]} search={search} />
<LogLineDetailsFields log={log} logs={logs} fields={fieldsWithoutLinks} search={search} />
</ControlledCollapse>
) : (
<ControlledCollapse
className={styles.collapsable}
key={group}
label={group}
collapsible
isOpen={store.getBool(`${logOptionsStorageKey}.log-details.${groupOptionName(group)}`, true)}
onToggle={(isOpen: boolean) => handleToggle(groupOptionName(group), isOpen)}
>
<LogLineDetailsLabelFields log={log} logs={logs} fields={groupedLabels[group]} search={search} />
</ControlledCollapse>
)
)}
{!labelGroups.length && fieldsWithoutLinks.length > 0 && (
<ControlledCollapse
className={styles.collapsable}
key={'fields'}
@ -152,39 +210,15 @@ export const LogLineDetailsComponent = memo(({ focusLogLine, log, logs }: LogLin
isOpen={fieldsOpen}
onToggle={(isOpen: boolean) => handleToggle('fieldsOpen', isOpen)}
>
<LogLineDetailsLabelFields log={log} logs={logs} fields={groupedLabels[group]} search={search} />
<LogLineDetailsFields log={log} logs={logs} fields={fieldsWithoutLinks} search={search} />
</ControlledCollapse>
) : (
<ControlledCollapse
className={styles.collapsable}
key={group}
label={group}
collapsible
isOpen={store.getBool(`${logOptionsStorageKey}.log-details.${groupOptionName(group)}`, true)}
onToggle={(isOpen: boolean) => handleToggle(groupOptionName(group), isOpen)}
>
<LogLineDetailsLabelFields log={log} logs={logs} fields={groupedLabels[group]} search={search} />
</ControlledCollapse>
)
)}
{!labelGroups.length && fieldsWithoutLinks.length > 0 && (
<ControlledCollapse
className={styles.collapsable}
key={'fields'}
label={t('logs.log-line-details.fields-section', 'Fields')}
collapsible
isOpen={fieldsOpen}
onToggle={(isOpen: boolean) => handleToggle('fieldsOpen', isOpen)}
>
<LogLineDetailsFields log={log} logs={logs} fields={fieldsWithoutLinks} search={search} />
</ControlledCollapse>
)}
{noDetails && <Trans i18nKey="logs.log-line-details.no-details">No fields to display.</Trans>}
</div>
</>
);
});
)}
{noDetails && <Trans i18nKey="logs.log-line-details.no-details">No fields to display.</Trans>}
</div>
</>
);
}
);
LogLineDetailsComponent.displayName = 'LogLineDetailsComponent';
function groupOptionName(group: string) {

View file

@ -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<DataSourceApi | null>(null);
const [dataFrames, setDataFrames] = useState<DataFrame[] | null | undefined>(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<TempoQuery> = {
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 (
<div>
{dataSource && Array.isArray(dataFrames) && traceProp && (
<TraceView dataFrames={dataFrames} traceProp={traceProp} datasource={dataSource} timeRange={timeRange} />
)}
{dataFrames === null && (
<div className={styles.message}>
<Tooltip
content={t(
'logs.log-line-details.trace.error-tooltip',
'The trace could have been sampled or be temporarily unavailable.'
)}
>
<Icon name="info-circle" />
</Tooltip>
{t('logs.log-line-details.trace.error-message', 'Could not retrieve trace.')}
</div>
)}
{dataFrames === undefined && (
<div className={styles.message}>
<Spinner />
{t('logs.log-line-details.trace.loading-message', 'Loading trace...')}
</div>
)}
</div>
);
};
const getStyles = (theme: GrafanaTheme2) => ({
message: css({
display: 'flex',
gap: theme.spacing(1),
alignItems: 'center',
}),
});

View file

@ -411,6 +411,8 @@ const LogListComponent = ({
containerElement={containerElement}
focusLogLine={focusLogLine}
logs={filteredLogs}
timeRange={timeRange}
timeZone={timeZone}
onResize={handleLogDetailsResize}
/>
)}

View file

@ -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',
});
});
});

View file

@ -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;
}

View file

@ -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": {