mirror of
https://github.com/grafana/grafana.git
synced 2025-12-18 22:16:21 -05:00
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:
parent
dcea3315fa
commit
9646a06a91
12 changed files with 676 additions and 232 deletions
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
]
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
};
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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} />
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
}),
|
||||
});
|
||||
|
|
@ -411,6 +411,8 @@ const LogListComponent = ({
|
|||
containerElement={containerElement}
|
||||
focusLogLine={focusLogLine}
|
||||
logs={filteredLogs}
|
||||
timeRange={timeRange}
|
||||
timeZone={timeZone}
|
||||
onResize={handleLogDetailsResize}
|
||||
/>
|
||||
)}
|
||||
|
|
|
|||
82
public/app/features/logs/components/panel/links.test.ts
Normal file
82
public/app/features/logs/components/panel/links.test.ts
Normal 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',
|
||||
});
|
||||
});
|
||||
});
|
||||
61
public/app/features/logs/components/panel/links.ts
Normal file
61
public/app/features/logs/components/panel/links.ts
Normal 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;
|
||||
}
|
||||
|
|
@ -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": {
|
||||
|
|
|
|||
Loading…
Reference in a new issue