Explore: Show log line if there is an interpolated link (#65489)

* Add back log lines changes

This reverts commit f43ef18732.

* Bring in @torkelo ’s changes to template_srv, implement with new format

* Enable functionality

* Remove non relevant test

* Fix tests, add @ifrost suggested tests and clarifying comment

* Add test around static link logic
This commit is contained in:
Kristina 2023-03-29 10:07:55 -05:00 committed by GitHub
parent 5d60dd08d0
commit 845951485f
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
12 changed files with 1020 additions and 763 deletions

View File

@ -1,4 +1,3 @@
import { property } from 'lodash';
import React from 'react'; import React from 'react';
import { import {
@ -24,8 +23,7 @@ import { getDatasourceSrv } from 'app/features/plugins/datasource_srv';
import { PromQuery } from 'app/plugins/datasource/prometheus/types'; import { PromQuery } from 'app/plugins/datasource/prometheus/types';
import { LokiQuery } from '../../../plugins/datasource/loki/types'; import { LokiQuery } from '../../../plugins/datasource/loki/types';
import { variableRegex } from '../../variables/utils'; import { getFieldLinksForExplore, getVariableUsageInfo } from '../utils/links';
import { getFieldLinksForExplore } from '../utils/links';
import { SpanLinkFunc, Trace, TraceSpan } from './components'; import { SpanLinkFunc, Trace, TraceSpan } from './components';
import { SpanLinks } from './components/types/links'; import { SpanLinks } from './components/types/links';
@ -192,7 +190,7 @@ function legacyCreateSpanLinkFactory(
// Check if all variables are defined and don't show if they aren't. This is usually handled by the // Check if all variables are defined and don't show if they aren't. This is usually handled by the
// getQueryFor* functions but this is for case of custom query supplied by the user. // getQueryFor* functions but this is for case of custom query supplied by the user.
if (dataLinkHasAllVariablesDefined(dataLink.internal!.query, scopedVars)) { if (getVariableUsageInfo(dataLink.internal!.query, scopedVars).allVariablesDefined) {
const link = mapInternalLinkToExplore({ const link = mapInternalLinkToExplore({
link: dataLink, link: dataLink,
internalLink: dataLink.internal!, internalLink: dataLink.internal!,
@ -576,65 +574,3 @@ function scopedVarsFromSpan(span: TraceSpan): ScopedVars {
}, },
}; };
} }
type VarValue = string | number | boolean | undefined;
/**
* This function takes some code from template service replace() function to figure out if all variables are
* interpolated. This is so we don't show links that do not work. This cuts a lots of corners though and that is why
* it's a local function. We sort of don't care about the dashboard template variables for example. Also we only link
* to loki/splunk/elastic, so it should be less probable that user needs part of a query that looks like a variable but
* is actually part of the query language.
* @param query
* @param scopedVars
*/
function dataLinkHasAllVariablesDefined<T extends DataQuery>(query: T, scopedVars: ScopedVars): boolean {
const vars = getVariablesMapInTemplate(getStringsFromObject(query), scopedVars);
return Object.values(vars).every((val) => val !== undefined);
}
function getStringsFromObject<T extends Object>(obj: T): string {
let acc = '';
for (const k of Object.keys(obj)) {
// Honestly not sure how to type this to make TS happy.
// @ts-ignore
if (typeof obj[k] === 'string') {
// @ts-ignore
acc += ' ' + obj[k];
// @ts-ignore
} else if (typeof obj[k] === 'object' && obj[k] !== null) {
// @ts-ignore
acc += ' ' + getStringsFromObject(obj[k]);
}
}
return acc;
}
function getVariablesMapInTemplate(target: string, scopedVars: ScopedVars): Record<string, VarValue> {
const regex = new RegExp(variableRegex);
const values: Record<string, VarValue> = {};
target.replace(regex, (match, var1, var2, fmt2, var3, fieldPath) => {
const variableName = var1 || var2 || var3;
values[variableName] = getVariableValue(variableName, fieldPath, scopedVars);
// Don't care about the result anyway
return '';
});
return values;
}
function getVariableValue(variableName: string, fieldPath: string | undefined, scopedVars: ScopedVars): VarValue {
const scopedVar = scopedVars[variableName];
if (!scopedVar) {
return undefined;
}
if (fieldPath) {
// @ts-ignore ScopedVars are typed in way that I don't think this is possible to type correctly.
return property(fieldPath)(scopedVar.value);
}
return scopedVar.value;
}

File diff suppressed because it is too large Load Diff

View File

@ -13,7 +13,7 @@ import {
DataLink, DataLink,
DisplayValue, DisplayValue,
} from '@grafana/data'; } from '@grafana/data';
import { getTemplateSrv } from '@grafana/runtime'; import { getTemplateSrv, VariableInterpolation } from '@grafana/runtime';
import { contextSrv } from 'app/core/services/context_srv'; import { contextSrv } from 'app/core/services/context_srv';
import { getTransformationVars } from 'app/features/correlations/transformations'; import { getTransformationVars } from 'app/features/correlations/transformations';
@ -21,40 +21,26 @@ import { getLinkSrv } from '../../panel/panellinks/link_srv';
type DataLinkFilter = (link: DataLink, scopedVars: ScopedVars) => boolean; type DataLinkFilter = (link: DataLink, scopedVars: ScopedVars) => boolean;
const dataLinkHasRequiredPermissions = (link: DataLink) => { const dataLinkHasRequiredPermissionsFilter = (link: DataLink) => {
return !link.internal || contextSrv.hasAccessToExplore(); return !link.internal || contextSrv.hasAccessToExplore();
}; };
/**
* Check if every variable in the link has a value. If not this returns false. If there are no variables in the link
* this will return true.
* @param link
* @param scopedVars
*/
const dataLinkHasAllVariablesDefined = (link: DataLink, scopedVars: ScopedVars) => {
let hasAllRequiredVarDefined = true;
if (link.internal) {
let stringifiedQuery = '';
try {
stringifiedQuery = JSON.stringify(link.internal.query || {});
// Hook into format function to verify if all values are non-empty
// Format function is run on all existing field values allowing us to check it's value is non-empty
getTemplateSrv().replace(stringifiedQuery, scopedVars, (f: string) => {
hasAllRequiredVarDefined = hasAllRequiredVarDefined && !!f;
return '';
});
} catch (err) {}
}
return hasAllRequiredVarDefined;
};
/** /**
* Fixed list of filters used in Explore. DataLinks that do not pass all the filters will not * Fixed list of filters used in Explore. DataLinks that do not pass all the filters will not
* be passed back to the visualization. * be passed back to the visualization.
*/ */
const DATA_LINK_FILTERS: DataLinkFilter[] = [dataLinkHasAllVariablesDefined, dataLinkHasRequiredPermissions]; const DATA_LINK_FILTERS: DataLinkFilter[] = [dataLinkHasRequiredPermissionsFilter];
/**
* This extension of the LinkModel was done to support correlations, which need the variables' names
* and values split out for display purposes
*
* Correlations are internal links only so the variables property will always be defined (but possibly empty)
* for internal links and undefined for non-internal links
*/
export interface ExploreFieldLinkModel extends LinkModel<Field> {
variables?: VariableInterpolation[];
}
/** /**
* Get links from the field of a dataframe and in addition check if there is associated * Get links from the field of a dataframe and in addition check if there is associated
@ -62,6 +48,9 @@ const DATA_LINK_FILTERS: DataLinkFilter[] = [dataLinkHasAllVariablesDefined, dat
* that we just supply datasource name and field value and Explore split window will know how to render that * that we just supply datasource name and field value and Explore split window will know how to render that
* appropriately. This is for example used for transition from log with traceId to trace datasource to show that * appropriately. This is for example used for transition from log with traceId to trace datasource to show that
* trace. * trace.
*
* Note: accessing a field via ${__data.fields.variable} will stay consistent with dashboards and return as existing but with an empty string
* Accessing a field with ${variable} will return undefined as this is unique to explore.
*/ */
export const getFieldLinksForExplore = (options: { export const getFieldLinksForExplore = (options: {
field: Field; field: Field;
@ -70,7 +59,7 @@ export const getFieldLinksForExplore = (options: {
range: TimeRange; range: TimeRange;
vars?: ScopedVars; vars?: ScopedVars;
dataFrame?: DataFrame; dataFrame?: DataFrame;
}): Array<LinkModel<Field>> => { }): ExploreFieldLinkModel[] => {
const { field, vars, splitOpenFn, range, rowIndex, dataFrame } = options; const { field, vars, splitOpenFn, range, rowIndex, dataFrame } = options;
const scopedVars: ScopedVars = { ...(vars || {}) }; const scopedVars: ScopedVars = { ...(vars || {}) };
scopedVars['__value'] = { scopedVars['__value'] = {
@ -117,7 +106,7 @@ export const getFieldLinksForExplore = (options: {
return DATA_LINK_FILTERS.every((filter) => filter(link, scopedVars)); return DATA_LINK_FILTERS.every((filter) => filter(link, scopedVars));
}); });
return links.map((link) => { const fieldLinks = links.map((link) => {
if (!link.internal) { if (!link.internal) {
const replace: InterpolateFunction = (value, vars) => const replace: InterpolateFunction = (value, vars) =>
getTemplateSrv().replace(value, { ...vars, ...scopedVars }); getTemplateSrv().replace(value, { ...vars, ...scopedVars });
@ -146,19 +135,36 @@ export const getFieldLinksForExplore = (options: {
}); });
} }
return mapInternalLinkToExplore({ const allVars = { ...scopedVars, ...internalLinkSpecificVars };
link, const variableData = getVariableUsageInfo(link, allVars);
internalLink: link.internal, let variables: VariableInterpolation[] = [];
scopedVars: { ...scopedVars, ...internalLinkSpecificVars },
range, // if the link has no variables (static link), add it with the right key but an empty value so we know what field the static link is associated with
field, if (variableData.variables.length === 0) {
onClickFn: splitOpenFn, const fieldName = field.name.toString();
replaceVariables: getTemplateSrv().replace.bind(getTemplateSrv()), variables.push({ variableName: fieldName, value: '', match: '' });
}); } else {
variables = variableData.variables;
}
if (variableData.allVariablesDefined) {
const internalLink = mapInternalLinkToExplore({
link,
internalLink: link.internal,
scopedVars: allVars,
range,
field,
onClickFn: splitOpenFn,
replaceVariables: getTemplateSrv().replace.bind(getTemplateSrv()),
});
return { ...internalLink, variables: variables };
} else {
return undefined;
}
} }
}); });
return fieldLinks.filter((link): link is ExploreFieldLinkModel => !!link);
} }
return []; return [];
}; };
@ -204,3 +210,35 @@ export function useLinks(range: TimeRange, splitOpenFn?: SplitOpen) {
[range, splitOpenFn] [range, splitOpenFn]
); );
} }
/**
* Use variable map from templateSrv to determine if all variables have values
* @param query
* @param scopedVars
*/
export function getVariableUsageInfo<T extends DataLink>(
query: T,
scopedVars: ScopedVars
): { variables: VariableInterpolation[]; allVariablesDefined: boolean } {
const variables: VariableInterpolation[] = [];
const replaceFn = getTemplateSrv().replace.bind(getTemplateSrv());
replaceFn(getStringsFromObject(query), scopedVars, undefined, variables);
return {
variables: variables,
allVariablesDefined: variables.every((variable) => variable.found),
};
}
function getStringsFromObject(obj: Object): string {
let acc = '';
let k: keyof typeof obj;
for (k in obj) {
if (typeof obj[k] === 'string') {
acc += ' ' + obj[k];
} else if (typeof obj[k] === 'object') {
acc += ' ' + getStringsFromObject(obj[k]);
}
}
return acc;
}

