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 {
@ -24,8 +23,7 @@ import { getDatasourceSrv } from 'app/features/plugins/datasource_srv';
import { PromQuery } from 'app/plugins/datasource/prometheus/types';
import { LokiQuery } from '../../../plugins/datasource/loki/types';
import { variableRegex } from '../../variables/utils';
import { getFieldLinksForExplore } from '../utils/links';
import { getFieldLinksForExplore, getVariableUsageInfo } from '../utils/links';
import { SpanLinkFunc, Trace, TraceSpan } from './components';
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
// 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({
link: dataLink,
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,
DisplayValue,
} from '@grafana/data';
import { getTemplateSrv } from '@grafana/runtime';
import { getTemplateSrv, VariableInterpolation } from '@grafana/runtime';
import { contextSrv } from 'app/core/services/context_srv';
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;
const dataLinkHasRequiredPermissions = (link: DataLink) => {
const dataLinkHasRequiredPermissionsFilter = (link: DataLink) => {
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
* 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
@ -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
* appropriately. This is for example used for transition from log with traceId to trace datasource to show that
* 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: {
field: Field;
@ -70,7 +59,7 @@ export const getFieldLinksForExplore = (options: {
range: TimeRange;
vars?: ScopedVars;
dataFrame?: DataFrame;
}): Array<LinkModel<Field>> => {
}): ExploreFieldLinkModel[] => {
const { field, vars, splitOpenFn, range, rowIndex, dataFrame } = options;
const scopedVars: ScopedVars = { ...(vars || {}) };
scopedVars['__value'] = {
@ -117,7 +106,7 @@ export const getFieldLinksForExplore = (options: {
return DATA_LINK_FILTERS.every((filter) => filter(link, scopedVars));
});
return links.map((link) => {
const fieldLinks = links.map((link) => {
if (!link.internal) {
const replace: InterpolateFunction = (value, vars) =>
getTemplateSrv().replace(value, { ...vars, ...scopedVars });
@ -146,19 +135,36 @@ export const getFieldLinksForExplore = (options: {
});
}
return mapInternalLinkToExplore({
link,
internalLink: link.internal,
scopedVars: { ...scopedVars, ...internalLinkSpecificVars },
range,
field,
onClickFn: splitOpenFn,
replaceVariables: getTemplateSrv().replace.bind(getTemplateSrv()),
});
const allVars = { ...scopedVars, ...internalLinkSpecificVars };
const variableData = getVariableUsageInfo(link, allVars);
let variables: VariableInterpolation[] = [];
// 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
if (variableData.variables.length === 0) {
const fieldName = field.name.toString();
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 [];
};
@ -204,3 +210,35 @@ export function useLinks(range: TimeRange, splitOpenFn?: SplitOpen) {
[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 { getLogLevelStyles, LogRowStyles } from './getLogRowStyles';
import { getAllFields } from './logParser';
import { getAllFields, createLogLineLinks } from './logParser';
export interface Props extends Themeable2 {
row: LogRowModel;
@ -51,10 +51,17 @@ class UnThemedLogDetails extends PureComponent<Props> {
const labels = row.labels ? row.labels : {};
const labelsAvailable = Object.keys(labels).length > 0;
const fieldsAndLinks = getAllFields(row, getFieldLinks);
const links = fieldsAndLinks.filter((f) => f.links?.length).sort();
const fields = fieldsAndLinks.filter((f) => f.links?.length === 0).sort();
let fieldsWithLinks = fieldsAndLinks.filter((f) => f.links?.length);
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 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
const levelClassName = hasError
@ -78,13 +85,13 @@ class UnThemedLogDetails extends PureComponent<Props> {
)}
{Object.keys(labels)
.sort()
.map((key) => {
.map((key, i) => {
const value = labels[key];
return (
<LogDetailsRow
key={`${key}=${value}`}
parsedKey={key}
parsedValue={value}
key={`${key}=${value}-${i}`}
parsedKeys={[key]}
parsedValues={[value]}
isLabel={true}
getStats={() => calculateLogsLabelStats(getRows(), key)}
onClickFilterOutLabel={onClickFilterOutLabel}
@ -95,16 +102,17 @@ class UnThemedLogDetails extends PureComponent<Props> {
app={app}
wrapLogMessage={wrapLogMessage}
displayedFields={displayedFields}
disableActions={false}
/>
);
})}
{fields.map((field) => {
const { key, value, fieldIndex } = field;
{fields.map((field, i) => {
const { keys, values, fieldIndex } = field;
return (
<LogDetailsRow
key={`${key}=${value}`}
parsedKey={key}
parsedValue={value}
key={`${keys[0]}=${values[0]}-${i}`}
parsedKeys={keys}
parsedValues={values}
onClickShowField={onClickShowField}
onClickHideField={onClickHideField}
onClickFilterOutLabel={onClickFilterOutLabel}
@ -114,24 +122,25 @@ class UnThemedLogDetails extends PureComponent<Props> {
wrapLogMessage={wrapLogMessage}
row={row}
app={app}
disableActions={false}
/>
);
})}
{linksAvailable && (
{fieldsWithLinksAvailable && (
<tr>
<td colSpan={100} className={styles.logDetailsHeading} aria-label="Data Links">
Links
</td>
</tr>
)}
{links.map((field) => {
const { key, value, links, fieldIndex } = field;
{displayedFieldsWithLinks.map((field, i) => {
const { keys, values, links, fieldIndex } = field;
return (
<LogDetailsRow
key={`${key}=${value}`}
parsedKey={key}
parsedValue={value}
key={`${keys[0]}=${values[0]}-${i}`}
parsedKeys={keys}
parsedValues={values}
links={links}
onClickShowField={onClickShowField}
onClickHideField={onClickHideField}
@ -140,10 +149,31 @@ class UnThemedLogDetails extends PureComponent<Props> {
wrapLogMessage={wrapLogMessage}
row={row}
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>
<td colSpan={100} aria-label="No details">
No details available

View File

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

View File

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

View File

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

View File

@ -1,155 +1,184 @@
import { ArrayVector, FieldType, MutableDataFrame } from '@grafana/data';
import { ExploreFieldLinkModel } from 'app/features/explore/utils/links';
import { createLogRow } from './__mocks__/logRow';
import { getAllFields } from './logParser';
import { getAllFields, createLogLineLinks, FieldDef } from './logParser';
describe('getAllFields', () => {
it('should filter out field with labels name and other type', () => {
const logRow = createLogRow({
entryFieldIndex: 10,
dataFrame: new MutableDataFrame({
refId: 'A',
fields: [
testStringField,
{
name: 'labels',
type: FieldType.other,
config: {},
values: new ArrayVector([{ place: 'luna', source: 'data' }]),
},
describe('logParser', () => {
describe('getAllFields', () => {
it('should filter out field with labels name and other type', () => {
const logRow = createLogRow({
entryFieldIndex: 10,
dataFrame: new MutableDataFrame({
refId: 'A',
fields: [
testStringField,
{
name: 'labels',
type: FieldType.other,
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);
expect(fields.length).toBe(1);
expect(fields.find((field) => field.key === 'labels')).toBe(undefined);
});
it('should return empty array if no variables', () => {
const variableLink: ExploreFieldLinkModel = {
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 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 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(0);
});
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 { DataFrame, Field, FieldType, LinkModel, LogRowModel } from '@grafana/data';
import { ExploreFieldLinkModel } from 'app/features/explore/utils/links';
type FieldDef = {
key: string;
value: string;
links?: Array<LinkModel<Field>>;
export type FieldDef = {
keys: string[];
values: string[];
links?: Array<LinkModel<Field>> | ExploreFieldLinkModel[];
fieldIndex: number;
};
@ -16,7 +17,11 @@ type FieldDef = {
export const getAllFields = memoizeOne(
(
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);
@ -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
*/
@ -38,8 +70,8 @@ export const getDataframeFields = memoizeOne(
.map((field) => {
const links = getFieldLinks ? getFieldLinks(field, row.rowIndex, row.dataFrame) : [];
return {
key: field.name,
value: field.values.get(row.rowIndex).toString(),
keys: [field.name],
values: [field.values.get(row.rowIndex).toString()],
links: links,
fieldIndex: field.index,
};
@ -57,10 +89,6 @@ function shouldRemoveField(field: Field, index: number, row: LogRowModel) {
if (field.name === 'id' || field.name === 'tsNs') {
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);
if (
field.name === firstTimeField?.name &&

View File

@ -147,7 +147,8 @@ export const escapeUnescapedString = (string: string) =>
export function logRowsToReadableJson(logs: LogRowModel[]) {
return logs.map((log) => {
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;
}, {});

View File

@ -185,17 +185,19 @@ describe('ensureStringValues', () => {
describe('containsVariable', () => {
it.each`
value | expected
${''} | ${false}
${'$var'} | ${true}
${{ thing1: '${var}' }} | ${true}
${{ thing1: '${var:fmt}' }} | ${true}
${{ thing1: ['1', '${var}'] }} | ${true}
${{ thing1: ['1', '[[var]]'] }} | ${true}
${{ thing1: ['1', '[[var:fmt]]'] }} | ${true}
${{ thing1: { thing2: '${var}' } }} | ${true}
${{ params: [['param', '$var']] }} | ${true}
${{ params: [['param', '${var}']] }} | ${true}
value | expected
${''} | ${false}
${'$var'} | ${true}
${{ thing1: '${var}' }} | ${true}
${{ thing1: '${var:fmt}' }} | ${true}
${{ thing1: '${var.fieldPath}' }} | ${true}
${{ thing1: '${var.fieldPath:fmt}' }} | ${true}
${{ thing1: ['1', '${var}'] }} | ${true}
${{ thing1: ['1', '[[var]]'] }} | ${true}
${{ thing1: ['1', '[[var:fmt]]'] }} | ${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 }) => {
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
* \$(\w+) $var1
* \[\[(\w+?)(?::(\w+))?\]\] [[var2]] or [[var2:fmt2]]
* \${(\w+)(?::(\w+))?} ${var3} or ${var3:fmt3}
* There are 6 capture groups that replace will return
* \$(\w+) $var1
* \[\[(\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;