View File

@ -8,7 +8,7 @@ import { calculateLogsLabelStats, calculateStats } from '../utils';
import { LogDetailsRow } from './LogDetailsRow'; import { LogDetailsRow } from './LogDetailsRow';
import { getLogLevelStyles, LogRowStyles } from './getLogRowStyles'; import { getLogLevelStyles, LogRowStyles } from './getLogRowStyles';
import { getAllFields } from './logParser'; import { getAllFields, createLogLineLinks } from './logParser';
export interface Props extends Themeable2 { export interface Props extends Themeable2 {
row: LogRowModel; row: LogRowModel;
@ -51,10 +51,17 @@ class UnThemedLogDetails extends PureComponent<Props> {
const labels = row.labels ? row.labels : {}; const labels = row.labels ? row.labels : {};
const labelsAvailable = Object.keys(labels).length > 0; const labelsAvailable = Object.keys(labels).length > 0;
const fieldsAndLinks = getAllFields(row, getFieldLinks); const fieldsAndLinks = getAllFields(row, getFieldLinks);
const links = fieldsAndLinks.filter((f) => f.links?.length).sort(); let fieldsWithLinks = fieldsAndLinks.filter((f) => f.links?.length);
const fields = fieldsAndLinks.filter((f) => f.links?.length === 0).sort(); const displayedFieldsWithLinks = fieldsWithLinks.filter((f) => f.fieldIndex !== row.entryFieldIndex).sort();
const hiddenFieldsWithLinks = fieldsWithLinks.filter((f) => f.fieldIndex === row.entryFieldIndex).sort();
const fieldsWithLinksFromVariableMap = createLogLineLinks(hiddenFieldsWithLinks);
// do not show the log message unless there is a link attached
const fields = fieldsAndLinks.filter((f) => f.links?.length === 0 && f.fieldIndex !== row.entryFieldIndex).sort();
const fieldsAvailable = fields && fields.length > 0; const fieldsAvailable = fields && fields.length > 0;
const linksAvailable = links && links.length > 0; const fieldsWithLinksAvailable =
(displayedFieldsWithLinks && displayedFieldsWithLinks.length > 0) ||
(fieldsWithLinksFromVariableMap && fieldsWithLinksFromVariableMap.length > 0);
// If logs with error, we are not showing the level color // If logs with error, we are not showing the level color
const levelClassName = hasError const levelClassName = hasError
@ -78,13 +85,13 @@ class UnThemedLogDetails extends PureComponent<Props> {
)} )}
{Object.keys(labels) {Object.keys(labels)
.sort() .sort()
.map((key) => { .map((key, i) => {
const value = labels[key]; const value = labels[key];
return ( return (
<LogDetailsRow <LogDetailsRow
key={`${key}=${value}`} key={`${key}=${value}-${i}`}
parsedKey={key} parsedKeys={[key]}
parsedValue={value} parsedValues={[value]}
isLabel={true} isLabel={true}
getStats={() => calculateLogsLabelStats(getRows(), key)} getStats={() => calculateLogsLabelStats(getRows(), key)}
onClickFilterOutLabel={onClickFilterOutLabel} onClickFilterOutLabel={onClickFilterOutLabel}
@ -95,16 +102,17 @@ class UnThemedLogDetails extends PureComponent<Props> {
app={app} app={app}
wrapLogMessage={wrapLogMessage} wrapLogMessage={wrapLogMessage}
displayedFields={displayedFields} displayedFields={displayedFields}
disableActions={false}
/> />
); );
})} })}
{fields.map((field) => { {fields.map((field, i) => {
const { key, value, fieldIndex } = field; const { keys, values, fieldIndex } = field;
return ( return (
<LogDetailsRow <LogDetailsRow
key={`${key}=${value}`} key={`${keys[0]}=${values[0]}-${i}`}
parsedKey={key} parsedKeys={keys}
parsedValue={value} parsedValues={values}
onClickShowField={onClickShowField} onClickShowField={onClickShowField}
onClickHideField={onClickHideField} onClickHideField={onClickHideField}
onClickFilterOutLabel={onClickFilterOutLabel} onClickFilterOutLabel={onClickFilterOutLabel}
@ -114,24 +122,25 @@ class UnThemedLogDetails extends PureComponent<Props> {
wrapLogMessage={wrapLogMessage} wrapLogMessage={wrapLogMessage}
row={row} row={row}
app={app} app={app}
disableActions={false}
/> />
); );
})} })}
{linksAvailable && ( {fieldsWithLinksAvailable && (
<tr> <tr>
<td colSpan={100} className={styles.logDetailsHeading} aria-label="Data Links"> <td colSpan={100} className={styles.logDetailsHeading} aria-label="Data Links">
Links Links
</td> </td>
</tr> </tr>
)} )}
{links.map((field) => { {displayedFieldsWithLinks.map((field, i) => {
const { key, value, links, fieldIndex } = field; const { keys, values, links, fieldIndex } = field;
return ( return (
<LogDetailsRow <LogDetailsRow
key={`${key}=${value}`} key={`${keys[0]}=${values[0]}-${i}`}
parsedKey={key} parsedKeys={keys}
parsedValue={value} parsedValues={values}
links={links} links={links}
onClickShowField={onClickShowField} onClickShowField={onClickShowField}
onClickHideField={onClickHideField} onClickHideField={onClickHideField}
@ -140,10 +149,31 @@ class UnThemedLogDetails extends PureComponent<Props> {
wrapLogMessage={wrapLogMessage} wrapLogMessage={wrapLogMessage}
row={row} row={row}
app={app} app={app}
disableActions={false}
/> />
); );
})} })}
{!fieldsAvailable && !labelsAvailable && !linksAvailable && ( {fieldsWithLinksFromVariableMap?.map((field, i) => {
const { keys, values, links, fieldIndex } = field;
return (
<LogDetailsRow
key={`${keys[0]}=${values[0]}-${i}`}
parsedKeys={keys}
parsedValues={values}
links={links}
onClickShowField={onClickShowField}
onClickHideField={onClickHideField}
getStats={() => calculateStats(row.dataFrame.fields[fieldIndex].values.toArray())}
displayedFields={displayedFields}
wrapLogMessage={wrapLogMessage}
row={row}
app={app}
disableActions={true}
/>
);
})}
{!fieldsAvailable && !labelsAvailable && !fieldsWithLinksAvailable && (
<tr> <tr>
<td colSpan={100} aria-label="No details"> <td colSpan={100} aria-label="No details">
No details available No details available

View File

@ -9,8 +9,8 @@ type Props = ComponentProps<typeof LogDetailsRow>;
const setup = (propOverrides?: Partial<Props>) => { const setup = (propOverrides?: Partial<Props>) => {
const props: Props = { const props: Props = {
parsedValue: '', parsedValues: [''],
parsedKey: '', parsedKeys: [''],
isLabel: true, isLabel: true,
wrapLogMessage: false, wrapLogMessage: false,
getStats: () => null, getStats: () => null,
@ -20,6 +20,7 @@ const setup = (propOverrides?: Partial<Props>) => {
onClickHideField: () => {}, onClickHideField: () => {},
displayedFields: [], displayedFields: [],
row: {} as LogRowModel, row: {} as LogRowModel,
disableActions: false,
}; };
Object.assign(props, propOverrides); Object.assign(props, propOverrides);
@ -40,11 +41,11 @@ jest.mock('@grafana/runtime', () => ({
describe('LogDetailsRow', () => { describe('LogDetailsRow', () => {
it('should render parsed key', () => { it('should render parsed key', () => {
setup({ parsedKey: 'test key' }); setup({ parsedKeys: ['test key'] });
expect(screen.getByText('test key')).toBeInTheDocument(); expect(screen.getByText('test key')).toBeInTheDocument();
}); });
it('should render parsed value', () => { it('should render parsed value', () => {
setup({ parsedValue: 'test value' }); setup({ parsedValues: ['test value'] });
expect(screen.getByText('test value')).toBeInTheDocument(); expect(screen.getByText('test value')).toBeInTheDocument();
}); });
@ -73,8 +74,8 @@ describe('LogDetailsRow', () => {
it('should render stats when stats icon is clicked', () => { it('should render stats when stats icon is clicked', () => {
setup({ setup({
parsedKey: 'key', parsedKeys: ['key'],
parsedValue: 'value', parsedValues: ['value'],
getStats: () => { getStats: () => {
return [ return [
{ {

View File

@ -13,8 +13,9 @@ import { getLogRowStyles } from './getLogRowStyles';
//Components //Components
export interface Props extends Themeable2 { export interface Props extends Themeable2 {
parsedValue: string; parsedValues: string[];
parsedKey: string; parsedKeys: string[];
disableActions: boolean;
wrapLogMessage?: boolean; wrapLogMessage?: boolean;
isLabel?: boolean; isLabel?: boolean;
onClickFilterLabel?: (key: string, value: string) => void; onClickFilterLabel?: (key: string, value: string) => void;
@ -60,6 +61,9 @@ const getStyles = memoizeOne((theme: GrafanaTheme2) => {
} }
} }
`, `,
adjoiningLinkButton: css`
margin-left: ${theme.spacing(1)};
`,
wrapLine: css` wrapLine: css`
label: wrapLine; label: wrapLine;
white-space: pre-wrap; white-space: pre-wrap;
@ -68,8 +72,8 @@ const getStyles = memoizeOne((theme: GrafanaTheme2) => {
padding: 0 ${theme.spacing(1)}; padding: 0 ${theme.spacing(1)};
`, `,
logDetailsValue: css` logDetailsValue: css`
display: table-cell; display: flex;
vertical-align: middle; align-items: center;
line-height: 22px; line-height: 22px;
.show-on-hover { .show-on-hover {
@ -105,9 +109,9 @@ class UnThemedLogDetailsRow extends PureComponent<Props, State> {
} }
showField = () => { showField = () => {
const { onClickShowField: onClickShowDetectedField, parsedKey, row } = this.props; const { onClickShowField: onClickShowDetectedField, parsedKeys, row } = this.props;
if (onClickShowDetectedField) { if (onClickShowDetectedField) {
onClickShowDetectedField(parsedKey); onClickShowDetectedField(parsedKeys[0]);
} }
reportInteraction('grafana_explore_logs_log_details_replace_line_clicked', { reportInteraction('grafana_explore_logs_log_details_replace_line_clicked', {
@ -118,9 +122,9 @@ class UnThemedLogDetailsRow extends PureComponent<Props, State> {
}; };
hideField = () => { hideField = () => {
const { onClickHideField: onClickHideDetectedField, parsedKey, row } = this.props; const { onClickHideField: onClickHideDetectedField, parsedKeys, row } = this.props;
if (onClickHideDetectedField) { if (onClickHideDetectedField) {
onClickHideDetectedField(parsedKey); onClickHideDetectedField(parsedKeys[0]);
} }
reportInteraction('grafana_explore_logs_log_details_replace_line_clicked', { reportInteraction('grafana_explore_logs_log_details_replace_line_clicked', {
@ -131,9 +135,9 @@ class UnThemedLogDetailsRow extends PureComponent<Props, State> {
}; };
filterLabel = () => { filterLabel = () => {
const { onClickFilterLabel, parsedKey, parsedValue, row } = this.props; const { onClickFilterLabel, parsedKeys, parsedValues, row } = this.props;
if (onClickFilterLabel) { if (onClickFilterLabel) {
onClickFilterLabel(parsedKey, parsedValue); onClickFilterLabel(parsedKeys[0], parsedValues[0]);
} }
reportInteraction('grafana_explore_logs_log_details_filter_clicked', { reportInteraction('grafana_explore_logs_log_details_filter_clicked', {
@ -144,9 +148,9 @@ class UnThemedLogDetailsRow extends PureComponent<Props, State> {
}; };
filterOutLabel = () => { filterOutLabel = () => {
const { onClickFilterOutLabel, parsedKey, parsedValue, row } = this.props; const { onClickFilterOutLabel, parsedKeys, parsedValues, row } = this.props;
if (onClickFilterOutLabel) { if (onClickFilterOutLabel) {
onClickFilterOutLabel(parsedKey, parsedValue); onClickFilterOutLabel(parsedKeys[0], parsedValues[0]);
} }
reportInteraction('grafana_explore_logs_log_details_filter_clicked', { reportInteraction('grafana_explore_logs_log_details_filter_clicked', {
@ -190,25 +194,68 @@ class UnThemedLogDetailsRow extends PureComponent<Props, State> {
}); });
} }
generateClipboardButton(val: string) {
const { theme } = this.props;
const styles = getStyles(theme);
return (
<div className={cx('show-on-hover', styles.copyButton)}>
<ClipboardButton
getText={() => val}
title="Copy value to clipboard"
fill="text"
variant="secondary"
icon="copy"
size="md"
/>
</div>
);
}
generateMultiVal(value: string[], showCopy?: boolean) {
return (
<table>
<tbody>
{value?.map((val, i) => {
return (
<tr key={`${val}-${i}`}>
<td>
{val}
{showCopy && val !== '' && this.generateClipboardButton(val)}
</td>
</tr>
);
})}
</tbody>
</table>
);
}
render() { render() {
const { const {
theme, theme,
parsedKey, parsedKeys,
parsedValue, parsedValues,
isLabel, isLabel,
links, links,
displayedFields, displayedFields,
wrapLogMessage, wrapLogMessage,
onClickFilterLabel, onClickFilterLabel,
onClickFilterOutLabel, onClickFilterOutLabel,
disableActions,
} = this.props; } = this.props;
const { showFieldsStats, fieldStats, fieldCount } = this.state; const { showFieldsStats, fieldStats, fieldCount } = this.state;
const styles = getStyles(theme); const styles = getStyles(theme);
const style = getLogRowStyles(theme); const style = getLogRowStyles(theme);
const hasFilteringFunctionality = onClickFilterLabel && onClickFilterOutLabel; const singleKey = parsedKeys == null ? false : parsedKeys.length === 1;
const singleVal = parsedValues == null ? false : parsedValues.length === 1;
const hasFilteringFunctionality = !disableActions && onClickFilterLabel && onClickFilterOutLabel;
const isMultiParsedValueWithNoContent =
!singleVal && parsedValues != null && !parsedValues.every((val) => val === '');
const toggleFieldButton = const toggleFieldButton =
displayedFields && displayedFields.includes(parsedKey) ? ( displayedFields && parsedKeys != null && displayedFields.includes(parsedKeys[0]) ? (
<IconButton variant="primary" tooltip="Hide this field" name="eye" onClick={this.hideField} /> <IconButton variant="primary" tooltip="Hide this field" name="eye" onClick={this.hideField} />
) : ( ) : (
<IconButton tooltip="Show this field instead of the message" name="eye" onClick={this.showField} /> <IconButton tooltip="Show this field instead of the message" name="eye" onClick={this.showField} />
@ -225,44 +272,37 @@ class UnThemedLogDetailsRow extends PureComponent<Props, State> {
{hasFilteringFunctionality && ( {hasFilteringFunctionality && (
<IconButton name="search-minus" tooltip="Filter out value" onClick={this.filterOutLabel} /> <IconButton name="search-minus" tooltip="Filter out value" onClick={this.filterOutLabel} />
)} )}
{displayedFields && toggleFieldButton} {!disableActions && displayedFields && toggleFieldButton}
<IconButton {!disableActions && (
variant={showFieldsStats ? 'primary' : 'secondary'} <IconButton
name="signal" variant={showFieldsStats ? 'primary' : 'secondary'}
tooltip="Ad-hoc statistics" name="signal"
className="stats-button" tooltip="Ad-hoc statistics"
onClick={this.showStats} className="stats-button"
/> disabled={!singleKey}
onClick={this.showStats}
/>
)}
</div> </div>
</td> </td>
{/* Key - value columns */} {/* Key - value columns */}
<td className={style.logDetailsLabel}>{parsedKey}</td> <td className={style.logDetailsLabel}>{singleKey ? parsedKeys[0] : this.generateMultiVal(parsedKeys)}</td>
<td className={cx(styles.wordBreakAll, wrapLogMessage && styles.wrapLine)}> <td className={cx(styles.wordBreakAll, wrapLogMessage && styles.wrapLine)}>
<div className={styles.logDetailsValue}> <div className={styles.logDetailsValue}>
{parsedValue} {singleVal ? parsedValues[0] : this.generateMultiVal(parsedValues, true)}
{singleVal && this.generateClipboardButton(parsedValues[0])}
<div className={cx('show-on-hover', styles.copyButton)}> <div className={cx((singleVal || isMultiParsedValueWithNoContent) && styles.adjoiningLinkButton)}>
<ClipboardButton {links?.map((link, i) => (
getText={() => parsedValue} <span key={`${link.title}-${i}`}>
title="Copy value to clipboard" <DataLinkButton link={link} />
fill="text" </span>
variant="secondary" ))}
icon="copy"
size="md"
/>
</div> </div>
{links?.map((link) => (
<span key={link.title}>
&nbsp;
<DataLinkButton link={link} />
</span>
))}
</div> </div>
</td> </td>
</tr> </tr>
{showFieldsStats && ( {showFieldsStats && singleKey && singleVal && (
<tr> <tr>
<td> <td>
<IconButton <IconButton
@ -276,8 +316,8 @@ class UnThemedLogDetailsRow extends PureComponent<Props, State> {
<div className={styles.logDetailsStats}> <div className={styles.logDetailsStats}>
<LogLabelStats <LogLabelStats
stats={fieldStats!} stats={fieldStats!}
label={parsedKey} label={parsedKeys[0]}
value={parsedValue} value={parsedValues[0]}
rowCount={fieldCount} rowCount={fieldCount}
isLabel={isLabel} isLabel={isLabel}
/> />

View File

@ -22,16 +22,16 @@ class UnThemedLogRowMessageDisplayedFields extends PureComponent<Props> {
: css` : css`
white-space: nowrap; white-space: nowrap;
`; `;
// only single key/value rows are filterable, so we only need the first field key for filtering
const line = showDetectedFields const line = showDetectedFields
.map((parsedKey) => { .map((parsedKey) => {
const field = fields.find((field) => { const field = fields.find((field) => {
const { key } = field; const { keys } = field;
return key === parsedKey; return keys[0] === parsedKey;
}); });
if (field !== undefined && field !== null) { if (field !== undefined && field !== null) {
return `${parsedKey}=${field.value}`; return `${parsedKey}=${field.values}`;
} }
if (row.labels[parsedKey] !== undefined && row.labels[parsedKey] !== null) { if (row.labels[parsedKey] !== undefined && row.labels[parsedKey] !== null) {

View File

@ -1,155 +1,184 @@
import { ArrayVector, FieldType, MutableDataFrame } from '@grafana/data'; import { ArrayVector, FieldType, MutableDataFrame } from '@grafana/data';
import { ExploreFieldLinkModel } from 'app/features/explore/utils/links';
import { createLogRow } from './__mocks__/logRow'; import { createLogRow } from './__mocks__/logRow';
import { getAllFields } from './logParser'; import { getAllFields, createLogLineLinks, FieldDef } from './logParser';
describe('getAllFields', () => { describe('logParser', () => {
it('should filter out field with labels name and other type', () => { describe('getAllFields', () => {
const logRow = createLogRow({ it('should filter out field with labels name and other type', () => {
entryFieldIndex: 10, const logRow = createLogRow({
dataFrame: new MutableDataFrame({ entryFieldIndex: 10,
refId: 'A', dataFrame: new MutableDataFrame({
fields: [ refId: 'A',
testStringField, fields: [
{ testStringField,
name: 'labels', {
type: FieldType.other, name: 'labels',
config: {}, type: FieldType.other,
values: new ArrayVector([{ place: 'luna', source: 'data' }]), config: {},
}, values: new ArrayVector([{ place: 'luna', source: 'data' }]),
},
],
}),
});
const fields = getAllFields(logRow);
expect(fields.length).toBe(1);
expect(fields.find((field) => field.keys[0] === 'labels')).toBe(undefined);
});
it('should not filter out field with labels name and string type', () => {
const logRow = createLogRow({
entryFieldIndex: 10,
dataFrame: new MutableDataFrame({
refId: 'A',
fields: [
testStringField,
{
name: 'labels',
type: FieldType.string,
config: {},
values: new ArrayVector([{ place: 'luna', source: 'data' }]),
},
],
}),
});
const fields = getAllFields(logRow);
expect(fields.length).toBe(2);
expect(fields.find((field) => field.keys[0] === 'labels')).not.toBe(undefined);
});
it('should filter out field with id name', () => {
const logRow = createLogRow({
entryFieldIndex: 10,
dataFrame: new MutableDataFrame({
refId: 'A',
fields: [
testStringField,
{
name: 'id',
type: FieldType.string,
config: {},
values: new ArrayVector(['1659620138401000000_8b1f7688_']),
},
],
}),
});
const fields = getAllFields(logRow);
expect(fields.length).toBe(1);
expect(fields.find((field) => field.keys[0] === 'id')).toBe(undefined);
});
it('should filter out field with config hidden field', () => {
const testField = { ...testStringField };
testField.config = {
custom: {
hidden: true,
},
};
const logRow = createLogRow({
entryFieldIndex: 10,
dataFrame: new MutableDataFrame({
refId: 'A',
fields: [{ ...testField }],
}),
});
const fields = getAllFields(logRow);
expect(fields.length).toBe(0);
expect(fields.find((field) => field.keys[0] === testField.name)).toBe(undefined);
});
it('should filter out field with null values', () => {
const logRow = createLogRow({
entryFieldIndex: 10,
dataFrame: new MutableDataFrame({
refId: 'A',
fields: [{ ...testFieldWithNullValue }],
}),
});
const fields = getAllFields(logRow);
expect(fields.length).toBe(0);
expect(fields.find((field) => field.keys[0] === testFieldWithNullValue.name)).toBe(undefined);
});
it('should not filter out field with string values', () => {
const logRow = createLogRow({
entryFieldIndex: 10,
dataFrame: new MutableDataFrame({
refId: 'A',
fields: [{ ...testStringField }],
}),
});
const fields = getAllFields(logRow);
expect(fields.length).toBe(1);
expect(fields.find((field) => field.keys[0] === testStringField.name)).not.toBe(undefined);
});
});
describe('createLogLineLinks', () => {
it('should change FieldDef to have keys of variable keys', () => {
const variableLink: ExploreFieldLinkModel = {
href: 'test',
onClick: () => {},
origin: {
config: { links: [] },
name: 'Line',
type: FieldType.string,
values: new ArrayVector(['a', 'b']),
},
title: 'test',
target: '_self',
variables: [
{ variableName: 'path', value: 'test', match: '${path}', found: true },
{ variableName: 'msg', value: 'test msg', match: '${msg}', found: true },
], ],
}), };
const fieldWithVarLink: FieldDef = {
fieldIndex: 2,
keys: ['Line'],
values: ['level=info msg="test msg" status_code=200 url=http://test'],
links: [variableLink],
};
const fields = createLogLineLinks([fieldWithVarLink]);
expect(fields.length).toBe(1);
expect(fields[0].keys.length).toBe(2);
expect(fields[0].keys[0]).toBe('path');
expect(fields[0].values[0]).toBe('test');
expect(fields[0].keys[1]).toBe('msg');
expect(fields[0].values[1]).toBe('test msg');
}); });
const fields = getAllFields(logRow); it('should return empty array if no variables', () => {
expect(fields.length).toBe(1); const variableLink: ExploreFieldLinkModel = {
expect(fields.find((field) => field.key === 'labels')).toBe(undefined); href: 'test',
}); onClick: () => {},
origin: {
config: { links: [] },
name: 'Line',
type: FieldType.string,
values: new ArrayVector(['a', 'b']),
},
title: 'test',
target: '_self',
};
it('should not filter out field with labels name and string type', () => { const fieldWithVarLink: FieldDef = {
const logRow = createLogRow({ fieldIndex: 2,
entryFieldIndex: 10, keys: ['Line'],
dataFrame: new MutableDataFrame({ values: ['level=info msg="test msg" status_code=200 url=http://test'],
refId: 'A', links: [variableLink],
fields: [ };
testStringField,
{ const fields = createLogLineLinks([fieldWithVarLink]);
name: 'labels', expect(fields.length).toBe(0);
type: FieldType.string,
config: {},
values: new ArrayVector([{ place: 'luna', source: 'data' }]),
},
],
}),
}); });
const fields = getAllFields(logRow);
expect(fields.length).toBe(2);
expect(fields.find((field) => field.key === 'labels')).not.toBe(undefined);
});
it('should filter out field with id name', () => {
const logRow = createLogRow({
entryFieldIndex: 10,
dataFrame: new MutableDataFrame({
refId: 'A',
fields: [
testStringField,
{
name: 'id',
type: FieldType.string,
config: {},
values: new ArrayVector(['1659620138401000000_8b1f7688_']),
},
],
}),
});
const fields = getAllFields(logRow);
expect(fields.length).toBe(1);
expect(fields.find((field) => field.key === 'id')).toBe(undefined);
});
it('should filter out entry field which is shown as the log message', () => {
const logRow = createLogRow({
entryFieldIndex: 3,
dataFrame: new MutableDataFrame({
refId: 'A',
fields: [
testStringField,
{
name: 'labels',
type: FieldType.other,
config: {},
values: new ArrayVector([{ place: 'luna', source: 'data' }]),
},
{
name: 'Time',
type: FieldType.time,
config: {},
values: new ArrayVector([1659620138401]),
},
{
name: 'Line',
type: FieldType.string,
config: {},
values: new ArrayVector([
'_entry="log text with ANSI \u001b[31mpart of the text\u001b[0m [616951240]" counter=300 float=NaN label=val3 level=info',
]),
},
],
}),
});
const fields = getAllFields(logRow);
expect(fields.find((field) => field.key === 'Line')).toBe(undefined);
});
it('should filter out field with config hidden field', () => {
const testField = { ...testStringField };
testField.config = {
custom: {
hidden: true,
},
};
const logRow = createLogRow({
entryFieldIndex: 10,
dataFrame: new MutableDataFrame({
refId: 'A',
fields: [{ ...testField }],
}),
});
const fields = getAllFields(logRow);
expect(fields.length).toBe(0);
expect(fields.find((field) => field.key === testField.name)).toBe(undefined);
});
it('should filter out field with null values', () => {
const logRow = createLogRow({
entryFieldIndex: 10,
dataFrame: new MutableDataFrame({
refId: 'A',
fields: [{ ...testFieldWithNullValue }],
}),
});
const fields = getAllFields(logRow);
expect(fields.length).toBe(0);
expect(fields.find((field) => field.key === testFieldWithNullValue.name)).toBe(undefined);
});
it('should not filter out field with string values', () => {
const logRow = createLogRow({
entryFieldIndex: 10,
dataFrame: new MutableDataFrame({
refId: 'A',
fields: [{ ...testStringField }],
}),
});
const fields = getAllFields(logRow);
expect(fields.length).toBe(1);
expect(fields.find((field) => field.key === testStringField.name)).not.toBe(undefined);
}); });
}); });

View File

@ -1,11 +1,12 @@
import memoizeOne from 'memoize-one'; import memoizeOne from 'memoize-one';
import { DataFrame, Field, FieldType, LinkModel, LogRowModel } from '@grafana/data'; import { DataFrame, Field, FieldType, LinkModel, LogRowModel } from '@grafana/data';
import { ExploreFieldLinkModel } from 'app/features/explore/utils/links';
type FieldDef = { export type FieldDef = {
key: string; keys: string[];
value: string; values: string[];
links?: Array<LinkModel<Field>>; links?: Array<LinkModel<Field>> | ExploreFieldLinkModel[];
fieldIndex: number; fieldIndex: number;
}; };
@ -16,7 +17,11 @@ type FieldDef = {
export const getAllFields = memoizeOne( export const getAllFields = memoizeOne(
( (
row: LogRowModel, row: LogRowModel,
getFieldLinks?: (field: Field, rowIndex: number, dataFrame: DataFrame) => Array<LinkModel<Field>> getFieldLinks?: (
field: Field,
rowIndex: number,
dataFrame: DataFrame
) => Array<LinkModel<Field>> | ExploreFieldLinkModel[]
) => { ) => {
const dataframeFields = getDataframeFields(row, getFieldLinks); const dataframeFields = getDataframeFields(row, getFieldLinks);
@ -24,6 +29,33 @@ export const getAllFields = memoizeOne(
} }
); );
/**
* A log line may contain many links that would all need to go on their own logs detail row
* This iterates through and creates a FieldDef (row) per link.
*/
export const createLogLineLinks = memoizeOne((hiddenFieldsWithLinks: FieldDef[]): FieldDef[] => {
let fieldsWithLinksFromVariableMap: FieldDef[] = [];
hiddenFieldsWithLinks.forEach((linkField) => {
linkField.links?.forEach((link: ExploreFieldLinkModel) => {
if (link.variables) {
const variableKeys = link.variables.map((variable) => {
const varName = variable.variableName;
const fieldPath = variable.fieldPath ? `.${variable.fieldPath}` : '';
return `${varName}${fieldPath}`;
});
const variableValues = link.variables.map((variable) => (variable.found ? variable.value : ''));
fieldsWithLinksFromVariableMap.push({
keys: variableKeys,
values: variableValues,
links: [link],
fieldIndex: linkField.fieldIndex,
});
}
});
});
return fieldsWithLinksFromVariableMap;
});
/** /**
* creates fields from the dataframe-fields, adding data-links, when field.config.links exists * creates fields from the dataframe-fields, adding data-links, when field.config.links exists
*/ */
@ -38,8 +70,8 @@ export const getDataframeFields = memoizeOne(
.map((field) => { .map((field) => {
const links = getFieldLinks ? getFieldLinks(field, row.rowIndex, row.dataFrame) : []; const links = getFieldLinks ? getFieldLinks(field, row.rowIndex, row.dataFrame) : [];
return { return {
key: field.name, keys: [field.name],
value: field.values.get(row.rowIndex).toString(), values: [field.values.get(row.rowIndex).toString()],
links: links, links: links,
fieldIndex: field.index, fieldIndex: field.index,
}; };
@ -57,10 +89,6 @@ function shouldRemoveField(field: Field, index: number, row: LogRowModel) {
if (field.name === 'id' || field.name === 'tsNs') { if (field.name === 'id' || field.name === 'tsNs') {
return true; return true;
} }
// entry field which we are showing as the log message
if (row.entryFieldIndex === index) {
return true;
}
const firstTimeField = row.dataFrame.fields.find((f) => f.type === FieldType.time); const firstTimeField = row.dataFrame.fields.find((f) => f.type === FieldType.time);
if ( if (
field.name === firstTimeField?.name && field.name === firstTimeField?.name &&

View File

@ -147,7 +147,8 @@ export const escapeUnescapedString = (string: string) =>
export function logRowsToReadableJson(logs: LogRowModel[]) { export function logRowsToReadableJson(logs: LogRowModel[]) {
return logs.map((log) => { return logs.map((log) => {
const fields = getDataframeFields(log).reduce<Record<string, string>>((acc, field) => { const fields = getDataframeFields(log).reduce<Record<string, string>>((acc, field) => {
acc[field.key] = field.value; const key = field.keys[0];
acc[key] = field.values[0];
return acc; return acc;
}, {}); }, {});

View File

@ -185,17 +185,19 @@ describe('ensureStringValues', () => {
describe('containsVariable', () => { describe('containsVariable', () => {
it.each` it.each`
value | expected value | expected
${''} | ${false} ${''} | ${false}
${'$var'} | ${true} ${'$var'} | ${true}
${{ thing1: '${var}' }} | ${true} ${{ thing1: '${var}' }} | ${true}
${{ thing1: '${var:fmt}' }} | ${true} ${{ thing1: '${var:fmt}' }} | ${true}
${{ thing1: ['1', '${var}'] }} | ${true} ${{ thing1: '${var.fieldPath}' }} | ${true}
${{ thing1: ['1', '[[var]]'] }} | ${true} ${{ thing1: '${var.fieldPath:fmt}' }} | ${true}
${{ thing1: ['1', '[[var:fmt]]'] }} | ${true} ${{ thing1: ['1', '${var}'] }} | ${true}
${{ thing1: { thing2: '${var}' } }} | ${true} ${{ thing1: ['1', '[[var]]'] }} | ${true}
${{ params: [['param', '$var']] }} | ${true} ${{ thing1: ['1', '[[var:fmt]]'] }} | ${true}
${{ params: [['param', '${var}']] }} | ${true} ${{ thing1: { thing2: '${var}' } }} | ${true}
${{ params: [['param', '$var']] }} | ${true}
${{ params: [['param', '${var}']] }} | ${true}
`('when called with value:$value then result should be:$expected', ({ value, expected }) => { `('when called with value:$value then result should be:$expected', ({ value, expected }) => {
expect(containsVariable(value, 'var')).toEqual(expected); expect(containsVariable(value, 'var')).toEqual(expected);
}); });

View File

@ -16,9 +16,10 @@ import { QueryVariableModel, TransactionStatus, VariableModel, VariableRefresh,
/* /*
* This regex matches 3 types of variable reference with an optional format specifier * This regex matches 3 types of variable reference with an optional format specifier
* \$(\w+) $var1 * There are 6 capture groups that replace will return
* \[\[(\w+?)(?::(\w+))?\]\] [[var2]] or [[var2:fmt2]] * \$(\w+) $var1
* \${(\w+)(?::(\w+))?} ${var3} or ${var3:fmt3} * \[\[(\w+?)(?::(\w+))?\]\] [[var2]] or [[var2:fmt2]]
* \${(\w+)(?:\.([^:^\}]+))?(?::([^\}]+))?} ${var3} or ${var3.fieldPath} or ${var3:fmt3} (or ${var3.fieldPath:fmt3} but that is not a separate capture group)
*/ */
export const variableRegex = /\$(\w+)|\[\[(\w+?)(?::(\w+))?\]\]|\${(\w+)(?:\.([^:^\}]+))?(?::([^\}]+))?}/g; export const variableRegex = /\$(\w+)|\[\[(\w+?)(?::(\w+))?\]\]|\${(\w+)(?:\.([^:^\}]+))?(?::([^\}]+))?}/g;